@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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 (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
package/src/engine.js CHANGED
@@ -8,7 +8,24 @@ import * as os from 'node:os';
8
8
  import * as crypto from 'node:crypto';
9
9
  import * as yaml from './util/yaml.js';
10
10
  import { createRequire } from 'node:module';
11
+ import { fileURLToPath } from 'node:url';
11
12
  const _require = createRequire(import.meta.url);
13
+
14
+ // FR-202 phase 3b (D-0050): a worker thread must import.() the real, exports-bearing
15
+ // engine.js -- NEVER `fileURLToPath(import.meta.url)` taken naively from inside this
16
+ // module's own code, because once bundled by ncc that resolves to
17
+ // dist/agentic-security.mjs, the CLI ENTRYPOINT (bin/agentic-security.js), which has
18
+ // no _runFileCascade/_initCascadeWorkerState exports at all and runs CLI dispatch as
19
+ // a side effect of being imported. `src/` and `dist/` are always shipped as SIBLING
20
+ // directories (see package.json's "files"), in both a dev checkout and the published
21
+ // npm package, so this is stable in both the unbundled (this file IS src/engine.js)
22
+ // and bundled (this file's code now lives inside dist/agentic-security.mjs) cases.
23
+ function _resolveEngineModulePathForWorkers() {
24
+ const hereFile = fileURLToPath(import.meta.url);
25
+ const hereDir = path.dirname(hereFile);
26
+ if (path.basename(hereDir) === 'src') return hereFile;
27
+ return path.join(hereDir, '..', 'src', 'engine.js');
28
+ }
12
29
  import { scanLLM } from './sast/llm.js';
13
30
  import { scanLLMOwasp } from './sast/llm-owasp.js';
14
31
  import { scanLlmCost } from './sast/llm-cost-advisor.js';
@@ -38,6 +55,33 @@ import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
38
55
  import { scanFastapiHardening } from './sast/fastapi-hardening.js';
39
56
  import { isDeterministic } from './posture/deterministic.js';
40
57
  import { proofCoverage } from './posture/proof-coverage.js';
58
+ import { computeScanHealth, applyFreshness } from './pipeline/scan-health.js';
59
+ import { computeCoverageLedger, summarizeCoverageForScanHealth } from './pipeline/coverage-ledger.js';
60
+ import { runAnnotatorAsync } from './pipeline/annotator-runner.js';
61
+ import { runDetector } from './pipeline/detector-runner.js';
62
+ import { registerProducer } from './pipeline/producer-registry.js';
63
+ import { collectProducerResult } from './pipeline/producer-collector.js';
64
+ import { completeEnrichment } from './pipeline/enrichment-completion.js';
65
+
66
+ // FR-101 (assurance-hardening PRD): the "late producers" — see
67
+ // pipeline/producer-registry.js's header for why this list and not the
68
+ // full ~90-detector set. Registered once at module load (this file is an
69
+ // ESM singleton; registering inside runFullScan would throw "duplicate
70
+ // producer id" on a second scan in the same process).
71
+ for (const def of [
72
+ { id: 'cross-lang-openapi', version: '1.0.0', phase: 'cross-language', languages: [] },
73
+ { id: 'cross-lang-grpc', version: '1.0.0', phase: 'cross-language', languages: [] },
74
+ { id: 'cross-lang-graphql', version: '1.0.0', phase: 'cross-language', languages: [] },
75
+ { id: 'cross-lang-orm', version: '1.0.0', phase: 'cross-language', languages: [] },
76
+ { id: 'cross-lang-queues', version: '1.0.0', phase: 'cross-language', languages: [] },
77
+ { id: 'iac-reachability', version: '1.0.0', phase: 'cross-language', languages: [] },
78
+ { id: 'iam-policy', version: '1.0.0', phase: 'cross-language', languages: [] },
79
+ { id: 'container-runtime', version: '1.0.0', phase: 'cross-language', languages: [] },
80
+ { id: 'business-logic-v2', version: '1.0.0', phase: 'business-logic', languages: [] },
81
+ { id: 'specification-drift', version: '1.0.0', phase: 'business-logic', languages: [] },
82
+ { id: 'concurrency', version: '1.0.0', phase: 'business-logic', languages: [] },
83
+ { id: 'privacy-taint', version: '1.0.0', phase: 'privacy', languages: [] },
84
+ ]) registerProducer(def);
41
85
  import { scanAuthZ } from './sast/authz.js';
42
86
  import { scanApiBrokenAuthz } from './sast/api-authz.js';
43
87
  import { scanCloudTemplates, isCloudFormationTemplate } from './sast/iac-cloud-templates.js';
@@ -139,7 +183,7 @@ import { annotateExecutionProofs } from './posture/prove-findings.js';
139
183
  import { mineVulnHistory, annotateHistoricalRisk } from './posture/vuln-archaeology.js';
140
184
  import { annotateVerifierVerdicts } from './posture/verifier.js';
141
185
  import { annotateRegressionTests } from './posture/regression-test-gen.js';
142
- import { annotateCalibratedConfidence } from './posture/calibration.js';
186
+ import { annotateCalibratedConfidence, calibrationFreshness } from './posture/calibration.js';
143
187
  import { annotateStableIds } from './posture/stable-id.js';
144
188
  import { clusterByRootCause } from './posture/clustering.js';
145
189
  import { demoteUnreachable } from './posture/reachability-filter.js';
@@ -200,6 +244,9 @@ import { annotateRelevance } from './posture/relevance.js';
200
244
  import { sweepRootCauses } from './posture/root-cause-sweep.js';
201
245
  import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
202
246
  import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
247
+ import { loadPrivacyGovernanceConfig, emitRopaArtifact } from './dataflow/privacy-governance.js';
248
+ import { buildDataInventory, emitDataInventoryArtifact, emitDataFlowGraph } from './dataflow/privacy-inventory.js';
249
+ import { adaptIRForPrivacyTaint } from './privacy/ir-adapter.js';
203
250
  import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
204
251
  import { runApiContractScan } from './posture/api-contract.js';
205
252
  import { annotateProvenance } from './sca/sigstore-verify.js';
@@ -213,6 +260,17 @@ import { annotateAttackTaxonomy, summarizeTaxonomy } from './posture/attack-taxo
213
260
  import { suppressByPastDecisions } from './posture/triage-memory.js';
214
261
  import { suppressByIntent } from './posture/intent-context.js';
215
262
  import { annotateGitHistory } from './posture/git-history.js';
263
+ // NOT `annotateProvenance` (sca/sigstore-verify.js's build-attestation
264
+ // annotator) and NOT `annotateFindingProvenance` (posture/provenance.js's
265
+ // AI-code fingerprint annotator). Both are already imported above in this
266
+ // file, so either name here is a duplicate binding — a SyntaxError — and the
267
+ // second is worse still because it also takes a findings array as its first
268
+ // argument, so a wrong import would RUN rather than fail. This one is named
269
+ // for the mechanism that distinguishes it —
270
+ // provenance derived from GIT HISTORY. See provenance/coordinator.js's header.
271
+ import { annotateGitProvenance, PROVENANCE_DEFAULT_TIMEOUT_MS, MAX_PROVIDER_ENRICHMENTS_PER_SCAN } from './posture/provenance/coordinator.js';
272
+ import { updateLifecycle } from './posture/provenance/lifecycle.js';
273
+ import { emptyProvenance, PROVENANCE_STATUS } from './posture/provenance/schema.js';
216
274
  import { applyThreatModel } from './posture/threat-model-grounding.js';
217
275
  import { annotateCrossRepoSignals } from './posture/pattern-propagation.js';
218
276
  import { annotateRiskDollars } from './posture/risk-dollars.js';
@@ -235,7 +293,7 @@ import { annotateAttackPlaybooks } from './posture/attack-playbooks.js';
235
293
  // R8: opt-in scan checkpointing/resume for the per-file loop.
236
294
  import {
237
295
  openCheckpoint, recordFileDone, completedFiles, resumeFindings, closeCheckpoint,
238
- computeRunKey, bundleShaForRunKey,
296
+ computeGlobalKey, globalKeyMeta, invalidatedFiles, bundleShaForRunKey,
239
297
  } from './posture/scan-checkpoint.js';
240
298
  import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
241
299
  import { effectiveVersion as _effectiveRulesetVersion } from './posture/ruleset-version.js';
@@ -2311,7 +2369,8 @@ function _brokenMarkerSecurityPredicate(matchText, ctx) {
2311
2369
  // SAST findings here are expected by design — suppress to avoid noise.
2312
2370
  const _INTENTIONAL_VULN_PATH_RE = /(?:^|\/)(?:codefixes|challenge[_\-]?(?:solution|code|fix|answer)|intentional[_\-]?vuln|ctf[_\-]?solution|vulnerable[_\-]?(?:example|sample|code))(?:\/|$)/i;
2313
2371
 
2314
- function scanStructuralVulns(fp, raw) {
2372
+ function scanStructuralVulns(fp, raw, _suppOut) {
2373
+ const _supp = _suppOut || _suppressionLog;
2315
2374
  if (_INTENTIONAL_VULN_PATH_RE.test(fp.replace(/\\/g, '/'))) return [];
2316
2375
  // Structural patterns vary: some describe code shapes (eval(), child_process.)
2317
2376
  // and shouldn't match in strings; others ALSO scan string content (e.g.
@@ -2324,7 +2383,7 @@ function scanStructuralVulns(fp, raw) {
2324
2383
  const findings = [];
2325
2384
  const ctx = inferFileContext(fp, raw);
2326
2385
  for (const pat of STRUCTURAL_VULN_PATTERNS) {
2327
- if (!_ruleAppliesIn(pat, ctx)) { _suppressionLog.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
2386
+ if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
2328
2387
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
2329
2388
  const re = new RegExp(pat.regex.source, pat.regex.flags);
2330
2389
  // Default: match against the string-stripped view so rule-library shapes
@@ -2341,7 +2400,7 @@ function scanStructuralVulns(fp, raw) {
2341
2400
  if (typeof pat.predicate === 'function') {
2342
2401
  const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise });
2343
2402
  if (verdict && !verdict.fire) {
2344
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
2403
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
2345
2404
  continue;
2346
2405
  }
2347
2406
  }
@@ -2349,7 +2408,7 @@ function scanStructuralVulns(fp, raw) {
2349
2408
  // Used to scope rules like Django DEBUG=True to files that actually
2350
2409
  // import / configure Django (avoid mis-firing on Flask's app.debug).
2351
2410
  if (pat.contextRe && !pat.contextRe.test(raw)) {
2352
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
2411
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
2353
2412
  continue;
2354
2413
  }
2355
2414
  // FP-4: severity classifier — return null to suppress, otherwise overrides pat.severity.
@@ -2357,7 +2416,7 @@ function scanStructuralVulns(fp, raw) {
2357
2416
  if (typeof pat.severityFn === 'function') {
2358
2417
  const s = pat.severityFn(m[0], { file: fp, line, snippet, lines });
2359
2418
  if (s === null) {
2360
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'severity-fn:non-security-context'});
2419
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'severity-fn:non-security-context'});
2361
2420
  continue;
2362
2421
  }
2363
2422
  effectiveSeverity = s;
@@ -2366,11 +2425,11 @@ function scanStructuralVulns(fp, raw) {
2366
2425
  // hardened code — an SSRF host allow/deny check, or a path
2367
2426
  // basename/containment guard near the sink — is a false positive.
2368
2427
  if (/SSRF/.test(pat.vuln) && _hasSsrfHostGuard({ lines, line })) {
2369
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'ssrf-host-guard'});
2428
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'ssrf-host-guard'});
2370
2429
  continue;
2371
2430
  }
2372
2431
  if (/Path Traversal/.test(pat.vuln) && _hasPathGuard({ lines, line })) {
2373
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'path-contained'});
2432
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'path-contained'});
2374
2433
  continue;
2375
2434
  }
2376
2435
  const id = `struct:${fp}:${line}:${pat.vuln.replace(/\s/g, '_')}`;
@@ -2578,6 +2637,33 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
2578
2637
  // so pfr[p] and the aggregates share object identity, exactly as in a normal run.
2579
2638
  function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
2580
2639
  function _getSuppressions(){ return [..._suppressionLog]; }
2640
+ // Task 11 reentrancy fix: `_suppressionLog` is module-level and unconditionally
2641
+ // cleared by `_resetSuppressions()` at the top of every `runFullScan` call.
2642
+ // `predicate-replay.js`'s `replayAt` calls `runFullScan` recursively FROM
2643
+ // WITHIN an outer, still-running scan's provenance resolution (to replay a
2644
+ // finding's predicate at a historical commit) -- before Task 11 wired
2645
+ // scan.secrets/scan.logicVulns into real provenance resolution, that recursive
2646
+ // call was only ever reachable from scan.findings/SCA origin walks, which this
2647
+ // exact fixture (test/fixtures/entropy-fp) never triggered. Wiring secrets in
2648
+ // exposed it for the first time: the nested call's `_resetSuppressions()`
2649
+ // silently wiped the OUTER scan's suppression log before its own return
2650
+ // statement read it via `_getSuppressions()`, so `scan.suppressions` came back
2651
+ // empty for anything that happened to walk deep git history.
2652
+ //
2653
+ // A plain snapshot/restore around ONE `replayAt` call is not sufficient on its
2654
+ // own: `coordinator.js` resolves several findings' origins CONCURRENTLY (its
2655
+ // own comment: "the scheduler runs these four at a time"), and each finding's
2656
+ // resolveOrigin walk can call `replayAt` multiple times sequentially -- so two
2657
+ // DIFFERENT findings' replay calls can be in flight at once, interleaved at
2658
+ // `runFullScan`'s own internal await points. Two overlapping snapshot/restore
2659
+ // pairs racing on the same global array means whichever restores last wins,
2660
+ // discarding whatever the other legitimately wrote in between. Exported so
2661
+ // predicate-replay.js can snapshot/restore its own call boundary AND serialize
2662
+ // that boundary process-wide (see its own comment on the exclusivity queue) --
2663
+ // the nested scan's own suppression output is never read by replayAt, so
2664
+ // nothing is lost by discarding it.
2665
+ function _snapshotSuppressionLog(){ return _suppressionLog.slice(); }
2666
+ function _restoreSuppressionLog(saved){ _suppressionLog.length = 0; if (Array.isArray(saved)) _suppressionLog.push(...saved); }
2581
2667
 
2582
2668
  // ── inline suppression pragma ───────────────────────────────────────────────
2583
2669
  //
@@ -3002,7 +3088,8 @@ function _logicPredicateFor(vuln){
3002
3088
  return null;
3003
3089
  }
3004
3090
 
3005
- function scanLogicVulns(fp,raw){
3091
+ function scanLogicVulns(fp,raw,_suppOut){
3092
+ const _supp = _suppOut || _suppressionLog;
3006
3093
  // Logic rules generally inspect the surrounding handler block including
3007
3094
  // string-literal route paths and key names, so the comment-stripped (but
3008
3095
  // string-preserving) view is the right default. Rules that explicitly only
@@ -3012,7 +3099,7 @@ function scanLogicVulns(fp,raw){
3012
3099
  const lines=raw.split("\n");const results=[];
3013
3100
  const ctx = inferFileContext(fp, raw);
3014
3101
  for(const pat of LOGIC_PATTERNS){
3015
- if (!_ruleAppliesIn(pat, ctx)) { _suppressionLog.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
3102
+ if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
3016
3103
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
3017
3104
  const re=new RegExp(pat.regex.source,pat.regex.flags);
3018
3105
  const predicate = _logicPredicateFor(pat.vuln);
@@ -3025,7 +3112,7 @@ function scanLogicVulns(fp,raw){
3025
3112
  // FP-2: credential FP filter
3026
3113
  if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
3027
3114
  const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
3028
- if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
3115
+ if(fpCheck.skip){_supp.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
3029
3116
  // Stage 4 correctness audit (coverage breadth, secrets): same
3030
3117
  // unredacted-snippet leak found in engine.js's scanEntropySecrets/
3031
3118
  // scanCredentials and sast/secret-concat.js — this is a THIRD,
@@ -3044,7 +3131,7 @@ function scanLogicVulns(fp,raw){
3044
3131
  if (predicate) {
3045
3132
  const verdict = predicate(m[0], {file:fp, line, snippet, lines});
3046
3133
  if (verdict && !verdict.fire) {
3047
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'logic-gate:'+verdict.reason});
3134
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'logic-gate:'+verdict.reason});
3048
3135
  continue;
3049
3136
  }
3050
3137
  }
@@ -5834,14 +5921,15 @@ const EXTRA_STRUCTURAL_PATTERNS=[
5834
5921
  fix:"Delete debug routes from production builds. NODE_ENV is unreliable as a security boundary."},
5835
5922
  ];
5836
5923
 
5837
- function scanExtraStructural(fp,raw){
5924
+ function scanExtraStructural(fp,raw,_suppOut){
5925
+ const _supp = _suppOut || _suppressionLog;
5838
5926
  const cleaned=stripNoiseAndStrings(raw);
5839
5927
  const cleanedNoise=stripNoise(raw,fp);
5840
5928
  const lines=raw.split('\n');
5841
5929
  const findings=[];
5842
5930
  const ctx = inferFileContext(fp, raw);
5843
5931
  for(const pat of EXTRA_STRUCTURAL_PATTERNS){
5844
- if (!_ruleAppliesIn(pat, ctx)) { _suppressionLog.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
5932
+ if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
5845
5933
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
5846
5934
  const re=new RegExp(pat.regex.source,pat.regex.flags);
5847
5935
  const haystack = pat.readsStringContent ? cleanedNoise : cleaned;
@@ -5851,14 +5939,14 @@ function scanExtraStructural(fp,raw){
5851
5939
  const snippet=lines[line-1]?.trim()||'';
5852
5940
  // contextRe: require a context match across the whole file.
5853
5941
  if (pat.contextRe && !pat.contextRe.test(raw)) {
5854
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
5942
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
5855
5943
  continue;
5856
5944
  }
5857
5945
  // Per-pattern predicate gate (mirrors scanStructuralVulns).
5858
5946
  if (typeof pat.predicate === 'function') {
5859
5947
  const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise });
5860
5948
  if (verdict && !verdict.fire) {
5861
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
5949
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
5862
5950
  continue;
5863
5951
  }
5864
5952
  }
@@ -5913,7 +6001,8 @@ function _isLikelyNonSecret(v, ctxLine, surroundingLines){
5913
6001
  return null;
5914
6002
  }
5915
6003
 
5916
- function scanEntropySecrets(fp,raw){
6004
+ function scanEntropySecrets(fp,raw,_suppOut){
6005
+ const _supp = _suppOut || _suppressionLog;
5917
6006
  if(raw.length>400000)return[]; // skip huge blobs
5918
6007
  const out=[];
5919
6008
  const lines=raw.split("\n");
@@ -5942,7 +6031,7 @@ function scanEntropySecrets(fp,raw){
5942
6031
  // Same redaction concern as the main finding below applies to the
5943
6032
  // suppression log — it's exposed via --include-suppressed, and a
5944
6033
  // heuristic "probably not a real secret" call can be wrong.
5945
- _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
6034
+ _supp.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
5946
6035
  continue;
5947
6036
  }
5948
6037
  // Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
@@ -7055,6 +7144,15 @@ function _osvCacheSet(key,val){try{sessionStorage.setItem('osv_'+key,JSON.string
7055
7144
  // Batched: api.first.org accepts up to ~100 CVEs per request via ?cve=A,B,C…
7056
7145
  // One HTTP round trip per 100 CVEs instead of one per CVE. Cache lookups
7057
7146
  // remain per-CVE so a partial cache-hit still benefits.
7147
+ //
7148
+ // FR-207: EPSS scores decay in relevance (same note as posture/epss.js's
7149
+ // separate implementation) -- track how old the CVE-keyed entries actually
7150
+ // USED by this scan are, mirroring _setKevMeta immediately below, so a
7151
+ // stale feed is visible on scan.scanHealth rather than silently applied.
7152
+ const _EPSS_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
7153
+ let _epssLiveMeta = { source: 'not-loaded', ageDays: null, stale: null, cvesChecked: 0 };
7154
+ export function epssLiveMeta() { return { ..._epssLiveMeta }; }
7155
+
7058
7156
  const _EPSS_BATCH = 100;
7059
7157
  async function _fetchEPSSBatch(cveIds){
7060
7158
  if (!cveIds || !cveIds.length) return new Map();
@@ -7069,7 +7167,7 @@ async function _fetchEPSSBatch(cveIds){
7069
7167
  const res = await fetch(url, { headers: { 'User-Agent': 'agentic-security/0.1' } });
7070
7168
  if (!res.ok) {
7071
7169
  // Mark every CVE in the batch as "tried and failed" so we don't refetch this scan.
7072
- for (const c of batch) _osvCacheSet('epss:'+c, false);
7170
+ for (const c of batch) _osvCacheSet('epss:'+c, { miss: true, ts: Date.now() });
7073
7171
  continue;
7074
7172
  }
7075
7173
  const j = await res.json();
@@ -7080,7 +7178,7 @@ async function _fetchEPSSBatch(cveIds){
7080
7178
  const score = parseFloat(row.epss);
7081
7179
  const percentile = parseFloat(row.percentile);
7082
7180
  if (Number.isFinite(score) && Number.isFinite(percentile)) {
7083
- const v = { score, percentile };
7181
+ const v = { score, percentile, ts: Date.now() };
7084
7182
  out.set(cve, v);
7085
7183
  _osvCacheSet('epss:'+cve, v);
7086
7184
  seen.add(cve);
@@ -7088,7 +7186,7 @@ async function _fetchEPSSBatch(cveIds){
7088
7186
  }
7089
7187
  // CVEs in the batch that EPSS does not know — cache the negative so we
7090
7188
  // don't retry within this scan run.
7091
- for (const c of batch) if (!seen.has(c.toUpperCase())) _osvCacheSet('epss:'+c, false);
7189
+ for (const c of batch) if (!seen.has(c.toUpperCase())) _osvCacheSet('epss:'+c, { miss: true, ts: Date.now() });
7092
7190
  } catch { /* network error → caller continues without enrichment */ }
7093
7191
  }
7094
7192
  return out;
@@ -7103,14 +7201,43 @@ async function _enrichWithEPSS(supplyChainResults){
7103
7201
  // Cache lookup pass: keep CVEs we already have, defer the rest to one batched fetch.
7104
7202
  const epssByCve = new Map();
7105
7203
  const uncached = [];
7204
+ // FR-207: age of every entry actually used this scan, cache hit or fresh
7205
+ // fetch — a fresh fetch is age~0, a cache hit carries whatever `ts` it was
7206
+ // written with. A pre-existing on-disk entry from before this field
7207
+ // existed has no `ts` (`|| 0` → maximally stale), matching this
7208
+ // codebase's established "never-dated == already stale" convention
7209
+ // (compliance-policy.js's `_staleness`).
7210
+ const ages = [];
7106
7211
  for (const c of allCves) {
7107
7212
  const hit = _osvCacheGet('epss:'+c);
7108
- if (hit === null) uncached.push(c);
7109
- else if (hit) epssByCve.set(c, hit); // hit === false means "tried, no data" — leave unset
7213
+ if (hit === null) { uncached.push(c); continue; }
7214
+ if (hit === false || hit.miss) continue; // tried, no data — leave unset
7215
+ epssByCve.set(c, hit);
7216
+ ages.push(Date.now() - (hit.ts || 0));
7110
7217
  }
7111
7218
  if (uncached.length) {
7112
7219
  const fetched = await _fetchEPSSBatch(uncached);
7113
- for (const [cve, v] of fetched) epssByCve.set(cve, v);
7220
+ for (const [cve, v] of fetched) { epssByCve.set(cve, v); ages.push(Date.now() - (v.ts || 0)); }
7221
+ }
7222
+ // Offline mode means "we deliberately have no network access this run" --
7223
+ // mirroring _loadKEVCatalog's own offline handling immediately above,
7224
+ // that is a genuine "we cannot vouch for freshness right now" state
7225
+ // (stale: null), not license to label whatever is on disk stale OR
7226
+ // fresh. This matters in practice: a cache entry written before this
7227
+ // field existed has no `ts` at all, and without the offline carve-out
7228
+ // that would report as "56 years old" (age computed against epoch)
7229
+ // under the exact conditions (no network) where that claim is least
7230
+ // verifiable.
7231
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
7232
+ _epssLiveMeta = { source: 'offline-skipped', ageDays: null, stale: null, cvesChecked: ages.length };
7233
+ } else if (ages.length) {
7234
+ const oldestMs = Math.max(...ages);
7235
+ _epssLiveMeta = {
7236
+ source: 'cache/live',
7237
+ ageDays: Math.floor(oldestMs / 86400000),
7238
+ stale: oldestMs > _EPSS_STALE_AFTER_MS,
7239
+ cvesChecked: ages.length,
7240
+ };
7114
7241
  }
7115
7242
  for (const r of out) {
7116
7243
  const cve = (r.cveAliases || []).find(a => /^CVE-/.test(a));
@@ -7251,6 +7378,31 @@ function _makePurl(ecosystem,name,version,group){
7251
7378
  return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
7252
7379
  }
7253
7380
 
7381
+ function _findManifestLine(text, sectionKey, depName) {
7382
+ const lines = text.split('\n');
7383
+ let inSection = false;
7384
+ let depth = 0;
7385
+ const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7386
+ const nameRe = new RegExp(`"${escaped}"\\s*:`);
7387
+ for (let i = 0; i < lines.length; i++) {
7388
+ const line = lines[i];
7389
+ if (!inSection) {
7390
+ if (new RegExp(`"${sectionKey}"\\s*:\\s*\\{`).test(line)) {
7391
+ inSection = true;
7392
+ depth = 1 + (line.match(/\{/g) || []).length - 1 - (line.match(/\}/g) || []).length;
7393
+ if (nameRe.test(line)) return i + 1;
7394
+ if (depth <= 0) inSection = false;
7395
+ }
7396
+ continue;
7397
+ }
7398
+ depth += (line.match(/\{/g) || []).length;
7399
+ depth -= (line.match(/\}/g) || []).length;
7400
+ if (depth <= 0) { inSection = false; continue; }
7401
+ if (nameRe.test(line)) return i + 1;
7402
+ }
7403
+ return null;
7404
+ }
7405
+
7254
7406
  function _parsePackageJson(text,filePath){
7255
7407
  const out=[];try{const d=JSON.parse(text);
7256
7408
  for(const[depKey,scope]of[['dependencies','required'],['devDependencies','optional']]){
@@ -7261,7 +7413,8 @@ function _parsePackageJson(text,filePath){
7261
7413
  const group=scoped?`@${parts[0]}`:'';
7262
7414
  const pkgName=scoped?parts[1]:name;
7263
7415
  out.push({name,version:ver,group,scope,purl:_makePurl('npm',pkgName,ver,group),ecosystem:'npm',filePath,
7264
- isUnpinned:verRange==='*'||verRange==='latest'||verRange===''||verRange==='>=0.0.0'});
7416
+ isUnpinned:verRange==='*'||verRange==='latest'||verRange===''||verRange==='>=0.0.0',
7417
+ line:_findManifestLine(text,depKey,name)});
7265
7418
  }
7266
7419
  }
7267
7420
  }catch(_){}return out;
@@ -7290,12 +7443,13 @@ function _parsePackageLockJson(text,filePath){
7290
7443
 
7291
7444
  function _parseRequirementsTxt(text,filePath){
7292
7445
  const out=[];
7293
- for(const line of text.split('\n')){
7294
- const t=line.trim();
7446
+ const lines=text.split('\n');
7447
+ for(let i=0;i<lines.length;i++){
7448
+ const t=lines[i].trim();
7295
7449
  if(!t||t.startsWith('#')||t.startsWith('-'))continue;
7296
7450
  const m=t.match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
7297
7451
  if(m)out.push({name:m[1],version:m[2],group:'',scope:'required',
7298
- purl:_makePurl('pypi',m[1].toLowerCase(),m[2],''),ecosystem:'pypi',filePath,isUnpinned:false});
7452
+ purl:_makePurl('pypi',m[1].toLowerCase(),m[2],''),ecosystem:'pypi',filePath,isUnpinned:false,line:i+1});
7299
7453
  }return out;
7300
7454
  }
7301
7455
 
@@ -8091,6 +8245,15 @@ async function queryOSV(components,allFileContents){
8091
8245
  fixedVersions: vuln.fixedVersions, severity: vuln.severity, cvssVector: vuln.cvssVector,
8092
8246
  hasKnownAttackRef: vuln.hasKnownAttackRef, osvVulnFunctions: vuln.osvVulnFunctions || [], reachable: comp.reachable, scope: comp.scope,
8093
8247
  file: comp.filePath,
8248
+ // `isDirect` is backfilled onto every component just above the queryOSV
8249
+ // call, but was never carried onto the entry materialized from it — so
8250
+ // every consumer asking "is this a direct dependency" got `undefined`.
8251
+ // The transitive-dedup block's `group.find(s => s.isDirect)` has
8252
+ // therefore always fallen through to `group[0]`, picking an arbitrary
8253
+ // member as the primary instead of the direct one, and the provenance
8254
+ // pass's direct-only filter had nothing to filter on. `line` is Task
8255
+ // 12's declaration line, which the SCA provenance evidence node reads.
8256
+ isDirect: comp.isDirect, line: comp.line, depChain: Array.isArray(comp.depChain) ? comp.depChain.map((s) => s.replace(/\/$/, '')) : [],
8094
8257
  // kept for generateRecs() compat
8095
8258
  advisory: `${vid}${cveStr}, ${vuln.description}`,
8096
8259
  range: fixStr ? `< ${fixStr}` : 'see advisory' });
@@ -8268,7 +8431,197 @@ async function queryRegistries(components){
8268
8431
 
8269
8432
  // Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
8270
8433
  // fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
8271
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8434
+ // FR-202 phase 3b (D-0050): a worker importing this module gets a FRESH, unmutated
8435
+ // copy of SOURCE_PATTERNS/SINK_PATTERNS/SANITIZER_PATTERNS/_projectIndex/
8436
+ // _GLOBAL_JAVA_TAINTED_METHODS -- none of the main thread's pre-loop setup carries
8437
+ // across the worker boundary. This reproduces that exact setup (mirroring
8438
+ // runFullScan's own pre-loop sequence) so a long-lived worker can run it ONCE at
8439
+ // startup, then service many _runFileCascade calls with correctly-populated state,
8440
+ // instead of redoing whole-project work per file.
8441
+ export async function _initCascadeWorkerState(fileContents, scanRoot) {
8442
+ _buildProjectIndex(fileContents);
8443
+ try { _GLOBAL_JAVA_TAINTED_METHODS = _buildGlobalJavaTaintedMethodIndex(fileContents); }
8444
+ catch { _GLOBAL_JAVA_TAINTED_METHODS = new Set(); }
8445
+ await _loadCustomRules(scanRoot);
8446
+ }
8447
+
8448
+ // FR-202 phase 3b (D-0050): the shape a preemptively-KILLED worker task leaves
8449
+ // behind. Unlike the existing post-hoc _perFileTimeoutMs check (which fires only
8450
+ // after a slow-but-completed cascade already populated a real delta), a worker
8451
+ // timeout means the file's analysis was terminated mid-execution -- there is no
8452
+ // partial result to salvage, so every field is empty and only the timeout marker
8453
+ // finding (added by the caller, same as the cooperative path) reflects the file.
8454
+ function _emptyCascadeDelta(c) {
8455
+ return {
8456
+ content: c, pfr: { findings: [], sources: [], sinks: [], sanitizers: [] },
8457
+ routes: [], findings: [], sources: [], sinks: [], sanitizers: [],
8458
+ logic: [], secrets: [], ciphersRest: [], ciphersTransit: [], suppressions: [],
8459
+ };
8460
+ }
8461
+
8462
+ // FR-202 phase 3a (D-0049): the per-file detector cascade returns a self-contained
8463
+ // delta instead of mutating the outer accumulator arrays directly, so the same
8464
+ // function body can later run inside a worker (phase 3b) and post its result back
8465
+ // rather than reaching into shared memory it would not have access to.
8466
+ export function _runFileCascade(p, c, scanRoot, _detectorErrors) {
8467
+ const _aR=[],_aF=[],_aSrc=[],_aSink=[],_aSan=[],_aLogic=[],_aSecrets=[],_aCiphersRest=[],_aCiphersTransit=[],_aSupp=[];
8468
+ const cc=_blankCached(c,_commentLangFor(p));_aR.push(...(runDetector(_detectorErrors,p,'scanRoutes',()=>scanRoutes(p,cc))||[]));const ta=performAnalysis(p,c);_aF.push(...ta.findings);_aSrc.push(...ta.sources);_aSink.push(...ta.sinks);_aSan.push(...ta.sanitizers);_aLogic.push(...(runDetector(_detectorErrors,p,'scanLogicVulns',()=>scanLogicVulns(p,cc,_aSupp))||[]));_aSecrets.push(...(runDetector(_detectorErrors,p,'scanCredentials',()=>scanCredentials(p,c))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanStructuralVulns',()=>scanStructuralVulns(p,cc,_aSupp))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanExtraStructural',()=>scanExtraStructural(p,cc,_aSupp))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanAliasedSinks',()=>scanAliasedSinks(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanJavaSAST',()=>scanJavaSAST(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanJavaBenchExtras',()=>scanJavaBenchExtras(p,cc))||[]));_aLogic.push(...(runDetector(_detectorErrors,p,'scanMiddlewareOrdering',()=>scanMiddlewareOrdering(p,cc))||[]));_aLogic.push(...(runDetector(_detectorErrors,p,'scanReDoS',()=>scanReDoS(p,cc))||[]));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){_aLogic.push(...(runDetector(_detectorErrors,p,'scanRegexReDoS',()=>scanRegexReDoS(p,cc))||[]));}_aLogic.push(...(runDetector(_detectorErrors,p,'scanTodosNearSecurity',()=>scanTodosNearSecurity(p,c))||[]));_aSecrets.push(...(runDetector(_detectorErrors,p,'scanEntropySecrets',()=>scanEntropySecrets(p,c,_aSupp))||[]));const cp=scanCiphers(p,cc);_aCiphersRest.push(...cp.atRest);_aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))_aF.push(...(runDetector(_detectorErrors,p,'scanGraphQL',()=>scanGraphQL(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanIaC',()=>scanIaC(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanTerraform',()=>scanTerraform(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanCloudTemplates',()=>scanCloudTemplates(p,c))||[]));
8469
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLLM',()=>scanLLM(p,c))||[]));
8470
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLLMOwasp',()=>scanLLMOwasp(p,c))||[]));
8471
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLlmCost',()=>scanLlmCost(p,c))||[]));
8472
+ _aLogic.push(...(runDetector(_detectorErrors,p,'scanBusinessLogic',()=>scanBusinessLogic(p,cc))||[]));
8473
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPipeline',()=>scanPipeline(p,cc))||[]));
8474
+ _aF.push(...(runDetector(_detectorErrors,p,'scanContainer',()=>scanContainer(p,cc))||[]));
8475
+ _aF.push(...(runDetector(_detectorErrors,p,'scanInstallScripts',()=>scanInstallScripts(p,cc))||[]));
8476
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMCP',()=>scanMCP(p,c))||[]));
8477
+ // PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
8478
+ // Every scanMCP rule judges the CURRENT content, so a description that is
8479
+ // innocuous today and hostile tomorrow passes both scans. This compares
8480
+ // against a recorded baseline, which is the only way to see a change.
8481
+ // Wired here rather than left as a tested module: a detector with no call
8482
+ // site is a dark detector, which is the exact class this session keeps
8483
+ // finding.
8484
+ if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
8485
+ try {
8486
+ const _cfg = JSON.parse(c);
8487
+ const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
8488
+ _aF.push(..._rp.findings);
8489
+ // Record on first sight so the NEXT scan has something to compare
8490
+ // against; refresh after reporting so a reviewed change is not
8491
+ // re-reported forever.
8492
+ _saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
8493
+ } catch (e) {
8494
+ // Only a malformed config is tolerated here — scanMCP already reports
8495
+ // what it can from one. Anything else is a programmer error and must
8496
+ // not be swallowed: a bare `catch {}` around this block hid a
8497
+ // ReferenceError (`root` vs `scanRoot`) that silently disabled the
8498
+ // whole detector while every unit test still passed.
8499
+ if (!(e instanceof SyntaxError)) throw e;
8500
+ }
8501
+ }
8502
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClaudeSettings',()=>scanClaudeSettings(p,c))||[]));
8503
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClaudeMdPromptInjection',()=>scanClaudeMdPromptInjection(p,c))||[]));
8504
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClaudeHookInjection',()=>scanClaudeHookInjection(p,c))||[]));
8505
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDjangoHardening',()=>scanDjangoHardening(p,cc))||[]));
8506
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDefiDeep',()=>scanDefiDeep(p,cc))||[]));
8507
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSpringbootHardening',()=>scanSpringbootHardening(p,cc))||[]));
8508
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLaravelHardening',()=>scanLaravelHardening(p,cc))||[]));
8509
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSwift',()=>scanSwift(p,cc))||[]));
8510
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDartFlutter',()=>scanDartFlutter(p,cc))||[]));
8511
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWeakRandomness',()=>scanWeakRandomness(p,cc))||[]));
8512
+ _aF.push(...(runDetector(_detectorErrors,p,'scanGraphQLModule',()=>scanGraphQLModule(p,cc))||[]));
8513
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSensitiveDataLogging',()=>scanSensitiveDataLogging(p,cc))||[]));
8514
+ _aF.push(...(runDetector(_detectorErrors,p,'scanComparisonSafety',()=>scanComparisonSafety(p,cc))||[]));
8515
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWeakPasswordHash',()=>scanWeakPasswordHash(p,cc))||[]));
8516
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCachePoisoning',()=>scanCachePoisoning(p,cc))||[]));
8517
+ _aF.push(...(runDetector(_detectorErrors,p,'scanNullByteInjection',()=>scanNullByteInjection(p,cc))||[]));
8518
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLlmTradingAgent',()=>scanLlmTradingAgent(p,c))||[]));
8519
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMobileManifest',()=>scanMobileManifest(p,cc))||[]));
8520
+ _aF.push(...(runDetector(_detectorErrors,p,'scanQuarkusHardening',()=>scanQuarkusHardening(p,cc))||[]));
8521
+ _aF.push(...(runDetector(_detectorErrors,p,'scanFastapiHardening',()=>scanFastapiHardening(p,cc))||[]));
8522
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAuthZ',()=>scanAuthZ(p,cc))||[]));
8523
+ _aF.push(...(runDetector(_detectorErrors,p,'scanModelLoad',()=>scanModelLoad(p,cc))||[]));
8524
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPromptTemplate',()=>scanPromptTemplate(p,c))||[]));
8525
+ _aF.push(...(runDetector(_detectorErrors,p,'scanXXE',()=>scanXXE(p,cc))||[]));
8526
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJNDI',()=>scanJNDI(p,cc))||[]));
8527
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJavaDeserialization',()=>scanJavaDeserialization(p,cc))||[]));
8528
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJwtExp',()=>scanJwtExp(p,cc))||[]));
8529
+ _aF.push(...(runDetector(_detectorErrors,p,'scanZipSlip',()=>scanZipSlip(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanSiblingGuard',()=>scanSiblingGuard(p,cc))||[]));
8530
+ _aF.push(...(runDetector(_detectorErrors,p,'scanFileUpload',()=>scanFileUpload(p,cc))||[]));
8531
+ _aF.push(...(runDetector(_detectorErrors,p,'scanHostHeader',()=>scanHostHeader(p,cc))||[]));
8532
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPythonSinks',()=>scanPythonSinks(p,cc))||[]));
8533
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCSharp',()=>scanCSharp(p,cc))||[]));
8534
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCpp',()=>scanCpp(p,cc))||[]));
8535
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSolidity',()=>scanSolidity(p,cc))||[]));
8536
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRust',()=>scanRust(p,cc))||[]));
8537
+ _aF.push(...(runDetector(_detectorErrors,p,'scanGoExtended',()=>scanGoExtended(p,cc))||[]));
8538
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDatabaseRLS',()=>scanDatabaseRLS(p,cc))||[]));
8539
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRateLimit',()=>scanRateLimit(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanResourceExhaustion',()=>scanResourceExhaustion(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanRedirectToctou',()=>scanRedirectToctou(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanCodegenSink',()=>scanCodegenSink(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanOwnershipAuthz',()=>scanOwnershipAuthz(p,cc))||[]));
8540
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAuthProvider',()=>scanAuthProvider(p,cc))||[]));
8541
+ _aF.push(...(runDetector(_detectorErrors,p,'scanEnvHygiene',()=>scanEnvHygiene(p,cc))||[]));
8542
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWebhook',()=>scanWebhook(p,cc))||[]));
8543
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClientSide',()=>scanClientSide(p,cc))||[]));
8544
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPromptFirewall',()=>scanPromptFirewall(p,c))||[]));
8545
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLlmRedteam',()=>scanLlmRedteam(p,c))||[]));
8546
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJulietShape',()=>scanJulietShape(p,c))||[]));
8547
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCppDataflow',()=>scanCppDataflow(p,cc))||[]));
8548
+ // Phase 1: new detectors.
8549
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMassAssignment',()=>scanMassAssignment(p,cc))||[]));
8550
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPrototypePollution',()=>scanPrototypePollution(p,cc))||[]));
8551
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCSRF',()=>scanCSRF(p,cc))||[]));
8552
+ _aF.push(...(runDetector(_detectorErrors,p,'scanTOCTOU',()=>scanTOCTOU(p,cc))||[]));
8553
+ _aF.push(...(runDetector(_detectorErrors,p,'scanNoSQLInjection',()=>scanNoSQLInjection(p,cc))||[]));
8554
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLDAPInjection',()=>scanLDAPInjection(p,cc))||[]));
8555
+ _aF.push(...(runDetector(_detectorErrors,p,'scanXPathInjection',()=>scanXPathInjection(p,cc))||[]));
8556
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSSTI',()=>scanSSTI(p,cc))||[]));
8557
+ _aF.push(...(runDetector(_detectorErrors,p,'scanOpenRedirect',()=>scanOpenRedirect(p,cc))||[]));
8558
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWrongContextSanitizer',()=>scanWrongContextSanitizer(p,cc))||[]));
8559
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSanitizerContextMismatch',()=>scanSanitizerContextMismatch(p,cc))||[]));
8560
+ _aF.push(...(runDetector(_detectorErrors,p,'scanFrontendHygiene',()=>scanFrontendHygiene(p,cc))||[]));
8561
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCsvInjection',()=>scanCsvInjection(p,cc))||[]));
8562
+ // R16 — specialist crypto-hygiene classes (constant-time comparison,
8563
+ // secret zeroization). Narrow by design: keyed on the secret-ness of the
8564
+ // identifier, and silent whenever the correct constant-time or
8565
+ // guaranteed-wipe API is already present.
8566
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCryptoSpecialist',()=>scanCryptoSpecialist(p,cc))||[]));
8567
+ _aF.push(...(runDetector(_detectorErrors,p,'scanStoredTaint',()=>scanStoredTaint(p,cc))||[]));
8568
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJavaStructural',()=>scanJavaStructural(p,cc))||[]));
8569
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCsharpStructural',()=>scanCsharpStructural(p,cc))||[]));
8570
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJsFrameworkStructural',()=>scanJsFrameworkStructural(p,cc))||[]));
8571
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPythonStructural',()=>scanPythonStructural(p,cc))||[]));
8572
+ _aF.push(...(runDetector(_detectorErrors,p,'scanGoStructural',()=>scanGoStructural(p,cc))||[]));
8573
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSecretConcat',()=>scanSecretConcat(p,cc))||[]));
8574
+ _aF.push(...(runDetector(_detectorErrors,p,'scanXssReflectedMultilang',()=>scanXssReflectedMultilang(p,cc))||[]));
8575
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCodeInjectionMultilang',()=>scanCodeInjectionMultilang(p,cc))||[]));
8576
+ _aF.push(...(runDetector(_detectorErrors,p,'scanResponseSplitting',()=>scanResponseSplitting(p,cc))||[]));
8577
+ _aF.push(...(runDetector(_detectorErrors,p,'scanStoredPromptInjection',()=>scanStoredPromptInjection(p,c))||[]));
8578
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRAGPoisoning',()=>scanRAGPoisoning(p,c))||[]));
8579
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAgentToolEscalation',()=>scanAgentToolEscalation(p,c))||[]));
8580
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAgentUntrustedFlow',()=>scanAgentUntrustedFlow(p,c))||[]));
8581
+ _aF.push(...(runDetector(_detectorErrors,p,'scanEventEntrypoints',()=>scanEventEntrypoints(p,cc))||[]));
8582
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDbTaint',()=>scanDbTaint(p,cc))||[]));
8583
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSSRFCloudMetadata',()=>scanSSRFCloudMetadata(p,cc))||[]));
8584
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMutationXSS',()=>scanMutationXSS(p,cc))||[]));
8585
+ _aF.push(...(runDetector(_detectorErrors,p,'scanKotlin',()=>scanKotlin(p,cc))||[]));
8586
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRuby',()=>scanRuby(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanRubyPathJoin',()=>scanRubyPathJoin(p,cc))||[]));
8587
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPhp',()=>scanPhp(p,cc))||[]));
8588
+ // Integration block: scaffolded SAST scanners. Gated by env var.
8589
+ if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
8590
+ if (process.env.AGENTIC_SECURITY_NO_LLM_APP !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanLlmApp',()=>scanLlmApp(p,c))||[]));
8591
+ if (process.env.AGENTIC_SECURITY_NO_MOBILE !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanMobile',()=>scanMobile(p,cc))||[]));
8592
+ if (process.env.AGENTIC_SECURITY_NO_PQC !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanPqc',()=>scanPqc(p,cc))||[]));
8593
+ if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV!== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanWeb3Advanced',()=>scanWeb3Advanced(p,cc))||[]));
8594
+ if (process.env.AGENTIC_SECURITY_NO_DAPP !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanDappFrontend',()=>scanDappFrontend(p,cc))||[]));
8595
+ if (process.env.AGENTIC_SECURITY_NO_CLOUD_IAM!== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanCloudIam',()=>scanCloudIam(p,cc))||[]));
8596
+ if (process.env.AGENTIC_SECURITY_NO_K8S_ADM !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanK8sAdmission',()=>scanK8sAdmission(p,cc))||[]));
8597
+ if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanCryptoProtocol',()=>scanCryptoProtocol(p,cc))||[]));
8598
+ if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanMlSupplyChain',()=>scanMlSupplyChain(p,cc))||[]));
8599
+ }
8600
+ return {content:c, pfr:ta, routes:_aR, findings:_aF, sources:_aSrc, sinks:_aSink, sanitizers:_aSan, logic:_aLogic, secrets:_aSecrets, ciphersRest:_aCiphersRest, ciphersTransit:_aCiphersTransit, suppressions:_aSupp};
8601
+ }
8602
+
8603
+ // `provenance:false` is the RE-ENTRANCY BRAKE, not a feature flag.
8604
+ // posture/provenance/predicate-replay.js answers "did this finding's condition
8605
+ // hold at commit X" by calling runFullScan back on that commit's blobs. Once
8606
+ // runFullScan itself runs the provenance pass, that is an unbounded recursion —
8607
+ // scan → provenance → replay → scan → … — which manifests as a scan that never
8608
+ // returns and spawns `git` forever, because every level of it is synchronous
8609
+ // execFileSync work. The replay's findings are discarded, so it wants no
8610
+ // provenance anyway; and it must NOT touch the lifecycle store, whose events
8611
+ // would otherwise be written from historical blobs as if they were this scan.
8612
+ // Passed explicitly per invocation rather than held in a module-level guard so
8613
+ // concurrent scans in one process cannot disable each other's provenance.
8614
+ //
8615
+ // `completeScan` is a SEPARATE question from `provenance`, and conflating them
8616
+ // is what let the fourth instance of this bug through. `provenance:false` says
8617
+ // "do not run the pass at all"; `completeScan:false` says "the pass may run,
8618
+ // but this file set is a SUBSET of scanRoot, so absence of a finding proves
8619
+ // nothing." Only the lifecycle ledger's remediation pass reads it — that is the
8620
+ // one place a finding's absence is turned into a positive claim. Defaults true
8621
+ // because a direct runFullScan caller supplying no file-subsetting options is
8622
+ // scanning everything it was given; runScan.js narrows it for --changed-since
8623
+ // and for caller-supplied fileContents.
8624
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8272
8625
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8273
8626
  // analysis can recognize calls to user-input-returning helper methods
8274
8627
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -8295,7 +8648,7 @@ function _deterministicFileTimings(timings) {
8295
8648
 
8296
8649
  const _fileTimings = [];
8297
8650
  let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
8298
- const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
8651
+ const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];const _detectorErrors=[];
8299
8652
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
8300
8653
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
8301
8654
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
@@ -8303,17 +8656,28 @@ function _deterministicFileTimings(timings) {
8303
8656
  const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
8304
8657
  let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
8305
8658
  const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
8659
+ let _ckptInvalidated = [];
8306
8660
  if (_ckptEnabled) {
8307
8661
  try {
8308
- const _runKey = computeRunKey({
8662
+ const _ckptIdentity = {
8309
8663
  engineVersion: _ENGINE_VERSION,
8310
8664
  rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version,
8311
8665
  bundleSha: bundleShaForRunKey(),
8312
- fileContents, depFileContents,
8666
+ depFileContents,
8667
+ };
8668
+ // FR-208: the global key covers everything that would affect how EVERY
8669
+ // file is analysed (engine/ruleset/bundle/deps/env); each scanned
8670
+ // file's OWN content is checked per-record inside openCheckpoint
8671
+ // instead, so changing one file no longer discards every other file's
8672
+ // already-completed work — see scan-checkpoint.js's module header.
8673
+ _ckpt = openCheckpoint(scanRoot, {
8674
+ globalKey: computeGlobalKey(_ckptIdentity),
8675
+ meta: globalKeyMeta(_ckptIdentity),
8676
+ fileContents,
8313
8677
  });
8314
- _ckpt = openCheckpoint(scanRoot, { runKey: _runKey });
8315
8678
  for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
8316
8679
  _ckptDone = completedFiles(_ckpt);
8680
+ _ckptInvalidated = invalidatedFiles(_ckpt);
8317
8681
  } catch (_) { _ckpt = null; }
8318
8682
  }
8319
8683
  // Replay a checkpointed file's ENTIRE contribution, in the same array order
@@ -8360,146 +8724,45 @@ function _deterministicFileTimings(timings) {
8360
8724
  // is what the checkpoint format has to survive. Never set in normal use.
8361
8725
  if (_ckptAbortAfter > 0 && _ckptWrites >= _ckptAbortAfter) process.exit(137);
8362
8726
  };
8363
- let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
8727
+ // FR-202 phase 3b (D-0050): opt-in real deadline enforcement for the per-file
8728
+ // cascade via a reused worker pool. Off by default -- the synchronous path above
8729
+ // this loop is completely unchanged when the flag is unset.
8730
+ const _useWorkerCascade = process.env.AGENTIC_SECURITY_WORKER_CASCADE === '1';
8731
+ let _cascadePool = null;
8732
+ if (_useWorkerCascade) {
8733
+ const { createCascadePool } = await import('./pipeline/cascade-worker-pool.js');
8734
+ _cascadePool = createCascadePool({
8735
+ fileContents, scanRoot,
8736
+ modulePath: _resolveEngineModulePathForWorkers(),
8737
+ poolSize: Number(process.env.AGENTIC_SECURITY_WORKER_POOL_SIZE) || 4,
8738
+ });
8739
+ }
8740
+ let i=0;
8741
+ try {
8742
+ for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
8364
8743
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
8365
8744
  const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
8366
- try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));aF.push(...scanCloudTemplates(p,c));
8367
- aF.push(...scanLLM(p,c));
8368
- aF.push(...scanLLMOwasp(p,c));
8369
- aF.push(...scanLlmCost(p,c));
8370
- aLogic.push(...scanBusinessLogic(p,cc));
8371
- aF.push(...scanPipeline(p,cc));
8372
- aF.push(...scanContainer(p,cc));
8373
- aF.push(...scanInstallScripts(p,cc));
8374
- aF.push(...scanMCP(p,c));
8375
- // PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
8376
- // Every scanMCP rule judges the CURRENT content, so a description that is
8377
- // innocuous today and hostile tomorrow passes both scans. This compares
8378
- // against a recorded baseline, which is the only way to see a change.
8379
- // Wired here rather than left as a tested module: a detector with no call
8380
- // site is a dark detector, which is the exact class this session keeps
8381
- // finding.
8382
- if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
8383
- try {
8384
- const _cfg = JSON.parse(c);
8385
- const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
8386
- aF.push(..._rp.findings);
8387
- // Record on first sight so the NEXT scan has something to compare
8388
- // against; refresh after reporting so a reviewed change is not
8389
- // re-reported forever.
8390
- _saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
8391
- } catch (e) {
8392
- // Only a malformed config is tolerated here — scanMCP already reports
8393
- // what it can from one. Anything else is a programmer error and must
8394
- // not be swallowed: a bare `catch {}` around this block hid a
8395
- // ReferenceError (`root` vs `scanRoot`) that silently disabled the
8396
- // whole detector while every unit test still passed.
8397
- if (!(e instanceof SyntaxError)) throw e;
8398
- }
8399
- }
8400
- aF.push(...scanClaudeSettings(p,c));
8401
- aF.push(...scanClaudeMdPromptInjection(p,c));
8402
- aF.push(...scanClaudeHookInjection(p,c));
8403
- aF.push(...scanDjangoHardening(p,cc));
8404
- aF.push(...scanDefiDeep(p,cc));
8405
- aF.push(...scanSpringbootHardening(p,cc));
8406
- aF.push(...scanLaravelHardening(p,cc));
8407
- aF.push(...scanSwift(p,cc));
8408
- aF.push(...scanDartFlutter(p,cc));
8409
- aF.push(...scanWeakRandomness(p,cc));
8410
- aF.push(...scanGraphQLModule(p,cc));
8411
- aF.push(...scanSensitiveDataLogging(p,cc));
8412
- aF.push(...scanComparisonSafety(p,cc));
8413
- aF.push(...scanWeakPasswordHash(p,cc));
8414
- aF.push(...scanCachePoisoning(p,cc));
8415
- aF.push(...scanNullByteInjection(p,cc));
8416
- aF.push(...scanLlmTradingAgent(p,c));
8417
- aF.push(...scanMobileManifest(p,cc));
8418
- aF.push(...scanQuarkusHardening(p,cc));
8419
- aF.push(...scanFastapiHardening(p,cc));
8420
- aF.push(...scanAuthZ(p,cc));
8421
- aF.push(...scanModelLoad(p,cc));
8422
- aF.push(...scanPromptTemplate(p,c));
8423
- aF.push(...scanXXE(p,cc));
8424
- aF.push(...scanJNDI(p,cc));
8425
- aF.push(...scanJavaDeserialization(p,cc));
8426
- aF.push(...scanJwtExp(p,cc));
8427
- aF.push(...scanZipSlip(p,cc));aF.push(...scanSiblingGuard(p,cc));
8428
- aF.push(...scanFileUpload(p,cc));
8429
- aF.push(...scanHostHeader(p,cc));
8430
- aF.push(...scanPythonSinks(p,cc));
8431
- aF.push(...scanCSharp(p,cc));
8432
- aF.push(...scanCpp(p,cc));
8433
- aF.push(...scanSolidity(p,cc));
8434
- aF.push(...scanRust(p,cc));
8435
- aF.push(...scanGoExtended(p,cc));
8436
- aF.push(...scanDatabaseRLS(p,cc));
8437
- aF.push(...scanRateLimit(p,cc));aF.push(...scanResourceExhaustion(p,cc));aF.push(...scanRedirectToctou(p,cc));aF.push(...scanCodegenSink(p,cc));aF.push(...scanOwnershipAuthz(p,cc));
8438
- aF.push(...scanAuthProvider(p,cc));
8439
- aF.push(...scanEnvHygiene(p,cc));
8440
- aF.push(...scanWebhook(p,cc));
8441
- aF.push(...scanClientSide(p,cc));
8442
- aF.push(...scanPromptFirewall(p,c));
8443
- aF.push(...scanLlmRedteam(p,c));
8444
- aF.push(...scanJulietShape(p,c));
8445
- aF.push(...scanCppDataflow(p,cc));
8446
- // Phase 1: new detectors.
8447
- aF.push(...scanMassAssignment(p,cc));
8448
- aF.push(...scanPrototypePollution(p,cc));
8449
- aF.push(...scanCSRF(p,cc));
8450
- aF.push(...scanTOCTOU(p,cc));
8451
- aF.push(...scanNoSQLInjection(p,cc));
8452
- aF.push(...scanLDAPInjection(p,cc));
8453
- aF.push(...scanXPathInjection(p,cc));
8454
- aF.push(...scanSSTI(p,cc));
8455
- aF.push(...scanOpenRedirect(p,cc));
8456
- aF.push(...scanWrongContextSanitizer(p,cc));
8457
- aF.push(...scanSanitizerContextMismatch(p,cc));
8458
- aF.push(...scanFrontendHygiene(p,cc));
8459
- aF.push(...scanCsvInjection(p,cc));
8460
- // R16 — specialist crypto-hygiene classes (constant-time comparison,
8461
- // secret zeroization). Narrow by design: keyed on the secret-ness of the
8462
- // identifier, and silent whenever the correct constant-time or
8463
- // guaranteed-wipe API is already present.
8464
- aF.push(...scanCryptoSpecialist(p,cc));
8465
- aF.push(...scanStoredTaint(p,cc));
8466
- aF.push(...scanJavaStructural(p,cc));
8467
- aF.push(...scanCsharpStructural(p,cc));
8468
- aF.push(...scanJsFrameworkStructural(p,cc));
8469
- aF.push(...scanPythonStructural(p,cc));
8470
- aF.push(...scanGoStructural(p,cc));
8471
- aF.push(...scanSecretConcat(p,cc));
8472
- aF.push(...scanXssReflectedMultilang(p,cc));
8473
- aF.push(...scanCodeInjectionMultilang(p,cc));
8474
- aF.push(...scanResponseSplitting(p,cc));
8475
- aF.push(...scanStoredPromptInjection(p,c));
8476
- aF.push(...scanRAGPoisoning(p,c));
8477
- aF.push(...scanAgentToolEscalation(p,c));
8478
- aF.push(...scanAgentUntrustedFlow(p,c));
8479
- aF.push(...scanEventEntrypoints(p,cc));
8480
- aF.push(...scanDbTaint(p,cc));
8481
- aF.push(...scanSSRFCloudMetadata(p,cc));
8482
- aF.push(...scanMutationXSS(p,cc));
8483
- aF.push(...scanKotlin(p,cc));
8484
- aF.push(...scanRuby(p,cc));aF.push(...scanRubyPathJoin(p,cc));
8485
- aF.push(...scanPhp(p,cc));
8486
- // Integration block: scaffolded SAST scanners. Gated by env var.
8487
- if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
8488
- if (process.env.AGENTIC_SECURITY_NO_LLM_APP !== '1') aF.push(...scanLlmApp(p,c));
8489
- if (process.env.AGENTIC_SECURITY_NO_MOBILE !== '1') aF.push(...scanMobile(p,cc));
8490
- if (process.env.AGENTIC_SECURITY_NO_PQC !== '1') aF.push(...scanPqc(p,cc));
8491
- if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV!== '1') aF.push(...scanWeb3Advanced(p,cc));
8492
- if (process.env.AGENTIC_SECURITY_NO_DAPP !== '1') aF.push(...scanDappFrontend(p,cc));
8493
- if (process.env.AGENTIC_SECURITY_NO_CLOUD_IAM!== '1') aF.push(...scanCloudIam(p,cc));
8494
- if (process.env.AGENTIC_SECURITY_NO_K8S_ADM !== '1') aF.push(...scanK8sAdmission(p,cc));
8495
- if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO !== '1') aF.push(...scanCryptoProtocol(p,cc));
8496
- if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY !== '1') aF.push(...scanMlSupplyChain(p,cc));
8745
+ try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
8746
+ let _delta;
8747
+ if (_cascadePool) {
8748
+ const _res = await _cascadePool.runFile(p, c, scanRoot, _detectorErrors, { timeoutMs: _perFileTimeoutMs });
8749
+ if (_res.ok) { _delta = _res.result; }
8750
+ else if (_res.timedOut) { _delta = _emptyCascadeDelta(c); }
8751
+ else { throw new Error(_res.error); }
8752
+ } else {
8753
+ _delta = _runFileCascade(p,c,scanRoot,_detectorErrors);
8497
8754
  }
8755
+ fc[p]=_delta.content;pfr[p]=_delta.pfr;
8756
+ aR.push(..._delta.routes);aF.push(..._delta.findings);aSrc.push(..._delta.sources);aSink.push(..._delta.sinks);aSan.push(..._delta.sanitizers);aLogic.push(..._delta.logic);aSecrets.push(..._delta.secrets);aCiphersRest.push(..._delta.ciphersRest);aCiphersTransit.push(..._delta.ciphersTransit);_suppressionLog.push(..._delta.suppressions);
8498
8757
  const _ftElapsed=Date.now()-_ft0;
8499
8758
  if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
8500
8759
  _fileTimings.push({file:p,ms:_ftElapsed});
8501
- _ckptRecord(p,_mk,_ftElapsed,ta);
8502
- }catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}if(i%5===0)await new Promise(r=>setTimeout(r,0));}
8760
+ _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
8761
+ }catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}
8762
+ if(i%5===0)await new Promise(r=>setTimeout(r,0));}
8763
+ } finally {
8764
+ if (_cascadePool) await _cascadePool.shutdown();
8765
+ }
8503
8766
  // Deserialization-gadget detector runs once with full-tree context (it needs
8504
8767
  // manifest contents to know which gadget libs are on the classpath).
8505
8768
  try {
@@ -8507,7 +8770,7 @@ function _deterministicFileTimings(timings) {
8507
8770
  if (_gadgets.size) {
8508
8771
  for (const p of files) {
8509
8772
  const c = fc[p]; if (!c) continue;
8510
- aF.push(...scanDeserializationGadgets(p, c, { gadgets: _gadgets }));
8773
+ aF.push(...(runDetector(_detectorErrors,p,'scanDeserializationGadgets',()=>scanDeserializationGadgets(p, c, { gadgets: _gadgets }))||[]));
8511
8774
  }
8512
8775
  }
8513
8776
  } catch(_) {}
@@ -8650,17 +8913,17 @@ function _deterministicFileTimings(timings) {
8650
8913
  // R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
8651
8914
  // Convention deviation is PROJECT-scoped by design: the convention is a
8652
8915
  // property of the codebase, not of one file (see convention-deviation.js).
8653
- try{aF.push(...scanConventionDeviationProject(fc));}catch(_){}
8654
- try{aF.push(...scanApiBrokenAuthz(aR));}catch(_){}
8916
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanConventionDeviationProject',()=>scanConventionDeviationProject(fc))||[]));
8917
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanApiBrokenAuthz',()=>scanApiBrokenAuthz(aR))||[]));
8655
8918
  // R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
8656
- try{aF.push(...scanCrossService(aR,fc));}catch(_){}
8919
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanCrossService',()=>scanCrossService(aR,fc))||[]));
8657
8920
  // R21 (PRD §5): RBAC role-tier consistency over the route inventory.
8658
- try{aF.push(...scanRbacConsistency(aR,fc));}catch(_){}
8921
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanRbacConsistency',()=>scanRbacConsistency(aR,fc))||[]));
8659
8922
  setProgress({current:i,total:files.length,file:"Reachability + guards...",phase:"Linking"});annotateReachability(aF,aR,callGraph,fc);aF.forEach(f=>detectGuardsForFinding(f,fc));
8660
8923
  setProgress({current:i,total:files.length,file:"Inferring sanitizers...",phase:"Linking"});const learned=inferSanitizers(fc);applyLearnedSanitizers(aF,learned,fc);
8661
8924
  setProgress({current:i,total:files.length,file:"Sanitizer effectiveness...",phase:"Linking"});applySanitizerEffectiveness(aF);
8662
8925
  setProgress({current:i,total:files.length,file:"Attack chains...",phase:"Linking"});const chains=crossFindingChain(aF);aF.push(...chains);
8663
- setProgress({current:i,total:files.length,file:"Config file cross-ref...",phase:"Linking"});aLogic.push(...scanConfigFiles(fc));
8926
+ setProgress({current:i,total:files.length,file:"Config file cross-ref...",phase:"Linking"});aLogic.push(...(runDetector(_detectorErrors,'<project>','scanConfigFiles',()=>scanConfigFiles(fc))||[]));
8664
8927
  setProgress({current:i,total:files.length,file:"OSV vulnerability database...",phase:"SCA"});
8665
8928
  const allFileContents={...fc, ...depFileContents};
8666
8929
  // PRD F11.4 — malicious install hooks, scanned where package.json actually
@@ -8675,7 +8938,7 @@ function _deterministicFileTimings(timings) {
8675
8938
  // A manifest detector belongs on the manifest path.
8676
8939
  for (const [mp, mc] of Object.entries(depFileContents)) {
8677
8940
  if (!/(?:^|\/)package\.json$/i.test(mp)) continue;
8678
- try { aF.push(...scanInstallScripts(mp, mc)); } catch (_) { /* never fail a scan on one manifest */ }
8941
+ aF.push(...(runDetector(_detectorErrors,mp,'scanInstallScripts',()=>scanInstallScripts(mp, mc))||[]));
8679
8942
  }
8680
8943
  const components=parseManifests(allFileContents);
8681
8944
  // R8 (PRD §5): OS packages from an extracted container image's package DBs
@@ -8806,8 +9069,8 @@ function _deterministicFileTimings(timings) {
8806
9069
  }
8807
9070
  }
8808
9071
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
8809
- try{aF.push(...scanDbTaintCrossFile(fc));}catch(_){}
8810
- try{aF.push(...scanStoredPromptInjectionCrossFile(fc));}catch(_){}
9072
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9073
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanStoredPromptInjectionCrossFile',()=>scanStoredPromptInjectionCrossFile(fc))||[]));
8811
9074
  // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
8812
9075
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
8813
9076
  if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
@@ -8894,6 +9157,7 @@ function _deterministicFileTimings(timings) {
8894
9157
  const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8895
9158
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8896
9159
  let _deepCallGraph = null;
9160
+ let _deepFailure = null;
8897
9161
  if (_deepEnabled) {
8898
9162
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8899
9163
  const t0 = Date.now();
@@ -8931,7 +9195,10 @@ function _deterministicFileTimings(timings) {
8931
9195
  aF.push(...irFindings);
8932
9196
  } catch (e) {
8933
9197
  // Deep mode is best-effort. A parser blowup in one file shouldn't kill
8934
- // the scan — fall back to the pattern-only result.
9198
+ // the scan — fall back to the pattern-only result. Recorded (not just
9199
+ // swallowed) so scanHealth (FR-206) can report the downgrade instead of
9200
+ // looking identical to a scan where deep mode simply wasn't requested.
9201
+ _deepFailure = String((e && e.message) || e);
8935
9202
  }
8936
9203
  } else if (_deepRequested && _inCi) {
8937
9204
  // Operator asked for deep but we're in CI — emit a non-blocking notice
@@ -8945,6 +9212,23 @@ function _deterministicFileTimings(timings) {
8945
9212
  confidence: 1.0,
8946
9213
  });
8947
9214
  }
9215
+ // FR-205/FR-206: make the deep-analysis decision explicit in machine
9216
+ // output, not just as an occasional info-severity finding. Covers the case
9217
+ // the finding-based notice above does not: deep mode never explicitly
9218
+ // requested at all (the common default-unset-in-CI case), which previously
9219
+ // produced no signal of any kind that assurance had quietly narrowed.
9220
+ const _deepStatus = {
9221
+ requested: _deepRequested,
9222
+ enabled: _deepEnabled,
9223
+ inCi: _inCi,
9224
+ ciOverrideAllowed: _deepInCiAllowed,
9225
+ reason: _deepEnabled
9226
+ ? null
9227
+ : (_deepRequested
9228
+ ? (_inCi ? 'requested, but running in CI without AGENTIC_SECURITY_DEEP_IN_CI=1' : 'unknown')
9229
+ : (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
9230
+ failure: _deepFailure,
9231
+ };
8948
9232
  // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8949
9233
  if (_deepCallGraph) {
8950
9234
  try {
@@ -9087,19 +9371,88 @@ function _deterministicFileTimings(timings) {
9087
9371
  // Every catch in this block writes into _annotatorErrors so the operator
9088
9372
  // can tell "didn't run" from "ran cleanly." The array is surfaced as
9089
9373
  // scan.annotatorErrors in the report; an empty array means clean.
9374
+ //
9375
+ // FR-PROV-029 (Finding Provenance PRD): `skipAnnotators` lets a caller skip
9376
+ // this entire ~54-annotator pipeline below. The guard also covers
9377
+ // non-annotator finalization that lives in the same block — entropy-vs-
9378
+ // named secret dedup, orphan classification, supply-chain in-place
9379
+ // filtering, `Object.freeze(finalFindings)`, and closing the resume
9380
+ // checkpoint — so a skipped run leaves all of that undone too; name the
9381
+ // option accordingly if a future caller needs annotators skipped WITHOUT
9382
+ // skipping those steps. predicate-replay.js's `replayAt()` is the only
9383
+ // caller that sets it — it re-runs runFullScan scoped to a historical
9384
+ // commit's blob content purely to recompute `computeStableId()` over the
9385
+ // raw detector output (`scan.findings`/`scan.secrets`); it never reads
9386
+ // anything an annotator sets (verified empirically — see the commit
9387
+ // message). Every binding the pipeline below
9388
+ // populates that the final `return` still references is declared here,
9389
+ // OUTSIDE the guarded block, defaulted to exactly what it was before any
9390
+ // annotator ran. A skipped run returns those fields at their
9391
+ // pre-annotation default instead of throwing a ReferenceError; a normal
9392
+ // (non-skipping) run is byte-for-byte unaffected, since the guarded block
9393
+ // below still assigns the same values to these same bindings — it just no
9394
+ // longer *declares* them, so nothing here changes what a value ends up
9395
+ // being, only where the variable comes into scope.
9090
9396
  let _executionProofSummary = null, _vulnHistory = null;
9091
9397
  let _logicClaims = null;
9092
- const _annotatorErrors = [];
9093
- const _runAnnotator = (phase, fn) => {
9094
- try { return fn(); }
9095
- catch (e) {
9096
- _annotatorErrors.push({ phase, err: String((e && e.message) || e) });
9097
- return undefined;
9098
- }
9099
- };
9100
- _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
9101
- _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
9102
- _runAnnotator("demoteUnreachable", () => {
9398
+ let _annotatorErrors = [];
9399
+ let _v3 = {};
9400
+ let _privacyIrBacked = null;
9401
+ let _privacyTaxonomyVersion = null;
9402
+ let _privacyFramework = null;
9403
+ let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
9404
+ _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
9405
+ _licenseGraph = null, _attributions = null, _taxonomySummary = null;
9406
+ let _scanMeta = null;
9407
+ let _entrypointInventory = {};
9408
+ let _rootCauseSweep = null;
9409
+ let _proofCoverage = null;
9410
+ let _coverageLedger = null;
9411
+ let _scanHealth = null;
9412
+ // Task 11 (PRD P0 scope): ruleId backfill for scan.secrets / blameable
9413
+ // scan.logicVulns findings MUST run unconditionally, HERE, outside the
9414
+ // `skipAnnotators` guard below -- not just because the live scan needs it,
9415
+ // but because predicate-replay.js's replayAt() recurses into THIS function
9416
+ // with skipAnnotators:true and recomputes computeStableId() directly on
9417
+ // whatever it finds in the nested scan's own scan.secrets/scan.logicVulns.
9418
+ // If the backfill only ran on the live (skipAnnotators:false) call, the
9419
+ // nested replay scan would compute a DIFFERENT stableId (falling back to
9420
+ // the shared f.cwe -- e.g. every secret type collapsing onto "CWE-798")
9421
+ // than the live scan's already-backfilled finding, so replayAt's
9422
+ // `sid === targetStableId` check would NEVER match -- permanently landing
9423
+ // every secrets/logicVulns finding on status:'partial',
9424
+ // reason:'predicate-never-confirmed-in-candidates'. Caught empirically:
9425
+ // test/fixtures/entropy-fp's AWS-key fixture resolved 'partial' instead of
9426
+ // 'complete' until this moved here from inside the (skipAnnotators-gated)
9427
+ // provenance block further down.
9428
+ //
9429
+ // At this point in the function, aSecrets/aLogic hold every BLAMEABLE
9430
+ // producer's output (scanCredentials/scanEntropySecrets;
9431
+ // scanLogicVulns/scanBusinessLogic/scanMiddlewareOrdering/scanReDoS/
9432
+ // scanRegexReDoS/scanTodosNearSecurity/scanConfigFiles) -- the 3 synthetic
9433
+ // producers (license-policy:/deploy-platform:/stack-playbook:) and
9434
+ // logic-claims.js's ingested claims are pushed LATER, inside the
9435
+ // `skipAnnotators` guard below, so they are never present in a nested
9436
+ // replay scan's aLogic and never need this backfill for replay-matching
9437
+ // purposes. The provenance block further down re-applies this same
9438
+ // idempotent backfill to the full, final `blameableLogic` (which by then
9439
+ // includes logic-claims too) before calling annotateGitProvenance on it.
9440
+ const _slugify = (s) => String(s || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
9441
+ for (const f of aSecrets) { if (!f.ruleId) f.ruleId = `secret:${_slugify(f.vuln)}`; }
9442
+ for (const f of aLogic) { if (!f.ruleId) f.ruleId = `logic:${_slugify(f.vuln)}`; }
9443
+ if (!skipAnnotators) {
9444
+ // FR-106 (assurance-hardening PRD): Promise-aware, explicitly awaited at
9445
+ // every one of its ~51 call sites below (previously a sync `try{return
9446
+ // fn()}` let an async callback's rejection escape as an unhandled
9447
+ // rejection, and every caller fired-and-forgot regardless). Logic lives in
9448
+ // pipeline/annotator-runner.js, fault-injection tested directly there
9449
+ // since this project's own annotators are deliberately built never to
9450
+ // throw (posture/CLAUDE.md's "no throwing" convention) — this closure just
9451
+ // supplies the local _annotatorErrors array.
9452
+ const _runAnnotator = (phase, fn) => runAnnotatorAsync(_annotatorErrors, phase, fn);
9453
+ await _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
9454
+ await _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
9455
+ await _runAnnotator("demoteUnreachable", () => {
9103
9456
  demoteUnreachable(finalFindings, { routes: aR });
9104
9457
  // `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
9105
9458
  // (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
@@ -9108,14 +9461,14 @@ function _deterministicFileTimings(timings) {
9108
9461
  });
9109
9462
  // Premortem #8: backfill parser/family BEFORE confidence and calibration,
9110
9463
  // because both consume those fields and silently no-op when they're null.
9111
- _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
9112
- _runAnnotator("annotateConfidence", () => { annotateConfidence(finalFindings); });
9464
+ await _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
9465
+ await _runAnnotator("annotateConfidence", () => { annotateConfidence(finalFindings); });
9113
9466
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): Brier-calibrated probability +
9114
9467
  // 95% Wilson CI from per-family historical TP/FP. Falls back to null with
9115
9468
  // an explicit `calibration_reason` when N is below the calibration floor.
9116
- _runAnnotator("annotateCalibratedConfidence", () => { annotateCalibratedConfidence(finalFindings, { scanRoot }); });
9469
+ await _runAnnotator("annotateCalibratedConfidence", () => { annotateCalibratedConfidence(finalFindings, { scanRoot }); });
9117
9470
  const _projectCtx = (() => { try { return detectProjectContext(fc, aR); } catch { return {}; } })();
9118
- _runAnnotator("annotateExploitability", () => { annotateExploitability(finalFindings, _projectCtx); });
9471
+ await _runAnnotator("annotateExploitability", () => { annotateExploitability(finalFindings, _projectCtx); });
9119
9472
  // Roadmap #6 — proof-gate precision pass. Runs AFTER confidence +
9120
9473
  // exploitability so it can demote their tiers, and BEFORE mitigation /
9121
9474
  // composite-risk so the demotion flows into the canonical ranking. Default
@@ -9155,8 +9508,8 @@ function _deterministicFileTimings(timings) {
9155
9508
  if (f.stableId) unsanitizersOnPath[f.stableId] = undo;
9156
9509
  }
9157
9510
  }
9158
- _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
9159
- _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
9511
+ await _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
9512
+ await _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
9160
9513
  }
9161
9514
  // Addition #1 — default falsification pass. Actively tries to DISPROVE each
9162
9515
  // taint-style finding by locating a context-matched control on the path, and
@@ -9166,29 +9519,45 @@ function _deterministicFileTimings(timings) {
9166
9519
  // default; the LLM tier is only wired when an endpoint is configured. Opt out
9167
9520
  // with AGENTIC_SECURITY_NO_FALSIFICATION=1.
9168
9521
  if (process.env.AGENTIC_SECURITY_NO_FALSIFICATION !== '1') {
9169
- _runAnnotator("annotateFalsification", () => { annotateFalsification(finalFindings, fc); });
9522
+ await _runAnnotator("annotateFalsification", () => { annotateFalsification(finalFindings, fc); });
9170
9523
  }
9171
9524
  // Addition #5 — capability-based model routing. Stamp each finding with the
9172
9525
  // model tier a cost-sensitive fixer/triager/PoC subagent should be dispatched
9173
9526
  // on for THIS vuln class (crypto/auth/critical → strongest; injection → mid;
9174
9527
  // low-sev hardening → cheapest). Advisory metadata consumed at dispatch time.
9175
- _runAnnotator("annotateDispatchModel", () => {
9528
+ await _runAnnotator("annotateDispatchModel", () => {
9176
9529
  for (const f of finalFindings) { try { f.dispatchModel = routeModelForFinding(f).model; } catch { /* advisory only */ } }
9177
9530
  });
9178
9531
  // v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
9179
9532
  // the mitigation composite, persona prioritization, and final why-fired
9180
9533
  // record so those see the demotion signals.
9181
- _runAnnotator("annotateWafMitigation", () => { annotateWafMitigation(finalFindings, scanRoot); });
9182
- _runAnnotator("annotateAuthMitigation", () => { annotateAuthMitigation(finalFindings, scanRoot); });
9183
- _runAnnotator("annotateNetworkMitigation", () => { annotateNetworkMitigation(finalFindings, scanRoot); });
9184
- _runAnnotator("annotateTelemetry", () => { annotateTelemetry(finalFindings, scanRoot); });
9185
- _runAnnotator("annotateFeatureFlagGating", () => { annotateFeatureFlagGating(finalFindings, fc, { scanRoot }); });
9534
+ await _runAnnotator("annotateWafMitigation", () => { annotateWafMitigation(finalFindings, scanRoot); });
9535
+ await _runAnnotator("annotateAuthMitigation", () => { annotateAuthMitigation(finalFindings, scanRoot); });
9536
+ await _runAnnotator("annotateNetworkMitigation", () => { annotateNetworkMitigation(finalFindings, scanRoot); });
9537
+ await _runAnnotator("annotateTelemetry", () => { annotateTelemetry(finalFindings, scanRoot); });
9538
+ await _runAnnotator("annotateFeatureFlagGating", () => { annotateFeatureFlagGating(finalFindings, fc, { scanRoot }); });
9186
9539
  // v3 next-gen: composite mitigation verdict consumes every prod signal above.
9187
- _runAnnotator("annotateMitigationComposite", () => { annotateMitigationComposite(finalFindings); });
9540
+ await _runAnnotator("annotateMitigationComposite", () => { annotateMitigationComposite(finalFindings); });
9188
9541
  // Composite risk score (0..100 derived ordinal). Must run AFTER mitigation
9189
9542
  // composite + exploitability + toxicityScore so it sees the final values.
9190
9543
  // Used by agents and UI as the canonical sort key for "which finding first."
9191
- _runAnnotator("annotateCompositeRisk", () => { annotateCompositeRisk(finalFindings); });
9544
+ await _runAnnotator("annotateCompositeRisk", () => { annotateCompositeRisk(finalFindings); });
9545
+
9546
+ // FR-405 (assurance-hardening PRD): null means "privacy analysis never
9547
+ // ran at all" (AGENTIC_SECURITY_NO_PRIVACY=1, or the annotator threw
9548
+ // before setting this) — treated the same as false by the gate below,
9549
+ // since neither case has real IR-backed evidence to offer. Declared at
9550
+ // function scope (now hoisted above the `skipAnnotators` guard, near
9551
+ // _executionProofSummary et al. — same reasoning: FR-PROV-029), because
9552
+ // the annotatePrivacyTaint closure that assigns to it (inside the
9553
+ // AGENTIC_SECURITY_NO_INTEGRATION block below) runs and exits before that
9554
+ // block closes — a block-scoped `let` inside that if-statement would be
9555
+ // unreachable by the later assessPrivacyFramework call and the final
9556
+ // return, both of which are outside the block.
9557
+ //
9558
+ // FR-402: _privacyTaxonomyVersion (which taxonomy version actually
9559
+ // classified this scan's fields) has the same scoping constraint as
9560
+ // _privacyIrBacked directly above (D-0011) and is hoisted alongside it.
9192
9561
 
9193
9562
  // ── World-class integration block ─────────────────────────────────────
9194
9563
  // Each annotator is opt-in via env var and try/catch wrapped. They run
@@ -9198,46 +9567,89 @@ function _deterministicFileTimings(timings) {
9198
9567
  // Cross-service taint annotation reads .agentic-security/services.yml
9199
9568
  // and bumps severity on cross-service-reachable findings.
9200
9569
  if (process.env.AGENTIC_SECURITY_NO_CROSS_SERVICE !== '1') {
9201
- _runAnnotator("runCrossServiceTaint", () => { runCrossServiceTaint(scanRoot, finalFindings); });
9570
+ await _runAnnotator("runCrossServiceTaint", () => { runCrossServiceTaint(scanRoot, finalFindings); });
9202
9571
  }
9203
9572
  // Runtime correlation: demotes findings whose paths were unobserved
9204
9573
  // in production eBPF traces (when a trace file is present).
9205
9574
  if (process.env.AGENTIC_SECURITY_NO_RUNTIME_CORRELATION !== '1') {
9206
- _runAnnotator("annotateRuntimeCorrelation", async () => { await annotateRuntimeCorrelation(scanRoot, finalFindings); });
9575
+ await _runAnnotator("annotateRuntimeCorrelation", async () => { await annotateRuntimeCorrelation(scanRoot, finalFindings); });
9207
9576
  }
9208
9577
  // Triage learning: applies per-(project, family, file-glob) calibration
9209
9578
  // from prior wont-fix / false-positive decisions.
9210
9579
  if (process.env.AGENTIC_SECURITY_NO_TRIAGE_LEARNING !== '1') {
9211
- _runAnnotator("applyLearnedCalibration", () => { applyLearnedCalibration(scanRoot, finalFindings); });
9580
+ await _runAnnotator("applyLearnedCalibration", () => { applyLearnedCalibration(scanRoot, finalFindings); });
9212
9581
  }
9213
9582
  // Formal verification: CBMC for C/C++, MIRI for Rust. Opt-in via
9214
9583
  // AGENTIC_SECURITY_FORMAL=1 (off by default — requires external tools).
9215
9584
  if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
9216
- _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
9585
+ await _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
9217
9586
  }
9218
9587
  // SMT path feasibility: Z3-backed proof of reachability. Opt-in via
9219
9588
  // AGENTIC_SECURITY_SMT_FEASIBILITY=1.
9220
9589
  if (process.env.AGENTIC_SECURITY_SMT_FEASIBILITY === '1') {
9221
- _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
9590
+ await _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
9222
9591
  }
9223
9592
  // Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
9593
+ // FR-405 (assurance-hardening PRD): _privacyIrBacked is declared at
9594
+ // function scope above the AGENTIC_SECURITY_NO_INTEGRATION block, not
9595
+ // here — this closure runs and exits before that block's closing brace,
9596
+ // so a block-scoped declaration would be unreachable from the later
9597
+ // assessPrivacyFramework call and the final return.
9224
9598
  if (process.env.AGENTIC_SECURITY_NO_PRIVACY !== '1') {
9225
- _runAnnotator("annotatePrivacyTaint", () => {
9226
- // Build a minimal IR map from fc; the privacy taint module needs
9227
- // per-file content + a coarse decls/calls list. We construct it
9228
- // from existing finding sources rather than re-parsing every file.
9229
- const minimalIR = new Map();
9230
- for (const [fp, content] of Object.entries(fc || {})) {
9231
- if (typeof content !== 'string') continue;
9232
- minimalIR.set(fp, { _content: content, decls: [], calls: [] });
9233
- }
9234
- const r = annotatePrivacyTaint(minimalIR);
9235
- if (r && Array.isArray(r.findings)) finalFindings.push(...r.findings);
9599
+ await _runAnnotator("annotatePrivacyTaint", () => {
9600
+ // FR-401 (assurance-hardening PRD, A-06): decls/calls used to be
9601
+ // hardcoded empty for every file (the actual root cause, per
9602
+ // decisions.md D-0003, was a missing adapter reconciling
9603
+ // privacy-taint.js's flat per-file shape with the real Layer-1 IR's
9604
+ // per-function/CFG shape not a missing capability in the IR or
9605
+ // taint engine themselves; see privacy/ir-adapter.js).
9606
+ //
9607
+ // Reuse _sharedIR when deep mode already built it (free — no
9608
+ // second parse pass). When deep mode is OFF (the common default
9609
+ // path), do NOT force a fresh IR build here: buildProjectIR parses
9610
+ // every file and is exactly the cost deep mode is opt-in for
9611
+ // (NFR: "no more than 15% ... overhead ... excluding newly enabled
9612
+ // deep analysis"). In that case privacy analysis still runs, but
9613
+ // honestly marked as not IR-backed (irBacked:false below) rather
9614
+ // than silently claiming the same coverage as the deep-mode path —
9615
+ // this is the signal FR-405's "missing capability -> not_assessed,
9616
+ // never satisfied" contract needs to key off, not yet wired into
9617
+ // privacy-framework.js's own bucketing in this cycle (separate,
9618
+ // later FR-405 work).
9619
+ const irBacked = !!(_sharedIR && _sharedIR.perFile);
9620
+ const adaptedIR = irBacked
9621
+ ? adaptIRForPrivacyTaint(_sharedIR.perFile, fc, storedRegistry)
9622
+ : new Map(Object.entries(fc || {})
9623
+ .filter(([, content]) => typeof content === 'string')
9624
+ .map(([fp, content]) => [fp, { _content: content, decls: [], calls: [] }]));
9625
+ // FR-402: pass scanRoot so an operator's .agentic-security/
9626
+ // privacy-taxonomy.json (if any) is actually loaded on a real scan.
9627
+ const r = annotatePrivacyTaint(adaptedIR, { scanRoot });
9628
+ r.irBacked = irBacked;
9629
+ _privacyIrBacked = irBacked;
9630
+ _privacyTaxonomyVersion = r.taxonomyVersion || null;
9631
+ // FR-102 (assurance-hardening PRD): route through the registered-
9632
+ // producer collector rather than a bare push. r is already computed
9633
+ // above (this call also drives the DPIA artifact below), so the
9634
+ // thunk just hands back what's already there.
9635
+ collectProducerResult(finalFindings, _annotatorErrors, 'privacy-taint', () => (r && Array.isArray(r.findings)) ? r.findings : []);
9236
9636
  // Persist the DPIA scaffold for compliance review.
9237
9637
  if (r && r.piiFields) {
9238
9638
  try {
9239
- const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
9639
+ // FR-407: governance fields are loaded once and threaded into
9640
+ // both the DPIA (per-class subsection) and the RoPA artifact
9641
+ // (the full register) so an operator only maintains one config
9642
+ // file for both.
9643
+ const governanceConfig = loadPrivacyGovernanceConfig(scanRoot);
9644
+ const dpia = emitDpiaArtifact(r.piiFields, r.findings || [], { policyExemptions: r.policyExemptions || [], governanceConfig });
9240
9645
  _safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
9646
+ const ropa = emitRopaArtifact(r.piiFields, governanceConfig);
9647
+ _safeWriteState(_statePath(scanRoot, 'ropa.md'), ropa);
9648
+ // FR-406: code-derived data inventory + flow graph, built from
9649
+ // the SAME findings/exemptions already computed above.
9650
+ const inventory = buildDataInventory(r.piiFields, r.findings || [], r.policyExemptions || []);
9651
+ _safeWriteState(_statePath(scanRoot, 'data-inventory.json'), emitDataInventoryArtifact(inventory));
9652
+ _safeWriteState(_statePath(scanRoot, 'data-flow-graph.md'), emitDataFlowGraph(inventory));
9241
9653
  } catch (_) {}
9242
9654
  }
9243
9655
  });
@@ -9246,80 +9658,80 @@ function _deterministicFileTimings(timings) {
9246
9658
  // ATLAS, D3FEND, kill-chain stage, and CAPEC IDs so downstream SIEM /
9247
9659
  // SOAR systems can correlate with existing detection rules.
9248
9660
  if (process.env.AGENTIC_SECURITY_NO_ATTACK_TAX !== '1') {
9249
- _runAnnotator("annotateAttackTaxonomy", () => { annotateAttackTaxonomy(finalFindings); });
9661
+ await _runAnnotator("annotateAttackTaxonomy", () => { annotateAttackTaxonomy(finalFindings); });
9250
9662
  }
9251
9663
  // Triage memory — demote findings whose (family, dir) bucket was
9252
9664
  // previously marked wont-fix or false-positive in this project.
9253
9665
  if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY !== '1') {
9254
- _runAnnotator("suppressByPastDecisions", () => { suppressByPastDecisions(scanRoot, finalFindings); });
9666
+ await _runAnnotator("suppressByPastDecisions", () => { suppressByPastDecisions(scanRoot, finalFindings); });
9255
9667
  }
9256
9668
  // Intent-aware FP suppression — demote findings on files marked as
9257
9669
  // intentionally vulnerable (sandbox/CTF/tutorial/example/etc.).
9258
9670
  if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX !== '1') {
9259
- _runAnnotator("suppressByIntent", () => { suppressByIntent(scanRoot, finalFindings); });
9671
+ await _runAnnotator("suppressByIntent", () => { suppressByIntent(scanRoot, finalFindings); });
9260
9672
  }
9261
9673
  // Git history — stamp each finding with introducedBy / introducedIn /
9262
9674
  // originatingPrompt by running `git blame` on the finding's line.
9263
9675
  if (process.env.AGENTIC_SECURITY_NO_GIT_HISTORY !== '1') {
9264
- _runAnnotator("annotateGitHistory", () => { annotateGitHistory(scanRoot, finalFindings); });
9676
+ await _runAnnotator("annotateGitHistory", () => { annotateGitHistory(scanRoot, finalFindings); });
9265
9677
  }
9266
9678
  // Threat-model grounding — bump severity on crown-jewels, demote
9267
9679
  // out-of-scope, tag compliance regimes, stamp attacker profile.
9268
9680
  if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING !== '1') {
9269
- _runAnnotator("applyThreatModel", () => { applyThreatModel(scanRoot, finalFindings); });
9681
+ await _runAnnotator("applyThreatModel", () => { applyThreatModel(scanRoot, finalFindings); });
9270
9682
  }
9271
9683
  // Cross-repo pattern propagation — surface sibling-repo fixes and
9272
9684
  // triage decisions for the same family from this developer's history.
9273
9685
  if (process.env.AGENTIC_SECURITY_NO_CROSS_REPO !== '1') {
9274
- _runAnnotator("annotateCrossRepoSignals", () => { annotateCrossRepoSignals(scanRoot, finalFindings); });
9686
+ await _runAnnotator("annotateCrossRepoSignals", () => { annotateCrossRepoSignals(scanRoot, finalFindings); });
9275
9687
  }
9276
9688
  // Risk-in-dollars — combine EPSS + crown-jewel + reachability into an
9277
9689
  // expected-value-of-exploitation USD figure per finding.
9278
9690
  if (process.env.AGENTIC_SECURITY_NO_RISK_DOLLARS !== '1') {
9279
- _runAnnotator("annotateRiskDollars", () => { annotateRiskDollars(scanRoot, finalFindings); });
9691
+ await _runAnnotator("annotateRiskDollars", () => { annotateRiskDollars(scanRoot, finalFindings); });
9280
9692
  }
9281
9693
  // Time-to-fix — estimate engineering hours per finding.
9282
9694
  if (process.env.AGENTIC_SECURITY_NO_TIME_TO_FIX !== '1') {
9283
- _runAnnotator("annotateTimeToFix", () => { annotateTimeToFix(scanRoot, finalFindings); });
9695
+ await _runAnnotator("annotateTimeToFix", () => { annotateTimeToFix(scanRoot, finalFindings); });
9284
9696
  }
9285
9697
  }
9286
9698
  // v3 next-gen: crown-jewel mapping (FR-PROD-5) — score each file/finding by
9287
9699
  // business impact. Must run before persona prioritization (which uses it).
9288
- _runAnnotator("annotateCrownJewelScores", () => { annotateCrownJewelScores(finalFindings, fc); });
9700
+ await _runAnnotator("annotateCrownJewelScores", () => { annotateCrownJewelScores(finalFindings, fc); });
9289
9701
  // v3 next-gen: clone clusters (FR-SEM-8) + emit clone-outlier infos.
9290
- _runAnnotator("annotateCloneClusters", () => { annotateCloneClusters(finalFindings); });
9702
+ await _runAnnotator("annotateCloneClusters", () => { annotateCloneClusters(finalFindings); });
9291
9703
  try {
9292
9704
  const outliers = findCloneOutliers(finalFindings);
9293
9705
  if (outliers && outliers.length) finalFindings.push(...outliers);
9294
9706
  } catch(_) {}
9295
9707
  // v3 next-gen: AI-generated-code fingerprint (FR-LEARN-10). Property bag tag.
9296
- _runAnnotator("annotateAiProvenance", () => { annotateAiProvenance(finalFindings, fc); });
9708
+ await _runAnnotator("annotateAiProvenance", () => { annotateAiProvenance(finalFindings, fc); });
9297
9709
  // v3 next-gen: whole-program type narrowing (FR-SEM-10) — heuristic
9298
9710
  // confidence dampener on findings rooted in functions whose callers all
9299
9711
  // pass narrowly-typed values.
9300
- _runAnnotator("annotateTypeNarrowing", () => { annotateTypeNarrowing(finalFindings, fc); });
9712
+ await _runAnnotator("annotateTypeNarrowing", () => { annotateTypeNarrowing(finalFindings, fc); });
9301
9713
  // v3 next-gen: STRIDE classification (FR-LOGIC-10).
9302
- _runAnnotator("annotateStrideCategory", () => { annotateStrideCategory(finalFindings); });
9714
+ await _runAnnotator("annotateStrideCategory", () => { annotateStrideCategory(finalFindings); });
9303
9715
  // v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
9304
9716
  // crown-jewels + mitigation composite so it sees those signals.
9305
- _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
9717
+ await _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
9306
9718
  // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
9307
9719
  // SCA findings (package-name-keyed) — must run against supplyChain, not
9308
9720
  // finalFindings (SAST), which has no package-name field at all.
9309
- _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
9721
+ await _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
9310
9722
  // v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
9311
9723
  // mitigation composite — gated/unreachable findings get the bounty scaled
9312
9724
  // down rather than zeroed.
9313
- _runAnnotator("annotateBountyPrediction", () => { annotateBountyPrediction(finalFindings); });
9725
+ await _runAnnotator("annotateBountyPrediction", () => { annotateBountyPrediction(finalFindings); });
9314
9726
  // v3 next-gen: attack-playbook annotation (FR-ADV-4). Only for high+ findings.
9315
- _runAnnotator("annotateAttackPlaybooks", () => { annotateAttackPlaybooks(finalFindings); });
9727
+ await _runAnnotator("annotateAttackPlaybooks", () => { annotateAttackPlaybooks(finalFindings); });
9316
9728
  // Phase-1 next-gen P1.1 (FR-VER-2): attach a runnable PoC to each finding
9317
9729
  // when a CWE template covers it. Findings without coverage get f.poc=null.
9318
9730
  // Premortem #12: pass fileContents so PoC param-key inference can re-read
9319
9731
  // the actual handler line when detector snippets are misattributed.
9320
- _runAnnotator("annotatePocs", () => { annotatePocs(finalFindings, { routes: aR, fileContents: fc }); });
9732
+ await _runAnnotator("annotatePocs", () => { annotatePocs(finalFindings, { routes: aR, fileContents: fc }); });
9321
9733
  // FR-VER-3: regression-test generator (builds on the PoC artifact).
9322
- _runAnnotator("annotateRegressionTests", () => { annotateRegressionTests(finalFindings); });
9734
+ await _runAnnotator("annotateRegressionTests", () => { annotateRegressionTests(finalFindings); });
9323
9735
  // R2 — execution proof. Synthesizes a SANDBOX-RUNNABLE PoC (the HTTP PoCs
9324
9736
  // above need a live server, so they can never be executed by the prover)
9325
9737
  // and lets R1's sandbox decide the tier. Opt-in via AGENTIC_SECURITY_PROVE=1
@@ -9332,7 +9744,7 @@ function _deterministicFileTimings(timings) {
9332
9744
  // Never a finding and never a severity change: those bugs are fixed, and a
9333
9745
  // historical fix is not evidence of a present defect. Opt-in because it
9334
9746
  // shells out to git over up to 500 commits.
9335
- _runAnnotator('annotateHistoricalRisk', () => {
9747
+ await _runAnnotator('annotateHistoricalRisk', () => {
9336
9748
  if (process.env.AGENTIC_SECURITY_ARCHAEOLOGY !== '1' || !scanRoot) return;
9337
9749
  _vulnHistory = mineVulnHistory(scanRoot);
9338
9750
  annotateHistoricalRisk(finalFindings, _vulnHistory);
@@ -9346,65 +9758,38 @@ function _deterministicFileTimings(timings) {
9346
9758
  // verifier verdict — verified-exploit (live PoC ran), verified-by-llm,
9347
9759
  // verified-sanitizer-absence, unverified-by-design, or cannot-verify.
9348
9760
  // Fail-closed: any error → cannot-verify, never a silent drop.
9349
- _runAnnotator("annotateVerifierVerdicts", () => { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); });
9761
+ await _runAnnotator("annotateVerifierVerdicts", () => { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); });
9350
9762
  // Cross-language taint (Sentinel-parity FR-DET-3) — five boundary types:
9351
9763
  // HTTP/REST via OpenAPI, gRPC via .proto, GraphQL via SDL, SQL/ORM
9352
9764
  // round-trip, and IaC → application-code reachability (FR-DET-4).
9353
9765
  const _allXlangFiles = { ...fc, ...depFileContents };
9354
- try {
9355
- const xl = scanCrossLangOpenAPI(_allXlangFiles, finalFindings);
9356
- if (xl && xl.length) finalFindings.push(...xl);
9357
- } catch(_) {}
9358
- try {
9359
- const xl = scanCrossLangGrpc(_allXlangFiles, finalFindings);
9360
- if (xl && xl.length) finalFindings.push(...xl);
9361
- } catch(_) {}
9362
- try {
9363
- const xl = scanCrossLangGraphql(_allXlangFiles, finalFindings);
9364
- if (xl && xl.length) finalFindings.push(...xl);
9365
- } catch(_) {}
9366
- try {
9367
- const xl = scanCrossLangOrm(_allXlangFiles, finalFindings);
9368
- if (xl && xl.length) finalFindings.push(...xl);
9369
- } catch(_) {}
9766
+ // FR-101/FR-102 (assurance-hardening PRD): each of these 11 producers now
9767
+ // goes through the registered-producer collector (pipeline/producer-
9768
+ // collector.js) instead of a bare try/catch + push — a thrown exception
9769
+ // becomes a diagnostic in _annotatorErrors (previously silently swallowed
9770
+ // by `catch(_) {}` with no trace anywhere) and the collector stamps
9771
+ // producerId provenance on each finding.
9772
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-openapi', () => scanCrossLangOpenAPI(_allXlangFiles, finalFindings));
9773
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-grpc', () => scanCrossLangGrpc(_allXlangFiles, finalFindings));
9774
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-graphql', () => scanCrossLangGraphql(_allXlangFiles, finalFindings));
9775
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-orm', () => scanCrossLangOrm(_allXlangFiles, finalFindings));
9370
9776
  // Phase-1 next-gen P1.5 (FR-XSAT-4): cross-language taint via Kafka, SQS,
9371
9777
  // RabbitMQ, Redis streams, and Google Pub/Sub topics.
9372
- try {
9373
- const xl = scanCrossLangQueues(_allXlangFiles, finalFindings);
9374
- if (xl && xl.length) finalFindings.push(...xl);
9375
- } catch(_) {}
9376
- try {
9377
- const xl = scanIacReachability(_allXlangFiles, finalFindings);
9378
- if (xl && xl.length) finalFindings.push(...xl);
9379
- } catch(_) {}
9778
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-queues', () => scanCrossLangQueues(_allXlangFiles, finalFindings));
9779
+ collectProducerResult(finalFindings, _annotatorErrors, 'iac-reachability', () => scanIacReachability(_allXlangFiles, finalFindings));
9380
9780
  // Phase-2.5 next-gen: IAM policy reachability (FR-XSAT-7).
9381
- try {
9382
- const ia = scanIamPolicies(_allXlangFiles, finalFindings);
9383
- if (ia && ia.length) finalFindings.push(...ia);
9384
- } catch(_) {}
9781
+ collectProducerResult(finalFindings, _annotatorErrors, 'iam-policy', () => scanIamPolicies(_allXlangFiles, finalFindings));
9385
9782
  // Phase-2.5 next-gen: container runtime config audit (FR-XSAT-8).
9386
- try {
9387
- const cr = scanContainerRuntime(_allXlangFiles);
9388
- if (cr && cr.length) finalFindings.push(...cr);
9389
- } catch(_) {}
9783
+ collectProducerResult(finalFindings, _annotatorErrors, 'container-runtime', () => scanContainerRuntime(_allXlangFiles));
9390
9784
  // Phase-4 next-gen: business-logic analysis (FR-LOGIC-1, FR-LOGIC-2, FR-LOGIC-7).
9391
- try {
9392
- const bl = scanBusinessLogicV2(_allXlangFiles);
9393
- if (bl && bl.length) finalFindings.push(...bl);
9394
- } catch(_) {}
9785
+ collectProducerResult(finalFindings, _annotatorErrors, 'business-logic-v2', () => scanBusinessLogicV2(_allXlangFiles));
9395
9786
  // v3 next-gen: specification-mining drift detector (FR-LOGIC-8). Emits
9396
9787
  // findings for function-name-vs-body mismatches. Low confidence by default;
9397
9788
  // active-learning loop tunes per project.
9398
- try {
9399
- const sm = scanSpecificationDrift(_allXlangFiles);
9400
- if (sm && sm.length) finalFindings.push(...sm);
9401
- } catch(_) {}
9789
+ collectProducerResult(finalFindings, _annotatorErrors, 'specification-drift', () => scanSpecificationDrift(_allXlangFiles));
9402
9790
  // v3 next-gen: bounded concurrency-bug detector (FR-SEM-9). Heuristic only;
9403
9791
  // catches missed unlocks, fire-and-forget async, and 2-lock deadlock cycles.
9404
- try {
9405
- const cc = scanConcurrency(_allXlangFiles);
9406
- if (cc && cc.length) finalFindings.push(...cc);
9407
- } catch(_) {}
9792
+ collectProducerResult(finalFindings, _annotatorErrors, 'concurrency', () => scanConcurrency(_allXlangFiles));
9408
9793
  // FR-LOGIC-6: LLM-driven flow narration (template fallback when no LLM endpoint).
9409
9794
  try { await annotateNarration(finalFindings); }
9410
9795
  catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
@@ -9671,14 +10056,14 @@ function _deterministicFileTimings(timings) {
9671
10056
  classifyOrphans(aSrc,aSink,finalFindings,fc);
9672
10057
  // v3 next-gen: capture scan-level reports (counterfactual, threat model,
9673
10058
  // trust-boundary diagram, calibration-drift alarms). All best-effort.
9674
- let _v3 = {};
9675
- _runAnnotator("_v3.counterfactual", () => { _v3.counterfactual = runCounterfactual(finalFindings, fc); });
9676
- _runAnnotator("_v3.threatModel", () => { _v3.threatModel = buildThreatModel(finalFindings, fc); });
9677
- _runAnnotator("_v3.trustBoundaryDiagram", () => { _v3.trustBoundaryDiagram = buildTrustBoundaryDiagram(finalFindings, fc); });
9678
- _runAnnotator("_v3.calibrationDrift", () => { _v3.calibrationDrift = computeCalibrationDrift(scanRoot); });
10059
+ // (_v3 is hoisted above the `skipAnnotators` guard — FR-PROV-029.)
10060
+ await _runAnnotator("_v3.counterfactual", () => { _v3.counterfactual = runCounterfactual(finalFindings, fc); });
10061
+ await _runAnnotator("_v3.threatModel", () => { _v3.threatModel = buildThreatModel(finalFindings, fc); });
10062
+ await _runAnnotator("_v3.trustBoundaryDiagram", () => { _v3.trustBoundaryDiagram = buildTrustBoundaryDiagram(finalFindings, fc); });
10063
+ await _runAnnotator("_v3.calibrationDrift", () => { _v3.calibrationDrift = computeCalibrationDrift(scanRoot); });
9679
10064
  // v3 next-gen: why-fired provenance is captured LAST so it reflects the
9680
10065
  // final state of each finding after every other annotator has run.
9681
- _runAnnotator("annotateWhyFired", () => { annotateWhyFired(finalFindings, {}); });
10066
+ await _runAnnotator("annotateWhyFired", () => { annotateWhyFired(finalFindings, {}); });
9682
10067
  // SCA-SAST correlation: link SAST findings to SCA vulnerable packages
9683
10068
  try{for(const f of finalFindings){if(!f.chain||!f.chain.length)continue;const src=f.chain[0]?.label||'';for(const sc of supplyChain){if(sc.type!=='vulnerable_dep')continue;if(src.includes(sc.name)||f.vuln?.toLowerCase().includes(sc.name)){f.scaCorrelation={osvId:sc.osvId,package:sc.name,version:sc.version,confirmed:true};sc.sastConfirmed=true;break;}}}}catch(_){}
9684
10069
  // Multi-sink chain detection: group findings by source variable
@@ -9689,10 +10074,9 @@ function _deterministicFileTimings(timings) {
9689
10074
  // Each is opt-in via env var. They produce machine-readable artifacts
9690
10075
  // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
9691
10076
  // sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
9692
- let _privacyFramework = null;
9693
- let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
9694
- _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
9695
- _licenseGraph = null, _attributions = null, _taxonomySummary = null;
10077
+ // (_privacyFramework/_threatModel/_apiContractFindings/_sbomDiff/
10078
+ // _complianceReport/_exploitBundles/_pqcPlan/_licenseGraph/_attributions/
10079
+ // _taxonomySummary are hoisted above the `skipAnnotators` guard — FR-PROV-029.)
9696
10080
  if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
9697
10081
  // Threat model — STRIDE + entities + attack trees rooted in findings.
9698
10082
  if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL !== '1') {
@@ -9787,6 +10171,12 @@ function _deterministicFileTimings(timings) {
9787
10171
  // filesScanned feeds the vacuous-satisfaction guard: a clean signal
9788
10172
  // from a run that read no files is not evidence of compliance.
9789
10173
  filesScanned: files.length,
10174
+ // FR-405: feeds the SAME guard, specifically for controls whose
10175
+ // only mapped signal comes from privacy-taint — a non-IR-backed
10176
+ // run (deep mode off, the common default) still emits a "no
10177
+ // findings" pass, which is not real evidence for those controls
10178
+ // even though the scan otherwise examined real files.
10179
+ privacyIrBacked: _privacyIrBacked,
9790
10180
  });
9791
10181
  if (_privacyFramework) persistPrivacyFramework(scanRoot, _privacyFramework);
9792
10182
  if (_privacyFramework && process.env.AGENTIC_SECURITY_PRIVACY_FRAMEWORK === '1') {
@@ -9812,6 +10202,34 @@ function _deterministicFileTimings(timings) {
9812
10202
  }
9813
10203
  }
9814
10204
 
10205
+ // FR-103 (assurance-hardening PRD): canonical enrichment completion pass.
10206
+ // This is the LAST point in the function where a finding could still be
10207
+ // missing stableId/confidence/calibration/exploitability because it was
10208
+ // appended by a late producer (see pipeline/enrichment-completion.js's
10209
+ // header for why this is a gap-filling pass rather than relocating ~19
10210
+ // append sites earlier in this function). Must run after every push site
10211
+ // above and before the freeze immediately below.
10212
+ const _projectCtxForCompletion = typeof _projectCtx !== 'undefined' ? _projectCtx : {};
10213
+ completeEnrichment(finalFindings, { scanRoot, projectCtx: _projectCtxForCompletion });
10214
+
10215
+ // FR-104 (assurance-hardening PRD): freeze the finding collection.
10216
+ // Object.freeze on an array blocks push/pop/splice/length changes (this
10217
+ // module and every caller downstream run under ESM strict mode, so a
10218
+ // violation THROWS a TypeError rather than silently no-op'ing) — no
10219
+ // producer may append after this point. This is a SHALLOW freeze: the
10220
+ // remaining read/annotate calls below (countUnmodeledSinkCandidates,
10221
+ // buildEntrypointInventory, annotateRelevance, sweepRootCauses,
10222
+ // proofCoverage) either only read `finalFindings` or, for annotateRelevance,
10223
+ // legitimately mutate FIELDS on individual finding objects — verified by
10224
+ // reading each function's body (none push/splice/pop/shift/unshift onto
10225
+ // the array they're handed). Field mutation is annotation, not production,
10226
+ // and annotateRelevance running this late is an established, deliberate
10227
+ // part of this codebase's architecture (its own header: "runs after every
10228
+ // finding has been appended... so nothing escapes annotation"). What
10229
+ // freezing forecloses is exactly what FR-104 asks for: no MORE findings
10230
+ // can be added after this point.
10231
+ Object.freeze(finalFindings);
10232
+
9815
10233
  // Coverage-honesty report (#5 + #6): per-language analysis tier (IR-taint vs
9816
10234
  // pattern-only), dense/large/timeout skips, and unmodeled-sink candidates.
9817
10235
  let _analysisTier = null, _unmodeledSinks = null;
@@ -9826,19 +10244,314 @@ function _deterministicFileTimings(timings) {
9826
10244
  // seen when only N-of-those-candidates were actually analyzed.
9827
10245
  // checkpoint.total intentionally keeps files.length — that field means the
9828
10246
  // full candidate set for resume bookkeeping, a different, correct meaning.
9829
- const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
10247
+ _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
9830
10248
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
9831
10249
  // it. Anything that threw before this point leaves it in place to resume from.
9832
10250
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
10251
+ // Finding Provenance (M0/M1) — attaches `finding.findingProvenance`.
10252
+ //
10253
+ // Placed HERE, not immediately after the SCA/multi-sink correlation blocks
10254
+ // where the plan first put it, for the same reason `annotateRelevance` below
10255
+ // runs this late: several post-scan artifact emitters between those blocks
10256
+ // and this point still PUSH findings (`runApiContractScan`, `runSbomDiff`,
10257
+ // the cross-language chain passes). Annotating before them would have left
10258
+ // every finding they produce with no `findingProvenance` at all, which is
10259
+ // precisely the "absent field" state the coordinator's terminal-status
10260
+ // guarantee exists to make unreachable. Running after `Object.freeze` above
10261
+ // is safe and is in fact the point: the finding SET can no longer change, and
10262
+ // a shallow freeze still permits annotating FIELDS on the individual finding
10263
+ // objects. This annotator never appends or drops a finding.
10264
+ if (provenance !== false) {
10265
+ // Task 11 (PRD P0 scope): real origin resolution for scan.secrets and
10266
+ // blameable scan.logicVulns findings. Both channels were previously
10267
+ // stamped an unconditional not_available (see the backstop loop below) —
10268
+ // not because computeStableId can't handle them (its ruleId() fallback
10269
+ // chain already tolerates a missing f.ruleId), but because nobody
10270
+ // backfilled a real per-pattern ruleId and nobody called
10271
+ // annotateGitProvenance on these channels at all. scan.secrets findings
10272
+ // set neither ruleId nor family nor parser, and every secret type shares
10273
+ // the same fixed f.cwe ("CWE-798"), so without a per-pattern backfill
10274
+ // every secret in a scan would collide onto ONE stableId.
10275
+ //
10276
+ // scan.logicVulns is not one detector's output — three of its ~9
10277
+ // producers (license-policy:, deploy-platform:, stack-playbook:) use a
10278
+ // FIXED PLACEHOLDER `line` (0 or 1), not a real diffable source location
10279
+ // — they read scanRoot-level files (package.json, vercel.json, ...)
10280
+ // directly rather than from the scanned fileContents. Routing those
10281
+ // through git-blame-style resolution would fabricate a plausible-looking
10282
+ // but meaningless commit attribution (e.g. "package.json line 1" blamed
10283
+ // on whatever commit last touched that line, unrelated to the actual
10284
+ // license/platform/stack finding). They are excluded by id prefix here
10285
+ // and stay on the honest, PERMANENT not_available path in the backstop
10286
+ // loop below — never routed through resolveOrigin. Declared here, OUTSIDE
10287
+ // the _runAnnotator callback below, because the backstop loop (also
10288
+ // outside that callback — see its own comment on why) needs
10289
+ // `blameableLogic`/`syntheticLogic` too.
10290
+ //
10291
+ // `aSecrets`/`aLogic`'s ruleId backfill for the producers that exist by
10292
+ // this point in the function already happened much earlier (right before
10293
+ // the `skipAnnotators` guard opens — see that comment for why it CANNOT
10294
+ // live here alone). `_slugify` is declared there and is in scope here too.
10295
+ // What's re-applied below is only the SAME idempotent backfill
10296
+ // (`if (!f.ruleId)`), now over the full, final `blameableLogic` — which by
10297
+ // this point additionally includes logic-claims.js's late-pushed ingested
10298
+ // claims, the one blameable producer the early pass could not see.
10299
+ const SYNTHETIC_LOGIC_PREFIXES = ['license-policy:', 'deploy-platform:', 'stack-playbook:'];
10300
+ const isSyntheticLogicFinding = (f) => typeof f?.id === 'string'
10301
+ && SYNTHETIC_LOGIC_PREFIXES.some((p) => f.id.startsWith(p));
10302
+ const blameableLogic = aLogic.filter((f) => !isSyntheticLogicFinding(f));
10303
+ const syntheticLogic = aLogic.filter(isSyntheticLogicFinding);
10304
+ for (const f of blameableLogic) {
10305
+ if (!f.ruleId) f.ruleId = `logic:${_slugify(f.vuln)}`;
10306
+ }
10307
+ annotateStableIds(aSecrets);
10308
+ annotateStableIds(blameableLogic);
10309
+ await _runAnnotator("annotateGitProvenance", async () => {
10310
+ // ONE deadline for the whole scan's provenance work, computed here and
10311
+ // threaded into all five annotateGitProvenance calls below. Computed per call
10312
+ // inside the coordinator, the effective scan-level budget was 2× the
10313
+ // configured --provenance-timeout: the SAST pass got a fresh window and
10314
+ // then the SCA pass got another one, so an operator asking for a 30s cap
10315
+ // could wait 60s. The spec describes a single global deadline; this is
10316
+ // where "global" has to be established, because this is the only scope
10317
+ // that sees both passes.
10318
+ const provenanceTimeoutMs = process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS
10319
+ ? parseInt(process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS, 10) : undefined;
10320
+ // Deterministic mode promises byte-identical SARIF run-to-run
10321
+ // (posture/deterministic.js), and `observedAt` above is already frozen to
10322
+ // honour that. But `findingProvenance.status` (complete/partial/
10323
+ // budget_exhausted/...) is driven by THIS deadline, which was computed
10324
+ // from a real `Date.now()` even under --deterministic — so two runs of
10325
+ // the identical scan could cross a 60s budget at different points under
10326
+ // machine contention (git subprocess calls slowed by CPU/IO pressure) and
10327
+ // land different findings on `budget_exhausted` vs a resolved status,
10328
+ // changing the emitted SARIF between runs. An explicit
10329
+ // --provenance-timeout / AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS still wins
10330
+ // (an operator asking for a tight budget gets it regardless of mode);
10331
+ // absent that, deterministic mode gets a much larger fixed ceiling so
10332
+ // resolution has room to finish under realistic load instead of a bound
10333
+ // that's routinely crossed — see test/proof-corpus-lib.test.js's
10334
+ // "produces byte-identical SARIF across two runs".
10335
+ const DETERMINISTIC_PROVENANCE_TIMEOUT_MS = 300000; // 5 minutes
10336
+ const provenanceDeadlineAt = Date.now()
10337
+ + (Number.isFinite(provenanceTimeoutMs) && provenanceTimeoutMs > 0
10338
+ ? provenanceTimeoutMs
10339
+ : (isDeterministic() ? DETERMINISTIC_PROVENANCE_TIMEOUT_MS : PROVENANCE_DEFAULT_TIMEOUT_MS));
10340
+ const provenanceCtx = {
10341
+ scanRoot,
10342
+ deadlineAt: provenanceDeadlineAt,
10343
+ // CLI flags that set these land in Task 17; reading the env directly here
10344
+ // mirrors AGENTIC_SECURITY_NO_GIT_HISTORY's existing pattern in this file.
10345
+ disabled: process.env.AGENTIC_SECURITY_NO_PROVENANCE === '1',
10346
+ scanId: process.env.AGENTIC_SECURITY_SCAN_ID || null,
10347
+ // Frozen under --deterministic so SARIF (which now carries
10348
+ // findingProvenance.firstObserved.observedAt — see report/index.js's
10349
+ // toSARIF) stays byte-identical run-to-run, matching the exact
10350
+ // convention posture/deterministic.js's makeDeterministic() already
10351
+ // uses for meta.startedAt. This value predates that guarantee; it was
10352
+ // invisible before findingProvenance reached any output format.
10353
+ observedAt: isDeterministic() ? '1970-01-01T00:00:00.000Z' : new Date().toISOString(),
10354
+ // FR-PROV-028 / "Evidence integrity": the cache key (coordinator.js's
10355
+ // `makeCacheKey`) and `computeDigest`'s `rulesetVersion` binding both
10356
+ // read this field, so it has to be the REAL effective ruleset version,
10357
+ // not an env var operators essentially never set. `_effectiveRulesetVersion`
10358
+ // (posture/ruleset-version.js's `effectiveVersion`) already resolves
10359
+ // env override > pinned file > CURRENT_RULESET_VERSION (== the running
10360
+ // scanner's own package version) — the same helper the checkpoint
10361
+ // identity above (`_ckptIdentity.rulesetVersion`) already calls for an
10362
+ // unrelated purpose. Computed ONCE here and shared via `provenanceCtx`,
10363
+ // same precedent as `deadlineAt` and `providerEnrichments` below: it's
10364
+ // cheap (an env read plus one small JSON file stat/read), but five
10365
+ // recomputations across the five annotateGitProvenance calls buys
10366
+ // nothing and risks a mid-scan pinned-file edit producing five
10367
+ // different answers in one run.
10368
+ rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version || null,
10369
+ since: process.env.AGENTIC_SECURITY_PROVENANCE_SINCE || null,
10370
+ timeoutMs: provenanceTimeoutMs,
10371
+ mode: process.env.AGENTIC_SECURITY_PROVENANCE_MODE || 'standard',
10372
+ // Fix-round item 2: ONE shared provider-enrichment counter for the
10373
+ // whole scan, same precedent as the single `deadlineAt` above. Object
10374
+ // identity (not the primitive value) is what makes the cap survive the
10375
+ // `{ ...provenanceCtx, findingType: ... }` spreads used by four of the
10376
+ // five annotateGitProvenance calls below — see coordinator.js's
10377
+ // `providerEnrichments` comment for why a bare number would not work.
10378
+ providerEnrichments: { remaining: MAX_PROVIDER_ENRICHMENTS_PER_SCAN },
10379
+ };
10380
+ await annotateGitProvenance(finalFindings, provenanceCtx);
10381
+ // Direct dependencies only: a transitive dep's vulnerable version was never
10382
+ // declared in this repository's manifests, so there is no commit here that
10383
+ // introduced it and `resolveDirectSCAOrigin` would have nothing to walk.
10384
+ //
10385
+ // Keyed on `isDirect`, the same field the transitive-dedup block above
10386
+ // uses. The plan's `!s.isTransitive` was a silent no-op: `isTransitive`
10387
+ // exists on dependency COMPONENTS but was never carried onto the
10388
+ // vulnerable_dep entries, so the negation was true for every entry and the
10389
+ // filter excluded nothing. `isDirect` is now propagated at materialization.
10390
+ const directDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && s.isDirect);
10391
+ // `resolveDirectSCAOrigin`/`scaStableId` key on `filePath`; the vulnerable_dep
10392
+ // entries built above carry the manifest path as `file` (report/index.js
10393
+ // already reads `sc.filePath || sc.file` for the same reason). Backfill the
10394
+ // alias rather than teaching the SCA resolver a second field name — without
10395
+ // it every direct dependency resolves to `not_available: no-manifest-path`.
10396
+ for (const s of directDeps) { if (!s.filePath && s.file) s.filePath = s.file; }
10397
+ await annotateGitProvenance(directDeps, { ...provenanceCtx, findingType: 'sca' });
10398
+ // M3 §3.2: transitive dependencies now get real origin resolution too,
10399
+ // narrowing what was previously an unconditional not_available backstop
10400
+ // to genuinely unresolvable cases (non-npm lockfiles, no candidate
10401
+ // history) — see transitive-sca.js's own scope note.
10402
+ const transitiveDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && !s.isDirect);
10403
+ for (const s of transitiveDeps) { if (!s.filePath && s.file) s.filePath = s.file; }
10404
+ await annotateGitProvenance(transitiveDeps, { ...provenanceCtx, findingType: 'sca-transitive' });
10405
+ // Task 11 (PRD P0 scope): `aSecrets`/`blameableLogic` already have real
10406
+ // stableIds backfilled above (outside this callback — see that comment).
10407
+ // findingType 'secret'/'logic' is not consumed by any branch in
10408
+ // coordinator.js's resolveOne (only 'sca'/'sca-transitive' select a
10409
+ // different resolution strategy) — both fall through to the plain SAST
10410
+ // path (file+line blame short-circuit, then origin-resolver.js). Passed
10411
+ // anyway for clarity and future debugging; harmless today.
10412
+ await annotateGitProvenance(aSecrets, { ...provenanceCtx, findingType: 'secret' });
10413
+ await annotateGitProvenance(blameableLogic, { ...provenanceCtx, findingType: 'logic' });
10414
+ // FR-PROV-013 — introduce/remediate/reintroduce events. Best-effort by
10415
+ // design: the lifecycle store is a convenience ledger, and a failed write
10416
+ // (read-only tree, lock contention) must never fail a scan, matching how
10417
+ // every other provenance component degrades.
10418
+ //
10419
+ // Gated on `disabled` as well as on the `provenance` parameter, because
10420
+ // those are two different opt-outs and only one of them was being honoured.
10421
+ // updateLifecycle WRITES to disk; an operator who set
10422
+ // AGENTIC_SECURITY_NO_PROVENANCE=1 has said the feature does nothing, and a
10423
+ // disabled feature that still litters `.agentic-security/provenance/` is
10424
+ // not disabled. (annotateGitProvenance handles `disabled` internally by
10425
+ // stamping not_available, which is why it is still called above — every
10426
+ // finding must keep a terminal status even with the feature off.)
10427
+ //
10428
+ // Gated on `scanRoot` too. Every path into updateLifecycle resolves the
10429
+ // store through statePath(scanRoot, …), and statePath falls back to the
10430
+ // PROCESS CWD when scanRoot is null — so a runFullScan called with no
10431
+ // scanRoot (the in-process test/bench harnesses, predicate replay before
10432
+ // its provenance:false brake, any embedder) wrote a lifecycle ledger into
10433
+ // whatever directory the process happened to be in, keyed on that
10434
+ // unrelated run's findings. That is how this repo's own
10435
+ // scanner/.agentic-security/provenance/lifecycle.json accumulated 374
10436
+ // stableIds and 3000+ spurious remediated/reintroduced events. No
10437
+ // scanRoot means no project to keep a ledger for.
10438
+ //
10439
+ // `completeScan` is the other half, and it is a correctness gate rather
10440
+ // than a hygiene one — see runFullScan's parameter comment and
10441
+ // lifecycle.js's applyScan.
10442
+ //
10443
+ // TRUTHY IS NOT ENOUGH — it has to be a real directory. `resolveProjectRoot`
10444
+ // honours a caller-supplied scanRoot only when it exists on disk AND is a
10445
+ // directory; for anything else (a typo'd path, a `/tmp/...` a test never
10446
+ // created, a file rather than a directory) it silently falls back to walking
10447
+ // UP FROM THE PROCESS CWD for a project marker. So `agentic-security scan
10448
+ // ./typo` run from inside a project writes THAT project's lifecycle ledger
10449
+ // from a scan that never looked at it — and since such a scan finds nothing
10450
+ // while still claiming `completeScan`, the remediation pass closes every
10451
+ // open finding the real project had. Same corruption as the null-scanRoot
10452
+ // case above, reached through a different door: found by tracing the writes
10453
+ // that kept reappearing in this repo's own scanner/.agentic-security AFTER
10454
+ // the null guard was added. All three remaining writers were
10455
+ // truthy-but-nonexistent scanRoots.
10456
+ //
10457
+ // Narrow on purpose. Every other state write in such a scan lands in the
10458
+ // same wrong place, which is a wider `state-dir.js` question; the lifecycle
10459
+ // ledger is singled out here because it is the only one that turns the
10460
+ // mistake into a destructive claim about findings it never saw.
10461
+ let scanRootIsRealDir = false;
10462
+ try { scanRootIsRealDir = !!scanRoot && fs.statSync(scanRoot).isDirectory(); } catch { scanRootIsRealDir = false; }
10463
+ if (scanRootIsRealDir && !provenanceCtx.disabled) {
10464
+ try {
10465
+ await updateLifecycle(scanRoot, finalFindings, {
10466
+ scanId: provenanceCtx.scanId, observedAt: provenanceCtx.observedAt,
10467
+ completeScan,
10468
+ });
10469
+ } catch (_) { /* best-effort */ }
10470
+ }
10471
+ });
10472
+ // Structural backstop for the terminal-status guarantee, for BOTH channels.
10473
+ // Deliberately OUTSIDE the _runAnnotator callback above: _runAnnotator
10474
+ // swallows whatever the callback throws, so anything that depends on the
10475
+ // callback reaching its last line is a convention, not a guarantee. If
10476
+ // annotateGitProvenance threw before its per-finding loop began
10477
+ // (getRepoState blowing up on a corrupt repo, say), everything below the
10478
+ // throw is skipped and every finding and supplyChain entry silently carries
10479
+ // no findingProvenance at all — exactly the absent-field state the status
10480
+ // enum exists to make unreachable. Enforced here so it holds structurally.
10481
+ for (const f of finalFindings) {
10482
+ if (f && typeof f === 'object' && !f.findingProvenance) {
10483
+ f.findingProvenance = emptyProvenance(PROVENANCE_STATUS.ERROR, {
10484
+ limitations: ['provenance annotator did not reach this finding'],
10485
+ });
10486
+ }
10487
+ }
10488
+ // The supply-chain half. report/index.js normalizes EVERY supplyChain entry
10489
+ // into an SCA finding — not just the direct vulnerable_dep ones the resolver
10490
+ // can speak to — and pipeline/finding-schema.js requires findingProvenance on
10491
+ // every channel. Three distinct populations reach this loop and they are not
10492
+ // the same statement, so they do not share a limitation string:
10493
+ //
10494
+ // - transitive vulnerable_deps: resolved by resolveTransitiveSCAOrigin
10495
+ // above (M3 §3.2) — this branch is now reached only if that annotation
10496
+ // pass itself failed to stamp the entry.
10497
+ // - unpinned_dep / no_lockfile and friends: these describe the ABSENCE of a
10498
+ // declaration, so "which commit introduced this version" is not a question
10499
+ // that has an answer to defer.
10500
+ // - anything the annotator failed to reach, as above.
10501
+ //
10502
+ // The first two are honest `not_available` — that is exactly what the status
10503
+ // is for. Only a genuine annotator failure is an `error`, which is why this
10504
+ // loop distinguishes them rather than stamping one status for all three.
10505
+ for (const sc of (supplyChain || [])) {
10506
+ if (!sc || typeof sc !== 'object' || sc.findingProvenance) continue;
10507
+ sc.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10508
+ limitations: [sc.type === 'vulnerable_dep'
10509
+ ? 'transitive dependency origin resolution failed for this entry (annotator error)'
10510
+ : `origin resolution does not apply to a ${sc.type || 'non-vulnerability'} supply-chain entry`],
10511
+ });
10512
+ }
10513
+ // The OTHER two channels report/index.js normalizes into findings —
10514
+ // `scan.secrets` and `scan.logicVulns`. The same argument that produced the
10515
+ // supplyChain loop above applies verbatim: pipeline/finding-schema.js makes
10516
+ // `findingProvenance` REQUIRED on every channel, and normalizeFindings emits
10517
+ // a finding for each of these, so leaving them unstamped ships a
10518
+ // schema-incomplete finding whose absent field is indistinguishable from
10519
+ // "escaped annotation" — the exact condition the status enum exists to
10520
+ // remove.
10521
+ //
10522
+ // Task 11 (PRD P0 scope): `aSecrets` and `blameableLogic` now go through
10523
+ // REAL resolution above (real stableIds backfilled, real
10524
+ // annotateGitProvenance calls made), so this loop no longer covers them
10525
+ // wholesale — it is now a defensive catch-all for any entry the real call
10526
+ // somehow didn't reach (same precedent as the supplyChain loop above), plus
10527
+ // `syntheticLogic`, which can NEVER get real resolution by design (see the
10528
+ // classification comment above `SYNTHETIC_LOGIC_PREFIXES`) and stays here
10529
+ // permanently and honestly, not as a deferral.
10530
+ for (const x of (syntheticLogic || [])) {
10531
+ if (!x || typeof x !== 'object' || x.findingProvenance) continue;
10532
+ x.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10533
+ limitations: ['this finding describes dependency/config/policy state, not a single source line a commit introduced -- origin resolution does not apply'],
10534
+ });
10535
+ }
10536
+ for (const bucket of [aSecrets, blameableLogic]) {
10537
+ for (const x of (bucket || [])) {
10538
+ if (!x || typeof x !== 'object' || x.findingProvenance) continue;
10539
+ x.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10540
+ limitations: ['origin resolution annotator did not reach this finding'],
10541
+ });
10542
+ }
10543
+ }
10544
+ }
9833
10545
  // Addition #2 — attack-surface completeness inventory (entry points → dispositions).
9834
- let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
10546
+ // (_entrypointInventory is hoisted above the `skipAnnotators` guard FR-PROV-029.)
10547
+ try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
9835
10548
  // R9 + R6 — relevance scoping. Runs HERE, after every finding has been
9836
10549
  // appended (multi-sink chains, cross-language chains) and after the
9837
10550
  // entry-point inventory exists, so no finding escapes annotation and the
9838
10551
  // attack surface it is scored against is the complete one. Recall-
9839
10552
  // preserving: never removes a finding, never touches severity, and only
9840
10553
  // asserts `unreachable` on positive evidence (see posture/relevance.js).
9841
- _runAnnotator("annotateRelevance", () => {
10554
+ await _runAnnotator("annotateRelevance", () => {
9842
10555
  annotateRelevance(finalFindings, {
9843
10556
  fileContents: fc,
9844
10557
  entrypointInventory: _entrypointInventory,
@@ -9848,14 +10561,46 @@ function _deterministicFileTimings(timings) {
9848
10561
  });
9849
10562
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
9850
10563
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
9851
- let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
10564
+ // (_rootCauseSweep/_proofCoverage/_coverageLedger/_scanHealth are hoisted
10565
+ // above the `skipAnnotators` guard — FR-PROV-029.)
10566
+ try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
9852
10567
  // PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
9853
10568
  // computed over the provable subset makes a narrow subset look like strength;
9854
10569
  // the three-bucket split (provable / declined-on-purpose / not-yet-classified)
9855
10570
  // is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
9856
- let _proofCoverage = null;
9857
10571
  try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
9858
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
10572
+ // FR-203: per-file/per-analyzer coverage ledger, computed from exactly
10573
+ // the signals FR-201 (_detectorErrors) and FR-202 (the _timeout:true
10574
+ // marker finding) already produce -- files actually scanned come from
10575
+ // fc's own keys (skipped-for-size/density files were never added to it).
10576
+ const _timedOutFiles = finalFindings.filter(f => f && f._timeout === true).map(f => f.file);
10577
+ _coverageLedger = computeCoverageLedger({ files: Object.keys(fc), detectorErrors: _detectorErrors, timedOutFiles: _timedOutFiles });
10578
+ // FR-206 (assurance-hardening PRD, Milestone 0): additive scan-health
10579
+ // summary, computed from signals the engine already collects.
10580
+ // `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
10581
+ // until FR-203's coverage ledger existed to compute it for real.
10582
+ _scanHealth = computeScanHealth({
10583
+ scanMeta: _scanMeta,
10584
+ annotatorErrors: _annotatorErrors,
10585
+ engineErrors: { cppDataflowParseErrors: _cppDataflowParseErrors.value },
10586
+ deepStatus: _deepStatus,
10587
+ analyzerCoverage: summarizeCoverageForScanHealth(_coverageLedger),
10588
+ });
10589
+ // FR-207: stale vulnerability feeds, calibration data, and compliance
10590
+ // evidence are real assurance gaps, not just findings the feed omits --
10591
+ // surfaced the same way every other scan-health condition is, so
10592
+ // --assurance strict (FR-204) can fail on them. Custom-rule-pack
10593
+ // freshness is the one leg NOT computed here: that mechanism only runs
10594
+ // in bin/agentic-security.js, after this scan object already exists (see
10595
+ // applyFreshness's own header comment in pipeline/scan-health.js).
10596
+ _scanHealth = applyFreshness(_scanHealth, {
10597
+ kev: kevCatalogMeta(),
10598
+ epss: epssLiveMeta(),
10599
+ calibration: calibrationFreshness(),
10600
+ compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
10601
+ });
10602
+ } // end if (!skipAnnotators) — FR-PROV-029
10603
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger};}
9859
10604
 
9860
10605
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
9861
10606
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -10322,6 +11067,7 @@ export {
10322
11067
  classifyOrphans, classifyField, classifyEndpoint, shouldScan,
10323
11068
  _isFalsePositiveCredential, _detectSafeSinkShape,
10324
11069
  _loadCustomRules, _isCustomSuppressed, _isPathIgnored,
11070
+ _snapshotSuppressionLog, _restoreSuppressionLog,
10325
11071
  scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
10326
11072
  payloadsForFinding, buildProofObligation,
10327
11073
  DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,