@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
package/src/engine.js CHANGED
@@ -206,6 +206,7 @@ import { applyPathConstraints } from './posture/path-predicates.js';
206
206
  import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
207
207
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
208
208
  import { runDeepAnalysis } from './dataflow/index.js';
209
+ import { buildLineageGraph } from './lineage/index.js';
209
210
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
210
211
  import { annotateCloneClusters, findCloneOutliers } from './posture/semantic-clone.js';
211
212
  import { annotateAiProvenance } from './posture/ai-code-fingerprint.js';
@@ -260,6 +261,17 @@ import { annotateAttackTaxonomy, summarizeTaxonomy } from './posture/attack-taxo
260
261
  import { suppressByPastDecisions } from './posture/triage-memory.js';
261
262
  import { suppressByIntent } from './posture/intent-context.js';
262
263
  import { annotateGitHistory } from './posture/git-history.js';
264
+ // NOT `annotateProvenance` (sca/sigstore-verify.js's build-attestation
265
+ // annotator) and NOT `annotateFindingProvenance` (posture/provenance.js's
266
+ // AI-code fingerprint annotator). Both are already imported above in this
267
+ // file, so either name here is a duplicate binding — a SyntaxError — and the
268
+ // second is worse still because it also takes a findings array as its first
269
+ // argument, so a wrong import would RUN rather than fail. This one is named
270
+ // for the mechanism that distinguishes it —
271
+ // provenance derived from GIT HISTORY. See provenance/coordinator.js's header.
272
+ import { annotateGitProvenance, PROVENANCE_DEFAULT_TIMEOUT_MS, MAX_PROVIDER_ENRICHMENTS_PER_SCAN } from './posture/provenance/coordinator.js';
273
+ import { updateLifecycle } from './posture/provenance/lifecycle.js';
274
+ import { emptyProvenance, PROVENANCE_STATUS } from './posture/provenance/schema.js';
263
275
  import { applyThreatModel } from './posture/threat-model-grounding.js';
264
276
  import { annotateCrossRepoSignals } from './posture/pattern-propagation.js';
265
277
  import { annotateRiskDollars } from './posture/risk-dollars.js';
@@ -2626,6 +2638,33 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
2626
2638
  // so pfr[p] and the aggregates share object identity, exactly as in a normal run.
2627
2639
  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
2640
  function _getSuppressions(){ return [..._suppressionLog]; }
2641
+ // Task 11 reentrancy fix: `_suppressionLog` is module-level and unconditionally
2642
+ // cleared by `_resetSuppressions()` at the top of every `runFullScan` call.
2643
+ // `predicate-replay.js`'s `replayAt` calls `runFullScan` recursively FROM
2644
+ // WITHIN an outer, still-running scan's provenance resolution (to replay a
2645
+ // finding's predicate at a historical commit) -- before Task 11 wired
2646
+ // scan.secrets/scan.logicVulns into real provenance resolution, that recursive
2647
+ // call was only ever reachable from scan.findings/SCA origin walks, which this
2648
+ // exact fixture (test/fixtures/entropy-fp) never triggered. Wiring secrets in
2649
+ // exposed it for the first time: the nested call's `_resetSuppressions()`
2650
+ // silently wiped the OUTER scan's suppression log before its own return
2651
+ // statement read it via `_getSuppressions()`, so `scan.suppressions` came back
2652
+ // empty for anything that happened to walk deep git history.
2653
+ //
2654
+ // A plain snapshot/restore around ONE `replayAt` call is not sufficient on its
2655
+ // own: `coordinator.js` resolves several findings' origins CONCURRENTLY (its
2656
+ // own comment: "the scheduler runs these four at a time"), and each finding's
2657
+ // resolveOrigin walk can call `replayAt` multiple times sequentially -- so two
2658
+ // DIFFERENT findings' replay calls can be in flight at once, interleaved at
2659
+ // `runFullScan`'s own internal await points. Two overlapping snapshot/restore
2660
+ // pairs racing on the same global array means whichever restores last wins,
2661
+ // discarding whatever the other legitimately wrote in between. Exported so
2662
+ // predicate-replay.js can snapshot/restore its own call boundary AND serialize
2663
+ // that boundary process-wide (see its own comment on the exclusivity queue) --
2664
+ // the nested scan's own suppression output is never read by replayAt, so
2665
+ // nothing is lost by discarding it.
2666
+ function _snapshotSuppressionLog(){ return _suppressionLog.slice(); }
2667
+ function _restoreSuppressionLog(saved){ _suppressionLog.length = 0; if (Array.isArray(saved)) _suppressionLog.push(...saved); }
2629
2668
 
2630
2669
  // ── inline suppression pragma ───────────────────────────────────────────────
2631
2670
  //
@@ -7340,6 +7379,31 @@ function _makePurl(ecosystem,name,version,group){
7340
7379
  return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
7341
7380
  }
7342
7381
 
7382
+ function _findManifestLine(text, sectionKey, depName) {
7383
+ const lines = text.split('\n');
7384
+ let inSection = false;
7385
+ let depth = 0;
7386
+ const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7387
+ const nameRe = new RegExp(`"${escaped}"\\s*:`);
7388
+ for (let i = 0; i < lines.length; i++) {
7389
+ const line = lines[i];
7390
+ if (!inSection) {
7391
+ if (new RegExp(`"${sectionKey}"\\s*:\\s*\\{`).test(line)) {
7392
+ inSection = true;
7393
+ depth = 1 + (line.match(/\{/g) || []).length - 1 - (line.match(/\}/g) || []).length;
7394
+ if (nameRe.test(line)) return i + 1;
7395
+ if (depth <= 0) inSection = false;
7396
+ }
7397
+ continue;
7398
+ }
7399
+ depth += (line.match(/\{/g) || []).length;
7400
+ depth -= (line.match(/\}/g) || []).length;
7401
+ if (depth <= 0) { inSection = false; continue; }
7402
+ if (nameRe.test(line)) return i + 1;
7403
+ }
7404
+ return null;
7405
+ }
7406
+
7343
7407
  function _parsePackageJson(text,filePath){
7344
7408
  const out=[];try{const d=JSON.parse(text);
7345
7409
  for(const[depKey,scope]of[['dependencies','required'],['devDependencies','optional']]){
@@ -7350,7 +7414,8 @@ function _parsePackageJson(text,filePath){
7350
7414
  const group=scoped?`@${parts[0]}`:'';
7351
7415
  const pkgName=scoped?parts[1]:name;
7352
7416
  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'});
7417
+ isUnpinned:verRange==='*'||verRange==='latest'||verRange===''||verRange==='>=0.0.0',
7418
+ line:_findManifestLine(text,depKey,name)});
7354
7419
  }
7355
7420
  }
7356
7421
  }catch(_){}return out;
@@ -7379,12 +7444,13 @@ function _parsePackageLockJson(text,filePath){
7379
7444
 
7380
7445
  function _parseRequirementsTxt(text,filePath){
7381
7446
  const out=[];
7382
- for(const line of text.split('\n')){
7383
- const t=line.trim();
7447
+ const lines=text.split('\n');
7448
+ for(let i=0;i<lines.length;i++){
7449
+ const t=lines[i].trim();
7384
7450
  if(!t||t.startsWith('#')||t.startsWith('-'))continue;
7385
7451
  const m=t.match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
7386
7452
  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});
7453
+ purl:_makePurl('pypi',m[1].toLowerCase(),m[2],''),ecosystem:'pypi',filePath,isUnpinned:false,line:i+1});
7388
7454
  }return out;
7389
7455
  }
7390
7456
 
@@ -8180,6 +8246,15 @@ async function queryOSV(components,allFileContents){
8180
8246
  fixedVersions: vuln.fixedVersions, severity: vuln.severity, cvssVector: vuln.cvssVector,
8181
8247
  hasKnownAttackRef: vuln.hasKnownAttackRef, osvVulnFunctions: vuln.osvVulnFunctions || [], reachable: comp.reachable, scope: comp.scope,
8182
8248
  file: comp.filePath,
8249
+ // `isDirect` is backfilled onto every component just above the queryOSV
8250
+ // call, but was never carried onto the entry materialized from it — so
8251
+ // every consumer asking "is this a direct dependency" got `undefined`.
8252
+ // The transitive-dedup block's `group.find(s => s.isDirect)` has
8253
+ // therefore always fallen through to `group[0]`, picking an arbitrary
8254
+ // member as the primary instead of the direct one, and the provenance
8255
+ // pass's direct-only filter had nothing to filter on. `line` is Task
8256
+ // 12's declaration line, which the SCA provenance evidence node reads.
8257
+ isDirect: comp.isDirect, line: comp.line, depChain: Array.isArray(comp.depChain) ? comp.depChain.map((s) => s.replace(/\/$/, '')) : [],
8183
8258
  // kept for generateRecs() compat
8184
8259
  advisory: `${vid}${cveStr}, ${vuln.description}`,
8185
8260
  range: fixStr ? `< ${fixStr}` : 'see advisory' });
@@ -8526,7 +8601,28 @@ async function queryRegistries(components){
8526
8601
  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
8602
  }
8528
8603
 
8529
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8604
+ // `provenance:false` is the RE-ENTRANCY BRAKE, not a feature flag.
8605
+ // posture/provenance/predicate-replay.js answers "did this finding's condition
8606
+ // hold at commit X" by calling runFullScan back on that commit's blobs. Once
8607
+ // runFullScan itself runs the provenance pass, that is an unbounded recursion —
8608
+ // scan → provenance → replay → scan → … — which manifests as a scan that never
8609
+ // returns and spawns `git` forever, because every level of it is synchronous
8610
+ // execFileSync work. The replay's findings are discarded, so it wants no
8611
+ // provenance anyway; and it must NOT touch the lifecycle store, whose events
8612
+ // would otherwise be written from historical blobs as if they were this scan.
8613
+ // Passed explicitly per invocation rather than held in a module-level guard so
8614
+ // concurrent scans in one process cannot disable each other's provenance.
8615
+ //
8616
+ // `completeScan` is a SEPARATE question from `provenance`, and conflating them
8617
+ // is what let the fourth instance of this bug through. `provenance:false` says
8618
+ // "do not run the pass at all"; `completeScan:false` says "the pass may run,
8619
+ // but this file set is a SUBSET of scanRoot, so absence of a finding proves
8620
+ // nothing." Only the lifecycle ledger's remediation pass reads it — that is the
8621
+ // one place a finding's absence is turned into a positive claim. Defaults true
8622
+ // because a direct runFullScan caller supplying no file-subsetting options is
8623
+ // scanning everything it was given; runScan.js narrows it for --changed-since
8624
+ // and for caller-supplied fileContents.
8625
+ 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
8626
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8531
8627
  // analysis can recognize calls to user-input-returning helper methods
8532
8628
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -9134,6 +9230,95 @@ function _deterministicFileTimings(timings) {
9134
9230
  : (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
9135
9231
  failure: _deepFailure,
9136
9232
  };
9233
+ // Sub-project E, increment 5 — Data Flow Explorer lineage graph.
9234
+ // DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
9235
+ // (see src/lineage/DESIGN_GRAPH_BUILDER.md §9.5): lineage analysis has no
9236
+ // degraded/non-IR-backed mode, so gating it on deep mode ALSO being on
9237
+ // would make AGENTIC_SECURITY_LINEAGE_DEEP=1 alone silently produce
9238
+ // nothing whenever deep mode itself was off.
9239
+ //
9240
+ // Undisclosed-until-final-review side effect (N-4): this block populates
9241
+ // the SAME `_sharedIR` memo the privacy-taint block further below reads
9242
+ // (`_privacyIrBacked = !!(_sharedIR && _sharedIR.perFile)`), so setting
9243
+ // AGENTIC_SECURITY_LINEAGE_DEEP=1 also upgrades privacy-taint annotation
9244
+ // to IR-backed (`privacyIrBacked: true` instead of `false`) even with
9245
+ // AGENTIC_SECURITY_DEEP itself off. A real, deliberate-once-noticed
9246
+ // consequence of the memo sharing, not a bug — but worth knowing about
9247
+ // before being surprised by it.
9248
+ const _lineageRequested = process.env.AGENTIC_SECURITY_LINEAGE_DEEP === '1';
9249
+ const _lineageStatus = {
9250
+ requested: _lineageRequested,
9251
+ enabled: false,
9252
+ reason: _lineageRequested ? null : 'not requested',
9253
+ failure: null,
9254
+ };
9255
+ let _lineageGraph = null;
9256
+ if (_lineageRequested) {
9257
+ _lineageStatus.enabled = true;
9258
+ const _lineageBudgetMs = parseInt(process.env.AGENTIC_SECURITY_LINEAGE_TIMEOUT_MS || '300000', 10);
9259
+ try {
9260
+ // Reuses the SAME _sharedIR memo _deepEnabled's own block above uses —
9261
+ // if deep mode already built it, this is free; if not, this is what
9262
+ // first triggers the build (the whole reason this gate is independent).
9263
+ const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
9264
+ const _lr = buildLineageGraph(callGraph, {
9265
+ // scanRoot can be null (e.g. an MCP scan_diff caller, or
9266
+ // runFullScan invoked directly with no scanRoot) — fall back to
9267
+ // undefined rather than inventing a repository name from the
9268
+ // process's own CWD basename, which would make lineage node ids
9269
+ // depend on wherever the process happened to be launched from and
9270
+ // collide across two unrelated repos sharing a directory basename.
9271
+ // `undefined` lets graph-builder.js's own `opts.repository ?? 'repo'`
9272
+ // default apply — the single source of truth for that literal.
9273
+ repository: scanRoot ? path.basename(path.resolve(scanRoot)) : undefined,
9274
+ // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): the real
9275
+ // scan root, threaded straight through — distinct from `repository`
9276
+ // above, which is only a basename. `index.js`'s own `buildLineageGraph`
9277
+ // uses this ONLY to load `.agentic-security/privacy-policy.json`
9278
+ // (never to derive `repository`/node ids), mirroring how `fileContents`
9279
+ // was threaded through for Sub-project B's own transit-evidence scan.
9280
+ scanRoot,
9281
+ deterministic: isDeterministic(),
9282
+ perFile,
9283
+ fileContents,
9284
+ });
9285
+ if (_lr.status === 'complete') {
9286
+ _lineageGraph = _lr.graph;
9287
+ if (_lr.elapsedMs > _lineageBudgetMs) {
9288
+ // Same "measured, not truly interrupted" disclosure _deepEnabled's
9289
+ // own elapsed > budgetMs branch already makes for IR-taint — see
9290
+ // src/lineage/DESIGN_GRAPH_BUILDER.md §9.5.
9291
+ aF.push({
9292
+ id: `lineage-timeout:${scanRoot || ''}`,
9293
+ file: '(lineage-engine)', line: 0,
9294
+ vuln: `Lineage graph build exceeded ${_lineageBudgetMs}ms budget (${_lr.elapsedMs}ms used) — results may be incomplete`,
9295
+ severity: 'info',
9296
+ parser: 'LINEAGE',
9297
+ confidence: 0.5,
9298
+ });
9299
+ }
9300
+ } else if (_lr.status === 'failed') {
9301
+ _lineageStatus.failure = _lr.failure;
9302
+ }
9303
+ // status === 'not_available' needs no extra handling: _lineageGraph
9304
+ // stays null, and enabled:true/failure:null correctly reads as "ran,
9305
+ // produced nothing" (a genuinely empty/malformed callGraph), distinct
9306
+ // from "never ran" (requested:false).
9307
+ } catch (e) {
9308
+ // Lineage is best-effort, mirroring _deepEnabled's own catch above
9309
+ // (engine.js, `_deepFailure = String((e && e.message) || e);`):
9310
+ // anything OUTSIDE buildLineageGraph's own try/catch — the _buildIR()
9311
+ // call itself, path.resolve, etc. — must never kill the scan. Without
9312
+ // this, a throw here (e.g. from buildCallGraph, which
9313
+ // buildProjectIR/buildProjectIRAsync do NOT guard per-file the way
9314
+ // per-file parsing is guarded) previously took down the entire scan
9315
+ // the moment AGENTIC_SECURITY_LINEAGE_DEEP=1 was set — see
9316
+ // src/lineage/DESIGN_GRAPH_BUILDER.md §9.5 item 1 and the Global
9317
+ // Constraint this violated ("a lineage-build failure must never fail
9318
+ // the scan"). Regression-tested in test/lineage-fault-injection.test.js.
9319
+ _lineageStatus.failure = String((e && e.message) || e);
9320
+ }
9321
+ }
9137
9322
  // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
9138
9323
  if (_deepCallGraph) {
9139
9324
  try {
@@ -9276,9 +9461,76 @@ function _deterministicFileTimings(timings) {
9276
9461
  // Every catch in this block writes into _annotatorErrors so the operator
9277
9462
  // can tell "didn't run" from "ran cleanly." The array is surfaced as
9278
9463
  // scan.annotatorErrors in the report; an empty array means clean.
9464
+ //
9465
+ // FR-PROV-029 (Finding Provenance PRD): `skipAnnotators` lets a caller skip
9466
+ // this entire ~54-annotator pipeline below. The guard also covers
9467
+ // non-annotator finalization that lives in the same block — entropy-vs-
9468
+ // named secret dedup, orphan classification, supply-chain in-place
9469
+ // filtering, `Object.freeze(finalFindings)`, and closing the resume
9470
+ // checkpoint — so a skipped run leaves all of that undone too; name the
9471
+ // option accordingly if a future caller needs annotators skipped WITHOUT
9472
+ // skipping those steps. predicate-replay.js's `replayAt()` is the only
9473
+ // caller that sets it — it re-runs runFullScan scoped to a historical
9474
+ // commit's blob content purely to recompute `computeStableId()` over the
9475
+ // raw detector output (`scan.findings`/`scan.secrets`); it never reads
9476
+ // anything an annotator sets (verified empirically — see the commit
9477
+ // message). Every binding the pipeline below
9478
+ // populates that the final `return` still references is declared here,
9479
+ // OUTSIDE the guarded block, defaulted to exactly what it was before any
9480
+ // annotator ran. A skipped run returns those fields at their
9481
+ // pre-annotation default instead of throwing a ReferenceError; a normal
9482
+ // (non-skipping) run is byte-for-byte unaffected, since the guarded block
9483
+ // below still assigns the same values to these same bindings — it just no
9484
+ // longer *declares* them, so nothing here changes what a value ends up
9485
+ // being, only where the variable comes into scope.
9279
9486
  let _executionProofSummary = null, _vulnHistory = null;
9280
9487
  let _logicClaims = null;
9281
- const _annotatorErrors = [];
9488
+ let _annotatorErrors = [];
9489
+ let _v3 = {};
9490
+ let _privacyIrBacked = null;
9491
+ let _privacyTaxonomyVersion = null;
9492
+ let _privacyFramework = null;
9493
+ let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
9494
+ _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
9495
+ _licenseGraph = null, _attributions = null, _taxonomySummary = null;
9496
+ let _scanMeta = null;
9497
+ let _entrypointInventory = {};
9498
+ let _rootCauseSweep = null;
9499
+ let _proofCoverage = null;
9500
+ let _coverageLedger = null;
9501
+ let _scanHealth = null;
9502
+ // Task 11 (PRD P0 scope): ruleId backfill for scan.secrets / blameable
9503
+ // scan.logicVulns findings MUST run unconditionally, HERE, outside the
9504
+ // `skipAnnotators` guard below -- not just because the live scan needs it,
9505
+ // but because predicate-replay.js's replayAt() recurses into THIS function
9506
+ // with skipAnnotators:true and recomputes computeStableId() directly on
9507
+ // whatever it finds in the nested scan's own scan.secrets/scan.logicVulns.
9508
+ // If the backfill only ran on the live (skipAnnotators:false) call, the
9509
+ // nested replay scan would compute a DIFFERENT stableId (falling back to
9510
+ // the shared f.cwe -- e.g. every secret type collapsing onto "CWE-798")
9511
+ // than the live scan's already-backfilled finding, so replayAt's
9512
+ // `sid === targetStableId` check would NEVER match -- permanently landing
9513
+ // every secrets/logicVulns finding on status:'partial',
9514
+ // reason:'predicate-never-confirmed-in-candidates'. Caught empirically:
9515
+ // test/fixtures/entropy-fp's AWS-key fixture resolved 'partial' instead of
9516
+ // 'complete' until this moved here from inside the (skipAnnotators-gated)
9517
+ // provenance block further down.
9518
+ //
9519
+ // At this point in the function, aSecrets/aLogic hold every BLAMEABLE
9520
+ // producer's output (scanCredentials/scanEntropySecrets;
9521
+ // scanLogicVulns/scanBusinessLogic/scanMiddlewareOrdering/scanReDoS/
9522
+ // scanRegexReDoS/scanTodosNearSecurity/scanConfigFiles) -- the 3 synthetic
9523
+ // producers (license-policy:/deploy-platform:/stack-playbook:) and
9524
+ // logic-claims.js's ingested claims are pushed LATER, inside the
9525
+ // `skipAnnotators` guard below, so they are never present in a nested
9526
+ // replay scan's aLogic and never need this backfill for replay-matching
9527
+ // purposes. The provenance block further down re-applies this same
9528
+ // idempotent backfill to the full, final `blameableLogic` (which by then
9529
+ // includes logic-claims too) before calling annotateGitProvenance on it.
9530
+ const _slugify = (s) => String(s || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
9531
+ for (const f of aSecrets) { if (!f.ruleId) f.ruleId = `secret:${_slugify(f.vuln)}`; }
9532
+ for (const f of aLogic) { if (!f.ruleId) f.ruleId = `logic:${_slugify(f.vuln)}`; }
9533
+ if (!skipAnnotators) {
9282
9534
  // FR-106 (assurance-hardening PRD): Promise-aware, explicitly awaited at
9283
9535
  // every one of its ~51 call sites below (previously a sync `try{return
9284
9536
  // fn()}` let an async callback's rejection escape as an unhandled
@@ -9384,18 +9636,18 @@ function _deterministicFileTimings(timings) {
9384
9636
  // FR-405 (assurance-hardening PRD): null means "privacy analysis never
9385
9637
  // ran at all" (AGENTIC_SECURITY_NO_PRIVACY=1, or the annotator threw
9386
9638
  // 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;
9639
+ // since neither case has real IR-backed evidence to offer. Declared at
9640
+ // function scope (now hoisted above the `skipAnnotators` guard, near
9641
+ // _executionProofSummary et al. same reasoning: FR-PROV-029), because
9642
+ // the annotatePrivacyTaint closure that assigns to it (inside the
9643
+ // AGENTIC_SECURITY_NO_INTEGRATION block below) runs and exits before that
9644
+ // block closes a block-scoped `let` inside that if-statement would be
9645
+ // unreachable by the later assessPrivacyFramework call and the final
9646
+ // return, both of which are outside the block.
9647
+ //
9648
+ // FR-402: _privacyTaxonomyVersion (which taxonomy version actually
9649
+ // classified this scan's fields) has the same scoping constraint as
9650
+ // _privacyIrBacked directly above (D-0011) and is hoisted alongside it.
9399
9651
 
9400
9652
  // ── World-class integration block ─────────────────────────────────────
9401
9653
  // Each annotator is opt-in via env var and try/catch wrapped. They run
@@ -9894,7 +10146,7 @@ function _deterministicFileTimings(timings) {
9894
10146
  classifyOrphans(aSrc,aSink,finalFindings,fc);
9895
10147
  // v3 next-gen: capture scan-level reports (counterfactual, threat model,
9896
10148
  // trust-boundary diagram, calibration-drift alarms). All best-effort.
9897
- let _v3 = {};
10149
+ // (_v3 is hoisted above the `skipAnnotators` guard — FR-PROV-029.)
9898
10150
  await _runAnnotator("_v3.counterfactual", () => { _v3.counterfactual = runCounterfactual(finalFindings, fc); });
9899
10151
  await _runAnnotator("_v3.threatModel", () => { _v3.threatModel = buildThreatModel(finalFindings, fc); });
9900
10152
  await _runAnnotator("_v3.trustBoundaryDiagram", () => { _v3.trustBoundaryDiagram = buildTrustBoundaryDiagram(finalFindings, fc); });
@@ -9912,10 +10164,9 @@ function _deterministicFileTimings(timings) {
9912
10164
  // Each is opt-in via env var. They produce machine-readable artifacts
9913
10165
  // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
9914
10166
  // 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;
10167
+ // (_privacyFramework/_threatModel/_apiContractFindings/_sbomDiff/
10168
+ // _complianceReport/_exploitBundles/_pqcPlan/_licenseGraph/_attributions/
10169
+ // _taxonomySummary are hoisted above the `skipAnnotators` guard — FR-PROV-029.)
9919
10170
  if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
9920
10171
  // Threat model — STRIDE + entities + attack trees rooted in findings.
9921
10172
  if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL !== '1') {
@@ -10083,12 +10334,307 @@ function _deterministicFileTimings(timings) {
10083
10334
  // seen when only N-of-those-candidates were actually analyzed.
10084
10335
  // checkpoint.total intentionally keeps files.length — that field means the
10085
10336
  // 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}};
10337
+ _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
10338
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
10088
10339
  // it. Anything that threw before this point leaves it in place to resume from.
10089
10340
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
10341
+ // Finding Provenance (M0/M1) — attaches `finding.findingProvenance`.
10342
+ //
10343
+ // Placed HERE, not immediately after the SCA/multi-sink correlation blocks
10344
+ // where the plan first put it, for the same reason `annotateRelevance` below
10345
+ // runs this late: several post-scan artifact emitters between those blocks
10346
+ // and this point still PUSH findings (`runApiContractScan`, `runSbomDiff`,
10347
+ // the cross-language chain passes). Annotating before them would have left
10348
+ // every finding they produce with no `findingProvenance` at all, which is
10349
+ // precisely the "absent field" state the coordinator's terminal-status
10350
+ // guarantee exists to make unreachable. Running after `Object.freeze` above
10351
+ // is safe and is in fact the point: the finding SET can no longer change, and
10352
+ // a shallow freeze still permits annotating FIELDS on the individual finding
10353
+ // objects. This annotator never appends or drops a finding.
10354
+ if (provenance !== false) {
10355
+ // Task 11 (PRD P0 scope): real origin resolution for scan.secrets and
10356
+ // blameable scan.logicVulns findings. Both channels were previously
10357
+ // stamped an unconditional not_available (see the backstop loop below) —
10358
+ // not because computeStableId can't handle them (its ruleId() fallback
10359
+ // chain already tolerates a missing f.ruleId), but because nobody
10360
+ // backfilled a real per-pattern ruleId and nobody called
10361
+ // annotateGitProvenance on these channels at all. scan.secrets findings
10362
+ // set neither ruleId nor family nor parser, and every secret type shares
10363
+ // the same fixed f.cwe ("CWE-798"), so without a per-pattern backfill
10364
+ // every secret in a scan would collide onto ONE stableId.
10365
+ //
10366
+ // scan.logicVulns is not one detector's output — three of its ~9
10367
+ // producers (license-policy:, deploy-platform:, stack-playbook:) use a
10368
+ // FIXED PLACEHOLDER `line` (0 or 1), not a real diffable source location
10369
+ // — they read scanRoot-level files (package.json, vercel.json, ...)
10370
+ // directly rather than from the scanned fileContents. Routing those
10371
+ // through git-blame-style resolution would fabricate a plausible-looking
10372
+ // but meaningless commit attribution (e.g. "package.json line 1" blamed
10373
+ // on whatever commit last touched that line, unrelated to the actual
10374
+ // license/platform/stack finding). They are excluded by id prefix here
10375
+ // and stay on the honest, PERMANENT not_available path in the backstop
10376
+ // loop below — never routed through resolveOrigin. Declared here, OUTSIDE
10377
+ // the _runAnnotator callback below, because the backstop loop (also
10378
+ // outside that callback — see its own comment on why) needs
10379
+ // `blameableLogic`/`syntheticLogic` too.
10380
+ //
10381
+ // `aSecrets`/`aLogic`'s ruleId backfill for the producers that exist by
10382
+ // this point in the function already happened much earlier (right before
10383
+ // the `skipAnnotators` guard opens — see that comment for why it CANNOT
10384
+ // live here alone). `_slugify` is declared there and is in scope here too.
10385
+ // What's re-applied below is only the SAME idempotent backfill
10386
+ // (`if (!f.ruleId)`), now over the full, final `blameableLogic` — which by
10387
+ // this point additionally includes logic-claims.js's late-pushed ingested
10388
+ // claims, the one blameable producer the early pass could not see.
10389
+ const SYNTHETIC_LOGIC_PREFIXES = ['license-policy:', 'deploy-platform:', 'stack-playbook:'];
10390
+ const isSyntheticLogicFinding = (f) => typeof f?.id === 'string'
10391
+ && SYNTHETIC_LOGIC_PREFIXES.some((p) => f.id.startsWith(p));
10392
+ const blameableLogic = aLogic.filter((f) => !isSyntheticLogicFinding(f));
10393
+ const syntheticLogic = aLogic.filter(isSyntheticLogicFinding);
10394
+ for (const f of blameableLogic) {
10395
+ if (!f.ruleId) f.ruleId = `logic:${_slugify(f.vuln)}`;
10396
+ }
10397
+ annotateStableIds(aSecrets);
10398
+ annotateStableIds(blameableLogic);
10399
+ await _runAnnotator("annotateGitProvenance", async () => {
10400
+ // ONE deadline for the whole scan's provenance work, computed here and
10401
+ // threaded into all five annotateGitProvenance calls below. Computed per call
10402
+ // inside the coordinator, the effective scan-level budget was 2× the
10403
+ // configured --provenance-timeout: the SAST pass got a fresh window and
10404
+ // then the SCA pass got another one, so an operator asking for a 30s cap
10405
+ // could wait 60s. The spec describes a single global deadline; this is
10406
+ // where "global" has to be established, because this is the only scope
10407
+ // that sees both passes.
10408
+ const provenanceTimeoutMs = process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS
10409
+ ? parseInt(process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS, 10) : undefined;
10410
+ // Deterministic mode promises byte-identical SARIF run-to-run
10411
+ // (posture/deterministic.js), and `observedAt` above is already frozen to
10412
+ // honour that. But `findingProvenance.status` (complete/partial/
10413
+ // budget_exhausted/...) is driven by THIS deadline, which was computed
10414
+ // from a real `Date.now()` even under --deterministic — so two runs of
10415
+ // the identical scan could cross a 60s budget at different points under
10416
+ // machine contention (git subprocess calls slowed by CPU/IO pressure) and
10417
+ // land different findings on `budget_exhausted` vs a resolved status,
10418
+ // changing the emitted SARIF between runs. An explicit
10419
+ // --provenance-timeout / AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS still wins
10420
+ // (an operator asking for a tight budget gets it regardless of mode);
10421
+ // absent that, deterministic mode gets a much larger fixed ceiling so
10422
+ // resolution has room to finish under realistic load instead of a bound
10423
+ // that's routinely crossed — see test/proof-corpus-lib.test.js's
10424
+ // "produces byte-identical SARIF across two runs".
10425
+ const DETERMINISTIC_PROVENANCE_TIMEOUT_MS = 300000; // 5 minutes
10426
+ const provenanceDeadlineAt = Date.now()
10427
+ + (Number.isFinite(provenanceTimeoutMs) && provenanceTimeoutMs > 0
10428
+ ? provenanceTimeoutMs
10429
+ : (isDeterministic() ? DETERMINISTIC_PROVENANCE_TIMEOUT_MS : PROVENANCE_DEFAULT_TIMEOUT_MS));
10430
+ const provenanceCtx = {
10431
+ scanRoot,
10432
+ deadlineAt: provenanceDeadlineAt,
10433
+ // CLI flags that set these land in Task 17; reading the env directly here
10434
+ // mirrors AGENTIC_SECURITY_NO_GIT_HISTORY's existing pattern in this file.
10435
+ disabled: process.env.AGENTIC_SECURITY_NO_PROVENANCE === '1',
10436
+ scanId: process.env.AGENTIC_SECURITY_SCAN_ID || null,
10437
+ // Frozen under --deterministic so SARIF (which now carries
10438
+ // findingProvenance.firstObserved.observedAt — see report/index.js's
10439
+ // toSARIF) stays byte-identical run-to-run, matching the exact
10440
+ // convention posture/deterministic.js's makeDeterministic() already
10441
+ // uses for meta.startedAt. This value predates that guarantee; it was
10442
+ // invisible before findingProvenance reached any output format.
10443
+ observedAt: isDeterministic() ? '1970-01-01T00:00:00.000Z' : new Date().toISOString(),
10444
+ // FR-PROV-028 / "Evidence integrity": the cache key (coordinator.js's
10445
+ // `makeCacheKey`) and `computeDigest`'s `rulesetVersion` binding both
10446
+ // read this field, so it has to be the REAL effective ruleset version,
10447
+ // not an env var operators essentially never set. `_effectiveRulesetVersion`
10448
+ // (posture/ruleset-version.js's `effectiveVersion`) already resolves
10449
+ // env override > pinned file > CURRENT_RULESET_VERSION (== the running
10450
+ // scanner's own package version) — the same helper the checkpoint
10451
+ // identity above (`_ckptIdentity.rulesetVersion`) already calls for an
10452
+ // unrelated purpose. Computed ONCE here and shared via `provenanceCtx`,
10453
+ // same precedent as `deadlineAt` and `providerEnrichments` below: it's
10454
+ // cheap (an env read plus one small JSON file stat/read), but five
10455
+ // recomputations across the five annotateGitProvenance calls buys
10456
+ // nothing and risks a mid-scan pinned-file edit producing five
10457
+ // different answers in one run.
10458
+ rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version || null,
10459
+ since: process.env.AGENTIC_SECURITY_PROVENANCE_SINCE || null,
10460
+ timeoutMs: provenanceTimeoutMs,
10461
+ mode: process.env.AGENTIC_SECURITY_PROVENANCE_MODE || 'standard',
10462
+ // Fix-round item 2: ONE shared provider-enrichment counter for the
10463
+ // whole scan, same precedent as the single `deadlineAt` above. Object
10464
+ // identity (not the primitive value) is what makes the cap survive the
10465
+ // `{ ...provenanceCtx, findingType: ... }` spreads used by four of the
10466
+ // five annotateGitProvenance calls below — see coordinator.js's
10467
+ // `providerEnrichments` comment for why a bare number would not work.
10468
+ providerEnrichments: { remaining: MAX_PROVIDER_ENRICHMENTS_PER_SCAN },
10469
+ };
10470
+ await annotateGitProvenance(finalFindings, provenanceCtx);
10471
+ // Direct dependencies only: a transitive dep's vulnerable version was never
10472
+ // declared in this repository's manifests, so there is no commit here that
10473
+ // introduced it and `resolveDirectSCAOrigin` would have nothing to walk.
10474
+ //
10475
+ // Keyed on `isDirect`, the same field the transitive-dedup block above
10476
+ // uses. The plan's `!s.isTransitive` was a silent no-op: `isTransitive`
10477
+ // exists on dependency COMPONENTS but was never carried onto the
10478
+ // vulnerable_dep entries, so the negation was true for every entry and the
10479
+ // filter excluded nothing. `isDirect` is now propagated at materialization.
10480
+ const directDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && s.isDirect);
10481
+ // `resolveDirectSCAOrigin`/`scaStableId` key on `filePath`; the vulnerable_dep
10482
+ // entries built above carry the manifest path as `file` (report/index.js
10483
+ // already reads `sc.filePath || sc.file` for the same reason). Backfill the
10484
+ // alias rather than teaching the SCA resolver a second field name — without
10485
+ // it every direct dependency resolves to `not_available: no-manifest-path`.
10486
+ for (const s of directDeps) { if (!s.filePath && s.file) s.filePath = s.file; }
10487
+ await annotateGitProvenance(directDeps, { ...provenanceCtx, findingType: 'sca' });
10488
+ // M3 §3.2: transitive dependencies now get real origin resolution too,
10489
+ // narrowing what was previously an unconditional not_available backstop
10490
+ // to genuinely unresolvable cases (non-npm lockfiles, no candidate
10491
+ // history) — see transitive-sca.js's own scope note.
10492
+ const transitiveDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && !s.isDirect);
10493
+ for (const s of transitiveDeps) { if (!s.filePath && s.file) s.filePath = s.file; }
10494
+ await annotateGitProvenance(transitiveDeps, { ...provenanceCtx, findingType: 'sca-transitive' });
10495
+ // Task 11 (PRD P0 scope): `aSecrets`/`blameableLogic` already have real
10496
+ // stableIds backfilled above (outside this callback — see that comment).
10497
+ // findingType 'secret'/'logic' is not consumed by any branch in
10498
+ // coordinator.js's resolveOne (only 'sca'/'sca-transitive' select a
10499
+ // different resolution strategy) — both fall through to the plain SAST
10500
+ // path (file+line blame short-circuit, then origin-resolver.js). Passed
10501
+ // anyway for clarity and future debugging; harmless today.
10502
+ await annotateGitProvenance(aSecrets, { ...provenanceCtx, findingType: 'secret' });
10503
+ await annotateGitProvenance(blameableLogic, { ...provenanceCtx, findingType: 'logic' });
10504
+ // FR-PROV-013 — introduce/remediate/reintroduce events. Best-effort by
10505
+ // design: the lifecycle store is a convenience ledger, and a failed write
10506
+ // (read-only tree, lock contention) must never fail a scan, matching how
10507
+ // every other provenance component degrades.
10508
+ //
10509
+ // Gated on `disabled` as well as on the `provenance` parameter, because
10510
+ // those are two different opt-outs and only one of them was being honoured.
10511
+ // updateLifecycle WRITES to disk; an operator who set
10512
+ // AGENTIC_SECURITY_NO_PROVENANCE=1 has said the feature does nothing, and a
10513
+ // disabled feature that still litters `.agentic-security/provenance/` is
10514
+ // not disabled. (annotateGitProvenance handles `disabled` internally by
10515
+ // stamping not_available, which is why it is still called above — every
10516
+ // finding must keep a terminal status even with the feature off.)
10517
+ //
10518
+ // Gated on `scanRoot` too. Every path into updateLifecycle resolves the
10519
+ // store through statePath(scanRoot, …), and statePath falls back to the
10520
+ // PROCESS CWD when scanRoot is null — so a runFullScan called with no
10521
+ // scanRoot (the in-process test/bench harnesses, predicate replay before
10522
+ // its provenance:false brake, any embedder) wrote a lifecycle ledger into
10523
+ // whatever directory the process happened to be in, keyed on that
10524
+ // unrelated run's findings. That is how this repo's own
10525
+ // scanner/.agentic-security/provenance/lifecycle.json accumulated 374
10526
+ // stableIds and 3000+ spurious remediated/reintroduced events. No
10527
+ // scanRoot means no project to keep a ledger for.
10528
+ //
10529
+ // `completeScan` is the other half, and it is a correctness gate rather
10530
+ // than a hygiene one — see runFullScan's parameter comment and
10531
+ // lifecycle.js's applyScan.
10532
+ //
10533
+ // TRUTHY IS NOT ENOUGH — it has to be a real directory. `resolveProjectRoot`
10534
+ // honours a caller-supplied scanRoot only when it exists on disk AND is a
10535
+ // directory; for anything else (a typo'd path, a `/tmp/...` a test never
10536
+ // created, a file rather than a directory) it silently falls back to walking
10537
+ // UP FROM THE PROCESS CWD for a project marker. So `agentic-security scan
10538
+ // ./typo` run from inside a project writes THAT project's lifecycle ledger
10539
+ // from a scan that never looked at it — and since such a scan finds nothing
10540
+ // while still claiming `completeScan`, the remediation pass closes every
10541
+ // open finding the real project had. Same corruption as the null-scanRoot
10542
+ // case above, reached through a different door: found by tracing the writes
10543
+ // that kept reappearing in this repo's own scanner/.agentic-security AFTER
10544
+ // the null guard was added. All three remaining writers were
10545
+ // truthy-but-nonexistent scanRoots.
10546
+ //
10547
+ // Narrow on purpose. Every other state write in such a scan lands in the
10548
+ // same wrong place, which is a wider `state-dir.js` question; the lifecycle
10549
+ // ledger is singled out here because it is the only one that turns the
10550
+ // mistake into a destructive claim about findings it never saw.
10551
+ let scanRootIsRealDir = false;
10552
+ try { scanRootIsRealDir = !!scanRoot && fs.statSync(scanRoot).isDirectory(); } catch { scanRootIsRealDir = false; }
10553
+ if (scanRootIsRealDir && !provenanceCtx.disabled) {
10554
+ try {
10555
+ await updateLifecycle(scanRoot, finalFindings, {
10556
+ scanId: provenanceCtx.scanId, observedAt: provenanceCtx.observedAt,
10557
+ completeScan,
10558
+ });
10559
+ } catch (_) { /* best-effort */ }
10560
+ }
10561
+ });
10562
+ // Structural backstop for the terminal-status guarantee, for BOTH channels.
10563
+ // Deliberately OUTSIDE the _runAnnotator callback above: _runAnnotator
10564
+ // swallows whatever the callback throws, so anything that depends on the
10565
+ // callback reaching its last line is a convention, not a guarantee. If
10566
+ // annotateGitProvenance threw before its per-finding loop began
10567
+ // (getRepoState blowing up on a corrupt repo, say), everything below the
10568
+ // throw is skipped and every finding and supplyChain entry silently carries
10569
+ // no findingProvenance at all — exactly the absent-field state the status
10570
+ // enum exists to make unreachable. Enforced here so it holds structurally.
10571
+ for (const f of finalFindings) {
10572
+ if (f && typeof f === 'object' && !f.findingProvenance) {
10573
+ f.findingProvenance = emptyProvenance(PROVENANCE_STATUS.ERROR, {
10574
+ limitations: ['provenance annotator did not reach this finding'],
10575
+ });
10576
+ }
10577
+ }
10578
+ // The supply-chain half. report/index.js normalizes EVERY supplyChain entry
10579
+ // into an SCA finding — not just the direct vulnerable_dep ones the resolver
10580
+ // can speak to — and pipeline/finding-schema.js requires findingProvenance on
10581
+ // every channel. Three distinct populations reach this loop and they are not
10582
+ // the same statement, so they do not share a limitation string:
10583
+ //
10584
+ // - transitive vulnerable_deps: resolved by resolveTransitiveSCAOrigin
10585
+ // above (M3 §3.2) — this branch is now reached only if that annotation
10586
+ // pass itself failed to stamp the entry.
10587
+ // - unpinned_dep / no_lockfile and friends: these describe the ABSENCE of a
10588
+ // declaration, so "which commit introduced this version" is not a question
10589
+ // that has an answer to defer.
10590
+ // - anything the annotator failed to reach, as above.
10591
+ //
10592
+ // The first two are honest `not_available` — that is exactly what the status
10593
+ // is for. Only a genuine annotator failure is an `error`, which is why this
10594
+ // loop distinguishes them rather than stamping one status for all three.
10595
+ for (const sc of (supplyChain || [])) {
10596
+ if (!sc || typeof sc !== 'object' || sc.findingProvenance) continue;
10597
+ sc.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10598
+ limitations: [sc.type === 'vulnerable_dep'
10599
+ ? 'transitive dependency origin resolution failed for this entry (annotator error)'
10600
+ : `origin resolution does not apply to a ${sc.type || 'non-vulnerability'} supply-chain entry`],
10601
+ });
10602
+ }
10603
+ // The OTHER two channels report/index.js normalizes into findings —
10604
+ // `scan.secrets` and `scan.logicVulns`. The same argument that produced the
10605
+ // supplyChain loop above applies verbatim: pipeline/finding-schema.js makes
10606
+ // `findingProvenance` REQUIRED on every channel, and normalizeFindings emits
10607
+ // a finding for each of these, so leaving them unstamped ships a
10608
+ // schema-incomplete finding whose absent field is indistinguishable from
10609
+ // "escaped annotation" — the exact condition the status enum exists to
10610
+ // remove.
10611
+ //
10612
+ // Task 11 (PRD P0 scope): `aSecrets` and `blameableLogic` now go through
10613
+ // REAL resolution above (real stableIds backfilled, real
10614
+ // annotateGitProvenance calls made), so this loop no longer covers them
10615
+ // wholesale — it is now a defensive catch-all for any entry the real call
10616
+ // somehow didn't reach (same precedent as the supplyChain loop above), plus
10617
+ // `syntheticLogic`, which can NEVER get real resolution by design (see the
10618
+ // classification comment above `SYNTHETIC_LOGIC_PREFIXES`) and stays here
10619
+ // permanently and honestly, not as a deferral.
10620
+ for (const x of (syntheticLogic || [])) {
10621
+ if (!x || typeof x !== 'object' || x.findingProvenance) continue;
10622
+ x.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10623
+ limitations: ['this finding describes dependency/config/policy state, not a single source line a commit introduced -- origin resolution does not apply'],
10624
+ });
10625
+ }
10626
+ for (const bucket of [aSecrets, blameableLogic]) {
10627
+ for (const x of (bucket || [])) {
10628
+ if (!x || typeof x !== 'object' || x.findingProvenance) continue;
10629
+ x.findingProvenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
10630
+ limitations: ['origin resolution annotator did not reach this finding'],
10631
+ });
10632
+ }
10633
+ }
10634
+ }
10090
10635
  // Addition #2 — attack-surface completeness inventory (entry points → dispositions).
10091
- let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
10636
+ // (_entrypointInventory is hoisted above the `skipAnnotators` guard FR-PROV-029.)
10637
+ try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
10092
10638
  // R9 + R6 — relevance scoping. Runs HERE, after every finding has been
10093
10639
  // appended (multi-sink chains, cross-language chains) and after the
10094
10640
  // entry-point inventory exists, so no finding escapes annotation and the
@@ -10105,29 +10651,31 @@ function _deterministicFileTimings(timings) {
10105
10651
  });
10106
10652
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
10107
10653
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
10108
- let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
10654
+ // (_rootCauseSweep/_proofCoverage/_coverageLedger/_scanHealth are hoisted
10655
+ // above the `skipAnnotators` guard — FR-PROV-029.)
10656
+ try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
10109
10657
  // PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
10110
10658
  // computed over the provable subset makes a narrow subset look like strength;
10111
10659
  // the three-bucket split (provable / declined-on-purpose / not-yet-classified)
10112
10660
  // is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
10113
- let _proofCoverage = null;
10114
10661
  try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
10115
10662
  // FR-203: per-file/per-analyzer coverage ledger, computed from exactly
10116
10663
  // the signals FR-201 (_detectorErrors) and FR-202 (the _timeout:true
10117
10664
  // marker finding) already produce -- files actually scanned come from
10118
10665
  // fc's own keys (skipped-for-size/density files were never added to it).
10119
10666
  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 });
10667
+ _coverageLedger = computeCoverageLedger({ files: Object.keys(fc), detectorErrors: _detectorErrors, timedOutFiles: _timedOutFiles });
10121
10668
  // FR-206 (assurance-hardening PRD, Milestone 0): additive scan-health
10122
10669
  // summary, computed from signals the engine already collects.
10123
10670
  // `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
10124
10671
  // until FR-203's coverage ledger existed to compute it for real.
10125
- let _scanHealth = computeScanHealth({
10672
+ _scanHealth = computeScanHealth({
10126
10673
  scanMeta: _scanMeta,
10127
10674
  annotatorErrors: _annotatorErrors,
10128
10675
  engineErrors: { cppDataflowParseErrors: _cppDataflowParseErrors.value },
10129
10676
  deepStatus: _deepStatus,
10130
10677
  analyzerCoverage: summarizeCoverageForScanHealth(_coverageLedger),
10678
+ lineageStatus: _lineageStatus,
10131
10679
  });
10132
10680
  // FR-207: stale vulnerability feeds, calibration data, and compliance
10133
10681
  // evidence are real assurance gaps, not just findings the feed omits --
@@ -10142,7 +10690,8 @@ function _deterministicFileTimings(timings) {
10142
10690
  calibration: calibrationFreshness(),
10143
10691
  compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
10144
10692
  });
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};}
10693
+ } // end if (!skipAnnotators) — FR-PROV-029
10694
+ 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,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus};}
10146
10695
 
10147
10696
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
10148
10697
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -10609,6 +11158,7 @@ export {
10609
11158
  classifyOrphans, classifyField, classifyEndpoint, shouldScan,
10610
11159
  _isFalsePositiveCredential, _detectSafeSinkShape,
10611
11160
  _loadCustomRules, _isCustomSuppressed, _isPathIgnored,
11161
+ _snapshotSuppressionLog, _restoreSuppressionLog,
10612
11162
  scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
10613
11163
  payloadsForFinding, buildProofObligation,
10614
11164
  DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,