@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -2130,6 +2130,8 @@ function _unionAnnotationTaint(fn, entrySet) {
2130
2130
  if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
2131
2131
  entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
2132
2132
  }
2133
+ // DAG-08: access paths seeded by a bound boundary contract (lineage/boundary-contract.js). Absent unless a contract bound to this function.
2134
+ if (Array.isArray(fn.boundarySeeds) && fn.boundarySeeds.length) entrySet = new Set([...entrySet, ...fn.boundarySeeds]);
2133
2135
  if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
2134
2136
  const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
2135
2137
  if (!extra.size) return entrySet;
@@ -2359,9 +2361,9 @@ function stateEq(a, b) {
2359
2361
  // that runs out used to leave a result that looked complete; now `runTaintEngine`'s return value carries `analysisIncomplete`, and the scan
2360
2362
  // reports it as `deepStatus.incomplete`.
2361
2363
  let _incomplete = new Map();
2362
- function _noteIncomplete(reason, detail) {
2364
+ function _noteIncomplete(reason, detail, n = 1) {
2363
2365
  const cur = _incomplete.get(reason);
2364
- if (cur) cur.count += 1; else _incomplete.set(reason, { reason, count: 1, example: String(detail ?? '').slice(0, 160) });
2366
+ if (cur) cur.count += n; else _incomplete.set(reason, { reason, count: n, example: String(detail ?? '').slice(0, 160) });
2365
2367
  }
2366
2368
  export const INCOMPLETE_REASONS = Object.freeze({
2367
2369
  'summary-budget': 'the interprocedural summary budget ran out; later callees were treated as having no effect',
@@ -2369,6 +2371,10 @@ export const INCOMPLETE_REASONS = Object.freeze({
2369
2371
  'function-limit': 'more functions than the per-scan function limit; the rest were not analysed',
2370
2372
  'higher-order-limit': 'more callback invocations than the per-function limit; the rest were not analysed',
2371
2373
  'analysis-error': 'the analysis of a function threw and its result was discarded',
2374
+ // DAG-07 R07.3
2375
+ 'context-cap-fallback': 'a call under a tainted argument shape was answered with the empty-entry summary because the per-function context cap was reached',
2376
+ 'recursion-nonconverged': 'a recursive summary was still changing when its refinement rounds ran out',
2377
+ 'incremental-empty-entry-reuse': 'a summary restored from a previous scan stood in for a tainted call context',
2372
2378
  });
2373
2379
 
2374
2380
  export function runTaintEngine(perFileIR, callGraph, opts = {}) {
@@ -2401,6 +2407,9 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2401
2407
  // v0.69 — opts.summaryCache lets the caller (runDeepAnalysis with
2402
2408
  // incremental mode) hand in a pre-seeded cache from persisted state.
2403
2409
  const summaryCache = opts.summaryCache || new SummaryCache();
2410
+ // DAG-05: cross-scan reuse. `_replay` (incremental-replay.js) records what each function
2411
+ // contributed and, for a function the dependency plan proved clean, replays it.
2412
+ const _replay = opts._replay || null;
2404
2413
 
2405
2414
  // Deterministic ordering (Sentinel-parity §9.2): sort functions by qid so
2406
2415
  // cache-cold runs produce the same finding sequence run-over-run.
@@ -2408,6 +2417,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2408
2417
  a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
2409
2418
  );
2410
2419
  _buildRegexNames(fnList);
2420
+ if (_replay) _replay.setMainLoop(new Set(fnList.slice(0, fnLimit).map((f) => f.qid))); // DAG-05
2411
2421
  _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs);
2412
2422
  // PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
2413
2423
  // silently `break`s once `n > fnLimit` (default 5000), which for a single
@@ -2438,6 +2448,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2438
2448
  // unbounded blowup). v0.66 — the inner ctx now records mutatedParams
2439
2449
  // via _mutatedParamsOut so cross-function param mutation propagates.
2440
2450
  const MAX_FP_ITERS = 3;
2451
+ let _fpConverged = false; // DAG-05: did the pre-pass reach a fixed point within MAX_FP_ITERS
2441
2452
  // qid -> this function's own exit-state (every tainted access path at
2442
2453
  // return, from the LAST pre-pass iteration only — see the class-field
2443
2454
  // cross-taint pass below for why this must be a real, already-observed
@@ -2452,6 +2463,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2452
2463
  let changedThisIter = false;
2453
2464
  for (const fn of fnList) {
2454
2465
  if (Date.now() > deadlineMs) break;
2466
+ // DAG-05 begin: a function proved clean is not re-walked; its recorded exit state and findings stand in.
2467
+ if (_replay && _replay.canSkip(fn.qid)) {
2468
+ exitStateByQid.set(fn.qid, _replay.exitState(fn.qid));
2469
+ if (it === 0) _replayFindings(fn.qid);
2470
+ continue;
2471
+ }
2472
+ // DAG-05 end
2455
2473
  const entry = new Set();
2456
2474
  const key = fn.qid + '::empty';
2457
2475
  const existing = summaryCache.get(fn.qid, entry);
@@ -2506,8 +2524,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2506
2524
  // write, silently delivering 2 rounds of fixed-point refinement instead
2507
2525
  // of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
2508
2526
  // `changedThisIter` tracks actual value changes instead.
2509
- if (!changedThisIter) break;
2527
+ if (!changedThisIter) { _fpConverged = true; break; }
2510
2528
  }
2529
+ // DAG-05 begin
2530
+ if (_replay) { _replay.recordExitStates(exitStateByQid); _replay.noteConverged(_fpConverged); }
2531
+ // DAG-05 end
2511
2532
  // Class-field cross-taint pass (SARD_80_F1_SCANNER_PRD.md — Juliet flow
2512
2533
  // variants 45/65-68: a method writes a tainted value to a static or
2513
2534
  // instance field and a SIBLING method of the same class reads it into a
@@ -2836,6 +2857,9 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2836
2857
  if (++n > fnLimit) { _noteIncomplete('function-limit', `limit ${fnLimit}`); break; }
2837
2858
  if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'main function loop'); break; } // global timeout
2838
2859
  if (onProgress) onProgress({ current: n, total: _progressTotal });
2860
+ // DAG-05 begin: replayed in the pre-pass; idempotent here for a pre-pass cut short.
2861
+ if (_replay && _replay.canSkip(fn.qid)) { _replayFindings(fn.qid); continue; }
2862
+ // DAG-05 end
2839
2863
  // Module-level functions: analyze with an empty entry state. The function
2840
2864
  // discovers its own sources from req.body/process.env/etc. as it walks.
2841
2865
  const callContext = {
@@ -2934,6 +2958,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2934
2958
  }
2935
2959
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
2936
2960
  if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
2961
+ if (typeof summaryCache.completionNotes === 'function') for (const n of summaryCache.completionNotes()) _noteIncomplete(n.reason, n.example, n.count);
2937
2962
  Object.defineProperty(all, 'analysisIncomplete', { value: [..._incomplete.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1)), enumerable: false });
2938
2963
  _collapseNestedHsSinks(all);
2939
2964
  return all;
@@ -2963,10 +2988,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2963
2988
  // this fix.
2964
2989
  const file = f.file || attributedFn.file;
2965
2990
  const key = `${f.sinkId}:${file}:${f.line}${f.siteKey ? ':' + f.siteKey : ''}`;
2966
- if (seen.has(key)) continue;
2991
+ // DAG-05: a duplicate is still part of what THIS function contributed (the first
2992
+ // collector may be a function that later changes), so it is recorded; it is not re-emitted.
2993
+ const _dup = seen.has(key);
2994
+ if (_dup && !_replay) continue;
2967
2995
  seen.add(key);
2968
2996
  const fn = attributedFn;
2969
- all.push({
2997
+ const _mapped = {
2970
2998
  id: `ir-taint:${file}:${f.line}:${f.sinkId}${f.siteKey ? ':' + f.siteKey : ''}`,
2971
2999
  ...(f.siteKey ? { siteKey: f.siteKey } : {}),
2972
3000
  file,
@@ -3049,7 +3077,21 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
3049
3077
  chain: (f.trace || []).map(t => ({
3050
3078
  file: fn.file, line: t.line, label: t.sourceLabel, provenance: t.provenance || null,
3051
3079
  })),
3052
- });
3080
+ };
3081
+ if (_replay) _replay.record(attributedFn.qid, key, _mapped);
3082
+ if (!_dup) all.push(_mapped);
3083
+ }
3084
+ }
3085
+
3086
+ // DAG-05: emit a replayed function's recorded findings through the same
3087
+ // de-duplication the collector applies, so a replayed finding and a recomputed
3088
+ // one for the same sink are one finding.
3089
+ function _replayFindings(qid) {
3090
+ for (const { key, obj } of _replay.findingsFor(qid)) {
3091
+ if (seen.has(key)) continue;
3092
+ seen.add(key);
3093
+ all.push(obj);
3094
+ _replay.noteReused(obj.id);
3053
3095
  }
3054
3096
  }
3055
3097
 
@@ -22,8 +22,13 @@
22
22
  //
23
23
  // We do NOT attempt to prove ARBITRARY satisfiability — the v2 use case.
24
24
 
25
+ import { createRequire } from 'node:module';
25
26
  import { provablyMatches, safeSanitizerOutputRegex } from './string-domain.js';
26
27
 
28
+ // Optional package, loaded at first use through a runtime require so a bundler keeps it external and a
29
+ // build without it fails here (caught below), never at start-up.
30
+ const require = createRequire(import.meta.url);
31
+
27
32
  // CWE → canonical exploit input. Conservative payloads safe to display.
28
33
  const EXPLOIT_INPUTS = {
29
34
  'CWE-89': `1' OR '1'='1`, // SQL injection
@@ -93,8 +98,7 @@ async function _maybeLoadZ3() {
93
98
  if (_z3Loaded) return _z3;
94
99
  _z3Loaded = true;
95
100
  try {
96
- const mod = await import('z3-solver').catch(() => null);
97
- _z3 = mod || null;
101
+ _z3 = require('z3-solver') || null;
98
102
  } catch { _z3 = null; }
99
103
  return _z3;
100
104
  }
@@ -158,6 +158,28 @@ export function backwardSlice(callGraph, finding, opts = {}) {
158
158
  return out.reverse(); // source-first
159
159
  }
160
160
 
161
+ /**
162
+ * Same walk as backwardSlice, plus a completion record in the shared
163
+ * vocabulary (DAG-02 R02.5): a chain cut at MAX_SLICE_DEPTH is a prefix of the
164
+ * real predecessor chain, so it reports `partial` with the depth limit as its
165
+ * reason and can never license a negative claim. backwardSlice itself is
166
+ * unchanged.
167
+ */
168
+ export function backwardSliceWithCompletion(callGraph, finding, opts = {}) {
169
+ const slice = backwardSlice(callGraph, finding, opts);
170
+ const capped = slice.some((s) => s && s.reason === 'slice-depth-cap');
171
+ return {
172
+ slice,
173
+ completion: {
174
+ status: capped ? 'partial' : 'complete',
175
+ reasons: capped ? ['slice-depth-limit'] : [],
176
+ unexploredFrontierCount: capped ? 1 : 0,
177
+ visited: slice.length,
178
+ limits: { maxDepth: MAX_SLICE_DEPTH },
179
+ },
180
+ };
181
+ }
182
+
161
183
  // ── Persistent cross-scan summary cache ────────────────────────────────────
162
184
 
163
185
  function _cachePath(scanRoot) {
@@ -0,0 +1,118 @@
1
+ // DAG-05: the engine-facing half of cross-scan reuse.
2
+ //
3
+ // `ReplayHooks` is handed to `runTaintEngine` as `opts._replay`. It does two jobs:
4
+ //
5
+ // * RECORD (every run): for each function, the findings it contributed to the
6
+ // result (every pass, after the same de-duplication key the engine uses) and
7
+ // its pre-pass exit state, so the next scan has something to reuse.
8
+ // * REPLAY (warm run): for a function the plan proved clean, hand the engine its
9
+ // recorded exit state and findings instead of re-walking its body.
10
+ //
11
+ // A function is replayable only when every one of these holds: the dependency
12
+ // plan says clean (own body, resolution, the whole closure and its cycle are
13
+ // unchanged), a complete record exists for it, and the record came from an
14
+ // analysis that converged. Anything less and the engine analyses it as before.
15
+
16
+ import { jsonSafeFinding } from './incremental.js';
17
+
18
+ const MAX_EXIT_PATHS = 4000;
19
+
20
+ export class ReplayHooks {
21
+ /**
22
+ * @param {object} o
23
+ * @param {Set<string>} [o.clean] qids that may be replayed (empty or absent: record only)
24
+ * @param {Map<string,object>} [o.records] prior records: qid -> { exit:[...], findings:[{key,obj}] }
25
+ * @param {string} [o.generation] generation id of the cache the records came from
26
+ * @param {Map<string,string>} [o.closure] qid -> closure fingerprint (for provenance)
27
+ */
28
+ constructor({ clean, records, generation, closure } = {}) {
29
+ this.clean = clean || new Set();
30
+ this.records = records || new Map();
31
+ this.generation = generation || null;
32
+ this.closure = closure || new Map();
33
+ this._found = new Map(); // qid -> Map(key -> obj) recorded this run
34
+ this._unrecordable = new Set(); // qids whose findings could not be serialised
35
+ this._exits = new Map(); // qid -> [path] recorded this run
36
+ this.converged = false;
37
+ this.mainLoop = null; // Set<qid>: the functions this run's main loop covers (the first fnLimit by qid)
38
+ this.replayed = new Set(); // qids actually skipped this run
39
+ this.reusedFindingIds = new Set();
40
+ }
41
+
42
+ /**
43
+ * The engine's main loop covers only the first `fnLimit` functions by qid, so a record
44
+ * made while a function was outside it lacks that loop's findings. Replay needs the same
45
+ * membership now as when the record was made.
46
+ */
47
+ setMainLoop(qids) { this.mainLoop = qids; }
48
+
49
+ canSkip(qid) {
50
+ if (!this.clean.has(qid)) return false;
51
+ const r = this.records.get(qid);
52
+ if (!r) return false;
53
+ return !this.mainLoop || r.inMain === this.mainLoop.has(qid);
54
+ }
55
+
56
+ exitState(qid) {
57
+ const r = this.records.get(qid);
58
+ return new Set((r && r.exit) || []);
59
+ }
60
+
61
+ /** Recorded findings for a replayed function, cloned so the engine may mutate them. */
62
+ findingsFor(qid) {
63
+ const r = this.records.get(qid);
64
+ if (!r) return [];
65
+ this.replayed.add(qid);
66
+ const out = [];
67
+ for (const { key, obj } of r.findings || []) {
68
+ const c = JSON.parse(JSON.stringify(obj));
69
+ out.push({ key, obj: c });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ noteReused(id) { this.reusedFindingIds.add(id); }
75
+
76
+ /** Called from the engine's finding collector for every mapped finding. */
77
+ record(qid, key, obj) {
78
+ if (!qid) return;
79
+ let m = this._found.get(qid);
80
+ if (!m) { m = new Map(); this._found.set(qid, m); }
81
+ if (m.has(key)) return;
82
+ const c = jsonSafeFinding(obj);
83
+ if (c === null) { this._unrecordable.add(qid); return; }
84
+ m.set(key, c);
85
+ }
86
+
87
+ recordExitStates(exitByQid) {
88
+ for (const [qid, set] of exitByQid) {
89
+ const arr = [...set].filter((p) => typeof p === 'string').sort();
90
+ this._exits.set(qid, arr);
91
+ }
92
+ }
93
+
94
+ noteConverged(v) { this.converged = !!v; }
95
+
96
+ /**
97
+ * The records to persist for the next scan: a clean function carries its prior
98
+ * record forward, every other function gets what was recorded this run. A
99
+ * function with an unserialisable finding or an oversized exit state gets no
100
+ * record, which makes it always recompute.
101
+ */
102
+ buildRecords(qids) {
103
+ const out = {};
104
+ for (const qid of qids) {
105
+ if (this.replayed.has(qid)) {
106
+ const r = this.records.get(qid);
107
+ if (r) out[qid] = r;
108
+ continue;
109
+ }
110
+ if (this._unrecordable.has(qid)) continue;
111
+ const exit = this._exits.get(qid);
112
+ if (!exit || exit.length > MAX_EXIT_PATHS) continue;
113
+ const found = this._found.get(qid) || new Map();
114
+ out[qid] = { exit, inMain: this.mainLoop ? this.mainLoop.has(qid) : true, findings: [...found].map(([key, obj]) => ({ key, obj })) };
115
+ }
116
+ return out;
117
+ }
118
+ }
@@ -0,0 +1,257 @@
1
+ // DAG-05: one incremental session per deep-analysis run.
2
+ //
3
+ // begin read the persisted state, fingerprint the current project, decide warm
4
+ // or cold (and say why), restore the typed contexts of clean functions
5
+ // finish stamp cache provenance on the findings, persist the new generation
6
+ // atomically, and describe what was reused versus recomputed
7
+ //
8
+ // The decision is made from CONTENT (dep-fingerprint.js). Any doubt is cold:
9
+ // an unreadable, corrupt, incomplete or incompatible cache, a changed global
10
+ // input, an unsupported option, a prior analysis that was cut short or did not
11
+ // converge. A cold run is a full analysis and reports `mode: 'cold'` with the
12
+ // reason, so a missing speed-up is never silent and a wrong answer never
13
+ // results from a cache that could not be trusted.
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import * as crypto from 'node:crypto';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { SummaryCache } from './summaries.js';
21
+ import {
22
+ readIncrementalState, validateIncrementalState, hashFileContent, diffFileHashes,
23
+ serializeContexts, restoreContexts, commitIncrementalState, CONTEXT_SCHEMA,
24
+ } from './incremental.js';
25
+ import { ReplayHooks } from './incremental-replay.js';
26
+ import {
27
+ buildDependencyModel, globalComponents, planIncremental, countByReason, diffComponents, DEP_GRAPH_SCHEMA,
28
+ } from './dep-fingerprint.js';
29
+ import { loadProjectStubs } from '../ir/type-stubs.js';
30
+
31
+ const REPLAY_SCHEMA = 'replay/1';
32
+
33
+ // Options whose effect on a function's result is not captured by the function's own
34
+ // fingerprint, so reuse under them is refused rather than guessed.
35
+ const UNSUPPORTED_FOR_REPLAY = [
36
+ ['AGENTIC_SECURITY_POINTS_TO', 'a whole-program points-to graph feeds every function'],
37
+ ['AGENTIC_SECURITY_RHS', 'reachability slicing restricts which functions are analysed'],
38
+ ['AGENTIC_SECURITY_KCFA_CALLSTRING', 'call-string contexts key summaries by caller'],
39
+ ['AGENTIC_SECURITY_BACKWARD_SLICE', 'the backward slicer needs live IR references a stored finding does not have'],
40
+ ['AGENTIC_SECURITY_SYMEXEC', 'the exploit prover needs live IR references a stored finding does not have'],
41
+ ];
42
+ // Reasons that make a finished run's records untrustworthy for reuse.
43
+ const BLOCKING_INCOMPLETE = new Set(['deadline', 'analysis-error', 'summary-budget']);
44
+
45
+ let _scannerVersion = null;
46
+ function scannerVersion() {
47
+ if (_scannerVersion) return _scannerVersion;
48
+ try {
49
+ const here = path.dirname(fileURLToPath(import.meta.url));
50
+ for (const rel of ['../../package.json', '../package.json', './package.json']) {
51
+ try { const v = JSON.parse(fs.readFileSync(path.resolve(here, rel), 'utf8')).version; if (v) { _scannerVersion = v; return v; } } catch { /* next */ }
52
+ }
53
+ } catch { /* fall through */ }
54
+ _scannerVersion = 'unknown';
55
+ return _scannerVersion;
56
+ }
57
+
58
+ /** Reverse call graph (callee -> callers) from the dependency model's own edges. */
59
+ function callersFrom(model) {
60
+ const callers = {};
61
+ for (const [qid, f] of Object.entries(model.functions)) {
62
+ for (const c of f.callees) (callers[c] ||= []).push(qid);
63
+ }
64
+ return callers;
65
+ }
66
+
67
+ /**
68
+ * Start a session. Returns null when incremental analysis is not enabled or the
69
+ * caller supplied no scan root / file contents (nothing could be persisted).
70
+ */
71
+ export function beginIncrementalSession({ enabled, opts, perFileIR, callGraph, cha, env = process.env, now = Date.now }) {
72
+ if (!enabled || !opts || !opts.scanRoot || !opts.fileContents) return null;
73
+ const t0 = now();
74
+ const fileHashes = {};
75
+ for (const [fp, content] of Object.entries(opts.fileContents)) fileHashes[fp] = hashFileContent(content);
76
+ const global = globalComponents({
77
+ scanRoot: opts.scanRoot, fileContents: opts.fileContents, opts, env,
78
+ stubsProvider: opts._stubsProvider || ((root) => (root ? loadProjectStubs(root) : null)),
79
+ });
80
+ const rulesDigest = crypto.createHash('sha256').update(`${global.catalog}${global.sanitizers}`).digest('hex').slice(0, 16);
81
+ const version = { scanner: opts.scannerVersion || scannerVersion(), rules: opts.rulesDigest || `content:${rulesDigest}` };
82
+ const tRead = now();
83
+ const prior = readIncrementalState(opts.scanRoot);
84
+ const readMs = now() - tRead;
85
+ const t1 = now();
86
+ const reuse = prior.deps && prior.deps.global && prior.deps.global.analyzers === global.analyzers
87
+ ? { functions: prior.deps.functions, priorFiles: prior.files, curFiles: fileHashes } : null;
88
+ const model = buildDependencyModel({ perFileIR, callGraph, cha, fileContents: opts.fileContents, reuse });
89
+ const fingerprintMs = (t1 - t0) + (now() - t1);
90
+ const session = {
91
+ root: opts.scanRoot, version, global, model, fileHashes, prior, callGraph,
92
+ mode: 'cold', reason: null, plan: null, cache: null, hooks: null,
93
+ restored: { contexts: 0, rejected: 0 }, fingerprintMs, priorGeneration: prior.generation || null,
94
+ fileDiff: null, io: opts._incrementalIo || {}, timing: { fingerprintMs, readMs, planMs: 0, restoreMs: 0 },
95
+ };
96
+ const decide = () => {
97
+ const unsupported = UNSUPPORTED_FOR_REPLAY.find(([name]) => env[name] === '1');
98
+ if (unsupported) return `replay unsupported under ${unsupported[0]}: ${unsupported[1]}`;
99
+ if (!Object.keys(model.functions).length) return 'no analysable functions in this project, so there is nothing to reuse';
100
+ const valid = validateIncrementalState(prior, version);
101
+ if (!valid.valid) {
102
+ // name the content that moved when the version string alone does not
103
+ const moved = prior.deps && prior.deps.global ? diffComponents(prior.deps.global, global) : [];
104
+ return moved.length && /rule-pack/.test(valid.reason) ? `${valid.reason} (${moved.join(', ')})` : valid.reason;
105
+ }
106
+ if (!prior.deps) return 'prior state has no dependency model';
107
+ if (!prior.replay || prior.replay.schema !== REPLAY_SCHEMA) return 'prior state has no replay records';
108
+ if (!prior.replay.complete) return `prior analysis was incomplete (${(prior.replay.reasons || []).join(', ') || 'unspecified'})`;
109
+ if (!prior.replay.converged) return 'prior fixed point did not converge';
110
+ if (prior.deps.schema !== DEP_GRAPH_SCHEMA) return 'prior dependency graph has an incompatible schema';
111
+ return null;
112
+ };
113
+ const why = decide();
114
+ session.fileDiff = diffFileHashes(prior.files || {}, fileHashes);
115
+ if (why) {
116
+ session.reason = why;
117
+ session.hooks = new ReplayHooks();
118
+ return session;
119
+ }
120
+ const tPlan = now();
121
+ const records = new Map(Object.entries(prior.replay.records || {}));
122
+ const plan = planIncremental(
123
+ { global: prior.deps.global || {}, deps: prior.deps, hasRecord: (q) => records.has(q) },
124
+ { global, model },
125
+ );
126
+ if (plan.mode === 'cold') {
127
+ session.reason = plan.reason;
128
+ session.plan = plan;
129
+ session.hooks = new ReplayHooks();
130
+ return session;
131
+ }
132
+ session.timing.planMs = now() - tPlan;
133
+ const tRestore = now();
134
+ const cache = new SummaryCache();
135
+ const stats = restoreContexts(cache, { schema: CONTEXT_SCHEMA, contexts: prior.replay.contexts }, plan.clean);
136
+ // A clean function whose empty-entry context could not be restored cannot be replayed.
137
+ for (const q of [...plan.clean]) {
138
+ if (!cache.has(q, new Set())) { plan.clean.delete(q); plan.dirty.set(q, 'context-not-restorable'); }
139
+ }
140
+ session.timing.restoreMs = now() - tRestore;
141
+ const closure = new Map(Object.entries(model.functions).map(([q, f]) => [q, f.closure]));
142
+ session.mode = 'warm';
143
+ session.plan = plan;
144
+ session.cache = cache;
145
+ session.restored = stats;
146
+ session.hooks = new ReplayHooks({ clean: plan.clean, records, generation: prior.generation, closure });
147
+ return session;
148
+ }
149
+
150
+ /** Options to merge into the runTaintEngine call. */
151
+ export function engineOptionsFor(session) {
152
+ if (!session) return {};
153
+ return { _replay: session.hooks, ...(session.cache ? { summaryCache: session.cache } : {}) };
154
+ }
155
+
156
+ /**
157
+ * Finish: stamp provenance, persist the new generation, return the stats object
158
+ * to attach to the findings array.
159
+ */
160
+ export function finishIncrementalSession(session, findings, { now = Date.now } = {}) {
161
+ if (!session) return null;
162
+ const hooks = session.hooks;
163
+ const model = session.model;
164
+ // provenance on every finding
165
+ const gen = session.priorGeneration;
166
+ let reused = 0; let recomputed = 0;
167
+ for (const f of findings) {
168
+ if (hooks && hooks.reusedFindingIds.has(f.id)) {
169
+ f.cacheProvenance = { state: 'reused', generation: gen, closureFingerprint: model.functions[f._funcQid]?.closure ?? null };
170
+ reused++;
171
+ } else {
172
+ f.cacheProvenance = { state: 'recomputed' };
173
+ recomputed++;
174
+ }
175
+ }
176
+ const incomplete = Array.isArray(findings.analysisIncomplete) ? findings.analysisIncomplete : [];
177
+ const blocking = incomplete.filter((x) => BLOCKING_INCOMPLETE.has(x.reason)).map((x) => x.reason);
178
+ const nonConverged = incomplete.some((x) => x.reason === 'recursion-nonconverged');
179
+ const cache = findings._summaryCache;
180
+ const t0 = now();
181
+ let committed = false;
182
+ let commitError = null;
183
+ const persistPhases = {};
184
+ let lapT = now();
185
+ const lap = (n) => { const x = now(); persistPhases[n] = x - lapT; lapT = x; };
186
+ try {
187
+ const ctx = serializeContexts(cache);
188
+ lap('serialize-contexts');
189
+ const records = hooks.buildRecords(Object.keys(model.functions));
190
+ lap('build-records');
191
+ const replay = {
192
+ schema: REPLAY_SCHEMA,
193
+ complete: blocking.length === 0,
194
+ reasons: [...new Set(blocking)].sort(),
195
+ converged: hooks.converged && !nonConverged,
196
+ contexts: ctx.contexts, contextsNotPersisted: ctx.skipped, records,
197
+ };
198
+ const deps = {
199
+ schema: DEP_GRAPH_SCHEMA, global: session.global, chaDigest: model.chaDigest,
200
+ functions: model.functions, fileSymbols: model.fileSymbols, sccs: model.sccs,
201
+ };
202
+ lap('assemble');
203
+ committed = commitIncrementalState(session.root, {
204
+ files: session.fileHashes,
205
+ // the typed contexts live in replay.json; summaries.json keeps the legacy reverse call graph only
206
+ summaries: {},
207
+ callers: callersFrom(model),
208
+ deps, replay,
209
+ }, session.version, session.io);
210
+ lap('commit');
211
+ if (!committed) commitError = 'state not written (state writes disabled, unwritable directory, or an interrupted write)';
212
+ } catch (e) { commitError = String((e && e.message) || e); }
213
+ const persistMs = now() - t0;
214
+
215
+ const plan = session.plan;
216
+ const cleanN = plan && plan.clean ? plan.clean.size : 0;
217
+ const replayed = hooks ? hooks.replayed.size : 0;
218
+ const fnTotal = Object.keys(model.functions).length;
219
+ return {
220
+ // legacy fields the existing tests and consumers read
221
+ seeded: session.restored.restored ?? 0, reusable: replayed, discardedLegacy: 0,
222
+ mode: session.mode,
223
+ reason: session.mode === 'cold' ? session.reason : null,
224
+ priorGeneration: gen,
225
+ provenance: {
226
+ reusedFunctions: replayed,
227
+ recomputedFunctions: Math.max(0, fnTotal - replayed),
228
+ reusedFindings: reused,
229
+ recomputedFindings: recomputed,
230
+ restoredContexts: session.restored.restored ?? 0,
231
+ contextsRejected: session.restored.rejected ?? 0,
232
+ },
233
+ invalidation: plan ? {
234
+ cleanFunctions: cleanN,
235
+ dirtyFunctions: plan.dirty.size,
236
+ dirtyByReason: countByReason(plan.dirty),
237
+ // the first 500 recomputed functions and why, so a reader can see what the edit reached
238
+ dirty: [...plan.dirty].sort((a, b) => (a[0] < b[0] ? -1 : 1)).slice(0, 500).map(([qid, reason]) => ({ qid, reason })),
239
+ removedSymbols: plan.removed,
240
+ removedFiles: plan.removedFiles || [],
241
+ movedOrRenamedSymbols: plan.renamed || [],
242
+ sccWidened: plan.widened || 0,
243
+ changedGlobalComponents: plan.changedComponents || [],
244
+ } : null,
245
+ files: { changed: session.fileDiff.changed.length, added: session.fileDiff.added.length, removed: session.fileDiff.removed.length, unchanged: session.fileDiff.unchanged.length },
246
+ model: { ...model.stats, fingerprintMs: session.fingerprintMs },
247
+ timing: session.timing,
248
+ persisted: { committed, error: commitError, ms: persistMs, phaseMs: persistPhases },
249
+ // DAG-07 R07.3 shape: reuse proven equivalent by fingerprint is complete; a cold fallback is a full analysis.
250
+ completion: {
251
+ status: 'complete',
252
+ reasons: [],
253
+ emptyEntryReusedForTaintedContexts: 0,
254
+ summariesWithTruncatedFindings: 0,
255
+ },
256
+ };
257
+ }