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

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 (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -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 +192 -15
  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 +15 -15
  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 +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -30,16 +30,19 @@
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.
41
-
42
- import { matchSource, matchSinkOrSanitizer } from './catalog.js';
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.
44
+
45
+ import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
43
46
  import { functionRecord } from '../ir/callgraph.js';
44
47
  import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
45
48
  import { aliasesForVar } from './points-to.js';
@@ -47,6 +50,13 @@ import { higherOrderTaintFlow } from './higher-order.js';
47
50
  import { SummaryCache, entryStateFromCall } from './summaries.js';
48
51
  import { lookupBuiltinSummary } from './builtin-summaries.js';
49
52
  import { isImplicitFlowEnabled, buildImplicitContext, implicitAssignTarget, markImplicitTaint, createImplicitFinding } from './implicit-flow.js';
53
+ // NOTE: receiver-context.js's receiverTypeAtCall is deliberately NOT imported
54
+ // here. It was the implementation of _receiverTypeFor's old `this.field`
55
+ // branch, whose PascalCase-of-field-name guess is the false-negative bug this
56
+ // file's _receiverTypeFor comment describes. Its other exports are still used
57
+ // (summaries.js imports hashReceiverType for cache keying); only this call
58
+ // site is gone.
59
+ import { resolveMethod, classOfVar } from '../ir/class-hierarchy.js';
50
60
 
51
61
  // v0.70 #2 — addPath that also taints every alias of the variable.
52
62
  // When `target` is a dotted path like "a.x" and the root `a` has aliases
@@ -99,6 +109,74 @@ function _flattenCalleeName(calleeExpr) {
99
109
  return null;
100
110
  }
101
111
 
112
+ // PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): unlike _flattenCalleeName
113
+ // (which only flattens ONE level — `x.method`/`this.method` — because that is
114
+ // the 2-segment shape catalog matching and resolveKnownCallee both key on),
115
+ // _receiverTypeFor needs the FULL dotted chain, including `this`, to see how
116
+ // LONG the chain actually is: a 3+-segment chain (`this.userRepo.save` ->
117
+ // ['this','userRepo','save'], `svc.db.query` -> ['svc','db','query']) names a
118
+ // receiver that is a property path, and CHA cannot type property paths at all.
119
+ // A partial flatten (`_flattenCalleeName` returns just 'save' for
120
+ // `this.userRepo.save`, since its object isn't a bare ident) would hide that
121
+ // distinction and make a 3-segment chain look like an untyped bare call.
122
+ //
123
+ // Note: parser-js.js encodes ThisExpression as {kind:'ident', name:'_this_'}
124
+ // (a sentinel, not literal 'this'). We convert it to the literal string
125
+ // 'this' so the flattened chain reads the way the source does and its segment
126
+ // count is honest (`this.db.query` is 3 segments, not 2).
127
+ function _fullyFlattenMemberChain(calleeExpr) {
128
+ if (!calleeExpr) return null;
129
+ if (typeof calleeExpr === 'string') return calleeExpr;
130
+ if (calleeExpr.kind === 'ident') {
131
+ const name = calleeExpr.name || null;
132
+ // Convert the parser's _this_ sentinel to the literal 'this' string
133
+ return name === '_this_' ? 'this' : name;
134
+ }
135
+ if (calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
136
+ const base = _fullyFlattenMemberChain(calleeExpr.object);
137
+ return base ? `${base}.${calleeExpr.prop}` : calleeExpr.prop;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ // Shared by R6 (catalog receiver-type gating) and R11 (member-call
143
+ // resolution) so both use the exact same precision bar, per the PRD's own
144
+ // sequencing note that R11 must not be more permissive than R6. Returns null
145
+ // whenever CHA has nothing useful to say — callers must treat null as
146
+ // "unknown", never as a signal to suppress or refuse (see this file's
147
+ // "Unknown ≠ clean" global constraint).
148
+ function _receiverTypeFor(calleeExpr, callContext) {
149
+ if (!callContext || !callContext._cha) return null;
150
+ const flat = _fullyFlattenMemberChain(calleeExpr);
151
+ if (!flat || !flat.includes('.')) return null;
152
+ const parts = flat.split('.');
153
+ // Only a bare `x.method()` receiver (exactly 2 dot-separated parts) is
154
+ // something CHA can genuinely verify: classOfVar only tracks bare local
155
+ // variable -> class bindings from `let/const x = new Foo()`, never
156
+ // property-path types. This one condition replaces two separate prior
157
+ // bugs found in whole-branch review: the this.field branch (`this.x.y()`
158
+ // is 3 parts, `this` as root) used to PascalCase-guess a type from the
159
+ // field name and could never return null, silently suppressing real
160
+ // findings on any field name outside a fixed vocabulary
161
+ // (this.dbConn.query(), this.readReplica.query(), ...); the non-this
162
+ // branch used to resolve parts[0] (the chain ROOT) for multi-segment
163
+ // chains like svc.db.query(), which answers "what type is svc?" instead
164
+ // of the actual question "what type is svc.db?" -- a question CHA has no
165
+ // way to answer, since it never tracks field types, only local-variable
166
+ // types. Both were name/shape guesses being trusted as confident
167
+ // resolutions. A multi-segment or `this`-rooted chain now honestly
168
+ // returns null (unknown, permissive) rather than guessing.
169
+ //
170
+ // The same doctrine already killed a third instance of this bug class: a
171
+ // receiver assigned via `const c = mysql.createConnection({})` cannot be
172
+ // typed by CHA (member-call factory, not `new X()`), so classOfVar returns
173
+ // null — returning the bare name 'c' as a "type" suppressed a real finding
174
+ // (regression: CVE-2021-22214-node-sqli-shape). There is no name-based
175
+ // fallback anywhere in this function for exactly that reason.
176
+ if (parts.length !== 2) return null;
177
+ return classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, parts[0]);
178
+ }
179
+
102
180
  // Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
103
181
  // Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
104
182
  // is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
@@ -117,7 +195,106 @@ function _resolvableCalleeName(calleeExpr) {
117
195
  return null;
118
196
  }
119
197
 
120
- function exprTaint(expr, state) {
198
+ // R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): unlike R6's _receiverTypeFor
199
+ // (used only to narrow an ALREADY-pattern-matched catalog sink — safe to be
200
+ // wrong in either direction, since the worst case is over/under-gating an
201
+ // existing match), this function creates a NEW interprocedural call-graph
202
+ // edge. A wrong resolution here fabricates a data-flow path that does not
203
+ // exist, which this codebase's own doctrine treats as strictly worse than
204
+ // a missed one (see _resolvableCalleeName's comment above). It therefore
205
+ // calls classOfVar DIRECTLY, which only returns non-null when the receiver
206
+ // was genuinely assignment-tracked (`let x = new Foo()`), and additionally
207
+ // requires the receiver to be a bare, non-`this` identifier expression.
208
+ //
209
+ // Historically _receiverTypeFor carried NAME-based fallbacks (a
210
+ // PascalCase-of-`this.field` guess and a bare-identifier-name fallback) that
211
+ // this function was careful never to reuse, because a same-named
212
+ // parameter/variable/field (e.g. a duck-typed
213
+ // `function process(Model, data) { Model.save(data); }`) would resolve to an
214
+ // unrelated real class purely by name coincidence. Whole-branch review found
215
+ // those same guesses were also wrong for R6's much softer use, so they are
216
+ // gone: _receiverTypeFor now bottoms out in the same classOfVar call this
217
+ // function makes. The two are deliberately kept as separate functions
218
+ // anyway — they take different inputs (a flattened chain vs. a member
219
+ // expression) and R11's extra `resolveMethod` step means it can still refuse
220
+ // where R6 does not.
221
+ function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
222
+ if (!calleeExpr || calleeExpr.kind !== 'member' || typeof calleeExpr.prop !== 'string') return null;
223
+ if (!callContext || !callContext._cha) return null;
224
+ if (!calleeExpr.object || calleeExpr.object.kind !== 'ident' || calleeExpr.object.name === '_this_') return null;
225
+ const className = classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, calleeExpr.object.name);
226
+ if (!className) return null;
227
+ const found = resolveMethod(callContext._cha, className, calleeExpr.prop);
228
+ if (!found) return null;
229
+ return `${found.className}.${found.methodName}`;
230
+ }
231
+
232
+ // Resolve calleeExpr to { qid, fn } via the call graph — the shared
233
+ // resolve-and-lookup sequence every summary-consulting call site needs.
234
+ // Extracted from what were two independent, drifting copies (assign-RHS and
235
+ // plain-call-statement) so a future change (like PRD R11 in this same file)
236
+ // only has to land once. See _resolvableCalleeName's own comment for why a
237
+ // bare-name/pre-flattened-string callee is the ONLY case handled here for
238
+ // now — Task 4 (PRD R11) extends this function's body to add a second,
239
+ // CHA-gated resolution path for member-expression callees.
240
+ function _resolveCalleeForSummary(calleeExpr, callContext) {
241
+ if (!callContext || !callContext._callGraph || !callContext._callGraph.resolveKnownCallee) return null;
242
+ const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
243
+ let _resolvableName = _resolvableCalleeName(calleeExpr);
244
+ // PRD R11: _resolvableCalleeName refuses every member-expression callee.
245
+ // When that's the reason we have nothing, try the CHA-gated path before
246
+ // giving up — but ONLY then, so the existing exact/bare-name behavior is
247
+ // completely unchanged for every case it already handled.
248
+ if (!_resolvableName) _resolvableName = _resolveMemberCalleeViaCHA(calleeExpr, callContext);
249
+ if (!_resolvableName) return null;
250
+ const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
251
+ const fn = functionRecord(callContext._callGraph, resolved);
252
+ const qid = resolved && (resolved.qid || resolved);
253
+ return typeof qid === 'string' ? { qid, fn } : null;
254
+ }
255
+
256
+ // PRD R10 (docs/DETECTION_GAP_REMEDIATION_PRD.md): the only two places that
257
+ // consult a callee's SummaryCache entry are the assign-RHS and plain-call-
258
+ // statement paths in step() below — a call nested INSIDE another expression
259
+ // (most commonly a sink's own argument list, `sink(getUserInput())`) reaches
260
+ // neither, so exprTaint's 'call' case fell back to checking only the nested
261
+ // call's OWN arguments, silently losing the callee's return-taint. Mirrors
262
+ // the same resolve -> get-or-compute -> merge sequence step()'s two existing
263
+ // call sites use, via the Task 3/4 shared _resolveCalleeForSummary.
264
+ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
265
+ if (!callContext || !callContext._summaryCache) return false;
266
+ const target = _resolveCalleeForSummary(calleeExpr, callContext);
267
+ if (!target) return false;
268
+ const { qid, fn } = target;
269
+ const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
270
+ const entry = paramNames.length
271
+ ? entryStateFromCall(paramNames, argExprs || [], state)
272
+ : new Set();
273
+ let sum = callContext._summaryCache.get(qid, entry);
274
+ if (!sum && fn && fn.cfg) {
275
+ sum = callContext._summaryCache.compute(qid, entry, () => {
276
+ const inner = {
277
+ _findings: [], _taintSources: [], _returnTainted: false,
278
+ _stack: new Set(), deadlineMs: callContext.deadlineMs,
279
+ _summaryCache: callContext._summaryCache,
280
+ _callGraph: callContext._callGraph,
281
+ _mutatedParamsOut: new Set(),
282
+ _cha: callContext._cha,
283
+ };
284
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
285
+ return {
286
+ returnTainted: !!inner._returnTainted,
287
+ mutatedParams: inner._mutatedParamsOut || new Set(),
288
+ taintedGlobals: new Set(),
289
+ findings: inner._findings,
290
+ };
291
+ });
292
+ }
293
+ _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
294
+ return !!(sum && sum.returnTainted);
295
+ }
296
+
297
+ function exprTaint(expr, state, callContext) {
121
298
  if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
122
299
  if (!expr) return false;
123
300
  // Constant propagation: variables assigned from literals are never tainted
@@ -131,16 +308,18 @@ function exprTaint(expr, state) {
131
308
  switch (expr.kind) {
132
309
  case 'literal': return false;
133
310
  case 'binary':
134
- case 'logical': return exprTaint(expr.left, state) || exprTaint(expr.right, state);
135
- case 'tpl': return (expr.parts || []).some(p => exprTaint(p, state));
136
- case 'union': return (expr.branches || []).some(b => exprTaint(b, state));
137
- case 'object': return (expr.props || []).some(p => exprTaint(p.value, state));
138
- case 'array': return (expr.elements || []).some(e => exprTaint(e, state));
311
+ case 'logical': return exprTaint(expr.left, state, callContext) || exprTaint(expr.right, state, callContext);
312
+ case 'tpl': return (expr.parts || []).some(p => exprTaint(p, state, callContext));
313
+ case 'union': return (expr.branches || []).some(b => exprTaint(b, state, callContext));
314
+ case 'object': return (expr.props || []).some(p => exprTaint(p.value, state, callContext));
315
+ case 'array': return (expr.elements || []).some(e => exprTaint(e, state, callContext));
139
316
  case 'call': {
140
- // Calls are handled at the CFG level (the call has already been processed).
141
- // For an inline call expression, conservatively return whether any arg is tainted.
142
- // This loses the sanitizer effect but is safe.
143
- return (expr.args || []).some(a => exprTaint(a, state));
317
+ // The call's own arguments (unchanged from before) OR PRD R10 the
318
+ // resolved callee's own return-taint summary. Args-tainted is checked
319
+ // FIRST and short-circuits: it's the cheap, no-resolve-attempt case and
320
+ // was already correct.
321
+ if ((expr.args || []).some(a => exprTaint(a, state, callContext))) return true;
322
+ return _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
144
323
  }
145
324
  case 'unknown': return false;
146
325
  default: return false;
@@ -261,13 +440,62 @@ function literalSkeletonMatchesFamily(expr, cwe) {
261
440
 
262
441
  // calleeExpr / argExprs: the IR nodes for the call's callee and arguments.
263
442
  // state: the taint-state Set to evaluate argument taint against.
443
+ // callContext: context from the engine (contains _cha for CHA lookups).
264
444
  // Returns { cat, argTaints }.
265
- function _matchCallCatalog(calleeExpr, argExprs, state) {
266
- const cat = matchSinkOrSanitizer(calleeExpr, _currentFile);
267
- const argTaints = (argExprs || []).map(a => exprTaint(a, state));
445
+ function _matchCallCatalog(calleeExpr, argExprs, state, callContext) {
446
+ const receiverType = _receiverTypeFor(calleeExpr, callContext);
447
+ const cat = matchSinkOrSanitizer(calleeExpr, _currentFile, receiverType);
448
+ const argTaints = (argExprs || []).map(a => exprTaint(a, state, callContext));
268
449
  return { cat, argTaints };
269
450
  }
270
451
 
452
+ // Sanitizer callees observed on an expression.
453
+ //
454
+ // The engine deliberately does NOT let a sanitizer kill taint. A blanket
455
+ // "any sanitizer clears the flow" rule scores well on benchmarks and silently
456
+ // drops a real SQL injection whenever the code applied an HTML escaper — the
457
+ // C/C++ catalog work already found strncpy/snprintf tagged effect:'strip' when
458
+ // they bound length rather than sanitising content. So the walk RECORDS which
459
+ // sanitizers touched the value and hands them to sanitizer-gate.js, which
460
+ // labels the finding, and the proof gate demotes it. Recall-preserving, same
461
+ // precedent as falsification.js / proof-gate.js: never removed, never
462
+ // severity-touched.
463
+ function _sanitizersInExprTree(expr, out) {
464
+ if (!expr || typeof expr !== 'object') return;
465
+ if (expr.kind === 'call') {
466
+ const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
467
+ if (cat) {
468
+ for (const e of cat) {
469
+ if (e.kind === 'sanitizer' && e.match && e.match.callee) out.add(e.match.callee);
470
+ }
471
+ }
472
+ }
473
+ for (const k of ['left', 'right', 'callee', 'object', 'property', 'value']) {
474
+ if (expr[k] && typeof expr[k] === 'object') _sanitizersInExprTree(expr[k], out);
475
+ }
476
+ for (const k of ['args', 'parts', 'branches', 'elements']) {
477
+ if (Array.isArray(expr[k])) for (const e of expr[k]) _sanitizersInExprTree(e, out);
478
+ }
479
+ if (Array.isArray(expr.props)) for (const p of expr.props) _sanitizersInExprTree(p && p.value, out);
480
+ }
481
+
482
+ // Sanitizers applied to `expr`: those called inline within it, plus those
483
+ // recorded against any variable it reads (`const safe = escapeHtml(x); sink(safe)`).
484
+ function _sanitizersForExpr(expr, callContext) {
485
+ const out = new Set();
486
+ _sanitizersInExprTree(expr, out);
487
+ const byVar = callContext && callContext._sanitizersByVar;
488
+ if (byVar && byVar.size) {
489
+ const vars = new Set();
490
+ _collectExprVars(expr, vars);
491
+ for (const v of vars) {
492
+ const s = byVar.get(v);
493
+ if (s) for (const n of s) out.add(n);
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+
271
499
  // cat / argTaints: the result of _matchCallCatalog (computed by the caller,
272
500
  // at whatever point in its case is appropriate for its own state-mutation
273
501
  // ordering).
@@ -299,7 +527,13 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
299
527
  ? reachingSources.slice(0, 5)
300
528
  // Fallback: better to surface "no precise source" than the wrong source.
301
529
  : [];
530
+ // Sanitizers seen on the value reaching THIS argument. Consumed by
531
+ // sanitizer-gate.js, which labels only when the sanitizer's family
532
+ // covers the finding's threat class — an xss escaper on a SQL sink
533
+ // must not read as sanitised.
534
+ const _sanNames = _sanitizersForExpr(taintedArgExpr, callContext);
302
535
  findings.push({
536
+ ...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
303
537
  kind: 'taint',
304
538
  sinkId: e.id,
305
539
  vuln: e.vuln?.name || 'Tainted Sink',
@@ -318,9 +552,68 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
318
552
  return { findings };
319
553
  }
320
554
 
555
+ // PRD R13(a): finding shape for a member-write sink match (el.innerHTML =
556
+ // tainted). Distinct from _sinkFindingsForCall because there is no call
557
+ // argument list to index into — the "argument" of interest is the whole
558
+ // assignment RHS, which the catalog entries already mark via argIndex:'rhs'
559
+ // (a sentinel that existed in these 3 entries since they were added, with no
560
+ // consumer until now). Mirrors _sinkFindingsForCall's trace/sanitizer
561
+ // attribution exactly, so a member-write finding looks like any other
562
+ // deep-mode finding downstream.
563
+ function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, targetPath) {
564
+ const findings = [];
565
+ for (const e of hits) {
566
+ const reachingSources = _sourcesReachingExpr(sourceExpr, state, callContext._taintSources);
567
+ const traceForThisFinding = reachingSources.length ? reachingSources.slice(0, 5) : [];
568
+ const _sanNames = _sanitizersForExpr(sourceExpr, callContext);
569
+ findings.push({
570
+ ...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
571
+ kind: 'taint',
572
+ sinkId: e.id,
573
+ vuln: e.vuln?.name || 'Tainted Sink',
574
+ severity: e.vuln?.severity || 'high',
575
+ cwe: e.vuln?.cwe || null,
576
+ remediation: e.vuln?.remediation || null,
577
+ line,
578
+ argIndex: 'rhs',
579
+ callee: targetPath,
580
+ sourceProvenance: (traceForThisFinding[0]?.provenance) || null,
581
+ trace: traceForThisFinding,
582
+ });
583
+ }
584
+ return findings;
585
+ }
586
+
587
+ // Surfaces a cached (or freshly computed) summary's `findings` into the
588
+ // CURRENT caller's context — the only place a class-field/k=2 pre-pass's
589
+ // speculative findings (in runTaintEngine) become reportable, because
590
+ // reaching this point means a real call site actually consulted that exact
591
+ // qid+entry. Called uniformly on both a cache HIT (`summaryCache.get()`)
592
+ // and a cache MISS (`summaryCache.compute()`'s return value), so it doesn't
593
+ // matter whether this call site is the first one to ever reach this
594
+ // qid+entry or the fifth — findings ride on the summary object itself now,
595
+ // not on a one-shot merge inside compute()'s callback. Module-level (not
596
+ // nested in runTaintEngine) because step()'s assign/plain-call interproc
597
+ // branches call it too, and step() is a top-level function with no access
598
+ // to runTaintEngine's locals.
599
+ function _mergeSummaryFindings(callContext, callerQid, sum, via) {
600
+ if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
601
+ callContext._findings.push(...sum.findings.map(f => ({ ...f, _funcQid: callerQid || null, _via: via })));
602
+ }
603
+
321
604
  // Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
322
605
  function step(node, stateIn, callContext) {
323
- const state = new Set(stateIn);
606
+ // `let`, not `const` the 'call' case (built-in-mutation and mutated-param
607
+ // branches below) reassigns this binding. It was `const` until Stage 3 of
608
+ // the correctness audit: a bare-statement call to Object.assign/_.merge/
609
+ // etc. with a tainted source arg, or any plain call whose callee summary
610
+ // reports mutated params, threw "Assignment to constant variable" here.
611
+ // The engine's per-function analyzeFunction() call sites all wrap in a
612
+ // blanket try/catch, so the exception was silent — and it discarded every
613
+ // finding already collected for the ENTIRE containing function, not just
614
+ // the mutation site, since the throw unwound past `findings.push(...)`
615
+ // calls for unrelated sinks earlier in the same function body.
616
+ let state = new Set(stateIn);
324
617
  const findings = [];
325
618
 
326
619
  switch (node.kind) {
@@ -343,11 +636,36 @@ function step(node, stateIn, callContext) {
343
636
  // return path below, including the early interprocedural returns.
344
637
  if (node.source && node.source.kind === 'call') {
345
638
  const { cat: _sinkCat, argTaints: _sinkArgTaints } =
346
- _matchCallCatalog(node.source.callee, node.source.args, state);
639
+ _matchCallCatalog(node.source.callee, node.source.args, state, callContext);
347
640
  findings.push(..._sinkFindingsForCall(
348
641
  node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
349
642
  state, callContext, node.line).findings);
350
643
  }
644
+ // PRD R13(a): the assignment TARGET can itself be a sink shape
645
+ // (el.innerHTML = tainted) — additive to the RHS-call-sink check
646
+ // above, which only ever looked at node.source. `target` is only a
647
+ // dotted member-access path when the LHS was a member expression
648
+ // (lhsPath in parser-js.js); a bare identifier target ("x") has no
649
+ // dot and _matchMemberWriteSink correctly returns null for it.
650
+ if (target && target.includes('.')) {
651
+ const _memberHits = matchMemberWriteSink(target, _currentFile);
652
+ if (_memberHits && exprTaint(node.source, state, callContext)) {
653
+ findings.push(..._memberWriteSinkFindings(
654
+ _memberHits, node.source, state, callContext, node.line, target));
655
+ }
656
+ }
657
+ // Record which sanitizers were applied to the value now held by `target`
658
+ // (inline in the RHS, or inherited from the vars the RHS reads). Placed
659
+ // before every early return in this case so the map cannot go stale on
660
+ // the interprocedural paths below. A clean RHS clears the entry, mirroring
661
+ // removePathAndDescendants — a stale sanitizer would label a later,
662
+ // genuinely unsanitized flow.
663
+ if (target) {
664
+ const _san = _sanitizersForExpr(node.source, callContext);
665
+ const _byVar = (callContext._sanitizersByVar ||= new Map());
666
+ if (_san.size) _byVar.set(target, _san);
667
+ else _byVar.delete(target);
668
+ }
351
669
  // Constant propagation: track variables assigned from literals
352
670
  if (target && _activeConstantVars) {
353
671
  if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
@@ -362,18 +680,10 @@ function step(node, stateIn, callContext) {
362
680
  const calleeName = node.source && node.source.kind === 'call'
363
681
  ? _flattenCalleeName(node.source.callee) : null;
364
682
  if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
365
- const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
366
- const _resolvableName = node.source && node.source.kind === 'call'
367
- ? _resolvableCalleeName(node.source.callee) : null;
368
- // resolveKnownCallee: never guess via resolve()'s bare-tail
369
- // fallback. _resolvableCalleeName already refuses JS member
370
- // expressions, but a pre-flattened STRING callee (Go/PHP/Ruby/
371
- // C++/Python parsers) can still be dotted, and only the resolver
372
- // itself can tell — see callgraph.js.
373
- const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
374
- ? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
375
- const fn = functionRecord(callContext._callGraph, resolved);
376
- const qid = resolved && (resolved.qid || resolved);
683
+ const _resolvedTarget = node.source && node.source.kind === 'call'
684
+ ? _resolveCalleeForSummary(node.source.callee, callContext) : null;
685
+ const fn = _resolvedTarget && _resolvedTarget.fn;
686
+ const qid = _resolvedTarget && _resolvedTarget.qid;
377
687
  if (typeof qid === 'string') {
378
688
  // v0.66 — context-sensitive lookup. Build the entry-state from
379
689
  // the call args + current taint; look up (and lazily compute) the
@@ -398,16 +708,26 @@ function step(node, stateIn, callContext) {
398
708
  _summaryCache: callContext._summaryCache,
399
709
  _callGraph: callContext._callGraph,
400
710
  _mutatedParamsOut: new Set(),
711
+ _cha: callContext._cha,
401
712
  };
402
- try { analyzeFunction(fn, entry, inner); } catch {}
713
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
403
714
  return {
404
715
  returnTainted: !!inner._returnTainted,
405
716
  mutatedParams: inner._mutatedParamsOut || new Set(),
406
717
  taintedGlobals: new Set(),
407
- findings: [],
718
+ // Real findings from the callee's own body — e.g.
719
+ // `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
720
+ // — ride on the summary itself (was hardcoded `[]`, so
721
+ // nothing ever read inner._findings and the SQLi inside
722
+ // makeQuery was silently dropped). _mergeSummaryFindings
723
+ // below surfaces them into THIS caller now that a real
724
+ // call site has been established, and does the same on a
725
+ // future cache hit from any other real caller.
726
+ findings: inner._findings,
408
727
  };
409
728
  });
410
729
  }
730
+ _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
411
731
  if (sum && sum.returnTainted) {
412
732
  newState = _addPathAliasAware(newState, target, callContext);
413
733
  callContext._taintSources.push({
@@ -429,16 +749,35 @@ function step(node, stateIn, callContext) {
429
749
  // Fallback: check builtin summaries for unresolved external calls
430
750
  const builtin = lookupBuiltinSummary(calleeName);
431
751
  if (builtin) {
432
- if (builtin.returnTainted && (node.source.args || []).some(a => exprTaint(a, newState))) {
752
+ const _argTainted = (node.source.args || []).some(a => exprTaint(a, newState, callContext));
753
+ if (builtin.returnTainted && _argTainted) {
433
754
  newState = _addPathAliasAware(newState, target, callContext);
434
755
  } else if (!builtin.returnTainted) {
435
- newState = removePathAndDescendants(newState, target);
756
+ // PRD R4b: a builtin summary saying returnTainted:false can mean
757
+ // two very different things — a genuinely non-deriving function
758
+ // (crypto.randomBytes) where clearing taint is correct, or a
759
+ // sanitizer-shaped function (encodeURIComponent, parseInt,
760
+ // DOMPurify.sanitize...) that DOES receive tainted input and
761
+ // whose safety is family-scoped (a URL encoder does nothing for
762
+ // SQLi). `_sanitizersForExpr` above already recorded the latter
763
+ // case into `_sanitizersByVar` when this callee is ALSO a
764
+ // registered catalog sanitizer — defer to sanitizer-gate.js's
765
+ // family-aware demotion there instead of unconditionally
766
+ // killing every family's taint here. Only a genuinely-untainted
767
+ // argument, or a callee with no catalog-sanitizer registration,
768
+ // still clears via removePathAndDescendants.
769
+ const _recordedSan = target && callContext._sanitizersByVar && callContext._sanitizersByVar.get(target);
770
+ if (_argTainted && _recordedSan && _recordedSan.size) {
771
+ newState = _addPathAliasAware(newState, target, callContext);
772
+ } else {
773
+ newState = removePathAndDescendants(newState, target);
774
+ }
436
775
  return { state: newState, findings };
437
776
  }
438
777
  if (builtin.mutatedParams && builtin.mutatedParams.size) {
439
778
  for (const idx of builtin.mutatedParams) {
440
779
  const argExpr = (node.source.args || [])[parseInt(idx)];
441
- if (argExpr && argExpr.kind === 'ident' && (node.source.args || []).some(a => exprTaint(a, newState))) {
780
+ if (argExpr && argExpr.kind === 'ident' && (node.source.args || []).some(a => exprTaint(a, newState, callContext))) {
442
781
  newState = _addPathAliasAware(newState, argExpr.name, callContext);
443
782
  }
444
783
  }
@@ -451,7 +790,7 @@ function step(node, stateIn, callContext) {
451
790
  const sourcePath = accessPathOf(node.source);
452
791
  if (sourcePath) newState = addPath(newState, sourcePath);
453
792
  callContext._taintSources.push({ varName: target, sourceId: src.id, sourceLabel: src.label, provenance: src.provenance || null, line: node.line });
454
- } else if (exprTaint(node.source, newState)) {
793
+ } else if (exprTaint(node.source, newState, callContext)) {
455
794
  // P1.1: when the source IS a pure access path (e.g., RHS is `obj.foo.bar`),
456
795
  // taint the TARGET as well as transitively propagate the source path so
457
796
  // later uses of the same source remain tainted. The target path
@@ -475,20 +814,14 @@ function step(node, stateIn, callContext) {
475
814
  // Computed here (before the mutation passes below) so that argTaints
476
815
  // reflects the pre-mutation state, exactly as before this logic was
477
816
  // extracted into _matchCallCatalog/_sinkFindingsForCall.
478
- const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state);
817
+ const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state, callContext);
479
818
  // v0.66 — apply mutated-param taint at plain (non-assign) call sites.
480
819
  // Object.assign(target, tainted) → target becomes tainted in caller.
481
820
  const _plainCallCalleeName = _flattenCalleeName(node.callee);
482
821
  if (callContext._summaryCache && callContext._callGraph && _plainCallCalleeName) {
483
- const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
484
- const _resolvableName = _resolvableCalleeName(node.callee);
485
- // resolveKnownCallee: see the comment at the sibling call site above
486
- // — a pre-flattened dotted STRING callee must not be guessed via
487
- // resolve()'s bare-tail fallback.
488
- const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
489
- ? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
490
- const fn = functionRecord(callContext._callGraph, resolved);
491
- const qid = resolved && (resolved.qid || resolved);
822
+ const _resolvedTarget = _resolveCalleeForSummary(node.callee, callContext);
823
+ const fn = _resolvedTarget && _resolvedTarget.fn;
824
+ const qid = _resolvedTarget && _resolvedTarget.qid;
492
825
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
493
826
  const paramNames = fn.params;
494
827
  const entry = paramNames.length
@@ -509,16 +842,21 @@ function step(node, stateIn, callContext) {
509
842
  _summaryCache: callContext._summaryCache,
510
843
  _callGraph: callContext._callGraph,
511
844
  _mutatedParamsOut: new Set(),
845
+ _cha: callContext._cha,
512
846
  };
513
- try { analyzeFunction(fn, entry, inner); } catch {}
847
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
514
848
  return {
515
849
  returnTainted: !!inner._returnTainted,
516
850
  mutatedParams: inner._mutatedParamsOut || new Set(),
517
851
  taintedGlobals: new Set(),
518
- findings: [],
852
+ // See the sibling assign-call-site compute() above — same
853
+ // fix, same reason: this callee's own findings were
854
+ // computed correctly and then thrown away (hardcoded `[]`).
855
+ findings: inner._findings,
519
856
  };
520
857
  });
521
858
  }
859
+ _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
522
860
  if (sum && sum.mutatedParams && sum.mutatedParams.size) {
523
861
  const mutated = callContext._summaryCache.applyAtCallSite(
524
862
  sum, paramNames, node.args || [], state);
@@ -574,7 +912,13 @@ function step(node, stateIn, callContext) {
574
912
  if (dot <= 0) return null;
575
913
  const recv = callee.slice(0, dot);
576
914
  const recvTainted = isCoveredBy(state, recv);
577
- return higherOrderTaintFlow(node, recvTainted);
915
+ // higherOrderTaintFlow requires a flattened STRING callee (its own
916
+ // `typeof callee !== 'string'` guard) — passing the raw `node` here
917
+ // handed it a JS/TS structured callee expr ({kind:'member',...})
918
+ // unconditionally, which never passed that guard, so this feature
919
+ // was entirely dead for JS/TS (the primary catalogued language).
920
+ // `callee` here is `_plainCallCalleeName`, already flattened above.
921
+ return higherOrderTaintFlow({ ...node, callee }, recvTainted);
578
922
  })();
579
923
  if (hoFlow && hoFlow.taintsCallbackParam === 0) {
580
924
  // The first arg should be the callback. If it's a plain ident or
@@ -587,7 +931,17 @@ function step(node, stateIn, callContext) {
587
931
  if (cb && (cb.kind === 'ident' || cb.kind === 'function-value')) {
588
932
  callContext._higherOrderInvocations = callContext._higherOrderInvocations || [];
589
933
  callContext._higherOrderInvocations.push({
590
- callee: cb.kind === 'ident' ? cb.name : (cb.qid || null),
934
+ // A resolved-by-name callback (`arr.map(processItem)`) and an
935
+ // inline callback (`arr.map(x => ...)`, parser-js.js's
936
+ // exprOf now emits {kind:'function-value', qid}) need different
937
+ // resolution strategies downstream — a bare name looked up via
938
+ // the call graph's byNameInFile index (ambiguous/guessable) vs.
939
+ // an exact qid looked up directly in callGraph.functions. Kept
940
+ // as two fields rather than overloading `callee` with either
941
+ // shape, so the consumer can't accidentally hand a qid to the
942
+ // name-based resolver (or vice versa).
943
+ callee: cb.kind === 'ident' ? cb.name : null,
944
+ calleeQid: cb.kind === 'function-value' ? (cb.qid || null) : null,
591
945
  paramIndex: 0,
592
946
  taintedParam: true,
593
947
  line: node.line,
@@ -606,7 +960,7 @@ function step(node, stateIn, callContext) {
606
960
  }
607
961
 
608
962
  case 'return': {
609
- if (exprTaint(node.value, state)) {
963
+ if (exprTaint(node.value, state, callContext)) {
610
964
  callContext._returnTainted = true;
611
965
  }
612
966
  return { state, findings };
@@ -622,6 +976,18 @@ function step(node, stateIn, callContext) {
622
976
  }
623
977
  }
624
978
 
979
+ // R14(a): annotation-derived taint is a function-invariant fact — identical
980
+ // for every call to this qid — so it is unioned into the per-call-site entry
981
+ // state, never into the SummaryCache key (that stays exactly what the caller
982
+ // supplied). Returns the ORIGINAL Set unchanged when there's nothing to add,
983
+ // so callers that never touch annotation-shaped params pay zero extra cost.
984
+ function _unionAnnotationTaint(fn, entrySet) {
985
+ if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
986
+ const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
987
+ if (!extra.size) return entrySet;
988
+ return new Set([...entrySet, ...extra]);
989
+ }
990
+
625
991
  // Worklist traversal of one function's CFG with a given entry-taint-state.
626
992
  // Returns the merged exit state + the union of findings on every path + the
627
993
  // taint sources observed (for evidence trails).
@@ -688,15 +1054,36 @@ function analyzeFunction(fn, entryState, callContext) {
688
1054
  try {
689
1055
  const union = new Set();
690
1056
  for (const s of inStates.values()) for (const p of s) union.add(p);
691
- const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union));
1057
+ const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union, callContext));
692
1058
  // Mark vars assigned inside a tainted branch as implicit-tainted.
693
1059
  let implicitState = new Set();
694
1060
  for (const [nid, ctx] of ictx) {
695
1061
  const t = implicitAssignTarget(nodes[nid], ctx);
696
1062
  if (t) implicitState = markImplicitTaint(implicitState, t);
697
1063
  }
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).
1064
+ // Stage 6 correctness audit: these are two genuinely different gates,
1065
+ // previously conflated into one loop over `ictx`. `allConst` is a
1066
+ // leak from the SINK CALL'S OWN EXECUTION revealing the branch was
1067
+ // taken — that requires the call itself to be genuinely inside the
1068
+ // tainted branch (now correctly dominance-scoped by
1069
+ // buildImplicitContext, see its header). `argRefsImplicit` is a leak
1070
+ // from a VARIABLE that was implicit-tainted earlier — once a var is
1071
+ // marked, its taint is a normal fact about the var, not about where
1072
+ // it's later read; requiring the READ site to also be lexically
1073
+ // inside a branch would miss the canonical
1074
+ // `if (tainted) { p = x; } eval(p)` pattern the moment `eval(p)` is
1075
+ // (correctly) recognized as being outside the branch.
1076
+ const reportedNids = new Set();
1077
+ const reportImplicit = (nid, node, sink, conditionLabel) => {
1078
+ if (reportedNids.has(nid)) return;
1079
+ reportedNids.add(nid);
1080
+ callContext._findings.push({
1081
+ ...createImplicitFinding(node, conditionLabel),
1082
+ _funcQid: fn.qid, sinkId: sink.id,
1083
+ cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
1084
+ });
1085
+ };
1086
+ // Pass 1 — constant-arg sink calls genuinely inside a tainted branch.
700
1087
  for (const [nid, ctx] of ictx) {
701
1088
  const node = nodes[nid];
702
1089
  if (!node || node.kind !== 'call') continue;
@@ -704,17 +1091,25 @@ function analyzeFunction(fn, entryState, callContext) {
704
1091
  const sink = cat && cat.find((e) => e.kind === 'sink');
705
1092
  if (!sink) continue;
706
1093
  const inS = inStates.get(nid) || new Set();
707
- 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
- });
1094
+ if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
711
1095
  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',
1096
+ if (allConst) reportImplicit(nid, node, sink, ctx.conditionLabel);
1097
+ }
1098
+ // Pass 2 — any sink call anywhere in the function whose argument
1099
+ // reads an implicit-tainted variable, regardless of whether the
1100
+ // call site itself is inside a branch.
1101
+ if (implicitState.size) {
1102
+ for (const [nid, node] of Object.entries(nodes)) {
1103
+ if (!node || node.kind !== 'call') continue;
1104
+ const cat = matchSinkOrSanitizer(node.callee, _currentFile);
1105
+ const sink = cat && cat.find((e) => e.kind === 'sink');
1106
+ if (!sink) continue;
1107
+ const inS = inStates.get(nid) || new Set();
1108
+ if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
1109
+ const argRefsImplicit = (node.args || []).some((a) => {
1110
+ const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
717
1111
  });
1112
+ if (argRefsImplicit) reportImplicit(nid, node, sink, ictx.get(nid)?.conditionLabel || null);
718
1113
  }
719
1114
  }
720
1115
  } catch { /* implicit flow is best-effort + opt-in */ }
@@ -785,9 +1180,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
785
1180
  // unbounded blowup). v0.66 — the inner ctx now records mutatedParams
786
1181
  // via _mutatedParamsOut so cross-function param mutation propagates.
787
1182
  const MAX_FP_ITERS = 3;
788
- let prevCacheSize = -1;
789
1183
  for (let it = 0; it < MAX_FP_ITERS; it++) {
790
1184
  if (Date.now() > deadlineMs) break;
1185
+ // Tracks whether this iteration actually changed any cached summary's
1186
+ // VALUE — the correct convergence signal (see below). Reset each
1187
+ // iteration; if nothing changed, the fixed point has been reached and
1188
+ // further iterations would recompute byte-identical results.
1189
+ let changedThisIter = false;
791
1190
  for (const fn of fnList) {
792
1191
  if (Date.now() > deadlineMs) break;
793
1192
  const entry = new Set();
@@ -800,22 +1199,49 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
800
1199
  _stack: new Set(), deadlineMs,
801
1200
  _summaryCache: summaryCache, _callGraph: callGraph,
802
1201
  _mutatedParamsOut: new Set(),
1202
+ _currentFnQid: fn.qid,
1203
+ _cha: opts._cha,
1204
+ _pointsTo: opts._pointsTo,
803
1205
  };
804
- try { analyzeFunction(fn, entry, ctx); } catch {}
1206
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), ctx); } catch {}
1207
+ // Report real findings discovered by this probe rather than letting
1208
+ // them die with `ctx` — see _collectFindings's header comment. Safe to
1209
+ // call every iteration and again from the main loop below: dedup is by
1210
+ // (sinkId, file, line), so re-discovering the same empty-entry finding
1211
+ // multiple times collapses to one reported finding, never a duplicate.
1212
+ _collectFindings(fn, ctx._findings);
805
1213
  const next = {
806
1214
  returnTainted: !!ctx._returnTainted,
807
1215
  mutatedParams: ctx._mutatedParamsOut || new Set(),
808
1216
  taintedGlobals: new Set(),
809
1217
  findings: [],
810
1218
  };
1219
+ // Membership-aware, not size-only — two summaries with the same
1220
+ // mutatedParams CARDINALITY but different MEMBERS (e.g. {'a'} vs
1221
+ // {'b'}) were treated as unchanged, so a real refinement across
1222
+ // iterations (a callee's mutated-field identity settling once its own
1223
+ // callees' summaries became known) was silently never written to the
1224
+ // cache. Same bug class as summaries.js's _summaryEq, fixed alongside
1225
+ // it. Reuses the same access-path-aware setsEqual already imported
1226
+ // for taint-state comparison elsewhere in this file (mutatedParams
1227
+ // entries are access paths too, e.g. '_this_.field').
811
1228
  if (!existing
812
1229
  || existing.returnTainted !== next.returnTainted
813
- || (existing.mutatedParams?.size || 0) !== next.mutatedParams.size) {
1230
+ || !accessSetsEqual(existing.mutatedParams, next.mutatedParams)) {
814
1231
  summaryCache.set(fn.qid, entry, next);
1232
+ changedThisIter = true;
815
1233
  }
816
1234
  }
817
- if (summaryCache.size() === prevCacheSize) break;
818
- prevCacheSize = summaryCache.size();
1235
+ // NOT `summaryCache.size() === prevCacheSize` (the pre-fix check): every
1236
+ // function gets a cache key on iteration 0 (`!existing` is true for all
1237
+ // of them), so `.size()` — a KEY COUNT — jumps from 0 to N once and then
1238
+ // never changes again, since overwriting an existing Map key never
1239
+ // changes `.size`. That made the loop `break` after iteration 1
1240
+ // regardless of whether iteration 1 itself found real refinements to
1241
+ // write, silently delivering 2 rounds of fixed-point refinement instead
1242
+ // of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
1243
+ // `changedThisIter` tracks actual value changes instead.
1244
+ if (!changedThisIter) break;
819
1245
  }
820
1246
  // Class-field cross-taint pass: when a method writes tainted data to _this_.field,
821
1247
  // re-analyze other methods of the same class with those fields in the entry state.
@@ -842,13 +1268,24 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
842
1268
  _stack: new Set(), deadlineMs,
843
1269
  _summaryCache: summaryCache, _callGraph: callGraph,
844
1270
  _mutatedParamsOut: new Set(),
1271
+ _currentFnQid: fn.qid,
1272
+ _cha: opts._cha,
1273
+ _pointsTo: opts._pointsTo,
845
1274
  };
846
- try { analyzeFunction(fn, fields, ctx); } catch {}
1275
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, fields), ctx); } catch {}
1276
+ // `findings` carries the REAL findings from this probe (was hardcoded
1277
+ // `[]`, discarding them) — but this pass is speculative (every field
1278
+ // in `fields` is assumed simultaneously tainted; nothing here confirms
1279
+ // this exact method is ever reached with that state), so it must not
1280
+ // report them itself. They ride on the cached summary and are only
1281
+ // surfaced by _mergeSummaryFindings when a REAL call site (assign,
1282
+ // plain-call, or higher-order) actually consults this qid+entry —
1283
+ // at that point a genuine reachable caller has been established.
847
1284
  summaryCache.set(fn.qid, fields, {
848
1285
  returnTainted: !!ctx._returnTainted,
849
1286
  mutatedParams: ctx._mutatedParamsOut || new Set(),
850
1287
  taintedGlobals: new Set(),
851
- findings: [],
1288
+ findings: ctx._findings,
852
1289
  });
853
1290
  }
854
1291
  }
@@ -866,13 +1303,32 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
866
1303
  _stack: new Set(), deadlineMs,
867
1304
  _summaryCache: summaryCache, _callGraph: callGraph,
868
1305
  _mutatedParamsOut: new Set(),
1306
+ _currentFnQid: fn.qid,
1307
+ _cha: opts._cha,
1308
+ _pointsTo: opts._pointsTo,
869
1309
  };
870
- try { analyzeFunction(fn, taintedEntry, ctx); } catch {}
1310
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, taintedEntry), ctx); } catch {}
1311
+ // `findings` carries the real findings from this probe (was hardcoded
1312
+ // `[]`). This pass assumes EVERY param is simultaneously tainted —
1313
+ // there's no check that any real caller ever passes tainted data here
1314
+ // at all (the header comment above claims "AND at least one caller in
1315
+ // the call graph"; the code has never actually enforced that) — so
1316
+ // these findings must not be reported unconditionally, only when a real
1317
+ // call site's own entry state happens to match and consults this cached
1318
+ // summary via _mergeSummaryFindings. This is the exact scenario that
1319
+ // motivated storing them at all: an inline callback
1320
+ // (`arr.forEach(x => sink(x))`) has one param, so it gets probed here
1321
+ // with taintedEntry={param} BEFORE the higher-order invocation loop
1322
+ // below ever runs; without `findings` riding on the cached summary, the
1323
+ // real finding computed right here was thrown away and unrecoverable —
1324
+ // the higher-order loop's own `summaryCache.get()` would hit this
1325
+ // now-cached (finding-less) summary and never call `compute()` (the
1326
+ // only place that used to merge findings) at all.
871
1327
  summaryCache.set(fn.qid, taintedEntry, {
872
1328
  returnTainted: !!ctx._returnTainted,
873
1329
  mutatedParams: ctx._mutatedParamsOut || new Set(),
874
1330
  taintedGlobals: new Set(),
875
- findings: [],
1331
+ findings: ctx._findings,
876
1332
  });
877
1333
  }
878
1334
  for (const fn of fnList) {
@@ -888,9 +1344,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
888
1344
  deadlineMs, // honored by the worklist inside analyzeFunction
889
1345
  _summaryCache: summaryCache,
890
1346
  _callGraph: callGraph,
1347
+ _currentFnQid: fn.qid,
1348
+ // PRD R12: index.js builds this graph (AGENTIC_SECURITY_POINTS_TO=1)
1349
+ // and passes it in opts._pointsTo, but nothing previously copied it
1350
+ // onto callContext — _addPathAliasAware reads callContext._pointsTo,
1351
+ // which was therefore always undefined, and alias-aware tainting was
1352
+ // a no-op even with the flag set.
1353
+ _pointsTo: opts._pointsTo,
1354
+ // PRD R6/R11: same pattern as _pointsTo above — the CHA opts.js builds
1355
+ // must reach callContext or every receiver-type/member-call consumer
1356
+ // is permanently a no-op.
1357
+ _cha: opts._cha,
891
1358
  };
892
1359
  try {
893
- analyzeFunction(fn, new Set(), callContext);
1360
+ analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
894
1361
  } catch { continue; }
895
1362
  // Process higher-order invocations: resolve callbacks and analyze with
896
1363
  // tainted first-param. Feed findings back into the caller's finding set.
@@ -899,13 +1366,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
899
1366
  for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
900
1367
  if (Date.now() > deadlineMs) break;
901
1368
  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);
1369
+ if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
1370
+ // Two resolution strategies, matching the two shapes the push site can
1371
+ // record: an inline callback (`arr.map(x => ...)`) carries an exact
1372
+ // qid (parser-js.js's exprOf synthesizes it identically to how
1373
+ // enterFn will independently name the same node) look it up directly
1374
+ // in callGraph.functions, no name resolution involved. A by-reference
1375
+ // callback (`arr.map(processItem)`) carries a bare ident name; resolve
1376
+ // it the same way every other call-graph lookup in this file does.
1377
+ // resolveKnownCallee (never the bare-tail-guessing resolve()) since a
1378
+ // wrong guess here would fabricate a callback relationship that
1379
+ // doesn't exist.
1380
+ const cbFn = inv.calleeQid
1381
+ ? functionRecord(callGraph, inv.calleeQid)
1382
+ : functionRecord(callGraph, callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null);
909
1383
  if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
910
1384
  const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
911
1385
  let cbSummary = summaryCache.get(cbFn.qid, cbEntry);
@@ -916,23 +1390,60 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
916
1390
  _stack: new Set(), deadlineMs,
917
1391
  _summaryCache: summaryCache, _callGraph: callGraph,
918
1392
  _mutatedParamsOut: new Set(),
1393
+ _cha: callContext._cha,
919
1394
  };
920
- 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' })));
1395
+ try { analyzeFunction(cbFn, _unionAnnotationTaint(cbFn, cbEntry), inner); } catch {}
923
1396
  return {
924
1397
  returnTainted: !!inner._returnTainted,
925
1398
  mutatedParams: inner._mutatedParamsOut || new Set(),
926
1399
  taintedGlobals: new Set(),
927
- findings: [],
1400
+ findings: inner._findings,
928
1401
  };
929
1402
  });
930
1403
  }
1404
+ // Uniform with the assign/plain-call sites: merge whether this was a
1405
+ // fresh compute() (findings from `inner` just above) or a cache HIT —
1406
+ // e.g. the k=2 pass already probed this exact qid+entry (a callback
1407
+ // with one param has taintedEntry===cbEntry) and stashed its own real
1408
+ // findings on the summary rather than reporting them speculatively.
1409
+ _mergeSummaryFindings(callContext, fn.qid, cbSummary, 'higher-order');
931
1410
  }
932
- for (const f of callContext._findings) {
933
- const key = `${f.sinkId}:${fn.file}:${f.line}`;
1411
+ _collectFindings(fn, callContext._findings);
1412
+ }
1413
+ // v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
1414
+ // Dead code suppression: demote findings in functions with zero callers
1415
+ // (except route handlers which are entry points)
1416
+ const calledQids = new Set();
1417
+ if (callGraph.edges) for (const e of callGraph.edges) if (e.callee) calledQids.add(typeof e.callee === 'string' ? e.callee : e.callee?.qid);
1418
+ if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (Array.isArray(callers) ? callers.length : callers?.size) calledQids.add(qid); }
1419
+ for (const f of all) {
1420
+ if (!f._funcQid) continue;
1421
+ const fn = callGraph.functions?.get(f._funcQid);
1422
+ if (!fn) continue;
1423
+ if (calledQids.has(f._funcQid)) continue;
1424
+ if (fn.name === '<module>' || /handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
1425
+ f._inDeadCode = true;
1426
+ const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
1427
+ if (dg[f.severity]) f.severity = dg[f.severity];
1428
+ }
1429
+ Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
1430
+ return all;
1431
+
1432
+ // Dedup + map a raw findings array (from analyzeFunction's callContext)
1433
+ // into the reported IR-TAINT shape, attributed to `fn`. Used directly by
1434
+ // the main loop and the empty-entry pre-pass (both analyze under an entry
1435
+ // state that is either empty or true-by-construction, so their findings
1436
+ // are unconditionally real). The class-field and k=2 pre-passes are
1437
+ // speculative (they assume fields/params are tainted without confirming
1438
+ // any real caller ever does that) — see _mergeSummaryFindings, which is
1439
+ // the gate that gives their findings a chance to be reported only once a
1440
+ // genuine caller is established.
1441
+ function _collectFindings(attributedFn, srcFindings) {
1442
+ for (const f of srcFindings) {
1443
+ const key = `${f.sinkId}:${attributedFn.file}:${f.line}`;
934
1444
  if (seen.has(key)) continue;
935
1445
  seen.add(key);
1446
+ const fn = attributedFn;
936
1447
  all.push({
937
1448
  id: `ir-taint:${fn.file}:${f.line}:${f.sinkId}`,
938
1449
  file: fn.file,
@@ -946,6 +1457,39 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
946
1457
  // (a control-dependence finding, not an explicit data-flow one).
947
1458
  confidence: (f.implicit && typeof f.confidence === 'number') ? f.confidence : 0.75,
948
1459
  ...(f.implicit === true ? { implicit: true } : {}),
1460
+ // Sanitizer callees observed on the value reaching this sink. This
1461
+ // mapping is an explicit allowlist, so a field absent here is silently
1462
+ // dropped — which is what previously left sanitizer-gate.js inert.
1463
+ ...(Array.isArray(f._sanitizersOnPath) && f._sanitizersOnPath.length
1464
+ ? { _sanitizersOnPath: f._sanitizersOnPath } : {}),
1465
+ // _funcQid: the enclosing function's qid, set upstream during the walk
1466
+ // but silently dropped by this allowlist before backward.js's
1467
+ // annotateBackwardSlices ever saw it — the same class of omission
1468
+ // _sanitizersOnPath had. Without it, annotateBackwardSlices's very
1469
+ // first check (`if (!f._funcQid) skip`) discarded every finding, so
1470
+ // backward-slice annotation was permanently a no-op regardless of
1471
+ // AGENTIC_SECURITY_BACKWARD_SLICE.
1472
+ ...(f._funcQid ? { _funcQid: f._funcQid } : {}),
1473
+ // callee: kept as plain `callee` (not underscore-prefixed) to match
1474
+ // backward.js's own contract, which reads `f.callee` on both real and
1475
+ // fake-fixture findings throughout its module and test suite. It is
1476
+ // the SAME object reference as the CFG call node's own `callee` (set
1477
+ // at `_sinkFindingsForCall`'s call site as `callee: calleeExpr`,
1478
+ // never copied) — backward.js's sink-node lookup matches
1479
+ // `n.callee === f.callee` by reference identity, so dropping this
1480
+ // field (as the allowlist previously did) meant that match could
1481
+ // never succeed regardless of `_funcQid`. It is not read by
1482
+ // report/index.js's normalizeFindings, which is itself an explicit
1483
+ // allowlist that never names `callee`, so this does not reach SARIF/
1484
+ // JSON/HTML report output.
1485
+ ...(f.callee !== undefined ? { callee: f.callee } : {}),
1486
+ // sourceProvenance/chain[].provenance: catalog.js's per-source label
1487
+ // (e.g. 'http-body' for req.body) computed earlier at the finding's
1488
+ // creation site — previously dropped by this allowlist, which is
1489
+ // what left posture/exploitability-probability.js's
1490
+ // 'source-from-network' factor permanently dead (it reads
1491
+ // t.provenance off chain/trace entries).
1492
+ sourceProvenance: f.sourceProvenance || null,
949
1493
  source: f.trace && f.trace.length ? {
950
1494
  file: fn.file,
951
1495
  line: f.trace[0].line,
@@ -957,27 +1501,10 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
957
1501
  label: f.sinkId,
958
1502
  },
959
1503
  chain: (f.trace || []).map(t => ({
960
- file: fn.file, line: t.line, label: t.sourceLabel,
1504
+ file: fn.file, line: t.line, label: t.sourceLabel, provenance: t.provenance || null,
961
1505
  })),
962
1506
  });
963
1507
  }
964
1508
  }
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;
1509
+
983
1510
  }