/** * The scan rules themselves, and the proof that packaging refuses a module that * breaks them. * * `no-hand-built-ssh.test.ts` asserts the CURRENT tree is clean, which is a * different claim: it passes both when the rules work and when they match * nothing. These tests are the ones that fail if a rule stops catching things. */ import { describe, expect, it } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildModule } from '../module/packaging/build'; import { JAILED_CLI_SPAWN_RULE, scanModuleDirectory, scanModuleScriptSource, } from './module-script-scan'; const rules = (src: string) => scanModuleScriptSource('f.ts', src).map((v) => v.rule); describe('module script scan — SSH rules', () => { it('catches a hand-built ssh string', () => { expect(rules("run(`ssh root@${ip} 'systemctl restart x'`);")).toContain( 'raw ssh invocation (ssh … root@)', ); }); it('catches StrictHostKeyChecking however it is invoked', () => { expect(rules("const c = 'ssh -o StrictHostKeyChecking=no host';")).toContain( 'raw ssh invocation (StrictHostKeyChecking)', ); }); it('catches an ssh2 import', () => { expect(rules("import { Client } from 'ssh2';")).toContain("'ssh2' import"); }); it('does not fire on ordinary module code', () => { expect(rules('const x = probe(system, { kind: "systemd", unit: "caddy" }, run);')).toEqual([]); }); }); describe('module script scan — namespace tools (hygiene, not a boundary)', () => { const NS = 'namespace escape (bwrap/unshare/nsenter)'; // Each token asserted separately and by name. An alternation that silently // loses one leg still passes a test that only ever exercises the first. it.each([ ['bwrap', 'run(`bwrap --dev-bind / / ${cmd}`);'], ['unshare', "run('unshare --user --map-root-user id');"], ['nsenter', "run('nsenter -t 1 -m -- ls /');"], ['CLONE_NEWUSER', 'const flags = CLONE_NEWUSER | CLONE_NEWNS;'], ])('catches %s', (_token, src) => { expect(rules(src)).toContain(NS); }); // The word boundaries are the whole of this rule's false-positive defence, so // the negative case has to be a word that CONTAINS a token. `buildSharedConfig` // would pass whether or not the `\b`s are there, which makes it no test at all. it('does not fire on an identifier that merely contains a token', () => { expect(rules('const unshared = pending.filter((p) => !p.shared);')).toEqual([]); }); }); describe('module script scan — jailed CLI spawn rule', () => { // Each spawn spelling asserted separately. An alternation that loses a leg // still passes a test that only exercises the first. it.each([ [ "execFileSync('celilo', argv)", "execFileSync('celilo', ['module', 'config', 'set', 'x', 'y']);", ], ['execFileSync("celilo", argv)', 'execFileSync("celilo", args);'], ['execSync(`celilo …`)', 'execSync(`celilo module secret set x y`);'], [ 'exec(`celilo …`) after a shell prefix', 'exec(`cd /opt && sudo celilo system config get k`);', ], ["spawn('celilo', argv)", "const child = spawn('celilo', ['events', 'status']);"], ])('catches %s', (_spelling, src) => { expect(rules(src)).toContain(JAILED_CLI_SPAWN_RULE); }); it('does not fire on operator prose that merely names the CLI', () => { expect(rules('throw new Error("re-run `celilo module deploy x` to regenerate it");')).toEqual( [], ); }); // The `\w` after `celilo ` is what separates a CLI invocation from an // argument that merely begins with the string, so the negative case has to // be a hyphenated name, which would match a bare `\bcelilo\b`. it('does not fire on a container argument named celilo-*', () => { expect(rules('execSync(`docker exec celilo-mgr status`);')).toEqual([]); }); it('does not fire on the celilo.db file name', () => { expect(rules('execSync(`cp /var/lib/celilo/celilo.db /tmp/x`);')).toEqual([]); }); }); describe('module script scan — raw-exec escape hatch', () => { it('flags a runAppCommand call with no justification', () => { expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain( 'unjustified raw-exec escape hatch', ); }); it('flags runAppCommandWithSecret too', () => { expect(rules('const r = runAppCommandWithSecret(system, cli, secret, run);')).toContain( 'unjustified raw-exec escape hatch', ); }); it('accepts a call justified immediately above', () => { const src = [ '// escape-hatch: forgejo admin user create is CLI-only, no HTTP API path.', 'const r = runAppCommand(system, cmd, run);', ].join('\n'); expect(rules(src)).toEqual([]); }); it('accepts a call wrapped in waitFor, justified above the enclosing statement', () => { // The real shape in modules/caddy-internal — the justification sits above // `const ready = await waitFor(`, a few lines up from the call itself. const src = [ '// escape-hatch: the command output IS the payload; no API to ask.', 'const ready = await waitFor(', ' () =>', ' runAppCommand(target, CMD, run, {', ' timeoutMs: 10_000,', ' }).ok,', ');', ].join('\n'); expect(rules(src)).toEqual([]); }); it('does not let a distant hatch launder a later call', () => { const src = [ '// escape-hatch: justifies the call directly below it, and nothing else.', 'const a = runAppCommand(system, one, run);', ...Array(12).fill('doSomethingElse();'), 'const b = runAppCommand(system, two, run);', ].join('\n'); // Exactly one violation: the second call, which has no hatch in reach. expect(rules(src)).toEqual(['unjustified raw-exec escape hatch']); }); it('does not flag the import of runAppCommand', () => { expect(rules("import { runAppCommand, probe } from '@celilo/capabilities';")).toEqual([]); }); }); describe('module script scan — what it deliberately does not scan', () => { let dir: string; function write(rel: string, content: string): void { const full = join(dir, rel); mkdirSync(join(full, '..'), { recursive: true }); writeFileSync(full, content); } it('ignores bundled node_modules and test files', () => { dir = mkdtempSync(join(tmpdir(), 'celilo-scan-test-')); try { write('manifest.yml', 'id: demo\n'); // @celilo/capabilities legitimately BUILDS the ssh string these rules ban. // Scanning the bundled closure would fail every module in the fleet. write( 'scripts/node_modules/@celilo/capabilities/src/remote.ts', 'const c = `ssh -o StrictHostKeyChecking=no root@${ip} ${cmd}`;', ); write('scripts/setup.test.ts', "run('ssh root@host uptime');"); write('scripts/setup.ts', 'export const fine = 1;\n'); expect(scanModuleDirectory(dir)).toEqual([]); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('packaging refuses a module that breaks the policy', () => { let dir: string; function write(rel: string, content: string): void { const full = join(dir, rel); mkdirSync(join(full, '..'), { recursive: true }); writeFileSync(full, content); } it('fails the build, naming the file, line and rule', async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-package-policy-')); try { write('manifest.yml', 'id: demo\nversion: 0.1.0\n'); write( 'scripts/setup.ts', ['export function bad(ip: string) {', ' return `ssh root@${ip} uptime`;', '}'].join('\n'), ); const result = await buildModule({ sourceDir: dir }); expect(result.success).toBe(false); expect(result.error).toContain('scripts/setup.ts:2'); expect(result.error).toContain('raw ssh invocation'); expect(result.error).toContain('MODULE_PRIMITIVES.md'); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('fails the build for an unjustified escape hatch', async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-package-policy-')); try { write('manifest.yml', 'id: demo\nversion: 0.1.0\n'); write('scripts/setup.ts', 'const r = runAppCommand(system, "rm -rf /srv", run);\n'); const result = await buildModule({ sourceDir: dir }); expect(result.success).toBe(false); expect(result.error).toContain('unjustified raw-exec escape hatch'); } finally { rmSync(dir, { recursive: true, force: true }); } }); });