feat(arsenal): real invocations for the reverse-engineering / mobile / smart-contract analysis loadout
The Kali+ adapter factory only carries bespoke ARG_TEMPLATES for the network
and source/supply-chain scanners. The reverse-engineering, mobile and
smart-contract analysers catalogued by the cloud/mobile/binary-RE loadouts fall
through to DEFAULT_TEMPLATE and spawn a bare `<binary> <file>` — a broken
invocation for a subcommand/flag-driven tool, and for radare2 one that drops
into an INTERACTIVE prompt and hangs the agent loop until the timeout burns out.
Add conservative, read-only ARG_TEMPLATES (mirroring each catalog commandHint)
for objdump, readelf, checksec, r2, exiftool, myth (mythril), apkleaks, slither
and mobsfscan. File-oriented tools resolve their artifact through a new
`artifactPath` helper that refuses an absent path or an inherited http(s) URL by
throwing, which the handler turns into a clean {success:false} result (never a
bare `objdump ''`). No intrusive or code-executing flag is added — risk stays
where the catalog put it, and the existing option-looking-target / scope gates
still apply.
Add an invocation-honesty guard test (sibling of the tool-count honesty test):
every mintable adapter must be either templated or explicitly filed under one
fall-through reason — POSITIONAL_TARGET_OK, OPERATOR_DRIVEN, or KNOWN_DEBT — so a
newly catalogued subcommand tool can never silently ship a broken positional
invocation, and the remaining debt (feroxbuster, osv-scanner, hashcat, apktool,
yara) is tracked in the open rather than hidden.
All offline gate checks pass (lint typecheck test doctor verify-claims
test:no-fitting test:no-self-fitting test:gate prompt:audit smoke
test:arsenal-tools arsenal:smoke).
This commit is contained in:
1 parent
a122c567f1
commit
353ea33a23
2 files changed
+198
No files matched your search
@@ -14,6 +14,8 @@ import {
|
|||||||
adapterToCustomTool,
|
adapterToCustomTool,
|
||||||
buildAdapterTools,
|
buildAdapterTools,
|
||||||
toolNameFor,
|
toolNameFor,
|
||||||
|
isMintable,
|
||||||
|
hasArgTemplate,
|
||||||
type AdapterToolDeps,
|
type AdapterToolDeps,
|
||||||
type SubprocessResult,
|
type SubprocessResult,
|
||||||
} from '../arsenal/adapter-tools.js';
|
} from '../arsenal/adapter-tools.js';
|
||||||
@@ -261,3 +263,107 @@ describe('source / supply-chain scanners run a real invocation (not `<binary> <t
|
|||||||
expect(deps.spawns.length).toBe(0);
|
expect(deps.spawns.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reverse / mobile / smart-contract analysers run a real invocation (not `<binary> <file>`)', () => {
|
||||||
|
// Each entry: [adapter id, params, expected argv]. Without bespoke templates these all fell through
|
||||||
|
// to DEFAULT_TEMPLATE and spawned `['<file>']` — 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, unknown>, 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 `<binary> <target>`.
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// `<binary> <target>` 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 `<binary> <target>`').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the fall-through allow-lists are disjoint and never overlap a templated adapter', () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,6 +93,24 @@ const scanPath = (target: string, params: Record<string, unknown>): string => {
|
|||||||
return p;
|
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 `<binary> ''` / `<binary>` 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, unknown>): 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
|
* 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.
|
* 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<string, ArgTemplate> = {
|
|||||||
defaultTimeoutMs: 180_000,
|
defaultTimeoutMs: 180_000,
|
||||||
build: (target, params) => ['-d', scanPath(target, params), '-o', 'json'],
|
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 `<binary> <file>` — 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 <file>` 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 <file>` 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=<path>`, not a bare positional.
|
||||||
|
targetParam: 'file',
|
||||||
|
defaultTimeoutMs: 30_000,
|
||||||
|
build: (target, params) => ['--file=' + artifactPath(target, params)],
|
||||||
|
},
|
||||||
|
r2: {
|
||||||
|
// A bare `r2 <file>` 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 <file>` 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 <file>` 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 <file>` 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. */
|
/** 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;
|
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 `<binary> <target>`. 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
|
* 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.
|
* common target keys, then `context.target.address`. Returns undefined if none is present.
|
||||||
|
|||||||
Reference in new issue
Block a user