feat: pack-hunt safety + opt-in Kali+ arsenal + capability-approval gate + tester-feedback UX
Adds three coordinated capabilities and tightens two defaults on purpose — this is safety hardening, NOT a backward-compatible drop-in (see "Tightened defaults"). Fifteen independent adversarial review passes; findings fixed and test-pinned. - Pack-hunt safety: a strict-majority adjudicator with a comment/string/regex/ raw-string/heredoc/POD-aware cite-check (language-gated across C/C++/Py/Rust/Go/ Java/JS/TS/shell/SQL/PHP/Ruby/Perl) so a hallucinated killing-guard can't refute a real finding — the cited comparison's operator must match source, and a string/regex/heredoc/generic-arglist/template-interpolation is blanked before matching. Plus a rate-limit/degraded-health tracker, a global LLM semaphore, and a PackBoard with an atomic claim CAS + snapshot-on-emit/clone-on-ingest. - Opt-in Kali+ arsenal (T3MP3ST_FULL_ARSENAL, OFF by default): 35 default tools (31 built-in + 4 external-CLI wrappers) -> 83 with the catalog armed (verify-claims CLAIM 4). Most adapters need the underlying binary installed. metasploit / hydra / bloodhound are never minted through the generic factory. - Capability-approval + spicy-action gate for the NEW dangerous arsenal only (pre-existing built-ins keep running freely): approve-once, fail-safe deny when unattended, redacted audit trail over SSE. T3MP3ST_GATE_BUILTINS=1 extends it to the spicy built-in probes. Audit redaction is a pure, unit-tested module. Tightened defaults (intentional, ON by default — NOT backward-compatible): - Egress containment: once a mission target is set, built-in networked tools refuse off-scope public hosts (not the target/subdomains, not loopback/private) with SCOPE DENIED — keeps the agent on authorized scope. - Role-specialized operator toolkits: each archetype gets a curated default tool menu (scanner -> scan tools, exploiter -> sqli/xss/etc). Tools stay registered team-wide; the per-role menu is the operator's working set. UX (from live-tester feedback): a one-click "Approve & run" modal when a standard Hunt blocks on an external-target receipt (was a dead-end); bounded-parallel benchmark file ingestion; the deterministic mission-gate chip co-located with the subjective SITREP pace narration (no more "behind schedule" next to a clean gate); and click-through finding detail + evidence in the UI (was export-only). Docs: README + INTEGRITY_LEDGER made re-derivable — honest 35/83 tool count, the egress-containment default documented, and the Cybench number reconciled to the single verify-claims-enforced 23/40 = 58% (dropped the stale/uncited 21 and 26). README voice pass: killed the underselling `Successful Exploits: 0` disqualifier (read as "never popped anything" — false, given CVE-Zero + the live disclosure pipeline); swagger now rests on receipts, not vibes. Also corrected the operator status — the downstream operators run the SAME real tool-backed ReAct loop as recon (not "orchestration stubs"); the honest limit is that the coordinated swarm is unproven (single-agent is the benchmarked path), not that they're scaffolding. Gates: 292 tests, tsc + lint clean, verify-claims 20/20, arsenal:smoke 125/0, disclosure-CLI self-test 11/11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
5255000cb6
commit
06ffe3ef66
35 files changed
+5019
-211
No files matched your search
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Kali+ adapter → CustomTool factory (Phase-1).
|
||||
* Pins the load-bearing safety + behavior contract:
|
||||
* - catalog_only / import_only adapters are NEVER minted (metasploit/hydra/bloodhound stay off the
|
||||
* callable surface) — the factory returns null.
|
||||
* - a missing binary DEGRADES (returns {success:false, error:<installHint>}) instead of throwing.
|
||||
* - an out-of-scope target is refused (SCOPE DENIED) BEFORE the subprocess runs.
|
||||
* - a mintable adapter produces a CustomTool with a working name + category + real stdout output.
|
||||
* - buildAdapterTools drops non-mintable adapters and skips already-registered tool names.
|
||||
* All deps are injected fakes — no real binaries are spawned.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
adapterToCustomTool,
|
||||
buildAdapterTools,
|
||||
toolNameFor,
|
||||
type AdapterToolDeps,
|
||||
type SubprocessResult,
|
||||
} from '../arsenal/adapter-tools.js';
|
||||
import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
|
||||
import type { ToolAdapter } from '../arsenal/catalog.js';
|
||||
import type { CustomTool, ToolContext } from '../types/index.js';
|
||||
|
||||
function adapter(id: string): ToolAdapter {
|
||||
const a = TOOL_ADAPTERS.find(x => x.id === id);
|
||||
if (!a) throw new Error(`test fixture: adapter '${id}' not found in catalog`);
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Mint an adapter and assert (for the type-checker and the test) that it was not gated to null. */
|
||||
function mint(id: string, deps: AdapterToolDeps): CustomTool {
|
||||
const tool = adapterToCustomTool(adapter(id), deps);
|
||||
expect(tool, `${id} should be mintable`).not.toBeNull();
|
||||
return tool as CustomTool;
|
||||
}
|
||||
|
||||
/** A deps double that records subprocess spawns and lets each behavior be configured per test. */
|
||||
function makeDeps(overrides: Partial<AdapterToolDeps> = {}): AdapterToolDeps & { spawns: string[][] } {
|
||||
const spawns: string[][] = [];
|
||||
return {
|
||||
spawns,
|
||||
isToolAvailable: async () => true,
|
||||
runSubprocess: async (_command, args): Promise<SubprocessResult> => {
|
||||
spawns.push(args);
|
||||
return { stdout: 'FAKE_OUTPUT', stderr: '', exitCode: 0 };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ctx = (parameters: Record<string, unknown>): ToolContext => ({ parameters });
|
||||
|
||||
describe('adapterToCustomTool — mint gate', () => {
|
||||
it('NEVER mints catalog_only / import_only adapters (metasploit, hydra, bloodhound → null)', () => {
|
||||
const deps = makeDeps();
|
||||
expect(adapterToCustomTool(adapter('metasploit'), deps)).toBeNull(); // catalog_only
|
||||
expect(adapterToCustomTool(adapter('hydra'), deps)).toBeNull(); // catalog_only
|
||||
expect(adapterToCustomTool(adapter('bloodhound'), deps)).toBeNull(); // import_only
|
||||
});
|
||||
|
||||
it('mints a command-ready adapter with a working name + category', () => {
|
||||
const tool = mint('nmap', makeDeps());
|
||||
expect(tool.name).toBe(toolNameFor(adapter('nmap')));
|
||||
expect(tool.name).toBe('nmap_tool');
|
||||
expect(tool.category).toBe('network'); // passthrough of the catalog category
|
||||
expect(typeof tool.handler).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adapterToCustomTool — degrade / scope / execution', () => {
|
||||
it('a missing binary DEGRADES (does not throw) and surfaces the installHint', async () => {
|
||||
const deps = makeDeps({ isToolAvailable: async () => false });
|
||||
const tool = mint('nmap', deps);
|
||||
const res = await tool.handler(ctx({ target: '127.0.0.1' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain(adapter('nmap').installHint);
|
||||
expect(deps.spawns.length).toBe(0); // nothing was spawned
|
||||
});
|
||||
|
||||
it('scopeOk=false → SCOPE DENIED before the subprocess runs', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => false });
|
||||
const tool = mint('nuclei', deps);
|
||||
const res = await tool.handler(ctx({ url: 'https://evil.example.com' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/SCOPE DENIED/);
|
||||
expect(res.error).toContain('evil.example.com');
|
||||
expect(deps.spawns.length).toBe(0); // refused before spawning
|
||||
});
|
||||
|
||||
it('scopeOk=true → runs the subprocess and returns stdout as output', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nuclei', deps);
|
||||
const res = await tool.handler(ctx({ url: 'https://target.example.com' }));
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.output).toBe('FAKE_OUTPUT');
|
||||
expect(deps.spawns.length).toBe(1);
|
||||
// nuclei template threads the target through -target
|
||||
expect(deps.spawns[0]).toContain('https://target.example.com');
|
||||
});
|
||||
|
||||
it('a networked adapter with no target degrades gracefully (no spawn)', async () => {
|
||||
const deps = makeDeps();
|
||||
const tool = mint('nuclei', deps);
|
||||
const res = await tool.handler(ctx({}));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/requires a target/);
|
||||
expect(deps.spawns.length).toBe(0);
|
||||
});
|
||||
|
||||
it('a non-zero exit code is reported as failure, not a throw', async () => {
|
||||
const deps = makeDeps({
|
||||
scopeOk: () => true,
|
||||
runSubprocess: async () => ({ stdout: '', stderr: 'boom', exitCode: 2 }),
|
||||
});
|
||||
const tool = mint('nikto', deps);
|
||||
const res = await tool.handler(ctx({ url: 'https://target.example.com' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adapterToCustomTool — argument-injection hardening', () => {
|
||||
it('nmap IGNORES an LLM-supplied free-form flags param (no -oN/--script reaches argv)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nmap', deps);
|
||||
const res = await tool.handler(ctx({
|
||||
target: 'target.example.com',
|
||||
flags: '-oN /tmp/pwned --script vuln,exploit', // injection attempt
|
||||
}));
|
||||
expect(res.success).toBe(true);
|
||||
const argv = deps.spawns[0];
|
||||
expect(argv).toEqual(['-sV', '-T4', 'target.example.com']); // flags hardcoded; injection dropped
|
||||
expect(argv).not.toContain('-oN');
|
||||
expect(argv.join(' ')).not.toContain('--script');
|
||||
});
|
||||
|
||||
it('nmap accepts a clean port spec but drops one carrying an injected flag', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nmap', deps);
|
||||
await tool.handler(ctx({ target: 'target.example.com', ports: '22,80,443' }));
|
||||
expect(deps.spawns[0]).toEqual(['-sV', '-T4', '-p', '22,80,443', 'target.example.com']);
|
||||
await tool.handler(ctx({ target: 'target.example.com', ports: '80 -oN /tmp/x' }));
|
||||
expect(deps.spawns[1]).toEqual(['-sV', '-T4', 'target.example.com']); // malicious ports dropped
|
||||
});
|
||||
|
||||
it('refuses an option-looking target (leading dash) before spawning', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nmap', deps);
|
||||
const res = await tool.handler(ctx({ target: '-oN/tmp/pwned' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/option-looking target/);
|
||||
expect(deps.spawns.length).toBe(0); // never spawned
|
||||
});
|
||||
|
||||
it('curl sends a literal body via --data-raw and REFUSES a local-file-read data value (-d @file)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('curl', deps);
|
||||
// a clean body is sent verbatim with --data-raw (never plain -d, which would file-interpret @/<)
|
||||
const ok = await tool.handler(ctx({ url: 'https://target.example.com/', data: 'a=1&b=2' }));
|
||||
expect(ok.success).toBe(true);
|
||||
expect(deps.spawns[0]).toContain('--data-raw');
|
||||
expect(deps.spawns[0]).not.toContain('-d');
|
||||
// `@/etc/passwd` (and `<file`) make curl read a local file into the body — must be refused, no spawn
|
||||
for (const evil of ['@/etc/passwd', '@-', '<secret.txt']) {
|
||||
const res = await tool.handler(ctx({ url: 'https://target.example.com/', data: evil }));
|
||||
expect(res.success, `data '${evil}' must be refused`).toBe(false);
|
||||
expect(res.error).toMatch(/read a local file/);
|
||||
}
|
||||
expect(deps.spawns.length).toBe(1); // only the clean call ever spawned
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdapterTools', () => {
|
||||
it('drops non-mintable adapters (metasploit/hydra/bloodhound never appear)', () => {
|
||||
const tools = buildAdapterTools(TOOL_ADAPTERS, makeDeps());
|
||||
const names = tools.map(t => t.name);
|
||||
expect(names).toContain(toolNameFor(adapter('nmap')));
|
||||
expect(names).not.toContain(toolNameFor(adapter('metasploit')));
|
||||
expect(names).not.toContain(toolNameFor(adapter('hydra')));
|
||||
expect(names).not.toContain(toolNameFor(adapter('bloodhound')));
|
||||
// count matches the catalog's mintable population
|
||||
const mintable = TOOL_ADAPTERS.filter(
|
||||
a => a.execution === 'safe_command' || a.execution === 'receipt_required'
|
||||
).length;
|
||||
expect(tools.length).toBe(mintable);
|
||||
});
|
||||
|
||||
it('skips adapters whose minted name is already registered', () => {
|
||||
const already = new Set([toolNameFor(adapter('nmap'))]);
|
||||
const tools = buildAdapterTools(TOOL_ADAPTERS, makeDeps(), already);
|
||||
expect(tools.map(t => t.name)).not.toContain(toolNameFor(adapter('nmap')));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Shared adjudicate + cite-check (Phase-0 #5) — the two safety primitives lifted from
|
||||
* scripts/refute-finding.mjs into a pure src/mission/adjudicate.ts so the disclosure CLI
|
||||
* and the live pack-audit path can't drift (the pack-hunt design, HT-2).
|
||||
*
|
||||
* Pins: strict-majority thresholds; a MINORITY refute does NOT flip; ties don't refute;
|
||||
* a REFUTED whose killing_guard fails guardExistsInSource is downgraded to SURVIVED;
|
||||
* null/abstain handling never counts as a refute.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
adjudicate,
|
||||
guardExistsInSource,
|
||||
downgradeUnverifiedRefutes,
|
||||
type RefuterVote,
|
||||
} from '../mission/adjudicate.js';
|
||||
|
||||
const refuted = (guard?: { file: string; line: number; quote: string }): RefuterVote => ({
|
||||
verdict: 'REFUTED',
|
||||
killing_guard: guard ?? null,
|
||||
});
|
||||
const survived = (): RefuterVote => ({ verdict: 'SURVIVED', killing_guard: null });
|
||||
|
||||
describe('adjudicate — strict-majority tally', () => {
|
||||
it('2 of 3 REFUTED → REFUTED (strict majority)', () => {
|
||||
const a = adjudicate([refuted(), refuted(), survived()]);
|
||||
expect(a.verdict).toBe('REFUTED');
|
||||
expect(a.refutedCount).toBe(2);
|
||||
expect(a.total).toBe(3);
|
||||
});
|
||||
|
||||
it('1 of 3 REFUTED → SURVIVED (a MINORITY refute does NOT flip)', () => {
|
||||
const a = adjudicate([refuted(), survived(), survived()]);
|
||||
expect(a.verdict).toBe('SURVIVED');
|
||||
expect(a.refutedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('a TIE does not refute — 1 of 2 REFUTED → SURVIVED (need STRICT majority)', () => {
|
||||
expect(adjudicate([refuted(), survived()]).verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('a TIE does not refute — 2 of 4 REFUTED → SURVIVED', () => {
|
||||
expect(adjudicate([refuted(), refuted(), survived(), survived()]).verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('3 of 4 REFUTED → REFUTED', () => {
|
||||
expect(adjudicate([refuted(), refuted(), refuted(), survived()]).verdict).toBe('REFUTED');
|
||||
});
|
||||
|
||||
it('unanimous single REFUTED → REFUTED; single SURVIVED → SURVIVED', () => {
|
||||
expect(adjudicate([refuted()]).verdict).toBe('REFUTED');
|
||||
expect(adjudicate([survived()]).verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('collects killing guards from the counted REFUTED votes (nulls dropped)', () => {
|
||||
const g = { file: 'x.c', line: 9, quote: 'if (n > cap) return' };
|
||||
const a = adjudicate([refuted(g), refuted(g), survived()]);
|
||||
expect(a.killing_guards).toHaveLength(2);
|
||||
// a REFUTED with a null guard contributes no entry
|
||||
const b = adjudicate([refuted(g), refuted(), refuted()]);
|
||||
expect(b.killing_guards).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjudicate — null / abstain handling', () => {
|
||||
it('null and junk verdicts are dropped: neither refute count nor panel size', () => {
|
||||
// 1 real REFUTED, plus a null and a junk vote → panel size 1, not a majority-of-3
|
||||
const a = adjudicate([refuted(), null, { verdict: 'MAYBE' } as RefuterVote]);
|
||||
expect(a.total).toBe(1);
|
||||
expect(a.refutedCount).toBe(1);
|
||||
expect(a.verdict).toBe('REFUTED'); // 1 of 1 valid
|
||||
});
|
||||
|
||||
it('abstentions cannot manufacture a majority — 1 REFUTED + 2 abstain among 2 survived stays SURVIVED', () => {
|
||||
const a = adjudicate([refuted(), survived(), survived(), null, { verdict: 'junk' } as RefuterVote]);
|
||||
expect(a.total).toBe(3);
|
||||
expect(a.verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('empty panel → INCONCLUSIVE', () => {
|
||||
expect(adjudicate([]).verdict).toBe('INCONCLUSIVE');
|
||||
expect(adjudicate([null, undefined, { verdict: 'junk' } as RefuterVote]).verdict).toBe('INCONCLUSIVE');
|
||||
expect(adjudicate([null]).total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardExistsInSource — deterministic cite-check', () => {
|
||||
const source = [
|
||||
'void copy(size_t n) {',
|
||||
' if (n > cap) return; // the real killing guard',
|
||||
' memcpy(dst, src, n);',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
it('verifies an exact quote (normalized substring, whitespace-insensitive)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, source)).toBe(true);
|
||||
// reformatted (extra spaces / different indent) still verifies
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if(n>cap) return' }, source)).toBe(true);
|
||||
});
|
||||
|
||||
it('verifies a reversed-order comparison via the structural fallback (operator mirrored)', () => {
|
||||
// not a substring (operands swapped), but source `n > cap` ≡ cited `cap < n` — same guard, mirrored.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap < n' }, source)).toBe(true);
|
||||
// same operands + same operator, just reformatted, also verifies structurally.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n>cap' }, source)).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a same-operand comparison with the WRONG operator (a different guard, not a paraphrase)', () => {
|
||||
// source guard is `n > cap`; a cited `n >= cap` is a DIFFERENT bound (it also blocks n==cap) and
|
||||
// must NOT verify just because it shares the operands — the operator carries the guard's meaning.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n >= cap' }, source)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n == cap' }, source)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap <= n' }, source)).toBe(false); // wrong mirror
|
||||
});
|
||||
|
||||
// --- regex-literal tokenizing (adversarial review sixth pass): a quote INSIDE a regex must not open
|
||||
// a phantom string that swallows a real guard on a later line. ---
|
||||
it('a quote inside a regex literal does NOT blank a real guard on a later line', () => {
|
||||
const src = [
|
||||
`function handle(idx, buf) {`,
|
||||
` const cleaned = name.replace(/['"]/g, '');`, // ubiquitous sanitizer — quotes live in the regex
|
||||
` if (idx >= buf.length) return ERR;`, // the real killing guard, on the NEXT line
|
||||
` return buf[idx];`,
|
||||
`}`,
|
||||
].join('\n');
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 3, quote: 'idx >= buf.length' }, src)).toBe(true);
|
||||
});
|
||||
|
||||
it('an apostrophe-bearing regex does NOT blank a downstream comparison guard', () => {
|
||||
const src = [
|
||||
`s = s.replace(/'/g, "x");`, // a stray apostrophe inside the regex
|
||||
`if (userId >= tenant.limit) throw new Error('scope');`, // the real guard must still verify
|
||||
].join('\n');
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 2, quote: 'userId >= tenant.limit' }, src)).toBe(true);
|
||||
});
|
||||
|
||||
it('still REJECTS a comparison that lives ONLY inside a regex literal (no false-verify)', () => {
|
||||
// the regex body is blanked like a string, so a hallucinated guard cannot hide there.
|
||||
const src = `const re = /idx >= len/; return arr[idx];`;
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, src)).toBe(false);
|
||||
});
|
||||
|
||||
// --- seventh-pass BLOCKER: a regex in VALUE position (return/typeof/close-paren) must NOT leak its
|
||||
// body — else a comparison that only lives inside the regex false-verifies a hallucinated guard. ---
|
||||
it('a value-position regex body cannot false-verify a hallucinated guard (return / if(x) /re/)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.ts', line: 3, quote: 'm_id >= ls.w' },
|
||||
'const cell = ls.cells[m_id];\nreturn /m_id >= ls.w/.test(String(m_id));')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a >= b' }, 'return /a >= b/.test(x);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a >= b' }, 'if (x) /a >= b/.test(y);')).toBe(false);
|
||||
});
|
||||
|
||||
it('over-blanks a JS guard sharing a line AFTER a regex (safe: SURVIVED), keeps it in a no-regex language', () => {
|
||||
// A regex-bearing language (JS): everything after a regex `/` on the line is conservatively blanked
|
||||
// to end-of-line, so this same-line-after-regex guard does NOT verify — a false-REJECT (→ the
|
||||
// finding SURVIVES), the module's safe direction. The realistic case (guard on its OWN line) still
|
||||
// verifies (covered by the tests above).
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx < len' },
|
||||
`function f(s, idx, len) { return /['"]/.test(s) && idx < len; }`)).toBe(false);
|
||||
// A no-regex language (C/C++/…) keeps every `/` as division, so a real comparison sharing a line
|
||||
// with a division verifies at full fidelity.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < len' },
|
||||
'int g(int idx, int len) { int r = a / b; if (idx < len) return r; }')).toBe(true);
|
||||
});
|
||||
|
||||
it('a single division on the guard line is preserved (idx >= len / 2)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len / 2' }, 'if (idx >= len / 2) return;')).toBe(true);
|
||||
});
|
||||
|
||||
// --- 8th-pass BLOCKER: a DIVISION `/` earlier on a line must not mis-pair with a later regex's
|
||||
// opening `/` (which would leak the regex body) or with a comment's `/` (leaking the comment). ---
|
||||
it('a division before a regex does not leak the regex body (no mis-pairing false-verify)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'y = a / b; const re = /idx >= len/.test(z);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'y = foo() / b; const re = /idx >= len/.test(z);')).toBe(false);
|
||||
});
|
||||
|
||||
it('a division before a comment does not leak the comment body', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'lo <= hi' }, 'y = w / 2; // lo <= hi')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'lo <= hi' }, 'y = w / 2; /* lo <= hi */ z = 0;')).toBe(false);
|
||||
});
|
||||
|
||||
it('a division after a CALL is preserved, and a regex after a CONTROL-flow ) is blanked', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx < getLen() / 2' }, 'if (idx < getLen() / 2) return;')).toBe(true); // call-div kept
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'if (x) /idx >= len/.test(y);')).toBe(false); // control-) regex blanked
|
||||
});
|
||||
|
||||
// --- 9th-pass BLOCKERS: a regex after a block-closing `}`, after a keyword-as-PROPERTY, or after a
|
||||
// block comment must not leak its body (the position-heuristic lexers all missed these). ---
|
||||
it('a regex after a block-closing } does not leak its body', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, '{ let a = 1; } /idx > len/.test(input);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'if (c) { work(); } /idx > len/.exec(s);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'function h(){ return 0; } /idx > len/.test(q);')).toBe(false);
|
||||
});
|
||||
|
||||
it('a regex after a keyword used as a PROPERTY (o.in / c) does not leak (no division mis-pair)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'const n = o.in / c; return raw.replace(/idx > len/g,"");')).toBe(false);
|
||||
});
|
||||
|
||||
it('a regex after a block comment does not leak its body', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, '/* note */ const re = /idx > len/.test(s);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'if /*c*/ (x) /idx > len/.test(y);')).toBe(false);
|
||||
});
|
||||
|
||||
it('a regex containing BOTH a comparison and a quote does not leak (/a > b\'/)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, "z = /a > b'/.test(s);")).toBe(false);
|
||||
});
|
||||
|
||||
// --- 10th-pass BLOCKERS (fixed by deciding regex-vs-division by LANGUAGE, not per-`/`): a regex-
|
||||
// internal quote must not consume a later real string's quote, and a `\`-continued string must
|
||||
// not leak its comparison. ---
|
||||
it('a regex-internal quote does not consume a later string, leaking its comparison', () => {
|
||||
expect(guardExistsInSource({ file: 'x.ts', line: 1, quote: 'b > c' }, "const r = /a'/.test(x) ? y : 'b > c';")).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'm_id >= ls.w' }, "if (/x'/.test(name)) log('m_id >= ls.w');")).toBe(false);
|
||||
});
|
||||
|
||||
it('a backslash-continued string does not leak its comparison (JS and C)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'var s = "boundary idx >= len \\\nmore"; buf[idx]=v;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= len' }, 'char* s = "boundary idx >= len \\\nmore"; buf[idx]=v;')).toBe(false);
|
||||
});
|
||||
|
||||
it('a no-regex language (.c) keeps divisions at full fidelity (a division never blanks a real guard)', () => {
|
||||
// In C there are no `/…/` regex literals, so `/` is always division; a real bounds guard sharing a
|
||||
// line with a division still verifies — no over-blank, unlike the regex-bearing (JS) path.
|
||||
expect(guardExistsInSource({ file: 'los.c', line: 2, quote: 'm_id >= ls_w' }, 'int cell(int m_id, int ls_w) {\n if (m_id >= ls_w) return -1; return buf[m_id] / scale;\n}')).toBe(true);
|
||||
});
|
||||
|
||||
// --- 11th-pass BLOCKERS: multi-language comment / string handling (the tokenizer is per-language). ---
|
||||
it('a JS nested template literal does not leak its inner string as code', () => {
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'a > b' }, 't = `${`a > b`}`;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'idx >= len' }, 'h = `${`if (idx >= len) continue`}`;')).toBe(false);
|
||||
});
|
||||
|
||||
it('a \\-continued string does not leak across the newline (LF and CRLF)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= len' }, 'char* s = "x idx >= len \\\nmore"; return buf[idx];')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'var s = "x idx >= len \\\r\nmore"; buf[idx]=v;')).toBe(false);
|
||||
});
|
||||
|
||||
it('Python: # comments and triple-quoted strings are stripped, but // is floor-division (kept)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.py', line: 1, quote: 'idx >= len' }, 'x = 1 # idx >= len is the intended check\nreturn buf[idx]')).toBe(false); // # comment
|
||||
expect(guardExistsInSource({ file: 'a.py', line: 2, quote: 'idx >= len' }, 'def f():\n """note: idx >= len denotes overflow"""\n return buf[idx]')).toBe(false); // triple-quote
|
||||
expect(guardExistsInSource({ file: 'a.py', line: 2, quote: 'idx >= n' }, 'h = a // b\nif idx >= n: return -1')).toBe(true); // // is floor-div, guard verifies
|
||||
});
|
||||
|
||||
it('a C backslash-continued // comment does not leak onto the next line', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'idx >= len' }, 'int f() { // cont \\\nidx >= len leaks?\n return 0; }')).toBe(false);
|
||||
});
|
||||
|
||||
it('# (shell) and -- (SQL/Lua) line comments are stripped for those languages', () => {
|
||||
expect(guardExistsInSource({ file: 'run.sh', line: 1, quote: 'idx >= len' }, 'echo hi # idx >= len check needed')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'q.sql', line: 1, quote: 'a >= b' }, 'select 1; -- a >= b intended')).toBe(false);
|
||||
});
|
||||
|
||||
// --- 12th-pass: raw-string forms (Go / C++ / Rust) must not leak their body ---
|
||||
it('raw-string literals (Go backtick, C++ R"(...)", Rust r#"..."#) do not leak a comparison', () => {
|
||||
expect(guardExistsInSource({ file: 'x.go', line: 1, quote: 'idx >= len' }, 'q := `SELECT * WHERE idx >= len`\nreturn buf[idx]')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.cpp', line: 1, quote: 'n > cap' }, 'auto s = R"(payload n > cap here)";\nint real = 0;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.cpp', line: 1, quote: 'n > cap' }, 'auto s = R"xy(has a \\" then n > cap here)xy"; int r = 0;')).toBe(false); // inner-quote parity
|
||||
expect(guardExistsInSource({ file: 'x.rs', line: 1, quote: 'n > cap' }, 'let s = r#"note n > cap and a " quote"#; let r = 0;')).toBe(false);
|
||||
// and real guards in those languages still verify
|
||||
expect(guardExistsInSource({ file: 'x.go', line: 1, quote: 'idx >= len' }, 'if idx >= len { return }')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.rs', line: 1, quote: 'n > cap' }, 'if n > cap { return; }')).toBe(true);
|
||||
});
|
||||
|
||||
it('heredoc bodies (shell <<EOF / <<- / <<~ / quoted, ruby <<~) do not leak; code after them survives', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'x > y' }, 'cat <<EOF\nif (( x > y )); then :; fi\nEOF\necho done')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'a >= b' }, 'cat <<-EOF\n\ta >= b here\n\tEOF\n:')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'i >= len' }, 't = <<~SQL\n if i >= len then end\nSQL\nputs t')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 4, quote: 'n > cap' }, 'cat <<EOF\nbody\nEOF\nif (( n > cap )); then :; fi')).toBe(true); // code after heredoc
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'n > cap' }, 'arr << item\nif n > cap then end')).toBe(true); // `<<` left-shift, not a heredoc
|
||||
});
|
||||
|
||||
it('ambiguous extensions (.m, .pl) use the safe superset — a % comment cannot leak', () => {
|
||||
expect(guardExistsInSource({ file: 'x.m', line: 1, quote: 'a >= b' }, 'y = 1 % a >= b intended check\nz = 2')).toBe(false); // MATLAB %
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a >= b' }, 'foo :- bar. % a >= b\nbaz.')).toBe(false); // Prolog %
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 1, quote: 'idx >= len' }, 'x = %q(note idx >= len here)\ny = 1')).toBe(false); // Ruby %q over-blanked
|
||||
});
|
||||
|
||||
it('the `\\`-line-comment splice fires ONLY for C/C++ (JS/Python `//`/`#` do not splice)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.c', line: 2, quote: 'idx >= len' }, 'int f() { // cont \\\nidx >= len leaks?\n return 0; }')).toBe(false); // C splices
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 2, quote: 'idx >= len' }, 'foo(); // note ends with \\\nif (idx >= len) return;')).toBe(true); // JS does NOT splice
|
||||
expect(guardExistsInSource({ file: 'x.py', line: 2, quote: 'idx >= len' }, 'x = 1 # note ends with \\\nif idx >= len: return')).toBe(true); // Python does NOT splice
|
||||
});
|
||||
|
||||
it('shell `<<<` here-string is NOT a heredoc (does not swallow the following code)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'n > cap' }, 'grep foo <<<word\nif (( n > cap )); then :; fi')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'x > y' }, 'cat <<EOF\nx > y\nEOF\n:')).toBe(false); // a real heredoc still blanks
|
||||
});
|
||||
|
||||
// --- 13th-pass: Perl POD / __END__ / quote-like operators (q// qq{} s/// tr/// m// qr// qw[]) ---
|
||||
it('Perl quote-like operators do not leak their body', () => {
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'i >= size' }, 'my $s = q(check i >= size before use);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a > b' }, '$x =~ s/note a > b here/X/g;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a >= b' }, 'if ($x =~ m{a >= b}) { }')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a > b' }, 'my @w = qw[a > b c];')).toBe(false);
|
||||
});
|
||||
|
||||
it('Perl POD (=word..=cut) and __END__/__DATA__ blocks do not leak', () => {
|
||||
expect(guardExistsInSource({ file: 'Auth.pm', line: 3, quote: 'idx < len' }, 'package Auth;\n=head1 SAFETY\nrefused unless idx < len upstream.\n=cut\nsub access { return $arr[0]; }')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'Q.pm', line: 3, quote: 'count > limit' }, 'sub f { 1 }\n__END__\ncount > limit is enforced\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('Perl quote-op lexing does not misfire on ordinary Perl (my/sub/hash), and a real guard verifies', () => {
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: '$i >= $size' }, 'sub f {\n my $x = 1;\n if ($i >= $size) { return; }\n}')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 2, quote: '$n > $cap' }, 'my $v = $h->{q};\nif ($n > $cap) { die }')).toBe(true);
|
||||
});
|
||||
|
||||
// --- 14th-pass fixes ---
|
||||
it('a single-parameter generic Type<Param> is NOT minted as a comparison guard (C++/Java/Rust/TS)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.cpp', line: 1, quote: 'vector < int' }, 'std::vector<int> cache;\nreturn cache[i];')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.java', line: 1, quote: 'List < String' }, 'List<String> names = new ArrayList<>();')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.rs', line: 1, quote: 'Vec < u8' }, 'let mut buf: Vec<u8> = vec![];')).toBe(false);
|
||||
// a genuine single-`<`/`>` guard still verifies (via the operand-checked structural path)
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < len' }, 'if (idx < len) ok();')).toBe(true);
|
||||
});
|
||||
|
||||
it('a sigil-dropped citation verifies against $-prefixed operands (shell/Perl/PHP)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 1, quote: 'n > cap' }, 'if (( $n > $cap )); then :; fi')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'i >= size' }, 'if ($i >= $size) { return; }')).toBe(true);
|
||||
});
|
||||
|
||||
it('PostgreSQL dollar-quoted strings ($$…$$ / $tag$…$tag$) do not leak; $N params are not confused', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'n > cap' }, 'SELECT $$ if n > cap then $$;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'idx >= len' }, 'SELECT $body$ where idx >= len $body$ AS note;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'a > b' }, 'SELECT $1 WHERE a > b;')).toBe(true); // $1 is a param, not a dollar-quote
|
||||
});
|
||||
|
||||
it('a comparison inside a JS `${…}` template interpolation does NOT verify (display text, not a bounds guard) and nested templates never leak', () => {
|
||||
// A comparison in a `${…}` interpolation produces template display text, not a killing guard — treat
|
||||
// it conservatively (blank), so a hallucinated guard citing it cannot false-VERIFY. Over-rejecting a
|
||||
// (contrived) real guard hidden inside an interpolation is the SAFE direction.
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'const s = `v ${idx >= len ? 1 : 0} w`; return;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, 't = `${`a > b`}`;')).toBe(false);
|
||||
// interpolation reached THROUGH a nested template (a string) must not leak either
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, 'x = `${ `${ a > b }` }`;')).toBe(false);
|
||||
// a real guard OUTSIDE any template still verifies
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'const s = `hi ${name}`; if (idx >= len) return;')).toBe(true);
|
||||
});
|
||||
|
||||
it('multi-parameter generics never false-VERIFY a `<` guard, and real `<` guards still verify', () => {
|
||||
expect(guardExistsInSource({ file: 'm.ts', line: 1, quote: 'Map < K' }, 'const m: Map<K, V> = new Map();')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'HashMap < K' }, 'let m: HashMap<K, V> = h();')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'Nested < Vec' }, 'let x: Nested<Vec<int>> = y;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'Box < T' }, 'let b: Box<T> = z;')).toBe(false); // single-arg still rejected
|
||||
expect(guardExistsInSource({ file: 'm.ts', line: 1, quote: 'idx < len' }, 'if (idx < len) return;')).toBe(true);
|
||||
});
|
||||
|
||||
it('PHP heredoc/nowdoc bodies are blanked; `<<` left-shift is not a heredoc', () => {
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'idx >= len' }, '$s = <<<EOT\nif (idx >= len) leak\nEOT;\n$r = 0;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'n > cap' }, "$s = <<<'EOT'\nnote n > cap here\nEOT;\n$r = 0;")).toBe(false); // nowdoc
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'x > y' }, '$s = <<<EOT\n x > y here\n EOT;\n$r = 0;')).toBe(false); // 7.3+ indented closer
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'n > cap' }, '$y = $x << 2;\nif (n > cap) return;')).toBe(true); // `<<` = left-shift, guard survives
|
||||
});
|
||||
|
||||
it('an INDENTED __END__/__DATA__ is ordinary code (only column-0 is a Perl trailer)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: 'n > cap' }, 'sub f {\n __END__\n if (n > cap) { return }\n}')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: 'a > b' }, 'code();\n__END__\na > b in pod trailer')).toBe(false);
|
||||
});
|
||||
|
||||
it('Perl __END__ with trailing text is blanked, and a stray leading `=` without `=cut` stays code', () => {
|
||||
expect(guardExistsInSource({ file: 'Q.pm', line: 3, quote: 'count > limit' }, 'sub f { 1 }\n__END__ data\ncount > limit\n')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: '$n > $cap' }, 'my $r = 1\n=x\nif ($n > $cap) { die }')).toBe(true); // no =cut → not POD
|
||||
});
|
||||
|
||||
it('a bare Ruby `<<WORD` heredoc blanks its body only when a terminator exists (not `arr << CONST`)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'i > n' }, 'q = <<SQL\n where i > n\nSQL\nputs q')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'n > cap' }, 'arr << CONST\nif n > cap then end')).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a hallucinated guard not present in source', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (m_id >= ls.w) continue;' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a paraphrased comparison whose operands never co-occur on one line', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'foo >= bar' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS on empty/short quote, empty source, or null guard', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: '' }, source)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, '')).toBe(false);
|
||||
expect(guardExistsInSource(null, source)).toBe(false);
|
||||
// sub-6-char quote falls through the substring path; no comparison → false
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'x;' }, source)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardExistsInSource — hardened against hallucinated cites (HT-2 regression)', () => {
|
||||
const source = [
|
||||
'void copy(size_t n) {',
|
||||
' if (n > cap) return; // the real killing guard',
|
||||
' memcpy(dst, src, n);',
|
||||
'}',
|
||||
].join('\n');
|
||||
// an OOB sink with NO bounds check at all — the exact case a hallucinating refuter targets
|
||||
const unbounded = 'void f(int idx){ memcpy(dst, table[idx], width); }';
|
||||
|
||||
it('REJECTS a structural cite whose operands co-occur on a line but are never actually compared', () => {
|
||||
// `n` and `src` both appear on the memcpy line, but `n > src` is not a comparison anywhere.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 3, quote: 'n > src' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a fabricated bounds check on an unbounded sink (operands present, comparison absent)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= width' }, unbounded)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < width' }, unbounded)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a comparison of two tokens that are never compared together in source', () => {
|
||||
// source only ever compares n vs cap; {cap, dst} is not a real comparison.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap > dst' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a bare common token that IS a substring but has no guard shape', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'size_t' }, source)).toBe(false);
|
||||
// the sink line itself is not a guard, even though it is a literal substring
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 3, quote: 'memcpy(dst, src, n)' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('still verifies a genuine clamp/min guard cited exactly (no false-negative regression)', () => {
|
||||
const clamp = 'n = std::min(n, cap);';
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'std::min(n, cap)' }, clamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a guard that exists only in a COMMENT, not real code (comment stripping)', () => {
|
||||
const withComment = 'x = alloc();\n// if (n > cap) return; (just a note, not real code)\ny = use(x);';
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, withComment)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a guard that only matches by STITCHING across two unrelated lines', () => {
|
||||
const twoLines = 'if (done) break;\nprocess(data);';
|
||||
// "break;process" is a substring of the whitespace-flattened file, but never appears on ONE line
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'break; process' }, twoLines)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a guard that exists only inside a STRING LITERAL, not real code', () => {
|
||||
const inString = 'const msg = "if (n > cap) return";\nprocess(msg);';
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'if (n > cap) return' }, inString)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT false-reject a real guard sharing a line with a // inside a STRING literal', () => {
|
||||
const withUrl = 'const u = "http://x/y"; if (n > cap) return;'; // the // is inside the string, not a comment
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'n > cap' }, withUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('END-TO-END: two hallucinated structural refutes no longer kill a real finding', () => {
|
||||
const resolve = (f: string): string => (f === 'sink.c' ? unbounded : '');
|
||||
const raw = [
|
||||
refuted({ file: 'sink.c', line: 1, quote: 'idx >= width' }), // fabricated bound
|
||||
refuted({ file: 'sink.c', line: 1, quote: 'idx < width' }), // fabricated bound
|
||||
survived(),
|
||||
];
|
||||
const checked = downgradeUnverifiedRefutes(raw, resolve);
|
||||
expect(adjudicate(checked).verdict).toBe('SURVIVED');
|
||||
expect(adjudicate(checked).refutedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downgradeUnverifiedRefutes — mandatory cite-check flips hallucinated refutes', () => {
|
||||
const realSource = 'if (n > cap) return;\nmemcpy(dst, src, n);';
|
||||
const resolve = (file: string): string => (file === 'real.c' ? realSource : '');
|
||||
|
||||
it('a REFUTED with a VERIFIED guard survives the check (stays REFUTED)', () => {
|
||||
const votes = [refuted({ file: 'real.c', line: 1, quote: 'if (n > cap) return' })];
|
||||
const out = downgradeUnverifiedRefutes(votes, resolve);
|
||||
expect(out[0]?.verdict).toBe('REFUTED');
|
||||
expect(out[0]?.guard_check).toBe('verified');
|
||||
});
|
||||
|
||||
it('a REFUTED whose killing_guard FAILS guardExistsInSource is downgraded to SURVIVED', () => {
|
||||
const votes = [refuted({ file: 'real.c', line: 1, quote: 'if (bogus >= nope) skip' })];
|
||||
const out = downgradeUnverifiedRefutes(votes, resolve);
|
||||
expect(out[0]?.verdict).toBe('SURVIVED');
|
||||
expect(out[0]?.original_verdict).toBe('REFUTED');
|
||||
expect(out[0]?.guard_check).toBe('unverified');
|
||||
});
|
||||
|
||||
it('a REFUTED citing a MISSING file is downgraded (resolve returns empty)', () => {
|
||||
const votes = [refuted({ file: 'ghost.c', line: 1, quote: 'if (n > cap) return' })];
|
||||
expect(downgradeUnverifiedRefutes(votes, resolve)[0]?.verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('a REFUTED with NO killing_guard cannot stand on a cite → downgraded', () => {
|
||||
expect(downgradeUnverifiedRefutes([refuted()], resolve)[0]?.verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('SURVIVED votes and nulls pass through unchanged; the check is pure (input not mutated)', () => {
|
||||
const votes: Array<RefuterVote | null> = [survived(), null];
|
||||
const out = downgradeUnverifiedRefutes(votes, resolve);
|
||||
expect(out[0]?.verdict).toBe('SURVIVED');
|
||||
expect(out[1]).toBeNull();
|
||||
// purity: the original REFUTED vote object is not mutated by the downgrade
|
||||
const original = refuted({ file: 'ghost.c', line: 1, quote: 'nope nope' });
|
||||
downgradeUnverifiedRefutes([original], resolve);
|
||||
expect(original.verdict).toBe('REFUTED');
|
||||
expect(original.original_verdict).toBeUndefined();
|
||||
});
|
||||
|
||||
it('END-TO-END: a minority-verified + majority-hallucinated panel does NOT refute after the cite-check', () => {
|
||||
// 2 hallucinated REFUTED + 1 SURVIVED: raw tally would REFUTE (2 of 3).
|
||||
const raw = [
|
||||
refuted({ file: 'real.c', line: 1, quote: 'if (madeup > 999) die' }),
|
||||
refuted({ file: 'real.c', line: 1, quote: 'if (alsofake < 0) bail' }),
|
||||
survived(),
|
||||
];
|
||||
expect(adjudicate(raw).verdict).toBe('REFUTED'); // before cite-check: wrongly refuted
|
||||
const checked = downgradeUnverifiedRefutes(raw, resolve);
|
||||
expect(adjudicate(checked).verdict).toBe('SURVIVED'); // after cite-check: real finding preserved
|
||||
expect(adjudicate(checked).refutedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Capability approval + spicy-action warning gate. Pins the safety contract:
|
||||
* - ungated tools pass straight through; gated (intrusive/credential/dangerous) do not.
|
||||
* - FAIL-SAFE: gated + no approver + not pre-approved → DENIED (unattended runs don't self-fire).
|
||||
* - "approve once, then free": the interactive approver is asked ONCE per tool, then it runs free.
|
||||
* - pre-authorization allowlist runs free (headless).
|
||||
* - spicy actions fire a NON-BLOCKING warning every time an approved call runs; everything audited.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
ApprovalController,
|
||||
isGatedRisk,
|
||||
isSpicyRisk,
|
||||
type ApprovalRequest,
|
||||
type RiskTier,
|
||||
} from '../arsenal/approval.js';
|
||||
|
||||
const req = (over: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
||||
tool: 'metasploit',
|
||||
risk: 'dangerous',
|
||||
operator: 'exploit-dev',
|
||||
target: '10.0.0.5',
|
||||
action: 'metasploit exploit/x → 10.0.0.5',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('risk classification', () => {
|
||||
it('gates only intrusive / credential / dangerous', () => {
|
||||
const gated: RiskTier[] = ['intrusive', 'credential', 'dangerous'];
|
||||
const open: RiskTier[] = ['local_read', 'passive', 'active'];
|
||||
for (const r of gated) expect(isGatedRisk(r)).toBe(true);
|
||||
for (const r of open) expect(isGatedRisk(r)).toBe(false);
|
||||
expect(isGatedRisk(undefined)).toBe(false);
|
||||
});
|
||||
it('marks credential + dangerous as spicy (loud-warn), not intrusive', () => {
|
||||
expect(isSpicyRisk('dangerous')).toBe(true);
|
||||
expect(isSpicyRisk('credential')).toBe(true);
|
||||
expect(isSpicyRisk('intrusive')).toBe(false);
|
||||
expect(isSpicyRisk('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApprovalController.gate', () => {
|
||||
it('lets an ungated (active) tool straight through, no record', async () => {
|
||||
const c = new ApprovalController();
|
||||
const d = await c.gate(req({ tool: 'nmap', risk: 'active' }));
|
||||
expect(d.allowed).toBe(true);
|
||||
expect(c.getAudit()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FAIL-SAFE: a gated tool with no approver and no allowlist is DENIED', async () => {
|
||||
const c = new ApprovalController(); // nothing wired
|
||||
const d = await c.gate(req());
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toMatch(/not authorized/);
|
||||
expect(c.getAudit()[0]?.outcome).toBe('denied-no-approver');
|
||||
});
|
||||
|
||||
it('pre-authorization allowlist runs free (headless), and audits', async () => {
|
||||
const c = new ApprovalController({ preApprovedTools: ['metasploit', 'hydra'] });
|
||||
const d = await c.gate(req());
|
||||
expect(d.allowed).toBe(true);
|
||||
expect(c.getAudit()[0]?.outcome).toBe('allowed-preapproved');
|
||||
// a tool NOT on the list is still denied
|
||||
expect((await c.gate(req({ tool: 'sqlmap', risk: 'intrusive' }))).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('interactive: approve ONCE, then free — the approver is asked exactly one time', async () => {
|
||||
const requestApproval = vi.fn(async () => true);
|
||||
const c = new ApprovalController({ requestApproval });
|
||||
expect((await c.gate(req())).allowed).toBe(true); // asks, approves
|
||||
expect((await c.gate(req())).allowed).toBe(true); // free — no second ask
|
||||
expect((await c.gate(req())).allowed).toBe(true);
|
||||
expect(requestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(c.isApproved('metasploit')).toBe(true);
|
||||
expect(c.getAudit().map((r) => r.outcome)).toEqual([
|
||||
'allowed-interactive',
|
||||
'allowed-preapproved',
|
||||
'allowed-preapproved',
|
||||
]);
|
||||
});
|
||||
|
||||
it('interactive decline denies and does NOT approve the tool', async () => {
|
||||
const requestApproval = vi.fn(async () => false);
|
||||
const c = new ApprovalController({ requestApproval });
|
||||
const d = await c.gate(req());
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toMatch(/declined/);
|
||||
expect(c.isApproved('metasploit')).toBe(false);
|
||||
expect(c.getAudit()[0]?.outcome).toBe('denied-declined');
|
||||
});
|
||||
|
||||
it('spicy actions fire a NON-BLOCKING warning every allowed run; ungated-but-approved does not', async () => {
|
||||
const onWarning = vi.fn();
|
||||
const c = new ApprovalController({ preApprovedTools: ['metasploit', 'sqlmap'], onWarning });
|
||||
await c.gate(req()); // dangerous → spicy → warns
|
||||
await c.gate(req()); // still warns on every run
|
||||
await c.gate(req({ tool: 'sqlmap', risk: 'intrusive' })); // gated but NOT spicy → no warn
|
||||
expect(onWarning).toHaveBeenCalledTimes(2);
|
||||
expect(onWarning.mock.calls[0][0].tool).toBe('metasploit');
|
||||
// the warning never blocks — all three calls were allowed
|
||||
expect(c.getAudit().every((r) => r.outcome.startsWith('allowed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('audit trail is a defensive copy carrying who/what/target/outcome/spicy/time', async () => {
|
||||
const c = new ApprovalController({ preApprovedTools: ['hydra'], now: () => 1234 });
|
||||
await c.gate(req({ tool: 'hydra', risk: 'credential', action: 'hydra ssh 10.0.0.5' }));
|
||||
const audit = c.getAudit();
|
||||
expect(audit[0]).toMatchObject({
|
||||
tool: 'hydra',
|
||||
risk: 'credential',
|
||||
operator: 'exploit-dev',
|
||||
spicy: true,
|
||||
outcome: 'allowed-preapproved',
|
||||
at: 1234,
|
||||
});
|
||||
audit[0].tool = 'MUTATED';
|
||||
expect(c.getAudit()[0].tool).toBe('hydra'); // internal trail untouched
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Arsenal.execute() enforces the capability-approval gate for gated tools, alongside the scope gate.
|
||||
* A dangerous/credential/intrusive tool must not run until approved; ungated tools are untouched; the
|
||||
* gate runs BEFORE the handler (a denied call never executes).
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS, SPICY_BUILTIN_TIERS, stampSpicyBuiltin } from '../arsenal/index.js';
|
||||
import { ApprovalController } from '../arsenal/approval.js';
|
||||
import type { CustomTool, ToolContext } from '../types/index.js';
|
||||
|
||||
const ctx = (parameters: Record<string, unknown> = {}): ToolContext => ({ parameters });
|
||||
|
||||
function probe(name: string, riskTier?: CustomTool['riskTier']): CustomTool & { ran: () => boolean } {
|
||||
let ran = false;
|
||||
const tool: CustomTool = {
|
||||
name, description: 'x', category: 'exploitation', riskTier, parameters: [],
|
||||
handler: async () => { ran = true; return { success: true, output: 'DID_RUN' }; },
|
||||
};
|
||||
return Object.assign(tool, { ran: () => ran });
|
||||
}
|
||||
|
||||
describe('Arsenal.execute() — approval gate', () => {
|
||||
it('no controller wired = gating off (backward-compat): a dangerous tool still runs', async () => {
|
||||
const ars = new Arsenal();
|
||||
const t = probe('metasploit_module', 'dangerous');
|
||||
ars.register(t);
|
||||
expect((await ars.execute('metasploit_module', ctx())).success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
});
|
||||
|
||||
it('fail-safe: a gated tool is DENIED (handler never runs) when nothing approves it', async () => {
|
||||
const ars = new Arsenal();
|
||||
const t = probe('metasploit_module', 'dangerous');
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController()); // no approver, no allowlist
|
||||
const res = await ars.execute('metasploit_module', ctx());
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/APPROVAL REQUIRED/);
|
||||
expect(t.ran()).toBe(false); // gate ran before the handler
|
||||
});
|
||||
|
||||
it('a pre-authorized gated tool runs, and a spicy call fires the warning', async () => {
|
||||
const ars = new Arsenal();
|
||||
const onWarning = vi.fn();
|
||||
const t = probe('metasploit_module', 'dangerous');
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController({ preApprovedTools: ['metasploit_module'], onWarning }));
|
||||
const res = await ars.execute('metasploit_module', ctx({ module: 'exploit/x', target: '10.0.0.5' }));
|
||||
expect(res.success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
expect(onWarning).toHaveBeenCalledTimes(1);
|
||||
expect(onWarning.mock.calls[0][0].action).toContain('metasploit_module');
|
||||
});
|
||||
|
||||
it('ZERO-REGRESSION: pre-existing built-in tools stay UNGATED — only the new opt-in arsenal is gated', () => {
|
||||
// The approval gate covers the newly-armed dangerous arsenal (metasploit/hydra + Kali+ adapters,
|
||||
// stamped by post-ex.ts / the adapter factory). Built-in tools that ran freely before the gate
|
||||
// existed must keep that behavior — they carry NO riskTier, so isGatedRisk() never fires on them.
|
||||
const all = [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS];
|
||||
const tier = (name: string): string | undefined => all.find((t) => t.name === name)?.riskTier;
|
||||
for (const n of ['sqli_scan', 'xss_scan', 'password_spray', 'hash_crack', 'dir_bruteforce']) {
|
||||
expect(tier(n), `${n} must stay ungated (zero regression)`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('an ungated tool is never gated even with a locked-down controller', async () => {
|
||||
const ars = new Arsenal();
|
||||
const t = probe('httpx_tool', 'active'); // active = ungated
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController()); // fail-safe deny for gated tools only
|
||||
expect((await ars.execute('httpx_tool', ctx())).success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opt-in built-in gating (T3MP3ST_GATE_BUILTINS) — stampSpicyBuiltin', () => {
|
||||
it('stamps ONLY the spicy built-ins with their tier, and never mutates the shared array', () => {
|
||||
// the shared BUILTIN_TOOLS stay ungated (default posture / zero regression) …
|
||||
for (const n of Object.keys(SPICY_BUILTIN_TIERS)) {
|
||||
expect(BUILTIN_TOOLS.find((t) => t.name === n)?.riskTier, `${n} shared def stays ungated`).toBeUndefined();
|
||||
}
|
||||
// … and stampSpicyBuiltin returns a tiered COPY for a spicy tool, the tool unchanged otherwise.
|
||||
for (const t of BUILTIN_TOOLS) {
|
||||
const stamped = stampSpicyBuiltin(t);
|
||||
const wantTier = SPICY_BUILTIN_TIERS[t.name];
|
||||
if (wantTier) {
|
||||
expect(stamped.riskTier).toBe(wantTier);
|
||||
expect(stamped).not.toBe(t); // fresh object — original untouched
|
||||
expect(t.riskTier).toBeUndefined();
|
||||
} else {
|
||||
expect(stamped).toBe(t); // ungated tools pass through by reference
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a stamped spicy built-in IS fenced by the same fail-safe gate (password_spray → denied unattended)', async () => {
|
||||
const spray = BUILTIN_TOOLS.find((t) => t.name === 'password_spray');
|
||||
expect(spray, 'password_spray must exist in the arsenal').toBeTruthy();
|
||||
const gated = stampSpicyBuiltin(spray as CustomTool);
|
||||
expect(gated.riskTier).toBe('credential');
|
||||
const ars = new Arsenal();
|
||||
ars.register(gated);
|
||||
ars.setApprovalController(new ApprovalController()); // headless, no approver, no allowlist
|
||||
const res = await ars.execute('password_spray', ctx({ url: 'https://target.example.com/login' }));
|
||||
expect(res.success).toBe(false); // fail-safe deny — the opt-in gate now covers it
|
||||
expect(res.error).toMatch(/APPROVAL REQUIRED/);
|
||||
});
|
||||
|
||||
it('a stamped spicy built-in RUNS once pre-approved (approve-once / allowlist)', async () => {
|
||||
const t = probe('password_spray', 'credential'); // as stampSpicyBuiltin would produce
|
||||
const ars = new Arsenal();
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController({ preApprovedTools: ['password_spray'] }));
|
||||
expect((await ars.execute('password_spray', ctx({ url: 'https://target.example.com/login' }))).success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Egress scope gate — Phase-0 pack-hunt safety primitive.
|
||||
* arsenal.execute() must refuse a networked tool call whose target is outside the authorized
|
||||
* scope BEFORE the handler runs, so an LLM-supplied target can never reach an off-target host.
|
||||
* Pins: scopeViolation host math, and that execute() denies out-of-scope without invoking the handler.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Arsenal, scopeViolation, hostFromTargetValue, type ArsenalScope } from '../arsenal/index.js';
|
||||
import type { CustomTool, ToolContext } from '../types/index.js';
|
||||
|
||||
const scope: ArsenalScope = { allowedHosts: ['target.example.com'], allowLoopback: true, allowPrivate: true };
|
||||
const ctx = (parameters: Record<string, unknown>, target?: string): ToolContext =>
|
||||
({ parameters, ...(target ? { target: { id: 't', name: 't', type: 'web_application', address: target } as never } : {}) });
|
||||
|
||||
describe('egress scope gate', () => {
|
||||
it('extracts a bare host from urls / user@host:port / brackets', () => {
|
||||
expect(hostFromTargetValue('https://EVIL.com/admin?x=1')).toBe('evil.com');
|
||||
expect(hostFromTargetValue('root@10.0.0.5:22')).toBe('10.0.0.5');
|
||||
expect(hostFromTargetValue('scanme.example.com:443')).toBe('scanme.example.com');
|
||||
expect(hostFromTargetValue('not a host, just text')).toBe('not a host, just text'); // still lowered; scope decides
|
||||
expect(hostFromTargetValue(42)).toBeNull();
|
||||
});
|
||||
|
||||
it('allows authorized host + its subdomains, loopback, and private ranges', () => {
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://target.example.com/x' }))).toBeNull();
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://api.target.example.com/x' }))).toBeNull(); // subdomain
|
||||
expect(scopeViolation(scope, ctx({ host: '127.0.0.1' }))).toBeNull();
|
||||
expect(scopeViolation(scope, ctx({ target: '10.1.2.3' }))).toBeNull();
|
||||
expect(scopeViolation(scope, ctx({ domain: 'localhost' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks an out-of-scope host and reports it', () => {
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://evil.com/x' }))).toBe('evil.com');
|
||||
expect(scopeViolation(scope, ctx({ target: '8.8.8.8' }))).toBe('8.8.8.8');
|
||||
// checks context.target.address too
|
||||
expect(scopeViolation(scope, ctx({}, 'http://attacker.net'))).toBe('attacker.net');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED on a network-shaped but unparseable target (//host, file://, scheme) — no fail-open', () => {
|
||||
// these host-normalize to '' — must be treated as OUT of scope, not "no host = allowed"
|
||||
expect(scopeViolation(scope, ctx({ url: '//evil.com' }))).toBe('//evil.com');
|
||||
expect(scopeViolation(scope, ctx({ target: 'file:///etc/passwd' }))).toBe('file:///etc/passwd');
|
||||
expect(scopeViolation(scope, ctx({ rhosts: '//9.9.9.9' }))).toBe('//9.9.9.9'); // rhosts now covered too
|
||||
});
|
||||
|
||||
it('ZERO-REGRESSION: a plain absolute local path is NOT blocked (local-file tools run under a scope)', () => {
|
||||
// a path has no host and is not a network target — semgrep/trivy/slither scanning /code must pass
|
||||
expect(scopeViolation(scope, ctx({ target: '/code/src/app.py' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('validates a CIDR TARGET range, not just its slash-stripped base (mask-strip escape)', () => {
|
||||
// base 10.0.0.0 is private, but /0 would sweep the whole internet — block on the RANGE
|
||||
expect(scopeViolation(scope, ctx({ target: '10.0.0.0/0' }))).toBe('10.0.0.0/0');
|
||||
expect(scopeViolation(scope, ctx({ target: '192.168.0.0/8' }))).toBe('192.168.0.0/8'); // /8 base 192.168 spills out
|
||||
// an in-block private CIDR is still allowed (zero regression for legit internal scans)
|
||||
expect(scopeViolation(scope, ctx({ rhosts: '10.0.0.0/8' }))).toBeNull(); // exactly 10/8
|
||||
expect(scopeViolation(scope, ctx({ target: '10.1.2.0/24' }))).toBeNull(); // within 10/8
|
||||
// a URL path segment ending in /digits is NOT a CIDR mask
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://target.example.com/api/12' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('a CIDR of an exactly-allowed host beyond /32 is out of scope', () => {
|
||||
const s2: ArsenalScope = { allowedHosts: ['203.0.113.10'], allowLoopback: false, allowPrivate: false };
|
||||
expect(scopeViolation(s2, ctx({ target: '203.0.113.10/8' }))).toBe('203.0.113.10/8');
|
||||
expect(scopeViolation(s2, ctx({ target: '203.0.113.10' }))).toBeNull(); // the host itself is fine
|
||||
});
|
||||
|
||||
it('no scope set = enforcement off (backward-compat)', () => {
|
||||
expect(scopeViolation(null, ctx({ url: 'https://evil.com' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('a tool with no target-like param is never gated', () => {
|
||||
expect(scopeViolation(scope, ctx({ hash: 'deadbeef', text: 'hello' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('execute() refuses an out-of-scope call WITHOUT running the handler', async () => {
|
||||
const ars = new Arsenal();
|
||||
let ran = false;
|
||||
const probe: CustomTool = {
|
||||
name: 'probe', description: 'x', category: 'web', parameters: [],
|
||||
handler: async () => { ran = true; return { success: true, output: 'hit' }; },
|
||||
};
|
||||
ars.register(probe);
|
||||
ars.setScope(scope);
|
||||
const denied = await ars.execute('probe', ctx({ url: 'https://evil.com' }));
|
||||
expect(ran).toBe(false);
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.error).toMatch(/SCOPE DENIED/);
|
||||
// in-scope call runs the handler
|
||||
const ok = await ars.execute('probe', ctx({ url: 'https://target.example.com' }));
|
||||
expect(ran).toBe(true);
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Operator role toolkits — the per-operator tool allowlist (the swarm's specialization layer).
|
||||
* Each archetype's `defaultTools` is enforced at the AgentLoop via the arsenal name-allowlist
|
||||
* (getToolDefinitions(_, names)). Pins the four properties: real tools only, the gate exposes
|
||||
* exactly the toolkit, each operator has BROAD coverage, the swarm collectively covers every
|
||||
* tool, and overlap (same tool on several operators) is allowed.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS } from '../arsenal/index.js';
|
||||
import { ARCHETYPE_PROFILES } from '../operators/index.js';
|
||||
|
||||
describe('Operator role toolkits — specialized · broad · full-coverage · overlap-OK', () => {
|
||||
const arsenal = new Arsenal();
|
||||
arsenal.registerMany(BUILTIN_TOOLS); // same population the mission does (src/index.ts)
|
||||
arsenal.registerMany(EXTERNAL_TOOLS);
|
||||
const allNames = arsenal.getToolDefinitions().map(t => t.name);
|
||||
const archetypes = Object.keys(ARCHETYPE_PROFILES) as (keyof typeof ARCHETYPE_PROFILES)[];
|
||||
|
||||
it('every operator toolkit contains only REAL arsenal tools (no phantoms)', () => {
|
||||
for (const a of archetypes) {
|
||||
const phantom = ARCHETYPE_PROFILES[a].defaultTools.filter(t => !allNames.includes(t));
|
||||
expect(phantom, `${a} references non-existent tools`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('the name-allowlist gate exposes EXACTLY the operator toolkit', () => {
|
||||
for (const a of archetypes) {
|
||||
const kit = ARCHETYPE_PROFILES[a].defaultTools;
|
||||
const exposed = arsenal.getToolDefinitions(undefined, kit).map(t => t.name).sort();
|
||||
expect(exposed, `${a} exposed tool set`).toEqual([...new Set(kit)].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it('each operator has BROAD coverage (>= 12 tools)', () => {
|
||||
for (const a of archetypes) {
|
||||
expect(ARCHETYPE_PROFILES[a].defaultTools.length, `${a} toolkit size`).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
|
||||
it('the swarm collectively covers EVERY arsenal tool', () => {
|
||||
const covered = new Set(archetypes.flatMap(a => ARCHETYPE_PROFILES[a].defaultTools));
|
||||
const uncovered = allNames.filter(n => !covered.has(n));
|
||||
expect(uncovered, 'tools no operator can reach').toEqual([]);
|
||||
});
|
||||
|
||||
it('overlap is allowed — generalist tools appear on multiple operators', () => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const a of archetypes) for (const t of ARCHETYPE_PROFILES[a].defaultTools) counts[t] = (counts[t] || 0) + 1;
|
||||
expect(counts['http_request'], 'http_request should be broadly shared').toBeGreaterThan(1);
|
||||
expect(counts['technology_detect'], 'technology_detect should be broadly shared').toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* PackBoard — pins the load-bearing invariants of the Phase-2 shared workspace
|
||||
* (the pack-hunt design + the red-team §2):
|
||||
* - de-dup collapses a duplicate post into an ENDORSEMENT that raises smoke (HT-1)
|
||||
* - claim() is an ATOMIC compare-and-set: two calls, same expectedVersion → exactly one grant (HT-1)
|
||||
* - an expired lease is reclaimable (the reaper backstop)
|
||||
* - refute() does NOT flip status by itself (HT-2)
|
||||
* - situationReport() is BOUNDED — never dumps the raw board, even with many leads
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PackBoard, dedupKey } from '../pack/board.js';
|
||||
import type { LeadInput } from '../pack/board.js';
|
||||
|
||||
// A deterministic, hand-cranked clock so lease/liveness math is exact in tests.
|
||||
function fakeClock(start = 1_000_000) {
|
||||
let t = start;
|
||||
const now = () => t;
|
||||
return { now, set: (v: number) => { t = v; }, advance: (dt: number) => { t += dt; } };
|
||||
}
|
||||
|
||||
const leadInput = (over: Partial<LeadInput> = {}): LeadInput => ({
|
||||
kind: 'lead',
|
||||
title: 'command injection in exec sink',
|
||||
where: { file: 'src/router.js', line: 42 },
|
||||
vulnClass: 'injection',
|
||||
confidence: 'high',
|
||||
provenance: 'tool',
|
||||
cwe: 'CWE-77',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('dedupKey — reuses cli-swarm formula', () => {
|
||||
it('near lines collapse, far lines are distinct (line bucket of 15)', () => {
|
||||
expect(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 100 }))
|
||||
.toBe(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 103 }));
|
||||
expect(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 100 }))
|
||||
.not.toBe(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 580 }));
|
||||
});
|
||||
|
||||
it('distinct vuln classes at the same file:line-bucket do NOT collide (H11)', () => {
|
||||
// no CWE to disambiguate — vulnClass must keep an XSS and an SQLi a line apart distinct
|
||||
expect(dedupKey({ file: 'app.js', line: 10, vulnClass: 'xss' }))
|
||||
.not.toBe(dedupKey({ file: 'app.js', line: 12, vulnClass: 'sqli' }));
|
||||
});
|
||||
|
||||
it('coordinate-less url leads key on the surface, not one shared empty bucket (H13)', () => {
|
||||
expect(dedupKey({ url: 'https://x/a', vulnClass: 'ssrf' }))
|
||||
.not.toBe(dedupKey({ url: 'https://x/b', vulnClass: 'ssrf' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PackBoard — reference/evidence hardening (H12/H15/M5)', () => {
|
||||
it('getLead returns a SNAPSHOT — mutating it cannot corrupt the CAS version/status/claim', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('a', leadInput());
|
||||
const snap = board.getLead(lead.id)!;
|
||||
snap.version = 9999; // caller pokes the returned object
|
||||
snap.status = 'confirmed';
|
||||
snap.claim = { by: 'x', leaseUntil: 9e15, version: 9999 };
|
||||
const fresh = board.getLead(lead.id)!;
|
||||
expect(fresh.version).toBe(lead.version); // internal state untouched
|
||||
expect(fresh.status).toBe('open');
|
||||
expect(fresh.claim).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a duplicate with STRONGER provenance/severity monotonically upgrades the card (never downgrades)', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const weak = board.postLead('a', leadInput({ provenance: 'context', severity: 'low' }));
|
||||
board.postLead('b', leadInput({ provenance: 'tool', severity: 'high' })); // same dedupKey, stronger
|
||||
const up = board.getLead(weak.id)!;
|
||||
expect(up.provenance).toBe('tool');
|
||||
expect(up.severity).toBe('high');
|
||||
board.postLead('c', leadInput({ provenance: 'none', severity: 'info' })); // weaker dup
|
||||
const still = board.getLead(weak.id)!;
|
||||
expect(still.provenance).toBe('tool'); // not downgraded
|
||||
expect(still.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('EVENT payloads are snapshots too — a subscriber mutating a lead:claimed payload cannot corrupt the CAS', () => {
|
||||
// the outbound accessors were hardened; the emit paths must match (7th-pass finding) or a
|
||||
// synchronous SSE/controller subscriber could flip status / reset the version / forge a lease.
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const posted = board.postLead('a', leadInput());
|
||||
board.on('lead:claimed', ({ lead }) => { lead.status = 'open'; lead.version = 0; delete lead.claim; });
|
||||
board.claim(posted.id, 'agentA', posted.version);
|
||||
const after = board.getLead(posted.id)!;
|
||||
expect(after.status).toBe('claimed'); // not reverted to open by the subscriber
|
||||
expect(after.version).toBe(1); // CAS token intact
|
||||
expect(after.claim?.by).toBe('agentA'); // lease intact
|
||||
});
|
||||
|
||||
it('INGEST clones `where` — mutating the caller-retained object after postLead cannot re-point the card', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const w = { file: 'safe.js', line: 10 };
|
||||
const snap = board.postLead('a', leadInput({ where: w }));
|
||||
w.file = 'attacker.js';
|
||||
(w as unknown as { url: string }).url = 'http://evil';
|
||||
const after = board.getLead(snap.id)!;
|
||||
expect(after.where.file).toBe('safe.js'); // internal projection immune to post-hoc mutation
|
||||
expect((after.where as { url?: string }).url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('postLead — de-dup collapses a duplicate into an endorsement (raises smoke)', () => {
|
||||
it('a second post with the same dedupKey does NOT create a new lead; it endorses and raises smoke', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const a = board.postLead('agent-a', leadInput());
|
||||
expect(a.smoke).toBe(1);
|
||||
expect(a.endorsements).toHaveLength(0);
|
||||
|
||||
// same file, a nearby line (same bucket), same CWE → same dedupKey → duplicate
|
||||
const b = board.postLead('agent-b', leadInput({ title: 'dup from B', where: { file: 'src/router.js', line: 45 } }));
|
||||
|
||||
expect(board.getAllLeads()).toHaveLength(1); // no second card
|
||||
expect(b.id).toBe(a.id); // returned the SAME lead
|
||||
expect(b.smoke).toBe(2); // smoke rose
|
||||
expect(b.endorsements).toHaveLength(1);
|
||||
expect(b.endorsements[0].by).toBe('agent-b');
|
||||
});
|
||||
|
||||
it('a genuinely different lead (different dedupKey) is a new card', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
board.postLead('agent-a', leadInput());
|
||||
board.postLead('agent-a', leadInput({ where: { file: 'src/other.js', line: 9 }, cwe: 'CWE-22' }));
|
||||
expect(board.getAllLeads()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claim — a real synchronous compare-and-set (atomic)', () => {
|
||||
it('two claims with the SAME expectedVersion → exactly one granted', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
const v = lead.version; // both racers see the same version
|
||||
|
||||
const first = board.claim(lead.id, 'agent-1', v);
|
||||
const second = board.claim(lead.id, 'agent-2', v);
|
||||
|
||||
const grants = [first.granted, second.granted].filter(Boolean);
|
||||
expect(grants).toHaveLength(1); // exactly one winner
|
||||
expect(first.granted).toBe(true); // FIFO on the single-threaded loop: first wins
|
||||
expect(second.granted).toBe(false); // stale expectedVersion (and lease held) → denied
|
||||
|
||||
const held = board.getLead(lead.id);
|
||||
expect(held?.claim?.by).toBe('agent-1');
|
||||
expect(held?.status).toBe('claimed');
|
||||
});
|
||||
|
||||
it('a stale expectedVersion is rejected and returns the current version', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
board.endorse('x', lead.id); // bumps version
|
||||
const res = board.claim(lead.id, 'agent-1', 0); // expected the pre-endorse version
|
||||
expect(res.granted).toBe(false);
|
||||
expect(res.version).toBe(board.getLead(lead.id)?.version);
|
||||
});
|
||||
|
||||
it('a quorum-terminal lead (refuted/confirmed/dead) is NOT claimable — sticky refutation (HT-2)', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
board.setLeadStatus('quorum', lead.id, 'refuted'); // the quorum killed it
|
||||
const res = board.claim(lead.id, 'agent-1', board.getLead(lead.id)!.version);
|
||||
expect(res.granted).toBe(false); // cannot be re-claimed back into active work
|
||||
expect(board.getLead(lead.id)?.status).toBe('refuted'); // not resurrected to 'claimed'
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseExpiredClaims — an expired lease is reclaimable', () => {
|
||||
it('reaps a lease past its leaseUntil and returns the lead to open, then another agent can claim it', () => {
|
||||
const clock = fakeClock();
|
||||
const board = new PackBoard({}, clock.now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
|
||||
const c = board.claim(lead.id, 'agent-1', lead.version, 1000);
|
||||
expect(c.granted).toBe(true);
|
||||
|
||||
// before expiry: agent-2 cannot claim (live lease held)
|
||||
expect(board.claim(lead.id, 'agent-2', c.version).granted).toBe(false);
|
||||
|
||||
clock.advance(1001); // lease window elapses
|
||||
const released = board.releaseExpiredClaims();
|
||||
expect(released).toContain(lead.id);
|
||||
|
||||
const reopened = board.getLead(lead.id);
|
||||
expect(reopened?.claim).toBeUndefined();
|
||||
expect(reopened?.status).toBe('open');
|
||||
|
||||
// now agent-2 CAN claim it with the current version
|
||||
const retry = board.claim(lead.id, 'agent-2', reopened?.version ?? -1);
|
||||
expect(retry.granted).toBe(true);
|
||||
expect(board.getLead(lead.id)?.claim?.by).toBe('agent-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refute — does NOT flip status by itself (HT-2)', () => {
|
||||
it('appends a refutation vote but leaves status unchanged; only the quorum flips it', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
expect(lead.status).toBe('open');
|
||||
|
||||
const r = board.refute('auditor', lead.id, 'claims a startsWith guard', 'src/router.js:12');
|
||||
expect(r?.refutations).toHaveLength(1);
|
||||
expect(r?.refutations[0].guard).toBe('src/router.js:12');
|
||||
expect(r?.status).toBe('open'); // STILL open — refute() is a signal, not a verdict
|
||||
|
||||
// even many refutations don't flip it
|
||||
board.refute('auditor-2', lead.id, 'agrees');
|
||||
board.refute('auditor-3', lead.id, 'agrees');
|
||||
expect(board.getLead(lead.id)?.status).toBe('open');
|
||||
|
||||
// only the explicit quorum path changes status
|
||||
board.setLeadStatus('quorum', lead.id, 'refuted');
|
||||
expect(board.getLead(lead.id)?.status).toBe('refuted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('situationReport — bounded, never dumps the raw board', () => {
|
||||
it('stays under the char cap even with far more leads than the cap could hold verbatim', () => {
|
||||
const cap = 1200;
|
||||
const board = new PackBoard({ reportCharCap: cap, defaultMaxLeads: 8 }, fakeClock().now);
|
||||
|
||||
// flood the board with 500 distinct leads (distinct files → distinct dedupKeys)
|
||||
for (let i = 0; i < 500; i++) {
|
||||
board.postLead(`agent-${i % 7}`, leadInput({
|
||||
title: `finding number ${i} with a deliberately long descriptive title `.repeat(3),
|
||||
where: { file: `src/module_${i}.js`, line: (i * 31) % 400 },
|
||||
cwe: `CWE-${i % 90}`,
|
||||
}));
|
||||
}
|
||||
for (let i = 0; i < 20; i++) board.heartbeat(`agent-${i}`, 'hunting', 'x'.repeat(50));
|
||||
|
||||
const report = board.situationReport('agent-0', { maxLeads: 8 });
|
||||
expect(report.length).toBeLessThanOrEqual(cap);
|
||||
expect(report).toContain('PACK BOARD');
|
||||
});
|
||||
|
||||
it('ranks unclaimed leads by smoke and excludes leads claimed by others / includes your own', () => {
|
||||
const clock = fakeClock();
|
||||
const board = new PackBoard({ reportCharCap: 4000 }, clock.now);
|
||||
|
||||
const hot = board.postLead('a', leadInput({ title: 'HOT lead', where: { file: 'src/hot.js', line: 1 }, cwe: 'CWE-1' }));
|
||||
board.endorse('b', hot.id);
|
||||
board.endorse('c', hot.id); // smoke = 3
|
||||
|
||||
const cold = board.postLead('a', leadInput({ title: 'cold lead', where: { file: 'src/cold.js', line: 1 }, cwe: 'CWE-2' }));
|
||||
|
||||
// a lead claimed by SOMEONE ELSE should not appear in the open-leads section
|
||||
const other = board.postLead('a', leadInput({ title: 'owned by z', where: { file: 'src/z.js', line: 1 }, cwe: 'CWE-3' }));
|
||||
board.claim(other.id, 'agent-z', other.version);
|
||||
|
||||
// a lead the reader claims → should show under YOUR CLAIMS
|
||||
const own = board.postLead('a', leadInput({ title: 'my own work', where: { file: 'src/mine.js', line: 1 }, cwe: 'CWE-4' }));
|
||||
board.claim(own.id, 'reader', own.version);
|
||||
|
||||
board.heartbeat('reader', 'hunting');
|
||||
const report = board.situationReport('reader', { maxLeads: 8 });
|
||||
|
||||
expect(report).toContain('HOT lead');
|
||||
expect(report).toContain('cold lead');
|
||||
expect(report.indexOf('HOT lead')).toBeLessThan(report.indexOf('cold lead')); // smoke ranking
|
||||
expect(report).not.toContain('owned by z'); // claimed by another agent → hidden from open leads
|
||||
expect(report).toContain('YOUR CLAIMS');
|
||||
expect(report).toContain('my own work');
|
||||
expect(cold.smoke).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Rate-limit / degraded-health + LLM semaphore — Phase-0 pack-hunt safety primitive (HT-3).
|
||||
* A rate-limited slot must NEVER read as "clean / found-nothing", and a single global semaphore
|
||||
* must cap ALL local-agent calls. Pins: isRateLimited on real quota strings, the 0.5 degraded
|
||||
* threshold, and that the Semaphore never exceeds max concurrent holders.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isRateLimited, DegradedTracker, Semaphore } from '../pack/health.js';
|
||||
|
||||
describe('isRateLimited', () => {
|
||||
it('catches real usage-limit / 429 / quota signatures', () => {
|
||||
// real Claude/Anthropic banner
|
||||
expect(isRateLimited("ERROR: You've hit your usage limit. try again at 12:10 PM")).toBe(true);
|
||||
expect(isRateLimited('Claude: you have hit your usage limit')).toBe(true);
|
||||
expect(isRateLimited('429 Too Many Requests')).toBe(true);
|
||||
expect(isRateLimited('HTTP 429')).toBe(true);
|
||||
expect(isRateLimited('rate limit reached for this model')).toBe(true);
|
||||
expect(isRateLimited('rate-limited')).toBe(true);
|
||||
expect(isRateLimited('quota exceeded')).toBe(true);
|
||||
expect(isRateLimited('{"error":{"code":"insufficient_quota"}}')).toBe(true);
|
||||
expect(isRateLimited('Too Many Requests')).toBe(true);
|
||||
});
|
||||
|
||||
it('catches provider-specific throttle signals the original regex missed', () => {
|
||||
expect(isRateLimited('Error: Overloaded (overloaded_error)')).toBe(true); // Anthropic 529
|
||||
expect(isRateLimited('HTTP 529: overloaded')).toBe(true);
|
||||
expect(isRateLimited('TooManyRequestsException: Rate exceeded')).toBe(true); // AWS Bedrock
|
||||
expect(isRateLimited('grpc status RESOURCE_EXHAUSTED')).toBe(true); // gRPC / Vertex
|
||||
expect(isRateLimited('You exceeded your current quota, please check your plan')).toBe(true); // OpenAI reversed
|
||||
expect(isRateLimited('request was throttled, retry-after 30s')).toBe(true);
|
||||
expect(isRateLimited('503 Service Unavailable')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT false-positive on a bare integer that merely looks like a status code', () => {
|
||||
expect(isRateLimited('see line 429 of the file')).toBe(false);
|
||||
expect(isRateLimited('CWE-529: exposure of sensitive information')).toBe(false);
|
||||
expect(isRateLimited('found 4290 candidate sinks')).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT false-positive on benign prose that incidentally contains throttle vocabulary', () => {
|
||||
// the signals must be the SPECIFIC provider tokens, not bare words in finding narratives
|
||||
expect(isRateLimited('resource exhausted state in the parser')).toBe(false); // not RESOURCE_EXHAUSTED
|
||||
expect(isRateLimited('a note about an overloaded operator')).toBe(false); // not overloaded_error
|
||||
expect(isRateLimited('the quota management module')).toBe(false); // not "quota exceeded"
|
||||
expect(isRateLimited('capacity planning document')).toBe(false); // not "at capacity"
|
||||
expect(isRateLimited('opened the throttle valve')).toBe(false); // "throttle" (noun) != "throttled"/"throttling"
|
||||
expect(isRateLimited('disk quota for the container')).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT flag a rate-limit / DoS FINDING body as a throttled slot (finding vs throttle)', () => {
|
||||
// a pack agent's OWN result describing a rate-limit/DoS vuln must read as a FINDING, not degraded
|
||||
expect(isRateLimited('Found: rate limiting is MISSING on /login (CWE-307)')).toBe(false);
|
||||
expect(isRateLimited('no rate-limit on password reset — brute force possible')).toBe(false);
|
||||
expect(isRateLimited('CWE-400: uncontrolled resource consumption leads to service unavailable')).toBe(false);
|
||||
expect(isRateLimited('recommend: slow down the attacker via a captcha')).toBe(false);
|
||||
expect(isRateLimited('concurrent request handling bug (race)')).toBe(false);
|
||||
expect(isRateLimited('the endpoint enforces no usage limit at all')).toBe(false);
|
||||
// but a limit actually being HIT (a genuine throttle) is still caught
|
||||
expect(isRateLimited('rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimited('rate-limited')).toBe(true);
|
||||
});
|
||||
|
||||
it('still catches the canonical provider throttles the tightening must not lose (8th-pass regression)', () => {
|
||||
expect(isRateLimited('{"type":"error","error":{"type":"rate_limit_error"}}')).toBe(true); // Anthropic 429 body
|
||||
expect(isRateLimited('exceeded your per-minute rate limit')).toBe(true); // qualifier between verb and "rate limit"
|
||||
expect(isRateLimited('usage limit reached')).toBe(true); // Claude subscription banner (verb after)
|
||||
expect(isRateLimited("You've reached your usage limit")).toBe(true); // verb before
|
||||
expect(isRateLimited('Message limit reached')).toBe(true);
|
||||
// and the finding-vs-throttle discrimination still holds
|
||||
expect(isRateLimited('the endpoint enforces no usage limit at all')).toBe(false);
|
||||
expect(isRateLimited('rate limiting is MISSING on /login')).toBe(false);
|
||||
});
|
||||
|
||||
it('catches the provider throttles the tightening MUST NOT lose (9th-pass false-negatives)', () => {
|
||||
expect(isRateLimited('You have exceeded a secondary rate limit')).toBe(true); // GitHub/GitLab
|
||||
expect(isRateLimited('ServiceQuotaExceededException: rate exceeded')).toBe(true); // AWS Bedrock
|
||||
expect(isRateLimited('The engine is currently overloaded, please try again')).toBe(true); // OpenAI/Anthropic 529 prose
|
||||
expect(isRateLimited('Overloaded')).toBe(true); // Anthropic 529 bare body
|
||||
expect(isRateLimited('grpc error: Resource has been exhausted (e.g. check quota)')).toBe(true); // gRPC/Vertex prose
|
||||
// still NOT a false-positive on benign code-review vocabulary
|
||||
expect(isRateLimited('a note about an overloaded operator')).toBe(false);
|
||||
expect(isRateLimited('resource exhausted state in the parser')).toBe(false);
|
||||
});
|
||||
|
||||
it('catches the underscore 429 enum and future-tense throttle bodies (11th-pass false-negatives)', () => {
|
||||
expect(isRateLimited('{"reason":"too_many_requests"}')).toBe(true); // HTTP 429 reason enum
|
||||
expect(isRateLimited('TOO_MANY_REQUESTS')).toBe(true);
|
||||
expect(isRateLimited("This request would exceed your organization's rate limit")).toBe(true); // Anthropic future-tense
|
||||
expect(isRateLimited('you will exceed your per-minute rate limit')).toBe(true);
|
||||
// and still not a finding body
|
||||
expect(isRateLimited('rate limiting is MISSING on /login')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores normal findings output and empty/undefined text', () => {
|
||||
expect(isRateLimited('[{"title":"x","file":"a.py","line":5,"cwe":"CWE-22"}]')).toBe(false);
|
||||
expect(isRateLimited('Clean — nothing exploitable in my specialty')).toBe(false);
|
||||
expect(isRateLimited('')).toBe(false);
|
||||
// guards against undefined leaking in despite the string type (matches cli-swarm's String(x||'') coercion)
|
||||
expect(isRateLimited(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DegradedTracker — 0.5 threshold', () => {
|
||||
it('is OK when all calls succeed', () => {
|
||||
const t = new DegradedTracker();
|
||||
t.record(true);
|
||||
t.record(true);
|
||||
t.record(true);
|
||||
expect(t.calls).toBe(3);
|
||||
expect(t.fails).toBe(0);
|
||||
expect(t.verdict()).toBe('OK');
|
||||
});
|
||||
|
||||
it('stays OK just below the threshold (2 of 5 failed = 0.4)', () => {
|
||||
const t = new DegradedTracker();
|
||||
[true, true, true, false, false].forEach((ok) => t.record(ok));
|
||||
expect(t.failRatio()).toBeCloseTo(0.4);
|
||||
expect(t.verdict()).toBe('OK');
|
||||
});
|
||||
|
||||
it('flips to INVALID_DEGRADED exactly AT the 0.5 threshold (2 of 4 failed)', () => {
|
||||
const t = new DegradedTracker();
|
||||
[true, true, false, false].forEach((ok) => t.record(ok));
|
||||
expect(t.failRatio()).toBe(0.5);
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
});
|
||||
|
||||
it('is INVALID_DEGRADED when failures dominate', () => {
|
||||
const t = new DegradedTracker();
|
||||
[false, false, false, true].forEach((ok) => t.record(ok));
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
});
|
||||
|
||||
it('a rate-limited slot poisons the verdict — never clean', () => {
|
||||
const t = new DegradedTracker();
|
||||
// two slots came back "successfully" but one is actually a quota banner
|
||||
t.recordResult(true, 'Clean — no findings');
|
||||
t.recordResult(true, "You've hit your usage limit");
|
||||
// the quota banner is booked as a failure => 1/2 = 0.5 => degraded
|
||||
expect(t.fails).toBe(1);
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
});
|
||||
|
||||
it('an empty run (no calls) is INVALID_DEGRADED, not clean', () => {
|
||||
const t = new DegradedTracker();
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
expect(t.failRatio()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Semaphore', () => {
|
||||
it('rejects a non-positive max', () => {
|
||||
expect(() => new Semaphore(0)).toThrow(RangeError);
|
||||
expect(() => new Semaphore(-1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('never exceeds max concurrent holders under a burst', async () => {
|
||||
const MAX = 3;
|
||||
const N = 40;
|
||||
const sem = new Semaphore(MAX);
|
||||
let live = 0;
|
||||
let peak = 0;
|
||||
|
||||
const task = async (): Promise<void> => {
|
||||
const release = await sem.acquire();
|
||||
live += 1;
|
||||
peak = Math.max(peak, live);
|
||||
expect(live).toBeLessThanOrEqual(MAX); // invariant checked WHILE holding
|
||||
// yield across a macrotask so overlap is real, not artificial
|
||||
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 5)));
|
||||
live -= 1;
|
||||
release();
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: N }, task));
|
||||
expect(peak).toBe(MAX); // saturates (N >> MAX) but never over
|
||||
expect(live).toBe(0);
|
||||
expect(sem.inFlight).toBe(0);
|
||||
expect(sem.queued).toBe(0);
|
||||
});
|
||||
|
||||
it('serializes fully at max=1', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
let live = 0;
|
||||
let peak = 0;
|
||||
const task = async (): Promise<void> => {
|
||||
const release = await sem.acquire();
|
||||
live += 1;
|
||||
peak = Math.max(peak, live);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
live -= 1;
|
||||
release();
|
||||
};
|
||||
await Promise.all([task(), task(), task(), task()]);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it('serves waiters FIFO', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
const order: number[] = [];
|
||||
const first = await sem.acquire(); // occupy the only slot
|
||||
// three callers queue up in order
|
||||
const p2 = sem.acquire().then((rel) => { order.push(2); rel(); });
|
||||
const p3 = sem.acquire().then((rel) => { order.push(3); rel(); });
|
||||
const p4 = sem.acquire().then((rel) => { order.push(4); rel(); });
|
||||
expect(sem.queued).toBe(3);
|
||||
first(); // release => drains in enqueue order
|
||||
await Promise.all([p2, p3, p4]);
|
||||
expect(order).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it('double-release is a no-op (cannot inflate the pool past max)', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
const release = await sem.acquire();
|
||||
release();
|
||||
release(); // second call must NOT free a phantom extra slot
|
||||
|
||||
let live = 0;
|
||||
let peak = 0;
|
||||
const task = async (): Promise<void> => {
|
||||
const rel = await sem.acquire();
|
||||
live += 1;
|
||||
peak = Math.max(peak, live);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
live -= 1;
|
||||
rel();
|
||||
};
|
||||
await Promise.all([task(), task(), task()]);
|
||||
expect(peak).toBe(1); // still fully serialized
|
||||
});
|
||||
|
||||
it('run() releases even when the body throws', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
await expect(sem.run(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
||||
expect(sem.inFlight).toBe(0);
|
||||
// slot is free again — a subsequent acquire resolves immediately
|
||||
const release = await sem.acquire();
|
||||
expect(sem.inFlight).toBe(1);
|
||||
release();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Purpose-built post-ex drivers (metasploit, hydra). Pins the load-bearing safety contract:
|
||||
* - each carries its riskTier (dangerous / credential) so the approval gate fences it.
|
||||
* - a missing binary DEGRADES (does not throw).
|
||||
* - the target is scope-checked AND dash-guarded before anything spawns.
|
||||
* - metasploit refuses msf-command injection (;, newline, quotes) in module/options.
|
||||
* - the happy path builds the expected argv; hydra caps its task rate.
|
||||
* All deps are injected fakes — no real msfconsole/hydra is spawned.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createMetasploitTool, createHydraTool, buildPostExTools } from '../arsenal/post-ex.js';
|
||||
import type { AdapterToolDeps, SubprocessResult } from '../arsenal/adapter-tools.js';
|
||||
import type { ToolContext } from '../types/index.js';
|
||||
|
||||
function makeDeps(overrides: Partial<AdapterToolDeps> = {}): AdapterToolDeps & { spawns: Array<{ cmd: string; args: string[] }> } {
|
||||
const spawns: Array<{ cmd: string; args: string[] }> = [];
|
||||
return {
|
||||
spawns,
|
||||
isToolAvailable: async () => true,
|
||||
runSubprocess: async (cmd, args): Promise<SubprocessResult> => {
|
||||
spawns.push({ cmd, args });
|
||||
return { stdout: 'FAKE', stderr: '', exitCode: 0 };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const ctx = (parameters: Record<string, unknown>): ToolContext => ({ parameters });
|
||||
|
||||
describe('metasploit_module driver', () => {
|
||||
it('is a dangerous, approval-gated tool', () => {
|
||||
expect(createMetasploitTool(makeDeps()).riskTier).toBe('dangerous');
|
||||
});
|
||||
|
||||
it('degrades (no throw) when msfconsole is absent', async () => {
|
||||
const deps = makeDeps({ isToolAvailable: async () => false });
|
||||
const res = await createMetasploitTool(deps).handler(ctx({ module: 'auxiliary/scanner/ssh/ssh_version', target: '10.0.0.5' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/not installed/);
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an out-of-scope RHOSTS before spawning', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => false });
|
||||
const res = await createMetasploitTool(deps).handler(ctx({ module: 'exploit/x', target: 'evil.example.com' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/SCOPE DENIED/);
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses msf-command injection in a module/option value', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = createMetasploitTool(deps);
|
||||
const inj = await tool.handler(ctx({ module: 'exploit/x; sessions -K', target: '10.0.0.5' }));
|
||||
expect(inj.success).toBe(false);
|
||||
expect(inj.error).toMatch(/invalid module|msf-command metacharacters/); // caught by the module-path validator
|
||||
const inj2 = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { LHOST: '1.2.3.4\nexploit -j' } }));
|
||||
expect(inj2.success).toBe(false);
|
||||
expect(inj2.error).toMatch(/msf-command metacharacters/); // an option VALUE newline is caught by MSF_UNSAFE
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a target-override via options (RHOSTS/RHOST/VHOST) — no out-of-scope escape', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true }); // the resolved target is in-scope
|
||||
const tool = createMetasploitTool(deps);
|
||||
const res = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { RHOSTS: '9.9.9.9' } }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/target override refused/);
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a whitespace-padded / space-bearing option key (RHOSTS-override injection escape)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true }); // resolved target is in-scope
|
||||
const tool = createMetasploitTool(deps);
|
||||
// each of these keys would inject a second `set …` directive into the -x string if it slipped
|
||||
// past the exact-match target guard; the strict key validator must refuse them all.
|
||||
for (const key of ['RHOSTS ', ' rhosts', 'RHOSTS\t', 'RHOSTS 9.9.9.9', 'AutoRunScript multi/handler']) {
|
||||
const res = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { [key]: 'y' } }));
|
||||
expect(res.success, `key "${key}" must be refused`).toBe(false);
|
||||
}
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
// a clean option key still works
|
||||
const ok = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { RPORT: 8080 } }));
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a malformed // , scheme, or @ target (checked host != emitted RHOSTS)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
// '8.8.8.8@10.0.0.5' scope-checks as 10.0.0.5 but msf would receive the raw @-value — refuse it
|
||||
for (const t of ['//evil.com', 'file:///etc/passwd', '8.8.8.8@10.0.0.5']) {
|
||||
const res = await createMetasploitTool(deps).handler(ctx({ module: 'exploit/x', target: t }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/malformed target/);
|
||||
}
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('builds the resource-command -x string on the happy path', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const res = await createMetasploitTool(deps).handler(
|
||||
ctx({ module: 'auxiliary/scanner/ssh/ssh_login', target: '10.0.0.5', options: { RPORT: 22 } }),
|
||||
);
|
||||
expect(res.success).toBe(true);
|
||||
expect(deps.spawns[0].cmd).toBe('msfconsole');
|
||||
const cmd = deps.spawns[0].args[deps.spawns[0].args.indexOf('-x') + 1];
|
||||
expect(cmd).toContain('use auxiliary/scanner/ssh/ssh_login');
|
||||
expect(cmd).toContain('set RHOSTS 10.0.0.5');
|
||||
expect(cmd).toContain('set RPORT 22');
|
||||
expect(cmd).toContain('run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydra_bruteforce driver', () => {
|
||||
it('is a credential, approval-gated tool', () => {
|
||||
expect(createHydraTool(makeDeps()).riskTier).toBe('credential');
|
||||
});
|
||||
|
||||
it('degrades when hydra is absent', async () => {
|
||||
const deps = makeDeps({ isToolAvailable: async () => false });
|
||||
const res = await createHydraTool(deps).handler(ctx({ service: 'ssh', target: '10.0.0.5', user: 'root', password: 'x' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/not installed/);
|
||||
});
|
||||
|
||||
it('rejects a bogus service and option-looking credentials', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = createHydraTool(deps);
|
||||
expect((await tool.handler(ctx({ service: 'ssh; rm -rf', target: '10.0.0.5', user: 'a', password: 'b' }))).success).toBe(false);
|
||||
expect((await tool.handler(ctx({ service: 'ssh', target: '10.0.0.5', user: '-oN', password: 'b' }))).success).toBe(false);
|
||||
expect((await tool.handler(ctx({ service: 'ssh', target: '//evil.com', user: 'a', password: 'b' }))).success).toBe(false); // malformed target
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scope-denies the target and requires user+pass', async () => {
|
||||
expect((await createHydraTool(makeDeps({ scopeOk: () => false })).handler(ctx({ service: 'ssh', target: '8.8.8.8', user: 'a', password: 'b' }))).success).toBe(false);
|
||||
expect((await createHydraTool(makeDeps({ scopeOk: () => true })).handler(ctx({ service: 'ssh', target: '10.0.0.5' }))).error).toMatch(/user/);
|
||||
});
|
||||
|
||||
it('builds argv and caps the task rate at 16', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
await createHydraTool(deps).handler(ctx({ service: 'ssh', target: '10.0.0.5', userlist: '/u.txt', passlist: '/p.txt', tasks: 500 }));
|
||||
const argv = deps.spawns[0].args;
|
||||
expect(argv).toEqual(['-L', '/u.txt', '-P', '/p.txt', '-t', '16', '-f', 'ssh://10.0.0.5']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPostExTools', () => {
|
||||
it('returns both drivers, both approval-gated', () => {
|
||||
const tools = buildPostExTools(makeDeps());
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['hydra_bruteforce', 'metasploit_module']);
|
||||
expect(tools.every((t) => t.riskTier === 'dangerous' || t.riskTier === 'credential')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Secret / credential redaction (server response + audit + SSE boundary). Pins the load-bearing scrub,
|
||||
* including the 7th-pass fix: a `scheme://user:pass@host` login URL (the shape approval audit target/
|
||||
* action strings take for a gated credential tool) must not leak its password over the REST endpoint or
|
||||
* the SSE feed.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { redactString, redactSecrets } from '../redact.js';
|
||||
|
||||
describe('redactString — URL userinfo (basic-auth) scrub', () => {
|
||||
it('strips the password from a scheme://user:pass@host login URL (all schemes)', () => {
|
||||
expect(redactString('https://admin:hunter2@vpn.corp/login')).toBe('https://[redacted]@vpn.corp/login');
|
||||
expect(redactString('ssh://root:toor@10.0.0.5')).toBe('ssh://[redacted]@10.0.0.5');
|
||||
expect(redactString('ftp://svc:S3cr3t@files.internal/x')).toBe('ftp://[redacted]@files.internal/x');
|
||||
});
|
||||
|
||||
it('covers a password that itself contains @ (redacts up to the LAST @ before the host)', () => {
|
||||
expect(redactString('https://svcacct:S3cr3t-P@ss!23@intranet.corp/login'))
|
||||
.toBe('https://[redacted]@intranet.corp/login');
|
||||
});
|
||||
|
||||
it('leaves credential-free URLs and non-authority URIs untouched', () => {
|
||||
expect(redactString('https://example.com/path?x=1')).toBe('https://example.com/path?x=1');
|
||||
expect(redactString('visit https://host/a@b/c for docs')).toBe('visit https://host/a@b/c for docs'); // @ after a path segment, not userinfo
|
||||
expect(redactString('mailto:alice@example.com')).toBe('mailto:alice@example.com'); // no ://, not userinfo
|
||||
});
|
||||
|
||||
it('still redacts the pre-existing secret shapes (regression)', () => {
|
||||
expect(redactString('key AKIAIOSFODNN7EXAMPLE here')).toContain('[redacted]');
|
||||
expect(redactString('token=abc123def456ghi')).toBe('token=[redacted]');
|
||||
expect(redactString('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toContain('Bearer [redacted]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactSecrets — approval-audit record shape', () => {
|
||||
it('scrubs a userinfo password in the audit target/action a gated credential tool would record', () => {
|
||||
// mirrors what Arsenal.execute() records for password_spray({ url: 'https://user:pass@host/login' })
|
||||
const record = {
|
||||
tool: 'password_spray',
|
||||
risk: 'credential',
|
||||
target: 'https://admin:hunter2@vpn.corp/login',
|
||||
action: 'password_spray (url=https://admin:hunter2@vpn.corp/login username=admin) → https://admin:hunter2@vpn.corp/login',
|
||||
outcome: 'allowed-preapproved',
|
||||
};
|
||||
const out = redactSecrets(record) as typeof record;
|
||||
expect(out.target).toBe('https://[redacted]@vpn.corp/login');
|
||||
expect(out.action).not.toContain('hunter2');
|
||||
expect(out.action).toContain('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts a key named like a secret and recurses through arrays/objects', () => {
|
||||
const out = redactSecrets({ password: 'p', nested: [{ token: 't', url: 'https://u:pw@h/x' }] }) as {
|
||||
password: string; nested: Array<{ token: string; url: string }>;
|
||||
};
|
||||
expect(out.password).toBe('[redacted]');
|
||||
expect(out.nested[0].token).toBe('[redacted]');
|
||||
expect(out.nested[0].url).toBe('https://[redacted]@h/x');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user