@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
@@ -35,42 +35,77 @@ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
35
35
 
36
36
  const FILES_PATH = 'files.json';
37
37
  const SUMMARIES_PATH = 'summaries.json';
38
- const VERSION_PATH = 'version.json';
38
+ const VERSION_PATH = 'version.json'; // the MANIFEST: written last, names and digests every data file
39
+ const DEPS_PATH = 'deps.json'; // DAG-05: dependency model + global components
40
+ const REPLAY_PATH = 'replay.json'; // DAG-05: typed contexts, exit states, per-function findings
41
+ const DATA_FILES = [FILES_PATH, SUMMARIES_PATH, DEPS_PATH, REPLAY_PATH];
39
42
  const MAX_PERSISTED_SUMMARIES = 50000;
43
+ // DAG-05 R05.6: bump when the on-disk shape changes; an older or newer record is rejected, never guessed at.
44
+ export const INCREMENTAL_FORMAT = 'incremental-state/2';
40
45
 
41
46
  /** Compute the content hash used for file-equality detection. */
42
47
  export function hashFileContent(stripped) {
43
48
  return crypto.createHash('sha256').update(stripped || '').digest('hex');
44
49
  }
45
50
 
46
- /** Read the persisted state. Returns a fresh empty state on any error. */
51
+ /**
52
+ * Read the persisted state. Returns a fresh empty state on any error.
53
+ *
54
+ * DAG-05 R05.6: the version file is a manifest that names every data file with
55
+ * its SHA-256. A state is accepted only when the format is the current one, every
56
+ * named file exists and matches its digest and parses, so a crash between the
57
+ * temp write and the rename, a truncated file or a hand edit is rejected as a
58
+ * whole. A rejection is carried as `state.rejected` (the reason) so the caller
59
+ * can say why it fell back to cold analysis.
60
+ */
47
61
  export function readIncrementalState(projectRoot) {
48
62
  const dir = statePath(projectRoot, 'incremental');
63
+ const versionFp = path.join(dir, VERSION_PATH);
64
+ let manifest;
49
65
  try {
50
- const versionFp = path.join(dir, VERSION_PATH);
51
66
  if (!fs.existsSync(versionFp)) return _emptyState();
52
- const v = JSON.parse(fs.readFileSync(versionFp, 'utf8'));
53
- return {
54
- version: v,
55
- files: _readJsonOrEmpty(path.join(dir, FILES_PATH), {}),
56
- summaries: _readJsonOrEmpty(path.join(dir, SUMMARIES_PATH), {}),
57
- };
67
+ manifest = JSON.parse(fs.readFileSync(versionFp, 'utf8'));
58
68
  } catch (_e) {
59
- return _emptyState();
69
+ return _rejected('cache manifest is corrupt (does not parse)');
70
+ }
71
+ if (!manifest || typeof manifest !== 'object' || manifest.format !== INCREMENTAL_FORMAT) {
72
+ return _rejected(`cache format is not ${INCREMENTAL_FORMAT} (found ${manifest && manifest.format ? manifest.format : 'legacy'})`);
73
+ }
74
+ const listed = manifest.files && typeof manifest.files === 'object' ? manifest.files : null;
75
+ if (!listed || !manifest.version || typeof manifest.version !== 'object') return _rejected('cache manifest is incomplete');
76
+ const parsed = {};
77
+ for (const name of DATA_FILES) {
78
+ const entry = listed[name];
79
+ if (!entry || typeof entry.sha256 !== 'string') return _rejected(`cache manifest does not list ${name}`);
80
+ let text;
81
+ try { text = fs.readFileSync(path.join(dir, name), 'utf8'); }
82
+ catch { return _rejected(`incomplete cache: ${name} is missing`); }
83
+ const got = crypto.createHash('sha256').update(text).digest('hex');
84
+ if (got !== entry.sha256) return _rejected(`cache record ${name} failed its integrity digest (interrupted write or corruption)`);
85
+ try { parsed[name] = JSON.parse(text); }
86
+ catch { return _rejected(`cache record ${name} does not parse`); }
60
87
  }
88
+ if (!parsed[FILES_PATH] || typeof parsed[FILES_PATH] !== 'object' || Array.isArray(parsed[FILES_PATH])) return _rejected('cache record files.json has the wrong shape');
89
+ if (!parsed[SUMMARIES_PATH] || typeof parsed[SUMMARIES_PATH] !== 'object') return _rejected('cache record summaries.json has the wrong shape');
90
+ if (parsed[DEPS_PATH] !== null && (typeof parsed[DEPS_PATH] !== 'object' || (parsed[DEPS_PATH] && parsed[DEPS_PATH].schema !== 'dep-graph/1'))) {
91
+ return _rejected('cache dependency graph has an incompatible schema');
92
+ }
93
+ return {
94
+ version: manifest.version,
95
+ generation: manifest.generation || null,
96
+ files: parsed[FILES_PATH],
97
+ summaries: parsed[SUMMARIES_PATH],
98
+ deps: parsed[DEPS_PATH],
99
+ replay: parsed[REPLAY_PATH],
100
+ };
61
101
  }
62
102
 
63
103
  function _emptyState() {
64
- return { version: null, files: {}, summaries: {} };
104
+ return { version: null, files: {}, summaries: {}, deps: null, replay: null };
65
105
  }
66
106
 
67
- function _readJsonOrEmpty(fp, fallback) {
68
- try {
69
- if (!fs.existsSync(fp)) return fallback;
70
- return JSON.parse(fs.readFileSync(fp, 'utf8'));
71
- } catch (_e) {
72
- return fallback;
73
- }
107
+ function _rejected(reason) {
108
+ return { ..._emptyState(), rejected: reason };
74
109
  }
75
110
 
76
111
  /**
@@ -78,6 +113,7 @@ function _readJsonOrEmpty(fp, fallback) {
78
113
  * Returns `{ valid: boolean, reason?: string }`.
79
114
  */
80
115
  export function validateIncrementalState(state, currentVersion) {
116
+ if (state && state.rejected) return { valid: false, reason: state.rejected };
81
117
  if (!state || !state.version) return { valid: false, reason: 'no prior state' };
82
118
  if (!currentVersion) return { valid: false, reason: 'no current version' };
83
119
  if (state.version.scanner !== currentVersion.scanner) return { valid: false, reason: 'scanner version changed' };
@@ -163,7 +199,19 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
163
199
  mutatedThisFields: new Set(s.mutatedThisFields || []),
164
200
  taintedGlobals: new Set(s.taintedGlobals || []),
165
201
  findings: Array.isArray(s.findings) ? s.findings : [],
202
+ // DAG-07 R07.3: provenance of a restored summary. Never serialized
203
+ // (serializeSummaries copies named fields only), so it cannot leak back
204
+ // into the persisted state; SummaryCache reads it to tell a restored
205
+ // empty-entry summary standing in for a tainted context from a computed one.
206
+ _incrementalSeed: true,
166
207
  };
208
+ if (Number.isInteger(s.findingsTruncated) && s.findingsTruncated > 0) {
209
+ // Findings beyond the persist cap were dropped when this was saved, so
210
+ // reuse is a partial replay of what the function reported.
211
+ summary._findingsTruncated = s.findingsTruncated;
212
+ if (summaryCache._incrementalFindingsTruncated === undefined) summaryCache._incrementalFindingsTruncated = 0;
213
+ summaryCache._incrementalFindingsTruncated += 1;
214
+ }
167
215
  // Use the bottom taint-state key — these are summaries that DON'T depend
168
216
  // on entry taint state (e.g., pure functions). Higher-fidelity reuse
169
217
  // would require persisting the entry-state hash too; deferred.
@@ -198,59 +246,213 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
198
246
  export function serializeSummaries(summaryCache) {
199
247
  const out = {};
200
248
  if (!summaryCache || !summaryCache._cache) return out;
249
+ // DAG-07 R07.3. The cache key is `<qid>::<entryHash>[@caller][::receiver]`
250
+ // and a qid itself contains `::`, so the old `key.split('::')[0]` reduced
251
+ // every key to its FILE and let whichever entry context was written last
252
+ // stand for the whole file. Only the EMPTY-entry summary is a statement
253
+ // about what a function does when nothing tainted flows in; a summary
254
+ // computed under a tainted entry must never be restored as that. Parse the
255
+ // key from the right, persist the empty-entry summary under the real qid,
256
+ // and count what was not persisted so reuse can be reported honestly.
257
+ const EMPTY_SUFFIX = '::empty';
201
258
  let count = 0;
259
+ const skippedContexts = new Map();
202
260
  for (const [key, summary] of summaryCache._cache) {
203
- if (count >= MAX_PERSISTED_SUMMARIES) break;
204
261
  if (!summary || summary._budgetExceeded || summary._recursive) continue;
205
- const qid = key.split('::')[0];
262
+ if (!key.endsWith(EMPTY_SUFFIX)) {
263
+ const m = /^(.*?::[^:]*@\d+)::/.exec(key);
264
+ const q = m ? m[1] : key;
265
+ skippedContexts.set(q, (skippedContexts.get(q) || 0) + 1);
266
+ continue;
267
+ }
268
+ const qid = key.slice(0, -EMPTY_SUFFIX.length);
206
269
  if (!qid) continue;
270
+ if (count >= MAX_PERSISTED_SUMMARIES) break;
271
+ const findings = Array.isArray(summary.findings) ? summary.findings : [];
207
272
  out[qid] = {
208
273
  returnTainted: !!summary.returnTainted,
209
274
  mutatedParams: [...(summary.mutatedParams || [])],
210
275
  mutatedThisFields: [...(summary.mutatedThisFields || [])],
211
276
  taintedGlobals: [...(summary.taintedGlobals || [])],
212
- findings: Array.isArray(summary.findings) ? summary.findings.slice(0, 50) : [],
277
+ findings: findings.slice(0, 50),
278
+ entryContext: 'empty',
279
+ ...(findings.length > 50 ? { findingsTruncated: findings.length - 50 } : {}),
213
280
  };
214
281
  count++;
215
282
  }
283
+ for (const [q, n] of skippedContexts) if (out[q]) out[q].contextsNotPersisted = n;
216
284
  return out;
217
285
  }
218
286
 
287
+ // ── DAG-05 R05.3: typed, context-keyed persistence ──────────────────────────
288
+
289
+ /**
290
+ * A copy of a finding that survives JSON. An expression-object `callee` is kept as a structural copy:
291
+ * it is plain data, equal by value to the original, and only the backward slicer compares it by
292
+ * reference (replay is refused under that option). Returns null when the value cannot be serialised
293
+ * at all (a cycle), which makes the owner non-replayable rather than lossy.
294
+ */
295
+ export function jsonSafeFinding(f) {
296
+ try { return JSON.parse(JSON.stringify(f)); } catch { return null; }
297
+ }
298
+
299
+ export const CONTEXT_SCHEMA = 'summary-context/1';
300
+ const MAX_FINDINGS_PER_CONTEXT = 200;
301
+
302
+ /**
303
+ * Every computed context of every function, each under its COMPLETE key: qid,
304
+ * the canonical entry-taint list, the state hash derived from it and the
305
+ * receiver hash. A context is either persisted whole or counted in `skipped`
306
+ * with the reason; it is never reduced to the empty-entry slot.
307
+ *
308
+ * { schema, contexts: { qid: [ { entry:[...], stateHash, recv, summary } ] }, skipped: { reason: n } }
309
+ */
310
+ export function serializeContexts(summaryCache) {
311
+ const contexts = {};
312
+ const skipped = {};
313
+ const note = (r) => { skipped[r] = (skipped[r] || 0) + 1; };
314
+ if (!summaryCache || !summaryCache._cache) return { schema: CONTEXT_SCHEMA, contexts, skipped };
315
+ const meta = summaryCache._meta;
316
+ let count = 0;
317
+ for (const [key, summary] of summaryCache._cache) {
318
+ if (!summary) continue;
319
+ if (summary._budgetExceeded) { note('budget-exceeded'); continue; }
320
+ if (summary._recursive) { note('recursion-stub'); continue; }
321
+ const m = meta && meta.get(key);
322
+ if (!m) { note('no-key-metadata'); continue; }
323
+ const findings = Array.isArray(summary.findings) ? summary.findings : [];
324
+ if (findings.length > MAX_FINDINGS_PER_CONTEXT) { note('findings-over-cap'); continue; }
325
+ const safe = [];
326
+ let ok = true;
327
+ for (const f of findings) { const c = jsonSafeFinding(f); if (c === null) { ok = false; break; } safe.push(c); }
328
+ if (!ok) { note('findings-not-serialisable'); continue; }
329
+ if (count >= MAX_PERSISTED_SUMMARIES) { note('persist-cap'); continue; }
330
+ (contexts[m.qid] ||= []).push({
331
+ entry: m.entry, stateHash: m.stateHash, recv: m.recv || null,
332
+ summary: {
333
+ returnTainted: !!summary.returnTainted,
334
+ mutatedParams: [...(summary.mutatedParams || [])].sort(),
335
+ mutatedThisFields: [...(summary.mutatedThisFields || [])].sort(),
336
+ taintedGlobals: [...(summary.taintedGlobals || [])].sort(),
337
+ findings: safe,
338
+ },
339
+ });
340
+ count++;
341
+ }
342
+ for (const q of Object.keys(contexts)) contexts[q].sort((a, b) => (a.stateHash + (a.recv || '') < b.stateHash + (b.recv || '') ? -1 : 1));
343
+ return { schema: CONTEXT_SCHEMA, contexts, skipped };
344
+ }
345
+
346
+ /**
347
+ * Restore persisted contexts for the given function ids. Each record is checked
348
+ * (shape, entry list reproduces its state hash) before it is stored under its
349
+ * real key; a record that fails is counted and ignored, so the function simply
350
+ * recomputes that context. Returns { restored, rejected }.
351
+ */
352
+ export function restoreContexts(summaryCache, persisted, qids) {
353
+ let restored = 0; let rejected = 0;
354
+ if (!summaryCache || !persisted || persisted.schema !== CONTEXT_SCHEMA || !persisted.contexts) return { restored, rejected };
355
+ for (const qid of qids) {
356
+ for (const rec of persisted.contexts[qid] || []) {
357
+ const s = rec && rec.summary;
358
+ if (!s || !Array.isArray(rec.entry) || typeof rec.stateHash !== 'string' || !Array.isArray(s.findings)) { rejected++; continue; }
359
+ const summary = {
360
+ returnTainted: !!s.returnTainted,
361
+ mutatedParams: new Set(s.mutatedParams || []),
362
+ mutatedThisFields: new Set(s.mutatedThisFields || []),
363
+ taintedGlobals: new Set(s.taintedGlobals || []),
364
+ findings: s.findings,
365
+ };
366
+ if (summaryCache.restoreContext(qid, rec.entry, rec.recv || null, rec.stateHash, summary)) restored++; else rejected++;
367
+ }
368
+ }
369
+ return { restored, rejected };
370
+ }
371
+
219
372
  /**
220
- * Commit incremental state to disk. Idempotent — safe to call from anywhere.
373
+ * Commit incremental state to disk. Idempotent: safe to call from anywhere.
221
374
  *
222
375
  * state.files { filepath: sha256 }
223
376
  * state.summaries { qid: summary } (output of serializeSummaries)
224
377
  * state.callers { qid: [callerQid] } (reverse call-graph)
378
+ * state.deps DAG-05 dependency model + global components (or null)
379
+ * state.replay DAG-05 typed contexts / exit states / findings (or null)
225
380
  * currentVersion { scanner, rules }
381
+ *
382
+ * DAG-05 R05.6: atomic. Every data file is written to a uniquely named temp file
383
+ * and fsynced, then renamed over its final name, and the manifest (version.json,
384
+ * which holds each file's SHA-256) is renamed LAST. A crash anywhere in that
385
+ * sequence leaves either the previous manifest with data it no longer matches, or
386
+ * a half-renamed set; `readIncrementalState` rejects both whole, so the next scan
387
+ * is cold, never a mixture of two generations. `io` is a seam for tests that
388
+ * simulate the crash (`io.renameSync`).
226
389
  */
227
- export function commitIncrementalState(projectRoot, state, currentVersion) {
390
+ export function commitIncrementalState(projectRoot, state, currentVersion, io = {}) {
228
391
  if (!projectRoot) return false;
229
392
  const dir = statePath(projectRoot, 'incremental');
393
+ const rename = io.renameSync || fs.renameSync;
394
+ const gen = crypto.randomBytes(6).toString('hex');
395
+ const temps = [];
230
396
  try {
231
- if (!stateWritesEnabled()) return;
232
- fs.mkdirSync(dir, { recursive: true });
233
- fs.writeFileSync(path.join(dir, VERSION_PATH), JSON.stringify(currentVersion, null, 2));
234
- fs.writeFileSync(path.join(dir, FILES_PATH), JSON.stringify(state.files || {}, null, 2));
235
- const payload = {
236
- summaries: state.summaries || {},
237
- callers: state.callers || {},
397
+ if (!stateWritesEnabled()) return false;
398
+ fs.mkdirSync(dir, { recursive: true });
399
+ const payloads = {
400
+ [FILES_PATH]: state.files || {},
401
+ [SUMMARIES_PATH]: { summaries: state.summaries || {}, callers: state.callers || {} },
402
+ [DEPS_PATH]: state.deps || null,
403
+ [REPLAY_PATH]: state.replay || null,
238
404
  };
239
- fs.writeFileSync(path.join(dir, SUMMARIES_PATH), JSON.stringify(payload));
405
+ const listed = {};
406
+ for (const name of DATA_FILES) {
407
+ const text = JSON.stringify(payloads[name]);
408
+ listed[name] = { sha256: crypto.createHash('sha256').update(text).digest('hex'), bytes: Buffer.byteLength(text) };
409
+ const tmp = path.join(dir, `${name}.tmp-${gen}`);
410
+ temps.push(tmp);
411
+ _writeFileTemp(tmp, text);
412
+ }
413
+ const manifest = { format: INCREMENTAL_FORMAT, generation: gen, version: currentVersion, files: listed };
414
+ const manifestTmp = path.join(dir, `${VERSION_PATH}.tmp-${gen}`);
415
+ temps.push(manifestTmp);
416
+ _writeFileTemp(manifestTmp, JSON.stringify(manifest, null, 2));
417
+ for (const name of DATA_FILES) rename(path.join(dir, `${name}.tmp-${gen}`), path.join(dir, name));
418
+ rename(manifestTmp, path.join(dir, VERSION_PATH));
419
+ _sweepTemps(dir, gen);
240
420
  return true;
241
421
  } catch (_e) {
422
+ // Leave whatever the crash left: the reader rejects an inconsistent set. Only
423
+ // our own temp files are removed, and only the ones not yet renamed.
424
+ for (const t of temps) { try { if (fs.existsSync(t)) fs.unlinkSync(t); } catch { /* best effort */ } }
242
425
  return false;
243
426
  }
244
427
  }
245
428
 
246
- /** Drop persisted state — used when a version mismatch is detected. */
429
+ // No fsync, on purpose: the reader checks the SHA-256 of every file the manifest names, so a write
430
+ // that did not reach the disk (power loss) is detected and rejected whole and the next scan is cold.
431
+ // Durability is not what protects the cache; the digest check is. An fsync per file cost more than
432
+ // the analysis it saved on a small edit.
433
+ function _writeFileTemp(fp, text) {
434
+ fs.writeFileSync(fp, text, { mode: 0o600 });
435
+ }
436
+
437
+ /** Remove temp files left by an earlier interrupted commit (never the current one's). */
438
+ function _sweepTemps(dir, keepGen) {
439
+ try {
440
+ for (const n of fs.readdirSync(dir)) {
441
+ if (/\.tmp-[0-9a-f]+$/.test(n) && !n.endsWith(`.tmp-${keepGen}`)) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* best effort */ } }
442
+ }
443
+ } catch { /* best effort */ }
444
+ }
445
+
446
+ /** Drop persisted state, used when a version mismatch is detected. */
247
447
  export function dropIncrementalState(projectRoot) {
248
448
  const dir = statePath(projectRoot, 'incremental');
249
449
  try {
250
450
  if (!fs.existsSync(dir)) return true;
251
- for (const fn of [VERSION_PATH, FILES_PATH, SUMMARIES_PATH]) {
252
- const fp = path.join(dir, fn);
253
- if (fs.existsSync(fp)) fs.unlinkSync(fp);
451
+ for (const fn of fs.readdirSync(dir)) {
452
+ if (DATA_FILES.includes(fn) || fn === VERSION_PATH || /\.tmp-[0-9a-f]+$/.test(fn)) {
453
+ const fp = path.join(dir, fn);
454
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
455
+ }
254
456
  }
255
457
  return true;
256
458
  } catch (_e) {
@@ -6,11 +6,9 @@ import { applyPathFeasibility, buildConstantFnMap } from './path-feasibility.js'
6
6
  import { SummaryCache, entryStateFromCall } from './summaries.js';
7
7
  import { rhsReachableFunctions, shouldAnalyzeUnderRhs } from './tabulation.js';
8
8
  import { annotateBackwardSlices } from './backward.js';
9
- import {
10
- readIncrementalState, validateIncrementalState, diffFileHashes,
11
- hashFileContent, pickReusableSummaries, seedSummaryCache,
12
- serializeSummaries, commitIncrementalState,
13
- } from './incremental.js';
9
+ // DAG-05: dependency-complete incremental analysis (see incremental-session.js).
10
+ import { beginIncrementalSession, engineOptionsFor, finishIncrementalSession } from './incremental-session.js';
11
+ import { demandEnabled, runDemandQueries } from './demand-query.js'; // DAG-02
14
12
  import { buildPointsTo } from './points-to.js';
15
13
  import { buildClassHierarchy, annotateRTA } from '../ir/class-hierarchy.js';
16
14
  import { annotateSoftTaint } from './soft-taint.js';
@@ -34,57 +32,15 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
34
32
  // P2.1 — RHS-lite reachability slice. When AGENTIC_SECURITY_RHS=1 the
35
33
  // engine narrows analysis to sink-reachable functions. Default OFF
36
34
  // because it changes the finding-set composition.
35
+ let rhsCompletion = null;
37
36
  if (process.env.AGENTIC_SECURITY_RHS === '1') {
38
37
  const ctx = rhsReachableFunctions(perFileIR, callGraph);
38
+ rhsCompletion = ctx.completion || null;
39
39
  if (ctx.reachable) {
40
40
  opts = { ...opts, _rhsReachable: ctx.reachable, _rhsCheck: shouldAnalyzeUnderRhs };
41
41
  }
42
42
  }
43
- // v0.69 — cross-scan incremental cache (AGENTIC_SECURITY_INCREMENTAL=1).
44
- // Read persisted state, seed the SummaryCache with summaries from files
45
- // whose content hasn't changed, then hand it to runTaintEngine. After,
46
- // serialize the cache and commit to disk.
47
43
  const incrementalEnabled = process.env.AGENTIC_SECURITY_INCREMENTAL === '1';
48
- let preSeededCache = null;
49
- let priorState = null;
50
- let currentFileHashes = null;
51
- if (incrementalEnabled && opts.scanRoot && opts.fileContents) {
52
- priorState = readIncrementalState(opts.scanRoot);
53
- const currentVersion = {
54
- scanner: opts.scannerVersion || 'unknown',
55
- rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
56
- };
57
- // Compute current file hashes UNCONDITIONALLY — the first scan (and any
58
- // scan after a version/rule invalidation) must persist a baseline so the
59
- // NEXT scan has something to reuse. This previously lived inside the
60
- // valid-state branch, so a cold cache never committed and incremental was
61
- // a permanent no-op (the commit guard at the bottom keys on currentFileHashes).
62
- currentFileHashes = {};
63
- for (const [fp, content] of Object.entries(opts.fileContents)) {
64
- currentFileHashes[fp] = hashFileContent(content);
65
- }
66
- const valid = validateIncrementalState(priorState, currentVersion);
67
- if (valid.valid) {
68
- const diff = diffFileHashes(priorState.files || {}, currentFileHashes);
69
- const changedQids = new Set();
70
- // Map a changed file to the qids it owns. perFileIR exposes file→fns.
71
- for (const fp of [...diff.changed, ...diff.added, ...diff.removed]) {
72
- const ir = perFileIR[fp];
73
- if (!ir) continue;
74
- for (const fn of (ir.functions || [])) changedQids.add(fn.qid);
75
- }
76
- const persistedPayload = (priorState.summaries && priorState.summaries.summaries) || priorState.summaries || {};
77
- const callerOfQid = (priorState.summaries && priorState.summaries.callers) || {};
78
- const { reusable } = pickReusableSummaries(persistedPayload, callerOfQid, changedQids);
79
- preSeededCache = new SummaryCache();
80
- const seededN = seedSummaryCache(preSeededCache, persistedPayload, reusable);
81
- preSeededCache._incrementalSeeded = seededN;
82
- preSeededCache._incrementalReusable = reusable.size;
83
- } else {
84
- // Stale → caller should drop; we just don't seed.
85
- priorState = null;
86
- }
87
- }
88
44
  // PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): Class Hierarchy
89
45
  // Analysis. Built once per scan (mirrors the points-to graph immediately
90
46
  // below) and threaded through opts so the taint engine can (a) narrow
@@ -119,12 +75,18 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
119
75
  // Runs the formal Reps-Horwitz-Sagiv tabulation in parallel with the
120
76
  // worklist engine. We MERGE findings — the IFDS solver may catch
121
77
  // context-sensitive flows the k=2 cache joined out. Deduped by sink+line.
78
+ // DAG-05: the session decides warm or cold from content fingerprints (it needs the
79
+ // call graph and class hierarchy built above) and restores typed contexts.
80
+ const incrementalSession = beginIncrementalSession({ enabled: incrementalEnabled, opts, perFileIR, callGraph, cha: classHierarchy });
122
81
  let findings = runTaintEngine(perFileIR, callGraph, {
123
82
  ...opts,
124
- summaryCache: preSeededCache || undefined,
83
+ ...engineOptionsFor(incrementalSession),
125
84
  _pointsTo: pointsToGraph || undefined,
126
85
  _cha: classHierarchy || undefined,
127
86
  });
87
+ const engineOutput = findings;
88
+ // DAG-05: provenance + persistence happen right after the engine, on the engine's own array.
89
+ const incrementalStats = finishIncrementalSession(incrementalSession, engineOutput);
128
90
  if (process.env.AGENTIC_SECURITY_IFDS === '1') {
129
91
  try {
130
92
  const ifdsFindings = runIfdsTaintEngine(perFileIR, callGraph, opts);
@@ -152,16 +114,20 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
152
114
  }
153
115
  } catch { /* privacy-deep failure should not fail the scan */ }
154
116
  }
117
+ // DAG-02 begin (demand-driven sink queries; opt-in, additive, never changes `findings`)
118
+ if (demandEnabled() && Array.isArray(opts.demandQueries) && opts.demandQueries.length) {
119
+ try {
120
+ const demandResults = runDemandQueries(perFileIR, callGraph, opts.demandQueries, { ...(opts.demandOpts || {}), engineOpts: { ...opts, _cha: classHierarchy || undefined, _pointsTo: pointsToGraph || undefined } });
121
+ Object.defineProperty(findings, '_demandResults', { value: demandResults, enumerable: false });
122
+ } catch { /* demand queries must never fail the scan */ }
123
+ }
124
+ // DAG-02 end
155
125
  for (const f of findings) f._pathFeasibilityPruned = totalPruned;
156
- if (preSeededCache) {
157
- Object.defineProperty(findings, '_incrementalStats', {
158
- value: {
159
- seeded: preSeededCache._incrementalSeeded || 0,
160
- reusable: preSeededCache._incrementalReusable || 0,
161
- },
162
- enumerable: false,
163
- });
126
+ if (incrementalStats) {
127
+ Object.defineProperty(findings, '_incrementalStats', { value: incrementalStats, enumerable: false });
128
+ if (findings !== engineOutput) Object.defineProperty(engineOutput, '_incrementalStats', { value: incrementalStats, enumerable: false });
164
129
  }
130
+ if (rhsCompletion) Object.defineProperty(findings, '_rhsCompletion', { value: rhsCompletion, enumerable: false });
165
131
  // P1.4 — backward slice (opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1).
166
132
  // Next-gen taint capability #5 (SMT path-feasibility rebuild): ALSO run
167
133
  // this when AGENTIC_SECURITY_SYMEXEC=1 is set, even if the operator never
@@ -221,43 +187,8 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
221
187
  }
222
188
  } catch { /* prover failure should not fail the scan */ }
223
189
  }
224
- // v0.69 — commit incremental state after a successful scan.
225
- if (incrementalEnabled && opts.scanRoot && currentFileHashes) {
226
- const cache = findings._summaryCache;
227
- const summaries = cache ? serializeSummaries(cache) : {};
228
- // Reverse call-graph (qid → callers) — derive from callGraph.
229
- const callers = {};
230
- if (callGraph && callGraph.functions) {
231
- for (const fn of callGraph.functions.values()) {
232
- if (!Array.isArray(fn.calls)) continue;
233
- for (const c of fn.calls) {
234
- // fn.calls entries are objects ({site, callee, args, line}); using one
235
- // as an object key stringifies it to "[object Object]" and collapses
236
- // every entry into a single bogus bucket. Key by the RESOLVED qid so
237
- // this map is queryable by the qids the worklist actually uses.
238
- const name = c && typeof c === 'object' ? c.callee : c;
239
- if (!name) continue;
240
- // resolveKnownCallee (not resolve): a raw fn.calls name may be a
241
- // flattened member-call string ("loader.read"); the plain
242
- // resolve() would guess via its bare-tail fallback and invent an
243
- // edge to an unrelated same-named function. See callgraph.js.
244
- const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
245
- if (!qid) continue;
246
- if (!callers[qid]) callers[qid] = [];
247
- callers[qid].push(fn.qid);
248
- }
249
- }
250
- }
251
- commitIncrementalState(opts.scanRoot, {
252
- files: currentFileHashes,
253
- summaries,
254
- callers,
255
- }, {
256
- scanner: opts.scannerVersion || 'unknown',
257
- rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
258
- });
259
- }
260
190
  return findings;
261
191
  }
262
192
 
193
+ export { demandEnabled, runDemandQueries } from './demand-query.js'; // DAG-02
263
194
  export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, buildConstantFnMap, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };