@clear-capabilities/agentic-security-scanner 0.185.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 (167) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/agentic-security.js +24 -1
  3. package/dist/1128.index.js +25 -21
  4. package/dist/2584.index.js +1 -1
  5. package/dist/4171.index.js +2 -2
  6. package/dist/5752.index.js +466 -0
  7. package/dist/7405.index.js +1 -1
  8. package/dist/8158.index.js +1713 -0
  9. package/dist/8798.index.js +1 -1
  10. package/dist/9382.index.js +2 -2
  11. package/dist/9579.index.js +1 -1
  12. package/dist/agentic-security.mjs +29 -29
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/dist/{0b301d45b231f144a1b4.json → ca62ee93079be4c24eee.json} +7 -7
  15. package/package.json +7 -7
  16. package/src/dataflow/CLAUDE.md +6 -3
  17. package/src/dataflow/access-paths.js +12 -2
  18. package/src/dataflow/demand-query.js +279 -40
  19. package/src/dataflow/dep-fingerprint.js +1 -1
  20. package/src/dataflow/exploit-prover.js +6 -2
  21. package/src/dataflow/ir-cache.js +268 -0
  22. package/src/dataflow/privacy-taint.js +7 -0
  23. package/src/engine.js +62 -5
  24. package/src/ir/CLAUDE.md +4 -0
  25. package/src/ir/callgraph.js +36 -8
  26. package/src/ir/index.js +18 -4
  27. package/src/ir/parser-js.js +10 -0
  28. package/src/language/CLAUDE.md +13 -0
  29. package/src/language/nix-option-types.js +170 -0
  30. package/src/language/nix-script-taint.js +46 -3
  31. package/src/language/nix-secrets.js +20 -2
  32. package/src/lineage/CLAUDE.md +2 -1
  33. package/src/lineage/smt-orchestrator.js +47 -11
  34. package/src/lineage/smt-z3-backend.js +294 -0
  35. package/src/posture/CLAUDE.md +5 -1
  36. package/src/posture/business-logic.js +16 -7
  37. package/src/posture/dag-commands.js +442 -0
  38. package/src/posture/finding-defaults.js +19 -0
  39. package/src/posture/git-history.js +91 -1
  40. package/src/sast/CLAUDE.md +2 -0
  41. package/src/sast/_call-args.js +70 -0
  42. package/src/sast/init-scope.js +87 -0
  43. package/src/sast/listen-bind-address.js +49 -0
  44. package/src/sast/redirect-target.js +106 -0
  45. package/src/sast/reflected-input.js +76 -0
  46. package/src/sast/send-file-root.js +27 -0
  47. package/src/sast/sensitive-data-logging.js +38 -1
  48. package/dist/119.index.js +0 -197
  49. package/dist/128.index.js +0 -129
  50. package/dist/1293.index.js +0 -5689
  51. package/dist/1336.index.js +0 -16331
  52. package/dist/1351.index.js +0 -133
  53. package/dist/1387.index.js +0 -271
  54. package/dist/1437.index.js +0 -23
  55. package/dist/1445.index.js +0 -110
  56. package/dist/1505.index.js +0 -151
  57. package/dist/1557.index.js +0 -293
  58. package/dist/1621.index.js +0 -311
  59. package/dist/1751.index.js +0 -160
  60. package/dist/1765.index.js +0 -727
  61. package/dist/1861.index.js +0 -690
  62. package/dist/1906.index.js +0 -433
  63. package/dist/208.index.js +0 -1631
  64. package/dist/2223.index.js +0 -159
  65. package/dist/2238.index.js +0 -295
  66. package/dist/2530.index.js +0 -152
  67. package/dist/2565.index.js +0 -116
  68. package/dist/2574.index.js +0 -127
  69. package/dist/2620.index.js +0 -345
  70. package/dist/2773.index.js +0 -98
  71. package/dist/2775.index.js +0 -32
  72. package/dist/2966.index.js +0 -171
  73. package/dist/2972.index.js +0 -87
  74. package/dist/2991.index.js +0 -131
  75. package/dist/3063.index.js +0 -833
  76. package/dist/3075.index.js +0 -227
  77. package/dist/3121.index.js +0 -297
  78. package/dist/3314.index.js +0 -263
  79. package/dist/3480.index.js +0 -1822
  80. package/dist/3481.index.js +0 -24
  81. package/dist/3506.index.js +0 -268
  82. package/dist/3533.index.js +0 -591
  83. package/dist/3653.index.js +0 -450
  84. package/dist/3740.index.js +0 -722
  85. package/dist/3822.index.js +0 -131
  86. package/dist/3868.index.js +0 -292
  87. package/dist/3918.index.js +0 -192
  88. package/dist/4090.index.js +0 -288
  89. package/dist/4113.index.js +0 -1286
  90. package/dist/4121.index.js +0 -1190
  91. package/dist/4142.index.js +0 -690
  92. package/dist/4168.index.js +0 -509
  93. package/dist/4281.index.js +0 -993
  94. package/dist/4336.index.js +0 -436
  95. package/dist/4387.index.js +0 -532
  96. package/dist/4413.index.js +0 -302
  97. package/dist/4476.index.js +0 -4510
  98. package/dist/4491.index.js +0 -133
  99. package/dist/4538.index.js +0 -185
  100. package/dist/4684.index.js +0 -319
  101. package/dist/4740.index.js +0 -165
  102. package/dist/4780.index.js +0 -209
  103. package/dist/4802.index.js +0 -225
  104. package/dist/4812.index.js +0 -379
  105. package/dist/4835.index.js +0 -126
  106. package/dist/494.index.js +0 -226
  107. package/dist/4991.index.js +0 -549
  108. package/dist/5199.index.js +0 -305
  109. package/dist/5216.index.js +0 -109
  110. package/dist/5439.index.js +0 -301
  111. package/dist/5443.index.js +0 -97
  112. package/dist/5496.index.js +0 -377
  113. package/dist/5738.index.js +0 -240
  114. package/dist/5801.index.js +0 -588
  115. package/dist/5817.index.js +0 -714
  116. package/dist/5858.index.js +0 -223
  117. package/dist/5933.index.js +0 -143
  118. package/dist/601.index.js +0 -23
  119. package/dist/6173.index.js +0 -273
  120. package/dist/6291.index.js +0 -1871
  121. package/dist/6312.index.js +0 -666
  122. package/dist/634.index.js +0 -16182
  123. package/dist/644.index.js +0 -87
  124. package/dist/6508.index.js +0 -283
  125. package/dist/6560.index.js +0 -645
  126. package/dist/6576.index.js +0 -422
  127. package/dist/6579.index.js +0 -271
  128. package/dist/6655.index.js +0 -3221
  129. package/dist/6745.index.js +0 -305
  130. package/dist/6818.index.js +0 -21642
  131. package/dist/6932.index.js +0 -1190
  132. package/dist/6972.index.js +0 -167
  133. package/dist/6995.index.js +0 -163
  134. package/dist/7249.index.js +0 -191
  135. package/dist/7372.index.js +0 -1197
  136. package/dist/7546.index.js +0 -541
  137. package/dist/76.index.js +0 -448
  138. package/dist/7879.index.js +0 -163
  139. package/dist/8025.index.js +0 -130
  140. package/dist/8054.index.js +0 -248
  141. package/dist/808.index.js +0 -1330
  142. package/dist/8097.index.js +0 -201
  143. package/dist/8126.index.js +0 -191
  144. package/dist/8178.index.js +0 -78
  145. package/dist/8382.index.js +0 -93
  146. package/dist/8408.index.js +0 -261
  147. package/dist/8540.index.js +0 -174
  148. package/dist/8635.index.js +0 -802
  149. package/dist/8688.index.js +0 -410
  150. package/dist/8699.index.js +0 -779
  151. package/dist/8891.index.js +0 -457
  152. package/dist/8916.index.js +0 -885
  153. package/dist/9146.index.js +0 -170
  154. package/dist/9168.index.js +0 -841
  155. package/dist/9292.index.js +0 -369
  156. package/dist/9422.index.js +0 -866
  157. package/dist/9440.index.js +0 -453
  158. package/dist/9479.index.js +0 -152
  159. package/dist/9481.index.js +0 -220
  160. package/dist/9539.index.js +0 -100
  161. package/dist/9547.index.js +0 -103
  162. package/dist/9692.index.js +0 -90
  163. package/dist/9800.index.js +0 -462
  164. package/dist/9899.index.js +0 -138
  165. package/dist/9981.index.js +0 -206
  166. package/dist/999.index.js +0 -117
  167. 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
  }