@clear-capabilities/agentic-security-scanner 0.185.0 → 0.186.2

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 (174) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +66 -18
  3. package/dist/1128.index.js +25 -21
  4. package/dist/{5817.index.js → 1602.index.js} +378 -29
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/{0b301d45b231f144a1b4.json → 4d06c8944ae121d7085c.json} +8 -8
  8. package/dist/5752.index.js +466 -0
  9. package/dist/7405.index.js +1 -1
  10. package/dist/8158.index.js +1713 -0
  11. package/dist/8798.index.js +1 -1
  12. package/dist/9382.index.js +2 -2
  13. package/dist/9560.index.js +1 -1
  14. package/dist/9579.index.js +1 -1
  15. package/dist/agentic-security.mjs +29 -29
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +8 -8
  18. package/src/dataflow/CLAUDE.md +6 -3
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/demand-query.js +279 -40
  21. package/src/dataflow/dep-fingerprint.js +1 -1
  22. package/src/dataflow/exploit-prover.js +6 -2
  23. package/src/dataflow/ir-cache.js +268 -0
  24. package/src/dataflow/privacy-taint.js +7 -0
  25. package/src/engine.js +62 -5
  26. package/src/ir/CLAUDE.md +4 -0
  27. package/src/ir/callgraph.js +36 -8
  28. package/src/ir/index.js +18 -4
  29. package/src/ir/parser-js.js +10 -0
  30. package/src/language/CLAUDE.md +13 -0
  31. package/src/language/nix-option-types.js +170 -0
  32. package/src/language/nix-script-taint.js +46 -3
  33. package/src/language/nix-secrets.js +20 -2
  34. package/src/lineage/CLAUDE.md +2 -1
  35. package/src/lineage/smt-orchestrator.js +47 -11
  36. package/src/lineage/smt-z3-backend.js +294 -0
  37. package/src/posture/CLAUDE.md +5 -1
  38. package/src/posture/business-logic.js +16 -7
  39. package/src/posture/dag-commands.js +442 -0
  40. package/src/posture/finding-defaults.js +19 -0
  41. package/src/posture/git-history.js +91 -1
  42. package/src/posture/proof-fabric/cli.js +1 -1
  43. package/src/posture/proof-fabric/container.js +2 -2
  44. package/src/posture/proof-fabric/generate-haskell.js +205 -0
  45. package/src/posture/proof-fabric/generate.js +6 -3
  46. package/src/posture/proof-fabric/reproducer.js +4 -2
  47. package/src/posture/proof-fabric/runner.js +4 -4
  48. package/src/sast/CLAUDE.md +2 -0
  49. package/src/sast/_call-args.js +70 -0
  50. package/src/sast/init-scope.js +87 -0
  51. package/src/sast/listen-bind-address.js +49 -0
  52. package/src/sast/redirect-target.js +106 -0
  53. package/src/sast/reflected-input.js +76 -0
  54. package/src/sast/send-file-root.js +27 -0
  55. package/src/sast/sensitive-data-logging.js +38 -1
  56. package/dist/119.index.js +0 -197
  57. package/dist/128.index.js +0 -129
  58. package/dist/1293.index.js +0 -5689
  59. package/dist/1336.index.js +0 -16331
  60. package/dist/1351.index.js +0 -133
  61. package/dist/1387.index.js +0 -271
  62. package/dist/1437.index.js +0 -23
  63. package/dist/1445.index.js +0 -110
  64. package/dist/1505.index.js +0 -151
  65. package/dist/1557.index.js +0 -293
  66. package/dist/1621.index.js +0 -311
  67. package/dist/1751.index.js +0 -160
  68. package/dist/1765.index.js +0 -727
  69. package/dist/1861.index.js +0 -690
  70. package/dist/1906.index.js +0 -433
  71. package/dist/208.index.js +0 -1631
  72. package/dist/2223.index.js +0 -159
  73. package/dist/2238.index.js +0 -295
  74. package/dist/2530.index.js +0 -152
  75. package/dist/2565.index.js +0 -116
  76. package/dist/2574.index.js +0 -127
  77. package/dist/2620.index.js +0 -345
  78. package/dist/2773.index.js +0 -98
  79. package/dist/2775.index.js +0 -32
  80. package/dist/2966.index.js +0 -171
  81. package/dist/2972.index.js +0 -87
  82. package/dist/2991.index.js +0 -131
  83. package/dist/3063.index.js +0 -833
  84. package/dist/3075.index.js +0 -227
  85. package/dist/3121.index.js +0 -297
  86. package/dist/3314.index.js +0 -263
  87. package/dist/3480.index.js +0 -1822
  88. package/dist/3481.index.js +0 -24
  89. package/dist/3506.index.js +0 -268
  90. package/dist/3533.index.js +0 -591
  91. package/dist/3653.index.js +0 -450
  92. package/dist/3740.index.js +0 -722
  93. package/dist/3822.index.js +0 -131
  94. package/dist/3868.index.js +0 -292
  95. package/dist/3918.index.js +0 -192
  96. package/dist/4090.index.js +0 -288
  97. package/dist/4113.index.js +0 -1286
  98. package/dist/4121.index.js +0 -1190
  99. package/dist/4142.index.js +0 -690
  100. package/dist/4168.index.js +0 -509
  101. package/dist/4281.index.js +0 -993
  102. package/dist/4336.index.js +0 -436
  103. package/dist/4387.index.js +0 -532
  104. package/dist/4413.index.js +0 -302
  105. package/dist/4476.index.js +0 -4510
  106. package/dist/4491.index.js +0 -133
  107. package/dist/4538.index.js +0 -185
  108. package/dist/4684.index.js +0 -319
  109. package/dist/4740.index.js +0 -165
  110. package/dist/4780.index.js +0 -209
  111. package/dist/4802.index.js +0 -225
  112. package/dist/4812.index.js +0 -379
  113. package/dist/4835.index.js +0 -126
  114. package/dist/494.index.js +0 -226
  115. package/dist/4991.index.js +0 -549
  116. package/dist/5199.index.js +0 -305
  117. package/dist/5216.index.js +0 -109
  118. package/dist/5439.index.js +0 -301
  119. package/dist/5443.index.js +0 -97
  120. package/dist/5496.index.js +0 -377
  121. package/dist/5738.index.js +0 -240
  122. package/dist/5801.index.js +0 -588
  123. package/dist/5858.index.js +0 -223
  124. package/dist/5933.index.js +0 -143
  125. package/dist/601.index.js +0 -23
  126. package/dist/6173.index.js +0 -273
  127. package/dist/6291.index.js +0 -1871
  128. package/dist/6312.index.js +0 -666
  129. package/dist/634.index.js +0 -16182
  130. package/dist/644.index.js +0 -87
  131. package/dist/6508.index.js +0 -283
  132. package/dist/6560.index.js +0 -645
  133. package/dist/6576.index.js +0 -422
  134. package/dist/6579.index.js +0 -271
  135. package/dist/6655.index.js +0 -3221
  136. package/dist/6745.index.js +0 -305
  137. package/dist/6818.index.js +0 -21642
  138. package/dist/6932.index.js +0 -1190
  139. package/dist/6972.index.js +0 -167
  140. package/dist/6995.index.js +0 -163
  141. package/dist/7249.index.js +0 -191
  142. package/dist/7372.index.js +0 -1197
  143. package/dist/7546.index.js +0 -541
  144. package/dist/76.index.js +0 -448
  145. package/dist/7879.index.js +0 -163
  146. package/dist/8025.index.js +0 -130
  147. package/dist/8054.index.js +0 -248
  148. package/dist/808.index.js +0 -1330
  149. package/dist/8097.index.js +0 -201
  150. package/dist/8126.index.js +0 -191
  151. package/dist/8178.index.js +0 -78
  152. package/dist/8382.index.js +0 -93
  153. package/dist/8408.index.js +0 -261
  154. package/dist/8540.index.js +0 -174
  155. package/dist/8635.index.js +0 -802
  156. package/dist/8688.index.js +0 -410
  157. package/dist/8699.index.js +0 -779
  158. package/dist/8891.index.js +0 -457
  159. package/dist/8916.index.js +0 -885
  160. package/dist/9146.index.js +0 -170
  161. package/dist/9168.index.js +0 -841
  162. package/dist/9292.index.js +0 -369
  163. package/dist/9422.index.js +0 -866
  164. package/dist/9440.index.js +0 -453
  165. package/dist/9479.index.js +0 -152
  166. package/dist/9481.index.js +0 -220
  167. package/dist/9539.index.js +0 -100
  168. package/dist/9547.index.js +0 -103
  169. package/dist/9692.index.js +0 -90
  170. package/dist/9800.index.js +0 -462
  171. package/dist/9899.index.js +0 -138
  172. package/dist/9981.index.js +0 -206
  173. package/dist/999.index.js +0 -117
  174. package/dist/9999.index.js +0 -1698
@@ -14,9 +14,12 @@
14
14
  // 1. The frontier only SELECTS functions. A backward/forward intersection is a
15
15
  // candidate filter and is reported as one; the only witness is a finding the
16
16
  // forward engine produced over the selected region.
17
- // 2. A construct the selector cannot follow (instance fields, an exhausted
18
- // depth or work budget, an unresolved higher-order shape) never silently
19
- // drops a function. Default policy expands to the full function set
17
+ // 2. A construct the selector cannot follow (an instance field of a class it
18
+ // cannot name, such as a constructor function or a non-JavaScript receiver;
19
+ // an exhausted depth or work budget; an unresolved higher-order shape) never
20
+ // silently drops a function. An instance field of a named JavaScript or
21
+ // TypeScript class IS followed: the class's methods and the receiver at
22
+ // their call sites are selected. Default policy expands to the full function set
20
23
  // (`onLimit` / `onUnsupported` = 'full'); the 'partial' policy instead
21
24
  // reports partial scope with negativeClaimAllowed false.
22
25
  // 3. A limited reverse walk never authorizes a negative verdict. The verdict
@@ -40,6 +43,11 @@ const DEMAND_CONTEXTS = Object.freeze(['whole-program']);
40
43
  const DEFAULT_MAX_CALLER_DEPTH = 64;
41
44
  const DEFAULT_MAX_WORK = 50000;
42
45
  const MAX_PATH_SEGMENTS = 6;
46
+ // Bounds on the class-scoped expansion; past them the construct is reported
47
+ // unsupported and the query takes the conservative full path.
48
+ const MAX_CLASS_MEMBERS = 2000;
49
+ const MAX_ENCLOSING_CANDIDATES = 32;
50
+ const MAX_CLOSURE_HOPS = 6;
43
51
 
44
52
  /** Demand mode is opt-in: this env flag AND an explicit API/query list. */
45
53
  export function demandEnabled(env = process.env) {
@@ -122,6 +130,15 @@ export function sinkQueryKey(query, digests = {}) {
122
130
  // ---------------------------------------------------------------------------
123
131
 
124
132
  const SELF_ROOTS = new Set(['this', '_this_', 'self', 'cls']);
133
+ const JS_FILE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
134
+ // In JavaScript and TypeScript the receiver is `this` (lowered to `_this_`);
135
+ // `self` and `cls` are ordinary names there (a local, a parameter, the global
136
+ // `self`, or an alias made by `const self = this`, which the assignment rule
137
+ // follows back to `_this_`). In the other languages they are the receiver.
138
+ function isSelfRoot(root, file) {
139
+ if (root === 'this' || root === '_this_') return true;
140
+ return SELF_ROOTS.has(root) && !JS_FILE.test(file || '');
141
+ }
125
142
  const REQ_PARAM = /^(?:req|request|ctx)$/;
126
143
  // Roots that are language builtins or ambient globals, so a call on them is
127
144
  // modeled by the engine's own summaries rather than being an unresolved callee.
@@ -213,6 +230,83 @@ function fnInfo(fn) {
213
230
  return info;
214
231
  }
215
232
 
233
+ // Access paths a call can write through (its receiver and its arguments).
234
+ const _touchCache = new WeakMap();
235
+ function touchedPaths(call) {
236
+ let paths = _touchCache.get(call);
237
+ if (paths) return paths;
238
+ paths = [];
239
+ _touchCache.set(call, paths);
240
+ const collect = (e) => {
241
+ if (!e || typeof e !== 'object') return;
242
+ if (e.kind === 'function-value') return;
243
+ const ap = accessPathOf(e);
244
+ if (ap) { paths.push(ap); return; }
245
+ if (isExpr(e)) forEachChild(e, collect);
246
+ };
247
+ const callee = call.callee;
248
+ if (callee && typeof callee === 'object' && callee.kind === 'member') collect(callee.object);
249
+ else if (typeof callee === 'string' && callee.includes('.')) paths.push(callee.slice(0, callee.lastIndexOf('.')));
250
+ for (const a of (call.args || [])) collect(a);
251
+ return paths;
252
+ }
253
+
254
+ // Per-function views by root segment. Two access paths can overlap only when
255
+ // their first segments agree or one of them is the `*` wildcard, so a lookup
256
+ // for a path rooted at `r` need only visit the assignments and calls rooted at
257
+ // `r` (plus wildcard roots and, for assignments, the opaque ones). The views
258
+ // keep the function's own order, so the walk visits them exactly as a full scan
259
+ // would and only skips what cannot overlap.
260
+ function mergeSorted(lists) {
261
+ const out = [];
262
+ for (const l of lists) for (const i of l) out.push(i);
263
+ return out.sort((a, b) => a - b);
264
+ }
265
+
266
+ function assignsFor(info, root) {
267
+ let b = info.assignBuckets;
268
+ if (!b) {
269
+ b = { byRoot: new Map(), wild: [], opaque: [], views: new Map() };
270
+ info.assigns.forEach((a, i) => {
271
+ if (a.opaque) { b.opaque.push(i); return; }
272
+ const r = rootOf(a.target);
273
+ if (r === '*') { b.wild.push(i); return; }
274
+ if (!b.byRoot.has(r)) b.byRoot.set(r, []);
275
+ b.byRoot.get(r).push(i);
276
+ });
277
+ info.assignBuckets = b;
278
+ }
279
+ if (root === '*') return info.assigns;
280
+ let v = b.views.get(root);
281
+ if (!v) {
282
+ v = mergeSorted([b.opaque, b.wild, b.byRoot.get(root) || []]).map((i) => info.assigns[i]);
283
+ b.views.set(root, v);
284
+ }
285
+ return v;
286
+ }
287
+
288
+ function callsFor(info, root) {
289
+ let b = info.callBuckets;
290
+ if (!b) {
291
+ b = { byRoot: new Map(), wild: [], any: [], views: new Map() };
292
+ info.calls.forEach((occ, i) => {
293
+ const paths = touchedPaths(occ.expr);
294
+ if (!paths.length) return;
295
+ b.any.push(i);
296
+ const roots = new Set(paths.map(rootOf));
297
+ if (roots.has('*')) { b.wild.push(i); return; }
298
+ for (const r of roots) { if (!b.byRoot.has(r)) b.byRoot.set(r, []); b.byRoot.get(r).push(i); }
299
+ });
300
+ info.callBuckets = b;
301
+ }
302
+ let v = b.views.get(root);
303
+ if (!v) {
304
+ v = (root === '*' ? b.any : mergeSorted([b.wild, b.byRoot.get(root) || []])).map((i) => info.calls[i]);
305
+ b.views.set(root, v);
306
+ }
307
+ return v;
308
+ }
309
+
216
310
  const _indexCache = new WeakMap();
217
311
 
218
312
  /**
@@ -227,15 +321,34 @@ export function buildIncomingIndex(callGraph) {
227
321
  const incoming = new Map();
228
322
  const byTail = new Map();
229
323
  const modules = new Map(); const byFile = new Map();
324
+ const byScope = new Map(); // enclosing-scope segment of the qid -> member qids (class methods share their class name)
325
+ const namesInFile = new Map(); // file -> Map(function name -> [fn])
230
326
  const add = (qid, site) => { if (!incoming.has(qid)) incoming.set(qid, []); incoming.get(qid).push(site); };
231
- const resolveName = (name, file) => (name && callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, file) : null);
327
+ // Name resolution over a fixed graph is a pure function of (name, file), and
328
+ // most call sites repeat a handful of names per file (`push`, `has`, `get`),
329
+ // each of which would otherwise scan every file's name table on a miss.
330
+ const memo = new Map();
331
+ const rk = callGraph.resolveKnownCallee;
332
+ const resolveName = (name, file) => {
333
+ if (!name || !rk) return null;
334
+ let m = memo.get(file);
335
+ if (!m) { m = new Map(); memo.set(file, m); }
336
+ let v = m.get(name);
337
+ if (v === undefined) { v = rk(name, file) || null; m.set(name, v); }
338
+ return v;
339
+ };
232
340
  for (const fn of callGraph.functions.values()) {
233
341
  const tail = String(fn.name || '').split('.').pop();
234
342
  if (tail) { if (!byTail.has(tail)) byTail.set(tail, []); byTail.get(tail).push(fn.qid); }
235
343
  if (!byFile.has(fn.file)) byFile.set(fn.file, []); byFile.get(fn.file).push(fn);
236
344
  if (fn.name === '<module>' && String(fn.qid).includes('::top::')) { if (!modules.has(fn.file)) modules.set(fn.file, []); modules.get(fn.file).push(fn.qid); }
345
+ const parts = qidParts(fn.qid);
346
+ if (parts) { if (!byScope.has(parts.scope)) byScope.set(parts.scope, []); byScope.get(parts.scope).push(fn.qid); }
347
+ let nf = namesInFile.get(fn.file);
348
+ if (!nf) { nf = new Map(); namesInFile.set(fn.file, nf); }
349
+ if (!nf.has(fn.name)) nf.set(fn.name, []);
350
+ nf.get(fn.name).push(fn);
237
351
  }
238
- const base = buildCallerQidIndex(callGraph); // canonical statement-level edges, retained for parity checks
239
352
  for (const fn of callGraph.functions.values()) {
240
353
  const info = fnInfo(fn);
241
354
  for (const occ of info.calls) {
@@ -251,11 +364,25 @@ export function buildIncomingIndex(callGraph) {
251
364
  }
252
365
  }
253
366
  }
254
- const idx = { incoming, byTail, modules, byFile, base };
367
+ const idx = { incoming, byTail, modules, byFile, byScope, namesInFile, definers: new Map(), resolve: resolveName };
368
+ // The canonical statement-level edges, kept for parity checks. Built on first
369
+ // use: nothing on the query path reads them, and building them costs a second
370
+ // full pass of name resolution.
371
+ let base = null;
372
+ Object.defineProperty(idx, 'base', { enumerable: true, get() { if (!base) base = buildCallerQidIndex(callGraph); return base; } });
255
373
  _indexCache.set(callGraph, idx);
256
374
  return idx;
257
375
  }
258
376
 
377
+ /** `file::scope::name@line` -> { scope, name }; null for a qid of another shape. */
378
+ function qidParts(qid) {
379
+ const j = qid.lastIndexOf('::');
380
+ if (j < 0) return null;
381
+ const i = qid.lastIndexOf('::', j - 1);
382
+ if (i < 0) return null;
383
+ return { scope: qid.slice(i + 2, j), name: qid.slice(j + 2) };
384
+ }
385
+
259
386
  // ---------------------------------------------------------------------------
260
387
  // Sink-site discovery (nested sinks in assignment and return expressions)
261
388
  // ---------------------------------------------------------------------------
@@ -330,12 +457,14 @@ class Frontier {
330
457
  this.unsupported = []; // {kind, qid, detail}
331
458
  this.unresolved = new Map(); // name@qid -> record
332
459
  this.terminals = new Set(); // 'source' | 'param' | 'call' | 'free' | 'callback'
333
- this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0 };
460
+ this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0, siblings: 0 };
334
461
  this.depthCut = 0;
335
462
  this.workLeft = 0;
336
463
  this.sinkCalls = new Set();
337
- this.touchCache = new WeakMap();
338
464
  this.expanded = new Set();
465
+ this.mateCache = new Map();
466
+ this.holdersDone = new Set();
467
+ this.classDone = new Set();
339
468
  }
340
469
 
341
470
  addRegion(qid, reason) {
@@ -412,7 +541,7 @@ class Frontier {
412
541
  }
413
542
  for (const a of (e.args || [])) this.reads(fn, a, item);
414
543
  const name = calleeName(callee);
415
- const g = name && this.cg.resolveKnownCallee ? this.cg.resolveKnownCallee(name, fn.file) : null;
544
+ const g = this.idx.resolve(name, fn.file);
416
545
  if (g) {
417
546
  this.followed.producers += 1;
418
547
  this.addRegion(g, 'producer');
@@ -478,10 +607,14 @@ class Frontier {
478
607
  if (p === '$params') { for (const prm of info.params) this.push(fn.qid, prm, false, item.depth, item.reason); this.push(fn.qid, '$ret', false, item.depth, item.reason); return; }
479
608
 
480
609
  const root = rootOf(p);
481
- if (SELF_ROOTS.has(root)) { this.markUnsupported('instance-field', fn.qid, p); return; }
610
+ const selfRooted = isSelfRoot(root, fn.file);
611
+ // An instance field can be written by any method of its class and by whoever
612
+ // holds the receiver; select those (class-scoped expansion), or report the
613
+ // construct unsupported when the class cannot be determined.
614
+ if (selfRooted && !this.expandClass(fn, p, item)) return;
482
615
  this.checkSource(fn, p);
483
616
 
484
- for (const a of info.assigns) {
617
+ for (const a of assignsFor(info, root)) {
485
618
  if (a.opaque) { this.reads(fn, a.source, item); continue; }
486
619
  if (!segOverlap(a.target, p)) continue;
487
620
  // target covers the needed path (equal or a prefix): the value of the
@@ -493,15 +626,15 @@ class Frontier {
493
626
  else this.reads(fn, a.source, item);
494
627
  }
495
628
 
496
- for (const occ of info.calls) {
629
+ for (const occ of callsFor(info, root)) {
630
+ if (!this.callTouches(occ.expr, p)) continue;
497
631
  // the queried sink call itself runs after its argument is formed, so it
498
632
  // cannot change what the argument holds
499
- if (this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
500
- if (!this.callTouches(occ.expr, p)) continue;
633
+ if (this.sinkCalls.size && this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
501
634
  this.followed.mutators += 1;
502
635
  this.reads(fn, occ.expr, item);
503
636
  const name = calleeName(occ.expr.callee);
504
- const g = name && this.cg.resolveKnownCallee ? this.cg.resolveKnownCallee(name, fn.file) : null;
637
+ const g = this.idx.resolve(name, fn.file);
505
638
  if (g && g !== fn.qid) { this.addRegion(g, 'mutator'); this.push(g, '$params', false, item.depth, 'mutator'); }
506
639
  }
507
640
 
@@ -509,11 +642,107 @@ class Frontier {
509
642
  if (pi >= 0) {
510
643
  this.terminals.add('param');
511
644
  if (item.ep) this.expandCallers(fn, pi, item);
645
+ } else if (selfRooted) {
646
+ this.terminals.add('field');
512
647
  } else if (!info.assignedRoots.has(root) && !info.hasOpaqueAssign && !info.params.includes(root)) {
513
648
  this.freeVariable(fn, root, p, item);
514
649
  }
515
650
  }
516
651
 
652
+ /**
653
+ * The qids sharing the receiver that `fn`'s `this` names, or null when that
654
+ * cannot be decided from the qid structure. A method's qid scope is its class
655
+ * name, so a class is the set of functions with that scope. A closure's scope
656
+ * is the enclosing function's name, and its `this` may be that function's, so
657
+ * the enclosing function(s) are followed outward. Module-level functions and
658
+ * constructor functions (scope `top`) have no class to name.
659
+ */
660
+ classMates(fn) {
661
+ if (this.mateCache.has(fn.qid)) return this.mateCache.get(fn.qid);
662
+ const out = new Set([fn.qid]);
663
+ const seen = new Set();
664
+ const visit = (f, depth) => {
665
+ if (seen.has(f.qid)) return true;
666
+ seen.add(f.qid);
667
+ const parts = qidParts(f.qid);
668
+ if (!parts || !parts.scope || parts.scope === 'top' || parts.scope === '<module>') return false;
669
+ const named = (this.idx.namesInFile.get(f.file) || new Map()).get(parts.scope);
670
+ if (named && named.length) {
671
+ if (depth >= MAX_CLOSURE_HOPS || named.length > MAX_ENCLOSING_CANDIDATES) return false;
672
+ for (const g of named) { out.add(g.qid); if (!visit(g, depth + 1)) return false; }
673
+ return true;
674
+ }
675
+ const mates = this.idx.byScope.get(parts.scope) || [];
676
+ if (mates.length > MAX_CLASS_MEMBERS) return false;
677
+ for (const q of mates) out.add(q);
678
+ return true;
679
+ };
680
+ const ok = JS_FILE.test(fn.file || '') && visit(fn, 0);
681
+ // A class is named by its scope; a closure's receiver set is its own union.
682
+ const direct = ok && seen.size === 1 ? qidParts(fn.qid).scope : null;
683
+ out.key = direct != null ? `class:${direct}` : `fn:${fn.qid}`;
684
+ const res = ok ? out : null;
685
+ this.mateCache.set(fn.qid, res);
686
+ return res;
687
+ }
688
+
689
+ /**
690
+ * Select what can supply the value of a `this`-rooted path read in `fn`: every
691
+ * method of the class (each may write the field; they share one receiver, as the
692
+ * engine's class-field passes assume), and the receiver expression at every call
693
+ * site of those methods (the caller may have set the field before the call).
694
+ * Parameters written into fields are followed by the ordinary caller expansion.
695
+ */
696
+ expandClass(fn, p, item) {
697
+ const mates = this.classMates(fn);
698
+ if (!mates) { this.markUnsupported('instance-field', fn.qid, p); return false; }
699
+ // one expansion per (class, path): a sibling reaching here for the same path
700
+ // would only repeat what the function that selected it already did
701
+ const done = `${mates.key}|${p}`;
702
+ if (this.classDone.has(done)) return true;
703
+ this.classDone.add(done);
704
+ const dot = p.indexOf('.');
705
+ const suffix = dot < 0 ? '' : p.slice(dot + 1);
706
+ for (const q of mates) {
707
+ // A sibling's parameters reach the field through its call sites, which
708
+ // the receiver tracking below selects (the call that stores the value is
709
+ // a mutator of the receiver path), so no separate caller expansion.
710
+ if (q !== fn.qid) { this.addRegion(q, 'class-sibling'); this.push(q, p, false, item.depth, 'class-sibling'); }
711
+ this.receiverHolders(q, suffix, item);
712
+ }
713
+ this.followed.siblings += mates.size - 1;
714
+ return true;
715
+ }
716
+
717
+ /**
718
+ * At every call site of `qid`, the receiver's `suffix` field is what the callee
719
+ * sees as `this.<suffix>`. Following that one path (not the whole receiver)
720
+ * keeps the walk to the values that can actually reach the field.
721
+ */
722
+ receiverHolders(qid, suffix, item) {
723
+ const hk = `${qid}|${suffix}`;
724
+ if (this.holdersDone.has(hk)) return;
725
+ this.holdersDone.add(hk);
726
+ const sites = this.idx.incoming.get(qid) || [];
727
+ if (!sites.length) return;
728
+ if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
729
+ const withSuffix = (base) => (suffix ? `${base}.${suffix}` : base);
730
+ for (const s of sites) {
731
+ const caller = this.cg.functions.get(s.caller);
732
+ if (!caller) continue;
733
+ this.followed.callers += 1;
734
+ this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
735
+ const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
736
+ const callee = s.call.callee;
737
+ let recvPath = null;
738
+ if (callee && typeof callee === 'object' && callee.kind === 'member') {
739
+ recvPath = accessPathOf(callee.object);
740
+ if (!recvPath) this.reads(caller, callee.object, ni);
741
+ } else if (typeof callee === 'string' && callee.includes('.')) recvPath = callee.slice(0, callee.lastIndexOf('.'));
742
+ if (recvPath) this.push(s.caller, withSuffix(recvPath), true, ni.depth, 'caller');
743
+ }
744
+ }
745
+
517
746
  assignSource(fn, a, suffix, item) {
518
747
  const src = a.source;
519
748
  if (!src) return;
@@ -541,22 +770,7 @@ class Frontier {
541
770
  }
542
771
 
543
772
  callTouches(call, p) {
544
- let paths = this.touchCache.get(call);
545
- if (paths) return paths.some((q) => segOverlap(q, p));
546
- paths = [];
547
- this.touchCache.set(call, paths);
548
- const collect = (e) => {
549
- if (!e || typeof e !== 'object') return;
550
- if (e.kind === 'function-value') return;
551
- const ap = accessPathOf(e);
552
- if (ap) { paths.push(ap); return; }
553
- if (isExpr(e)) forEachChild(e, collect);
554
- };
555
- const callee = call.callee;
556
- if (callee && typeof callee === 'object' && callee.kind === 'member') collect(callee.object);
557
- else if (typeof callee === 'string' && callee.includes('.')) paths.push(callee.slice(0, callee.lastIndexOf('.')));
558
- for (const a of (call.args || [])) collect(a);
559
- return paths.some((q) => segOverlap(q, p));
773
+ return touchedPaths(call).some((q) => segOverlap(q, p));
560
774
  }
561
775
 
562
776
  expandCallers(fn, paramIdx, item) {
@@ -607,12 +821,19 @@ class Frontier {
607
821
  }
608
822
 
609
823
  freeVariable(fn, root, p, item) {
610
- const definers = [];
611
- for (const other of (this.idx.byFile.get(fn.file) || [])) {
612
- if (other.file !== fn.file || other.qid === fn.qid) continue;
613
- const oi = fnInfo(other);
614
- if (oi.assignedRoots.has(root) || oi.params.includes(root)) definers.push(other);
824
+ // every function of the file that binds the name, memoized per (file, name)
825
+ const dk = `${fn.file}\0${root}`;
826
+ let all = this.idx.definers.get(dk);
827
+ if (!all) {
828
+ all = [];
829
+ for (const other of (this.idx.byFile.get(fn.file) || [])) {
830
+ if (other.file !== fn.file) continue;
831
+ const oi = fnInfo(other);
832
+ if (oi.assignedRoots.has(root) || oi.params.includes(root)) all.push(other);
833
+ }
834
+ this.idx.definers.set(dk, all);
615
835
  }
836
+ const definers = all.filter((d) => d.qid !== fn.qid);
616
837
  // import bindings (`const x = require(...)`) carry no data of their own
617
838
  const importOnly = definers.length > 0 && definers.every((d) => fnInfo(d).assigns.filter((a) => a.target && rootOf(a.target) === root).every((a) => a.source && a.source.kind === 'call' && calleeName(a.source.callee) === 'require'));
618
839
  if (!definers.length) return; // ambient global; the engine models it by name only
@@ -695,7 +916,15 @@ function pickFindings(raw, sites, q) {
695
916
  return [...byKey.values()].sort((a, b) => (findingKey(a) < findingKey(b) ? -1 : 1));
696
917
  }
697
918
 
919
+ const _sbCache = new WeakMap();
698
920
  function sourceBearing(fn) {
921
+ if (_sbCache.has(fn)) return _sbCache.get(fn);
922
+ const v = sourceBearingUncached(fn);
923
+ _sbCache.set(fn, v);
924
+ return v;
925
+ }
926
+
927
+ function sourceBearingUncached(fn) {
699
928
  const info = fnInfo(fn);
700
929
  let found = Array.isArray(fn.paramAnnotations) && fn.paramAnnotations.length > 0;
701
930
  const rd = (e) => { if (found || !e || typeof e !== 'object') return; if (e.kind === 'function-value') return; if (matchSource(e, fn.file)) { found = true; return; } if (isExpr(e)) forEachChild(e, rd); };
@@ -773,10 +1002,19 @@ export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
773
1002
 
774
1003
  let regionIds = [...frontier.region.keys()].sort();
775
1004
  if (fallback) regionIds = [...callGraph.functions.keys()].sort();
776
- const regionGraph = { ...callGraph, functions: new Map(regionIds.map((id) => [id, callGraph.functions.get(id)])) };
1005
+ // A fallback analyzes every function: hand the engine the graph itself (the
1006
+ // same input, in the same order, as the full-analysis reference), not a copy.
1007
+ const regionGraph = fallback ? callGraph : { ...callGraph, functions: new Map(regionIds.map((id) => [id, callGraph.functions.get(id)])) };
777
1008
 
778
1009
  const run = opts.engine || runTaintEngine;
779
- const raw = run(perFileIR, regionGraph, engineOptsOf(opts));
1010
+ // Within one batch every fallback asks the engine the same question (the whole
1011
+ // graph, same options), so the first answer is shared by the rest. The memo
1012
+ // belongs to one runDemandQueries call and is dropped with it.
1013
+ let raw;
1014
+ if (fallback && opts._fullMemo) {
1015
+ if (!opts._fullMemo.has('raw')) opts._fullMemo.set('raw', run(perFileIR, regionGraph, engineOptsOf(opts)));
1016
+ raw = opts._fullMemo.get('raw');
1017
+ } else raw = run(perFileIR, regionGraph, engineOptsOf(opts));
780
1018
  for (const r of engineIncomplete(raw)) reasons.push(`engine:${r}`);
781
1019
 
782
1020
  let findings = pickFindings(raw, sites, q);
@@ -788,7 +1026,7 @@ export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
788
1026
  res.findings = findings;
789
1027
  res.siblingFieldSuppressed = siblingSuppressed;
790
1028
  res.witness = findings.length ? 'forward-engine-corroborated' : 'none';
791
- res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: sbIn.length, candidate: sbIn.length > 0 || frontier.terminals.has('param') || frontier.terminals.has('free') };
1029
+ res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: sbIn.length, candidate: sbIn.length > 0 || frontier.terminals.has('param') || frontier.terminals.has('free') || frontier.terminals.has('field') };
792
1030
  res.selectorCoverage = {
793
1031
  totalFunctions: total,
794
1032
  selectedFunctions: frontier.region.size,
@@ -884,7 +1122,8 @@ export function compareToFull(demand, full) {
884
1122
 
885
1123
  /** Run a batch of queries (the shape the optional dataflow/index.js hook uses). */
886
1124
  export function runDemandQueries(perFileIR, callGraph, queries, opts = {}) {
887
- return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, opts));
1125
+ const batchOpts = opts.engine ? opts : { ...opts, _fullMemo: new Map() };
1126
+ return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, batchOpts));
888
1127
  }
889
1128
 
890
1129
  /** Enumerate every sink site in the graph as a query (used by parity checks and the benchmark). */
@@ -526,7 +526,7 @@ function analyzerRoots() {
526
526
 
527
527
  let _analyzerMemo = null;
528
528
  /** Digest of the analyzer and parser sources (or the bundle when running from one). */
529
- function analyzerDigest() {
529
+ export function analyzerDigest() {
530
530
  if (_analyzerMemo) return _analyzerMemo;
531
531
  const { here, src, isSource } = analyzerRoots();
532
532
  const h = crypto.createHash('sha256');
@@ -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
  }