@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +192 -15
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +15 -15
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -57,12 +57,18 @@ export function computeDrift(scanRoot, opts = {}) {
57
57
  const fb = loadTriageFeedback(scanRoot);
58
58
  if (!fb.length) return { alarms: [], note: 'no-feedback-data' };
59
59
 
60
- // Group by family. Each entry should carry: family, verdict ('tp'|'fp'|'wai'),
61
- // reportedConfidence (0..1), ts.
60
+ // Group by family. Each entry should carry: family, verdict ('tp'|'fp'|'wontfix'),
61
+ // reportedConfidence (0..1), at.
62
+ // Field names/verdict values here previously didn't match the real
63
+ // producer (commands/triage.md's Step 2): it writes `at`, not `ts`, and
64
+ // never wrote `reportedConfidence` at all (fixed there alongside this —
65
+ // Stage 2 measurement-completeness audit) or a `'wai'` verdict (it only
66
+ // ever writes 'tp'|'fp'|'wontfix'), so this alarm could never fire against
67
+ // real triage data regardless of how badly calibration had drifted.
62
68
  const byFamily = new Map();
63
69
  for (const e of fb) {
64
- if (!e || !e.family || !inWindow(e.ts, window)) continue;
65
- if (!['tp', 'fp', 'wai'].includes(e.verdict)) continue;
70
+ if (!e || !e.family || !inWindow(e.at, window)) continue;
71
+ if (!['tp', 'fp', 'wontfix'].includes(e.verdict)) continue;
66
72
  if (typeof e.reportedConfidence !== 'number') continue;
67
73
  if (!byFamily.has(e.family)) byFamily.set(e.family, []);
68
74
  byFamily.get(e.family).push(e);
@@ -75,7 +81,7 @@ export function computeDrift(scanRoot, opts = {}) {
75
81
  const reportedAcc = entries.reduce((acc, e) => acc + e.reportedConfidence, 0) / entries.length;
76
82
  const divergence = Math.abs(reportedAcc - realizedAcc);
77
83
  if (divergence < threshold) continue;
78
- const firstTs = entries.map(e => e.ts).filter(Boolean).sort()[0];
84
+ const firstTs = entries.map(e => e.at).filter(Boolean).sort()[0];
79
85
  alarms.push({
80
86
  alarm: true,
81
87
  since: firstTs,
@@ -121,15 +121,37 @@ export function loadCalibrationHistory(scanRoot) {
121
121
  };
122
122
  if (seed) merge(seed);
123
123
  if (customer) merge(customer);
124
- // Merge triage-derived TP/FP counts (auto-feedback loop)
124
+ // Merge triage-derived TP/FP counts (auto-feedback loop).
125
+ //
126
+ // EA-01: only a state a human actually chose is a real signal. triage.js's
127
+ // syncWithScan sets every new finding to 'open' with no human involved, and
128
+ // auto-closes any finding the scanner stops seeing to 'fixed' with
129
+ // `automatic:true` on the transition — neither reflects a judgment that the
130
+ // finding was real. Counting them as TP made the "true positive" column
131
+ // entirely machine-generated while the FP column required an explicit human
132
+ // false-positive mark, so calibrated confidence trended toward 1.0 for any
133
+ // family with enough scan volume regardless of actual accuracy. Only a
134
+ // manual 'fixed' transition (confirmed real, now resolved) counts as TP;
135
+ // only 'false-positive' counts as FP. 'open', 'in-progress', 'wont-fix',
136
+ // and an automatically-reached 'fixed' contribute to neither bucket.
125
137
  try {
126
138
  const triage = _readJsonMaybe(statePath(scanRoot, 'triage.json'));
127
139
  if (triage && triage.findings) {
140
+ const lastFixedIsAutomatic = new Map();
141
+ for (const t of triage.transitions || []) {
142
+ if (t && t.to === 'fixed') lastFixedIsAutomatic.set(t.id, t.automatic === true);
143
+ }
128
144
  const triageFams = {};
129
145
  for (const f of Object.values(triage.findings)) {
130
146
  const fam = f.family || 'unknown';
131
147
  if (!triageFams[fam]) triageFams[fam] = { tp: 0, fp: 0 };
132
- if (f.state === 'fixed' || f.state === 'open' || f.state === 'in-progress') triageFams[fam].tp++;
148
+ // Require POSITIVE proof of a manual transition (=== false), not
149
+ // merely the absence of proof of an automatic one (!== true) — the
150
+ // latter fails OPEN: a finding with no transition record at all
151
+ // (lastFixedIsAutomatic.get returns undefined) counted as a manual
152
+ // TP by default, exactly the machine-generated-signal failure mode
153
+ // this whole block's comment states it exists to prevent.
154
+ if (f.state === 'fixed' && lastFixedIsAutomatic.get(f.id) === false) triageFams[fam].tp++;
133
155
  else if (f.state === 'false-positive') triageFams[fam].fp++;
134
156
  }
135
157
  merge({ families: triageFams });
@@ -28,7 +28,18 @@ function sinkKey(f) {
28
28
  const parser = f.parser || '';
29
29
  const rule = f.cwe || f.family || (f.vuln || '').slice(0, 40);
30
30
  const file = f.file || f.sink?.file || '';
31
- const sinkExpr = (f.sink?.label || f.sink?.snippet || f.snippet || '')
31
+ // PRD R3: dataflow/engine.js sets sink.label to the catalog rule id
32
+ // (f.sinkId) for IR-TAINT findings — the same generic string for every
33
+ // callsite of that rule, not a per-callsite snippet. Two textually-unrelated
34
+ // sinks sharing a rule (e.g. two independent eval() calls in one file) would
35
+ // otherwise collapse into one cluster and silently drop a real finding. The
36
+ // dedup pass upstream (dedupeFindingsWithEvidence) already collapses
37
+ // multiple sources converging on the SAME sink line, so every IR-TAINT
38
+ // finding reaching this point has a distinct line — folding the line into
39
+ // the key here can only split buckets more finely, never wrongly merge one.
40
+ const sinkExpr = (parser === 'IR-TAINT'
41
+ ? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}`
42
+ : (f.sink?.label || f.sink?.snippet || f.snippet || ''))
32
43
  .replace(/['"`][^'"`]*['"`]/g, '_S_')
33
44
  .replace(/\s+/g, ' ')
34
45
  .trim()
@@ -781,7 +781,7 @@
781
781
  "summary": "Software is maintained, replaced, and removed commensurate with risk.",
782
782
  "codeTestable": "partial",
783
783
  "mapsTo": [
784
- "family:vulnerable-dependency",
784
+ "family:vulnerable-dep",
785
785
  "family:dependency-drift"
786
786
  ]
787
787
  },
@@ -800,7 +800,7 @@
800
800
  "codeTestable": "yes",
801
801
  "mapsTo": [
802
802
  "family:dependency-confusion",
803
- "family:vulnerable-dependency"
803
+ "family:vulnerable-dep"
804
804
  ]
805
805
  },
806
806
  {
@@ -66,7 +66,7 @@
66
66
  "category": "Malicious Code",
67
67
  "summary": "Verify the application does not include known-malicious or compromised dependencies.",
68
68
  "evidence": [".agentic-security/sbom-history snapshots clean.", "No dependency-confusion or dependency-drift findings."],
69
- "mapsTo": ["family:vulnerable-dependency", "family:dependency-confusion", "family:dependency-drift"]
69
+ "mapsTo": ["family:vulnerable-dep", "family:dependency-confusion", "family:dependency-drift"]
70
70
  },
71
71
  {
72
72
  "id": "V14.1",
@@ -41,6 +41,7 @@ import * as fs from 'node:fs';
41
41
  import * as path from 'node:path';
42
42
  import * as yaml from '../util/yaml.js';
43
43
  import { statePath, safeWriteState } from './state-dir.js';
44
+ import { SCANNER_VERSION } from './version.js';
44
45
 
45
46
  const POLICY_FILE = 'compliance.policy.yml';
46
47
 
@@ -128,7 +129,29 @@ function _runCheck(check, ctx) {
128
129
  * Run all controls in the policy and emit a verification report.
129
130
  */
130
131
  export function verifyPolicy(policy, ctx) {
132
+ // CMP-5: loadPolicy() reports a parse failure as { _error }, which has no
133
+ // `.controls` — the same shape as "no policy file at all". Distinguishing
134
+ // them matters: a customer who typo'd their YAML deserves a loud error,
135
+ // not a report that silently treats their (unparsed) policy as having no
136
+ // controls to check.
137
+ if (policy && policy._error) return { controls: [], status: 'error', error: policy._error };
131
138
  if (!policy || !policy.controls) return { controls: [], status: 'no-policy' };
139
+ // CMP-5: a finding-family check must see every channel a real scan
140
+ // produces (findings=SAST, secrets, logicVulns, supplyChain=SCA) — the
141
+ // caller (engine.js) hands last-scan.json's own channel split through
142
+ // ctx, so a control checking family:hardcoded-secret or
143
+ // family:vulnerable-dep isn't blind to 3 of the engine's 4 finding types.
144
+ // Family defaults mirror report/index.js's normalizeFindings so the two
145
+ // modules can't drift apart on what family an untagged finding belongs to.
146
+ ctx = {
147
+ ...ctx,
148
+ findings: [
149
+ ...(ctx.findings || []),
150
+ ...(ctx.secrets || []).map(s => ({ ...s, family: s.family || 'hardcoded-secret' })),
151
+ ...(ctx.logicVulns || []),
152
+ ...(ctx.supplyChain || []).map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' })),
153
+ ],
154
+ };
132
155
  const results = [];
133
156
  for (const control of policy.controls) {
134
157
  if (control.not_applicable) {
@@ -166,6 +189,13 @@ export function emitEvidenceJsonLd(report, scanRoot) {
166
189
  framework: report.framework,
167
190
  version: report.version,
168
191
  generatedAt: new Date().toISOString(),
192
+ // CMP-5: this artifact is fed to GRC tooling (Vanta/Drata/SecureFrame) and
193
+ // auditors largely unread by a human — the same reason
194
+ // auditor-walkthrough.js's narrative carries this disclaimer verbatim.
195
+ // Without it here, a machine-consumed "ComplianceEvidence" document reads
196
+ // as an attestation, not a scanner's automated observation.
197
+ disclaimer: 'This artifact organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.',
198
+ provenance: { engineVersion: SCANNER_VERSION },
169
199
  summary: report.summary,
170
200
  controls: report.controls.map(c => ({
171
201
  '@type': 'Control',
@@ -192,7 +222,9 @@ export function emitEvidenceMarkdown(report, scanRoot) {
192
222
  const lines = [];
193
223
  lines.push(`# Compliance evidence — ${report.framework}`);
194
224
  lines.push('');
195
- lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0,10)}.`);
225
+ lines.push(`Generated by agentic-security (engine ${SCANNER_VERSION}) on ${new Date().toISOString().slice(0,10)}.`);
226
+ lines.push('');
227
+ lines.push('> This document organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.');
196
228
  lines.push('');
197
229
  lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
198
230
  lines.push('');
@@ -47,19 +47,53 @@ export function annotateConfidence(findings) {
47
47
  if (f.routeRooted) conf = Math.min(1, conf + 0.05);
48
48
  if (f.guards && f.guards.length) conf *= 0.80;
49
49
  if (f.reachable === false) conf *= 0.55;
50
+ // f.unvalidated is set later in the pipeline (llm-validator/index.js,
51
+ // invoked well after this first confidence pass), so it's never true
52
+ // here on a finding's first computation — see applyUnvalidatedPenalty
53
+ // below, the real enforcement point, run after validation. Kept here
54
+ // too so a caller that builds a synthetic finding with
55
+ // unvalidated:true pre-set still gets it applied in one pass.
50
56
  if (f.unvalidated) conf *= 0.85; // LLM validator unavailable
51
- if (f.llmOnly) conf *= 0.70; // LLM-only finding, no Layer-2 path
57
+ // f.llmOnly: currently unreachable no producer anywhere in this
58
+ // codebase ever sets it (grepped). Documented as a no-op rather than
59
+ // silently deleted, since the intent ("LLM-only finding, no Layer-2
60
+ // path") is a real, plausible signal that just isn't wired yet.
61
+ if (f.llmOnly) conf *= 0.70;
52
62
  }
53
63
  conf = Math.max(0, Math.min(1, conf));
54
64
  f.confidence = Math.round(conf * 1000) / 1000;
55
- // Premortem 3R-15: derive tier from the 2-decimal display value so a
56
- // finding reported as "0.75" never lands in two tiers depending on the
57
- // viewer's rounding. Add a +0.005 epsilon to anchor cutoffs to the
58
- // displayed rounded value (3-decimal raw 0.745 → 2-decimal 0.75 → high).
59
- const display = Math.round(f.confidence * 100) / 100;
60
- if (display >= 0.75) f.confidenceTier = 'high';
61
- else if (display >= 0.50) f.confidenceTier = 'medium';
62
- else if (display >= 0.25) f.confidenceTier = 'low';
63
- else f.confidenceTier = 'very-low';
65
+ f.confidenceTier = _tierFor(f.confidence);
66
+ }
67
+ }
68
+
69
+ // Premortem 3R-15: derive tier from the 2-decimal display value so a
70
+ // finding reported as "0.75" never lands in two tiers depending on the
71
+ // viewer's rounding (3-decimal raw 0.745 → 2-decimal 0.75 high).
72
+ function _tierFor(confidence) {
73
+ const display = Math.round(confidence * 100) / 100;
74
+ if (display >= 0.75) return 'high';
75
+ if (display >= 0.50) return 'medium';
76
+ if (display >= 0.25) return 'low';
77
+ return 'very-low';
78
+ }
79
+
80
+ // Retroactively applies the unvalidated penalty to findings whose
81
+ // confidence was already computed by annotateConfidence BEFORE
82
+ // f.unvalidated could be known — annotateConfidence only computes from
83
+ // scratch when f.confidence is still null (so hand-tuned detector
84
+ // confidences survive untouched), and f.unvalidated is set by
85
+ // llm-validator/index.js, which runs well after the pipeline's first
86
+ // annotateConfidence pass. Call this once, immediately after LLM
87
+ // validation runs (or is skipped). Idempotent: a finding is only
88
+ // adjusted once, tracked via f._unvalidatedPenaltyApplied.
89
+ export function applyUnvalidatedPenalty(findings) {
90
+ if (!Array.isArray(findings)) return;
91
+ for (const f of findings) {
92
+ if (!f || typeof f !== 'object') continue;
93
+ if (f._unvalidatedPenaltyApplied) continue;
94
+ f._unvalidatedPenaltyApplied = true;
95
+ if (!f.unvalidated || typeof f.confidence !== 'number') continue;
96
+ f.confidence = Math.round(Math.max(0, Math.min(1, f.confidence * 0.85)) * 1000) / 1000;
97
+ f.confidenceTier = _tierFor(f.confidence);
64
98
  }
65
99
  }
@@ -296,8 +296,12 @@ function _stripState(dir) {
296
296
  }
297
297
  }
298
298
 
299
- // `scoreCandidate` is deliberately NOT exported: an external caller could
300
- // score a candidate and then write it by some other route, which is exactly
301
- // the unscored-write path this module exists to make unavailable. Enrolment
302
- // scores and writes as one operation or not at all.
303
- export const _internals = { DEFAULT_TIER, scoreCandidate, _languageOf, _escapeRegex, _stripState };
299
+ // `scoreCandidate` is deliberately NOT exported not even via _internals,
300
+ // the test-only-helpers convention used elsewhere in this codebase: an
301
+ // external caller could score a candidate and then write it by some other
302
+ // route, which is exactly the unscored-write path this module exists to
303
+ // make unavailable. Enrolment scores and writes as one operation or not at
304
+ // all. Keep it out of this object even though every other _internals
305
+ // export in this codebase is a harmless pure helper — this one specific
306
+ // function is the security boundary, not test plumbing.
307
+ export const _internals = { DEFAULT_TIER, _languageOf, _escapeRegex, _stripState };
@@ -54,6 +54,25 @@ function _matches(f, manifest, matcher) {
54
54
  (manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
55
55
  }
56
56
 
57
+ /**
58
+ * Every finding, across every channel, that scores this entry.
59
+ *
60
+ * Third caller of the ONE predicate (gate, enrollment, and now the per-layer
61
+ * recall instrument in `bench/layer-recall/`). That instrument needs to know
62
+ * WHICH findings matched so it can attribute them to the analysis layer that
63
+ * produced them; re-deriving the predicate there is precisely the drift this
64
+ * module was extracted to prevent, so it reuses `_matches` instead.
65
+ */
66
+ export function matchingFindings(scan, manifest, matcher = matcherFor(manifest)) {
67
+ const out = [];
68
+ for (const channel of CHANNELS) {
69
+ const arr = scan?.[channel];
70
+ if (!Array.isArray(arr)) continue;
71
+ for (const f of arr) if (_matches(f, manifest, matcher)) out.push(f);
72
+ }
73
+ return out;
74
+ }
75
+
57
76
  /** Did the vulnerable (`pre/`) tree produce a matching finding? */
58
77
  export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
59
78
  return _any(scan, f => _matches(f, manifest, matcher));
@@ -101,6 +101,49 @@ function isSanitizedExpr(text) {
101
101
  return false;
102
102
  }
103
103
 
104
+ // For each SANITIZER_PATTERNS match in `text`, find the nearest `(...)` call
105
+ // span after the match and return its [start, end) bounds. Used to tell
106
+ // "the sanitizer call actually wraps the tainted value" (HtmlEncode(x)) apart
107
+ // from "the sanitizer pattern matched something unrelated elsewhere in a
108
+ // compound expression" (comment + (IsNullOrEmpty(flag) ? ... : ...)) — a
109
+ // common .NET idiom that combines a tainted value with an unrelated validity
110
+ // check in the same expression.
111
+ function _sanitizerCallSpans(text) {
112
+ const spans = [];
113
+ for (const re of SANITIZER_PATTERNS) {
114
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
115
+ let m;
116
+ while ((m = g.exec(text))) {
117
+ const openIdx = text.indexOf('(', g.lastIndex - 1);
118
+ if (openIdx !== -1 && openIdx - g.lastIndex < 3) {
119
+ let depth = 1, j = openIdx + 1;
120
+ while (j < text.length && depth > 0) {
121
+ if (text[j] === '(') depth++;
122
+ else if (text[j] === ')') depth--;
123
+ j++;
124
+ }
125
+ spans.push([openIdx, j]);
126
+ }
127
+ if (g.lastIndex === m.index) g.lastIndex++; // avoid infinite loop on zero-width matches
128
+ }
129
+ }
130
+ return spans;
131
+ }
132
+
133
+ // Is `ref` tainted-and-NOT-neutralized by an enclosing sanitizer call in
134
+ // `text`? True when every occurrence of `ref` as a whole word sits outside
135
+ // every sanitizer call span (i.e. no sanitizer actually wraps it).
136
+ function _refEscapesSanitizers(text, ref, spans) {
137
+ if (!spans.length) return true;
138
+ const re = new RegExp(`\\b${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
139
+ let m, sawAny = false;
140
+ while ((m = re.exec(text))) {
141
+ sawAny = true;
142
+ if (!spans.some(([s, e]) => m.index >= s && m.index < e)) return true;
143
+ }
144
+ return !sawAny;
145
+ }
146
+
104
147
  // Walk a single method's body and compute per-variable type + taint.
105
148
  // Returns { typeMap, taintMap, sourceLines } where sourceLines records the
106
149
  // declaration line at which each variable first became tainted.
@@ -296,12 +339,22 @@ export function receiverIsType(method, flow, receiver, typePattern) {
296
339
  // for short expressions but unsafe for arbitrary string-containing text.
297
340
  export function expressionIsTainted(flow, text, idents = null) {
298
341
  if (!text && !idents) return false;
342
+ // Check known-tainted variable references that ESCAPE every sanitizer
343
+ // call span first — a whole-expression sanitizer-pattern match (below)
344
+ // must not clear a tainted variable the sanitizer doesn't actually wrap.
345
+ // `HtmlEncode(x)` genuinely neutralizes `x` (inside the call's parens);
346
+ // `comment + (string.IsNullOrEmpty(flag) ? ... : ...)` does not neutralize
347
+ // `comment` just because an unrelated sanitizer pattern matched `flag`
348
+ // elsewhere in the same compound expression.
349
+ const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
350
+ const spans = text ? _sanitizerCallSpans(text) : [];
351
+ for (const r of refs) {
352
+ if (flow.taintMap.get(r) && (!text || _refEscapesSanitizers(text, r, spans))) return true;
353
+ }
299
354
  if (text) {
300
355
  if (isSourceExpr(text) && !isSanitizedExpr(text)) return true;
301
356
  if (isSanitizedExpr(text)) return false;
302
357
  }
303
- const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
304
- for (const r of refs) if (flow.taintMap.get(r)) return true;
305
358
  return false;
306
359
  }
307
360
 
@@ -311,9 +364,15 @@ export function expressionIsTainted(flow, text, idents = null) {
311
364
  // not treated as code identifiers.
312
365
  export function argIsTainted(flow, arg) {
313
366
  if (!arg) return false;
367
+ // Same span-aware fix as expressionIsTainted: a tainted identifier that
368
+ // escapes every sanitizer call span wins over a whole-argument
369
+ // sanitizer-pattern match.
370
+ const spans = arg.text ? _sanitizerCallSpans(arg.text) : [];
371
+ for (const id of arg.idents || []) {
372
+ if (flow.taintMap.get(id) && (!arg.text || _refEscapesSanitizers(arg.text, id, spans))) return true;
373
+ }
314
374
  if (arg.text && isSanitizedExpr(arg.text)) return false;
315
375
  if (arg.text && isSourceExpr(arg.text)) return true;
316
- for (const id of arg.idents || []) if (flow.taintMap.get(id)) return true;
317
376
  return false;
318
377
  }
319
378
 
@@ -4,7 +4,10 @@
4
4
  // returns platform-specific security findings: missing headers, public previews,
5
5
  // no health checks, unsafe infra settings.
6
6
  //
7
- // Platforms: Vercel, Railway, Fly.io, Render, Netlify, AWS Amplify, Cloudflare
7
+ // Platforms: Vercel, Railway, Fly.io, Netlify, Cloudflare.
8
+ // NOT implemented despite being previously listed here: Render, AWS Amplify
9
+ // (found via Stage-0 doc audit, 2026 — grepped for any render/amplify config
10
+ // path or platform key in this file; zero hits for either).
8
11
 
9
12
  import * as fs from 'node:fs';
10
13
  import * as path from 'node:path';
@@ -8,7 +8,13 @@
8
8
 
9
9
  function _routeKey(r) { return `${r.method || 'ANY'} ${r.path || '(file)'} @ ${r.file}:${r.line}`; }
10
10
  function _depKey(c) { return `${c.ecosystem}:${c.name}@${c.version}`; }
11
- function _findingKey(f) { return `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
11
+ // Prefer stableId (posture/stable-id.js) it omits the exact source line by
12
+ // design, so an unrelated edit that shifts a still-unfixed finding's line
13
+ // number doesn't register as one "removed" + one "added" finding, inflating
14
+ // drift tier and falsely flagging a PR as introducing/fixing something it
15
+ // didn't touch. Falls back to the line-sensitive key only for finding shapes
16
+ // that never got a stableId annotated.
17
+ function _findingKey(f) { return f.stableId || `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
12
18
 
13
19
  function _toMap(arr, keyFn) {
14
20
  const m = new Map();
@@ -6,10 +6,19 @@
6
6
  // from those attackers are actively weaponizing.
7
7
  //
8
8
  // Decoration shape (added to each SCA finding with a CVE):
9
- // epss: 0.92345
9
+ // epssScore: 0.92345 (this comment previously said `epss:`; the code has
10
+ // always written `epssScore` — the comment was wrong,
11
+ // not the code)
10
12
  // epssPercentile: 0.987
11
13
  // exploitedNow: true ← percentile >= 0.95
12
14
  //
15
+ // ⚠ engine.js ALSO sets f.epssScore/f.epssPercentile directly (around line
16
+ // 6451), independently of this module's fetchEPSS/decorate path — two
17
+ // implementations of the same decoration. This module's own `fetchEPSS` has
18
+ // no in-tree caller (allowlisted in no-dead-modules.test.js as future-public
19
+ // API); the live path is the one in engine.js. Worth consolidating, not
20
+ // resolved here.
21
+ //
13
22
  // Source: https://api.first.org/data/v1/epss?cve=CVE-...,CVE-...
14
23
  // Cached on disk: ~/.claude/agentic-security/epss-cache/<sha256>.json
15
24
  // 24-hour TTL. Falls back gracefully when offline.
@@ -93,6 +102,9 @@ function cvesIn(finding) {
93
102
  const found = new Set();
94
103
  if (typeof finding.cve === 'string') found.add(finding.cve.toUpperCase());
95
104
  if (Array.isArray(finding.cves)) for (const c of finding.cves) found.add(String(c).toUpperCase());
105
+ // The actual field name every SCA finding in this codebase uses (src/sca/
106
+ // CLAUDE.md's documented shape; carried through by normalizeFindings).
107
+ if (Array.isArray(finding.cveAliases)) for (const c of finding.cveAliases) found.add(String(c).toUpperCase());
96
108
  if (Array.isArray(finding.vulnerabilities)) {
97
109
  for (const v of finding.vulnerabilities) {
98
110
  if (typeof v.id === 'string' && v.id.startsWith('CVE-')) found.add(v.id.toUpperCase());
@@ -104,12 +104,25 @@ export function ensureKeyPair(dir = keyDir()) {
104
104
  return { privateKeyPem, publicKeyPem, created: true, ...p };
105
105
  } catch (e) {
106
106
  if (e.code !== 'EEXIST') throw e;
107
- return {
108
- privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
109
- publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
110
- created: false,
111
- ...p,
112
- };
107
+ // We lost the race on the private key — someone else's 'wx' won.
108
+ // Their public-key write follows immediately after their private-key
109
+ // write but is not itself atomic with it, so it may not have landed
110
+ // yet: a bare read here would throw an uncaught ENOENT on a genuinely
111
+ // transient state, not a real error. Retry briefly rather than crash.
112
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
113
+ for (let attempt = 0; ; attempt++) {
114
+ try {
115
+ return {
116
+ privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
117
+ publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
118
+ created: false,
119
+ ...p,
120
+ };
121
+ } catch (readErr) {
122
+ if (readErr.code !== 'ENOENT' || attempt >= 50) throw readErr;
123
+ Atomics.wait(sleepBuf, 0, 0, 10); // 10ms; ~500ms total budget
124
+ }
125
+ }
113
126
  }
114
127
  }
115
128
 
@@ -227,9 +240,26 @@ export function signEvidenceBundle(bundle, privateKeyPem) {
227
240
  * Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
228
241
  * source is an expected input, not an exceptional one.
229
242
  */
243
+ // The complete set of top-level keys a legitimately-built, signed bundle can
244
+ // carry — buildEvidenceBundle's six plus signEvidenceBundle's `signature`.
245
+ // EA-03 (Stage-0 audit, 2026): canonicalBytes SIGNS an allowlist of fields;
246
+ // verifyEvidenceBundle never checked for keys OUTSIDE that allowlist, so a
247
+ // bundle with a fabricated `verdict`/`proofLevel`/anything-else stapled on
248
+ // after signing verified as authentic — the signature simply never covered
249
+ // those bytes. Rejecting unknown keys here closes that; it must exactly match
250
+ // what buildEvidenceBundle+signEvidenceBundle actually produce, or a
251
+ // legitimate bundle would start failing verification.
252
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([
253
+ 'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
254
+ ]);
255
+
230
256
  export function verifyEvidenceBundle(bundle, publicKeyPem) {
231
257
  if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
232
258
  if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
259
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
260
+ if (unknownKeys.length) {
261
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
262
+ }
233
263
  const sig = bundle.signature;
234
264
  if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
235
265
  if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
@@ -189,9 +189,21 @@ export function annotateExploitProbability(findings, ctx = {}) {
189
189
  p = _clamp01(p);
190
190
  // Wilson CI: prefer historical CI when we have one, otherwise derive
191
191
  // a wider CI from the prior (n=10 implied sample at base rate).
192
+ //
193
+ // The point estimate (exploitProbability) is ALWAYS the per-finding,
194
+ // factor-adjusted `p` computed above — never the population-level
195
+ // historical hit rate. This module's own header scopes historical data
196
+ // explicitly to the CI ("Wilson CI computed from operator's historical
197
+ // hit rate... when enough samples exist"); overwriting the point
198
+ // estimate too collapsed every finding in a CWE×language slice with
199
+ // enough history to the SAME number regardless of its own reachability/
200
+ // sanitizer/auth signals — a maximally-dangerous finding and a
201
+ // maximally-safe one scored identically, while exploitProbabilityWhy
202
+ // kept listing factors that, in that branch, had zero effect on the
203
+ // reported number.
192
204
  const hist = _historicalCi(history, cwe, f.language || (f.file || '').split('.').pop());
193
205
  if (hist) {
194
- f.exploitProbability = hist.p;
206
+ f.exploitProbability = p;
195
207
  f.exploitProbabilityCI95 = hist.ci;
196
208
  f.exploitProbabilitySlice = hist.slice;
197
209
  f.exploitProbabilityN = hist.n;
@@ -22,7 +22,19 @@ import {
22
22
  } from './verification-separation.js';
23
23
 
24
24
  const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
25
- const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
25
+ // Stage 3 correctness audit (detection depth): this ladder was missing
26
+ // 'critical' — exploitability.js sets f.exploitabilityTier = 'critical' at
27
+ // score >= 0.80 (the tier falsification most needs to demote, since it's
28
+ // exactly the findings a false "survives" verdict would most overstate).
29
+ // _dropTier('critical') hit the `i <= 0` "unknown tier, leave unchanged"
30
+ // branch (indexOf returns -1 for an unrecognized value), so a falsified
31
+ // finding at the critical exploitability tier kept its full tier — the
32
+ // demotion silently no-op'd for the highest tier in the system. 'very-low'
33
+ // (confidence.js's own floor tier) is included too, for the same reason
34
+ // confidenceTier is demoted by this same function — it was already
35
+ // unchanged-at-floor by the same `i <= 0` fallback, so this is a
36
+ // completeness fix there, not a behavior change.
37
+ const TIERS = ['very-low', 'low', 'medium', 'high', 'critical']; // confidence / exploitability tier order
26
38
 
27
39
  function _dropTier(tier) {
28
40
  const i = TIERS.indexOf(tier);
@@ -66,7 +78,16 @@ export function classifyFinding(finding, fileContents) {
66
78
  }
67
79
  // A sanitizer that doesn't match the sink context does NOT block the flow —
68
80
  // the finding survives (this is a real bug, not a mitigation).
69
- if (finding.sanitizerMismatch === true) {
81
+ //
82
+ // Stage 3 correctness audit (detection depth): this was `=== true`, but
83
+ // the field's real producer (engine.js's applySanitizerEffectiveness)
84
+ // sets `f.sanitizerMismatch = f.sanitizerType` — a STRING sanitizer-type
85
+ // label ("Type Guard", "JWT Algo Pinning", ...), never the literal
86
+ // boolean `true`. Every OTHER consumer of this field (confidence.js,
87
+ // exploitability.js, engine.js's own scoring) checks it via plain
88
+ // truthiness; this strict-equality check could never match a real
89
+ // finding, making the whole branch dead code.
90
+ if (finding.sanitizerMismatch) {
70
91
  return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
71
92
  }
72
93
  const window = _pathWindow(finding, fileContents);
@@ -62,7 +62,7 @@ export function recordFixAttempt(scanRoot, record) {
62
62
  try {
63
63
  const dir = stateDir(scanRoot);
64
64
  if (!isSafeStateDir(dir)) return false;
65
- if (!stateWritesEnabled()) return;
65
+ if (!stateWritesEnabled()) return false;
66
66
  fs.mkdirSync(dir, { recursive: true });
67
67
  // One writeSync of one newline-terminated line: a concurrent reader sees
68
68
  // whole records or nothing, and a torn tail is dropped on read.
@@ -38,7 +38,16 @@ function _detectRunner(scanRoot) {
38
38
  } catch { return null; }
39
39
  })();
40
40
  if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
41
- return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
41
+ // --passWithNoTests is Jest-specific CLI syntax appending it
42
+ // unconditionally broke every non-Jest npm test script (mocha, vitest,
43
+ // ava, tap, or a plain node script) with an "unrecognized option" exit,
44
+ // failing verification for a reason that has nothing to do with
45
+ // whether the patch actually broke anything. Only add it when Jest is
46
+ // actually the configured runner.
47
+ const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
48
+ || Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
49
+ const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
50
+ return { runner: 'npm', cmd: 'npm', args };
42
51
  }
43
52
  if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
44
53
  return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };