@clear-capabilities/agentic-security-scanner 0.136.9 → 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.
@@ -42,7 +42,7 @@
42
42
  // sanitizer must never silently drop a real vulnerability, so the walk never
43
43
  // treats a sanitizer call as clearing the tainted path on its own.
44
44
 
45
- import { matchSource, matchSinkOrSanitizer } from './catalog.js';
45
+ import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
46
46
  import { functionRecord } from '../ir/callgraph.js';
47
47
  import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
48
48
  import { aliasesForVar } from './points-to.js';
@@ -50,6 +50,13 @@ import { higherOrderTaintFlow } from './higher-order.js';
50
50
  import { SummaryCache, entryStateFromCall } from './summaries.js';
51
51
  import { lookupBuiltinSummary } from './builtin-summaries.js';
52
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';
53
60
 
54
61
  // v0.70 #2 — addPath that also taints every alias of the variable.
55
62
  // When `target` is a dotted path like "a.x" and the root `a` has aliases
@@ -102,6 +109,74 @@ function _flattenCalleeName(calleeExpr) {
102
109
  return null;
103
110
  }
104
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
+
105
180
  // Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
106
181
  // Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
107
182
  // is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
@@ -120,7 +195,106 @@ function _resolvableCalleeName(calleeExpr) {
120
195
  return null;
121
196
  }
122
197
 
123
- 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) {
124
298
  if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
125
299
  if (!expr) return false;
126
300
  // Constant propagation: variables assigned from literals are never tainted
@@ -134,16 +308,18 @@ function exprTaint(expr, state) {
134
308
  switch (expr.kind) {
135
309
  case 'literal': return false;
136
310
  case 'binary':
137
- case 'logical': return exprTaint(expr.left, state) || exprTaint(expr.right, state);
138
- case 'tpl': return (expr.parts || []).some(p => exprTaint(p, state));
139
- case 'union': return (expr.branches || []).some(b => exprTaint(b, state));
140
- case 'object': return (expr.props || []).some(p => exprTaint(p.value, state));
141
- 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));
142
316
  case 'call': {
143
- // Calls are handled at the CFG level (the call has already been processed).
144
- // For an inline call expression, conservatively return whether any arg is tainted.
145
- // This loses the sanitizer effect but is safe.
146
- 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);
147
323
  }
148
324
  case 'unknown': return false;
149
325
  default: return false;
@@ -264,10 +440,12 @@ function literalSkeletonMatchesFamily(expr, cwe) {
264
440
 
265
441
  // calleeExpr / argExprs: the IR nodes for the call's callee and arguments.
266
442
  // state: the taint-state Set to evaluate argument taint against.
443
+ // callContext: context from the engine (contains _cha for CHA lookups).
267
444
  // Returns { cat, argTaints }.
268
- function _matchCallCatalog(calleeExpr, argExprs, state) {
269
- const cat = matchSinkOrSanitizer(calleeExpr, _currentFile);
270
- 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));
271
449
  return { cat, argTaints };
272
450
  }
273
451
 
@@ -374,6 +552,38 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
374
552
  return { findings };
375
553
  }
376
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
+
377
587
  // Surfaces a cached (or freshly computed) summary's `findings` into the
378
588
  // CURRENT caller's context — the only place a class-field/k=2 pre-pass's
379
589
  // speculative findings (in runTaintEngine) become reportable, because
@@ -426,11 +636,24 @@ function step(node, stateIn, callContext) {
426
636
  // return path below, including the early interprocedural returns.
427
637
  if (node.source && node.source.kind === 'call') {
428
638
  const { cat: _sinkCat, argTaints: _sinkArgTaints } =
429
- _matchCallCatalog(node.source.callee, node.source.args, state);
639
+ _matchCallCatalog(node.source.callee, node.source.args, state, callContext);
430
640
  findings.push(..._sinkFindingsForCall(
431
641
  node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
432
642
  state, callContext, node.line).findings);
433
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
+ }
434
657
  // Record which sanitizers were applied to the value now held by `target`
435
658
  // (inline in the RHS, or inherited from the vars the RHS reads). Placed
436
659
  // before every early return in this case so the map cannot go stale on
@@ -457,18 +680,10 @@ function step(node, stateIn, callContext) {
457
680
  const calleeName = node.source && node.source.kind === 'call'
458
681
  ? _flattenCalleeName(node.source.callee) : null;
459
682
  if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
460
- const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
461
- const _resolvableName = node.source && node.source.kind === 'call'
462
- ? _resolvableCalleeName(node.source.callee) : null;
463
- // resolveKnownCallee: never guess via resolve()'s bare-tail
464
- // fallback. _resolvableCalleeName already refuses JS member
465
- // expressions, but a pre-flattened STRING callee (Go/PHP/Ruby/
466
- // C++/Python parsers) can still be dotted, and only the resolver
467
- // itself can tell — see callgraph.js.
468
- const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
469
- ? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
470
- const fn = functionRecord(callContext._callGraph, resolved);
471
- 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;
472
687
  if (typeof qid === 'string') {
473
688
  // v0.66 — context-sensitive lookup. Build the entry-state from
474
689
  // the call args + current taint; look up (and lazily compute) the
@@ -493,8 +708,9 @@ function step(node, stateIn, callContext) {
493
708
  _summaryCache: callContext._summaryCache,
494
709
  _callGraph: callContext._callGraph,
495
710
  _mutatedParamsOut: new Set(),
711
+ _cha: callContext._cha,
496
712
  };
497
- try { analyzeFunction(fn, entry, inner); } catch {}
713
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
498
714
  return {
499
715
  returnTainted: !!inner._returnTainted,
500
716
  mutatedParams: inner._mutatedParamsOut || new Set(),
@@ -533,16 +749,35 @@ function step(node, stateIn, callContext) {
533
749
  // Fallback: check builtin summaries for unresolved external calls
534
750
  const builtin = lookupBuiltinSummary(calleeName);
535
751
  if (builtin) {
536
- 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) {
537
754
  newState = _addPathAliasAware(newState, target, callContext);
538
755
  } else if (!builtin.returnTainted) {
539
- 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
+ }
540
775
  return { state: newState, findings };
541
776
  }
542
777
  if (builtin.mutatedParams && builtin.mutatedParams.size) {
543
778
  for (const idx of builtin.mutatedParams) {
544
779
  const argExpr = (node.source.args || [])[parseInt(idx)];
545
- 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))) {
546
781
  newState = _addPathAliasAware(newState, argExpr.name, callContext);
547
782
  }
548
783
  }
@@ -555,7 +790,7 @@ function step(node, stateIn, callContext) {
555
790
  const sourcePath = accessPathOf(node.source);
556
791
  if (sourcePath) newState = addPath(newState, sourcePath);
557
792
  callContext._taintSources.push({ varName: target, sourceId: src.id, sourceLabel: src.label, provenance: src.provenance || null, line: node.line });
558
- } else if (exprTaint(node.source, newState)) {
793
+ } else if (exprTaint(node.source, newState, callContext)) {
559
794
  // P1.1: when the source IS a pure access path (e.g., RHS is `obj.foo.bar`),
560
795
  // taint the TARGET as well as transitively propagate the source path so
561
796
  // later uses of the same source remain tainted. The target path
@@ -579,20 +814,14 @@ function step(node, stateIn, callContext) {
579
814
  // Computed here (before the mutation passes below) so that argTaints
580
815
  // reflects the pre-mutation state, exactly as before this logic was
581
816
  // extracted into _matchCallCatalog/_sinkFindingsForCall.
582
- const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state);
817
+ const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state, callContext);
583
818
  // v0.66 — apply mutated-param taint at plain (non-assign) call sites.
584
819
  // Object.assign(target, tainted) → target becomes tainted in caller.
585
820
  const _plainCallCalleeName = _flattenCalleeName(node.callee);
586
821
  if (callContext._summaryCache && callContext._callGraph && _plainCallCalleeName) {
587
- const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
588
- const _resolvableName = _resolvableCalleeName(node.callee);
589
- // resolveKnownCallee: see the comment at the sibling call site above
590
- // — a pre-flattened dotted STRING callee must not be guessed via
591
- // resolve()'s bare-tail fallback.
592
- const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
593
- ? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
594
- const fn = functionRecord(callContext._callGraph, resolved);
595
- 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;
596
825
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
597
826
  const paramNames = fn.params;
598
827
  const entry = paramNames.length
@@ -613,8 +842,9 @@ function step(node, stateIn, callContext) {
613
842
  _summaryCache: callContext._summaryCache,
614
843
  _callGraph: callContext._callGraph,
615
844
  _mutatedParamsOut: new Set(),
845
+ _cha: callContext._cha,
616
846
  };
617
- try { analyzeFunction(fn, entry, inner); } catch {}
847
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
618
848
  return {
619
849
  returnTainted: !!inner._returnTainted,
620
850
  mutatedParams: inner._mutatedParamsOut || new Set(),
@@ -730,7 +960,7 @@ function step(node, stateIn, callContext) {
730
960
  }
731
961
 
732
962
  case 'return': {
733
- if (exprTaint(node.value, state)) {
963
+ if (exprTaint(node.value, state, callContext)) {
734
964
  callContext._returnTainted = true;
735
965
  }
736
966
  return { state, findings };
@@ -746,6 +976,18 @@ function step(node, stateIn, callContext) {
746
976
  }
747
977
  }
748
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
+
749
991
  // Worklist traversal of one function's CFG with a given entry-taint-state.
750
992
  // Returns the merged exit state + the union of findings on every path + the
751
993
  // taint sources observed (for evidence trails).
@@ -812,7 +1054,7 @@ function analyzeFunction(fn, entryState, callContext) {
812
1054
  try {
813
1055
  const union = new Set();
814
1056
  for (const s of inStates.values()) for (const p of s) union.add(p);
815
- const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union));
1057
+ const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union, callContext));
816
1058
  // Mark vars assigned inside a tainted branch as implicit-tainted.
817
1059
  let implicitState = new Set();
818
1060
  for (const [nid, ctx] of ictx) {
@@ -849,7 +1091,7 @@ function analyzeFunction(fn, entryState, callContext) {
849
1091
  const sink = cat && cat.find((e) => e.kind === 'sink');
850
1092
  if (!sink) continue;
851
1093
  const inS = inStates.get(nid) || new Set();
852
- if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
1094
+ if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
853
1095
  const allConst = (node.args || []).length > 0 && (node.args || []).every((a) => a && a.kind === 'literal');
854
1096
  if (allConst) reportImplicit(nid, node, sink, ctx.conditionLabel);
855
1097
  }
@@ -863,7 +1105,7 @@ function analyzeFunction(fn, entryState, callContext) {
863
1105
  const sink = cat && cat.find((e) => e.kind === 'sink');
864
1106
  if (!sink) continue;
865
1107
  const inS = inStates.get(nid) || new Set();
866
- if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
1108
+ if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
867
1109
  const argRefsImplicit = (node.args || []).some((a) => {
868
1110
  const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
869
1111
  });
@@ -957,8 +1199,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
957
1199
  _stack: new Set(), deadlineMs,
958
1200
  _summaryCache: summaryCache, _callGraph: callGraph,
959
1201
  _mutatedParamsOut: new Set(),
1202
+ _currentFnQid: fn.qid,
1203
+ _cha: opts._cha,
1204
+ _pointsTo: opts._pointsTo,
960
1205
  };
961
- try { analyzeFunction(fn, entry, ctx); } catch {}
1206
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), ctx); } catch {}
962
1207
  // Report real findings discovered by this probe rather than letting
963
1208
  // them die with `ctx` — see _collectFindings's header comment. Safe to
964
1209
  // call every iteration and again from the main loop below: dedup is by
@@ -1023,8 +1268,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1023
1268
  _stack: new Set(), deadlineMs,
1024
1269
  _summaryCache: summaryCache, _callGraph: callGraph,
1025
1270
  _mutatedParamsOut: new Set(),
1271
+ _currentFnQid: fn.qid,
1272
+ _cha: opts._cha,
1273
+ _pointsTo: opts._pointsTo,
1026
1274
  };
1027
- try { analyzeFunction(fn, fields, ctx); } catch {}
1275
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, fields), ctx); } catch {}
1028
1276
  // `findings` carries the REAL findings from this probe (was hardcoded
1029
1277
  // `[]`, discarding them) — but this pass is speculative (every field
1030
1278
  // in `fields` is assumed simultaneously tainted; nothing here confirms
@@ -1055,8 +1303,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1055
1303
  _stack: new Set(), deadlineMs,
1056
1304
  _summaryCache: summaryCache, _callGraph: callGraph,
1057
1305
  _mutatedParamsOut: new Set(),
1306
+ _currentFnQid: fn.qid,
1307
+ _cha: opts._cha,
1308
+ _pointsTo: opts._pointsTo,
1058
1309
  };
1059
- try { analyzeFunction(fn, taintedEntry, ctx); } catch {}
1310
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, taintedEntry), ctx); } catch {}
1060
1311
  // `findings` carries the real findings from this probe (was hardcoded
1061
1312
  // `[]`). This pass assumes EVERY param is simultaneously tainted —
1062
1313
  // there's no check that any real caller ever passes tainted data here
@@ -1093,9 +1344,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1093
1344
  deadlineMs, // honored by the worklist inside analyzeFunction
1094
1345
  _summaryCache: summaryCache,
1095
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,
1096
1358
  };
1097
1359
  try {
1098
- analyzeFunction(fn, new Set(), callContext);
1360
+ analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
1099
1361
  } catch { continue; }
1100
1362
  // Process higher-order invocations: resolve callbacks and analyze with
1101
1363
  // tainted first-param. Feed findings back into the caller's finding set.
@@ -1128,8 +1390,9 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1128
1390
  _stack: new Set(), deadlineMs,
1129
1391
  _summaryCache: summaryCache, _callGraph: callGraph,
1130
1392
  _mutatedParamsOut: new Set(),
1393
+ _cha: callContext._cha,
1131
1394
  };
1132
- try { analyzeFunction(cbFn, cbEntry, inner); } catch {}
1395
+ try { analyzeFunction(cbFn, _unionAnnotationTaint(cbFn, cbEntry), inner); } catch {}
1133
1396
  return {
1134
1397
  returnTainted: !!inner._returnTainted,
1135
1398
  mutatedParams: inner._mutatedParamsOut || new Set(),
@@ -1151,14 +1414,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1151
1414
  // Dead code suppression: demote findings in functions with zero callers
1152
1415
  // (except route handlers which are entry points)
1153
1416
  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); }
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); }
1156
1419
  for (const f of all) {
1157
1420
  if (!f._funcQid) continue;
1158
1421
  const fn = callGraph.functions?.get(f._funcQid);
1159
1422
  if (!fn) continue;
1160
1423
  if (calledQids.has(f._funcQid)) continue;
1161
- if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
1424
+ if (fn.name === '<module>' || /handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
1162
1425
  f._inDeadCode = true;
1163
1426
  const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
1164
1427
  if (dg[f.severity]) f.severity = dg[f.severity];
@@ -12,6 +12,7 @@ import {
12
12
  serializeSummaries, commitIncrementalState,
13
13
  } from './incremental.js';
14
14
  import { buildPointsTo } from './points-to.js';
15
+ import { buildClassHierarchy } from '../ir/class-hierarchy.js';
15
16
  import { annotateSoftTaint } from './soft-taint.js';
16
17
  import { runIfdsTaintEngine } from './ifds.js';
17
18
  import { proveExploits } from './exploit-prover.js';
@@ -79,6 +80,19 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
79
80
  priorState = null;
80
81
  }
81
82
  }
83
+ // PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): Class Hierarchy
84
+ // Analysis. Built once per scan (mirrors the points-to graph immediately
85
+ // below) and threaded through opts so the taint engine can (a) narrow
86
+ // catalog sink matches to the receiver's inferred type (R6) and (b) safely
87
+ // resolve a member call to a concrete callee when the receiver resolves to
88
+ // exactly one known class (R11). Unlike points-to, this is NOT gated behind
89
+ // an env flag — receiver-context.js and class-hierarchy.js were both
90
+ // already built, unit-tested, and left completely unreachable from the
91
+ // production pipeline (see PRD R6's evidence); building it always is cheap
92
+ // (a single walk of the already-parsed IR, no fixed-point iteration) and
93
+ // every consumer degrades to today's behavior when it finds no useful type.
94
+ let classHierarchy = null;
95
+ try { classHierarchy = buildClassHierarchy(perFileIR); } catch { classHierarchy = null; }
82
96
  // v0.70 #2 — Steensgaard points-to / alias analysis. Built once before
83
97
  // the worklist, passed via opts so the engine can resolve aliased
84
98
  // mutations (`let a = obj; a.x = tainted; sink(obj.x)`).
@@ -95,6 +109,7 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
95
109
  ...opts,
96
110
  summaryCache: preSeededCache || undefined,
97
111
  _pointsTo: pointsToGraph || undefined,
112
+ _cha: classHierarchy || undefined,
98
113
  });
99
114
  if (process.env.AGENTIC_SECURITY_IFDS === '1') {
100
115
  try {
@@ -330,14 +330,27 @@ export function aliasesForVar(pointsTo, qid, varName) {
330
330
  const fullName = `${qid}::${varName}`;
331
331
  const aliases = pointsTo.aliasesOf(fullName);
332
332
  const out = new Set([varName]);
333
+ // PRD R12 (docs/DETECTION_GAP_REMEDIATION_PRD.md): a real function qid is
334
+ // itself multi-segment (`file::scope::name@line`) and contains `::`, so
335
+ // stripping up to the FIRST `::` truncates mid-qid instead of removing
336
+ // it — `app.js::<module>::anon@5::obj` became `<module>::anon@5::obj`,
337
+ // not the bare `obj` the engine's per-function taint state keys on. Strip
338
+ // the exact known `${qid}::` prefix first; only fall back to the
339
+ // first-`::` heuristic for an alias that (unusually) carries a different
340
+ // qid than the one being queried.
341
+ const prefix = `${qid}::`;
333
342
  for (const a of aliases) {
334
- // Strip the qid prefix for engine-state lookups (engine state is per-fn).
335
- const idx = a.indexOf('::');
336
- if (idx > 0) {
337
- const local = a.slice(idx + 2);
338
- // Skip __loc: / __virt: synthetic names.
339
- if (local && !local.startsWith('__')) out.add(local);
343
+ let local = null;
344
+ if (a.startsWith(prefix)) {
345
+ local = a.slice(prefix.length);
346
+ } else {
347
+ const idx = a.indexOf('::');
348
+ if (idx > 0) local = a.slice(idx + 2);
340
349
  }
350
+ // Skip __loc: / __virt: synthetic names, and any alias that still
351
+ // carries a qid fragment (a cross-function alias isn't a valid path in
352
+ // THIS function's per-fn taint state).
353
+ if (local && !local.startsWith('__') && !local.includes('::')) out.add(local);
341
354
  }
342
355
  return [...out];
343
356
  }