mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
199 lines
7.3 KiB
TypeScript
199 lines
7.3 KiB
TypeScript
/**
|
|
* Every privileged ctx method checks its capability and leaves an audit line.
|
|
*
|
|
* This walks the whole ctx object rather than listing methods by hand, so a
|
|
* member added later without a guard fails here until it is either guarded
|
|
* or added to UNGATED with a reason.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const auditEntries: Array<Record<string, unknown>> = [];
|
|
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginPermissionGrantRepository: () => ({
|
|
listByPlugin: async () => [],
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../../utils/audit-logger.js", () => ({
|
|
logAudit: async (entry: Record<string, unknown>) => {
|
|
auditEntries.push(entry);
|
|
},
|
|
}));
|
|
|
|
const { createPluginContext, createPluginHandle } =
|
|
await import("../../../plugins/ctx.js");
|
|
const { invalidatePluginPermissionCache } =
|
|
await import("../../../plugins/permissions.js");
|
|
const { runAsActor } = await import("../../../plugins/actor.js");
|
|
const { PluginCapabilityError } =
|
|
await import("@termix-ssh/plugin-sdk/backend");
|
|
|
|
const PLUGIN_ID = "walker";
|
|
const ACTOR = "user-walker";
|
|
|
|
/**
|
|
* Members that need no capability, and why. Anything not listed here must
|
|
* refuse a plugin that declared and was granted nothing.
|
|
*/
|
|
const UNGATED: Record<string, string> = {
|
|
"log.debug": "writes to core's log under the plugin's own tag",
|
|
"log.info": "writes to core's log under the plugin's own tag",
|
|
"log.warn": "writes to core's log under the plugin's own tag",
|
|
"log.error": "writes to core's log under the plugin's own tag",
|
|
"events.emit": "namespace check: plugin.<id>.* only without events:core",
|
|
"events.on": "namespace check: plugin.* only without events:core",
|
|
"sync.registerEntity": "adds the plugin's own table to remote sync",
|
|
"registry.provide": "the plugin's own registry key",
|
|
"registry.consume": "reads what another plugin chose to publish",
|
|
"registry.revoke": "removes the plugin's own registry key",
|
|
"services.provide": "only services declared in manifest provides",
|
|
"services.get": "each call is checked against the service's permission",
|
|
"services.providers": "names only",
|
|
"secrets.offer": "only secrets declared in manifest providesSecret",
|
|
"secrets.withdraw": "the plugin's own offer",
|
|
"secrets.getShared": "each read is checked against the secret's permission",
|
|
"settings.get": "the plugin's own admin settings",
|
|
"settings.set": "the plugin's own admin settings",
|
|
"settings.getUser": "the plugin's own user settings",
|
|
"settings.setUser": "the plugin's own user settings",
|
|
"settings.getHost": "the plugin's own host settings",
|
|
"settings.getHostFor":
|
|
"the plugin's own host settings, as one user sees them",
|
|
"settings.getHostDefault":
|
|
"the plugin's own host settings, as a user's defaults",
|
|
"settings.setHost": "the plugin's own host settings",
|
|
"settings.getAll": "the plugin's own settings",
|
|
"settings.onChange": "the plugin's own settings",
|
|
"settings.onValidate": "the plugin's own settings",
|
|
"rbac.has": "answers for the actor only",
|
|
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
|
|
"rbac.require": "route middleware for the plugin's own permissions",
|
|
"capabilities.has": "a yes/no about the plugin's own grants",
|
|
"auth.listSecondFactors": "ids of the second factors, no user data",
|
|
"disposables.add": "the plugin's own cleanup",
|
|
"schedule.every": "the plugin's own timers",
|
|
"schedule.after": "the plugin's own timers",
|
|
"audit.record": "writes under the runtime's actor, never a plugin value",
|
|
asUser:
|
|
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
|
|
currentActor: "reads the actor, cannot set it",
|
|
"desktop.available": "a yes/no about the environment",
|
|
"desktop.local.available": "a yes/no about the environment",
|
|
"http.baseUrl": "reads the request's public origin",
|
|
"ssh.poolKey": "a string built from a host the plugin already holds",
|
|
"ssh.dropPooled": "closes only this plugin's own pooled connections",
|
|
"ssh.classifyKeyboardInteractive": "pure classification of a prompt",
|
|
"ssh.autoResponses": "pure mapping of prompts to answers",
|
|
"ssh.requiresSecret": "a yes/no about an auth type",
|
|
"ssh.supportsBackground": "a yes/no about an auth type",
|
|
};
|
|
|
|
function walk(
|
|
value: unknown,
|
|
path: string[] = [],
|
|
out: Array<{ path: string; fn: (...args: unknown[]) => unknown }> = [],
|
|
) {
|
|
if (!value || typeof value !== "object") return out;
|
|
for (const [key, member] of Object.entries(value)) {
|
|
if (path.length === 0 && (key === "manifest" || key === "pluginId")) {
|
|
continue;
|
|
}
|
|
const at = [...path, key];
|
|
if (typeof member === "function") {
|
|
out.push({ path: at.join("."), fn: member as never });
|
|
}
|
|
if (member && typeof member === "object") walk(member, at, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Args that get a call past argument checks and into the guard. */
|
|
const PLACEHOLDER_ARGS = [
|
|
1,
|
|
{ id: 1, ip: "10.0.0.1", port: 22, username: "u", type: "x" },
|
|
{ purpose: "background", pool: "p" },
|
|
() => undefined,
|
|
];
|
|
|
|
function contextWithNothing() {
|
|
const handle = createPluginHandle(PLUGIN_ID, { activate: () => {} });
|
|
return createPluginContext(
|
|
{
|
|
id: PLUGIN_ID,
|
|
name: "Walker",
|
|
version: "1.0.0",
|
|
description: "",
|
|
author: { name: "test" },
|
|
license: "MIT",
|
|
category: "Productivity",
|
|
engine: { termix: ">=2.9.0", api: "1" },
|
|
capabilities: [],
|
|
} as never,
|
|
handle,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
auditEntries.length = 0;
|
|
invalidatePluginPermissionCache();
|
|
});
|
|
|
|
describe("every privileged ctx member is guarded", () => {
|
|
const members = walk(contextWithNothing());
|
|
|
|
it("finds the members it walks", () => {
|
|
expect(members.length).toBeGreaterThan(60);
|
|
for (const name of Object.keys(UNGATED)) {
|
|
expect(
|
|
members.some((member) => member.path === name),
|
|
`UNGATED lists ${name}, which ctx no longer has`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
const gated = members.filter((member) => !(member.path in UNGATED));
|
|
|
|
it.each(gated.map((member) => [member.path]))(
|
|
"%s refuses a plugin with no grants and audits it",
|
|
async (path) => {
|
|
const ctx = contextWithNothing();
|
|
const member = walk(ctx).find((entry) => entry.path === path)!;
|
|
|
|
let caught: unknown;
|
|
await runAsActor(ACTOR, "request", async () => {
|
|
try {
|
|
const result = await member.fn(...PLACEHOLDER_ARGS);
|
|
// An async generator only runs its guard on the first next().
|
|
if (
|
|
result &&
|
|
typeof (result as AsyncGenerator).next === "function" &&
|
|
Symbol.asyncIterator in (result as object)
|
|
) {
|
|
await (result as AsyncGenerator).next();
|
|
}
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
});
|
|
// Sync refusals write their audit line in the background.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(
|
|
caught,
|
|
`${path} ran without a capability. Guard it, or add it to UNGATED with a reason.`,
|
|
).toBeInstanceOf(PluginCapabilityError);
|
|
expect(
|
|
auditEntries.some(
|
|
(entry) =>
|
|
entry.resourceId === PLUGIN_ID &&
|
|
entry.userId === ACTOR &&
|
|
entry.success === false,
|
|
),
|
|
`${path} refused without an audit line naming the plugin and actor`,
|
|
).toBe(true);
|
|
},
|
|
);
|
|
});
|