feat(arsenal): parse scanner JSON into structured findings (#42)

The generic adapter factory returned raw stdout only (`adapter-tools.ts` success
path), so 35 catalogued scanners with `parserStatus: 'planned'` never populated the
structured `ToolResult.findings` channel — their output was dropped on the floor and
the agent could neither verify nor chain on it. The whole downstream pipe already
exists and is honest-by-construction: the agent loop stamps `provenance:'tool'` + the
raw output, the operator materialises each finding with output-evidence, and the live
gate (`evidence/gate.ts`) marks it verified.

Add `src/arsenal/parsers.ts` — `parseToolOutput(toolId, rawStdout) → ToolFinding[]`
for the five scanners that already emit machine-readable JSON via their existing arg
templates (zero template changes): nuclei (-jsonl), httpx (-json), dalfox (--format
json), ffuf (-of json), katana (-jsonl). Parsers are pure, dependency-free, and NEVER
throw: empty/garbled/unknown input yields `[]`, never a fabricated finding (same
discipline as stub-honesty / no-phantom-tools). The factory attaches parsed findings
while ALWAYS keeping the raw stdout as `output` — the evidence of record the gate checks.

- nuclei/dalfox emit CWE, which the tool channel dropped: add optional `cwe?: string[]`
  to `ToolFinding` and thread it through the operator's finding materialisation.
- flip `parserStatus: 'planned' → 'structured'` for httpx, dalfox, katana (nuclei/ffuf
  were already structured).

Adds `src/__tests__/parsers.test.ts` + fixtures: per-scanner field mapping, the
honesty contract (empty/garbled/adversarial → [], no throw), a factory-wiring check
that a minted adapter returns findings while preserving raw stdout, and an end-to-end
assertion that every parsed finding PASSES `gateLiveFinding` with provenance='tool'.

The minted-adapter path is gated behind T3MP3ST_FULL_ARSENAL, so the benchmarked
default arsenal is untouched. Supply-chain JSON tools (semgrep/gitleaks/trivy/grype)
and secret scanners (need redaction) are a clean follow-up.
This commit is contained in:
Pazificateur69 authored and GitHub committed 2026-07-15 19:43:51 -04:00
1 parent 0f3213b310
commit f8c283377c
11 files changed
+423 -4

No files matched your search

+1
View File
@@ -0,0 +1 @@
[{"type":"V","inject_type":"inHTML-URL","severity":"high","cwe":"CWE-79","method":"GET","param":"q","data":"https://target.example.com/?q=<script>alert(1)</script>","evidence":"reflected in HTML body","poc":"<script>alert(1)</script>","message_str":"reflected XSS"}]
+1
View File
@@ -0,0 +1 @@
{"commandline":"ffuf -u https://target.example.com/FUZZ -w common.txt","results":[{"input":{"FUZZ":"admin"},"status":200,"length":1234,"words":56,"url":"https://target.example.com/admin"},{"input":{"FUZZ":"login"},"status":301,"length":0,"words":1,"url":"https://target.example.com/login"}]}
+2
View File
@@ -0,0 +1,2 @@
{"url":"https://target.example.com","status_code":200,"title":"Example Corp","tech":["Nginx","PHP","jQuery"],"webserver":"nginx"}
{"url":"https://api.example.com","status_code":403,"title":"Forbidden"}
+3
View File
@@ -0,0 +1,3 @@
{"timestamp":"2026-01-01T00:00:00Z","request":{"method":"GET","endpoint":"https://target.example.com/"}}
{"timestamp":"2026-01-01T00:00:01Z","request":{"method":"GET","endpoint":"https://target.example.com/about"}}
{"timestamp":"2026-01-01T00:00:02Z","request":{"method":"GET","endpoint":"https://target.example.com/contact"}}
+3
View File
@@ -0,0 +1,3 @@
[INF] nuclei banner line that is not JSON and must be skipped
{"template-id":"CVE-2021-44228","info":{"name":"Apache Log4j RCE","severity":"critical","description":"Log4j JNDI remote code execution via crafted header","classification":{"cve-id":["CVE-2021-44228"],"cwe-id":["CWE-502"],"cvss-score":10.0},"remediation":"Upgrade log4j to 2.17.0 or later"},"host":"https://target.example.com","matched-at":"https://target.example.com/api"}
{"template-id":"nginx-version","info":{"name":"Nginx Version Disclosure","severity":"info"},"host":"https://target.example.com","matched-at":"https://target.example.com"}
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseToolOutput, hasParser, PARSED_TOOL_IDS } from '../arsenal/parsers.js';
import { gateLiveFinding } from '../evidence/gate.js';
import { adapterToCustomTool, type AdapterToolDeps } from '../arsenal/adapter-tools.js';
import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
import { KillChainPhase } from '../types/index.js';
import type { CustomTool, Finding, ToolFinding } from '../types/index.js';
// ─────────────────────────────────────────────────────────────────────────────
// STRUCTURED PARSERS — turn real scanner stdout into ToolFinding[], honestly.
//
// Two things are pinned here:
// 1) each JSON-emitting scanner's output maps to the right structured fields, and
// 2) the honesty contract holds: empty / garbled / unknown input yields [] (never a
// fabricated finding), and a parsed finding — carried through the SAME shape the
// agent loop + operator build — PASSES the live provenance gate. That last check
// is the point of the whole layer: structured output that the honesty spine accepts.
// ─────────────────────────────────────────────────────────────────────────────
const fixture = (name: string): string =>
readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8');
describe('parseToolOutput — field mapping per scanner', () => {
it('nuclei: one finding per JSONL match; skips non-JSON lines; maps cve/cwe/cvss/severity', () => {
const f = parseToolOutput('nuclei', fixture('nuclei.jsonl'));
expect(f).toHaveLength(2); // banner line skipped
const log4j = f[0];
expect(log4j.title).toBe('Apache Log4j RCE');
expect(log4j.severity).toBe('critical');
expect(log4j.cve).toEqual(['CVE-2021-44228']);
expect(log4j.cwe).toEqual(['CWE-502']);
expect(log4j.cvss).toBe(10.0);
expect(log4j.remediation).toMatch(/2\.17\.0/);
expect(log4j.details).toContain('target.example.com');
expect(f[1].severity).toBe('info'); // nginx version disclosure
});
it('httpx: one info finding per live host with url/status/tech in details', () => {
const f = parseToolOutput('httpx', fixture('httpx.jsonl'));
expect(f).toHaveLength(2);
expect(f[0].severity).toBe('info');
expect(f[0].title).toContain('target.example.com');
expect(f[0].details).toContain('status: 200');
expect(f[0].details).toContain('Nginx');
});
it('dalfox: one finding per XSS probe with severity + CWE', () => {
const f = parseToolOutput('dalfox', fixture('dalfox.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].cwe).toEqual(['CWE-79']);
expect(f[0].title).toContain('q'); // the param
expect(f[0].details).toContain('poc');
});
it('ffuf: a single aggregate finding counting discovered paths (no per-path flood)', () => {
const f = parseToolOutput('ffuf', fixture('ffuf.json'));
expect(f).toHaveLength(1);
expect(f[0].title).toBe('ffuf: 2 path(s) discovered');
expect(f[0].severity).toBe('info');
expect(f[0].details).toContain('admin');
expect(f[0].details).toContain('login');
});
it('katana: a single aggregate finding counting discovered endpoints', () => {
const f = parseToolOutput('katana', fixture('katana.jsonl'));
expect(f).toHaveLength(1);
expect(f[0].title).toBe('katana: 3 endpoint(s) discovered');
expect(f[0].details).toContain('/about');
});
});
describe('parseToolOutput — honesty contract (never fabricate, never throw)', () => {
it('returns [] for empty, whitespace, and unparseable output', () => {
for (const raw of ['', ' ', '\n\n', 'not json at all', '{bad json', '<html>error</html>']) {
expect(parseToolOutput('nuclei', raw)).toEqual([]);
expect(parseToolOutput('ffuf', raw)).toEqual([]);
expect(parseToolOutput('dalfox', raw)).toEqual([]);
}
});
it('returns [] for a tool with no parser wired', () => {
expect(parseToolOutput('sqlmap', fixture('nuclei.jsonl'))).toEqual([]);
expect(parseToolOutput('metasploit', 'anything')).toEqual([]);
expect(hasParser('sqlmap')).toBe(false);
});
it('never throws on adversarial / deeply-nested / wrong-shape input', () => {
const evil = [
JSON.stringify({ results: 'not-an-array' }),
'[1,2,3]',
'{"info":null}\n{"info":{"classification":"nope"}}',
'['.repeat(500),
JSON.stringify({ results: [{ input: null, status: {} }] }),
];
for (const id of PARSED_TOOL_IDS) {
for (const raw of evil) {
expect(() => parseToolOutput(id, raw)).not.toThrow();
expect(Array.isArray(parseToolOutput(id, raw))).toBe(true);
}
}
});
it('exposes exactly the wired parser ids', () => {
expect([...PARSED_TOOL_IDS].sort()).toEqual(['dalfox', 'ffuf', 'httpx', 'katana', 'nuclei']);
});
});
describe('end-to-end: a parsed finding passes the live provenance gate', () => {
// Mirror how the agent loop stamps provenance (src/agent/index.ts) and how the operator
// materialises a ToolFinding into a Finding with output-evidence (src/operators/index.ts),
// then run the real gate. This proves parsed output is accepted by the honesty spine.
const materialise = (tf: ToolFinding, rawOutput: string, toolName: string): Finding => {
const stamped = { ...tf, provenance: 'tool' as const, toolName, toolOutput: rawOutput.slice(0, 4000) };
return {
id: 'finding-test',
title: stamped.title,
description: stamped.details,
severity: stamped.severity,
targetId: 'target-1',
operatorId: 'op-1',
phase: KillChainPhase.RECON,
cvss: stamped.cvss,
cve: stamped.cve,
cwe: stamped.cwe,
evidence: stamped.provenance === 'tool'
? [{ type: 'output', content: stamped.toolOutput || stamped.details || '', timestamp: 1, metadata: { tool: stamped.toolName } }]
: [],
remediation: stamped.remediation,
discoveredAt: 1,
};
};
it('a parsed nuclei critical finding is gated PASS with provenance=tool', () => {
const raw = fixture('nuclei.jsonl');
const tf = parseToolOutput('nuclei', raw)[0];
const gate = gateLiveFinding(materialise(tf, raw, 'nuclei_tool'));
expect(gate.passed).toBe(true);
expect(gate.provenance).toBe('tool');
});
it('every parsed finding across all scanners is gate-passable (real tool evidence)', () => {
for (const [id, file] of [
['nuclei', 'nuclei.jsonl'], ['httpx', 'httpx.jsonl'], ['dalfox', 'dalfox.json'],
['ffuf', 'ffuf.json'], ['katana', 'katana.jsonl'],
] as const) {
const raw = fixture(file);
for (const tf of parseToolOutput(id, raw)) {
const gate = gateLiveFinding(materialise(tf, raw, `${id}_tool`));
expect(gate.passed, `${id} finding "${tf.title}" should pass the gate`).toBe(true);
expect(gate.provenance).toBe('tool');
}
}
});
});
describe('factory wiring: a minted adapter returns structured findings from real stdout', () => {
// Proves the actual code path (adapter-tools.ts success return) populates ToolResult.findings —
// not just that the pure parser works. Deps are fakes; runSubprocess replays a fixture as stdout.
const depsReturning = (stdout: string): AdapterToolDeps => ({
isToolAvailable: async () => true,
runSubprocess: async () => ({ stdout, stderr: '', exitCode: 0 }),
});
const mint = (id: string, deps: AdapterToolDeps): CustomTool => {
const adapter = TOOL_ADAPTERS.find((a) => a.id === id);
if (!adapter) throw new Error(`fixture: adapter '${id}' not found`);
const tool = adapterToCustomTool(adapter, deps);
if (!tool) throw new Error(`fixture: adapter '${id}' is not mintable`);
return tool;
};
it('nuclei_tool surfaces parsed findings (with cwe/cve) AND keeps the raw stdout as evidence', async () => {
const raw = fixture('nuclei.jsonl');
const res = await mint('nuclei', depsReturning(raw)).handler({ parameters: { url: 'https://target.example.com' } });
expect(res.success).toBe(true);
expect(res.output).toBe(raw); // raw stdout preserved as the evidence of record
expect(res.findings).toHaveLength(2);
expect(res.findings?.[0].cwe).toEqual(['CWE-502']);
});
it('a tool whose scan yields no parseable output leaves findings unset (no fabrication)', async () => {
const res = await mint('nuclei', depsReturning('no findings here\n')).handler({ parameters: { url: 'https://target.example.com' } });
expect(res.success).toBe(true);
expect(res.findings).toBeUndefined();
});
});
+11 -1
View File
@@ -25,6 +25,7 @@
import type { ToolAdapter } from './catalog.js';
import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
import { parseToolOutput } from './parsers.js';
// =============================================================================
// INJECTED DEPENDENCIES
@@ -470,7 +471,16 @@ export function adapterToCustomTool(adapter: ToolAdapter, deps: AdapterToolDeps)
};
}
return { success: true, output: result.stdout };
// Populate the structured `findings` channel from the raw stdout when a parser is wired for
// this tool (parseToolOutput → [] otherwise). The raw stdout is ALWAYS kept as `output` — it
// is the evidence of record the agent loop stamps onto each finding and the live gate checks;
// the parser summarises it, never replaces it. A parse yielding nothing leaves findings unset.
const findings = parseToolOutput(adapter.id, result.stdout);
return {
success: true,
output: result.stdout,
...(findings.length ? { findings } : {}),
};
};
return {
+3 -3
View File
@@ -179,7 +179,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/httpx',
commandHint: 'httpx -u https://example.com -status-code -title -tech-detect -json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'HTTP probing and technology detection for discovered assets.',
},
{
@@ -211,7 +211,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/katana',
commandHint: 'katana -u https://example.com -jsonl -silent',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Crawler for endpoint discovery and attack-surface mapping.',
},
{
@@ -307,7 +307,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'text'],
installHint: 'brew install dalfox',
commandHint: 'dalfox url https://example.com?q=test -o result.json --format json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'XSS validation adapter for scoped endpoints.',
},
{
+210
View File
@@ -0,0 +1,210 @@
/**
* T3MP3ST Arsenal — tool-output → structured evidence parsers.
*
* Turns the raw stdout of the JSON-emitting scanners into `ToolFinding[]` so the generic
* adapter factory (src/arsenal/adapter-tools.ts) can return STRUCTURED findings, not just
* text. Downstream the pipe already exists and is honest-by-construction: the agent loop
* stamps `provenance:'tool'` + the raw output onto each finding, the operator materialises
* it into a `Finding` with output-evidence, and the live gate (src/evidence/gate.ts) can
* then mark it verified. Until now these adapters (parserStatus:'planned') returned raw
* stdout only — the structured channel (`ToolResult.findings`) was never populated.
*
* HONESTY CONTRACT (same discipline as stub-honesty / no-phantom-tools):
* - Pure, dependency-free, and NEVER throws. Empty / garbled output → `[]`, never a
* fabricated finding. The caller keeps the raw stdout as the evidence of record; a
* parser SUMMARISES real output, it does not invent it.
* - Parsers do NOT set provenance/toolName/toolOutput — the agent loop stamps those from
* the real subprocess result, so a parser can never forge provenance.
*/
import type { Severity, ToolFinding } from '../types/index.js';
// ── small, defensive helpers ────────────────────────────────────────────────
const SEVERITIES = new Set<Severity>(['critical', 'high', 'medium', 'low', 'info']);
const sev = (v: unknown, fallback: Severity = 'info'): Severity => {
const s = String(v ?? '').trim().toLowerCase();
return SEVERITIES.has(s as Severity) ? (s as Severity) : fallback;
};
const asObj = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
const asStrArray = (v: unknown): string[] =>
Array.isArray(v)
? v.map((x) => String(x)).filter((s) => s.trim().length > 0)
: v !== null && v !== undefined && v !== '' ? [String(v)] : [];
const num = (v: unknown): number | undefined => {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : undefined;
};
const truncate = (s: string, n = 400): string => (s.length > n ? `${s.slice(0, n)}…` : s);
/** Parse JSONL defensively: one object per non-empty line; any non-JSON line is skipped. */
function jsonl(raw: string): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (const line of String(raw).split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const parsed: unknown = JSON.parse(t);
if (parsed && typeof parsed === 'object') out.push(parsed as Record<string, unknown>);
} catch {
/* not a JSON line (banner, log, truncated tail) — skip */
}
}
return out;
}
/** Parse a single JSON document defensively; returns undefined on any error. */
function jsonDoc(raw: string): unknown {
try {
return JSON.parse(String(raw));
} catch {
return undefined;
}
}
// ── nuclei -jsonl : one finding per template match ───────────────────────────
function parseNuclei(raw: string): ToolFinding[] {
const out: ToolFinding[] = [];
for (const e of jsonl(raw)) {
const info = asObj(e.info);
const cls = asObj(info.classification);
const templateId = String(e['template-id'] ?? e.templateID ?? '');
const title = String(info.name || templateId || 'nuclei match');
const bits = [
e.host && `host: ${String(e.host)}`,
e['matched-at'] && `matched-at: ${String(e['matched-at'])}`,
info.description && `desc: ${truncate(String(info.description))}`,
].filter(Boolean) as string[];
const f: ToolFinding = { title, severity: sev(info.severity), details: bits.join(' | ') || title };
const cvss = num(cls['cvss-score']);
if (cvss !== undefined) f.cvss = cvss;
const cve = asStrArray(cls['cve-id']);
if (cve.length) f.cve = cve;
const cwe = asStrArray(cls['cwe-id']);
if (cwe.length) f.cwe = cwe;
if (info.remediation) f.remediation = String(info.remediation);
out.push(f);
}
return out;
}
// ── httpx -json : one info finding per live host (technology fingerprint) ─────
function parseHttpx(raw: string): ToolFinding[] {
const out: ToolFinding[] = [];
for (const e of jsonl(raw)) {
const url = String(e.url ?? e.input ?? e.host ?? '');
if (!url) continue;
const status = e.status_code ?? e['status-code'];
const title = String(e.title ?? '');
const tech = asStrArray(e.tech ?? e.technologies);
const server = String(e.webserver ?? e.server ?? '');
const bits = [
`url: ${url}`,
status !== null && status !== undefined && `status: ${String(status)}`,
title && `title: ${truncate(title, 120)}`,
server && `server: ${server}`,
tech.length && `tech: ${tech.join(', ')}`,
].filter(Boolean) as string[];
out.push({ title: `HTTP service ${url}`, severity: 'info', details: bits.join(' | ') });
}
return out;
}
// ── dalfox --format json : one finding per XSS probe ─────────────────────────
function parseDalfox(raw: string): ToolFinding[] {
const doc = jsonDoc(raw);
const arr: unknown[] = Array.isArray(doc)
? doc
: Array.isArray(asObj(doc).pocs) ? (asObj(doc).pocs as unknown[]) : [];
const out: ToolFinding[] = [];
for (const item of arr) {
const e = asObj(item);
const param = String(e.param ?? '');
const kind = String(e.type ?? e.inject_type ?? 'XSS');
const bits = [
e.method && `method: ${String(e.method)}`,
param && `param: ${param}`,
e.data && `data: ${truncate(String(e.data), 200)}`,
e.evidence && `evidence: ${truncate(String(e.evidence), 200)}`,
e.poc && `poc: ${truncate(String(e.poc), 300)}`,
].filter(Boolean) as string[];
const f: ToolFinding = {
title: `dalfox ${kind}${param ? ` @ ${param}` : ''}`,
severity: sev(e.severity, 'medium'),
details: bits.join(' | ') || String(e.message_str ?? e.message ?? 'XSS probe'),
};
const cwe = asStrArray(e.cwe).map((c) => (/^CWE-/i.test(c) ? c.toUpperCase() : `CWE-${c}`));
if (cwe.length) f.cwe = cwe;
out.push(f);
}
return out;
}
// ── ffuf -of json : one aggregate info finding (N paths) ─────────────────────
function parseFfuf(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).results;
return Array.isArray(r) ? r : [];
})();
if (!results.length) return [];
const lines = results.slice(0, 50).map((r) => {
const e = asObj(r);
const path = String(asObj(e.input).FUZZ ?? e.input ?? e.url ?? '');
return `${path} (status ${String(e.status ?? '?')}, len ${String(e.length ?? '?')})`;
});
const more = results.length > 50 ? `\n… +${results.length - 50} more` : '';
return [{
title: `ffuf: ${results.length} path(s) discovered`,
severity: 'info',
details: lines.join('\n') + more,
}];
}
// ── katana -jsonl : one aggregate info finding (N endpoints) ──────────────────
function parseKatana(raw: string): ToolFinding[] {
const endpoints: string[] = [];
for (const e of jsonl(raw)) {
const ep = String(asObj(e.request).endpoint ?? e.endpoint ?? e.url ?? '');
if (ep) endpoints.push(ep);
}
if (!endpoints.length) return [];
const more = endpoints.length > 50 ? `\n… +${endpoints.length - 50} more` : '';
return [{
title: `katana: ${endpoints.length} endpoint(s) discovered`,
severity: 'info',
details: endpoints.slice(0, 50).join('\n') + more,
}];
}
const PARSERS: Record<string, (raw: string) => ToolFinding[]> = {
nuclei: parseNuclei,
httpx: parseHttpx,
dalfox: parseDalfox,
ffuf: parseFfuf,
katana: parseKatana,
};
/** Adapter ids that have a structured output parser wired here. */
export const PARSED_TOOL_IDS: readonly string[] = Object.keys(PARSERS);
/** True iff a structured parser is wired for this adapter id. */
export function hasParser(toolId: string): boolean {
return Object.prototype.hasOwnProperty.call(PARSERS, toolId);
}
/**
* Parse a tool's raw stdout into structured `ToolFinding[]`.
*
* Returns `[]` for any tool with no parser, for empty output, and for output a parser cannot
* make sense of. NEVER throws — an unexpected parser failure degrades to `[]` so a malformed
* scan can never crash the agent loop or fabricate a finding.
*/
export function parseToolOutput(toolId: string, rawOutput: string): ToolFinding[] {
const parser = PARSERS[toolId];
if (!parser || !rawOutput || !String(rawOutput).trim()) return [];
try {
return parser(rawOutput);
} catch {
return [];
}
}
+1
View File
@@ -476,6 +476,7 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
phase: task.phase as KillChainPhase,
cvss: finding.cvss,
cve: finding.cve,
cwe: finding.cwe,
evidence: toolBacked
? [{
type: 'output',
+1
View File
@@ -426,6 +426,7 @@ export interface ToolFinding {
details: string;
cvss?: number;
cve?: string[];
cwe?: string[];
remediation?: string;
/**
* How this finding was produced — the provenance flag the honesty gate keys on: