@clear-capabilities/agentic-security-scanner 0.140.0 → 0.142.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/dist/113.index.js +79 -3
  3. package/dist/178.index.js +1 -1
  4. package/dist/238.index.js +77 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/435.index.js +12 -0
  7. package/dist/526.index.js +79 -3
  8. package/dist/637.index.js +1 -1
  9. package/dist/agentic-security.mjs +14 -14
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/compliance-frameworks/ccpa.json +34 -7
  12. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  13. package/dist/compliance-frameworks/gdpr.json +56 -12
  14. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  15. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  16. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  17. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  18. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  19. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  20. package/package.json +19 -5
  21. package/src/dataflow/CLAUDE.md +9 -0
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/dataflow/engine.js +95 -0
  24. package/src/dataflow/sanitizer-gate.js +61 -0
  25. package/src/engine.js +353 -31
  26. package/src/mcp/tools.js +12 -0
  27. package/src/posture/accuracy-scorecard.js +57 -0
  28. package/src/posture/aibom.js +110 -1
  29. package/src/posture/auditor-walkthrough.js +56 -17
  30. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  31. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  32. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  33. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  34. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  35. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  36. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  37. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  38. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  39. package/src/posture/concurrency-checker.js +3 -3
  40. package/src/posture/coverage-strength.js +182 -0
  41. package/src/posture/epss.js +17 -1
  42. package/src/posture/family-registry.js +103 -0
  43. package/src/posture/family-resolve.js +47 -0
  44. package/src/posture/fix-coverage.js +113 -0
  45. package/src/posture/fix-metrics.js +76 -0
  46. package/src/posture/mcp-rug-pull.js +144 -0
  47. package/src/posture/poc-inprocess.js +217 -1
  48. package/src/posture/proof-coverage.js +162 -0
  49. package/src/posture/reachability-filter.js +44 -0
  50. package/src/posture/sbom.js +12 -3
  51. package/src/runScan.js +56 -5
  52. package/src/sast/CLAUDE.md +2 -2
  53. package/src/sast/claude-md-prompt-injection.js +47 -3
  54. package/src/sast/cloud-iam.js +23 -0
  55. package/src/sast/convention-deviation.js +66 -3
  56. package/src/sast/crypto-protocol.js +23 -0
  57. package/src/sast/dapp-frontend.js +20 -0
  58. package/src/sast/iac-cloud-templates.js +346 -0
  59. package/src/sast/k8s-admission.js +27 -0
  60. package/src/sast/ml-supply-chain.js +22 -0
  61. package/src/sast/ruby.js +132 -0
  62. package/src/sast/web3-advanced.js +26 -0
  63. package/src/sca/CLAUDE.md +21 -4
  64. package/src/sca/container.js +18 -1
  65. package/src/sca/dep-confusion.js +69 -3
@@ -195,3 +195,79 @@ export function renderFixDurationSummary(sum) {
195
195
  }
196
196
 
197
197
  export const _internals = { _dist, _pct, RELIABLE_N };
198
+
199
+
200
+ // ── PRD F6.1 — score fixes on THREE AXES, not one ──────────────────────────
201
+ //
202
+ // The three axes the PRD names:
203
+ // (a) does the finding disappear — the rescan leg
204
+ // (b) does the project's own suite pass — the tests leg
205
+ // (c) does an independent verifier agree — the PoC re-check leg
206
+ //
207
+ // All three were already computed by verifyFixCore and then collapsed into one
208
+ // boolean, which is the problem: **(a) alone is satisfiable by deleting code.**
209
+ // A patch that removes the vulnerable function passes the rescan, has nothing
210
+ // left to fail, and — on a project with no detectable test suite — reaches
211
+ // ok:true having proven only that the detector went quiet.
212
+ //
213
+ // Reporting the axes separately makes that visible. `aOnly` is the number that
214
+ // matters most and the one nobody was publishing: attempts that satisfied ONLY
215
+ // the disappearance axis. A high aOnly with a high headline is the shape of a
216
+ // remediation feature that is deleting code and calling it a fix.
217
+ export function summarizeFixAxes(attempts) {
218
+ const list = Array.isArray(attempts) ? attempts.filter(Boolean) : [];
219
+ const d = list.length;
220
+
221
+ const rate = (pred) => ({ n: list.filter(pred).length, d });
222
+
223
+ // Each axis is judged INDEPENDENTLY of the overall verdict, so a leg that
224
+ // passed inside a failed attempt still counts for its own axis. Reading them
225
+ // off `ok` would make the three axes three copies of the same number.
226
+ const findingDisappeared = rate((a) => a.rescanOk === true || (a.ok === true && a.rescanOk !== false));
227
+ const testsStillPass = rate((a) => a.testsRan === true && a.testsOk !== false);
228
+ const verifierAgrees = rate((a) => a.pocOk === true);
229
+
230
+ const satisfiesAll = rate((a) =>
231
+ (a.rescanOk === true || (a.ok === true && a.rescanOk !== false))
232
+ && a.testsRan === true && a.testsOk !== false
233
+ && a.pocOk === true);
234
+
235
+ // The honesty number: disappearance WITHOUT either corroborating axis.
236
+ const aOnly = rate((a) => {
237
+ const disappeared = a.rescanOk === true || (a.ok === true && a.rescanOk !== false);
238
+ const corroborated = (a.testsRan === true && a.testsOk !== false) || a.pocOk === true;
239
+ return disappeared && !corroborated;
240
+ });
241
+
242
+ return {
243
+ total: d,
244
+ findingDisappeared,
245
+ testsStillPass,
246
+ verifierAgrees,
247
+ satisfiesAll,
248
+ aOnly,
249
+ meaning:
250
+ 'findingDisappeared = the detector went quiet; testsStillPass = the project suite ran AND passed; '
251
+ + 'verifierAgrees = an independent PoC re-check confirmed the hole is shut. '
252
+ + 'aOnly counts attempts that satisfied ONLY disappearance — the shape a code-deleting "fix" produces.',
253
+ caveat: d === 0
254
+ ? 'no attempts recorded; every rate is 0/0 and means nothing'
255
+ : 'rates carry {n,d}; a small d is indicative, not settled',
256
+ };
257
+ }
258
+
259
+ /** Markdown for a report. Denominators always attached. */
260
+ export function renderFixAxes(sum) {
261
+ if (!sum || !sum.total) return '_No fix attempts recorded._\n';
262
+ const row = (label, r, note) => `| ${label} | ${r.n}/${r.d} | ${note} |`;
263
+ return [
264
+ '| Axis | Rate | Meaning |',
265
+ '|---|---|---|',
266
+ row('(a) finding disappeared', sum.findingDisappeared, 'the detector went quiet'),
267
+ row('(b) project tests pass', sum.testsStillPass, 'the suite RAN and passed'),
268
+ row('(c) verifier agrees', sum.verifierAgrees, 'an independent PoC re-check confirmed it'),
269
+ row('all three', sum.satisfiesAll, 'the only row that means "fixed"'),
270
+ row('(a) ALONE', sum.aOnly, 'satisfiable by deleting code — watch this number'),
271
+ '',
272
+ ].join('\n');
273
+ }
@@ -0,0 +1,144 @@
1
+ // PRD F5.2 — rug-pull detection for MCP tool definitions.
2
+ //
3
+ // THE ATTACK
4
+ // ----------
5
+ // A user approves an MCP server by reading what its tools claim to do. The
6
+ // agent then loads those tool DESCRIPTIONS into its context on every session,
7
+ // and acts on them. If a description changes after approval — new instructions
8
+ // appended, the schema widened to accept a path it never took, the stated
9
+ // purpose rewritten — the agent obeys the new text while the human still
10
+ // believes they approved the old one.
11
+ //
12
+ // Nothing in mcp-audit.js could see this. Every rule there judges a definition
13
+ // on its CURRENT content, so a description that is innocuous today and hostile
14
+ // tomorrow passes both times. Rug-pull is a property of the CHANGE, not of any
15
+ // single snapshot, which is why it needs its own mechanism.
16
+ //
17
+ // WHAT IS AND IS NOT A RUG-PULL
18
+ // -----------------------------
19
+ // A NEW tool is not a rug-pull — nobody approved it yet, and mcp-audit judges it
20
+ // on content like any other. A REMOVED tool is not a rug-pull either; it can no
21
+ // longer instruct anything. The finding is specifically: this exact tool name
22
+ // was seen before, and what it says has changed since.
23
+ //
24
+ // The FIRST run records and reports nothing. There is no prior state to compare
25
+ // against, and inventing a finding on first sight would make every new project
26
+ // noisy while teaching people to ignore the rule that matters.
27
+ import crypto from 'node:crypto';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { statePath, stateWritesEnabled, isSafeStateDir } from './state-dir.js';
31
+
32
+ const BASELINE_FILE = 'mcp-tool-baseline.json';
33
+
34
+ /**
35
+ * Fingerprint the parts of a tool definition an agent actually ACTS on.
36
+ *
37
+ * Description and input schema, not the name — the name is the identity being
38
+ * tracked, so folding it in would make every tool its own fingerprint and the
39
+ * comparison vacuous. Ordering inside the schema is normalised so a formatting
40
+ * change is not reported as a behavioural one; a reader chasing a false
41
+ * rug-pull alert stops reading them.
42
+ */
43
+ export function fingerprintTool(tool) {
44
+ const payload = JSON.stringify({
45
+ description: String((tool && tool.description) || ''),
46
+ inputSchema: _canonical((tool && (tool.inputSchema || tool.input_schema)) || null),
47
+ });
48
+ return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 32);
49
+ }
50
+
51
+ function _canonical(v) {
52
+ if (Array.isArray(v)) return v.map(_canonical);
53
+ if (v && typeof v === 'object') {
54
+ const out = {};
55
+ for (const k of Object.keys(v).sort()) out[k] = _canonical(v[k]);
56
+ return out;
57
+ }
58
+ return v;
59
+ }
60
+
61
+ /** { serverName: { toolName: fingerprint } } from an MCP config object. */
62
+ export function fingerprintConfig(config) {
63
+ const out = {};
64
+ const servers = (config && (config.mcpServers || config.servers)) || {};
65
+ for (const [server, def] of Object.entries(servers)) {
66
+ const tools = (def && def.tools) || [];
67
+ if (!Array.isArray(tools) || !tools.length) continue;
68
+ out[server] = {};
69
+ for (const t of tools) {
70
+ if (t && t.name) out[server][t.name] = fingerprintTool(t);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export function loadBaseline(scanRoot) {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(statePath(scanRoot, BASELINE_FILE), 'utf8'));
79
+ } catch { return null; }
80
+ }
81
+
82
+ export function saveBaseline(scanRoot, fingerprints) {
83
+ // Same discipline as every other state writer here: decline rather than
84
+ // create a stray state dir outside a real project.
85
+ if (!stateWritesEnabled() || !isSafeStateDir(path.dirname(statePath(scanRoot, BASELINE_FILE)))) return false;
86
+ try {
87
+ fs.mkdirSync(path.dirname(statePath(scanRoot, BASELINE_FILE)), { recursive: true });
88
+ fs.writeFileSync(statePath(scanRoot, BASELINE_FILE),
89
+ JSON.stringify({ schema: 'mcp-tool-baseline/v1', recordedAt: new Date().toISOString(), fingerprints }, null, 1));
90
+ return true;
91
+ } catch { return false; }
92
+ }
93
+
94
+ /**
95
+ * Compare current tool definitions against the recorded baseline.
96
+ *
97
+ * Returns { findings, firstRun, changed, added, removed }. `findings` is empty
98
+ * on a first run by design.
99
+ */
100
+ export function detectRugPull(scanRoot, config, { file = '.mcp.json' } = {}) {
101
+ const current = fingerprintConfig(config);
102
+ const prior = loadBaseline(scanRoot);
103
+ const findings = [];
104
+ const changed = [], added = [], removed = [];
105
+
106
+ if (!prior || !prior.fingerprints) {
107
+ return { findings, firstRun: true, changed, added, removed, current };
108
+ }
109
+
110
+ for (const [server, tools] of Object.entries(current)) {
111
+ const before = prior.fingerprints[server] || {};
112
+ for (const [name, fp] of Object.entries(tools)) {
113
+ if (!(name in before)) { added.push(`${server}/${name}`); continue; }
114
+ if (before[name] === fp) continue;
115
+ changed.push(`${server}/${name}`);
116
+ findings.push({
117
+ id: `mcp-rug-pull:${file}:${server}:${name}`,
118
+ file,
119
+ line: 1,
120
+ vuln: `MCP: tool "${name}" definition CHANGED after approval (rug-pull)`,
121
+ severity: 'high',
122
+ cwe: 'CWE-494',
123
+ family: 'mcp-rug-pull',
124
+ parser: 'MCP-RUGPULL',
125
+ confidence: 0.9,
126
+ description:
127
+ `The tool "${name}" on server "${server}" was approved with one definition and now has another. `
128
+ + 'An agent loads tool descriptions into its context and acts on them, so a changed description is a '
129
+ + 'changed instruction — the human still believes they approved the previous text. This is the '
130
+ + 'documented rug-pull shape: benign at review time, hostile afterwards.',
131
+ remediation:
132
+ `Re-review "${name}" against what it claimed when approved. If the change is legitimate, refresh the `
133
+ + `baseline at .agentic-security/${BASELINE_FILE}; if it is not, remove the server before the next agent run.`,
134
+ });
135
+ }
136
+ }
137
+ for (const [server, tools] of Object.entries(prior.fingerprints)) {
138
+ for (const name of Object.keys(tools)) {
139
+ if (!current[server] || !(name in current[server])) removed.push(`${server}/${name}`);
140
+ }
141
+ }
142
+
143
+ return { findings, firstRun: false, changed, added, removed, current };
144
+ }
@@ -38,12 +38,14 @@ const MARKER = 'PROVEN';
38
38
  // PoC observes the acceptance and records it. Both are execution-based — code
39
39
  // ran and behaved insecurely — but they are proven by different evidence, and
40
40
  // conflating them is how a template ends up asserting something it never saw.
41
- const SUPPORTED = new Set([
41
+ export const SUPPORTED = new Set([
42
42
  'command-injection',
43
43
  'code-injection',
44
44
  'webhook-missing-signature-verification',
45
45
  'sql-injection',
46
46
  'path-traversal',
47
+ 'ldap-injection',
48
+ 'xxe',
47
49
  ]);
48
50
 
49
51
  // Classes proven by observing the HANDLER's behaviour rather than a payload
@@ -53,6 +55,8 @@ const BEHAVIOURAL = new Map([
53
55
  ['webhook-missing-signature-verification', (f, c) => _webhookPoc(f, c)],
54
56
  ['sql-injection', (f, c) => _sqlInjectionPoc(f, c)],
55
57
  ['path-traversal', (f, c) => _pathTraversalPoc(f, c)],
58
+ ['ldap-injection', (f, c) => _ldapInjectionPoc(f, c)],
59
+ ['xxe', (f, c) => _xxePoc(f, c)],
56
60
  ]);
57
61
 
58
62
  // Classes deliberately NOT here, with the reason, so the gap is a decision
@@ -491,6 +495,218 @@ function _sqlInjectionPoc(finding, fileContent) {
491
495
  const TRAVERSAL_SENTINEL = 'PROVEN_TRAVERSAL_SENTINEL_CONTENT';
492
496
  const READ_SINK = /\b(?:readFile|readFileSync|sendFile|readFileAsync)\s*\(/;
493
497
 
498
+
499
+ // ── LDAP injection (CWE-90) ────────────────────────────────────────────────
500
+ //
501
+ // Added because `ldap-injection` was the largest UNCLASSIFIED family in the
502
+ // published proof-coverage breakdown (10 of 280 corpus findings) and it has the
503
+ // same property that makes SQL provable: a decidable boundary where the
504
+ // question is settled without a running directory server.
505
+ //
506
+ // For SQL the question is "did the payload arrive as query TEXT or as a bound
507
+ // parameter". For LDAP it is "did the payload's filter METACHARACTERS survive".
508
+ // A correctly escaped value still contains the sentinel — `ldap_escape`,
509
+ // `EqualityFilter` and friends escape `(`, `)`, `*` and `\` to `\28`, `\29`,
510
+ // `\2a`, `\5c` — so matching the sentinel alone would call a FIXED handler
511
+ // vulnerable. The proof requires the sentinel AND an unescaped metacharacter in
512
+ // the same filter string.
513
+ const LDAP_SENTINEL = 'PROVEN_LDAPI';
514
+ // RELATIVE, like SQL_LOG. An absolute path (/tmp/...) is denied by the
515
+ // confinement backend, so the recorder silently wrote nothing and the proof
516
+ // came back proof-failed on a genuinely vulnerable handler — the sandbox
517
+ // working exactly as intended, and a reminder that a PoC must live entirely
518
+ // inside the sandbox root.
519
+ const LDAP_LOG = 'ldap-calls.jsonl';
520
+ const LDAP_DRIVERS = ['ldapjs', 'ldap-authentication', 'activedirectory', 'activedirectory2', 'ldapts'];
521
+ const LDAP_DRIVER_RES = LDAP_DRIVERS.map((d) => [
522
+ d, new RegExp(`require\\(\\s*['"\`]${d}['"\`]|from\\s*['"\`]${d}['"\`]`),
523
+ ]);
524
+
525
+ const LDAP_STUB = [
526
+ "const fs = require('fs');",
527
+ 'function record(args) {',
528
+ ' const filters = [];',
529
+ ' for (const a of args) {',
530
+ " if (typeof a === 'string') filters.push(a);",
531
+ " else if (a && typeof a === 'object') {",
532
+ " if (typeof a.filter === 'string') filters.push(a.filter);",
533
+ " if (a.filter && typeof a.filter === 'object' && typeof a.filter.toString === 'function') filters.push(String(a.filter));",
534
+ ' }',
535
+ ' }',
536
+ ' if (filters.length) {',
537
+ ` try { fs.appendFileSync(${JSON.stringify(LDAP_LOG)}, JSON.stringify({ filters }) + '\\n'); } catch {}`,
538
+ ' }',
539
+ '}',
540
+ 'const mk = () => new Proxy(function () {}, {',
541
+ " get(t, p) { if (typeof p === 'symbol' || p === 'then' || p === 'inspect') return undefined; return mk(); },",
542
+ ' apply(t, self, args) {',
543
+ ' record(args);',
544
+ " for (const a of args) if (typeof a === 'function') { try { a(null, { on: () => {} }); } catch {} }",
545
+ ' return mk();',
546
+ ' },',
547
+ '});',
548
+ 'module.exports = mk();',
549
+ ].join('\n');
550
+
551
+ function _ldapInjectionPoc(finding, fileContent) {
552
+ const driver = (LDAP_DRIVER_RES.find(([, re]) => re.test(fileContent)) || [])[0];
553
+ if (!driver) {
554
+ return {
555
+ ok: false,
556
+ reason: `no recognised LDAP client is required by this file, so there is no boundary to observe the filter at (looked for: ${LDAP_DRIVERS.join(', ')})`,
557
+ };
558
+ }
559
+ const found = _findHandler(fileContent);
560
+ if (!found) return NO_HANDLER;
561
+ const { call, reqIdent } = found;
562
+
563
+ const src = _requestSource(fileContent, reqIdent, finding.line);
564
+ if (!src) {
565
+ return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
566
+ }
567
+
568
+ const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
569
+ // Filter metacharacters plus the sentinel. An escaping handler keeps the
570
+ // sentinel and neutralises the parens/star, which is exactly the difference
571
+ // the decision below reads.
572
+ const payload = `*)(uid=*))(|(uid=*${LDAP_SENTINEL}`;
573
+ // The distinctive raw tail of that payload. Present verbatim only if no
574
+ // escaping happened between the request and the client.
575
+ const RAW_TAIL = `)(|(uid=*${LDAP_SENTINEL}`;
576
+
577
+ const code = [
578
+ `// Auto-generated proof for ${finding.file}.`,
579
+ `// Stubs the '${driver}' client with a recorder and calls the handler with a`,
580
+ '// payload carrying LDAP filter metacharacters. The marker is written only if',
581
+ '// those metacharacters reached the filter UNESCAPED — an escaped value still',
582
+ '// carries the sentinel, so the sentinel alone proves nothing.',
583
+ importLine,
584
+ "import fs from 'node:fs';",
585
+ 'await new Promise((resolve) => {',
586
+ ' let timer = null;',
587
+ ' const done = () => { clearTimeout(timer); resolve(); };',
588
+ ' const res = {',
589
+ ' send: done, json: done, end: done,',
590
+ ' status: () => ({ send: done, json: done, end: done }),',
591
+ ' };',
592
+ ` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: payload })} };`,
593
+ ' timer = setTimeout(resolve, 3000);',
594
+ ` try { ${invoke}(req, res); } catch { done(); }`,
595
+ '});',
596
+ '',
597
+ '// The whole decision, stated once: did the PAYLOAD\'s OWN metacharacters',
598
+ '// survive verbatim? Looking for any raw paren near the sentinel does not',
599
+ '// work — the application\'s filter template (\'(uid=\' + v + \')\') always',
600
+ '// contributes raw parens of its own, so that test called an ESCAPING',
601
+ '// handler vulnerable. Escaping rewrites the payload tail to \\2a\\29\\28…,',
602
+ '// so its verbatim presence is the bug and its absence is the fix working.',
603
+ 'let proven = false;',
604
+ 'try {',
605
+ ` for (const line of fs.readFileSync(${JSON.stringify(LDAP_LOG)}, 'utf8').split('\\n')) {`,
606
+ ' if (!line.trim()) continue;',
607
+ ' const rec = JSON.parse(line);',
608
+ ` for (const f of rec.filters) if (f.includes(${JSON.stringify(RAW_TAIL)})) proven = true;`,
609
+ ' }',
610
+ '} catch {}',
611
+ `if (proven) fs.writeFileSync('${MARKER}', 'x');`,
612
+ ].join('\n');
613
+
614
+ return {
615
+ ok: true,
616
+ poc: {
617
+ lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
618
+ marker: MARKER, paramKey: src.key, paramSource: src.prop, handler: handlerLabel,
619
+ driver,
620
+ observes: 'the request payload reached the LDAP client with its filter metacharacters unescaped',
621
+ requires: [base],
622
+ extraFiles: { [`node_modules/${driver}/index.js`]: LDAP_STUB },
623
+ code,
624
+ },
625
+ };
626
+ }
627
+
628
+
629
+ // ── XXE (CWE-611) ──────────────────────────────────────────────────────────
630
+ //
631
+ // The third-largest unclassified family in the published proof-coverage
632
+ // breakdown. It is provable in-process for a reason the SSRF class is NOT: the
633
+ // external entity can point at a LOCAL file the harness plants inside the
634
+ // sandbox, so the proof needs no network and a failed fetch can never be
635
+ // confinement talking.
636
+ //
637
+ // The decision is whether the parser RESOLVED the entity. The sentinel content
638
+ // lives only in a file the XML never contains, so its presence in the parsed
639
+ // output means the parser went and read it — which is the vulnerability. A
640
+ // parser with entity expansion off returns the entity unexpanded (or throws),
641
+ // and neither writes the marker.
642
+ const XXE_SENTINEL = 'PROVEN_XXE_ENTITY_RESOLVED';
643
+ const XXE_SENTINEL_FILE = 'xxe-sentinel.txt';
644
+ const XXE_PARSERS = ['libxmljs', 'libxmljs2', '@xmldom/xmldom', 'xmldom', 'node-expat'];
645
+ const XXE_PARSER_RES = XXE_PARSERS.map((d) => [
646
+ d, new RegExp(`require\\(\\s*['"\`]${d.replace('/', '\\/')}['"\`]|from\\s*['"\`]${d.replace('/', '\\/')}['"\`]`),
647
+ ]);
648
+
649
+ function _xxePoc(finding, fileContent) {
650
+ const parser = (XXE_PARSER_RES.find(([, re]) => re.test(fileContent)) || [])[0];
651
+ if (!parser) {
652
+ return {
653
+ ok: false,
654
+ reason: `no recognised XML parser is required by this file, so there is nothing to observe entity resolution at (looked for: ${XXE_PARSERS.join(', ')})`,
655
+ };
656
+ }
657
+ const found = _findHandler(fileContent);
658
+ if (!found) return NO_HANDLER;
659
+ const { call, reqIdent } = found;
660
+
661
+ const src = _requestSource(fileContent, reqIdent, finding.line);
662
+ if (!src) {
663
+ return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the XML entry point is unknown` };
664
+ }
665
+
666
+ const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
667
+ const xml = `<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file://./${XXE_SENTINEL_FILE}">]><r>&x;</r>`;
668
+
669
+ const code = [
670
+ `// Auto-generated proof for ${finding.file}.`,
671
+ '// Plants a sentinel file, hands the handler XML whose external entity points',
672
+ '// at it, and writes the marker only if the SENTINEL CONTENT comes back —',
673
+ '// which can only happen if the parser resolved the entity and read the file.',
674
+ '// A parser with entity expansion disabled returns it unexpanded or throws.',
675
+ importLine,
676
+ "import fs from 'node:fs';",
677
+ `fs.writeFileSync(${JSON.stringify(XXE_SENTINEL_FILE)}, ${JSON.stringify(XXE_SENTINEL)});`,
678
+ 'let seen = "";',
679
+ 'await new Promise((resolve) => {',
680
+ ' let timer = null;',
681
+ ' const capture = (v) => { try { seen += typeof v === "string" ? v : JSON.stringify(v); } catch {} clearTimeout(timer); resolve(); };',
682
+ ' const res = {',
683
+ ' send: capture, json: capture, end: capture,',
684
+ ' status: () => ({ send: capture, json: capture, end: capture }),',
685
+ ' };',
686
+ ` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: xml })} };`,
687
+ ' timer = setTimeout(resolve, 3000);',
688
+ ` try { const r = ${invoke}(req, res); if (r && typeof r.then === "function") r.then(capture, () => resolve()); } catch { resolve(); }`,
689
+ '});',
690
+ '',
691
+ '// The whole decision, stated once: the sentinel STRING is only reachable by',
692
+ '// reading the planted file. Matching the entity name or the XML itself would',
693
+ '// be satisfied by a parser that never expanded anything.',
694
+ `if (seen.includes(${JSON.stringify(XXE_SENTINEL)})) fs.writeFileSync('${MARKER}', 'x');`,
695
+ ].join('\n');
696
+
697
+ return {
698
+ ok: true,
699
+ poc: {
700
+ lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
701
+ marker: MARKER, paramKey: src.key, paramSource: src.prop, handler: handlerLabel,
702
+ driver: parser,
703
+ observes: 'the XML parser resolved an external entity and returned the contents of a local file',
704
+ requires: [base],
705
+ code,
706
+ },
707
+ };
708
+ }
709
+
494
710
  function _pathTraversalPoc(finding, fileContent) {
495
711
  const found = _findHandler(fileContent);
496
712
  if (!found) return NO_HANDLER;
@@ -0,0 +1,162 @@
1
+ // PRD F7.2 — publish what CANNOT be proven, and how much of the finding set that is.
2
+ //
3
+ // The PoC generator declines whole vulnerability classes it cannot honestly
4
+ // prove. Those reasons were documented in poc-inprocess.js's header and nowhere
5
+ // else, so the only number a reader ever saw was the proof RATE — computed over
6
+ // the findings that happened to be provable. A high rate on a small provable
7
+ // subset reads as strength and is nearly meaningless.
8
+ //
9
+ // Publishing which classes are structurally unprovable, and what share of real
10
+ // findings they represent, is more credible than a high proof rate. It is also
11
+ // the harder number to publish, which is rather the point.
12
+ //
13
+ // THE THREE BUCKETS, and why "unclassified" is separate from "indeterminate":
14
+ //
15
+ // provable — a proof class exists and a PoC can be attempted.
16
+ // indeterminate — the class is DECLINED ON PURPOSE, with a stated reason.
17
+ // Not a gap in effort; a limit of what a single-shot harness
18
+ // can honestly assert.
19
+ // unclassified — no proof class covers it and no decision has been recorded.
20
+ // This is the genuine backlog, and it is kept apart so it can
21
+ // never hide inside the principled exclusions.
22
+ // out-of-scope — the in-process harness is JAVASCRIPT-ONLY. A Python, Java,
23
+ // C#, PHP, Kotlin, Go or Ruby finding cannot be proven by it
24
+ // at any effort, so it is not backlog — it is the CEILING.
25
+ //
26
+ // That fourth bucket is the "stated ceiling" Feature 7's exit gate asks for, and
27
+ // omitting it made the headline number mean two different things at once.
28
+ // Measured on the CVE corpus: 280 findings, of which only 76 are JS/TS. Proof
29
+ // coverage is 26% of ALL findings and 96% of the ones the harness can reach.
30
+ // Reporting the first alone understates the harness; reporting the second alone
31
+ // overstates the product. Both are published, with the denominator on each.
32
+ //
33
+ // Folding `unclassified` into `indeterminate` would let "we haven't looked at
34
+ // this" borrow the credibility of "we looked and it can't be done".
35
+ import { SUPPORTED } from './poc-inprocess.js';
36
+
37
+ // Declined on purpose. Reasons are the ones recorded in poc-inprocess.js — kept
38
+ // as DATA here so a report can print them, rather than living only in a comment
39
+ // nobody downstream can read.
40
+ export const INDETERMINATE_BY_CLASS = Object.freeze({
41
+ 'idor': 'proving it means showing user A read user B\'s record, which needs two authenticated identities and a populated data store. A single-shot harness would invent both, and a PoC built on invented state proves something about the invention.',
42
+ 'broken-access-control': 'same as idor — requires two identities and real state.',
43
+ 'broken-authz': 'same as idor — requires two identities and real state.',
44
+ 'ssrf': 'the proof is that the server fetched an attacker-named host. The sandbox denies egress by design, so a failed fetch is confinement talking, not the finding.',
45
+ 'xss': 'needs a browser to say whether the payload executed; a marker file cannot observe a DOM.',
46
+ 'mutation-xss': 'needs a DOM and a parser round-trip — same limit as xss.',
47
+ 'open-redirect': 'the effect is a Location header a browser would follow; nothing in a marker-file harness observes the follow.',
48
+ });
49
+
50
+ // The in-process harness only ever loads JavaScript (see poc-inprocess.js's
51
+ // JS_EXT and its "ES-module source" refusal). Anything else is unreachable by
52
+ // construction.
53
+ const HARNESS_LANGS = /\.(?:js|cjs|mjs|jsx|ts|tsx)$/i;
54
+
55
+ // Internal: `bucketOf` is the public surface for this question. Not exported —
56
+ // the dead-export guard is right that a second entry point with no caller is
57
+ // just surface area.
58
+ function outOfHarnessScope(finding) {
59
+ const file = finding && typeof finding.file === 'string' ? finding.file : '';
60
+ if (!file) return false; // unknown file — do not claim a ceiling
61
+ return !HARNESS_LANGS.test(file);
62
+ }
63
+
64
+ /** Bucket a finding: 'provable' | 'indeterminate' | 'unclassified' | 'out-of-scope'. */
65
+ export function bucketOf(finding) {
66
+ // Language first: a Python SQL-injection finding is not "provable", however
67
+ // good the SQL proof class is. Ordering this after the class check would
68
+ // report a ceiling case as a capability.
69
+ if (outOfHarnessScope(finding)) return 'out-of-scope';
70
+ const fam = finding && typeof finding.family === 'string' ? finding.family : '';
71
+ if (!fam) return 'unclassified';
72
+ // Families are emitted as `<base>-<rule-slug>` in places, so match the base
73
+ // too — the same resolution problem the compliance evaluator hit.
74
+ const base = fam.split('-').slice(0, 2).join('-');
75
+ for (const key of SUPPORTED) {
76
+ if (fam === key || fam.startsWith(`${key}-`)) return 'provable';
77
+ }
78
+ for (const key of Object.keys(INDETERMINATE_BY_CLASS)) {
79
+ if (fam === key || fam.startsWith(`${key}-`) || base === key) return 'indeterminate';
80
+ }
81
+ return 'unclassified';
82
+ }
83
+
84
+ /**
85
+ * Proof coverage over a finding set.
86
+ *
87
+ * Every share carries {n, d} — a percentage without its denominator is exactly
88
+ * the shape of claim this module exists to replace.
89
+ */
90
+ export function proofCoverage(findings) {
91
+ const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
92
+ const d = list.length;
93
+ const buckets = { provable: [], indeterminate: [], unclassified: [], 'out-of-scope': [] };
94
+ for (const f of list) buckets[bucketOf(f)].push(f);
95
+
96
+ const byClass = {};
97
+ for (const f of buckets.indeterminate) {
98
+ const fam = f.family || '(none)';
99
+ const key = Object.keys(INDETERMINATE_BY_CLASS)
100
+ .find(k => fam === k || fam.startsWith(`${k}-`) || fam.split('-').slice(0, 2).join('-') === k) || fam;
101
+ if (!byClass[key]) byClass[key] = { n: 0, reason: INDETERMINATE_BY_CLASS[key] || 'declined' };
102
+ byClass[key].n += 1;
103
+ }
104
+
105
+ const unclassifiedFamilies = {};
106
+ for (const f of buckets.unclassified) {
107
+ const fam = f.family || '(no family)';
108
+ unclassifiedFamilies[fam] = (unclassifiedFamilies[fam] || 0) + 1;
109
+ }
110
+
111
+ const reachable = d - buckets['out-of-scope'].length;
112
+ const outLangs = {};
113
+ for (const f of buckets['out-of-scope']) {
114
+ const ext = (String(f.file || '').match(/\.[a-z0-9]+$/i) || ['(none)'])[0].toLowerCase();
115
+ outLangs[ext] = (outLangs[ext] || 0) + 1;
116
+ }
117
+
118
+ return {
119
+ total: d,
120
+ // Reported BOTH ways on purpose. `provable.d` is every finding; `ofReachable`
121
+ // is the share of what the harness can actually load. Publishing only one of
122
+ // them is how a number ends up meaning whatever the reader assumes.
123
+ provable: { n: buckets.provable.length, d, ofReachable: { n: buckets.provable.length, d: reachable } },
124
+ ceiling: {
125
+ reachable: { n: reachable, d },
126
+ outOfScope: { n: buckets['out-of-scope'].length, d, byExtension: outLangs },
127
+ reason: 'the in-process proof harness only loads JavaScript; findings in other languages cannot be execution-proven by it at any effort',
128
+ },
129
+ indeterminate: { n: buckets.indeterminate.length, d, byClass },
130
+ unclassified: { n: buckets.unclassified.length, d, families: unclassifiedFamilies },
131
+ provableClasses: [...SUPPORTED],
132
+ meaning: 'provable = a proof class exists; indeterminate = declined on purpose with a stated reason; unclassified = no proof class and no decision yet (the real backlog); out-of-scope = a language the JS-only harness cannot load at all (the ceiling, not backlog).',
133
+ };
134
+ }
135
+
136
+ /** Markdown for the scorecard. Every rate keeps its denominator. */
137
+ export function renderProofCoverage(cov) {
138
+ if (!cov || !cov.total) return '_No findings to report proof coverage over._\n';
139
+ const pct = (n) => `${n}/${cov.total} (${Math.round((n / cov.total) * 100)}%)`;
140
+ const lines = [
141
+ '| Bucket | Share | Meaning |',
142
+ '|---|---|---|',
143
+ `| Provable | ${pct(cov.provable.n)} | a proof class exists and a PoC can be attempted |`,
144
+ `| Indeterminate by class | ${pct(cov.indeterminate.n)} | declined on purpose — see reasons below |`,
145
+ `| Unclassified | ${pct(cov.unclassified.n)} | no proof class yet; the real backlog |`,
146
+ `| Out of harness scope | ${pct(cov.ceiling.outOfScope.n)} | not JavaScript — the harness cannot load it at all |`,
147
+ '',
148
+ // Stated every time, because the two readings of "provable" differ by a lot
149
+ // and a reader who sees only the first will draw the wrong conclusion.
150
+ `**Ceiling.** ${cov.ceiling.reason}. Of ${cov.total} findings, ${cov.ceiling.reachable.n} are reachable by the harness; `
151
+ + `proof coverage is ${cov.provable.n}/${cov.total} of ALL findings and `
152
+ + `${cov.provable.ofReachable.n}/${cov.provable.ofReachable.d} of the reachable ones.`,
153
+ '',
154
+ ];
155
+ const entries = Object.entries(cov.indeterminate.byClass).sort((a, b) => b[1].n - a[1].n);
156
+ if (entries.length) {
157
+ lines.push('**Why each class is declined**', '');
158
+ for (const [cls, { n, reason }] of entries) lines.push(`- \`${cls}\` (${n}): ${reason}`);
159
+ lines.push('');
160
+ }
161
+ return lines.join('\n');
162
+ }