diff --git a/src/__tests__/adapter-tools.test.ts b/src/__tests__/adapter-tools.test.ts index 9a3877f..5f6d6e7 100644 --- a/src/__tests__/adapter-tools.test.ts +++ b/src/__tests__/adapter-tools.test.ts @@ -14,6 +14,8 @@ import { adapterToCustomTool, buildAdapterTools, toolNameFor, + isMintable, + hasArgTemplate, type AdapterToolDeps, type SubprocessResult, } from '../arsenal/adapter-tools.js'; @@ -261,3 +263,107 @@ describe('source / supply-chain scanners run a real invocation (not ` `)', () => { + // Each entry: [adapter id, params, expected argv]. Without bespoke templates these all fell through + // to DEFAULT_TEMPLATE and spawned `['']` — a broken invocation for a subcommand/flag-driven + // analyser (and, for r2, one that drops into an interactive shell and hangs the loop). + const CASES: Array<[string, Record, string[]]> = [ + ['objdump', { file: 'a.out' }, ['-d', '-M', 'intel', 'a.out']], + ['readelf', { file: 'a.out' }, ['-a', 'a.out']], + ['checksec', { file: 'a.out' }, ['--file=a.out']], + ['radare2', { file: 'a.out' }, ['-q', '-e', 'scr.color=0', '-c', 'ij', 'a.out']], + ['exiftool', { file: 'a.out' }, ['-json', 'a.out']], + ['mythril', { file: 'Vault.sol' }, ['analyze', 'Vault.sol', '-o', 'json']], + ['apkleaks', { file: 'app.apk' }, ['-f', 'app.apk']], + ['slither', { path: 'contracts' }, ['contracts', '--json', '-']], + ['mobsfscan', { path: 'app-src' }, ['--json', 'app-src']], + ]; + + it.each(CASES)('%s builds its real analyser argv', async (id, params, expected) => { + const deps = makeDeps(); + await mint(id, deps).handler(ctx(params)); + expect(deps.spawns[0]).toEqual(expected); + }); + + it('radare2 never drops into an interactive shell (batch `-q -c`, never a bare file)', async () => { + const deps = makeDeps(); + await mint('radare2', deps).handler(ctx({ file: 'a.out' })); + const argv = deps.spawns[0]; + expect(argv).toContain('-q'); // quit after the command + expect(argv).toContain('-c'); // run one command, non-interactive + expect(argv).not.toEqual(['a.out']); // the old broken/hanging positional default + }); + + it('a file-oriented analyser with no path degrades (clean failure, no spawn)', async () => { + const deps = makeDeps(); + const res = await mint('objdump', deps).handler(ctx({})); + expect(res.success).toBe(false); + expect(res.error).toMatch(/file\/artifact path/); + expect(deps.spawns.length).toBe(0); // never spawned `objdump ''` + }); + + it('a file-oriented analyser refuses an http(s) URL as a local artifact (no spawn)', async () => { + const deps = makeDeps(); + // A networked mission address inherited via context must not become `objdump https://x`. + const res = await mint('readelf', deps).handler(ctx({ file: 'https://victim.example/firmware.bin' })); + expect(res.success).toBe(false); + expect(deps.spawns.length).toBe(0); + }); +}); + +describe('invocation-honesty guard — every mintable adapter is classified, none silently falls through', () => { + // Every adapter that CAN be minted either carries a bespoke ARG_TEMPLATE (a real invocation) or is + // explicitly filed under exactly one reason for legitimately falling through to ` `. + // This is the invocation-correctness sibling of the tool-count honesty test: a newly-catalogued + // subcommand tool (e.g. a future `zzuf`/`radare2`-style binary) FAILS this test until someone + // decides whether it needs a template — it can never silently ship a broken positional invocation. + + // ` ` is genuinely a correct, useful invocation for these. + const POSITIONAL_TARGET_OK = new Set([ + 'file', 'strings', 'binwalk', 'radamsa', 'jadx', 'class-dump', 'solhint', 'echidna', 'john', + ]); + // No single safe default exists: the operator supplies the full command (cloud CLIs, model-config + // red-team harnesses, device-runtime tools, project-scaffolded RE, or a target-executing debugger). + const OPERATOR_DRIVEN = new Set([ + 'prowler', 'scoutsuite', 'cloudfox', 'pmapper', 'aws-cli', 'az-cli', 'gcloud-cli', + 'garak', 'promptfoo', 'foundry-forge', 'foundry-cast', 'openssl', 'afl-fuzz', 'ghidra', + 'gdb', 'objection', 'drozer', + ]); + // KNOWN DEBT: the positional default is broken/degraded and these SHOULD get a template later. + // Tracked honestly here rather than hidden — a good follow-up PR shrinks this set. + const KNOWN_DEBT = new Set([ + 'feroxbuster', 'osv-scanner', 'hashcat', 'apktool', 'yara', + ]); + + const mintable = TOOL_ADAPTERS.filter(isMintable); + const classified = [POSITIONAL_TARGET_OK, OPERATOR_DRIVEN, KNOWN_DEBT]; + + it('every mintable adapter is either templated or explicitly filed under one fall-through reason', () => { + const unaccounted = mintable + .filter(a => !hasArgTemplate(a)) + .map(a => a.id) + .filter(id => !classified.some(s => s.has(id))); + expect(unaccounted, 'these mintable adapters silently fall through to ` `').toEqual([]); + }); + + it('the fall-through allow-lists are disjoint and never overlap a templated adapter', () => { + const seen = new Set(); + for (const set of classified) { + for (const id of set) { + expect(seen.has(id), `${id} is listed in more than one fall-through set`).toBe(false); + seen.add(id); + const a = TOOL_ADAPTERS.find(x => x.id === id); + expect(a, `allow-listed id '${id}' is not in the catalog`).toBeTruthy(); + expect(isMintable(a as ToolAdapter), `${id} is not mintable — remove it`).toBe(true); + expect(hasArgTemplate(a as ToolAdapter), `${id} IS templated — remove it from the fall-through lists`).toBe(false); + } + } + }); + + it('the reverse/mobile/smart-contract loadout is actually templated (regression pin)', () => { + for (const id of ['objdump', 'readelf', 'checksec', 'radare2', 'exiftool', 'mythril', 'apkleaks', 'slither', 'mobsfscan']) { + expect(hasArgTemplate(adapter(id)), `${id} lost its ARG_TEMPLATE`).toBe(true); + } + }); +}); diff --git a/src/arsenal/adapter-tools.ts b/src/arsenal/adapter-tools.ts index 8ecf3b9..0b3029d 100644 --- a/src/arsenal/adapter-tools.ts +++ b/src/arsenal/adapter-tools.ts @@ -93,6 +93,24 @@ const scanPath = (target: string, params: Record): string => { return p; }; +/** + * Resolve the local FILE/artifact a reverse-engineering or mobile analyser runs against (objdump, + * readelf, r2, myth, apkleaks, …). Unlike a directory scanner, a binary tool has no sensible cwd + * default — a bare `objdump .` is meaningless — so instead of silently degrading this REFUSES an + * absent or non-local path by throwing, which the factory handler turns into a clean + * `{ success:false }` result (never an unhandled rejection). The two rejected shapes: + * - no path at all → the tool would spawn ` ''` / `` and produce nothing, + * - an http(s) URL (e.g. a networked mission target inherited via context.target.address) → not a + * local artifact. (A leading `-` is already refused upstream by the factory's option-looking- + * target guard, since these templates read the artifact from their `targetParam`.) + */ +const artifactPath = (target: string, params: Record): string => { + const p = str(params.file) ?? str(params.path) ?? str(params.artifact) ?? (target || undefined); + if (!p) throw new Error('requires a file/artifact path (none was provided).'); + if (/^https?:\/\//i.test(p)) throw new Error(`'${p}' is a URL, not a local artifact path.`); + 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. @@ -273,6 +291,70 @@ const ARG_TEMPLATES: Record = { defaultTimeoutMs: 180_000, build: (target, params) => ['-d', scanPath(target, params), '-o', 'json'], }, + + // ── Reverse-engineering / mobile / smart-contract static analysis (local_read, operate on a FILE) ─ + // Without these each falls through to DEFAULT_TEMPLATE and spawns ` ` — which for a + // subcommand- or flag-driven analyser is a broken (or, for r2, an INTERACTIVE-shell-hanging) + // invocation. Templates mirror the catalog `commandHint` and hardcode the READ-ONLY subcommand + + // machine-readable output flags; the only tunable is the artifact/scan path (see artifactPath / + // scanPath). None add an intrusive or code-executing flag — risk stays where the catalog put it. + objdump: { + // `objdump ` errors ("no options given"); disassembly needs an action flag. + targetParam: 'file', + defaultTimeoutMs: 60_000, + build: (target, params) => ['-d', '-M', 'intel', artifactPath(target, params)], + }, + readelf: { + // `readelf ` errors ("Warning: Nothing to do"); `-a` dumps all ELF headers. + targetParam: 'file', + defaultTimeoutMs: 60_000, + build: (target, params) => ['-a', artifactPath(target, params)], + }, + checksec: { + // checksec takes its subject as `--file=`, not a bare positional. + targetParam: 'file', + defaultTimeoutMs: 30_000, + build: (target, params) => ['--file=' + artifactPath(target, params)], + }, + r2: { + // A bare `r2 ` drops into an INTERACTIVE prompt and blocks the agent loop until the + // per-adapter timeout burns out. `-q -c ij` runs ONE read-only info command as JSON, then quits; + // `scr.color=0` keeps the JSON free of ANSI escapes. No `w`/`!`/analysis-write commands are used. + targetParam: 'file', + defaultTimeoutMs: 60_000, + build: (target, params) => ['-q', '-e', 'scr.color=0', '-c', 'ij', artifactPath(target, params)], + }, + exiftool: { + // `exiftool ` works but prints human text; `-json` gives a structured, parseable channel. + targetParam: 'file', + defaultTimeoutMs: 30_000, + build: (target, params) => ['-json', artifactPath(target, params)], + }, + myth: { + // `myth ` errors; Mythril needs the `analyze` subcommand. `-o json` emits machine output. + targetParam: 'file', + defaultTimeoutMs: 300_000, + build: (target, params) => ['analyze', artifactPath(target, params), '-o', 'json'], + }, + apkleaks: { + // `apkleaks ` errors; the APK is supplied via `-f`. Results print to stdout. + targetParam: 'file', + defaultTimeoutMs: 180_000, + build: (target, params) => ['-f', artifactPath(target, params)], + }, + slither: { + // Slither accepts a positional target, but a bare run prints human text; `--json -` streams the + // findings as JSON to stdout. Directory-oriented, so it may default to the working dir. + targetParam: 'path', + defaultTimeoutMs: 300_000, + build: (target, params) => [scanPath(target, params), '--json', '-'], + }, + mobsfscan: { + // Directory-oriented static scanner; `--json` gives a structured channel over the default text. + targetParam: 'path', + defaultTimeoutMs: 180_000, + build: (target, params) => ['--json', scanPath(target, params)], + }, }; /** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */ @@ -289,6 +371,16 @@ function resolveTemplate(adapter: ToolAdapter): ArgTemplate { return ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id] ?? DEFAULT_TEMPLATE; } +/** + * True when the adapter has a bespoke `ARG_TEMPLATE` (a real, hardcoded invocation) rather than + * falling through to `DEFAULT_TEMPLATE`'s bare ` `. Exported so the invocation-honesty + * guard test can assert that every mintable adapter's invocation correctness is explicitly classified + * — and that a newly-catalogued subcommand tool cannot silently ship a broken positional invocation. + */ +export function hasArgTemplate(adapter: ToolAdapter): boolean { + return Boolean(ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id]); +} + /** * Resolve the target string for an adapter call: the template's preferred param first, then the other * common target keys, then `context.target.address`. Returns undefined if none is present.