diff --git a/src/__tests__/adapter-tools.test.ts b/src/__tests__/adapter-tools.test.ts index d9f30f1..a2756eb 100644 --- a/src/__tests__/adapter-tools.test.ts +++ b/src/__tests__/adapter-tools.test.ts @@ -191,3 +191,47 @@ describe('buildAdapterTools', () => { expect(tools.map(t => t.name)).not.toContain(toolNameFor(adapter('nmap'))); }); }); + +describe('source / supply-chain scanners run a real invocation (not ` `)', () => { + // Each entry: [adapter id, argv for `path: 'src'`]. Without bespoke templates these all fell + // through to DEFAULT_TEMPLATE and spawned `['src']` (or `['']` with no path) — a broken scan. + const CASES: Array<[string, string[]]> = [ + ['semgrep', ['scan', '--config', 'auto', '--json', 'src']], + ['gitleaks', ['detect', '--source', 'src', '--report-format', 'json', '--redact', '--no-banner']], + ['trufflehog', ['filesystem', 'src', '--json', '--no-update']], + ['trivy', ['fs', '--format', 'json', 'src']], + ['syft', ['dir:src', '-o', 'cyclonedx-json']], + ['grype', ['dir:src', '-o', 'json']], + ['checkov', ['-d', 'src', '-o', 'json']], + ]; + + it.each(CASES)('%s builds its real scanner argv for an explicit path', async (id, expected) => { + const deps = makeDeps(); + await mint(id, deps).handler(ctx({ path: 'src' })); + expect(deps.spawns[0]).toEqual(expected); + }); + + it.each(CASES)('%s defaults to the working dir "." when no path is given', async (id) => { + const deps = makeDeps(); + await mint(id, deps).handler(ctx({})); + // The path slot resolves to "." — assert "." is present and the argv is more than a bare target. + expect(deps.spawns[0].some(a => a === '.' || a === 'dir:.')).toBe(true); + expect(deps.spawns[0].length).toBeGreaterThan(1); + }); + + it('never inherits an http(s) mission target as a scan path (falls back to ".")', async () => { + const deps = makeDeps(); + // A networked mission address must not become `semgrep scan --config auto --json https://x` — + // that is not a filesystem path. scanPath rejects it and falls back to ".". + await mint('semgrep', deps).handler(ctx({ target: 'https://victim.example' })); + expect(deps.spawns[0]).toEqual(['scan', '--config', 'auto', '--json', '.']); + }); + + it('refuses an option-looking path (leading "-" would be a scanner flag) — no spawn', async () => { + const deps = makeDeps(); + // The factory's option-looking-target guard fires before argv is built, so the scanner never runs. + const result = await mint('trivy', deps).handler(ctx({ path: '--output=/etc/x' })); + expect(result.success).toBe(false); + expect(deps.spawns.length).toBe(0); + }); +}); diff --git a/src/arsenal/adapter-tools.ts b/src/arsenal/adapter-tools.ts index 07b08ca..8ecf3b9 100644 --- a/src/arsenal/adapter-tools.ts +++ b/src/arsenal/adapter-tools.ts @@ -78,6 +78,21 @@ interface ArgTemplate { const str = (v: unknown): string | undefined => typeof v === 'string' && v.trim() ? v.trim() : undefined; +/** + * Resolve the filesystem PATH a source/supply-chain scanner should run against (semgrep, gitleaks, + * trivy, …). These adapters are non-networked and operate on a directory, defaulting to the working + * dir (`.`). The subcommand + output flags in each template are HARDCODED — only this path is + * tunable, and it is sanitised so it cannot smuggle a flag or an inherited URL past the gate: + * - a leading `-` would be reparsed as a scanner flag (arg injection) → fall back to `.`, + * - an http(s) URL (e.g. a networked mission target inherited via context.target.address) is not a + * scan path → fall back to `.`. + */ +const scanPath = (target: string, params: Record): string => { + const p = str(params.path) ?? str(params.target) ?? (target || undefined); + if (!p || /^-/.test(p) || /^https?:\/\//i.test(p)) return '.'; + return p; +}; + /** * Per-binary arg templates for the common command-ready adapters. Keyed by `adapter.binary` (the * process name), with `adapter.id` also accepted as a fallback key so callers can template by either. @@ -217,6 +232,47 @@ const ARG_TEMPLATES: Record = { return args; }, }, + + // ── Source / supply-chain scanners (non-networked, operate on a PATH) ─────────────────────────── + // Without these, each falls through to DEFAULT_TEMPLATE and spawns ` ` — which for + // a subcommand-driven scanner is a broken invocation (e.g. `semgrep .` never runs a scan, and with + // no path it is `semgrep ''`). Templates mirror the catalog `commandHint` and hardcode the + // subcommand + machine-readable output flags; the only tunable is the scan path (see scanPath). + semgrep: { + targetParam: 'path', + defaultTimeoutMs: 300_000, + build: (target, params) => ['scan', '--config', 'auto', '--json', scanPath(target, params)], + }, + gitleaks: { + targetParam: 'path', + defaultTimeoutMs: 180_000, + build: (target, params) => ['detect', '--source', scanPath(target, params), '--report-format', 'json', '--redact', '--no-banner'], + }, + trufflehog: { + targetParam: 'path', + defaultTimeoutMs: 300_000, + build: (target, params) => ['filesystem', scanPath(target, params), '--json', '--no-update'], + }, + trivy: { + targetParam: 'path', + defaultTimeoutMs: 300_000, + build: (target, params) => ['fs', '--format', 'json', scanPath(target, params)], + }, + syft: { + targetParam: 'path', + defaultTimeoutMs: 180_000, + build: (target, params) => ['dir:' + scanPath(target, params), '-o', 'cyclonedx-json'], + }, + grype: { + targetParam: 'path', + defaultTimeoutMs: 180_000, + build: (target, params) => ['dir:' + scanPath(target, params), '-o', 'json'], + }, + checkov: { + targetParam: 'path', + defaultTimeoutMs: 180_000, + build: (target, params) => ['-d', scanPath(target, params), '-o', 'json'], + }, }; /** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */