@clear-capabilities/agentic-security-scanner 0.144.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/bin/agentic-security.js +294 -3
  3. package/dist/113.index.js +11 -3
  4. package/dist/178.index.js +24 -6
  5. package/dist/271.index.js +165 -0
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +22 -0
  8. package/dist/444.index.js +11 -2
  9. package/dist/449.index.js +76 -12
  10. package/dist/526.index.js +11 -3
  11. package/dist/637.index.js +27 -5
  12. package/dist/970.index.js +65 -1
  13. package/dist/agentic-security.mjs +9 -9
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +14 -8
  16. package/src/compare.js +6 -1
  17. package/src/dataflow/CLAUDE.md +1 -1
  18. package/src/engine.js +488 -29
  19. package/src/fix/apply-fix-service.js +1 -0
  20. package/src/history-scan.js +22 -5
  21. package/src/ir/CLAUDE.md +1 -1
  22. package/src/lsp/server.js +49 -2
  23. package/src/mcp/tools.js +20 -0
  24. package/src/pipeline/assurance-mode.js +64 -1
  25. package/src/pipeline/finding-schema.js +8 -1
  26. package/src/posture/CLAUDE.md +121 -0
  27. package/src/posture/accuracy-scorecard.js +60 -0
  28. package/src/posture/artifact-registry.js +24 -0
  29. package/src/posture/auditor-walkthrough.js +116 -13
  30. package/src/posture/compliance-policy.js +12 -2
  31. package/src/posture/cross-repo-memory.js +7 -2
  32. package/src/posture/fix-history.js +25 -2
  33. package/src/posture/fix-verify.js +9 -1
  34. package/src/posture/fleet.js +0 -0
  35. package/src/posture/git-history.js +13 -5
  36. package/src/posture/material-change.js +21 -2
  37. package/src/posture/mttr.js +75 -12
  38. package/src/posture/pre-incident-archaeology.js +39 -7
  39. package/src/posture/privacy-framework.js +14 -0
  40. package/src/posture/provenance/ai-authorship.js +68 -0
  41. package/src/posture/provenance/branch-entry.js +80 -0
  42. package/src/posture/provenance/cache.js +143 -0
  43. package/src/posture/provenance/confidence.js +36 -0
  44. package/src/posture/provenance/coordinator.js +786 -0
  45. package/src/posture/provenance/dag-walk.js +249 -0
  46. package/src/posture/provenance/evidence-attribution.js +59 -0
  47. package/src/posture/provenance/git-evidence.js +310 -0
  48. package/src/posture/provenance/lifecycle.js +208 -0
  49. package/src/posture/provenance/missing-control-resolver.js +137 -0
  50. package/src/posture/provenance/origin-resolver.js +342 -0
  51. package/src/posture/provenance/predicate-replay.js +133 -0
  52. package/src/posture/provenance/providers/config.js +39 -0
  53. package/src/posture/provenance/providers/github.js +62 -0
  54. package/src/posture/provenance/providers/gitlab.js +58 -0
  55. package/src/posture/provenance/repo-lineage.js +74 -0
  56. package/src/posture/provenance/sca-origin.js +139 -0
  57. package/src/posture/provenance/schema.js +255 -0
  58. package/src/posture/provenance/transitive-sca.js +147 -0
  59. package/src/posture/provenance/validate.js +30 -0
  60. package/src/posture/provenance-evidence-bundle.js +144 -0
  61. package/src/posture/sbom-diff.js +15 -2
  62. package/src/posture/secret-history.js +10 -2
  63. package/src/posture/state-dir.js +38 -14
  64. package/src/posture/vuln-archaeology.js +8 -2
  65. package/src/pr-delta.js +25 -4
  66. package/src/report/index.js +197 -3
  67. package/src/runScan.js +34 -5
  68. package/src/sast/rate-limit.js +33 -3
  69. package/src/util/git-hardening.js +128 -0
package/src/engine.js CHANGED
@@ -260,6 +260,17 @@ import { annotateAttackTaxonomy, summarizeTaxonomy } from './posture/attack-taxo
260
260
  import { suppressByPastDecisions } from './posture/triage-memory.js';
261
261
  import { suppressByIntent } from './posture/intent-context.js';
262
262
  import { annotateGitHistory } from './posture/git-history.js';
263
+ // NOT `annotateProvenance` (sca/sigstore-verify.js's build-attestation
264
+ // annotator) and NOT `annotateFindingProvenance` (posture/provenance.js's
265
+ // AI-code fingerprint annotator). Both are already imported above in this
266
+ // file, so either name here is a duplicate binding — a SyntaxError — and the
267
+ // second is worse still because it also takes a findings array as its first
268
+ // argument, so a wrong import would RUN rather than fail. This one is named
269
+ // for the mechanism that distinguishes it —
270
+ // provenance derived from GIT HISTORY. See provenance/coordinator.js's header.
271
+ import { annotateGitProvenance, PROVENANCE_DEFAULT_TIMEOUT_MS, MAX_PROVIDER_ENRICHMENTS_PER_SCAN } from './posture/provenance/coordinator.js';
272
+ import { updateLifecycle } from './posture/provenance/lifecycle.js';
273
+ import { emptyProvenance, PROVENANCE_STATUS } from './posture/provenance/schema.js';
263
274
  import { applyThreatModel } from './posture/threat-model-grounding.js';
264
275
  import { annotateCrossRepoSignals } from './posture/pattern-propagation.js';
265
276
  import { annotateRiskDollars } from './posture/risk-dollars.js';
@@ -2626,6 +2637,33 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
2626
2637
  // so pfr[p] and the aggregates share object identity, exactly as in a normal run.
2627
2638
  function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
2628
2639
  function _getSuppressions(){ return [..._suppressionLog]; }
2640
+ // Task 11 reentrancy fix: `_suppressionLog` is module-level and unconditionally
2641
+ // cleared by `_resetSuppressions()` at the top of every `runFullScan` call.
2642
+ // `predicate-replay.js`'s `replayAt` calls `runFullScan` recursively FROM
2643
+ // WITHIN an outer, still-running scan's provenance resolution (to replay a
2644
+ // finding's predicate at a historical commit) -- before Task 11 wired
2645
+ // scan.secrets/scan.logicVulns into real provenance resolution, that recursive
2646
+ // call was only ever reachable from scan.findings/SCA origin walks, which this
2647
+ // exact fixture (test/fixtures/entropy-fp) never triggered. Wiring secrets in
2648
+ // exposed it for the first time: the nested call's `_resetSuppressions()`
2649
+ // silently wiped the OUTER scan's suppression log before its own return
2650
+ // statement read it via `_getSuppressions()`, so `scan.suppressions` came back
2651
+ // empty for anything that happened to walk deep git history.
2652
+ //
2653
+ // A plain snapshot/restore around ONE `replayAt` call is not sufficient on its
2654
+ // own: `coordinator.js` resolves several findings' origins CONCURRENTLY (its
2655
+ // own comment: "the scheduler runs these four at a time"), and each finding's
2656
+ // resolveOrigin walk can call `replayAt` multiple times sequentially -- so two
2657
+ // DIFFERENT findings' replay calls can be in flight at once, interleaved at
2658
+ // `runFullScan`'s own internal await points. Two overlapping snapshot/restore
2659
+ // pairs racing on the same global array means whichever restores last wins,
2660
+ // discarding whatever the other legitimately wrote in between. Exported so
2661
+ // predicate-replay.js can snapshot/restore its own call boundary AND serialize
2662
+ // that boundary process-wide (see its own comment on the exclusivity queue) --
2663
+ // the nested scan's own suppression output is never read by replayAt, so
2664
+ // nothing is lost by discarding it.
2665
+ function _snapshotSuppressionLog(){ return _suppressionLog.slice(); }
2666
+ function _restoreSuppressionLog(saved){ _suppressionLog.length = 0; if (Array.isArray(saved)) _suppressionLog.push(...saved); }
2629
2667
 
2630
2668
  // ── inline suppression pragma ───────────────────────────────────────────────
2631
2669
  //
@@ -7340,6 +7378,31 @@ function _makePurl(ecosystem,name,version,group){
7340
7378
  return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
7341
7379
  }
7342
7380
 
7381
+ function _findManifestLine(text, sectionKey, depName) {
7382
+ const lines = text.split('\n');
7383
+ let inSection = false;
7384
+ let depth = 0;
7385
+ const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7386
+ const nameRe = new RegExp(`"${escaped}"\\s*:`);
7387
+ for (let i = 0; i < lines.length; i++) {
7388
+ const line = lines[i];
7389
+ if (!inSection) {
7390
+ if (new RegExp(`"${sectionKey}"\\s*:\\s*\\{`).test(line)) {
7391
+ inSection = true;
7392
+ depth = 1 + (line.match(/\{/g) || []).length - 1 - (line.match(/\}/g) || []).length;
7393
+ if (nameRe.test(line)) return i + 1;
7394
+ if (depth <= 0) inSection = false;
7395
+ }
7396
+ continue;
7397
+ }
7398
+ depth += (line.match(/\{/g) || []).length;
7399
+ depth -= (line.match(/\}/g) || []).length;
7400
+ if (depth <= 0) { inSection = false; continue; }
7401
+ if (nameRe.test(line)) return i + 1;
7402
+ }
7403
+ return null;
7404
+ }
7405
+
7343
7406
  function _parsePackageJson(text,filePath){
7344
7407
  const out=[];try{const d=JSON.parse(text);
7345
7408
  for(const[depKey,scope]of[['dependencies','required'],['devDependencies','optional']]){
@@ -7350,7 +7413,8 @@ function _parsePackageJson(text,filePath){
7350
7413
  const group=scoped?`@${parts[0]}`:'';
7351
7414
  const pkgName=scoped?parts[1]:name;
7352
7415
  out.push({name,version:ver,group,scope,purl:_makePurl('npm',pkgName,ver,group),ecosystem:'npm',filePath,
7353
- isUnpinned:verRange==='*'||verRange==='latest'||verRange===''||verRange==='>=0.0.0'});
7416
+ isUnpinned:verRange==='*'||verRange==='latest'||verRange===''||verRange==='>=0.0.0',
7417
+ line:_findManifestLine(text,depKey,name)});
7354
7418
  }
7355
7419
  }
7356
7420
  }catch(_){}return out;
@@ -7379,12 +7443,13 @@ function _parsePackageLockJson(text,filePath){
7379
7443
 
7380
7444
  function _parseRequirementsTxt(text,filePath){
7381
7445
  const out=[];
7382
- for(const line of text.split('\n')){
7383
- const t=line.trim();
7446
+ const lines=text.split('\n');
7447
+ for(let i=0;i<lines.length;i++){
7448
+ const t=lines[i].trim();
7384
7449
  if(!t||t.startsWith('#')||t.startsWith('-'))continue;
7385
7450
  const m=t.match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
7386
7451
  if(m)out.push({name:m[1],version:m[2],group:'',scope:'required',
7387
- purl:_makePurl('pypi',m[1].toLowerCase(),m[2],''),ecosystem:'pypi',filePath,isUnpinned:false});
7452
+ purl:_makePurl('pypi',m[1].toLowerCase(),m[2],''),ecosystem:'pypi',filePath,isUnpinned:false,line:i+1});
7388
7453
  }return out;
7389
7454
  }
7390
7455
 
@@ -8180,6 +8245,15 @@ async function queryOSV(components,allFileContents){
8180
8245
  fixedVersions: vuln.fixedVersions, severity: vuln.severity, cvssVector: vuln.cvssVector,
8181
8246
  hasKnownAttackRef: vuln.hasKnownAttackRef, osvVulnFunctions: vuln.osvVulnFunctions || [], reachable: comp.reachable, scope: comp.scope,
8182
8247
  file: comp.filePath,
8248
+ // `isDirect` is backfilled onto every component just above the queryOSV
8249
+ // call, but was never carried onto the entry materialized from it — so
8250
+ // every consumer asking "is this a direct dependency" got `undefined`.
8251
+ // The transitive-dedup block's `group.find(s => s.isDirect)` has
8252
+ // therefore always fallen through to `group[0]`, picking an arbitrary
8253
+ // member as the primary instead of the direct one, and the provenance
8254
+ // pass's direct-only filter had nothing to filter on. `line` is Task
8255
+ // 12's declaration line, which the SCA provenance evidence node reads.
8256
+ isDirect: comp.isDirect, line: comp.line, depChain: Array.isArray(comp.depChain) ? comp.depChain.map((s) => s.replace(/\/$/, '')) : [],
8183
8257
  // kept for generateRecs() compat
8184
8258
  advisory: `${vid}${cveStr}, ${vuln.description}`,
8185
8259
  range: fixStr ? `< ${fixStr}` : 'see advisory' });
@@ -8526,7 +8600,28 @@ async function queryRegistries(components){
8526
8600
  return {content:c, pfr:ta, routes:_aR, findings:_aF, sources:_aSrc, sinks:_aSink, sanitizers:_aSan, logic:_aLogic, secrets:_aSecrets, ciphersRest:_aCiphersRest, ciphersTransit:_aCiphersTransit, suppressions:_aSupp};
8527
8601
  }
8528
8602
 
8529
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8603
+ // `provenance:false` is the RE-ENTRANCY BRAKE, not a feature flag.
8604
+ // posture/provenance/predicate-replay.js answers "did this finding's condition
8605
+ // hold at commit X" by calling runFullScan back on that commit's blobs. Once
8606
+ // runFullScan itself runs the provenance pass, that is an unbounded recursion —
8607
+ // scan → provenance → replay → scan → … — which manifests as a scan that never
8608
+ // returns and spawns `git` forever, because every level of it is synchronous
8609
+ // execFileSync work. The replay's findings are discarded, so it wants no
8610
+ // provenance anyway; and it must NOT touch the lifecycle store, whose events
8611
+ // would otherwise be written from historical blobs as if they were this scan.
8612
+ // Passed explicitly per invocation rather than held in a module-level guard so
8613
+ // concurrent scans in one process cannot disable each other's provenance.
8614
+ //
8615
+ // `completeScan` is a SEPARATE question from `provenance`, and conflating them
8616
+ // is what let the fourth instance of this bug through. `provenance:false` says
8617
+ // "do not run the pass at all"; `completeScan:false` says "the pass may run,
8618
+ // but this file set is a SUBSET of scanRoot, so absence of a finding proves
8619
+ // nothing." Only the lifecycle ledger's remediation pass reads it — that is the
8620
+ // one place a finding's absence is turned into a positive claim. Defaults true
8621
+ // because a direct runFullScan caller supplying no file-subsetting options is
8622
+ // scanning everything it was given; runScan.js narrows it for --changed-since
8623
+ // and for caller-supplied fileContents.
8624
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8530
8625
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8531
8626
  // analysis can recognize calls to user-input-returning helper methods
8532
8627
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -9276,9 +9371,76 @@ function _deterministicFileTimings(timings) {
9276
9371
  // Every catch in this block writes into _annotatorErrors so the operator
9277
9372
  // can tell "didn't run" from "ran cleanly." The array is surfaced as
9278
9373
  // scan.annotatorErrors in the report; an empty array means clean.
9374
+ //
9375
+ // FR-PROV-029 (Finding Provenance PRD): `skipAnnotators` lets a caller skip
9376
+ // this entire ~54-annotator pipeline below. The guard also covers
9377
+ // non-annotator finalization that lives in the same block — entropy-vs-
9378
+ // named secret dedup, orphan classification, supply-chain in-place
9379
+ // filtering, `Object.freeze(finalFindings)`, and closing the resume
9380
+ // checkpoint — so a skipped run leaves all of that undone too; name the
9381
+ // option accordingly if a future caller needs annotators skipped WITHOUT
9382
+ // skipping those steps. predicate-replay.js's `replayAt()` is the only
9383
+ // caller that sets it — it re-runs runFullScan scoped to a historical
9384
+ // commit's blob content purely to recompute `computeStableId()` over the
9385
+ // raw detector output (`scan.findings`/`scan.secrets`); it never reads
9386
+ // anything an annotator sets (verified empirically — see the commit
9387
+ // message). Every binding the pipeline below
9388
+ // populates that the final `return` still references is declared here,
9389
+ // OUTSIDE the guarded block, defaulted to exactly what it was before any
9390
+ // annotator ran. A skipped run returns those fields at their
9391
+ // pre-annotation default instead of throwing a ReferenceError; a normal
9392
+ // (non-skipping) run is byte-for-byte unaffected, since the guarded block
9393
+ // below still assigns the same values to these same bindings — it just no
9394
+ // longer *declares* them, so nothing here changes what a value ends up
9395
+ // being, only where the variable comes into scope.
9279
9396
  let _executionProofSummary = null, _vulnHistory = null;
9280
9397
  let _logicClaims = null;
9281
- const _annotatorErrors = [];
9398
+ let _annotatorErrors = [];
9399
+ let _v3 = {};
9400
+ let _privacyIrBacked = null;
9401
+ let _privacyTaxonomyVersion = null;
9402
+ let _privacyFramework = null;
9403
+ let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
9404
+ _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
9405
+ _licenseGraph = null, _attributions = null, _taxonomySummary = null;
9406
+ let _scanMeta = null;
9407
+ let _entrypointInventory = {};
9408
+ let _rootCauseSweep = null;
9409
+ let _proofCoverage = null;
9410
+ let _coverageLedger = null;
9411
+ let _scanHealth = null;
9412
+ // Task 11 (PRD P0 scope): ruleId backfill for scan.secrets / blameable
9413
+ // scan.logicVulns findings MUST run unconditionally, HERE, outside the
9414
+ // `skipAnnotators` guard below -- not just because the live scan needs it,
9415
+ // but because predicate-replay.js's replayAt() recurses into THIS function
9416
+ // with skipAnnotators:true and recomputes computeStableId() directly on
9417
+ // whatever it finds in the nested scan's own scan.secrets/scan.logicVulns.
9418
+ // If the backfill only ran on the live (skipAnnotators:false) call, the
9419
+ // nested replay scan would compute a DIFFERENT stableId (falling back to
9420
+ // the shared f.cwe -- e.g. every secret type collapsing onto "CWE-798")
9421
+ // than the live scan's already-backfilled finding, so replayAt's
9422
+ // `sid === targetStableId` check would NEVER match -- permanently landing
9423
+ // every secrets/logicVulns finding on status:'partial',
9424
+ // reason:'predicate-never-confirmed-in-candidates'. Caught empirically:
9425
+ // test/fixtures/entropy-fp's AWS-key fixture resolved 'partial' instead of
9426
+ // 'complete' until this moved here from inside the (skipAnnotators-gated)
9427
+ // provenance block further down.
9428
+ //
9429
+ // At this point in the function, aSecrets/aLogic hold every BLAMEABLE
9430
+ // producer's output (scanCredentials/scanEntropySecrets;
9431
+ // scanLogicVulns/scanBusinessLogic/scanMiddlewareOrdering/scanReDoS/
9432
+ // scanRegexReDoS/scanTodosNearSecurity/scanConfigFiles) -- the 3 synthetic
9433
+ // producers (license-policy:/deploy-platform:/stack-playbook:) and
9434
+ // logic-claims.js's ingested claims are pushed LATER, inside the
9435
+ // `skipAnnotators` guard below, so they are never present in a nested
9436
+ // replay scan's aLogic and never need this backfill for replay-matching
9437
+ // purposes. The provenance block further down re-applies this same
9438
+ // idempotent backfill to the full, final `blameableLogic` (which by then
9439
+ // includes logic-claims too) before calling annotateGitProvenance on it.
9440
+ const _slugify = (s) => String(s || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
9441
+ for (const f of aSecrets) { if (!f.ruleId) f.ruleId = `secret:${_slugify(f.vuln)}`; }
9442
+ for (const f of aLogic) { if (!f.ruleId) f.ruleId = `logic:${_slugify(f.vuln)}`; }
9443
+ if (!skipAnnotators) {
9282
9444
  // FR-106 (assurance-hardening PRD): Promise-aware, explicitly awaited at
9283
9445
  // every one of its ~51 call sites below (previously a sync `try{return
9284
9446
  // fn()}` let an async callback's rejection escape as an unhandled
@@ -9384,18 +9546,18 @@ function _deterministicFileTimings(timings) {
9384
9546
  // FR-405 (assurance-hardening PRD): null means "privacy analysis never
9385
9547
  // ran at all" (AGENTIC_SECURITY_NO_PRIVACY=1, or the annotator threw
9386
9548
  // 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;
9549
+ // since neither case has real IR-backed evidence to offer. Declared at
9550
+ // function scope (now hoisted above the `skipAnnotators` guard, near
9551
+ // _executionProofSummary et al. same reasoning: FR-PROV-029), because
9552
+ // the annotatePrivacyTaint closure that assigns to it (inside the
9553
+ // AGENTIC_SECURITY_NO_INTEGRATION block below) runs and exits before that
9554
+ // block closes a block-scoped `let` inside that if-statement would be
9555
+ // unreachable by the later assessPrivacyFramework call and the final
9556
+ // return, both of which are outside the block.
9557
+ //
9558
+ // FR-402: _privacyTaxonomyVersion (which taxonomy version actually
9559
+ // classified this scan's fields) has the same scoping constraint as
9560
+ // _privacyIrBacked directly above (D-0011) and is hoisted alongside it.
9399
9561
 
9400
9562
  // ── World-class integration block ─────────────────────────────────────
9401
9563
  // Each annotator is opt-in via env var and try/catch wrapped. They run
@@ -9894,7 +10056,7 @@ function _deterministicFileTimings(timings) {
9894
10056
  classifyOrphans(aSrc,aSink,finalFindings,fc);
9895
10057
  // v3 next-gen: capture scan-level reports (counterfactual, threat model,
9896
10058
  // trust-boundary diagram, calibration-drift alarms). All best-effort.
9897
- let _v3 = {};
10059
+ // (_v3 is hoisted above the `skipAnnotators` guard — FR-PROV-029.)
9898
10060
  await _runAnnotator("_v3.counterfactual", () => { _v3.counterfactual = runCounterfactual(finalFindings, fc); });
9899
10061
  await _runAnnotator("_v3.threatModel", () => { _v3.threatModel = buildThreatModel(finalFindings, fc); });
9900
10062
  await _runAnnotator("_v3.trustBoundaryDiagram", () => { _v3.trustBoundaryDiagram = buildTrustBoundaryDiagram(finalFindings, fc); });
@@ -9912,10 +10074,9 @@ function _deterministicFileTimings(timings) {
9912
10074
  // Each is opt-in via env var. They produce machine-readable artifacts
9913
10075
  // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
9914
10076
  // sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
9915
- let _privacyFramework = null;
9916
- let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
9917
- _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
9918
- _licenseGraph = null, _attributions = null, _taxonomySummary = null;
10077
+ // (_privacyFramework/_threatModel/_apiContractFindings/_sbomDiff/
10078
+ // _complianceReport/_exploitBundles/_pqcPlan/_licenseGraph/_attributions/
10079
+ // _taxonomySummary are hoisted above the `skipAnnotators` guard — FR-PROV-029.)
9919
10080
  if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
9920
10081
  // Threat model — STRIDE + entities + attack trees rooted in findings.
9921
10082
  if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL !== '1') {
@@ -10083,12 +10244,307 @@ function _deterministicFileTimings(timings) {
10083
10244
  // seen when only N-of-those-candidates were actually analyzed.
10084
10245
  // checkpoint.total intentionally keeps files.length — that field means the
10085
10246
  // full candidate set for resume bookkeeping, a different, correct meaning.
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}};
10247
+ _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
10087
10248
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
10088
10249
  // it. Anything that threw before this point leaves it in place to resume from.
10089
10250
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
10251
+ // Finding Provenance (M0/M1) — attaches `finding.findingProvenance`.
10252
+ //
10253
+ // Placed HERE, not immediately after the SCA/multi-sink correlation blocks
10254
+ // where the plan first put it, for the same reason `annotateRelevance` below
10255
+ // runs this late: several post-scan artifact emitters between those blocks
10256
+ // and this point still PUSH findings (`runApiContractScan`, `runSbomDiff`,
10257
+ // the cross-language chain passes). Annotating before them would have left
10258
+ // every finding they produce with no `findingProvenance` at all, which is
10259
+ // precisely the "absent field" state the coordinator's terminal-status
10260
+ // guarantee exists to make unreachable. Running after `Object.freeze` above
10261
+ // is safe and is in fact the point: the finding SET can no longer change, and
10262
+ // a shallow freeze still permits annotating FIELDS on the individual finding
10263
+ // objects. This annotator never appends or drops a finding.
10264
+ if (provenance !== false) {
10265
+ // Task 11 (PRD P0 scope): real origin resolution for scan.secrets and
10266
+ // blameable scan.logicVulns findings. Both channels were previously
10267
+ // stamped an unconditional not_available (see the backstop loop below) —
10268
+ // not because computeStableId can't handle them (its ruleId() fallback
10269
+ // chain already tolerates a missing f.ruleId), but because nobody
10270
+ // backfilled a real per-pattern ruleId and nobody called
10271
+ // annotateGitProvenance on these channels at all. scan.secrets findings
10272
+ // set neither ruleId nor family nor parser, and every secret type shares
10273
+ // the same fixed f.cwe ("CWE-798"), so without a per-pattern backfill
10274
+ // every secret in a scan would collide onto ONE stableId.
10275
+ //
10276
+ // scan.logicVulns is not one detector's output — three of its ~9
10277
+ // producers (license-policy:, deploy-platform:, stack-playbook:) use a
10278
+ // FIXED PLACEHOLDER `line` (0 or 1), not a real diffable source location
10279
+ // — they read scanRoot-level files (package.json, vercel.json, ...)
10280
+ // directly rather than from the scanned fileContents. Routing those
10281
+ // through git-blame-style resolution would fabricate a plausible-looking
10282
+ // but meaningless commit attribution (e.g. "package.json line 1" blamed
10283
+ // on whatever commit last touched that line, unrelated to the actual
10284
+ // license/platform/stack finding). They are excluded by id prefix here
10285
+ // and stay on the honest, PERMANENT not_available path in the backstop
10286
+ // loop below — never routed through resolveOrigin. Declared here, OUTSIDE
10287
+ // the _runAnnotator callback below, because the backstop loop (also
10288
+ // outside that callback — see its own comment on why) needs
10289
+ // `blameableLogic`/`syntheticLogic` too.
10290
+ //
10291
+ // `aSecrets`/`aLogic`'s ruleId backfill for the producers that exist by
10292
+ // this point in the function already happened much earlier (right before
10293
+ // the `skipAnnotators` guard opens — see that comment for why it CANNOT
10294
+ // live here alone). `_slugify` is declared there and is in scope here too.
10295
+ // What's re-applied below is only the SAME idempotent backfill
10296
+ // (`if (!f.ruleId)`), now over the full, final `blameableLogic` — which by
10297
+ // this point additionally includes logic-claims.js's late-pushed ingested
10298
+ // claims, the one blameable producer the early pass could not see.
10299
+ const SYNTHETIC_LOGIC_PREFIXES = ['license-policy:', 'deploy-platform:', 'stack-playbook:'];
10300
+ const isSyntheticLogicFinding = (f) => typeof f?.id === 'string'
10301
+ && SYNTHETIC_LOGIC_PREFIXES.some((p) => f.id.startsWith(p));
10302
+ const blameableLogic = aLogic.filter((f) => !isSyntheticLogicFinding(f));
10303
+ const syntheticLogic = aLogic.filter(isSyntheticLogicFinding);
10304
+ for (const f of blameableLogic) {
10305
+ if (!f.ruleId) f.ruleId = `logic:${_slugify(f.vuln)}`;
10306
+ }
10307
+ annotateStableIds(aSecrets);
10308
+ annotateStableIds(blameableLogic);
10309
+ await _runAnnotator("annotateGitProvenance", async () => {
10310
+ // ONE deadline for the whole scan's provenance work, computed here and
10311
+ // threaded into all five annotateGitProvenance calls below. Computed per call
10312
+ // inside the coordinator, the effective scan-level budget was 2× the
10313
+ // configured --provenance-timeout: the SAST pass got a fresh window and
10314
+ // then the SCA pass got another one, so an operator asking for a 30s cap
10315
+ // could wait 60s. The spec describes a single global deadline; this is
10316
+ // where "global" has to be established, because this is the only scope
10317
+ // that sees both passes.
10318
+ const provenanceTimeoutMs = process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS
10319
+ ? parseInt(process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS, 10) : undefined;
10320
+ // Deterministic mode promises byte-identical SARIF run-to-run
10321
+ // (posture/deterministic.js), and `observedAt` above is already frozen to
10322
+ // honour that. But `findingProvenance.status` (complete/partial/
10323
+ // budget_exhausted/...) is driven by THIS deadline, which was computed
10324
+ // from a real `Date.now()` even under --deterministic — so two runs of
10325
+ // the identical scan could cross a 60s budget at different points under
10326
+ // machine contention (git subprocess calls slowed by CPU/IO pressure) and
10327
+ // land different findings on `budget_exhausted` vs a resolved status,
10328
+ // changing the emitted SARIF between runs. An explicit
10329
+ // --provenance-timeout / AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS still wins
10330
+ // (an operator asking for a tight budget gets it regardless of mode);
10331
+ // absent that, deterministic mode gets a much larger fixed ceiling so
10332
+ // resolution has room to finish under realistic load instead of a bound
10333
+ // that's routinely crossed — see test/proof-corpus-lib.test.js's
10334
+ // "produces byte-identical SARIF across two runs".
10335
+ const DETERMINISTIC_PROVENANCE_TIMEOUT_MS = 300000; // 5 minutes
10336
+ const provenanceDeadlineAt = Date.now()
10337
+ + (Number.isFinite(provenanceTimeoutMs) && provenanceTimeoutMs > 0
10338
+ ? provenanceTimeoutMs
10339
+ : (isDeterministic() ? DETERMINISTIC_PROVENANCE_TIMEOUT_MS : PROVENANCE_DEFAULT_TIMEOUT_MS));
10340
+ const provenanceCtx = {
10341
+ scanRoot,
10342
+ deadlineAt: provenanceDeadlineAt,
10343
+ // CLI flags that set these land in Task 17; reading the env directly here
10344
+ // mirrors AGENTIC_SECURITY_NO_GIT_HISTORY's existing pattern in this file.
10345
+ disabled: process.env.AGENTIC_SECURITY_NO_PROVENANCE === '1',
10346
+ scanId: process.env.AGENTIC_SECURITY_SCAN_ID || null,
10347
+ // Frozen under --deterministic so SARIF (which now carries
10348
+ // findingProvenance.firstObserved.observedAt — see report/index.js's
10349
+ // toSARIF) stays byte-identical run-to-run, matching the exact
10350
+ // convention posture/deterministic.js's makeDeterministic() already
10351
+ // uses for meta.startedAt. This value predates that guarantee; it was
10352
+ // invisible before findingProvenance reached any output format.
10353
+ observedAt: isDeterministic() ? '1970-01-01T00:00:00.000Z' : new Date().toISOString(),
10354
+ // FR-PROV-028 / "Evidence integrity": the cache key (coordinator.js's
10355
+ // `makeCacheKey`) and `computeDigest`'s `rulesetVersion` binding both
10356
+ // read this field, so it has to be the REAL effective ruleset version,
10357
+ // not an env var operators essentially never set. `_effectiveRulesetVersion`
10358
+ // (posture/ruleset-version.js's `effectiveVersion`) already resolves
10359
+ // env override > pinned file > CURRENT_RULESET_VERSION (== the running
10360
+ // scanner's own package version) — the same helper the checkpoint
10361
+ // identity above (`_ckptIdentity.rulesetVersion`) already calls for an
10362
+ // unrelated purpose. Computed ONCE here and shared via `provenanceCtx`,
10363
+ // same precedent as `deadlineAt` and `providerEnrichments` below: it's
10364
+ // cheap (an env read plus one small JSON file stat/read), but five
10365
+ // recomputations across the five annotateGitProvenance calls buys
10366
+ // nothing and risks a mid-scan pinned-file edit producing five
10367
+ // different answers in one run.
10368
+ rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version || null,
10369
+ since: process.env.AGENTIC_SECURITY_PROVENANCE_SINCE || null,
10370
+ timeoutMs: provenanceTimeoutMs,
10371
+ mode: process.env.AGENTIC_SECURITY_PROVENANCE_MODE || 'standard',
10372
+ // Fix-round item 2: ONE shared provider-enrichment counter for the
10373
+ // whole scan, same precedent as the single `deadlineAt` above. Object
10374
+ // identity (not the primitive value) is what makes the cap survive the
10375
+ // `{ ...provenanceCtx, findingType: ... }` spreads used by four of the
10376
+ // five annotateGitProvenance calls below — see coordinator.js's
10377
+ // `providerEnrichments` comment for why a bare number would not work.
10378
+ providerEnrichments: { remaining: MAX_PROVIDER_ENRICHMENTS_PER_SCAN },
10379
+ };
10380
+ await annotateGitProvenance(finalFindings, provenanceCtx);
10381
+ // Direct dependencies only: a transitive dep's vulnerable version was never
10382
+ // declared in this repository's manifests, so there is no commit here that
10383
+ // introduced it and `resolveDirectSCAOrigin` would have nothing to walk.
10384
+ //
10385
+ // Keyed on `isDirect`, the same field the transitive-dedup block above
10386
+ // uses. The plan's `!s.isTransitive` was a silent no-op: `isTransitive`
10387
+ // exists on dependency COMPONENTS but was never carried onto the
10388
+ // vulnerable_dep entries, so the negation was true for every entry and the
10389
+ // filter excluded nothing. `isDirect` is now propagated at materialization.
10390
+ const directDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && s.isDirect);
10391
+ // `resolveDirectSCAOrigin`/`scaStableId` key on `filePath`; the vulnerable_dep
10392
+ // entries built above carry the manifest path as `file` (report/index.js
10393
+ // already reads `sc.filePath || sc.file` for the same reason). Backfill the
10394
+ // alias rather than teaching the SCA resolver a second field name — without
10395
+ // it every direct dependency resolves to `not_available: no-manifest-path`.
10396
+ for (const s of directDeps) { if (!s.filePath && s.file) s.filePath = s.file; }
10397
+ await annotateGitProvenance(directDeps, { ...provenanceCtx, findingType: 'sca' });
10398
+ // M3 §3.2: transitive dependencies now get real origin resolution too,
10399
+ // narrowing what was previously an unconditional not_available backstop
10400
+ // to genuinely unresolvable cases (non-npm lockfiles, no candidate
10401
+ // history) — see transitive-sca.js's own scope note.
10402
+ const transitiveDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && !s.isDirect);
10403
+ for (const s of transitiveDeps) { if (!s.filePath && s.file) s.filePath = s.file; }
10404
+ await annotateGitProvenance(transitiveDeps, { ...provenanceCtx, findingType: 'sca-transitive' });
10405
+ // Task 11 (PRD P0 scope): `aSecrets`/`blameableLogic` already have real
10406
+ // stableIds backfilled above (outside this callback — see that comment).
10407
+ // findingType 'secret'/'logic' is not consumed by any branch in
10408
+ // coordinator.js's resolveOne (only 'sca'/'sca-transitive' select a
10409
+ // different resolution strategy) — both fall through to the plain SAST
10410
+ // path (file+line blame short-circuit, then origin-resolver.js). Passed
10411
+ // anyway for clarity and future debugging; harmless today.
10412
+ await annotateGitProvenance(aSecrets, { ...provenanceCtx, findingType: 'secret' });
10413
+ await annotateGitProvenance(blameableLogic, { ...provenanceCtx, findingType: 'logic' });
10414
+ // FR-PROV-013 — introduce/remediate/reintroduce events. Best-effort by
10415
+ // design: the lifecycle store is a convenience ledger, and a failed write
10416
+ // (read-only tree, lock contention) must never fail a scan, matching how
10417
+ // every other provenance component degrades.
10418
+ //
10419
+ // Gated on `disabled` as well as on the `provenance` parameter, because
10420
+ // those are two different opt-outs and only one of them was being honoured.
10421
+ // updateLifecycle WRITES to disk; an operator who set
10422
+ // AGENTIC_SECURITY_NO_PROVENANCE=1 has said the feature does nothing, and a
10423
+ // disabled feature that still litters `.agentic-security/provenance/` is
10424
+ // not disabled. (annotateGitProvenance handles `disabled` internally by
10425
+ // stamping not_available, which is why it is still called above — every
10426
+ // finding must keep a terminal status even with the feature off.)
10427
+ //
10428
+ // Gated on `scanRoot` too. Every path into updateLifecycle resolves the
10429
+ // store through statePath(scanRoot, …), and statePath falls back to the
10430
+ // PROCESS CWD when scanRoot is null — so a runFullScan called with no
10431
+ // scanRoot (the in-process test/bench harnesses, predicate replay before
10432
+ // its provenance:false brake, any embedder) wrote a lifecycle ledger into
10433
+ // whatever directory the process happened to be in, keyed on that
10434
+ // unrelated run's findings. That is how this repo's own
10435
+ // scanner/.agentic-security/provenance/lifecycle.json accumulated 374
10436
+ // stableIds and 3000+ spurious remediated/reintroduced events. No
10437
+ // scanRoot means no project to keep a ledger for.
10438
+ //
10439
+ // `completeScan` is the other half, and it is a correctness gate rather
10440
+ // than a hygiene one — see runFullScan's parameter comment and
10441
+ // lifecycle.js's applyScan.
10442
+ //
10443
+ // TRUTHY IS NOT ENOUGH — it has to be a real directory. `resolveProjectRoot`
10444
+ // honours a caller-supplied scanRoot only when it exists on disk AND is a
10445
+ // directory; for anything else (a typo'd path, a `/tmp/...` a test never
10446
+ // created, a file rather than a directory) it silently falls back to walking
10447
+ // UP FROM THE PROCESS CWD for a project marker. So `agentic-security scan
10448
+ // ./typo` run from inside a project writes THAT project's lifecycle ledger
10449
+ // from a scan that never looked at it — and since such a scan finds nothing
10450
+ // while still claiming `completeScan`, the remediation pass closes every
10451
+ // open finding the real project had. Same corruption as the null-scanRoot
10452
+ // case above, reached through a different door: found by tracing the writes
10453
+ // that kept reappearing in this repo's own scanner/.agentic-security AFTER
10454
+ // the null guard was added. All three remaining writers were
10455
+ // truthy-but-nonexistent scanRoots.
10456
+ //
10457
+ // Narrow on purpose. Every other state write in such a scan lands in the
10458
+ // same wrong place, which is a wider `state-dir.js` question; the lifecycle
10459
+ // ledger is singled out here because it is the only one that turns the
10460
+ // mistake into a destructive claim about findings it never saw.
10461
+ let scanRootIsRealDir = false;
10462
+ try { scanRootIsRealDir = !!scanRoot && fs.statSync(scanRoot).isDirectory(); } catch { scanRootIsRealDir = false; }
10463
+ if (scanRootIsRealDir && !provenanceCtx.disabled) {
10464
+ try {
10465
+ await updateLifecycle(scanRoot, finalFindings, {
10466
+ scanId: provenanceCtx.scanId, observedAt: provenanceCtx.observedAt,
10467
+ completeScan,
10468
+ });
10469
+ } catch (_) { /* best-effort */ }
10470
+ }
10471
+ });
10472
+ // Structural backstop for the terminal-status guarantee, for BOTH channels.
10473
+ // Deliberately OUTSIDE the _runAnnotator callback above: _runAnnotator
10474
+ // swallows whatever the callback throws, so anything that depends on the
10475
+ // callback reaching its last line is a convention, not a guarantee. If
10476
+ // annotateGitProvenance threw before its per-finding loop began
10477
+ // (getRepoState blowing up on a corrupt repo, say), everything below the
10478
+ // throw is skipped and every finding and supplyChain entry silently carries
10479
+ // no findingProvenance at all — exactly the absent-field state the status
10480
+ // enum exists to make unreachable. Enforced here so it holds structurally.
10481
+ for (const f of finalFindings) {
10482
+ if (f && typeof f === 'object' && !f.findingProvenance) {
10483
+ f.findingProvenance = emptyProvenance(PROVENANCE_STATUS.ERROR, {
10484
+ limitations: ['provenance annotator did not reach this finding'],
10485
+ });
10486
+ }
10487
+ }
10488
+ // The supply-chain half. report/index.js normalizes EVERY supplyChain entry
10489
+ // into an SCA finding — not just the direct vulnerable_dep ones the resolver
10490
+ // can speak to — and pipeline/finding-schema.js requires findingProvenance on
10491
+ // every channel. Three distinct populations reach this loop and they are not
10492
+ // the same statement, so they do not share a limitation string:
10493
+ //
10494
+ // - transitive vulnerable_deps: resolved by resolveTransitiveSCAOrigin
10495
+ // above (M3 §3.2) — this branch is now reached only if that annotation
10496
+ // pass itself failed to stamp the entry.
10497
+ // - unpinned_dep / no_lockfile and friends: these describe the ABSENCE of a
10498
+ // declaration, so "which commit introduced this version" is not a question
10499
+ // that has an answer to defer.
10500
+ // - anything the annotator failed to reach, as above.
10501
+ //
10502
+ // The first two are honest `not_available` — that is exactly what the status
10503
+ // is for. Only a genuine annotator failure is an `error`, which is why this
10504
+ // loop distinguishes them rather than stamping one status for all three.
10505
+ for (const sc of (supplyChain || [])) {
10506
+ if (!sc || typeof sc !== 'object' || sc.findingProvenance) continue;
10507
+ sc.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10508
+ limitations: [sc.type === 'vulnerable_dep'
10509
+ ? 'transitive dependency origin resolution failed for this entry (annotator error)'
10510
+ : `origin resolution does not apply to a ${sc.type || 'non-vulnerability'} supply-chain entry`],
10511
+ });
10512
+ }
10513
+ // The OTHER two channels report/index.js normalizes into findings —
10514
+ // `scan.secrets` and `scan.logicVulns`. The same argument that produced the
10515
+ // supplyChain loop above applies verbatim: pipeline/finding-schema.js makes
10516
+ // `findingProvenance` REQUIRED on every channel, and normalizeFindings emits
10517
+ // a finding for each of these, so leaving them unstamped ships a
10518
+ // schema-incomplete finding whose absent field is indistinguishable from
10519
+ // "escaped annotation" — the exact condition the status enum exists to
10520
+ // remove.
10521
+ //
10522
+ // Task 11 (PRD P0 scope): `aSecrets` and `blameableLogic` now go through
10523
+ // REAL resolution above (real stableIds backfilled, real
10524
+ // annotateGitProvenance calls made), so this loop no longer covers them
10525
+ // wholesale — it is now a defensive catch-all for any entry the real call
10526
+ // somehow didn't reach (same precedent as the supplyChain loop above), plus
10527
+ // `syntheticLogic`, which can NEVER get real resolution by design (see the
10528
+ // classification comment above `SYNTHETIC_LOGIC_PREFIXES`) and stays here
10529
+ // permanently and honestly, not as a deferral.
10530
+ for (const x of (syntheticLogic || [])) {
10531
+ if (!x || typeof x !== 'object' || x.findingProvenance) continue;
10532
+ x.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10533
+ limitations: ['this finding describes dependency/config/policy state, not a single source line a commit introduced -- origin resolution does not apply'],
10534
+ });
10535
+ }
10536
+ for (const bucket of [aSecrets, blameableLogic]) {
10537
+ for (const x of (bucket || [])) {
10538
+ if (!x || typeof x !== 'object' || x.findingProvenance) continue;
10539
+ x.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10540
+ limitations: ['origin resolution annotator did not reach this finding'],
10541
+ });
10542
+ }
10543
+ }
10544
+ }
10090
10545
  // Addition #2 — attack-surface completeness inventory (entry points → dispositions).
10091
- let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
10546
+ // (_entrypointInventory is hoisted above the `skipAnnotators` guard FR-PROV-029.)
10547
+ try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
10092
10548
  // R9 + R6 — relevance scoping. Runs HERE, after every finding has been
10093
10549
  // appended (multi-sink chains, cross-language chains) and after the
10094
10550
  // entry-point inventory exists, so no finding escapes annotation and the
@@ -10105,24 +10561,25 @@ function _deterministicFileTimings(timings) {
10105
10561
  });
10106
10562
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
10107
10563
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
10108
- let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
10564
+ // (_rootCauseSweep/_proofCoverage/_coverageLedger/_scanHealth are hoisted
10565
+ // above the `skipAnnotators` guard — FR-PROV-029.)
10566
+ try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
10109
10567
  // PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
10110
10568
  // computed over the provable subset makes a narrow subset look like strength;
10111
10569
  // the three-bucket split (provable / declined-on-purpose / not-yet-classified)
10112
10570
  // is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
10113
- let _proofCoverage = null;
10114
10571
  try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
10115
10572
  // FR-203: per-file/per-analyzer coverage ledger, computed from exactly
10116
10573
  // the signals FR-201 (_detectorErrors) and FR-202 (the _timeout:true
10117
10574
  // marker finding) already produce -- files actually scanned come from
10118
10575
  // fc's own keys (skipped-for-size/density files were never added to it).
10119
10576
  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 });
10577
+ _coverageLedger = computeCoverageLedger({ files: Object.keys(fc), detectorErrors: _detectorErrors, timedOutFiles: _timedOutFiles });
10121
10578
  // FR-206 (assurance-hardening PRD, Milestone 0): additive scan-health
10122
10579
  // summary, computed from signals the engine already collects.
10123
10580
  // `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
10124
10581
  // until FR-203's coverage ledger existed to compute it for real.
10125
- let _scanHealth = computeScanHealth({
10582
+ _scanHealth = computeScanHealth({
10126
10583
  scanMeta: _scanMeta,
10127
10584
  annotatorErrors: _annotatorErrors,
10128
10585
  engineErrors: { cppDataflowParseErrors: _cppDataflowParseErrors.value },
@@ -10142,6 +10599,7 @@ function _deterministicFileTimings(timings) {
10142
10599
  calibration: calibrationFreshness(),
10143
10600
  compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
10144
10601
  });
10602
+ } // end if (!skipAnnotators) — FR-PROV-029
10145
10603
  return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger};}
10146
10604
 
10147
10605
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
@@ -10609,6 +11067,7 @@ export {
10609
11067
  classifyOrphans, classifyField, classifyEndpoint, shouldScan,
10610
11068
  _isFalsePositiveCredential, _detectSafeSinkShape,
10611
11069
  _loadCustomRules, _isCustomSuppressed, _isPathIgnored,
11070
+ _snapshotSuppressionLog, _restoreSuppressionLog,
10612
11071
  scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
10613
11072
  payloadsForFinding, buildProofObligation,
10614
11073
  DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,