@clear-capabilities/agentic-security-scanner 0.139.1 → 0.141.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 +221 -0
  2. package/bin/agentic-security.js +40 -11
  3. package/dist/113.index.js +79 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/238.index.js +77 -1
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +12 -0
  8. package/dist/526.index.js +79 -3
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +14 -14
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/compliance-frameworks/ccpa.json +34 -7
  13. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  14. package/dist/compliance-frameworks/gdpr.json +56 -12
  15. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  16. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  17. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  18. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  19. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  20. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  21. package/package.json +16 -5
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/engine.js +281 -23
  24. package/src/mcp/tools.js +12 -0
  25. package/src/posture/accuracy-scorecard.js +57 -0
  26. package/src/posture/aibom.js +110 -1
  27. package/src/posture/auditor-walkthrough.js +137 -21
  28. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  29. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  30. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  31. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  32. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  33. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  34. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  35. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  36. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  37. package/src/posture/concurrency-checker.js +42 -5
  38. package/src/posture/coverage-strength.js +182 -0
  39. package/src/posture/epss.js +17 -1
  40. package/src/posture/family-registry.js +103 -0
  41. package/src/posture/family-resolve.js +47 -0
  42. package/src/posture/fix-coverage.js +113 -0
  43. package/src/posture/fix-metrics.js +76 -0
  44. package/src/posture/integrity.js +59 -8
  45. package/src/posture/mcp-rug-pull.js +144 -0
  46. package/src/posture/poc-generator.js +17 -1
  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 +50 -7
  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 +337 -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
@@ -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
+ }
@@ -71,3 +71,47 @@ export function demoteUnreachable(findings, opts = {}) {
71
71
  f._reachabilityDemoted = before;
72
72
  }
73
73
  }
74
+
75
+ // ── PRD F3.2 — reachability is its OWN claim, scored separately ────────────
76
+ //
77
+ // "A vulnerable version is present" and "the vulnerable FUNCTION is reachable"
78
+ // are different assertions with different error costs, and they were reported as
79
+ // one number.
80
+ //
81
+ // A false "unreachable" is a MISSED EXPLOIT — the finding is demoted to info
82
+ // and a real vulnerability stops being shown.
83
+ // A false "reachable" is noise — someone reads a finding that did not matter.
84
+ //
85
+ // Those costs are not symmetric, so a single accuracy figure covering both is
86
+ // the wrong instrument. This reports each separately with {n, d}, plus the
87
+ // DEMOTION RATE, which is the number that says how much work the reachability
88
+ // claim is doing: a demotion rate near zero means the feature is not earning
89
+ // its risk, and a high one means a great deal rests on it being right.
90
+ export function summarizeReachability(findings) {
91
+ const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
92
+
93
+ // Only findings the analysis actually had an opinion about belong in the
94
+ // denominator. A finding with no reachability verdict is UNKNOWN, and folding
95
+ // unknowns into "reachable" would inflate the claim being measured.
96
+ const judged = list.filter((f) => f.unreachable === true || f.reachable === true || f.functionReachable != null);
97
+ const d = judged.length;
98
+
99
+ const demoted = judged.filter((f) => f.unreachable === true);
100
+ const reachable = judged.filter((f) => f.unreachable !== true);
101
+
102
+ return {
103
+ total: list.length,
104
+ judged: { n: d, d: list.length },
105
+ unknown: { n: list.length - d, d: list.length },
106
+ reachable: { n: reachable.length, d },
107
+ unreachable: { n: demoted.length, d },
108
+ demotionRate: { n: demoted.length, d },
109
+ errorCosts: {
110
+ falseUnreachable: 'a MISSED EXPLOIT — the finding is demoted to info and a real vulnerability stops being shown',
111
+ falseReachable: 'noise — someone reads a finding that did not matter',
112
+ },
113
+ caveat: d === 0
114
+ ? 'nothing was judged for reachability; every rate is 0/0 and means nothing'
115
+ : 'unknown is a first-class state and is NOT counted as reachable',
116
+ };
117
+ }
@@ -7,24 +7,63 @@
7
7
  // SPDX 2.3 schema reference: https://spdx.github.io/spdx-spec/v2.3/
8
8
 
9
9
  import * as crypto from 'node:crypto';
10
+ import { isDeterministic } from './deterministic.js';
10
11
 
11
12
  function _purl(c) {
12
13
  if (c.purl) return c.purl;
13
14
  const eco = c.ecosystem || 'generic';
14
15
  const name = encodeURIComponent(c.name || '');
15
- const ver = encodeURIComponent(c.version || '');
16
+ // Same rule as _bomRef: no version means no `@version` segment at all, not an
17
+ // empty or undefined one. purl consumers treat `pkg:npm/x@` as malformed.
18
+ const ver = c.version ? encodeURIComponent(c.version) : '';
16
19
  // pkg:npm/<name>@<version> — pkg URL spec
17
- return `pkg:${eco === 'npm' ? 'npm' : eco === 'pypi' ? 'pypi' : eco === 'maven' ? 'maven' : eco === 'cargo' ? 'cargo' : eco === 'go' ? 'golang' : eco === 'rubygems' ? 'gem' : eco === 'composer' ? 'composer' : eco}/${name}@${ver}`;
20
+ return `pkg:${eco === 'npm' ? 'npm' : eco === 'pypi' ? 'pypi' : eco === 'maven' ? 'maven' : eco === 'cargo' ? 'cargo' : eco === 'go' ? 'golang' : eco === 'rubygems' ? 'gem' : eco === 'composer' ? 'composer' : eco}/${name}${ver ? `@${ver}` : ''}`;
18
21
  }
19
22
 
20
23
  function _bomRef(c) {
21
- return `${c.ecosystem || 'pkg'}:${c.name}@${c.version}`;
24
+ // A component with no version is ordinary — unpinned entries appear in real
25
+ // manifests — and the identifier must DEGRADE rather than interpolate a JS
26
+ // value. `npm:x@undefined` is not a version anyone can resolve, and it ships
27
+ // inside a document whose whole purpose is to be parsed by someone else's
28
+ // tooling, where it fails days later pointing at them rather than at us.
29
+ const eco = c.ecosystem || 'pkg';
30
+ const name = c.name || 'unknown';
31
+ return c.version ? `${eco}:${name}@${c.version}` : `${eco}:${name}`;
32
+ }
33
+
34
+ // CycloneDX `serialNumber` and SPDX `documentNamespace` are both required to
35
+ // identify a document, and both were minted with crypto.randomUUID() — so two
36
+ // scans of identical input produced different bytes, and `--deterministic` did
37
+ // not actually make an SBOM reproducible. An attestation over an SBOM is only
38
+ // meaningful if the SBOM can be regenerated and compared.
39
+ //
40
+ // Under --deterministic the identifier is derived from the document's own
41
+ // content instead of randomness. That preserves what the identifier is FOR:
42
+ // different content still yields a different id, while identical content
43
+ // yields an identical one — the standard reproducible-build treatment. Outside
44
+ // deterministic mode the random UUID is unchanged, so ordinary scans keep
45
+ // per-run-unique document ids.
46
+ function _stableUuidFrom(seed) {
47
+ const h = crypto.createHash('sha256').update(String(seed)).digest('hex');
48
+ // Shape the digest as a v4-looking UUID: the version/variant nibbles are set
49
+ // so consumers that validate the format still accept it.
50
+ return [
51
+ h.slice(0, 8),
52
+ h.slice(8, 12),
53
+ `4${h.slice(13, 16)}`,
54
+ `${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}`,
55
+ h.slice(20, 32),
56
+ ].join('-');
57
+ }
58
+
59
+ function _documentUuid(seed) {
60
+ return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
22
61
  }
23
62
 
24
63
  export function toCycloneDX(scan, meta = {}) {
25
64
  const components = scan.components || [];
26
65
  const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
27
- const serialNumber = `urn:uuid:${crypto.randomUUID()}`;
66
+ const serialNumber = `urn:uuid:${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
28
67
 
29
68
  const cdxComponents = components.map(c => ({
30
69
  type: 'library',
@@ -36,8 +75,12 @@ export function toCycloneDX(scan, meta = {}) {
36
75
  ...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
37
76
  }));
38
77
 
39
- const vulnerabilities = supplyChain.map(s => ({
40
- 'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || crypto.randomUUID()}`,
78
+ const vulnerabilities = supplyChain.map((s, i) => ({
79
+ // The last-resort id was crypto.randomUUID(), which reintroduced
80
+ // per-run drift for any advisory carrying neither an osvId nor an
81
+ // advisory string. Index within the (already deterministically sorted)
82
+ // supplyChain array identifies it just as well and is reproducible.
83
+ 'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || `unidentified-${i}`}`,
41
84
  id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
42
85
  source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
43
86
  references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
@@ -72,7 +115,7 @@ export function toCycloneDX(scan, meta = {}) {
72
115
  export function toSPDX(scan, meta = {}) {
73
116
  const components = scan.components || [];
74
117
  const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
75
- const docNamespace = `https://agentic-security.local/spdx/${crypto.randomUUID()}`;
118
+ const docNamespace = `https://agentic-security.local/spdx/${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
76
119
  const ts = meta.startedAt || new Date().toISOString();
77
120
 
78
121
  const packages = components.map((c, i) => ({
package/src/runScan.js CHANGED
@@ -4,7 +4,7 @@ import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
6
  import { listFiles } from './util/glob.js';
7
- import { runFullScan, shouldScan, isKubernetesManifest } from './engine.js';
7
+ import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
8
8
  import { appendScanSnapshot } from './posture/security-trend.js';
9
9
  import { recover as recoverFixHistory } from './posture/fix-history.js';
10
10
  import { stampScan } from './posture/ruleset-version.js';
@@ -13,11 +13,55 @@ const DEP_FILE_NAMES = new Set([
13
13
  'package.json','package-lock.json','yarn.lock','pnpm-lock.yaml',
14
14
  'requirements.txt','pyproject.toml','poetry.lock','Pipfile.lock',
15
15
  'composer.json','composer.lock','Gemfile','Gemfile.lock',
16
- 'go.mod','Cargo.toml','Cargo.lock',
16
+ // go.sum, not just go.mod: go.mod lists what this module REQUIRES, go.sum
17
+ // lists what was RESOLVED — the transitive graph that is actually shipped.
18
+ // `_parseGoSum` and its dispatch entry have always existed in engine.js; the
19
+ // file simply never reached them, so Go SCA saw direct requires only.
20
+ // Measured by bench/sca-replay at 15 of 549 labelled vulnerable versions.
21
+ 'go.mod','go.sum','Cargo.toml','Cargo.lock',
17
22
  'pom.xml','build.gradle','build.gradle.kts',
18
23
  'pubspec.yaml','pubspec.lock',
19
24
  ]);
20
25
 
26
+ // Python requirements files are `requirements/dev.txt`, `requirements-dev.txt`
27
+ // and `requirements/base.txt` at least as often as they are the bare name.
28
+ // pallets/flask ships `requirements/dev.txt` and scored 0 of 11 labelled
29
+ // vulnerabilities until this matched.
30
+ //
31
+ // Deliberately narrow. An arbitrary `.txt` reaching the PyPI parser would
32
+ // invent components out of prose, which is a worse failure than missing one:
33
+ // a false dependency is unfalsifiable noise in a supply-chain report.
34
+ const REQUIREMENTS_FILE = /^requirements(?:[._-][\w.-]+)?\.txt$/i;
35
+ const REQUIREMENTS_DIR_FILE = /(?:^|\/)requirements\/[\w.-]+\.txt$/i;
36
+
37
+ export function isDepFile(rel) {
38
+ const base = rel.split('/').pop();
39
+ if (DEP_FILE_NAMES.has(base)) return true;
40
+ if (REQUIREMENTS_FILE.test(base)) return true;
41
+ if (REQUIREMENTS_DIR_FILE.test(rel.split(path.sep).join('/'))) return true;
42
+ return false;
43
+ }
44
+
45
+ // Two caps, because the two kinds of file cost completely different amounts to
46
+ // process.
47
+ //
48
+ // A CODE file over the cap is skipped to protect the analysis path: parsing and
49
+ // walking an AST of a multi-megabyte generated file is where a scan goes from
50
+ // slow to hung.
51
+ //
52
+ // A MANIFEST is read by JSON.parse or a line loop. Applying the code cap to it
53
+ // bought nothing and cost everything: npm/cli's package-lock.json is 666 KB,
54
+ // next.js's pnpm-lock.yaml is 910 KB, magento2's composer.lock is 501 KB — so
55
+ // on every project large enough for supply-chain risk to matter, the lockfile
56
+ // was dropped and SCA silently fell back to the exact versions that happened to
57
+ // appear in package.json. That is DIRECT dependencies only, while the headline
58
+ // claim of this feature is transitive reachability.
59
+ //
60
+ // The manifest cap is larger, not absent. Reading an unbounded file into memory
61
+ // to parse it is how a scan becomes a denial of service against its own host.
62
+ const MAX_CODE_BYTES = 500_000;
63
+ const MAX_DEP_BYTES = 10_000_000;
64
+
21
65
  const DEFAULT_IGNORE = [
22
66
  '**/node_modules/**','**/.git/**','**/__pycache__/**','**/vendor/**',
23
67
  '**/dist/**','**/build/**','**/.next/**','**/venv/**','**/env/**','**/.venv/**',
@@ -33,11 +77,12 @@ export async function readTree(root, { ignore = [] } = {}) {
33
77
  const abs = path.join(root, rel);
34
78
  let stat;
35
79
  try { stat = await fs.stat(abs); } catch { continue; }
36
- if (stat.size > 500_000) continue;
80
+ const dep = isDepFile(rel);
81
+ if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES)) continue;
37
82
  let content;
38
83
  try { content = await fs.readFile(abs, 'utf8'); } catch { continue; }
39
84
  const base = path.basename(rel);
40
- if (DEP_FILE_NAMES.has(base)) depFileContents[rel] = content;
85
+ if (dep) depFileContents[rel] = content;
41
86
  // Cross-language taint module needs to see openapi/swagger specs even
42
87
  // though they aren't "code" per se. Stash them in depFileContents so
43
88
  // they ride through to runFullScan without polluting the SAST loop.
@@ -48,7 +93,13 @@ export async function readTree(root, { ignore = [] } = {}) {
48
93
  // A Kubernetes manifest is admitted on CONTENT, not on living under a
49
94
  // directory named k8s/ — see isKubernetesManifest. Without this the
50
95
  // k8s-admission detector is wired into the dispatch and never invoked by it.
51
- if (shouldScan(rel) || isKubernetesManifest(rel, content)) fileContents[rel] = content;
96
+ // BOTH gates must open, exactly as the k8s fix required: runScan admits a
97
+ // file here, then runFullScan re-filters the same list. Opening only one
98
+ // leaves the detector just as dark.
99
+ // A CloudFormation template is a `.yaml`/`.json` that no path predicate can
100
+ // recognise — same problem as a Kubernetes manifest, same fix, and the same
101
+ // requirement that BOTH gates open: runFullScan re-filters this exact list.
102
+ if (shouldScan(rel) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
52
103
  // Auxiliary files: .properties files are referenced by Java rules
53
104
  // (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
54
105
  // aliases). They are not scannable for vulns themselves, but the