@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9

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 (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
@@ -30,14 +30,17 @@
30
30
  // Sinks: anywhere a CFG node calls a catalog-registered sink with a tainted
31
31
  // argument, we emit a finding.
32
32
  //
33
- // Sanitizers: NOT consulted by this walk. `matchSinkOrSanitizer()` returns
34
- // catalog hits of both kinds, but every consumer in this directory keeps only
35
- // `e.kind === 'sink'`; no `'sanitizer'` branch exists anywhere in dataflow/*.
36
- // Taint dies here only when a variable is re-assigned from a clean expression
37
- // (removePathAndDescendants, below) which is orthogonal to whether the RHS
38
- // happens to be a catalog sanitizer. Recognising sanitizer entries at a call
39
- // site is open work; until it lands, treat the catalog's sanitizer half as
40
- // documentation consumed by other layers, not by this engine.
33
+ // Sanitizers: RECORDED, but they do not kill taint in this walk.
34
+ // `_sanitizersForExpr` (below) collects the sanitizer callees applied to the
35
+ // value reaching each sink argument inline, or inherited via
36
+ // `_sanitizersByVar` from the variable it reads and stamps them on the
37
+ // finding as `_sanitizersOnPath`. `dataflow/sanitizer-gate.js` then labels the
38
+ // finding `sanitized:true` only when the sanitizer's `appliesTo` family
39
+ // actually covers the finding's threat class; `engine.js`'s proof gate demotes
40
+ // from there. Taint itself still dies only when a variable is re-assigned from
41
+ // a clean expression (removePathAndDescendants, below) — a mislabelled
42
+ // sanitizer must never silently drop a real vulnerability, so the walk never
43
+ // treats a sanitizer call as clearing the tainted path on its own.
41
44
 
42
45
  import { matchSource, matchSinkOrSanitizer } from './catalog.js';
43
46
  import { functionRecord } from '../ir/callgraph.js';
@@ -268,6 +271,53 @@ function _matchCallCatalog(calleeExpr, argExprs, state) {
268
271
  return { cat, argTaints };
269
272
  }
270
273
 
274
+ // Sanitizer callees observed on an expression.
275
+ //
276
+ // The engine deliberately does NOT let a sanitizer kill taint. A blanket
277
+ // "any sanitizer clears the flow" rule scores well on benchmarks and silently
278
+ // drops a real SQL injection whenever the code applied an HTML escaper — the
279
+ // C/C++ catalog work already found strncpy/snprintf tagged effect:'strip' when
280
+ // they bound length rather than sanitising content. So the walk RECORDS which
281
+ // sanitizers touched the value and hands them to sanitizer-gate.js, which
282
+ // labels the finding, and the proof gate demotes it. Recall-preserving, same
283
+ // precedent as falsification.js / proof-gate.js: never removed, never
284
+ // severity-touched.
285
+ function _sanitizersInExprTree(expr, out) {
286
+ if (!expr || typeof expr !== 'object') return;
287
+ if (expr.kind === 'call') {
288
+ const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
289
+ if (cat) {
290
+ for (const e of cat) {
291
+ if (e.kind === 'sanitizer' && e.match && e.match.callee) out.add(e.match.callee);
292
+ }
293
+ }
294
+ }
295
+ for (const k of ['left', 'right', 'callee', 'object', 'property', 'value']) {
296
+ if (expr[k] && typeof expr[k] === 'object') _sanitizersInExprTree(expr[k], out);
297
+ }
298
+ for (const k of ['args', 'parts', 'branches', 'elements']) {
299
+ if (Array.isArray(expr[k])) for (const e of expr[k]) _sanitizersInExprTree(e, out);
300
+ }
301
+ if (Array.isArray(expr.props)) for (const p of expr.props) _sanitizersInExprTree(p && p.value, out);
302
+ }
303
+
304
+ // Sanitizers applied to `expr`: those called inline within it, plus those
305
+ // recorded against any variable it reads (`const safe = escapeHtml(x); sink(safe)`).
306
+ function _sanitizersForExpr(expr, callContext) {
307
+ const out = new Set();
308
+ _sanitizersInExprTree(expr, out);
309
+ const byVar = callContext && callContext._sanitizersByVar;
310
+ if (byVar && byVar.size) {
311
+ const vars = new Set();
312
+ _collectExprVars(expr, vars);
313
+ for (const v of vars) {
314
+ const s = byVar.get(v);
315
+ if (s) for (const n of s) out.add(n);
316
+ }
317
+ }
318
+ return out;
319
+ }
320
+
271
321
  // cat / argTaints: the result of _matchCallCatalog (computed by the caller,
272
322
  // at whatever point in its case is appropriate for its own state-mutation
273
323
  // ordering).
@@ -299,7 +349,13 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
299
349
  ? reachingSources.slice(0, 5)
300
350
  // Fallback: better to surface "no precise source" than the wrong source.
301
351
  : [];
352
+ // Sanitizers seen on the value reaching THIS argument. Consumed by
353
+ // sanitizer-gate.js, which labels only when the sanitizer's family
354
+ // covers the finding's threat class — an xss escaper on a SQL sink
355
+ // must not read as sanitised.
356
+ const _sanNames = _sanitizersForExpr(taintedArgExpr, callContext);
302
357
  findings.push({
358
+ ...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
303
359
  kind: 'taint',
304
360
  sinkId: e.id,
305
361
  vuln: e.vuln?.name || 'Tainted Sink',
@@ -318,9 +374,36 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
318
374
  return { findings };
319
375
  }
320
376
 
377
+ // Surfaces a cached (or freshly computed) summary's `findings` into the
378
+ // CURRENT caller's context — the only place a class-field/k=2 pre-pass's
379
+ // speculative findings (in runTaintEngine) become reportable, because
380
+ // reaching this point means a real call site actually consulted that exact
381
+ // qid+entry. Called uniformly on both a cache HIT (`summaryCache.get()`)
382
+ // and a cache MISS (`summaryCache.compute()`'s return value), so it doesn't
383
+ // matter whether this call site is the first one to ever reach this
384
+ // qid+entry or the fifth — findings ride on the summary object itself now,
385
+ // not on a one-shot merge inside compute()'s callback. Module-level (not
386
+ // nested in runTaintEngine) because step()'s assign/plain-call interproc
387
+ // branches call it too, and step() is a top-level function with no access
388
+ // to runTaintEngine's locals.
389
+ function _mergeSummaryFindings(callContext, callerQid, sum, via) {
390
+ if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
391
+ callContext._findings.push(...sum.findings.map(f => ({ ...f, _funcQid: callerQid || null, _via: via })));
392
+ }
393
+
321
394
  // Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
322
395
  function step(node, stateIn, callContext) {
323
- const state = new Set(stateIn);
396
+ // `let`, not `const` the 'call' case (built-in-mutation and mutated-param
397
+ // branches below) reassigns this binding. It was `const` until Stage 3 of
398
+ // the correctness audit: a bare-statement call to Object.assign/_.merge/
399
+ // etc. with a tainted source arg, or any plain call whose callee summary
400
+ // reports mutated params, threw "Assignment to constant variable" here.
401
+ // The engine's per-function analyzeFunction() call sites all wrap in a
402
+ // blanket try/catch, so the exception was silent — and it discarded every
403
+ // finding already collected for the ENTIRE containing function, not just
404
+ // the mutation site, since the throw unwound past `findings.push(...)`
405
+ // calls for unrelated sinks earlier in the same function body.
406
+ let state = new Set(stateIn);
324
407
  const findings = [];
325
408
 
326
409
  switch (node.kind) {
@@ -348,6 +431,18 @@ function step(node, stateIn, callContext) {
348
431
  node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
349
432
  state, callContext, node.line).findings);
350
433
  }
434
+ // Record which sanitizers were applied to the value now held by `target`
435
+ // (inline in the RHS, or inherited from the vars the RHS reads). Placed
436
+ // before every early return in this case so the map cannot go stale on
437
+ // the interprocedural paths below. A clean RHS clears the entry, mirroring
438
+ // removePathAndDescendants — a stale sanitizer would label a later,
439
+ // genuinely unsanitized flow.
440
+ if (target) {
441
+ const _san = _sanitizersForExpr(node.source, callContext);
442
+ const _byVar = (callContext._sanitizersByVar ||= new Map());
443
+ if (_san.size) _byVar.set(target, _san);
444
+ else _byVar.delete(target);
445
+ }
351
446
  // Constant propagation: track variables assigned from literals
352
447
  if (target && _activeConstantVars) {
353
448
  if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
@@ -404,10 +499,19 @@ function step(node, stateIn, callContext) {
404
499
  returnTainted: !!inner._returnTainted,
405
500
  mutatedParams: inner._mutatedParamsOut || new Set(),
406
501
  taintedGlobals: new Set(),
407
- findings: [],
502
+ // Real findings from the callee's own body — e.g.
503
+ // `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
504
+ // — ride on the summary itself (was hardcoded `[]`, so
505
+ // nothing ever read inner._findings and the SQLi inside
506
+ // makeQuery was silently dropped). _mergeSummaryFindings
507
+ // below surfaces them into THIS caller now that a real
508
+ // call site has been established, and does the same on a
509
+ // future cache hit from any other real caller.
510
+ findings: inner._findings,
408
511
  };
409
512
  });
410
513
  }
514
+ _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
411
515
  if (sum && sum.returnTainted) {
412
516
  newState = _addPathAliasAware(newState, target, callContext);
413
517
  callContext._taintSources.push({
@@ -515,10 +619,14 @@ function step(node, stateIn, callContext) {
515
619
  returnTainted: !!inner._returnTainted,
516
620
  mutatedParams: inner._mutatedParamsOut || new Set(),
517
621
  taintedGlobals: new Set(),
518
- findings: [],
622
+ // See the sibling assign-call-site compute() above — same
623
+ // fix, same reason: this callee's own findings were
624
+ // computed correctly and then thrown away (hardcoded `[]`).
625
+ findings: inner._findings,
519
626
  };
520
627
  });
521
628
  }
629
+ _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
522
630
  if (sum && sum.mutatedParams && sum.mutatedParams.size) {
523
631
  const mutated = callContext._summaryCache.applyAtCallSite(
524
632
  sum, paramNames, node.args || [], state);
@@ -574,7 +682,13 @@ function step(node, stateIn, callContext) {
574
682
  if (dot <= 0) return null;
575
683
  const recv = callee.slice(0, dot);
576
684
  const recvTainted = isCoveredBy(state, recv);
577
- return higherOrderTaintFlow(node, recvTainted);
685
+ // higherOrderTaintFlow requires a flattened STRING callee (its own
686
+ // `typeof callee !== 'string'` guard) — passing the raw `node` here
687
+ // handed it a JS/TS structured callee expr ({kind:'member',...})
688
+ // unconditionally, which never passed that guard, so this feature
689
+ // was entirely dead for JS/TS (the primary catalogued language).
690
+ // `callee` here is `_plainCallCalleeName`, already flattened above.
691
+ return higherOrderTaintFlow({ ...node, callee }, recvTainted);
578
692
  })();
579
693
  if (hoFlow && hoFlow.taintsCallbackParam === 0) {
580
694
  // The first arg should be the callback. If it's a plain ident or
@@ -587,7 +701,17 @@ function step(node, stateIn, callContext) {
587
701
  if (cb && (cb.kind === 'ident' || cb.kind === 'function-value')) {
588
702
  callContext._higherOrderInvocations = callContext._higherOrderInvocations || [];
589
703
  callContext._higherOrderInvocations.push({
590
- callee: cb.kind === 'ident' ? cb.name : (cb.qid || null),
704
+ // A resolved-by-name callback (`arr.map(processItem)`) and an
705
+ // inline callback (`arr.map(x => ...)`, parser-js.js's
706
+ // exprOf now emits {kind:'function-value', qid}) need different
707
+ // resolution strategies downstream — a bare name looked up via
708
+ // the call graph's byNameInFile index (ambiguous/guessable) vs.
709
+ // an exact qid looked up directly in callGraph.functions. Kept
710
+ // as two fields rather than overloading `callee` with either
711
+ // shape, so the consumer can't accidentally hand a qid to the
712
+ // name-based resolver (or vice versa).
713
+ callee: cb.kind === 'ident' ? cb.name : null,
714
+ calleeQid: cb.kind === 'function-value' ? (cb.qid || null) : null,
591
715
  paramIndex: 0,
592
716
  taintedParam: true,
593
717
  line: node.line,
@@ -695,8 +819,29 @@ function analyzeFunction(fn, entryState, callContext) {
695
819
  const t = implicitAssignTarget(nodes[nid], ctx);
696
820
  if (t) implicitState = markImplicitTaint(implicitState, t);
697
821
  }
698
- // A sink in a tainted branch whose arg is implicit-tainted (or constant)
699
- // and NOT already explicitly tainted (the normal pass covers that).
822
+ // Stage 6 correctness audit: these are two genuinely different gates,
823
+ // previously conflated into one loop over `ictx`. `allConst` is a
824
+ // leak from the SINK CALL'S OWN EXECUTION revealing the branch was
825
+ // taken — that requires the call itself to be genuinely inside the
826
+ // tainted branch (now correctly dominance-scoped by
827
+ // buildImplicitContext, see its header). `argRefsImplicit` is a leak
828
+ // from a VARIABLE that was implicit-tainted earlier — once a var is
829
+ // marked, its taint is a normal fact about the var, not about where
830
+ // it's later read; requiring the READ site to also be lexically
831
+ // inside a branch would miss the canonical
832
+ // `if (tainted) { p = x; } eval(p)` pattern the moment `eval(p)` is
833
+ // (correctly) recognized as being outside the branch.
834
+ const reportedNids = new Set();
835
+ const reportImplicit = (nid, node, sink, conditionLabel) => {
836
+ if (reportedNids.has(nid)) return;
837
+ reportedNids.add(nid);
838
+ callContext._findings.push({
839
+ ...createImplicitFinding(node, conditionLabel),
840
+ _funcQid: fn.qid, sinkId: sink.id,
841
+ cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
842
+ });
843
+ };
844
+ // Pass 1 — constant-arg sink calls genuinely inside a tainted branch.
700
845
  for (const [nid, ctx] of ictx) {
701
846
  const node = nodes[nid];
702
847
  if (!node || node.kind !== 'call') continue;
@@ -705,16 +850,24 @@ function analyzeFunction(fn, entryState, callContext) {
705
850
  if (!sink) continue;
706
851
  const inS = inStates.get(nid) || new Set();
707
852
  if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
708
- const argRefsImplicit = (node.args || []).some((a) => {
709
- const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
710
- });
711
853
  const allConst = (node.args || []).length > 0 && (node.args || []).every((a) => a && a.kind === 'literal');
712
- if (argRefsImplicit || allConst) {
713
- callContext._findings.push({
714
- ...createImplicitFinding(node, ctx.conditionLabel),
715
- _funcQid: fn.qid, sinkId: sink.id,
716
- cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
854
+ if (allConst) reportImplicit(nid, node, sink, ctx.conditionLabel);
855
+ }
856
+ // Pass 2 — any sink call anywhere in the function whose argument
857
+ // reads an implicit-tainted variable, regardless of whether the
858
+ // call site itself is inside a branch.
859
+ if (implicitState.size) {
860
+ for (const [nid, node] of Object.entries(nodes)) {
861
+ if (!node || node.kind !== 'call') continue;
862
+ const cat = matchSinkOrSanitizer(node.callee, _currentFile);
863
+ const sink = cat && cat.find((e) => e.kind === 'sink');
864
+ if (!sink) continue;
865
+ const inS = inStates.get(nid) || new Set();
866
+ if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
867
+ const argRefsImplicit = (node.args || []).some((a) => {
868
+ const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
717
869
  });
870
+ if (argRefsImplicit) reportImplicit(nid, node, sink, ictx.get(nid)?.conditionLabel || null);
718
871
  }
719
872
  }
720
873
  } catch { /* implicit flow is best-effort + opt-in */ }
@@ -785,9 +938,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
785
938
  // unbounded blowup). v0.66 — the inner ctx now records mutatedParams
786
939
  // via _mutatedParamsOut so cross-function param mutation propagates.
787
940
  const MAX_FP_ITERS = 3;
788
- let prevCacheSize = -1;
789
941
  for (let it = 0; it < MAX_FP_ITERS; it++) {
790
942
  if (Date.now() > deadlineMs) break;
943
+ // Tracks whether this iteration actually changed any cached summary's
944
+ // VALUE — the correct convergence signal (see below). Reset each
945
+ // iteration; if nothing changed, the fixed point has been reached and
946
+ // further iterations would recompute byte-identical results.
947
+ let changedThisIter = false;
791
948
  for (const fn of fnList) {
792
949
  if (Date.now() > deadlineMs) break;
793
950
  const entry = new Set();
@@ -802,20 +959,44 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
802
959
  _mutatedParamsOut: new Set(),
803
960
  };
804
961
  try { analyzeFunction(fn, entry, ctx); } catch {}
962
+ // Report real findings discovered by this probe rather than letting
963
+ // them die with `ctx` — see _collectFindings's header comment. Safe to
964
+ // call every iteration and again from the main loop below: dedup is by
965
+ // (sinkId, file, line), so re-discovering the same empty-entry finding
966
+ // multiple times collapses to one reported finding, never a duplicate.
967
+ _collectFindings(fn, ctx._findings);
805
968
  const next = {
806
969
  returnTainted: !!ctx._returnTainted,
807
970
  mutatedParams: ctx._mutatedParamsOut || new Set(),
808
971
  taintedGlobals: new Set(),
809
972
  findings: [],
810
973
  };
974
+ // Membership-aware, not size-only — two summaries with the same
975
+ // mutatedParams CARDINALITY but different MEMBERS (e.g. {'a'} vs
976
+ // {'b'}) were treated as unchanged, so a real refinement across
977
+ // iterations (a callee's mutated-field identity settling once its own
978
+ // callees' summaries became known) was silently never written to the
979
+ // cache. Same bug class as summaries.js's _summaryEq, fixed alongside
980
+ // it. Reuses the same access-path-aware setsEqual already imported
981
+ // for taint-state comparison elsewhere in this file (mutatedParams
982
+ // entries are access paths too, e.g. '_this_.field').
811
983
  if (!existing
812
984
  || existing.returnTainted !== next.returnTainted
813
- || (existing.mutatedParams?.size || 0) !== next.mutatedParams.size) {
985
+ || !accessSetsEqual(existing.mutatedParams, next.mutatedParams)) {
814
986
  summaryCache.set(fn.qid, entry, next);
987
+ changedThisIter = true;
815
988
  }
816
989
  }
817
- if (summaryCache.size() === prevCacheSize) break;
818
- prevCacheSize = summaryCache.size();
990
+ // NOT `summaryCache.size() === prevCacheSize` (the pre-fix check): every
991
+ // function gets a cache key on iteration 0 (`!existing` is true for all
992
+ // of them), so `.size()` — a KEY COUNT — jumps from 0 to N once and then
993
+ // never changes again, since overwriting an existing Map key never
994
+ // changes `.size`. That made the loop `break` after iteration 1
995
+ // regardless of whether iteration 1 itself found real refinements to
996
+ // write, silently delivering 2 rounds of fixed-point refinement instead
997
+ // of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
998
+ // `changedThisIter` tracks actual value changes instead.
999
+ if (!changedThisIter) break;
819
1000
  }
820
1001
  // Class-field cross-taint pass: when a method writes tainted data to _this_.field,
821
1002
  // re-analyze other methods of the same class with those fields in the entry state.
@@ -844,11 +1025,19 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
844
1025
  _mutatedParamsOut: new Set(),
845
1026
  };
846
1027
  try { analyzeFunction(fn, fields, ctx); } catch {}
1028
+ // `findings` carries the REAL findings from this probe (was hardcoded
1029
+ // `[]`, discarding them) — but this pass is speculative (every field
1030
+ // in `fields` is assumed simultaneously tainted; nothing here confirms
1031
+ // this exact method is ever reached with that state), so it must not
1032
+ // report them itself. They ride on the cached summary and are only
1033
+ // surfaced by _mergeSummaryFindings when a REAL call site (assign,
1034
+ // plain-call, or higher-order) actually consults this qid+entry —
1035
+ // at that point a genuine reachable caller has been established.
847
1036
  summaryCache.set(fn.qid, fields, {
848
1037
  returnTainted: !!ctx._returnTainted,
849
1038
  mutatedParams: ctx._mutatedParamsOut || new Set(),
850
1039
  taintedGlobals: new Set(),
851
- findings: [],
1040
+ findings: ctx._findings,
852
1041
  });
853
1042
  }
854
1043
  }
@@ -868,11 +1057,27 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
868
1057
  _mutatedParamsOut: new Set(),
869
1058
  };
870
1059
  try { analyzeFunction(fn, taintedEntry, ctx); } catch {}
1060
+ // `findings` carries the real findings from this probe (was hardcoded
1061
+ // `[]`). This pass assumes EVERY param is simultaneously tainted —
1062
+ // there's no check that any real caller ever passes tainted data here
1063
+ // at all (the header comment above claims "AND at least one caller in
1064
+ // the call graph"; the code has never actually enforced that) — so
1065
+ // these findings must not be reported unconditionally, only when a real
1066
+ // call site's own entry state happens to match and consults this cached
1067
+ // summary via _mergeSummaryFindings. This is the exact scenario that
1068
+ // motivated storing them at all: an inline callback
1069
+ // (`arr.forEach(x => sink(x))`) has one param, so it gets probed here
1070
+ // with taintedEntry={param} BEFORE the higher-order invocation loop
1071
+ // below ever runs; without `findings` riding on the cached summary, the
1072
+ // real finding computed right here was thrown away and unrecoverable —
1073
+ // the higher-order loop's own `summaryCache.get()` would hit this
1074
+ // now-cached (finding-less) summary and never call `compute()` (the
1075
+ // only place that used to merge findings) at all.
871
1076
  summaryCache.set(fn.qid, taintedEntry, {
872
1077
  returnTainted: !!ctx._returnTainted,
873
1078
  mutatedParams: ctx._mutatedParamsOut || new Set(),
874
1079
  taintedGlobals: new Set(),
875
- findings: [],
1080
+ findings: ctx._findings,
876
1081
  });
877
1082
  }
878
1083
  for (const fn of fnList) {
@@ -899,13 +1104,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
899
1104
  for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
900
1105
  if (Date.now() > deadlineMs) break;
901
1106
  const inv = hoInvocations[hi];
902
- if (!inv.callee || !inv.taintedParam) continue;
903
- // inv.callee is always a bare ident (only cb.kind === 'ident' pushes a
904
- // higher-order invocation see the push site above), so resolve()'s
905
- // bare-tail guess never triggers here either way; resolveKnownCallee
906
- // for consistency with the other call-graph lookups in this file.
907
- const resolved = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null;
908
- const cbFn = functionRecord(callGraph, resolved);
1107
+ if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
1108
+ // Two resolution strategies, matching the two shapes the push site can
1109
+ // record: an inline callback (`arr.map(x => ...)`) carries an exact
1110
+ // qid (parser-js.js's exprOf synthesizes it identically to how
1111
+ // enterFn will independently name the same node) look it up directly
1112
+ // in callGraph.functions, no name resolution involved. A by-reference
1113
+ // callback (`arr.map(processItem)`) carries a bare ident name; resolve
1114
+ // it the same way every other call-graph lookup in this file does.
1115
+ // resolveKnownCallee (never the bare-tail-guessing resolve()) since a
1116
+ // wrong guess here would fabricate a callback relationship that
1117
+ // doesn't exist.
1118
+ const cbFn = inv.calleeQid
1119
+ ? functionRecord(callGraph, inv.calleeQid)
1120
+ : functionRecord(callGraph, callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null);
909
1121
  if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
910
1122
  const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
911
1123
  let cbSummary = summaryCache.get(cbFn.qid, cbEntry);
@@ -918,21 +1130,57 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
918
1130
  _mutatedParamsOut: new Set(),
919
1131
  };
920
1132
  try { analyzeFunction(cbFn, cbEntry, inner); } catch {}
921
- // Merge any findings from the callback analysis into the caller.
922
- callContext._findings.push(...inner._findings.map(f => ({ ...f, _funcQid: fn.qid, _via: 'higher-order' })));
923
1133
  return {
924
1134
  returnTainted: !!inner._returnTainted,
925
1135
  mutatedParams: inner._mutatedParamsOut || new Set(),
926
1136
  taintedGlobals: new Set(),
927
- findings: [],
1137
+ findings: inner._findings,
928
1138
  };
929
1139
  });
930
1140
  }
1141
+ // Uniform with the assign/plain-call sites: merge whether this was a
1142
+ // fresh compute() (findings from `inner` just above) or a cache HIT —
1143
+ // e.g. the k=2 pass already probed this exact qid+entry (a callback
1144
+ // with one param has taintedEntry===cbEntry) and stashed its own real
1145
+ // findings on the summary rather than reporting them speculatively.
1146
+ _mergeSummaryFindings(callContext, fn.qid, cbSummary, 'higher-order');
931
1147
  }
932
- for (const f of callContext._findings) {
933
- const key = `${f.sinkId}:${fn.file}:${f.line}`;
1148
+ _collectFindings(fn, callContext._findings);
1149
+ }
1150
+ // v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
1151
+ // Dead code suppression: demote findings in functions with zero callers
1152
+ // (except route handlers which are entry points)
1153
+ const calledQids = new Set();
1154
+ if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
1155
+ if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
1156
+ for (const f of all) {
1157
+ if (!f._funcQid) continue;
1158
+ const fn = callGraph.functions?.get(f._funcQid);
1159
+ if (!fn) continue;
1160
+ if (calledQids.has(f._funcQid)) continue;
1161
+ if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
1162
+ f._inDeadCode = true;
1163
+ const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
1164
+ if (dg[f.severity]) f.severity = dg[f.severity];
1165
+ }
1166
+ Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
1167
+ return all;
1168
+
1169
+ // Dedup + map a raw findings array (from analyzeFunction's callContext)
1170
+ // into the reported IR-TAINT shape, attributed to `fn`. Used directly by
1171
+ // the main loop and the empty-entry pre-pass (both analyze under an entry
1172
+ // state that is either empty or true-by-construction, so their findings
1173
+ // are unconditionally real). The class-field and k=2 pre-passes are
1174
+ // speculative (they assume fields/params are tainted without confirming
1175
+ // any real caller ever does that) — see _mergeSummaryFindings, which is
1176
+ // the gate that gives their findings a chance to be reported only once a
1177
+ // genuine caller is established.
1178
+ function _collectFindings(attributedFn, srcFindings) {
1179
+ for (const f of srcFindings) {
1180
+ const key = `${f.sinkId}:${attributedFn.file}:${f.line}`;
934
1181
  if (seen.has(key)) continue;
935
1182
  seen.add(key);
1183
+ const fn = attributedFn;
936
1184
  all.push({
937
1185
  id: `ir-taint:${fn.file}:${f.line}:${f.sinkId}`,
938
1186
  file: fn.file,
@@ -946,6 +1194,39 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
946
1194
  // (a control-dependence finding, not an explicit data-flow one).
947
1195
  confidence: (f.implicit && typeof f.confidence === 'number') ? f.confidence : 0.75,
948
1196
  ...(f.implicit === true ? { implicit: true } : {}),
1197
+ // Sanitizer callees observed on the value reaching this sink. This
1198
+ // mapping is an explicit allowlist, so a field absent here is silently
1199
+ // dropped — which is what previously left sanitizer-gate.js inert.
1200
+ ...(Array.isArray(f._sanitizersOnPath) && f._sanitizersOnPath.length
1201
+ ? { _sanitizersOnPath: f._sanitizersOnPath } : {}),
1202
+ // _funcQid: the enclosing function's qid, set upstream during the walk
1203
+ // but silently dropped by this allowlist before backward.js's
1204
+ // annotateBackwardSlices ever saw it — the same class of omission
1205
+ // _sanitizersOnPath had. Without it, annotateBackwardSlices's very
1206
+ // first check (`if (!f._funcQid) skip`) discarded every finding, so
1207
+ // backward-slice annotation was permanently a no-op regardless of
1208
+ // AGENTIC_SECURITY_BACKWARD_SLICE.
1209
+ ...(f._funcQid ? { _funcQid: f._funcQid } : {}),
1210
+ // callee: kept as plain `callee` (not underscore-prefixed) to match
1211
+ // backward.js's own contract, which reads `f.callee` on both real and
1212
+ // fake-fixture findings throughout its module and test suite. It is
1213
+ // the SAME object reference as the CFG call node's own `callee` (set
1214
+ // at `_sinkFindingsForCall`'s call site as `callee: calleeExpr`,
1215
+ // never copied) — backward.js's sink-node lookup matches
1216
+ // `n.callee === f.callee` by reference identity, so dropping this
1217
+ // field (as the allowlist previously did) meant that match could
1218
+ // never succeed regardless of `_funcQid`. It is not read by
1219
+ // report/index.js's normalizeFindings, which is itself an explicit
1220
+ // allowlist that never names `callee`, so this does not reach SARIF/
1221
+ // JSON/HTML report output.
1222
+ ...(f.callee !== undefined ? { callee: f.callee } : {}),
1223
+ // sourceProvenance/chain[].provenance: catalog.js's per-source label
1224
+ // (e.g. 'http-body' for req.body) computed earlier at the finding's
1225
+ // creation site — previously dropped by this allowlist, which is
1226
+ // what left posture/exploitability-probability.js's
1227
+ // 'source-from-network' factor permanently dead (it reads
1228
+ // t.provenance off chain/trace entries).
1229
+ sourceProvenance: f.sourceProvenance || null,
949
1230
  source: f.trace && f.trace.length ? {
950
1231
  file: fn.file,
951
1232
  line: f.trace[0].line,
@@ -957,27 +1238,10 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
957
1238
  label: f.sinkId,
958
1239
  },
959
1240
  chain: (f.trace || []).map(t => ({
960
- file: fn.file, line: t.line, label: t.sourceLabel,
1241
+ file: fn.file, line: t.line, label: t.sourceLabel, provenance: t.provenance || null,
961
1242
  })),
962
1243
  });
963
1244
  }
964
1245
  }
965
- // v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
966
- // Dead code suppression: demote findings in functions with zero callers
967
- // (except route handlers which are entry points)
968
- const calledQids = new Set();
969
- if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
970
- if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
971
- for (const f of all) {
972
- if (!f._funcQid) continue;
973
- const fn = callGraph.functions?.get(f._funcQid);
974
- if (!fn) continue;
975
- if (calledQids.has(f._funcQid)) continue;
976
- if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
977
- f._inDeadCode = true;
978
- const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
979
- if (dg[f.severity]) f.severity = dg[f.severity];
980
- }
981
- Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
982
- return all;
1246
+
983
1247
  }