@clear-capabilities/agentic-security-scanner 0.142.0 → 0.144.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 (95) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/bin/agentic-security.js +530 -54
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +83 -18
  93. package/src/report/oscal.js +635 -0
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -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';
@@ -235,7 +282,7 @@ import { annotateAttackPlaybooks } from './posture/attack-playbooks.js';
235
282
  // R8: opt-in scan checkpointing/resume for the per-file loop.
236
283
  import {
237
284
  openCheckpoint, recordFileDone, completedFiles, resumeFindings, closeCheckpoint,
238
- computeRunKey, bundleShaForRunKey,
285
+ computeGlobalKey, globalKeyMeta, invalidatedFiles, bundleShaForRunKey,
239
286
  } from './posture/scan-checkpoint.js';
240
287
  import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
241
288
  import { effectiveVersion as _effectiveRulesetVersion } from './posture/ruleset-version.js';
@@ -2311,7 +2358,8 @@ function _brokenMarkerSecurityPredicate(matchText, ctx) {
2311
2358
  // SAST findings here are expected by design — suppress to avoid noise.
2312
2359
  const _INTENTIONAL_VULN_PATH_RE = /(?:^|\/)(?:codefixes|challenge[_\-]?(?:solution|code|fix|answer)|intentional[_\-]?vuln|ctf[_\-]?solution|vulnerable[_\-]?(?:example|sample|code))(?:\/|$)/i;
2313
2360
 
2314
- function scanStructuralVulns(fp, raw) {
2361
+ function scanStructuralVulns(fp, raw, _suppOut) {
2362
+ const _supp = _suppOut || _suppressionLog;
2315
2363
  if (_INTENTIONAL_VULN_PATH_RE.test(fp.replace(/\\/g, '/'))) return [];
2316
2364
  // Structural patterns vary: some describe code shapes (eval(), child_process.)
2317
2365
  // and shouldn't match in strings; others ALSO scan string content (e.g.
@@ -2324,7 +2372,7 @@ function scanStructuralVulns(fp, raw) {
2324
2372
  const findings = [];
2325
2373
  const ctx = inferFileContext(fp, raw);
2326
2374
  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; }
2375
+ if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
2328
2376
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
2329
2377
  const re = new RegExp(pat.regex.source, pat.regex.flags);
2330
2378
  // Default: match against the string-stripped view so rule-library shapes
@@ -2341,7 +2389,7 @@ function scanStructuralVulns(fp, raw) {
2341
2389
  if (typeof pat.predicate === 'function') {
2342
2390
  const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise });
2343
2391
  if (verdict && !verdict.fire) {
2344
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
2392
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
2345
2393
  continue;
2346
2394
  }
2347
2395
  }
@@ -2349,7 +2397,7 @@ function scanStructuralVulns(fp, raw) {
2349
2397
  // Used to scope rules like Django DEBUG=True to files that actually
2350
2398
  // import / configure Django (avoid mis-firing on Flask's app.debug).
2351
2399
  if (pat.contextRe && !pat.contextRe.test(raw)) {
2352
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
2400
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
2353
2401
  continue;
2354
2402
  }
2355
2403
  // FP-4: severity classifier — return null to suppress, otherwise overrides pat.severity.
@@ -2357,7 +2405,7 @@ function scanStructuralVulns(fp, raw) {
2357
2405
  if (typeof pat.severityFn === 'function') {
2358
2406
  const s = pat.severityFn(m[0], { file: fp, line, snippet, lines });
2359
2407
  if (s === null) {
2360
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'severity-fn:non-security-context'});
2408
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'severity-fn:non-security-context'});
2361
2409
  continue;
2362
2410
  }
2363
2411
  effectiveSeverity = s;
@@ -2366,11 +2414,11 @@ function scanStructuralVulns(fp, raw) {
2366
2414
  // hardened code — an SSRF host allow/deny check, or a path
2367
2415
  // basename/containment guard near the sink — is a false positive.
2368
2416
  if (/SSRF/.test(pat.vuln) && _hasSsrfHostGuard({ lines, line })) {
2369
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'ssrf-host-guard'});
2417
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'ssrf-host-guard'});
2370
2418
  continue;
2371
2419
  }
2372
2420
  if (/Path Traversal/.test(pat.vuln) && _hasPathGuard({ lines, line })) {
2373
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'path-contained'});
2421
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'path-contained'});
2374
2422
  continue;
2375
2423
  }
2376
2424
  const id = `struct:${fp}:${line}:${pat.vuln.replace(/\s/g, '_')}`;
@@ -3002,7 +3050,8 @@ function _logicPredicateFor(vuln){
3002
3050
  return null;
3003
3051
  }
3004
3052
 
3005
- function scanLogicVulns(fp,raw){
3053
+ function scanLogicVulns(fp,raw,_suppOut){
3054
+ const _supp = _suppOut || _suppressionLog;
3006
3055
  // Logic rules generally inspect the surrounding handler block including
3007
3056
  // string-literal route paths and key names, so the comment-stripped (but
3008
3057
  // string-preserving) view is the right default. Rules that explicitly only
@@ -3012,7 +3061,7 @@ function scanLogicVulns(fp,raw){
3012
3061
  const lines=raw.split("\n");const results=[];
3013
3062
  const ctx = inferFileContext(fp, raw);
3014
3063
  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; }
3064
+ if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
3016
3065
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
3017
3066
  const re=new RegExp(pat.regex.source,pat.regex.flags);
3018
3067
  const predicate = _logicPredicateFor(pat.vuln);
@@ -3025,7 +3074,7 @@ function scanLogicVulns(fp,raw){
3025
3074
  // FP-2: credential FP filter
3026
3075
  if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
3027
3076
  const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
3028
- if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
3077
+ if(fpCheck.skip){_supp.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
3029
3078
  // Stage 4 correctness audit (coverage breadth, secrets): same
3030
3079
  // unredacted-snippet leak found in engine.js's scanEntropySecrets/
3031
3080
  // scanCredentials and sast/secret-concat.js — this is a THIRD,
@@ -3044,7 +3093,7 @@ function scanLogicVulns(fp,raw){
3044
3093
  if (predicate) {
3045
3094
  const verdict = predicate(m[0], {file:fp, line, snippet, lines});
3046
3095
  if (verdict && !verdict.fire) {
3047
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'logic-gate:'+verdict.reason});
3096
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'logic-gate:'+verdict.reason});
3048
3097
  continue;
3049
3098
  }
3050
3099
  }
@@ -5834,14 +5883,15 @@ const EXTRA_STRUCTURAL_PATTERNS=[
5834
5883
  fix:"Delete debug routes from production builds. NODE_ENV is unreliable as a security boundary."},
5835
5884
  ];
5836
5885
 
5837
- function scanExtraStructural(fp,raw){
5886
+ function scanExtraStructural(fp,raw,_suppOut){
5887
+ const _supp = _suppOut || _suppressionLog;
5838
5888
  const cleaned=stripNoiseAndStrings(raw);
5839
5889
  const cleanedNoise=stripNoise(raw,fp);
5840
5890
  const lines=raw.split('\n');
5841
5891
  const findings=[];
5842
5892
  const ctx = inferFileContext(fp, raw);
5843
5893
  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; }
5894
+ if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
5845
5895
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
5846
5896
  const re=new RegExp(pat.regex.source,pat.regex.flags);
5847
5897
  const haystack = pat.readsStringContent ? cleanedNoise : cleaned;
@@ -5851,14 +5901,14 @@ function scanExtraStructural(fp,raw){
5851
5901
  const snippet=lines[line-1]?.trim()||'';
5852
5902
  // contextRe: require a context match across the whole file.
5853
5903
  if (pat.contextRe && !pat.contextRe.test(raw)) {
5854
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
5904
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'context-mismatch'});
5855
5905
  continue;
5856
5906
  }
5857
5907
  // Per-pattern predicate gate (mirrors scanStructuralVulns).
5858
5908
  if (typeof pat.predicate === 'function') {
5859
5909
  const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise });
5860
5910
  if (verdict && !verdict.fire) {
5861
- _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
5911
+ _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
5862
5912
  continue;
5863
5913
  }
5864
5914
  }
@@ -5913,7 +5963,8 @@ function _isLikelyNonSecret(v, ctxLine, surroundingLines){
5913
5963
  return null;
5914
5964
  }
5915
5965
 
5916
- function scanEntropySecrets(fp,raw){
5966
+ function scanEntropySecrets(fp,raw,_suppOut){
5967
+ const _supp = _suppOut || _suppressionLog;
5917
5968
  if(raw.length>400000)return[]; // skip huge blobs
5918
5969
  const out=[];
5919
5970
  const lines=raw.split("\n");
@@ -5942,7 +5993,7 @@ function scanEntropySecrets(fp,raw){
5942
5993
  // Same redaction concern as the main finding below applies to the
5943
5994
  // suppression log — it's exposed via --include-suppressed, and a
5944
5995
  // 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});
5996
+ _supp.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
5946
5997
  continue;
5947
5998
  }
5948
5999
  // Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
@@ -7055,6 +7106,15 @@ function _osvCacheSet(key,val){try{sessionStorage.setItem('osv_'+key,JSON.string
7055
7106
  // Batched: api.first.org accepts up to ~100 CVEs per request via ?cve=A,B,C…
7056
7107
  // One HTTP round trip per 100 CVEs instead of one per CVE. Cache lookups
7057
7108
  // remain per-CVE so a partial cache-hit still benefits.
7109
+ //
7110
+ // FR-207: EPSS scores decay in relevance (same note as posture/epss.js's
7111
+ // separate implementation) -- track how old the CVE-keyed entries actually
7112
+ // USED by this scan are, mirroring _setKevMeta immediately below, so a
7113
+ // stale feed is visible on scan.scanHealth rather than silently applied.
7114
+ const _EPSS_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
7115
+ let _epssLiveMeta = { source: 'not-loaded', ageDays: null, stale: null, cvesChecked: 0 };
7116
+ export function epssLiveMeta() { return { ..._epssLiveMeta }; }
7117
+
7058
7118
  const _EPSS_BATCH = 100;
7059
7119
  async function _fetchEPSSBatch(cveIds){
7060
7120
  if (!cveIds || !cveIds.length) return new Map();
@@ -7069,7 +7129,7 @@ async function _fetchEPSSBatch(cveIds){
7069
7129
  const res = await fetch(url, { headers: { 'User-Agent': 'agentic-security/0.1' } });
7070
7130
  if (!res.ok) {
7071
7131
  // 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);
7132
+ for (const c of batch) _osvCacheSet('epss:'+c, { miss: true, ts: Date.now() });
7073
7133
  continue;
7074
7134
  }
7075
7135
  const j = await res.json();
@@ -7080,7 +7140,7 @@ async function _fetchEPSSBatch(cveIds){
7080
7140
  const score = parseFloat(row.epss);
7081
7141
  const percentile = parseFloat(row.percentile);
7082
7142
  if (Number.isFinite(score) && Number.isFinite(percentile)) {
7083
- const v = { score, percentile };
7143
+ const v = { score, percentile, ts: Date.now() };
7084
7144
  out.set(cve, v);
7085
7145
  _osvCacheSet('epss:'+cve, v);
7086
7146
  seen.add(cve);
@@ -7088,7 +7148,7 @@ async function _fetchEPSSBatch(cveIds){
7088
7148
  }
7089
7149
  // CVEs in the batch that EPSS does not know — cache the negative so we
7090
7150
  // don't retry within this scan run.
7091
- for (const c of batch) if (!seen.has(c.toUpperCase())) _osvCacheSet('epss:'+c, false);
7151
+ for (const c of batch) if (!seen.has(c.toUpperCase())) _osvCacheSet('epss:'+c, { miss: true, ts: Date.now() });
7092
7152
  } catch { /* network error → caller continues without enrichment */ }
7093
7153
  }
7094
7154
  return out;
@@ -7103,14 +7163,43 @@ async function _enrichWithEPSS(supplyChainResults){
7103
7163
  // Cache lookup pass: keep CVEs we already have, defer the rest to one batched fetch.
7104
7164
  const epssByCve = new Map();
7105
7165
  const uncached = [];
7166
+ // FR-207: age of every entry actually used this scan, cache hit or fresh
7167
+ // fetch — a fresh fetch is age~0, a cache hit carries whatever `ts` it was
7168
+ // written with. A pre-existing on-disk entry from before this field
7169
+ // existed has no `ts` (`|| 0` → maximally stale), matching this
7170
+ // codebase's established "never-dated == already stale" convention
7171
+ // (compliance-policy.js's `_staleness`).
7172
+ const ages = [];
7106
7173
  for (const c of allCves) {
7107
7174
  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
7175
+ if (hit === null) { uncached.push(c); continue; }
7176
+ if (hit === false || hit.miss) continue; // tried, no data — leave unset
7177
+ epssByCve.set(c, hit);
7178
+ ages.push(Date.now() - (hit.ts || 0));
7110
7179
  }
7111
7180
  if (uncached.length) {
7112
7181
  const fetched = await _fetchEPSSBatch(uncached);
7113
- for (const [cve, v] of fetched) epssByCve.set(cve, v);
7182
+ for (const [cve, v] of fetched) { epssByCve.set(cve, v); ages.push(Date.now() - (v.ts || 0)); }
7183
+ }
7184
+ // Offline mode means "we deliberately have no network access this run" --
7185
+ // mirroring _loadKEVCatalog's own offline handling immediately above,
7186
+ // that is a genuine "we cannot vouch for freshness right now" state
7187
+ // (stale: null), not license to label whatever is on disk stale OR
7188
+ // fresh. This matters in practice: a cache entry written before this
7189
+ // field existed has no `ts` at all, and without the offline carve-out
7190
+ // that would report as "56 years old" (age computed against epoch)
7191
+ // under the exact conditions (no network) where that claim is least
7192
+ // verifiable.
7193
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
7194
+ _epssLiveMeta = { source: 'offline-skipped', ageDays: null, stale: null, cvesChecked: ages.length };
7195
+ } else if (ages.length) {
7196
+ const oldestMs = Math.max(...ages);
7197
+ _epssLiveMeta = {
7198
+ source: 'cache/live',
7199
+ ageDays: Math.floor(oldestMs / 86400000),
7200
+ stale: oldestMs > _EPSS_STALE_AFTER_MS,
7201
+ cvesChecked: ages.length,
7202
+ };
7114
7203
  }
7115
7204
  for (const r of out) {
7116
7205
  const cve = (r.cveAliases || []).find(a => /^CVE-/.test(a));
@@ -8268,6 +8357,175 @@ async function queryRegistries(components){
8268
8357
 
8269
8358
  // Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
8270
8359
  // fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
8360
+ // FR-202 phase 3b (D-0050): a worker importing this module gets a FRESH, unmutated
8361
+ // copy of SOURCE_PATTERNS/SINK_PATTERNS/SANITIZER_PATTERNS/_projectIndex/
8362
+ // _GLOBAL_JAVA_TAINTED_METHODS -- none of the main thread's pre-loop setup carries
8363
+ // across the worker boundary. This reproduces that exact setup (mirroring
8364
+ // runFullScan's own pre-loop sequence) so a long-lived worker can run it ONCE at
8365
+ // startup, then service many _runFileCascade calls with correctly-populated state,
8366
+ // instead of redoing whole-project work per file.
8367
+ export async function _initCascadeWorkerState(fileContents, scanRoot) {
8368
+ _buildProjectIndex(fileContents);
8369
+ try { _GLOBAL_JAVA_TAINTED_METHODS = _buildGlobalJavaTaintedMethodIndex(fileContents); }
8370
+ catch { _GLOBAL_JAVA_TAINTED_METHODS = new Set(); }
8371
+ await _loadCustomRules(scanRoot);
8372
+ }
8373
+
8374
+ // FR-202 phase 3b (D-0050): the shape a preemptively-KILLED worker task leaves
8375
+ // behind. Unlike the existing post-hoc _perFileTimeoutMs check (which fires only
8376
+ // after a slow-but-completed cascade already populated a real delta), a worker
8377
+ // timeout means the file's analysis was terminated mid-execution -- there is no
8378
+ // partial result to salvage, so every field is empty and only the timeout marker
8379
+ // finding (added by the caller, same as the cooperative path) reflects the file.
8380
+ function _emptyCascadeDelta(c) {
8381
+ return {
8382
+ content: c, pfr: { findings: [], sources: [], sinks: [], sanitizers: [] },
8383
+ routes: [], findings: [], sources: [], sinks: [], sanitizers: [],
8384
+ logic: [], secrets: [], ciphersRest: [], ciphersTransit: [], suppressions: [],
8385
+ };
8386
+ }
8387
+
8388
+ // FR-202 phase 3a (D-0049): the per-file detector cascade returns a self-contained
8389
+ // delta instead of mutating the outer accumulator arrays directly, so the same
8390
+ // function body can later run inside a worker (phase 3b) and post its result back
8391
+ // rather than reaching into shared memory it would not have access to.
8392
+ export function _runFileCascade(p, c, scanRoot, _detectorErrors) {
8393
+ const _aR=[],_aF=[],_aSrc=[],_aSink=[],_aSan=[],_aLogic=[],_aSecrets=[],_aCiphersRest=[],_aCiphersTransit=[],_aSupp=[];
8394
+ 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))||[]));
8395
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLLM',()=>scanLLM(p,c))||[]));
8396
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLLMOwasp',()=>scanLLMOwasp(p,c))||[]));
8397
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLlmCost',()=>scanLlmCost(p,c))||[]));
8398
+ _aLogic.push(...(runDetector(_detectorErrors,p,'scanBusinessLogic',()=>scanBusinessLogic(p,cc))||[]));
8399
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPipeline',()=>scanPipeline(p,cc))||[]));
8400
+ _aF.push(...(runDetector(_detectorErrors,p,'scanContainer',()=>scanContainer(p,cc))||[]));
8401
+ _aF.push(...(runDetector(_detectorErrors,p,'scanInstallScripts',()=>scanInstallScripts(p,cc))||[]));
8402
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMCP',()=>scanMCP(p,c))||[]));
8403
+ // PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
8404
+ // Every scanMCP rule judges the CURRENT content, so a description that is
8405
+ // innocuous today and hostile tomorrow passes both scans. This compares
8406
+ // against a recorded baseline, which is the only way to see a change.
8407
+ // Wired here rather than left as a tested module: a detector with no call
8408
+ // site is a dark detector, which is the exact class this session keeps
8409
+ // finding.
8410
+ if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
8411
+ try {
8412
+ const _cfg = JSON.parse(c);
8413
+ const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
8414
+ _aF.push(..._rp.findings);
8415
+ // Record on first sight so the NEXT scan has something to compare
8416
+ // against; refresh after reporting so a reviewed change is not
8417
+ // re-reported forever.
8418
+ _saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
8419
+ } catch (e) {
8420
+ // Only a malformed config is tolerated here — scanMCP already reports
8421
+ // what it can from one. Anything else is a programmer error and must
8422
+ // not be swallowed: a bare `catch {}` around this block hid a
8423
+ // ReferenceError (`root` vs `scanRoot`) that silently disabled the
8424
+ // whole detector while every unit test still passed.
8425
+ if (!(e instanceof SyntaxError)) throw e;
8426
+ }
8427
+ }
8428
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClaudeSettings',()=>scanClaudeSettings(p,c))||[]));
8429
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClaudeMdPromptInjection',()=>scanClaudeMdPromptInjection(p,c))||[]));
8430
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClaudeHookInjection',()=>scanClaudeHookInjection(p,c))||[]));
8431
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDjangoHardening',()=>scanDjangoHardening(p,cc))||[]));
8432
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDefiDeep',()=>scanDefiDeep(p,cc))||[]));
8433
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSpringbootHardening',()=>scanSpringbootHardening(p,cc))||[]));
8434
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLaravelHardening',()=>scanLaravelHardening(p,cc))||[]));
8435
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSwift',()=>scanSwift(p,cc))||[]));
8436
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDartFlutter',()=>scanDartFlutter(p,cc))||[]));
8437
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWeakRandomness',()=>scanWeakRandomness(p,cc))||[]));
8438
+ _aF.push(...(runDetector(_detectorErrors,p,'scanGraphQLModule',()=>scanGraphQLModule(p,cc))||[]));
8439
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSensitiveDataLogging',()=>scanSensitiveDataLogging(p,cc))||[]));
8440
+ _aF.push(...(runDetector(_detectorErrors,p,'scanComparisonSafety',()=>scanComparisonSafety(p,cc))||[]));
8441
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWeakPasswordHash',()=>scanWeakPasswordHash(p,cc))||[]));
8442
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCachePoisoning',()=>scanCachePoisoning(p,cc))||[]));
8443
+ _aF.push(...(runDetector(_detectorErrors,p,'scanNullByteInjection',()=>scanNullByteInjection(p,cc))||[]));
8444
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLlmTradingAgent',()=>scanLlmTradingAgent(p,c))||[]));
8445
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMobileManifest',()=>scanMobileManifest(p,cc))||[]));
8446
+ _aF.push(...(runDetector(_detectorErrors,p,'scanQuarkusHardening',()=>scanQuarkusHardening(p,cc))||[]));
8447
+ _aF.push(...(runDetector(_detectorErrors,p,'scanFastapiHardening',()=>scanFastapiHardening(p,cc))||[]));
8448
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAuthZ',()=>scanAuthZ(p,cc))||[]));
8449
+ _aF.push(...(runDetector(_detectorErrors,p,'scanModelLoad',()=>scanModelLoad(p,cc))||[]));
8450
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPromptTemplate',()=>scanPromptTemplate(p,c))||[]));
8451
+ _aF.push(...(runDetector(_detectorErrors,p,'scanXXE',()=>scanXXE(p,cc))||[]));
8452
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJNDI',()=>scanJNDI(p,cc))||[]));
8453
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJavaDeserialization',()=>scanJavaDeserialization(p,cc))||[]));
8454
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJwtExp',()=>scanJwtExp(p,cc))||[]));
8455
+ _aF.push(...(runDetector(_detectorErrors,p,'scanZipSlip',()=>scanZipSlip(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanSiblingGuard',()=>scanSiblingGuard(p,cc))||[]));
8456
+ _aF.push(...(runDetector(_detectorErrors,p,'scanFileUpload',()=>scanFileUpload(p,cc))||[]));
8457
+ _aF.push(...(runDetector(_detectorErrors,p,'scanHostHeader',()=>scanHostHeader(p,cc))||[]));
8458
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPythonSinks',()=>scanPythonSinks(p,cc))||[]));
8459
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCSharp',()=>scanCSharp(p,cc))||[]));
8460
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCpp',()=>scanCpp(p,cc))||[]));
8461
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSolidity',()=>scanSolidity(p,cc))||[]));
8462
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRust',()=>scanRust(p,cc))||[]));
8463
+ _aF.push(...(runDetector(_detectorErrors,p,'scanGoExtended',()=>scanGoExtended(p,cc))||[]));
8464
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDatabaseRLS',()=>scanDatabaseRLS(p,cc))||[]));
8465
+ _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))||[]));
8466
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAuthProvider',()=>scanAuthProvider(p,cc))||[]));
8467
+ _aF.push(...(runDetector(_detectorErrors,p,'scanEnvHygiene',()=>scanEnvHygiene(p,cc))||[]));
8468
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWebhook',()=>scanWebhook(p,cc))||[]));
8469
+ _aF.push(...(runDetector(_detectorErrors,p,'scanClientSide',()=>scanClientSide(p,cc))||[]));
8470
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPromptFirewall',()=>scanPromptFirewall(p,c))||[]));
8471
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLlmRedteam',()=>scanLlmRedteam(p,c))||[]));
8472
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJulietShape',()=>scanJulietShape(p,c))||[]));
8473
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCppDataflow',()=>scanCppDataflow(p,cc))||[]));
8474
+ // Phase 1: new detectors.
8475
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMassAssignment',()=>scanMassAssignment(p,cc))||[]));
8476
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPrototypePollution',()=>scanPrototypePollution(p,cc))||[]));
8477
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCSRF',()=>scanCSRF(p,cc))||[]));
8478
+ _aF.push(...(runDetector(_detectorErrors,p,'scanTOCTOU',()=>scanTOCTOU(p,cc))||[]));
8479
+ _aF.push(...(runDetector(_detectorErrors,p,'scanNoSQLInjection',()=>scanNoSQLInjection(p,cc))||[]));
8480
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLDAPInjection',()=>scanLDAPInjection(p,cc))||[]));
8481
+ _aF.push(...(runDetector(_detectorErrors,p,'scanXPathInjection',()=>scanXPathInjection(p,cc))||[]));
8482
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSSTI',()=>scanSSTI(p,cc))||[]));
8483
+ _aF.push(...(runDetector(_detectorErrors,p,'scanOpenRedirect',()=>scanOpenRedirect(p,cc))||[]));
8484
+ _aF.push(...(runDetector(_detectorErrors,p,'scanWrongContextSanitizer',()=>scanWrongContextSanitizer(p,cc))||[]));
8485
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSanitizerContextMismatch',()=>scanSanitizerContextMismatch(p,cc))||[]));
8486
+ _aF.push(...(runDetector(_detectorErrors,p,'scanFrontendHygiene',()=>scanFrontendHygiene(p,cc))||[]));
8487
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCsvInjection',()=>scanCsvInjection(p,cc))||[]));
8488
+ // R16 — specialist crypto-hygiene classes (constant-time comparison,
8489
+ // secret zeroization). Narrow by design: keyed on the secret-ness of the
8490
+ // identifier, and silent whenever the correct constant-time or
8491
+ // guaranteed-wipe API is already present.
8492
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCryptoSpecialist',()=>scanCryptoSpecialist(p,cc))||[]));
8493
+ _aF.push(...(runDetector(_detectorErrors,p,'scanStoredTaint',()=>scanStoredTaint(p,cc))||[]));
8494
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJavaStructural',()=>scanJavaStructural(p,cc))||[]));
8495
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCsharpStructural',()=>scanCsharpStructural(p,cc))||[]));
8496
+ _aF.push(...(runDetector(_detectorErrors,p,'scanJsFrameworkStructural',()=>scanJsFrameworkStructural(p,cc))||[]));
8497
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPythonStructural',()=>scanPythonStructural(p,cc))||[]));
8498
+ _aF.push(...(runDetector(_detectorErrors,p,'scanGoStructural',()=>scanGoStructural(p,cc))||[]));
8499
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSecretConcat',()=>scanSecretConcat(p,cc))||[]));
8500
+ _aF.push(...(runDetector(_detectorErrors,p,'scanXssReflectedMultilang',()=>scanXssReflectedMultilang(p,cc))||[]));
8501
+ _aF.push(...(runDetector(_detectorErrors,p,'scanCodeInjectionMultilang',()=>scanCodeInjectionMultilang(p,cc))||[]));
8502
+ _aF.push(...(runDetector(_detectorErrors,p,'scanResponseSplitting',()=>scanResponseSplitting(p,cc))||[]));
8503
+ _aF.push(...(runDetector(_detectorErrors,p,'scanStoredPromptInjection',()=>scanStoredPromptInjection(p,c))||[]));
8504
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRAGPoisoning',()=>scanRAGPoisoning(p,c))||[]));
8505
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAgentToolEscalation',()=>scanAgentToolEscalation(p,c))||[]));
8506
+ _aF.push(...(runDetector(_detectorErrors,p,'scanAgentUntrustedFlow',()=>scanAgentUntrustedFlow(p,c))||[]));
8507
+ _aF.push(...(runDetector(_detectorErrors,p,'scanEventEntrypoints',()=>scanEventEntrypoints(p,cc))||[]));
8508
+ _aF.push(...(runDetector(_detectorErrors,p,'scanDbTaint',()=>scanDbTaint(p,cc))||[]));
8509
+ _aF.push(...(runDetector(_detectorErrors,p,'scanSSRFCloudMetadata',()=>scanSSRFCloudMetadata(p,cc))||[]));
8510
+ _aF.push(...(runDetector(_detectorErrors,p,'scanMutationXSS',()=>scanMutationXSS(p,cc))||[]));
8511
+ _aF.push(...(runDetector(_detectorErrors,p,'scanKotlin',()=>scanKotlin(p,cc))||[]));
8512
+ _aF.push(...(runDetector(_detectorErrors,p,'scanRuby',()=>scanRuby(p,cc))||[]));_aF.push(...(runDetector(_detectorErrors,p,'scanRubyPathJoin',()=>scanRubyPathJoin(p,cc))||[]));
8513
+ _aF.push(...(runDetector(_detectorErrors,p,'scanPhp',()=>scanPhp(p,cc))||[]));
8514
+ // Integration block: scaffolded SAST scanners. Gated by env var.
8515
+ if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
8516
+ if (process.env.AGENTIC_SECURITY_NO_LLM_APP !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanLlmApp',()=>scanLlmApp(p,c))||[]));
8517
+ if (process.env.AGENTIC_SECURITY_NO_MOBILE !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanMobile',()=>scanMobile(p,cc))||[]));
8518
+ if (process.env.AGENTIC_SECURITY_NO_PQC !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanPqc',()=>scanPqc(p,cc))||[]));
8519
+ if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV!== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanWeb3Advanced',()=>scanWeb3Advanced(p,cc))||[]));
8520
+ if (process.env.AGENTIC_SECURITY_NO_DAPP !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanDappFrontend',()=>scanDappFrontend(p,cc))||[]));
8521
+ if (process.env.AGENTIC_SECURITY_NO_CLOUD_IAM!== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanCloudIam',()=>scanCloudIam(p,cc))||[]));
8522
+ if (process.env.AGENTIC_SECURITY_NO_K8S_ADM !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanK8sAdmission',()=>scanK8sAdmission(p,cc))||[]));
8523
+ if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanCryptoProtocol',()=>scanCryptoProtocol(p,cc))||[]));
8524
+ if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY !== '1') _aF.push(...(runDetector(_detectorErrors,p,'scanMlSupplyChain',()=>scanMlSupplyChain(p,cc))||[]));
8525
+ }
8526
+ 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};
8527
+ }
8528
+
8271
8529
  async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8272
8530
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8273
8531
  // analysis can recognize calls to user-input-returning helper methods
@@ -8295,7 +8553,7 @@ function _deterministicFileTimings(timings) {
8295
8553
 
8296
8554
  const _fileTimings = [];
8297
8555
  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=[];
8556
+ 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
8557
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
8300
8558
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
8301
8559
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
@@ -8303,17 +8561,28 @@ function _deterministicFileTimings(timings) {
8303
8561
  const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
8304
8562
  let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
8305
8563
  const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
8564
+ let _ckptInvalidated = [];
8306
8565
  if (_ckptEnabled) {
8307
8566
  try {
8308
- const _runKey = computeRunKey({
8567
+ const _ckptIdentity = {
8309
8568
  engineVersion: _ENGINE_VERSION,
8310
8569
  rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version,
8311
8570
  bundleSha: bundleShaForRunKey(),
8312
- fileContents, depFileContents,
8571
+ depFileContents,
8572
+ };
8573
+ // FR-208: the global key covers everything that would affect how EVERY
8574
+ // file is analysed (engine/ruleset/bundle/deps/env); each scanned
8575
+ // file's OWN content is checked per-record inside openCheckpoint
8576
+ // instead, so changing one file no longer discards every other file's
8577
+ // already-completed work — see scan-checkpoint.js's module header.
8578
+ _ckpt = openCheckpoint(scanRoot, {
8579
+ globalKey: computeGlobalKey(_ckptIdentity),
8580
+ meta: globalKeyMeta(_ckptIdentity),
8581
+ fileContents,
8313
8582
  });
8314
- _ckpt = openCheckpoint(scanRoot, { runKey: _runKey });
8315
8583
  for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
8316
8584
  _ckptDone = completedFiles(_ckpt);
8585
+ _ckptInvalidated = invalidatedFiles(_ckpt);
8317
8586
  } catch (_) { _ckpt = null; }
8318
8587
  }
8319
8588
  // Replay a checkpointed file's ENTIRE contribution, in the same array order
@@ -8360,146 +8629,45 @@ function _deterministicFileTimings(timings) {
8360
8629
  // is what the checkpoint format has to survive. Never set in normal use.
8361
8630
  if (_ckptAbortAfter > 0 && _ckptWrites >= _ckptAbortAfter) process.exit(137);
8362
8631
  };
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"});
8632
+ // FR-202 phase 3b (D-0050): opt-in real deadline enforcement for the per-file
8633
+ // cascade via a reused worker pool. Off by default -- the synchronous path above
8634
+ // this loop is completely unchanged when the flag is unset.
8635
+ const _useWorkerCascade = process.env.AGENTIC_SECURITY_WORKER_CASCADE === '1';
8636
+ let _cascadePool = null;
8637
+ if (_useWorkerCascade) {
8638
+ const { createCascadePool } = await import('./pipeline/cascade-worker-pool.js');
8639
+ _cascadePool = createCascadePool({
8640
+ fileContents, scanRoot,
8641
+ modulePath: _resolveEngineModulePathForWorkers(),
8642
+ poolSize: Number(process.env.AGENTIC_SECURITY_WORKER_POOL_SIZE) || 4,
8643
+ });
8644
+ }
8645
+ let i=0;
8646
+ try {
8647
+ for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
8364
8648
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
8365
8649
  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));
8650
+ 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;}
8651
+ let _delta;
8652
+ if (_cascadePool) {
8653
+ const _res = await _cascadePool.runFile(p, c, scanRoot, _detectorErrors, { timeoutMs: _perFileTimeoutMs });
8654
+ if (_res.ok) { _delta = _res.result; }
8655
+ else if (_res.timedOut) { _delta = _emptyCascadeDelta(c); }
8656
+ else { throw new Error(_res.error); }
8657
+ } else {
8658
+ _delta = _runFileCascade(p,c,scanRoot,_detectorErrors);
8497
8659
  }
8660
+ fc[p]=_delta.content;pfr[p]=_delta.pfr;
8661
+ 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
8662
  const _ftElapsed=Date.now()-_ft0;
8499
8663
  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
8664
  _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));}
8665
+ _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
8666
+ }catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}
8667
+ if(i%5===0)await new Promise(r=>setTimeout(r,0));}
8668
+ } finally {
8669
+ if (_cascadePool) await _cascadePool.shutdown();
8670
+ }
8503
8671
  // Deserialization-gadget detector runs once with full-tree context (it needs
8504
8672
  // manifest contents to know which gadget libs are on the classpath).
8505
8673
  try {
@@ -8507,7 +8675,7 @@ function _deterministicFileTimings(timings) {
8507
8675
  if (_gadgets.size) {
8508
8676
  for (const p of files) {
8509
8677
  const c = fc[p]; if (!c) continue;
8510
- aF.push(...scanDeserializationGadgets(p, c, { gadgets: _gadgets }));
8678
+ aF.push(...(runDetector(_detectorErrors,p,'scanDeserializationGadgets',()=>scanDeserializationGadgets(p, c, { gadgets: _gadgets }))||[]));
8511
8679
  }
8512
8680
  }
8513
8681
  } catch(_) {}
@@ -8650,17 +8818,17 @@ function _deterministicFileTimings(timings) {
8650
8818
  // R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
8651
8819
  // Convention deviation is PROJECT-scoped by design: the convention is a
8652
8820
  // 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(_){}
8821
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanConventionDeviationProject',()=>scanConventionDeviationProject(fc))||[]));
8822
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanApiBrokenAuthz',()=>scanApiBrokenAuthz(aR))||[]));
8655
8823
  // R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
8656
- try{aF.push(...scanCrossService(aR,fc));}catch(_){}
8824
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanCrossService',()=>scanCrossService(aR,fc))||[]));
8657
8825
  // R21 (PRD §5): RBAC role-tier consistency over the route inventory.
8658
- try{aF.push(...scanRbacConsistency(aR,fc));}catch(_){}
8826
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanRbacConsistency',()=>scanRbacConsistency(aR,fc))||[]));
8659
8827
  setProgress({current:i,total:files.length,file:"Reachability + guards...",phase:"Linking"});annotateReachability(aF,aR,callGraph,fc);aF.forEach(f=>detectGuardsForFinding(f,fc));
8660
8828
  setProgress({current:i,total:files.length,file:"Inferring sanitizers...",phase:"Linking"});const learned=inferSanitizers(fc);applyLearnedSanitizers(aF,learned,fc);
8661
8829
  setProgress({current:i,total:files.length,file:"Sanitizer effectiveness...",phase:"Linking"});applySanitizerEffectiveness(aF);
8662
8830
  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));
8831
+ setProgress({current:i,total:files.length,file:"Config file cross-ref...",phase:"Linking"});aLogic.push(...(runDetector(_detectorErrors,'<project>','scanConfigFiles',()=>scanConfigFiles(fc))||[]));
8664
8832
  setProgress({current:i,total:files.length,file:"OSV vulnerability database...",phase:"SCA"});
8665
8833
  const allFileContents={...fc, ...depFileContents};
8666
8834
  // PRD F11.4 — malicious install hooks, scanned where package.json actually
@@ -8675,7 +8843,7 @@ function _deterministicFileTimings(timings) {
8675
8843
  // A manifest detector belongs on the manifest path.
8676
8844
  for (const [mp, mc] of Object.entries(depFileContents)) {
8677
8845
  if (!/(?:^|\/)package\.json$/i.test(mp)) continue;
8678
- try { aF.push(...scanInstallScripts(mp, mc)); } catch (_) { /* never fail a scan on one manifest */ }
8846
+ aF.push(...(runDetector(_detectorErrors,mp,'scanInstallScripts',()=>scanInstallScripts(mp, mc))||[]));
8679
8847
  }
8680
8848
  const components=parseManifests(allFileContents);
8681
8849
  // R8 (PRD §5): OS packages from an extracted container image's package DBs
@@ -8806,8 +8974,8 @@ function _deterministicFileTimings(timings) {
8806
8974
  }
8807
8975
  }
8808
8976
  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(_){}
8977
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
8978
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanStoredPromptInjectionCrossFile',()=>scanStoredPromptInjectionCrossFile(fc))||[]));
8811
8979
  // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
8812
8980
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
8813
8981
  if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
@@ -8894,6 +9062,7 @@ function _deterministicFileTimings(timings) {
8894
9062
  const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8895
9063
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8896
9064
  let _deepCallGraph = null;
9065
+ let _deepFailure = null;
8897
9066
  if (_deepEnabled) {
8898
9067
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8899
9068
  const t0 = Date.now();
@@ -8931,7 +9100,10 @@ function _deterministicFileTimings(timings) {
8931
9100
  aF.push(...irFindings);
8932
9101
  } catch (e) {
8933
9102
  // Deep mode is best-effort. A parser blowup in one file shouldn't kill
8934
- // the scan — fall back to the pattern-only result.
9103
+ // the scan — fall back to the pattern-only result. Recorded (not just
9104
+ // swallowed) so scanHealth (FR-206) can report the downgrade instead of
9105
+ // looking identical to a scan where deep mode simply wasn't requested.
9106
+ _deepFailure = String((e && e.message) || e);
8935
9107
  }
8936
9108
  } else if (_deepRequested && _inCi) {
8937
9109
  // Operator asked for deep but we're in CI — emit a non-blocking notice
@@ -8945,6 +9117,23 @@ function _deterministicFileTimings(timings) {
8945
9117
  confidence: 1.0,
8946
9118
  });
8947
9119
  }
9120
+ // FR-205/FR-206: make the deep-analysis decision explicit in machine
9121
+ // output, not just as an occasional info-severity finding. Covers the case
9122
+ // the finding-based notice above does not: deep mode never explicitly
9123
+ // requested at all (the common default-unset-in-CI case), which previously
9124
+ // produced no signal of any kind that assurance had quietly narrowed.
9125
+ const _deepStatus = {
9126
+ requested: _deepRequested,
9127
+ enabled: _deepEnabled,
9128
+ inCi: _inCi,
9129
+ ciOverrideAllowed: _deepInCiAllowed,
9130
+ reason: _deepEnabled
9131
+ ? null
9132
+ : (_deepRequested
9133
+ ? (_inCi ? 'requested, but running in CI without AGENTIC_SECURITY_DEEP_IN_CI=1' : 'unknown')
9134
+ : (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
9135
+ failure: _deepFailure,
9136
+ };
8948
9137
  // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8949
9138
  if (_deepCallGraph) {
8950
9139
  try {
@@ -9090,16 +9279,18 @@ function _deterministicFileTimings(timings) {
9090
9279
  let _executionProofSummary = null, _vulnHistory = null;
9091
9280
  let _logicClaims = null;
9092
9281
  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", () => {
9282
+ // FR-106 (assurance-hardening PRD): Promise-aware, explicitly awaited at
9283
+ // every one of its ~51 call sites below (previously a sync `try{return
9284
+ // fn()}` let an async callback's rejection escape as an unhandled
9285
+ // rejection, and every caller fired-and-forgot regardless). Logic lives in
9286
+ // pipeline/annotator-runner.js, fault-injection tested directly there
9287
+ // since this project's own annotators are deliberately built never to
9288
+ // throw (posture/CLAUDE.md's "no throwing" convention) — this closure just
9289
+ // supplies the local _annotatorErrors array.
9290
+ const _runAnnotator = (phase, fn) => runAnnotatorAsync(_annotatorErrors, phase, fn);
9291
+ await _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
9292
+ await _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
9293
+ await _runAnnotator("demoteUnreachable", () => {
9103
9294
  demoteUnreachable(finalFindings, { routes: aR });
9104
9295
  // `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
9105
9296
  // (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
@@ -9108,14 +9299,14 @@ function _deterministicFileTimings(timings) {
9108
9299
  });
9109
9300
  // Premortem #8: backfill parser/family BEFORE confidence and calibration,
9110
9301
  // because both consume those fields and silently no-op when they're null.
9111
- _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
9112
- _runAnnotator("annotateConfidence", () => { annotateConfidence(finalFindings); });
9302
+ await _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
9303
+ await _runAnnotator("annotateConfidence", () => { annotateConfidence(finalFindings); });
9113
9304
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): Brier-calibrated probability +
9114
9305
  // 95% Wilson CI from per-family historical TP/FP. Falls back to null with
9115
9306
  // an explicit `calibration_reason` when N is below the calibration floor.
9116
- _runAnnotator("annotateCalibratedConfidence", () => { annotateCalibratedConfidence(finalFindings, { scanRoot }); });
9307
+ await _runAnnotator("annotateCalibratedConfidence", () => { annotateCalibratedConfidence(finalFindings, { scanRoot }); });
9117
9308
  const _projectCtx = (() => { try { return detectProjectContext(fc, aR); } catch { return {}; } })();
9118
- _runAnnotator("annotateExploitability", () => { annotateExploitability(finalFindings, _projectCtx); });
9309
+ await _runAnnotator("annotateExploitability", () => { annotateExploitability(finalFindings, _projectCtx); });
9119
9310
  // Roadmap #6 — proof-gate precision pass. Runs AFTER confidence +
9120
9311
  // exploitability so it can demote their tiers, and BEFORE mitigation /
9121
9312
  // composite-risk so the demotion flows into the canonical ranking. Default
@@ -9155,8 +9346,8 @@ function _deterministicFileTimings(timings) {
9155
9346
  if (f.stableId) unsanitizersOnPath[f.stableId] = undo;
9156
9347
  }
9157
9348
  }
9158
- _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
9159
- _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
9349
+ await _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
9350
+ await _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
9160
9351
  }
9161
9352
  // Addition #1 — default falsification pass. Actively tries to DISPROVE each
9162
9353
  // taint-style finding by locating a context-matched control on the path, and
@@ -9166,29 +9357,45 @@ function _deterministicFileTimings(timings) {
9166
9357
  // default; the LLM tier is only wired when an endpoint is configured. Opt out
9167
9358
  // with AGENTIC_SECURITY_NO_FALSIFICATION=1.
9168
9359
  if (process.env.AGENTIC_SECURITY_NO_FALSIFICATION !== '1') {
9169
- _runAnnotator("annotateFalsification", () => { annotateFalsification(finalFindings, fc); });
9360
+ await _runAnnotator("annotateFalsification", () => { annotateFalsification(finalFindings, fc); });
9170
9361
  }
9171
9362
  // Addition #5 — capability-based model routing. Stamp each finding with the
9172
9363
  // model tier a cost-sensitive fixer/triager/PoC subagent should be dispatched
9173
9364
  // on for THIS vuln class (crypto/auth/critical → strongest; injection → mid;
9174
9365
  // low-sev hardening → cheapest). Advisory metadata consumed at dispatch time.
9175
- _runAnnotator("annotateDispatchModel", () => {
9366
+ await _runAnnotator("annotateDispatchModel", () => {
9176
9367
  for (const f of finalFindings) { try { f.dispatchModel = routeModelForFinding(f).model; } catch { /* advisory only */ } }
9177
9368
  });
9178
9369
  // v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
9179
9370
  // the mitigation composite, persona prioritization, and final why-fired
9180
9371
  // 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 }); });
9372
+ await _runAnnotator("annotateWafMitigation", () => { annotateWafMitigation(finalFindings, scanRoot); });
9373
+ await _runAnnotator("annotateAuthMitigation", () => { annotateAuthMitigation(finalFindings, scanRoot); });
9374
+ await _runAnnotator("annotateNetworkMitigation", () => { annotateNetworkMitigation(finalFindings, scanRoot); });
9375
+ await _runAnnotator("annotateTelemetry", () => { annotateTelemetry(finalFindings, scanRoot); });
9376
+ await _runAnnotator("annotateFeatureFlagGating", () => { annotateFeatureFlagGating(finalFindings, fc, { scanRoot }); });
9186
9377
  // v3 next-gen: composite mitigation verdict consumes every prod signal above.
9187
- _runAnnotator("annotateMitigationComposite", () => { annotateMitigationComposite(finalFindings); });
9378
+ await _runAnnotator("annotateMitigationComposite", () => { annotateMitigationComposite(finalFindings); });
9188
9379
  // Composite risk score (0..100 derived ordinal). Must run AFTER mitigation
9189
9380
  // composite + exploitability + toxicityScore so it sees the final values.
9190
9381
  // Used by agents and UI as the canonical sort key for "which finding first."
9191
- _runAnnotator("annotateCompositeRisk", () => { annotateCompositeRisk(finalFindings); });
9382
+ await _runAnnotator("annotateCompositeRisk", () => { annotateCompositeRisk(finalFindings); });
9383
+
9384
+ // FR-405 (assurance-hardening PRD): null means "privacy analysis never
9385
+ // ran at all" (AGENTIC_SECURITY_NO_PRIVACY=1, or the annotator threw
9386
+ // before setting this) — treated the same as false by the gate below,
9387
+ // since neither case has real IR-backed evidence to offer. Declared here,
9388
+ // at function scope, because the annotatePrivacyTaint closure that
9389
+ // assigns to it (inside the AGENTIC_SECURITY_NO_INTEGRATION block below)
9390
+ // runs and exits before that block closes — a block-scoped `let` inside
9391
+ // that if-statement would be unreachable by the later assessPrivacyFramework
9392
+ // call and the final return, both of which are outside the block.
9393
+ let _privacyIrBacked = null;
9394
+ // FR-402: which taxonomy version actually classified this scan's fields —
9395
+ // same scoping constraint as _privacyIrBacked directly above (D-0011):
9396
+ // must be declared before the AGENTIC_SECURITY_NO_INTEGRATION block opens,
9397
+ // not inside it.
9398
+ let _privacyTaxonomyVersion = null;
9192
9399
 
9193
9400
  // ── World-class integration block ─────────────────────────────────────
9194
9401
  // Each annotator is opt-in via env var and try/catch wrapped. They run
@@ -9198,46 +9405,89 @@ function _deterministicFileTimings(timings) {
9198
9405
  // Cross-service taint annotation reads .agentic-security/services.yml
9199
9406
  // and bumps severity on cross-service-reachable findings.
9200
9407
  if (process.env.AGENTIC_SECURITY_NO_CROSS_SERVICE !== '1') {
9201
- _runAnnotator("runCrossServiceTaint", () => { runCrossServiceTaint(scanRoot, finalFindings); });
9408
+ await _runAnnotator("runCrossServiceTaint", () => { runCrossServiceTaint(scanRoot, finalFindings); });
9202
9409
  }
9203
9410
  // Runtime correlation: demotes findings whose paths were unobserved
9204
9411
  // in production eBPF traces (when a trace file is present).
9205
9412
  if (process.env.AGENTIC_SECURITY_NO_RUNTIME_CORRELATION !== '1') {
9206
- _runAnnotator("annotateRuntimeCorrelation", async () => { await annotateRuntimeCorrelation(scanRoot, finalFindings); });
9413
+ await _runAnnotator("annotateRuntimeCorrelation", async () => { await annotateRuntimeCorrelation(scanRoot, finalFindings); });
9207
9414
  }
9208
9415
  // Triage learning: applies per-(project, family, file-glob) calibration
9209
9416
  // from prior wont-fix / false-positive decisions.
9210
9417
  if (process.env.AGENTIC_SECURITY_NO_TRIAGE_LEARNING !== '1') {
9211
- _runAnnotator("applyLearnedCalibration", () => { applyLearnedCalibration(scanRoot, finalFindings); });
9418
+ await _runAnnotator("applyLearnedCalibration", () => { applyLearnedCalibration(scanRoot, finalFindings); });
9212
9419
  }
9213
9420
  // Formal verification: CBMC for C/C++, MIRI for Rust. Opt-in via
9214
9421
  // AGENTIC_SECURITY_FORMAL=1 (off by default — requires external tools).
9215
9422
  if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
9216
- _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
9423
+ await _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
9217
9424
  }
9218
9425
  // SMT path feasibility: Z3-backed proof of reachability. Opt-in via
9219
9426
  // AGENTIC_SECURITY_SMT_FEASIBILITY=1.
9220
9427
  if (process.env.AGENTIC_SECURITY_SMT_FEASIBILITY === '1') {
9221
- _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
9428
+ await _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
9222
9429
  }
9223
9430
  // Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
9431
+ // FR-405 (assurance-hardening PRD): _privacyIrBacked is declared at
9432
+ // function scope above the AGENTIC_SECURITY_NO_INTEGRATION block, not
9433
+ // here — this closure runs and exits before that block's closing brace,
9434
+ // so a block-scoped declaration would be unreachable from the later
9435
+ // assessPrivacyFramework call and the final return.
9224
9436
  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);
9437
+ await _runAnnotator("annotatePrivacyTaint", () => {
9438
+ // FR-401 (assurance-hardening PRD, A-06): decls/calls used to be
9439
+ // hardcoded empty for every file (the actual root cause, per
9440
+ // decisions.md D-0003, was a missing adapter reconciling
9441
+ // privacy-taint.js's flat per-file shape with the real Layer-1 IR's
9442
+ // per-function/CFG shape not a missing capability in the IR or
9443
+ // taint engine themselves; see privacy/ir-adapter.js).
9444
+ //
9445
+ // Reuse _sharedIR when deep mode already built it (free — no
9446
+ // second parse pass). When deep mode is OFF (the common default
9447
+ // path), do NOT force a fresh IR build here: buildProjectIR parses
9448
+ // every file and is exactly the cost deep mode is opt-in for
9449
+ // (NFR: "no more than 15% ... overhead ... excluding newly enabled
9450
+ // deep analysis"). In that case privacy analysis still runs, but
9451
+ // honestly marked as not IR-backed (irBacked:false below) rather
9452
+ // than silently claiming the same coverage as the deep-mode path —
9453
+ // this is the signal FR-405's "missing capability -> not_assessed,
9454
+ // never satisfied" contract needs to key off, not yet wired into
9455
+ // privacy-framework.js's own bucketing in this cycle (separate,
9456
+ // later FR-405 work).
9457
+ const irBacked = !!(_sharedIR && _sharedIR.perFile);
9458
+ const adaptedIR = irBacked
9459
+ ? adaptIRForPrivacyTaint(_sharedIR.perFile, fc, storedRegistry)
9460
+ : new Map(Object.entries(fc || {})
9461
+ .filter(([, content]) => typeof content === 'string')
9462
+ .map(([fp, content]) => [fp, { _content: content, decls: [], calls: [] }]));
9463
+ // FR-402: pass scanRoot so an operator's .agentic-security/
9464
+ // privacy-taxonomy.json (if any) is actually loaded on a real scan.
9465
+ const r = annotatePrivacyTaint(adaptedIR, { scanRoot });
9466
+ r.irBacked = irBacked;
9467
+ _privacyIrBacked = irBacked;
9468
+ _privacyTaxonomyVersion = r.taxonomyVersion || null;
9469
+ // FR-102 (assurance-hardening PRD): route through the registered-
9470
+ // producer collector rather than a bare push. r is already computed
9471
+ // above (this call also drives the DPIA artifact below), so the
9472
+ // thunk just hands back what's already there.
9473
+ collectProducerResult(finalFindings, _annotatorErrors, 'privacy-taint', () => (r && Array.isArray(r.findings)) ? r.findings : []);
9236
9474
  // Persist the DPIA scaffold for compliance review.
9237
9475
  if (r && r.piiFields) {
9238
9476
  try {
9239
- const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
9477
+ // FR-407: governance fields are loaded once and threaded into
9478
+ // both the DPIA (per-class subsection) and the RoPA artifact
9479
+ // (the full register) so an operator only maintains one config
9480
+ // file for both.
9481
+ const governanceConfig = loadPrivacyGovernanceConfig(scanRoot);
9482
+ const dpia = emitDpiaArtifact(r.piiFields, r.findings || [], { policyExemptions: r.policyExemptions || [], governanceConfig });
9240
9483
  _safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
9484
+ const ropa = emitRopaArtifact(r.piiFields, governanceConfig);
9485
+ _safeWriteState(_statePath(scanRoot, 'ropa.md'), ropa);
9486
+ // FR-406: code-derived data inventory + flow graph, built from
9487
+ // the SAME findings/exemptions already computed above.
9488
+ const inventory = buildDataInventory(r.piiFields, r.findings || [], r.policyExemptions || []);
9489
+ _safeWriteState(_statePath(scanRoot, 'data-inventory.json'), emitDataInventoryArtifact(inventory));
9490
+ _safeWriteState(_statePath(scanRoot, 'data-flow-graph.md'), emitDataFlowGraph(inventory));
9241
9491
  } catch (_) {}
9242
9492
  }
9243
9493
  });
@@ -9246,80 +9496,80 @@ function _deterministicFileTimings(timings) {
9246
9496
  // ATLAS, D3FEND, kill-chain stage, and CAPEC IDs so downstream SIEM /
9247
9497
  // SOAR systems can correlate with existing detection rules.
9248
9498
  if (process.env.AGENTIC_SECURITY_NO_ATTACK_TAX !== '1') {
9249
- _runAnnotator("annotateAttackTaxonomy", () => { annotateAttackTaxonomy(finalFindings); });
9499
+ await _runAnnotator("annotateAttackTaxonomy", () => { annotateAttackTaxonomy(finalFindings); });
9250
9500
  }
9251
9501
  // Triage memory — demote findings whose (family, dir) bucket was
9252
9502
  // previously marked wont-fix or false-positive in this project.
9253
9503
  if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY !== '1') {
9254
- _runAnnotator("suppressByPastDecisions", () => { suppressByPastDecisions(scanRoot, finalFindings); });
9504
+ await _runAnnotator("suppressByPastDecisions", () => { suppressByPastDecisions(scanRoot, finalFindings); });
9255
9505
  }
9256
9506
  // Intent-aware FP suppression — demote findings on files marked as
9257
9507
  // intentionally vulnerable (sandbox/CTF/tutorial/example/etc.).
9258
9508
  if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX !== '1') {
9259
- _runAnnotator("suppressByIntent", () => { suppressByIntent(scanRoot, finalFindings); });
9509
+ await _runAnnotator("suppressByIntent", () => { suppressByIntent(scanRoot, finalFindings); });
9260
9510
  }
9261
9511
  // Git history — stamp each finding with introducedBy / introducedIn /
9262
9512
  // originatingPrompt by running `git blame` on the finding's line.
9263
9513
  if (process.env.AGENTIC_SECURITY_NO_GIT_HISTORY !== '1') {
9264
- _runAnnotator("annotateGitHistory", () => { annotateGitHistory(scanRoot, finalFindings); });
9514
+ await _runAnnotator("annotateGitHistory", () => { annotateGitHistory(scanRoot, finalFindings); });
9265
9515
  }
9266
9516
  // Threat-model grounding — bump severity on crown-jewels, demote
9267
9517
  // out-of-scope, tag compliance regimes, stamp attacker profile.
9268
9518
  if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING !== '1') {
9269
- _runAnnotator("applyThreatModel", () => { applyThreatModel(scanRoot, finalFindings); });
9519
+ await _runAnnotator("applyThreatModel", () => { applyThreatModel(scanRoot, finalFindings); });
9270
9520
  }
9271
9521
  // Cross-repo pattern propagation — surface sibling-repo fixes and
9272
9522
  // triage decisions for the same family from this developer's history.
9273
9523
  if (process.env.AGENTIC_SECURITY_NO_CROSS_REPO !== '1') {
9274
- _runAnnotator("annotateCrossRepoSignals", () => { annotateCrossRepoSignals(scanRoot, finalFindings); });
9524
+ await _runAnnotator("annotateCrossRepoSignals", () => { annotateCrossRepoSignals(scanRoot, finalFindings); });
9275
9525
  }
9276
9526
  // Risk-in-dollars — combine EPSS + crown-jewel + reachability into an
9277
9527
  // expected-value-of-exploitation USD figure per finding.
9278
9528
  if (process.env.AGENTIC_SECURITY_NO_RISK_DOLLARS !== '1') {
9279
- _runAnnotator("annotateRiskDollars", () => { annotateRiskDollars(scanRoot, finalFindings); });
9529
+ await _runAnnotator("annotateRiskDollars", () => { annotateRiskDollars(scanRoot, finalFindings); });
9280
9530
  }
9281
9531
  // Time-to-fix — estimate engineering hours per finding.
9282
9532
  if (process.env.AGENTIC_SECURITY_NO_TIME_TO_FIX !== '1') {
9283
- _runAnnotator("annotateTimeToFix", () => { annotateTimeToFix(scanRoot, finalFindings); });
9533
+ await _runAnnotator("annotateTimeToFix", () => { annotateTimeToFix(scanRoot, finalFindings); });
9284
9534
  }
9285
9535
  }
9286
9536
  // v3 next-gen: crown-jewel mapping (FR-PROD-5) — score each file/finding by
9287
9537
  // business impact. Must run before persona prioritization (which uses it).
9288
- _runAnnotator("annotateCrownJewelScores", () => { annotateCrownJewelScores(finalFindings, fc); });
9538
+ await _runAnnotator("annotateCrownJewelScores", () => { annotateCrownJewelScores(finalFindings, fc); });
9289
9539
  // v3 next-gen: clone clusters (FR-SEM-8) + emit clone-outlier infos.
9290
- _runAnnotator("annotateCloneClusters", () => { annotateCloneClusters(finalFindings); });
9540
+ await _runAnnotator("annotateCloneClusters", () => { annotateCloneClusters(finalFindings); });
9291
9541
  try {
9292
9542
  const outliers = findCloneOutliers(finalFindings);
9293
9543
  if (outliers && outliers.length) finalFindings.push(...outliers);
9294
9544
  } catch(_) {}
9295
9545
  // v3 next-gen: AI-generated-code fingerprint (FR-LEARN-10). Property bag tag.
9296
- _runAnnotator("annotateAiProvenance", () => { annotateAiProvenance(finalFindings, fc); });
9546
+ await _runAnnotator("annotateAiProvenance", () => { annotateAiProvenance(finalFindings, fc); });
9297
9547
  // v3 next-gen: whole-program type narrowing (FR-SEM-10) — heuristic
9298
9548
  // confidence dampener on findings rooted in functions whose callers all
9299
9549
  // pass narrowly-typed values.
9300
- _runAnnotator("annotateTypeNarrowing", () => { annotateTypeNarrowing(finalFindings, fc); });
9550
+ await _runAnnotator("annotateTypeNarrowing", () => { annotateTypeNarrowing(finalFindings, fc); });
9301
9551
  // v3 next-gen: STRIDE classification (FR-LOGIC-10).
9302
- _runAnnotator("annotateStrideCategory", () => { annotateStrideCategory(finalFindings); });
9552
+ await _runAnnotator("annotateStrideCategory", () => { annotateStrideCategory(finalFindings); });
9303
9553
  // v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
9304
9554
  // crown-jewels + mitigation composite so it sees those signals.
9305
- _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
9555
+ await _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
9306
9556
  // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
9307
9557
  // SCA findings (package-name-keyed) — must run against supplyChain, not
9308
9558
  // finalFindings (SAST), which has no package-name field at all.
9309
- _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
9559
+ await _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
9310
9560
  // v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
9311
9561
  // mitigation composite — gated/unreachable findings get the bounty scaled
9312
9562
  // down rather than zeroed.
9313
- _runAnnotator("annotateBountyPrediction", () => { annotateBountyPrediction(finalFindings); });
9563
+ await _runAnnotator("annotateBountyPrediction", () => { annotateBountyPrediction(finalFindings); });
9314
9564
  // v3 next-gen: attack-playbook annotation (FR-ADV-4). Only for high+ findings.
9315
- _runAnnotator("annotateAttackPlaybooks", () => { annotateAttackPlaybooks(finalFindings); });
9565
+ await _runAnnotator("annotateAttackPlaybooks", () => { annotateAttackPlaybooks(finalFindings); });
9316
9566
  // Phase-1 next-gen P1.1 (FR-VER-2): attach a runnable PoC to each finding
9317
9567
  // when a CWE template covers it. Findings without coverage get f.poc=null.
9318
9568
  // Premortem #12: pass fileContents so PoC param-key inference can re-read
9319
9569
  // the actual handler line when detector snippets are misattributed.
9320
- _runAnnotator("annotatePocs", () => { annotatePocs(finalFindings, { routes: aR, fileContents: fc }); });
9570
+ await _runAnnotator("annotatePocs", () => { annotatePocs(finalFindings, { routes: aR, fileContents: fc }); });
9321
9571
  // FR-VER-3: regression-test generator (builds on the PoC artifact).
9322
- _runAnnotator("annotateRegressionTests", () => { annotateRegressionTests(finalFindings); });
9572
+ await _runAnnotator("annotateRegressionTests", () => { annotateRegressionTests(finalFindings); });
9323
9573
  // R2 — execution proof. Synthesizes a SANDBOX-RUNNABLE PoC (the HTTP PoCs
9324
9574
  // above need a live server, so they can never be executed by the prover)
9325
9575
  // and lets R1's sandbox decide the tier. Opt-in via AGENTIC_SECURITY_PROVE=1
@@ -9332,7 +9582,7 @@ function _deterministicFileTimings(timings) {
9332
9582
  // Never a finding and never a severity change: those bugs are fixed, and a
9333
9583
  // historical fix is not evidence of a present defect. Opt-in because it
9334
9584
  // shells out to git over up to 500 commits.
9335
- _runAnnotator('annotateHistoricalRisk', () => {
9585
+ await _runAnnotator('annotateHistoricalRisk', () => {
9336
9586
  if (process.env.AGENTIC_SECURITY_ARCHAEOLOGY !== '1' || !scanRoot) return;
9337
9587
  _vulnHistory = mineVulnHistory(scanRoot);
9338
9588
  annotateHistoricalRisk(finalFindings, _vulnHistory);
@@ -9346,65 +9596,38 @@ function _deterministicFileTimings(timings) {
9346
9596
  // verifier verdict — verified-exploit (live PoC ran), verified-by-llm,
9347
9597
  // verified-sanitizer-absence, unverified-by-design, or cannot-verify.
9348
9598
  // Fail-closed: any error → cannot-verify, never a silent drop.
9349
- _runAnnotator("annotateVerifierVerdicts", () => { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); });
9599
+ await _runAnnotator("annotateVerifierVerdicts", () => { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); });
9350
9600
  // Cross-language taint (Sentinel-parity FR-DET-3) — five boundary types:
9351
9601
  // HTTP/REST via OpenAPI, gRPC via .proto, GraphQL via SDL, SQL/ORM
9352
9602
  // round-trip, and IaC → application-code reachability (FR-DET-4).
9353
9603
  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(_) {}
9604
+ // FR-101/FR-102 (assurance-hardening PRD): each of these 11 producers now
9605
+ // goes through the registered-producer collector (pipeline/producer-
9606
+ // collector.js) instead of a bare try/catch + push — a thrown exception
9607
+ // becomes a diagnostic in _annotatorErrors (previously silently swallowed
9608
+ // by `catch(_) {}` with no trace anywhere) and the collector stamps
9609
+ // producerId provenance on each finding.
9610
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-openapi', () => scanCrossLangOpenAPI(_allXlangFiles, finalFindings));
9611
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-grpc', () => scanCrossLangGrpc(_allXlangFiles, finalFindings));
9612
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-graphql', () => scanCrossLangGraphql(_allXlangFiles, finalFindings));
9613
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-orm', () => scanCrossLangOrm(_allXlangFiles, finalFindings));
9370
9614
  // Phase-1 next-gen P1.5 (FR-XSAT-4): cross-language taint via Kafka, SQS,
9371
9615
  // 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(_) {}
9616
+ collectProducerResult(finalFindings, _annotatorErrors, 'cross-lang-queues', () => scanCrossLangQueues(_allXlangFiles, finalFindings));
9617
+ collectProducerResult(finalFindings, _annotatorErrors, 'iac-reachability', () => scanIacReachability(_allXlangFiles, finalFindings));
9380
9618
  // 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(_) {}
9619
+ collectProducerResult(finalFindings, _annotatorErrors, 'iam-policy', () => scanIamPolicies(_allXlangFiles, finalFindings));
9385
9620
  // 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(_) {}
9621
+ collectProducerResult(finalFindings, _annotatorErrors, 'container-runtime', () => scanContainerRuntime(_allXlangFiles));
9390
9622
  // 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(_) {}
9623
+ collectProducerResult(finalFindings, _annotatorErrors, 'business-logic-v2', () => scanBusinessLogicV2(_allXlangFiles));
9395
9624
  // v3 next-gen: specification-mining drift detector (FR-LOGIC-8). Emits
9396
9625
  // findings for function-name-vs-body mismatches. Low confidence by default;
9397
9626
  // active-learning loop tunes per project.
9398
- try {
9399
- const sm = scanSpecificationDrift(_allXlangFiles);
9400
- if (sm && sm.length) finalFindings.push(...sm);
9401
- } catch(_) {}
9627
+ collectProducerResult(finalFindings, _annotatorErrors, 'specification-drift', () => scanSpecificationDrift(_allXlangFiles));
9402
9628
  // v3 next-gen: bounded concurrency-bug detector (FR-SEM-9). Heuristic only;
9403
9629
  // 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(_) {}
9630
+ collectProducerResult(finalFindings, _annotatorErrors, 'concurrency', () => scanConcurrency(_allXlangFiles));
9408
9631
  // FR-LOGIC-6: LLM-driven flow narration (template fallback when no LLM endpoint).
9409
9632
  try { await annotateNarration(finalFindings); }
9410
9633
  catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
@@ -9672,13 +9895,13 @@ function _deterministicFileTimings(timings) {
9672
9895
  // v3 next-gen: capture scan-level reports (counterfactual, threat model,
9673
9896
  // trust-boundary diagram, calibration-drift alarms). All best-effort.
9674
9897
  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); });
9898
+ await _runAnnotator("_v3.counterfactual", () => { _v3.counterfactual = runCounterfactual(finalFindings, fc); });
9899
+ await _runAnnotator("_v3.threatModel", () => { _v3.threatModel = buildThreatModel(finalFindings, fc); });
9900
+ await _runAnnotator("_v3.trustBoundaryDiagram", () => { _v3.trustBoundaryDiagram = buildTrustBoundaryDiagram(finalFindings, fc); });
9901
+ await _runAnnotator("_v3.calibrationDrift", () => { _v3.calibrationDrift = computeCalibrationDrift(scanRoot); });
9679
9902
  // v3 next-gen: why-fired provenance is captured LAST so it reflects the
9680
9903
  // final state of each finding after every other annotator has run.
9681
- _runAnnotator("annotateWhyFired", () => { annotateWhyFired(finalFindings, {}); });
9904
+ await _runAnnotator("annotateWhyFired", () => { annotateWhyFired(finalFindings, {}); });
9682
9905
  // SCA-SAST correlation: link SAST findings to SCA vulnerable packages
9683
9906
  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
9907
  // Multi-sink chain detection: group findings by source variable
@@ -9787,6 +10010,12 @@ function _deterministicFileTimings(timings) {
9787
10010
  // filesScanned feeds the vacuous-satisfaction guard: a clean signal
9788
10011
  // from a run that read no files is not evidence of compliance.
9789
10012
  filesScanned: files.length,
10013
+ // FR-405: feeds the SAME guard, specifically for controls whose
10014
+ // only mapped signal comes from privacy-taint — a non-IR-backed
10015
+ // run (deep mode off, the common default) still emits a "no
10016
+ // findings" pass, which is not real evidence for those controls
10017
+ // even though the scan otherwise examined real files.
10018
+ privacyIrBacked: _privacyIrBacked,
9790
10019
  });
9791
10020
  if (_privacyFramework) persistPrivacyFramework(scanRoot, _privacyFramework);
9792
10021
  if (_privacyFramework && process.env.AGENTIC_SECURITY_PRIVACY_FRAMEWORK === '1') {
@@ -9812,6 +10041,34 @@ function _deterministicFileTimings(timings) {
9812
10041
  }
9813
10042
  }
9814
10043
 
10044
+ // FR-103 (assurance-hardening PRD): canonical enrichment completion pass.
10045
+ // This is the LAST point in the function where a finding could still be
10046
+ // missing stableId/confidence/calibration/exploitability because it was
10047
+ // appended by a late producer (see pipeline/enrichment-completion.js's
10048
+ // header for why this is a gap-filling pass rather than relocating ~19
10049
+ // append sites earlier in this function). Must run after every push site
10050
+ // above and before the freeze immediately below.
10051
+ const _projectCtxForCompletion = typeof _projectCtx !== 'undefined' ? _projectCtx : {};
10052
+ completeEnrichment(finalFindings, { scanRoot, projectCtx: _projectCtxForCompletion });
10053
+
10054
+ // FR-104 (assurance-hardening PRD): freeze the finding collection.
10055
+ // Object.freeze on an array blocks push/pop/splice/length changes (this
10056
+ // module and every caller downstream run under ESM strict mode, so a
10057
+ // violation THROWS a TypeError rather than silently no-op'ing) — no
10058
+ // producer may append after this point. This is a SHALLOW freeze: the
10059
+ // remaining read/annotate calls below (countUnmodeledSinkCandidates,
10060
+ // buildEntrypointInventory, annotateRelevance, sweepRootCauses,
10061
+ // proofCoverage) either only read `finalFindings` or, for annotateRelevance,
10062
+ // legitimately mutate FIELDS on individual finding objects — verified by
10063
+ // reading each function's body (none push/splice/pop/shift/unshift onto
10064
+ // the array they're handed). Field mutation is annotation, not production,
10065
+ // and annotateRelevance running this late is an established, deliberate
10066
+ // part of this codebase's architecture (its own header: "runs after every
10067
+ // finding has been appended... so nothing escapes annotation"). What
10068
+ // freezing forecloses is exactly what FR-104 asks for: no MORE findings
10069
+ // can be added after this point.
10070
+ Object.freeze(finalFindings);
10071
+
9815
10072
  // Coverage-honesty report (#5 + #6): per-language analysis tier (IR-taint vs
9816
10073
  // pattern-only), dense/large/timeout skips, and unmodeled-sink candidates.
9817
10074
  let _analysisTier = null, _unmodeledSinks = null;
@@ -9826,7 +10083,7 @@ function _deterministicFileTimings(timings) {
9826
10083
  // seen when only N-of-those-candidates were actually analyzed.
9827
10084
  // checkpoint.total intentionally keeps files.length — that field means the
9828
10085
  // 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}};
10086
+ 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,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
10087
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
9831
10088
  // it. Anything that threw before this point leaves it in place to resume from.
9832
10089
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
@@ -9838,7 +10095,7 @@ function _deterministicFileTimings(timings) {
9838
10095
  // attack surface it is scored against is the complete one. Recall-
9839
10096
  // preserving: never removes a finding, never touches severity, and only
9840
10097
  // asserts `unreachable` on positive evidence (see posture/relevance.js).
9841
- _runAnnotator("annotateRelevance", () => {
10098
+ await _runAnnotator("annotateRelevance", () => {
9842
10099
  annotateRelevance(finalFindings, {
9843
10100
  fileContents: fc,
9844
10101
  entrypointInventory: _entrypointInventory,
@@ -9855,7 +10112,37 @@ function _deterministicFileTimings(timings) {
9855
10112
  // is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
9856
10113
  let _proofCoverage = null;
9857
10114
  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};}
10115
+ // FR-203: per-file/per-analyzer coverage ledger, computed from exactly
10116
+ // the signals FR-201 (_detectorErrors) and FR-202 (the _timeout:true
10117
+ // marker finding) already produce -- files actually scanned come from
10118
+ // fc's own keys (skipped-for-size/density files were never added to it).
10119
+ const _timedOutFiles = finalFindings.filter(f => f && f._timeout === true).map(f => f.file);
10120
+ const _coverageLedger = computeCoverageLedger({ files: Object.keys(fc), detectorErrors: _detectorErrors, timedOutFiles: _timedOutFiles });
10121
+ // FR-206 (assurance-hardening PRD, Milestone 0): additive scan-health
10122
+ // summary, computed from signals the engine already collects.
10123
+ // `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
10124
+ // until FR-203's coverage ledger existed to compute it for real.
10125
+ let _scanHealth = computeScanHealth({
10126
+ scanMeta: _scanMeta,
10127
+ annotatorErrors: _annotatorErrors,
10128
+ engineErrors: { cppDataflowParseErrors: _cppDataflowParseErrors.value },
10129
+ deepStatus: _deepStatus,
10130
+ analyzerCoverage: summarizeCoverageForScanHealth(_coverageLedger),
10131
+ });
10132
+ // FR-207: stale vulnerability feeds, calibration data, and compliance
10133
+ // evidence are real assurance gaps, not just findings the feed omits --
10134
+ // surfaced the same way every other scan-health condition is, so
10135
+ // --assurance strict (FR-204) can fail on them. Custom-rule-pack
10136
+ // freshness is the one leg NOT computed here: that mechanism only runs
10137
+ // in bin/agentic-security.js, after this scan object already exists (see
10138
+ // applyFreshness's own header comment in pipeline/scan-health.js).
10139
+ _scanHealth = applyFreshness(_scanHealth, {
10140
+ kev: kevCatalogMeta(),
10141
+ epss: epssLiveMeta(),
10142
+ calibration: calibrationFreshness(),
10143
+ compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
10144
+ });
10145
+ 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
10146
 
9860
10147
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
9861
10148
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.