@clear-capabilities/agentic-security-scanner 0.151.2 → 0.152.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 (50) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/1905.index.js +30 -2
  3. package/dist/3499.index.js +20 -1
  4. package/dist/agentic-security.mjs +80 -80
  5. package/dist/agentic-security.mjs.sha256 +1 -1
  6. package/package.json +8 -4
  7. package/src/dataflow/CLAUDE.md +1 -1
  8. package/src/dataflow/catalog.js +842 -17
  9. package/src/dataflow/engine.js +865 -90
  10. package/src/dataflow/incremental.js +13 -0
  11. package/src/dataflow/index.js +18 -5
  12. package/src/dataflow/path-feasibility.js +86 -9
  13. package/src/dataflow/string-domain.js +132 -0
  14. package/src/dataflow/summaries.js +69 -8
  15. package/src/discovery/THREAT_MODEL.md +122 -0
  16. package/src/discovery/disprove.js +10 -1
  17. package/src/discovery/lenses.js +20 -1
  18. package/src/engine.js +108 -3
  19. package/src/ir/CLAUDE.md +2 -1
  20. package/src/ir/balanced-call.js +33 -1
  21. package/src/ir/callgraph.js +26 -2
  22. package/src/ir/class-hierarchy.js +119 -7
  23. package/src/ir/csharp-ir.js +97 -8
  24. package/src/ir/index.js +21 -1
  25. package/src/ir/parser-cs.js +706 -24
  26. package/src/ir/parser-java.js +527 -24
  27. package/src/ir/parser-js.js +22 -0
  28. package/src/ir/parser-php.js +446 -43
  29. package/src/ir/parser-rust.js +1338 -0
  30. package/src/ir/php-include-merge.js +190 -0
  31. package/src/lineage/sink-registry.js +18 -0
  32. package/src/lineage/source-registry.js +23 -0
  33. package/src/posture/csharp-analysis.js +341 -43
  34. package/src/posture/finding-defaults.js +5 -1
  35. package/src/posture/fix-coverage.js +1 -0
  36. package/src/posture/proof-coverage.js +1 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
@@ -44,19 +44,20 @@
44
44
 
45
45
  import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
46
46
  import { functionRecord } from '../ir/callgraph.js';
47
- import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
47
+ import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual, pathIsCoveredByPrefix } from './access-paths.js';
48
48
  import { aliasesForVar } from './points-to.js';
49
49
  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
+ import { isSafeValidationPattern } from './string-domain.js';
53
54
  // NOTE: receiver-context.js's receiverTypeAtCall is deliberately NOT imported
54
55
  // here. It was the implementation of _receiverTypeFor's old `this.field`
55
56
  // branch, whose PascalCase-of-field-name guess is the false-negative bug this
56
57
  // file's _receiverTypeFor comment describes. Its other exports are still used
57
58
  // (summaries.js imports hashReceiverType for cache keying); only this call
58
59
  // site is gone.
59
- import { resolveMethod, classOfVar } from '../ir/class-hierarchy.js';
60
+ import { resolveMethod, resolveMethodRTA, classOfVar } from '../ir/class-hierarchy.js';
60
61
 
61
62
  // v0.70 #2 — addPath that also taints every alias of the variable.
62
63
  // When `target` is a dotted path like "a.x" and the root `a` has aliases
@@ -101,10 +102,8 @@ function _addPathAliasAware(state, path, callContext) {
101
102
  return s;
102
103
  }
103
104
 
104
- let _activeConstantVars = null;
105
105
  // The file of the function currently being analyzed. Set at the top of
106
- // analyzeFunction (same context-threading pattern as _activeConstantVars
107
- // above) so exprIsSource/exprTaint/step can pass it to matchSource /
106
+ // analyzeFunction so exprIsSource/exprTaint/step can pass it to matchSource /
108
107
  // matchSinkOrSanitizer without plumbing it through every call signature.
109
108
  // It scopes language-specific catalog entries (currently just `cpp`) to
110
109
  // files of that language — see the header comment in catalog.js.
@@ -240,14 +239,52 @@ function _resolvableCalleeName(calleeExpr) {
240
239
  // expression) and R11's extra `resolveMethod` step means it can still refuse
241
240
  // where R6 does not.
242
241
  function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
243
- if (!calleeExpr || calleeExpr.kind !== 'member' || typeof calleeExpr.prop !== 'string') return null;
244
242
  if (!callContext || !callContext._cha) return null;
245
- if (!calleeExpr.object || calleeExpr.object.kind !== 'ident' || calleeExpr.object.name === '_this_') return null;
246
- const className = classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, calleeExpr.object.name);
243
+ let receiverName, methodName;
244
+ if (calleeExpr && calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
245
+ if (!calleeExpr.object || calleeExpr.object.kind !== 'ident' || calleeExpr.object.name === '_this_') return null;
246
+ receiverName = calleeExpr.object.name;
247
+ methodName = calleeExpr.prop;
248
+ } else if (typeof calleeExpr === 'string') {
249
+ // PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — the hand-rolled parsers
250
+ // (Java/C#/Go/PHP/Ruby/Kotlin) emit a FLAT dotted-string callee
251
+ // ("b.action"), never the Babel `{kind:'member'}` shape above. This
252
+ // function previously only ever handled the latter, so this whole
253
+ // CHA-gated path was silently JS-only. Only a simple `receiver.method`
254
+ // (exactly one dot) is handled — a longer chain (`a.b.c`) is a member
255
+ // chain this function has no way to walk without an AST, and guessing
256
+ // would risk exactly the fabricated-edge failure mode this function's
257
+ // own header comment warns about; refuse rather than guess.
258
+ const dot = calleeExpr.indexOf('.');
259
+ if (dot <= 0 || calleeExpr.indexOf('.', dot + 1) !== -1) return null;
260
+ receiverName = calleeExpr.slice(0, dot);
261
+ methodName = calleeExpr.slice(dot + 1);
262
+ if (receiverName === '_this_' || receiverName === 'this') return null;
263
+ } else {
264
+ return null;
265
+ }
266
+ const className = classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, receiverName);
247
267
  if (!className) return null;
248
- const found = resolveMethod(callContext._cha, className, calleeExpr.prop);
249
- if (!found) return null;
250
- return `${found.className}.${found.methodName}`;
268
+ const direct = resolveMethod(callContext._cha, className, methodName);
269
+ if (direct) return `${direct.className}.${direct.methodName}`;
270
+ // Direct/inherited resolution failed — this is expected when `className`
271
+ // is an ABSTRACT class or interface with no body of its own (Juliet's
272
+ // dominant idiom: `abstract void action(String d);` is never emitted as a
273
+ // function at all, so it's never added to any class's `methods` set).
274
+ // Fall back to RTA: which LIVE (actually `new`'d somewhere in this scan)
275
+ // subclasses of `className` implement `methodName`. Deliberately
276
+ // single-candidate-only for this landing — refuse rather than guess when
277
+ // more than one live subclass implements the method (e.g. a bad/good
278
+ // variant pair both alive in the same scan), matching this codebase's
279
+ // dominant ambiguity-refusal convention. Known, accepted limitation: this
280
+ // means the fix does nothing when Juliet's own bad+good siblings are
281
+ // scanned together, which is the common case — see the ledger for the
282
+ // measured impact and whether that limitation is worth lifting next.
283
+ const cha = callContext._cha;
284
+ if (!cha.liveClasses) return null;
285
+ const candidates = resolveMethodRTA(cha, methodName, cha.liveClasses, className);
286
+ if (candidates.length !== 1) return null;
287
+ return `${candidates[0].className}.${candidates[0].methodName}`;
251
288
  }
252
289
 
253
290
  // Resolve calleeExpr to { qid, fn } via the call graph — the shared
@@ -261,14 +298,24 @@ function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
261
298
  function _resolveCalleeForSummary(calleeExpr, callContext) {
262
299
  if (!callContext || !callContext._callGraph || !callContext._callGraph.resolveKnownCallee) return null;
263
300
  const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
264
- let _resolvableName = _resolvableCalleeName(calleeExpr);
265
- // PRD R11: _resolvableCalleeName refuses every member-expression callee.
266
- // When that's the reason we have nothing, try the CHA-gated path before
267
- // giving up — but ONLY then, so the existing exact/bare-name behavior is
268
- // completely unchanged for every case it already handled.
269
- if (!_resolvableName) _resolvableName = _resolveMemberCalleeViaCHA(calleeExpr, callContext);
270
- if (!_resolvableName) return null;
271
- const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
301
+ const _resolvableName = _resolvableCalleeName(calleeExpr);
302
+ if (_resolvableName) {
303
+ const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
304
+ const fn = functionRecord(callContext._callGraph, resolved);
305
+ const qid = resolved && (resolved.qid || resolved);
306
+ if (typeof qid === 'string') return { qid, fn };
307
+ }
308
+ // PRD R11 / W2.5: reached either because `_resolvableCalleeName` refused
309
+ // outright (a Babel member-expression callee), or because it handed back
310
+ // a name (a flat dotted string like "b.action" — the hand-rolled parsers'
311
+ // shape) that `resolveKnownCallee` could NOT resolve by name (`b` isn't a
312
+ // class name, it's a local/parameter). Try the CHA-gated path ONLY as a
313
+ // fallback after ordinary name resolution has already failed, so a callee
314
+ // that resolves correctly by name is never second-guessed by a coarser,
315
+ // over-approximating CHA/RTA lookup.
316
+ const _chaName = _resolveMemberCalleeViaCHA(calleeExpr, callContext);
317
+ if (!_chaName) return null;
318
+ const resolved = callContext._callGraph.resolveKnownCallee(_chaName, _callerFile);
272
319
  const fn = functionRecord(callContext._callGraph, resolved);
273
320
  const qid = resolved && (resolved.qid || resolved);
274
321
  return typeof qid === 'string' ? { qid, fn } : null;
@@ -306,13 +353,18 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
306
353
  return {
307
354
  returnTainted: !!inner._returnTainted,
308
355
  mutatedParams: inner._mutatedParamsOut || new Set(),
356
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
309
357
  taintedGlobals: new Set(),
310
358
  findings: inner._findings,
311
359
  };
312
360
  });
313
361
  }
314
362
  _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
315
- return !!(sum && sum.returnTainted);
363
+ // Next-gen taint capability #3: a single-field getter's return is tainted
364
+ // whenever THIS call's own receiver already carries that field as
365
+ // tainted, independent of `sum.returnTainted` (which reflects only the
366
+ // EMPTY-args entry state a zero-param getter always computes under).
367
+ return _calleeGetterFieldTainted(calleeExpr, state, callContext) || !!(sum && sum.returnTainted);
316
368
  }
317
369
 
318
370
  // Taint-recall PRD (80%): is a call's own RECEIVER tainted? Handles the two
@@ -339,11 +391,88 @@ function _calleeReceiverTainted(callee, state, callContext) {
339
391
  return false;
340
392
  }
341
393
 
394
+ // Next-gen taint capability #3 — object-instance field taint via method
395
+ // calls (JS/TS). `classTaintedFields` (below, in the pre-pass) already
396
+ // tracks "does ANY method of this class ever taint field F", but that is a
397
+ // deliberate, CLASS-WIDE over-approximation used only when no direct call-
398
+ // graph link between writer and reader is discoverable. When code instead
399
+ // calls a mutator method directly on a LOCAL receiver variable —
400
+ // `const bad = new Holder(); bad.setData(tainted); ...` — the engine had no
401
+ // mechanism at all to taint `bad`'s own `data` field: `_mutatedParamsOut`
402
+ // (v0.66) only ever tracks the callee's DECLARED PARAMETERS, never the
403
+ // implicit `this` receiver, so a setter's `this.field = v` mutation was
404
+ // invisible to its own caller. Fixed by treating `this` as an implicit
405
+ // mutable parameter (see the `_mutatedThisFieldsOut` population in
406
+ // `analyzeFunction`, mirroring `_mutatedParamsOut` exactly) and, on the read
407
+ // side, recognizing a single-field getter shape (`return this.field;`) so a
408
+ // later `bad.getData()` resolves to whatever was tainted on `bad` SPECIFICALLY
409
+ // — not on every other instance of `Holder`. Because the caller-side effect
410
+ // lands on the RECEIVER'S OWN access path (`bad.data`, not a class-wide
411
+ // bucket), two different local variables holding instances of the SAME
412
+ // class are naturally kept distinct — real object-instance sensitivity,
413
+ // without needing a full allocation-site-keyed heap model.
414
+
415
+ // Field name a getter-shaped function unconditionally returns, or null.
416
+ // Deliberately narrow and structural (no taint-state dependency): only a
417
+ // BARE `this.<field>`/`_this_.<field>`/`$this.<field>` return value counts
418
+ // (not `this.field.sub`, not `this.field + x`) — accessPathOf already
419
+ // rejects anything with further computation. If the function has multiple
420
+ // return statements naming DIFFERENT fields, this is ambiguous and bails to
421
+ // null rather than guess, since guessing the wrong field here couldn't add
422
+ // a false negative (the true field would just stay whatever it already was)
423
+ // but a memoized wrong answer would be needlessly confusing to reason about.
424
+ const _THIS_FIELD_RETURN_RE = /^(?:_this_|this|\$this)\.([^.]+)$/;
425
+ // Same receiver-spelling prefix, WITHOUT the end anchor — matches a deeper
426
+ // path too (`_this_.data.sub`), used to recover just the top-level field
427
+ // name a function's exit state taints on its receiver (mutator side).
428
+ const _THIS_FIELD_PREFIX_RE = /^(?:_this_|this|\$this)\.([^.]+)/;
429
+ const _getterFieldCache = new WeakMap();
430
+ function _thisFieldGetterName(fn) {
431
+ if (!fn || !fn.cfg || !fn.cfg.nodes) return null;
432
+ if (_getterFieldCache.has(fn)) return _getterFieldCache.get(fn);
433
+ let field = null;
434
+ for (const node of Object.values(fn.cfg.nodes)) {
435
+ if (node.kind !== 'return' || !node.value) continue;
436
+ const path = accessPathOf(node.value);
437
+ if (!path) continue;
438
+ const m = _THIS_FIELD_RETURN_RE.exec(path);
439
+ if (!m) continue;
440
+ if (field === null) field = m[1];
441
+ else if (field !== m[1]) { field = null; break; }
442
+ }
443
+ _getterFieldCache.set(fn, field);
444
+ return field;
445
+ }
446
+
447
+ // The receiver's OWN access path for a receiver-qualified callee, in
448
+ // EITHER IR shape this codebase's frontends produce — same dual-shape
449
+ // handling as `_calleeReceiverTainted` just above, but returning the path
450
+ // itself (to taint/query it) rather than a boolean.
451
+ function _receiverAccessPathOf(callee) {
452
+ if (!callee) return null;
453
+ if (typeof callee === 'string') {
454
+ const idx = callee.lastIndexOf('.');
455
+ return idx > 0 ? callee.slice(0, idx) : null;
456
+ }
457
+ if (callee.kind === 'member' && callee.object) return accessPathOf(callee.object);
458
+ return null;
459
+ }
460
+
461
+ // Does calling `calleeExpr` return a receiver-specific field that is
462
+ // ALREADY tainted on THIS SPECIFIC receiver (per `_thisFieldGetterName`)?
463
+ // Read-side counterpart of the `_mutatedThisFieldsOut` write-side fix.
464
+ function _calleeGetterFieldTainted(calleeExpr, state, callContext) {
465
+ if (!callContext || !callContext._summaryCache) return false;
466
+ const target = _resolveCalleeForSummary(calleeExpr, callContext);
467
+ const field = target && _thisFieldGetterName(target.fn);
468
+ if (!field) return false;
469
+ const receiverPath = _receiverAccessPathOf(calleeExpr);
470
+ return !!(receiverPath && isCoveredBy(state, `${receiverPath}.${field}`));
471
+ }
472
+
342
473
  function exprTaint(expr, state, callContext) {
343
- if (expr && (expr.kind === 'member' || expr.kind === 'call' || expr.kind === 'ident') && exprIsSource(expr)) return true;
474
+ if (expr && (expr.kind === 'member' || expr.kind === 'call' || expr.kind === 'ident') && exprIsSource(expr, callContext)) return true;
344
475
  if (!expr) return false;
345
- // Constant propagation: variables assigned from literals are never tainted
346
- if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
347
476
  // P1.1 — field-sensitive access path: if the expression is a pure
348
477
  // ident/member chain ("x.y.z"), ask the access-path lattice whether any
349
478
  // shorter prefix in the state covers it. This is what makes
@@ -359,6 +488,17 @@ function exprTaint(expr, state, callContext) {
359
488
  case 'object': return (expr.props || []).some(p => exprTaint(p.value, state, callContext));
360
489
  case 'array': return (expr.elements || []).some(e => exprTaint(e, state, callContext));
361
490
  case 'call': {
491
+ // Sound taint-kill for a coercion sanitizer used INLINE (not assigned
492
+ // to a variable first) — `sink("x=" . intval($_GET['id']))`,
493
+ // `if (sink(intval($x)))`. See _isCoercionCall's header comment for why
494
+ // this specific class of sanitizer is the one exception to "never kill
495
+ // taint on a sanitizer call": a genuine type coercion makes the VALUE
496
+ // itself non-injectable, independent of which sink it reaches, so its
497
+ // own return value is clean regardless of whether its argument was
498
+ // tainted. Checked first and returns immediately — a coerced value
499
+ // cannot un-clean itself via its own (nonexistent, since this exits
500
+ // early) receiver/nested-return taint.
501
+ if (_isCoercionCall(expr)) return false;
362
502
  // The call's own arguments OR — PRD R10 — the resolved callee's own
363
503
  // return-taint summary. Taint-recall PRD (80%): this used to
364
504
  // short-circuit on args-tainted and SKIP _nestedCallReturnTainted
@@ -378,7 +518,7 @@ function exprTaint(expr, state, callContext) {
378
518
  // direction) so the merge always runs when a call expression is
379
519
  // visited; _resolveCalleeForSummary + SummaryCache make repeat
380
520
  // resolution/computation for the same (qid, entry-state) cheap.
381
- const argsTainted = (expr.args || []).some(a => exprTaint(a, state, callContext));
521
+ const argsTainted = (expr.args || []).some((a, i) => !_isSprintfSafeArg(expr, i) && exprTaint(a, state, callContext));
382
522
  const nestedTainted = _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
383
523
  // Taint-recall PRD (80%): a call's RECEIVER can itself be tainted
384
524
  // independent of its arguments — `tainted.toString()`, `tainted.trim()`,
@@ -460,7 +600,16 @@ function _sourcesReachingExpr(expr, _state, taintSources) {
460
600
  }
461
601
 
462
602
  // Heuristic: does this expression read a registered source?
463
- function exprIsSource(expr) {
603
+ // `callContext` (SARD_80_F1 W4.C39, optional) lets the CALL-shaped branch
604
+ // resolve a receiver's CHA-declared type (mirroring `_receiverTypeFor`'s use
605
+ // for sinks) and pass it to `matchSource` so a `receiverTypeIn`-bearing
606
+ // source entry can confirm a match even when the receiver's own NAME fails
607
+ // (`dr.GetString(1)` — Juliet's idiomatic short SqlDataReader variable name,
608
+ // no "reader" substring). Every caller that has a callContext in scope now
609
+ // passes it through; callers that don't (none currently) simply get the
610
+ // prior, name-only behavior — `_receiverTypeFor` itself degrades to null
611
+ // when callContext/CHA is absent, so this is purely additive.
612
+ function exprIsSource(expr, callContext) {
464
613
  if (!expr) return null;
465
614
  if (expr.kind === 'member') {
466
615
  const hit = matchSource(expr, _currentFile);
@@ -470,7 +619,8 @@ function exprIsSource(expr) {
470
619
  // Previously only member reads were recognized, so Go's call-style sources
471
620
  // never tainted the assignment target. matchSource now resolves call sources.
472
621
  if (expr.kind === 'call') {
473
- const hit = matchSource(expr, _currentFile);
622
+ const receiverType = _receiverTypeFor(expr.callee, callContext);
623
+ const hit = matchSource(expr, _currentFile, receiverType);
474
624
  if (hit) return hit;
475
625
  }
476
626
  // Taint-recall PRD (80%): bare-identifier GLOBAL sources — PHP's $_GET/
@@ -492,7 +642,7 @@ function exprIsSource(expr) {
492
642
  if (hit) return hit;
493
643
  }
494
644
  if (expr.kind === 'member' && expr.object) {
495
- return exprIsSource(expr.object);
645
+ return exprIsSource(expr.object, callContext);
496
646
  }
497
647
  return null;
498
648
  }
@@ -608,6 +758,93 @@ function _calleeNames(callee) {
608
758
  return [];
609
759
  }
610
760
 
761
+ // SARD_80_F1 W3.2 — guard-predicate narrowing. `_calleeNames` only handles
762
+ // an OBJECT-shaped callee (Babel's `{kind:'ident'|'member',...}`); the five
763
+ // hand-rolled parsers (C#/Go/Kotlin/PHP/Ruby) plus Java/Python instead emit
764
+ // a flat, dot-joined STRING callee (per ir/CLAUDE.md) — `is_numeric` or
765
+ // `int.TryParse`, never a structured node. This normalizes both shapes to
766
+ // the same {full, bare-leaf} name pair `GUARD_PREDICATES` matches against.
767
+ function _bareCalleeNames(callee) {
768
+ if (typeof callee === 'string') {
769
+ const parts = callee.split('.');
770
+ return [callee, parts[parts.length - 1]];
771
+ }
772
+ return _calleeNames(callee);
773
+ }
774
+
775
+ // A hand-picked, deliberately CONSERVATIVE set of validating predicates
776
+ // that, when used as an if-statement's CONDITION and observed true, prove
777
+ // the checked variable cannot carry injection metacharacters on the TRUE
778
+ // branch specifically — never on the false branch (a guard that FAILS says
779
+ // nothing about the value's safety, so that edge, and any code after an
780
+ // if-with-no-else, keeps the original tainted state unchanged). Each entry
781
+ // names the callees that count as this guard and which argument holds the
782
+ // checked variable. Deliberately narrow for this first landing: a regex-
783
+ // based guard (`preg_match('/^\d+$/', $x)`) needs the PATTERN itself
784
+ // validated as a fully-anchored numeric literal to be sound, and an
785
+ // allow-list guard (`in_array($x, $allow, true)`) needs `$allow` resolved
786
+ // to a literal array — both are real, documented follow-ups, not attempted
787
+ // here to keep this landing's soundness easy to verify.
788
+ const GUARD_PREDICATES = [
789
+ // PHP: is_numeric/ctype_* — a string that PASSES is provably digits-only
790
+ // (or alnum), which cannot carry SQL/XPath/shell/LDAP metacharacters.
791
+ { names: ['is_numeric', 'ctype_digit', 'ctype_alnum'], argIndex: 0 },
792
+ // Java: Apache Commons / Spring's StringUtils.isNumeric(x).
793
+ { names: ['StringUtils.isNumeric', 'isNumeric'], argIndex: 0 },
794
+ // C#: `if (int.TryParse(x, out n))` and its numeric-type siblings. Each
795
+ // full dotted name is listed explicitly (not a bare 'TryParse' catch-all)
796
+ // to avoid over-matching an unrelated same-named helper method.
797
+ {
798
+ names: [
799
+ 'int.TryParse', 'Int32.TryParse', 'long.TryParse', 'Int64.TryParse',
800
+ 'double.TryParse', 'decimal.TryParse', 'float.TryParse',
801
+ 'uint.TryParse', 'short.TryParse', 'byte.TryParse',
802
+ ],
803
+ argIndex: 0,
804
+ },
805
+ ];
806
+
807
+ // Does `cond` (an if-statement's condition expression) recognizably guard a
808
+ // currently-tainted variable per `GUARD_PREDICATES`? Returns that variable's
809
+ // access path (to narrow on the true-branch edge only) or null.
810
+ function _guardNarrowsVar(cond, state) {
811
+ if (!cond || cond.kind !== 'call') return null;
812
+ const names = _bareCalleeNames(cond.callee);
813
+ for (const g of GUARD_PREDICATES) {
814
+ if (!g.names.some((n) => names.includes(n))) continue;
815
+ const arg = cond.args && cond.args[g.argIndex];
816
+ const path = arg && accessPathOf(arg);
817
+ if (path && isCoveredBy(state, path)) return path;
818
+ }
819
+ return null;
820
+ }
821
+
822
+ // Next-gen taint capability #2 — regex-validation guard narrowing (JS/TS).
823
+ // Recognizes the idiom `if (<regex-literal>.test(x)) { ... }` — `parser-js.js`
824
+ // lowers a regex literal's own `.test(...)` call to `{kind:'call',
825
+ // callee:{kind:'member', object:{kind:'literal', value:RegExp, isRegex:true},
826
+ // prop:'test'}, args:[x]}`. When the literal's pattern is
827
+ // `isSafeValidationPattern`-safe, an OBSERVED true return proves `x` cannot
828
+ // carry injection metacharacters, same soundness contract and TRUE-branch-
829
+ // only narrowing as `GUARD_PREDICATES`/`_guardNarrowsVar` above. Deliberately
830
+ // narrow for this landing, same "documented follow-up" precedent as that
831
+ // function's own header comment: a regex bound to a variable first
832
+ // (`const re = /.../; if (re.test(x))`) is not resolved back to its literal —
833
+ // this engine has no constant-propagation map for local variable bindings to
834
+ // reuse here, and adding one is a real, separate piece of work.
835
+ function _regexTestGuardNarrowsVar(cond, state) {
836
+ if (!cond || cond.kind !== 'call') return null;
837
+ const callee = cond.callee;
838
+ if (!callee || typeof callee !== 'object' || callee.kind !== 'member' || callee.prop !== 'test') return null;
839
+ const obj = callee.object;
840
+ if (!obj || obj.kind !== 'literal' || !obj.isRegex || !(obj.value instanceof RegExp)) return null;
841
+ if (!isSafeValidationPattern(obj.value)) return null;
842
+ const arg = cond.args && cond.args[0];
843
+ const path = arg && accessPathOf(arg);
844
+ if (path && isCoveredBy(state, path)) return path;
845
+ return null;
846
+ }
847
+
611
848
  function _unsanitizersInExprTree(expr, out) {
612
849
  if (!expr || typeof expr !== 'object') return;
613
850
  if (expr.kind === 'call') {
@@ -684,6 +921,131 @@ function _sanitizersForExpr(expr, callContext) {
684
921
  return out;
685
922
  }
686
923
 
924
+ // Sound taint-kill for TYPE-COERCION sanitizers (SARD_80_F1_SCANNER_PRD.md).
925
+ //
926
+ // Every OTHER sanitizer in this engine only ever DEMOTES (sanitizer-gate.js /
927
+ // proof-gate.js), never kills taint outright — the well-documented reason is
928
+ // that a sanitizer's effect is family-specific (`htmlspecialchars` does
929
+ // nothing for SQLi), so killing taint on ANY sanitizer call would silently
930
+ // drop a real vulnerability whenever an unrelated escaper happened to sit on
931
+ // the path. A catalog entry tagged `appliesTo: ['*']` is categorically
932
+ // different: it is registered specifically because it changes the VALUE'S
933
+ // TYPE (intval, filter_var(..., FILTER_VALIDATE_INT), parseInt, Integer.parseInt,
934
+ // strconv.Atoi, ...), not because it neutralizes one syntax. A value that has
935
+ // gone through a genuine type coercion cannot carry injection syntax for ANY
936
+ // string-based family at once — the same reasoning that forbids a blanket
937
+ // kill for family-specific sanitizers affirmatively REQUIRES one here, or
938
+ // every one of these 17+ catalog entries does nothing but cost confidence
939
+ // point.
940
+ //
941
+ // Scoped tightly: only fires when `expr` IS ITSELF a direct call whose own
942
+ // callee catalog-matches a universal sanitizer — never a guess, never
943
+ // something buried deeper in an unrelated expression tree (that's what
944
+ // `_sanitizersForExpr` already recall-preservingly handles for the DEMOTE
945
+ // path). `data = intval($_GET['x']);` fires this; `data = "x=" . intval($y);`
946
+ // (the coercion is only PART of the value) does not, and correctly still
947
+ // gets full taint tracking on the concatenation.
948
+ // Shared with _sinkFindingsForCall's identical sink-side check (below): a
949
+ // `requireLiteralArg` gate applies equally to a SANITIZER entry whose
950
+ // effectiveness depends on which literal was passed (`filter_var($x,
951
+ // FILTER_VALIDATE_INT)` genuinely coerces; `filter_var($x,
952
+ // FILTER_SANITIZE_STRING)` or a bare `filter_var($x)` does not — both are the
953
+ // SAME callee, distinguished only by this argument). Fails CLOSED: a missing
954
+ // or non-literal arg does not satisfy the requirement, same direction every
955
+ // other precision gate in this file takes when evidence is unavailable.
956
+ function _literalArgSatisfied(argExprs, requireLiteralArg) {
957
+ const { index, pattern } = requireLiteralArg;
958
+ const checkArg = (argExprs || [])[index];
959
+ return !!checkArg && checkArg.kind === 'literal' && new RegExp(pattern).test(String(checkArg.value));
960
+ }
961
+
962
+ // SARD_80_F1 W5.49 — PHP's `sprintf("...%d...", $tainted)` is a PER-ARGUMENT
963
+ // coercion sanitizer, distinct from `_isCoercionCall`'s whole-call model
964
+ // (intval/floatval/etc, where the ENTIRE call's return value is the coerced
965
+ // scalar). `sprintf` returns a TEMPLATE string; only the specific argument(s)
966
+ // bound to a purely-numeric conversion (%d/%u/%f/%x/%o/%b/...) are coerced —
967
+ // an argument bound to %s passes through raw and is NOT coerced, and %c
968
+ // converts an integer to a single arbitrary BYTE (an attacker-controlled
969
+ // code point can still be a quote/semicolon/backslash), so it is
970
+ // deliberately excluded from the safe set even though it looks numeric-ish.
971
+ //
972
+ // Confirmed as a REAL, previously-unrecognized PHP SQL-injection false-
973
+ // positive source via the public generator source this session already has
974
+ // an established exception for (stivalet/PHP-Vuln-test-suite-generator's own
975
+ // `bin/XML/construction.xml`): its CWE-89 "safe" construction sample is
976
+ // LITERALLY `$query = sprintf("SELECT * FROM student where id=%d",
977
+ // $tainted);` (`safety flawType="CWE_89_Injection" ... safe="1"`) — a type-
978
+ // coercion mitigation this engine had no mechanism for at all before this
979
+ // fix, confirmed via a direct, from-scratch `runScan()` reproduction of this
980
+ // exact real-generator shape (not corpus/gold access).
981
+ //
982
+ // Positional-vs-explicit argnum handling matches PHP's own sprintf spec
983
+ // (`%2$d` explicitly names argument 2, 1-based); an unrecognized/malformed
984
+ // conversion is conservatively treated as unsafe (not added to the safe
985
+ // set) rather than guessed.
986
+ const _SPRINTF_SAFE_NUMERIC_SPECS = new Set(['d', 'u', 'f', 'F', 'e', 'E', 'g', 'G', 'x', 'X', 'o', 'b']);
987
+ const _SPRINTF_CONVERSION_RE = /%(\d+\$)?[-+ 0']*(\d+)?(\.\d+)?([bcdeEfFgGosuxX%])/g;
988
+ function _sprintfSafeArgIndices(fmt) {
989
+ if (typeof fmt !== 'string') return null;
990
+ const safe = new Set();
991
+ let positional = 0;
992
+ const re = new RegExp(_SPRINTF_CONVERSION_RE.source, 'g');
993
+ let m;
994
+ while ((m = re.exec(fmt))) {
995
+ const spec = m[4];
996
+ if (spec === '%') continue; // literal %% consumes no argument
997
+ const idx = m[1] ? (parseInt(m[1], 10) - 1) : positional++;
998
+ if (_SPRINTF_SAFE_NUMERIC_SPECS.has(spec)) safe.add(idx);
999
+ }
1000
+ return safe;
1001
+ }
1002
+ // Numeric coercion defeats SYNTAX-ESCAPING injection (SQL/XPath/LDAP/
1003
+ // command/eval all require the attacker to inject a quote/semicolon/
1004
+ // metacharacter to break out of a string literal — impossible once the
1005
+ // value is pure digits) but does NOT defeat RESOURCE-SELECTION vulnerabilities
1006
+ // (file inclusion, path traversal): a purely numeric value can still select
1007
+ // an unintended resource among many (`pages/5.php` vs `pages/999.php`), so
1008
+ // coercion alone does not make it safe. Confirmed via the SAME public
1009
+ // generator source this fix already relies on: `bin/XML/construction.xml`
1010
+ // marks `$var = include(sprintf("pages/'%d'.php", $tainted));` UNSAFE
1011
+ // (`safe="0"`) even though the value is %d-coerced — found via a real,
1012
+ // reproducible regression (CWE-98 tp -1) during this fix's own real-corpus
1013
+ // verification. `_isCoercionCall`'s existing intval/floatval mechanism has
1014
+ // the IDENTICAL blind spot already (confirmed via direct reproduction:
1015
+ // `include("pages/" . intval($_GET['page']) . ".php")` already produces
1016
+ // zero findings before this change) — this fix does not introduce a new
1017
+ // risk class, but it's common enough via sprintf specifically to be worth
1018
+ // closing here: skip the coercion whenever the format string's STATIC
1019
+ // (non-conversion) text looks like a file-path template, a narrow,
1020
+ // principled heuristic (a `/` path separator, or a trailing `.ext`-shaped
1021
+ // suffix) rather than a benchmark-fitted exclusion.
1022
+ function _sprintfLooksLikeFilePath(fmt) {
1023
+ const stripped = fmt.replace(new RegExp(_SPRINTF_CONVERSION_RE.source, 'g'), '');
1024
+ if (stripped.includes('/')) return true;
1025
+ return /\.[A-Za-z0-9]{1,5}['")\]]*\s*$/.test(stripped);
1026
+ }
1027
+ // `argExprIndex` is the index within `expr.args` (0 = the format string
1028
+ // itself, 1 = the first value argument) — matches how callers already index
1029
+ // `expr.args` elsewhere in this file (e.g. `_literalArgSatisfied`).
1030
+ function _isSprintfSafeArg(expr, argExprIndex) {
1031
+ if (!expr || expr.kind !== 'call' || argExprIndex < 1) return false;
1032
+ const name = typeof expr.callee === 'string' ? expr.callee.split('.').pop() : null;
1033
+ if (name !== 'sprintf') return false;
1034
+ const fmtArg = (expr.args || [])[0];
1035
+ if (!fmtArg || fmtArg.kind !== 'literal' || typeof fmtArg.value !== 'string') return false;
1036
+ if (_sprintfLooksLikeFilePath(fmtArg.value)) return false;
1037
+ const safe = _sprintfSafeArgIndices(fmtArg.value);
1038
+ return !!safe && safe.has(argExprIndex - 1);
1039
+ }
1040
+
1041
+ function _isCoercionCall(expr) {
1042
+ if (!expr || expr.kind !== 'call' || !expr.callee) return false;
1043
+ const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
1044
+ if (!Array.isArray(cat)) return false;
1045
+ return cat.some(e => e.kind === 'sanitizer' && Array.isArray(e.appliesTo) && e.appliesTo.includes('*')
1046
+ && (!e.match || !e.match.requireLiteralArg || _literalArgSatisfied(expr.args, e.match.requireLiteralArg)));
1047
+ }
1048
+
687
1049
  // cat / argTaints: the result of _matchCallCatalog (computed by the caller,
688
1050
  // at whatever point in its case is appropriate for its own state-mutation
689
1051
  // ordering).
@@ -720,11 +1082,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
720
1082
  // is NOT satisfied, same direction proof-gate.js and every other
721
1083
  // precision annotator in this codebase take when evidence is
722
1084
  // simply unavailable rather than affirmatively clean.
723
- if (e.match && e.match.requireLiteralArg) {
724
- const { index, pattern } = e.match.requireLiteralArg;
725
- const checkArg = (argExprs || [])[index];
726
- if (!checkArg || checkArg.kind !== 'literal' || !new RegExp(pattern).test(String(checkArg.value))) continue;
727
- }
1085
+ if (e.match && e.match.requireLiteralArg && !_literalArgSatisfied(argExprs, e.match.requireLiteralArg)) continue;
728
1086
  // `match.requireKeyword` — the KEYWORD-argument analogue of the
729
1087
  // positional gate above, for languages where the dangerous form is
730
1088
  // selected by a named argument rather than a positional literal.
@@ -897,7 +1255,7 @@ function step(node, stateIn, callContext) {
897
1255
  return { state, findings };
898
1256
 
899
1257
  case 'assign': {
900
- const src = exprIsSource(node.source);
1258
+ const src = exprIsSource(node.source, callContext);
901
1259
  const target = typeof node.target === 'string' ? node.target : null;
902
1260
  // Sink matching is additive to this case's existing source/target/taint
903
1261
  // handling below: an assignment's RHS can itself be a sink call (e.g.
@@ -922,7 +1280,19 @@ function step(node, stateIn, callContext) {
922
1280
  // (lhsPath in parser-js.js); a bare identifier target ("x") has no
923
1281
  // dot and _matchMemberWriteSink correctly returns null for it.
924
1282
  if (target && target.includes('.')) {
925
- const _memberHits = matchMemberWriteSink(target, _currentFile);
1283
+ // PRD SARD_80_F1 §9/§15.1: a bare, non-dotted receiver segment
1284
+ // (`searcher` in `searcher.Filter`) can be CHA-typed exactly like
1285
+ // `_receiverTypeFor` types a call receiver — same classOfVar lookup,
1286
+ // same "only a plain local var-name assigned `new X()`" scope. This
1287
+ // lets a write-sink entry's `receiverTypeIn` fire on the real
1288
+ // allocation type when the variable's NAME doesn't match its
1289
+ // `receiver` regex (renamed identifiers, or just a different naming
1290
+ // convention than the one the regex was written against).
1291
+ const _receiverVar = target.slice(0, target.lastIndexOf('.'));
1292
+ const _memberReceiverType = (_receiverVar && !_receiverVar.includes('.') && callContext && callContext._cha)
1293
+ ? classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, _receiverVar)
1294
+ : null;
1295
+ const _memberHits = matchMemberWriteSink(target, _currentFile, _memberReceiverType);
926
1296
  if (_memberHits && exprTaint(node.source, state, callContext)) {
927
1297
  findings.push(..._memberWriteSinkFindings(
928
1298
  _memberHits, node.source, state, callContext, node.line, target));
@@ -947,11 +1317,6 @@ function step(node, stateIn, callContext) {
947
1317
  if (_unsan.size) _unByVar.set(target, _unsan);
948
1318
  else _unByVar.delete(target);
949
1319
  }
950
- // Constant propagation: track variables assigned from literals
951
- if (target && _activeConstantVars) {
952
- if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
953
- else _activeConstantVars.delete(target);
954
- }
955
1320
  let newState = state;
956
1321
  // Premortem #7: interprocedural return-taint via SummaryCache. If the
957
1322
  // RHS is a call to a known callee whose empty-entry-state summary says
@@ -995,6 +1360,7 @@ function step(node, stateIn, callContext) {
995
1360
  return {
996
1361
  returnTainted: !!inner._returnTainted,
997
1362
  mutatedParams: inner._mutatedParamsOut || new Set(),
1363
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
998
1364
  taintedGlobals: new Set(),
999
1365
  // Real findings from the callee's own body — e.g.
1000
1366
  // `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
@@ -1009,7 +1375,12 @@ function step(node, stateIn, callContext) {
1009
1375
  });
1010
1376
  }
1011
1377
  _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
1012
- if (sum && sum.returnTainted) {
1378
+ // Next-gen taint capability #3: `x = bad.getData()` — a single-
1379
+ // field getter's return is tainted whenever THIS call's own
1380
+ // receiver already carries that field, independent of `sum`
1381
+ // (a zero-param getter's entry-state summary can never see it).
1382
+ const _getterTainted = _calleeGetterFieldTainted(node.source.callee, newState, callContext);
1383
+ if ((sum && sum.returnTainted) || _getterTainted) {
1013
1384
  newState = _addPathAliasAware(newState, target, callContext);
1014
1385
  callContext._taintSources.push({
1015
1386
  varName: target,
@@ -1019,13 +1390,17 @@ function step(node, stateIn, callContext) {
1019
1390
  line: node.line,
1020
1391
  });
1021
1392
  }
1022
- // applyAtCallSite — mutated params propagate to caller arg-vars.
1023
- if (sum && sum.mutatedParams && sum.mutatedParams.size && paramNames.length) {
1024
- const mutated = callContext._summaryCache.applyAtCallSite(
1025
- sum, paramNames, callArgs, callerTainted);
1026
- for (const v of mutated.mutated) newState = addPath(newState, v);
1393
+ // applyAtCallSite — mutated params (and, next-gen capability #3,
1394
+ // the implicit `this` receiver's own mutated fields) propagate to
1395
+ // the caller's state.
1396
+ if (sum && ((sum.mutatedParams && sum.mutatedParams.size && paramNames.length) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
1397
+ const receiverPath = _receiverAccessPathOf(node.source.callee);
1398
+ const applied = callContext._summaryCache.applyAtCallSite(
1399
+ sum, paramNames, callArgs, callerTainted, receiverPath);
1400
+ for (const v of applied.mutated) newState = addPath(newState, v);
1401
+ for (const p of applied.mutatedThisPaths) newState = addPath(newState, p);
1027
1402
  }
1028
- if (sum && sum.returnTainted) return { state: newState, findings };
1403
+ if ((sum && sum.returnTainted) || _getterTainted) return { state: newState, findings };
1029
1404
  } else if (target && calleeName) {
1030
1405
  // Fallback: check builtin summaries for unresolved external calls
1031
1406
  const builtin = lookupBuiltinSummary(calleeName);
@@ -1066,7 +1441,12 @@ function step(node, stateIn, callContext) {
1066
1441
  }
1067
1442
  }
1068
1443
  }
1069
- if (src && target) {
1444
+ if (target && _isCoercionCall(node.source)) {
1445
+ // See _isCoercionCall's header comment: a real type coercion kills
1446
+ // taint outright, unconditionally of sink family — the one case in
1447
+ // this engine where a sanitizer does more than demote.
1448
+ newState = removePathAndDescendants(newState, target);
1449
+ } else if (src && target) {
1070
1450
  newState = _addPathAliasAware(newState, target, callContext);
1071
1451
  const sourcePath = accessPathOf(node.source);
1072
1452
  if (sourcePath) newState = addPath(newState, sourcePath);
@@ -1129,6 +1509,7 @@ function step(node, stateIn, callContext) {
1129
1509
  return {
1130
1510
  returnTainted: !!inner._returnTainted,
1131
1511
  mutatedParams: inner._mutatedParamsOut || new Set(),
1512
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
1132
1513
  taintedGlobals: new Set(),
1133
1514
  // See the sibling assign-call-site compute() above — same
1134
1515
  // fix, same reason: this callee's own findings were
@@ -1138,10 +1519,17 @@ function step(node, stateIn, callContext) {
1138
1519
  });
1139
1520
  }
1140
1521
  _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
1141
- if (sum && sum.mutatedParams && sum.mutatedParams.size) {
1142
- const mutated = callContext._summaryCache.applyAtCallSite(
1143
- sum, paramNames, node.args || [], state);
1144
- for (const v of mutated.mutated) state = addPath(state, v);
1522
+ // Next-gen taint capability #3: the plain-statement-position
1523
+ // counterpart of the assign-from-call site above — `bad.setData(
1524
+ // tainted);` never assigns anything, but the receiver's OWN field
1525
+ // still needs tainting so a LATER `bad.getData()`/`bad.data` read
1526
+ // sees it.
1527
+ if (sum && ((sum.mutatedParams && sum.mutatedParams.size) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
1528
+ const receiverPath = _receiverAccessPathOf(node.callee);
1529
+ const applied = callContext._summaryCache.applyAtCallSite(
1530
+ sum, paramNames, node.args || [], state, receiverPath);
1531
+ for (const v of applied.mutated) state = addPath(state, v);
1532
+ for (const p of applied.mutatedThisPaths) state = addPath(state, p);
1145
1533
  }
1146
1534
  }
1147
1535
  }
@@ -1194,7 +1582,28 @@ function step(node, stateIn, callContext) {
1194
1582
  // target, so it reaches this rule instead of the assign path. The
1195
1583
  // matching read lowers to `bag.[]`, already covered by the tainted
1196
1584
  // receiver prefix.
1197
- const _MUTATORS = /^(?:push|unshift|splice|fill|copyWithin|set|add|append|extend|insert|update|addAll|putAll|put|__setitem__)$/;
1585
+ // Case-insensitive: C#/Java collection APIs use PascalCase (`List.Add`,
1586
+ // `Dictionary.Insert`, `Stack.Push`) while JS/Python/Ruby use lowerCamel
1587
+ // (`push`, `append`, `add`) — a case-sensitive regex silently matched
1588
+ // only the latter group, so C#'s `list.Add(data)` (Juliet flow variants
1589
+ // 71-74, container-element taint) never registered as a mutator at all.
1590
+ // Widening to case-insensitive only ADDS matches (recall-preserving);
1591
+ // no existing lowercase mutator name collides with an unrelated,
1592
+ // dangerous PascalCase method by accident (checked against the C#/Java
1593
+ // catalog sink list). `push_str`/`extend_from_slice` are Rust's
1594
+ // String/Vec appenders (`push`/`extend`/`insert` already cover the
1595
+ // other languages and apply to Rust unchanged).
1596
+ // SARD_80_F1 W2.3: `addElement` (java.util.Vector's pre-Collections-
1597
+ // Framework method, still idiomatic in older code — Juliet's own age)
1598
+ // and `offer`/`offerFirst`/`offerLast` (the Queue/Deque interface's
1599
+ // mutator, implemented by ArrayDeque/LinkedList/PriorityQueue) were
1600
+ // both missing — found via direct probes showing `Vector.addElement`/
1601
+ // `ArrayDeque.offer` silently failed to taint their receiver while
1602
+ // every sibling API (`Vector`'s modern `add`, `Stack.push`,
1603
+ // `Hashtable.put`, `Properties.setProperty`, `TreeMap.put`) already
1604
+ // worked, an inconsistency with no principled reason — all six are
1605
+ // the same "write one element into a collection" shape.
1606
+ const _MUTATORS = /^(?:push|unshift|splice|fill|copyWithin|set|add|addElement|append|extend|insert|update|addAll|putAll|put|addrange|enqueue|offer|offerFirst|offerLast|__setitem__|push_str|extend_from_slice)$/i;
1198
1607
  // Mutate the state Set IN PLACE (the binding is const; the call case
1199
1608
  // returns this same Set ref). Avoids touching the unrelated
1200
1609
  // mutated-param paths in this case, keeping the blast radius to
@@ -1204,9 +1613,28 @@ function step(node, stateIn, callContext) {
1204
1613
  && _MUTATORS.test(node.callee.prop)) {
1205
1614
  _recv = accessPathOf(node.callee.object);
1206
1615
  } else if (typeof _plainCallCalleeName === 'string') {
1207
- const _dot = _plainCallCalleeName.lastIndexOf('.');
1208
- if (_dot > 0 && _MUTATORS.test(_plainCallCalleeName.slice(_dot + 1))) {
1209
- _recv = _plainCallCalleeName.slice(0, _dot);
1616
+ // SARD_80_F1 W2.3 follow-up: a REPEATED-method fluent chain
1617
+ // (`sb.append(a).append(b).append(c)`, StringBuilder/StringBuffer's
1618
+ // own idiomatic multi-line-SQL-building shape) dot-joins to
1619
+ // "StringBuilder.append.append.append" via the hand-rolled/CST
1620
+ // chain-flattening convention every frontend in this codebase
1621
+ // uses (correct for a chain ending in a DIFFERENT terminal method,
1622
+ // e.g. `DocumentBuilderFactory.newInstance().newDocumentBuilder()`
1623
+ // — wrong here, since `.append()` returning `this` means every
1624
+ // segment is really the SAME receiver, not a new one). Naively
1625
+ // taking everything before the LAST dot recovers
1626
+ // "StringBuilder.append.append" as the "receiver" — an
1627
+ // ever-growing, never-matching key that silently drops the
1628
+ // mutator's own taint tracking for every chained-append call.
1629
+ // Strip ALL trailing segments that repeat the SAME mutator method
1630
+ // name first, so `X.append.append.append` and plain `X.append`
1631
+ // resolve to the identical, real receiver "X".
1632
+ const _segs = _plainCallCalleeName.split('.');
1633
+ let _end = _segs.length - 1;
1634
+ const _lastSeg = _segs[_end];
1635
+ if (_end > 0 && _MUTATORS.test(_lastSeg)) {
1636
+ while (_end > 0 && _segs[_end] === _lastSeg) _end--;
1637
+ if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
1210
1638
  }
1211
1639
  }
1212
1640
  if (_recv) state.add(_recv);
@@ -1271,7 +1699,19 @@ function step(node, stateIn, callContext) {
1271
1699
  case 'if': {
1272
1700
  // Path-feasibility lite: if the condition is a literal false / unreachable,
1273
1701
  // mark the node so the CFG walker can skip the consequent edge.
1274
- // For now we simply propagate state to both branches.
1702
+ // For now we simply propagate state to both branches — EXCEPT for a
1703
+ // recognized guard-predicate condition (SARD_80_F1 W3.2), where the
1704
+ // TRUE branch specifically gets a narrowed (un-tainted-on-the-guarded-
1705
+ // var) state via `succOverride`; the false branch and any code after
1706
+ // an if-with-no-else still see the original, unnarrowed `state`. Per
1707
+ // this codebase's established CFG convention (path-feasibility.js),
1708
+ // `succ[0]` is the THEN edge and `succ[1+]` are the ELSE/fall-through
1709
+ // edge(s).
1710
+ const narrowedVar = _guardNarrowsVar(node.cond, state) || _regexTestGuardNarrowsVar(node.cond, state);
1711
+ if (narrowedVar && node.succ && node.succ.length > 0) {
1712
+ const thenState = removePathAndDescendants(state, narrowedVar);
1713
+ return { state, findings, succOverride: new Map([[node.succ[0], thenState]]) };
1714
+ }
1275
1715
  return { state, findings };
1276
1716
  }
1277
1717
 
@@ -1337,12 +1777,28 @@ function analyzeFunction(fn, entryState, callContext) {
1337
1777
  // No-op for the key unless AGENTIC_SECURITY_KCFA_CALLSTRING=1. Restored below.
1338
1778
  const _prevCallerCtx = (callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext)
1339
1779
  ? callContext._summaryCache.setCallerContext(fn.qid) : undefined;
1780
+ // SARD_80_F1 W4.C21/C22 — `_currentFile` (module-level, set just below) was
1781
+ // never saved/restored around a NESTED analyzeFunction call the way
1782
+ // `_prevCallerCtx` already is on the line above. A cross-FILE call site
1783
+ // (e.g. Bad() in file A calling a summary-computed callee in file B)
1784
+ // recurses into analyzeFunction(calleeFn, ..., inner) to compute the
1785
+ // callee's summary — that inner call sets `_currentFile = calleeFn.file`
1786
+ // (file B) and, until this fix, never set it back, so every subsequent
1787
+ // catalog lookup (`matchSinkOrSanitizer`/`matchSource`/`classOfVar`) in
1788
+ // the OUTER function's own remaining CFG nodes ran against the WRONG
1789
+ // file's language scoping for the rest of that outer function's walk.
1790
+ // Confirmed via a same-file-vs-cross-file A/B reproduction of a real
1791
+ // corpus false positive (see W4.C20/C21/C22) that isolated this leak as
1792
+ // one of two compounding defects. Saving/restoring here — the exact
1793
+ // pattern `_prevCallerCtx` already uses — makes `_currentFile` correctly
1794
+ // scoped to this function's own analysis regardless of what any callee's
1795
+ // nested summary computation does to it in between.
1796
+ const _prevFile = _currentFile;
1340
1797
  const work = [];
1341
1798
  const inStates = new Map();
1342
1799
  const outStates = new Map();
1343
1800
  inStates.set(fn.cfg.entry, new Set(entryState));
1344
1801
  work.push(fn.cfg.entry);
1345
- _activeConstantVars = new Map();
1346
1802
  _currentFile = fn.file || null;
1347
1803
  // v0.70 #2 — points-to context for the step() transfer. Setting it here
1348
1804
  // (instead of plumbing through step's signature) keeps the worklist loop
@@ -1363,15 +1819,32 @@ function analyzeFunction(fn, entryState, callContext) {
1363
1819
  const node = nodes[nid];
1364
1820
  if (!node) continue;
1365
1821
  const incoming = inStates.get(nid) || new Set();
1366
- const { state: out, findings } = step(node, incoming, callContext);
1367
- callContext._findings.push(...findings.map(f => ({ ...f, _funcQid: fn.qid })));
1822
+ const { state: out, findings, succOverride } = step(node, incoming, callContext);
1823
+ // SARD_80_F1 W4.C21/C22: stamp the file THIS finding actually belongs to
1824
+ // (fn's own file) at the moment of discovery — see `_collectFindings`'s
1825
+ // header comment for why this matters. `step()`/`_sinkFindingsForCall`
1826
+ // never set `file` themselves (only `line`), so every finding reaching
1827
+ // this line was, until now, implicitly reattributed later to whichever
1828
+ // function `_collectFindings` happened to be called with — correct only
1829
+ // when that's the SAME function, which a cross-file interprocedural
1830
+ // merge (`_mergeSummaryFindings`) is specifically the case where it is
1831
+ // NOT.
1832
+ callContext._findings.push(...findings.map(f => ({ ...f, _funcQid: fn.qid, file: f.file || fn.file })));
1368
1833
  const prevOut = outStates.get(nid);
1369
1834
  const merged = mergeStates(prevOut, out);
1370
1835
  if (!prevOut || !stateEq(prevOut, merged)) {
1371
1836
  outStates.set(nid, merged);
1372
1837
  for (const s of (node.succ || [])) {
1838
+ // SARD_80_F1 W3.2 — guard-predicate narrowing (case 'if' above): a
1839
+ // specific successor edge (the guard's TRUE branch) can carry a
1840
+ // NARROWER state than every other edge out of this node. `out` (not
1841
+ // `merged`) is what `succOverride` was derived from, and `out` is a
1842
+ // pure function of `incoming`, so gating this on `merged`'s own
1843
+ // convergence check above is sound — an unchanged `merged` means an
1844
+ // unchanged `incoming` means an unchanged override too.
1845
+ const edgeState = (succOverride && succOverride.has(s)) ? succOverride.get(s) : merged;
1373
1846
  const succIn = inStates.get(s);
1374
- const newIn = mergeStates(succIn, merged);
1847
+ const newIn = mergeStates(succIn, edgeState);
1375
1848
  if (!succIn || !stateEq(succIn, newIn)) {
1376
1849
  inStates.set(s, newIn);
1377
1850
  work.push(s);
@@ -1414,7 +1887,7 @@ function analyzeFunction(fn, entryState, callContext) {
1414
1887
  reportedNids.add(nid);
1415
1888
  callContext._findings.push({
1416
1889
  ...createImplicitFinding(node, conditionLabel),
1417
- _funcQid: fn.qid, sinkId: sink.id,
1890
+ _funcQid: fn.qid, sinkId: sink.id, file: fn.file,
1418
1891
  cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
1419
1892
  });
1420
1893
  };
@@ -1451,6 +1924,13 @@ function analyzeFunction(fn, entryState, callContext) {
1451
1924
  }
1452
1925
 
1453
1926
  const exit = outStates.get(fn.cfg.exit) || new Set();
1927
+ // Real cross-method field taint (SARD_80_F1_SCANNER_PRD.md, Juliet flow
1928
+ // variants 45/65-68): the runTaintEngine pre-pass needs the RAW exit state
1929
+ // (every tainted access path, not just the ones matching a declared param)
1930
+ // to tell whether THIS function's own body genuinely taints a class field —
1931
+ // see the pre-pass loop below for why this must be the real exit state, not
1932
+ // a guess.
1933
+ if (callContext) callContext._exitState = exit;
1454
1934
  // v0.66 — record which params are tainted at function exit so the
1455
1935
  // caller's applyAtCallSite can propagate that mutated taint back. We
1456
1936
  // intersect the exit-state with the function's declared params (only
@@ -1461,10 +1941,26 @@ function analyzeFunction(fn, entryState, callContext) {
1461
1941
  if (isCoveredBy(exit, p)) callContext._mutatedParamsOut.add(p);
1462
1942
  }
1463
1943
  }
1944
+ // Next-gen taint capability #3 — treat `this` as an implicit mutable
1945
+ // parameter, mirroring the block just above exactly: extract every field
1946
+ // name this function's own exit state taints on its receiver (any of the
1947
+ // three `this` spellings this codebase's frontends use), so the caller can
1948
+ // propagate that mutation onto ITS OWN receiver variable's access path
1949
+ // (`bad.data`), not a class-wide bucket. See this file's own header
1950
+ // comment above `_thisFieldGetterName` for the full rationale.
1951
+ if (callContext) {
1952
+ if (!callContext._mutatedThisFieldsOut) callContext._mutatedThisFieldsOut = new Set();
1953
+ for (const p of exit) {
1954
+ const m = _THIS_FIELD_PREFIX_RE.exec(p);
1955
+ if (m) callContext._mutatedThisFieldsOut.add(m[1]);
1956
+ }
1957
+ }
1464
1958
  // R2: restore the caller context for the enclosing function's analysis.
1465
1959
  if (_prevCallerCtx !== undefined && callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext) {
1466
1960
  callContext._summaryCache.setCallerContext(_prevCallerCtx);
1467
1961
  }
1962
+ // W4.C21/C22: restore _currentFile — see the save at this function's start.
1963
+ _currentFile = _prevFile;
1468
1964
  return exit;
1469
1965
  }
1470
1966
 
@@ -1521,12 +2017,40 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1521
2017
  const fnList = [...callGraph.functions.values()].sort((a, b) =>
1522
2018
  a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
1523
2019
  );
2020
+ // PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
2021
+ // silently `break`s once `n > fnLimit` (default 5000), which for a single
2022
+ // large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
2023
+ // functions) means roughly 70% of the corpus is never analyzed at all, no
2024
+ // matter how correct the resolution logic is for the functions it DOES
2025
+ // reach. This was completely invisible to every existing truncation
2026
+ // signal (wall-clock budget, per-file timeout/skip counts) — found while
2027
+ // investigating why a real, verified class-resolution fix (W1) showed
2028
+ // almost no aggregate SARD recall movement despite fixing individual
2029
+ // fixtures in isolation. Surfaced the same way the wall-clock budget
2030
+ // already is: a single info finding, not a return-shape change (this
2031
+ // function's return type — a flat array — is a wide, load-bearing
2032
+ // contract; changing it would touch every caller).
2033
+ if (fnList.length > fnLimit) {
2034
+ all.push({
2035
+ id: `ir-taint-fn-limit:${fnList[0] ? fnList[0].qid.split('::')[0] : ''}`,
2036
+ file: '(deep-engine)', line: 0,
2037
+ vuln: `IR-TAINT deep mode analyzed only ${fnLimit}/${fnList.length} functions (AGENTIC_SECURITY_DEEP_FN_LIMIT) — results are incomplete`,
2038
+ severity: 'info',
2039
+ parser: 'IR-TAINT',
2040
+ confidence: 0.5,
2041
+ });
2042
+ }
1524
2043
  // Pre-pass + fixed-point: compute empty-entry-state summaries for every
1525
2044
  // function, then re-run the pre-pass until the summary cache stabilizes
1526
2045
  // (capped at MAX_FP_ITERS so recursion and chains converge without
1527
2046
  // unbounded blowup). v0.66 — the inner ctx now records mutatedParams
1528
2047
  // via _mutatedParamsOut so cross-function param mutation propagates.
1529
2048
  const MAX_FP_ITERS = 3;
2049
+ // qid -> this function's own exit-state (every tainted access path at
2050
+ // return, from the LAST pre-pass iteration only — see the class-field
2051
+ // cross-taint pass below for why this must be a real, already-observed
2052
+ // exit state rather than a re-derived guess).
2053
+ const exitStateByQid = new Map();
1530
2054
  for (let it = 0; it < MAX_FP_ITERS; it++) {
1531
2055
  if (Date.now() > deadlineMs) break;
1532
2056
  // Tracks whether this iteration actually changed any cached summary's
@@ -1557,9 +2081,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1557
2081
  // (sinkId, file, line), so re-discovering the same empty-entry finding
1558
2082
  // multiple times collapses to one reported finding, never a duplicate.
1559
2083
  _collectFindings(fn, ctx._findings);
2084
+ exitStateByQid.set(fn.qid, ctx._exitState || new Set());
1560
2085
  const next = {
1561
2086
  returnTainted: !!ctx._returnTainted,
1562
2087
  mutatedParams: ctx._mutatedParamsOut || new Set(),
2088
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
1563
2089
  taintedGlobals: new Set(),
1564
2090
  findings: [],
1565
2091
  };
@@ -1590,26 +2116,143 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1590
2116
  // `changedThisIter` tracks actual value changes instead.
1591
2117
  if (!changedThisIter) break;
1592
2118
  }
1593
- // Class-field cross-taint pass: when a method writes tainted data to _this_.field,
1594
- // re-analyze other methods of the same class with those fields in the entry state.
1595
- const classTaintedFields = new Map();
1596
- for (const fn of fnList) {
1597
- if (Date.now() > deadlineMs) break;
1598
- const sum = summaryCache.get(fn.qid, new Set());
1599
- if (!sum || !sum.mutatedParams) continue;
1600
- for (const p of sum.mutatedParams) {
1601
- if (typeof p === 'string' && p.startsWith('_this_.')) {
1602
- const classPrefix = fn.qid.split('::')[0] + '::';
1603
- if (!classTaintedFields.has(classPrefix)) classTaintedFields.set(classPrefix, new Set());
1604
- classTaintedFields.get(classPrefix).add(p);
2119
+ // Class-field cross-taint pass (SARD_80_F1_SCANNER_PRD.md — Juliet flow
2120
+ // variants 45/65-68: a method writes a tainted value to a static or
2121
+ // instance field and a SIBLING method of the same class reads it into a
2122
+ // sink). The mechanism this replaced kept only the JS/Babel `_this_.field`
2123
+ // mutated-PARAM shape — but `_mutatedParamsOut` (above) only ever
2124
+ // intersects a function's own EXIT state against its declared PARAMS, and
2125
+ // a field is never a param, so that map was permanently empty for every
2126
+ // hand-rolled parser (C#, Java, PHP, Go, Ruby, Kotlin) — confirmed by
2127
+ // direct probing, not assumed: none of `sf = data;`, `this.inst = data;`
2128
+ // ever populated it, for any language.
2129
+ //
2130
+ // Real fix: `callContext._exitState` (stamped just above) is EVERY tainted
2131
+ // access path at a function's exit, not just the ones matching a param
2132
+ // name. Cross-reference it against the class's DECLARED FIELD NAMES (from
2133
+ // `ir.classes[].fields`, via `buildClassHierarchy` — only populated by a
2134
+ // parser that emits it; classes with no field list simply contribute
2135
+ // nothing here, which is a missed opportunity, never a wrong edge) to ask
2136
+ // "did this method's own body, taken alone, genuinely taint one of its
2137
+ // class's fields?" — a REAL, observed fact from the empty-entry pre-pass
2138
+ // above, not a guess. Both the bare form (`sf`) and the `this.`-qualified
2139
+ // form (`this.inst`, `_this_.inst`) are checked, since different parsers
2140
+ // lower a field write differently.
2141
+ const cha = opts._cha;
2142
+ const classTaintedFields = new Map(); // className -> Set(bareFieldName)
2143
+ // SARD_80_F1 W5.48 — per-WRITER record alongside the per-class union just
2144
+ // above: className -> field -> Set(qid of a method whose OWN exit state
2145
+ // taints this field). `classTaintedFields` only ever answers "is this
2146
+ // field EVER tainted somewhere in the class" (unioned across every
2147
+ // method), which is what made the cross-class pass below seed a reader
2148
+ // like `goodG2BSink()` as tainted even though its own real caller chain
2149
+ // (`goodG2B()`) only ever writes this field a LITERAL — Juliet's Flow
2150
+ // Variant 65-68 idiom (`_68a`/`_68b`) has bad()/goodG2B()/goodB2G() each
2151
+ // write the SAME static field then immediately call their OWN uniquely-
2152
+ // named sink (badSink/goodG2BSink/goodB2GSink), so "ever tainted
2153
+ // anywhere in the class" is true (via bad()) but says nothing about
2154
+ // which SPECIFIC caller reaches a SPECIFIC reader. This map lets the
2155
+ // cross-class pass ask the narrower, call-string-sensitive question
2156
+ // instead: "is EVERY discoverable direct caller of this reader a
2157
+ // confirmed NON-tainting writer of this field" — see its own use site.
2158
+ const fieldWriterQids = new Map();
2159
+ if (cha && cha.methodOwners && cha.classes) {
2160
+ for (const fn of fnList) {
2161
+ if (Date.now() > deadlineMs) break;
2162
+ const className = cha.methodOwners.get(fn.qid);
2163
+ if (!className) continue;
2164
+ const cls = cha.classes.get(className);
2165
+ const declaredFields = cls && cls.fields;
2166
+ if (!declaredFields || !declaredFields.size) continue;
2167
+ const exitState = exitStateByQid.get(fn.qid);
2168
+ if (!exitState || !exitState.size) continue;
2169
+ for (const field of declaredFields) {
2170
+ // SARD_80_F1 W5.10 — PHP's own `$this` reference keeps its `$`
2171
+ // sigil in every access path (`$this.field`, never bare `this.` —
2172
+ // parser-php.js's own `->`-to-`.` normalization, W5.9), a fourth
2173
+ // spelling alongside JS's `_this_.`/C#'s and Java's bare `this.`.
2174
+ //
2175
+ // SARD_80_F1 W5.17 — `isCoveredBy` deliberately never propagates UP
2176
+ // (its own doc: `set={"x.y.z"}` does NOT cover "x.y"), which is
2177
+ // correct for ordinary field reads but wrong here: an ARRAY-INDEXED
2178
+ // field write (`$this->input[1] = $_GET[...]`) taints the access
2179
+ // path `this.input.1`, not the bare `this.input`/`input` this loop
2180
+ // queries — so a getter method returning `$this->input[1]` (the
2181
+ // PHP-Vuln-test-suite-generator's own "object/Array" input-
2182
+ // indirection sample, confirmed via direct reproduction, not corpus
2183
+ // access) was invisible to this whole class-field pass, while the
2184
+ // scalar-field form one line below (`$this->input = $_GET[...]`)
2185
+ // already worked. `pathIsCoveredByPrefix(entry, field)` checks the
2186
+ // OPPOSITE direction — does `entry` sit AT OR BELOW `field` — which
2187
+ // is exactly "did the constructor taint SOME sub-path of this
2188
+ // field", independent of which specific index or nesting depth.
2189
+ const hasTaintedSubpath = (prefix) => { for (const p of exitState) { if (pathIsCoveredByPrefix(p, prefix)) return true; } return false; };
2190
+ if (isCoveredBy(exitState, field) || isCoveredBy(exitState, `this.${field}`) || isCoveredBy(exitState, `_this_.${field}`) || isCoveredBy(exitState, `$this.${field}`)
2191
+ || hasTaintedSubpath(field) || hasTaintedSubpath(`this.${field}`) || hasTaintedSubpath(`_this_.${field}`) || hasTaintedSubpath(`$this.${field}`)) {
2192
+ if (!classTaintedFields.has(className)) classTaintedFields.set(className, new Set());
2193
+ classTaintedFields.get(className).add(field);
2194
+ if (!fieldWriterQids.has(className)) fieldWriterQids.set(className, new Map());
2195
+ const fw = fieldWriterQids.get(className);
2196
+ if (!fw.has(field)) fw.set(field, new Set());
2197
+ fw.get(field).add(fn.qid);
2198
+ }
1605
2199
  }
1606
2200
  }
1607
2201
  }
1608
- for (const [classPrefix, fields] of classTaintedFields) {
2202
+ // SARD_80_F1 W5.50 — same reverse-call-graph lookup the cross-class pass
2203
+ // below uses (`callGraph.callersOf`, already built once by `buildCallGraph`
2204
+ // — see that pass's own header comment for why this reads the existing
2205
+ // index rather than re-deriving one), hoisted up so the SAME-class pass
2206
+ // immediately below can ALSO apply call-string precision. This pass had
2207
+ // the IDENTICAL over-approximation `classTaintedFields`'s own header
2208
+ // comment describes for the cross-class case — a sibling method reached
2209
+ // ONLY via a confirmed non-tainting writer was still blanket-seeded as
2210
+ // tainted, just scoped to siblings of the SAME class instead of a
2211
+ // different one. Genuinely a rarer shape here (per this pass's OWN
2212
+ // original comment, most sibling methods have no discoverable caller/
2213
+ // callee edge to each other at all — that remains true and still fails
2214
+ // closed correctly), but real whenever a sibling reader is directly
2215
+ // called from a KNOWN writer method (mirrors the cross-class idiom, just
2216
+ // without the class boundary).
2217
+ const _callerQidSetCache = new Map(); // fn.qid -> Set(callerQid)|null, memoized
2218
+ const callersOfQid = (qid) => {
2219
+ if (_callerQidSetCache.has(qid)) return _callerQidSetCache.get(qid);
2220
+ const edges = callGraph && callGraph.callersOf && callGraph.callersOf.get(qid);
2221
+ const set = edges && edges.length ? new Set(edges.map((e) => e.caller).filter(Boolean)) : null;
2222
+ _callerQidSetCache.set(qid, set);
2223
+ return set;
2224
+ };
2225
+ for (const [className, fields] of classTaintedFields) {
1609
2226
  if (Date.now() > deadlineMs) break;
1610
2227
  for (const fn of fnList) {
1611
- if (!fn.qid.startsWith(classPrefix)) continue;
1612
- if (summaryCache.has(fn.qid, fields)) continue;
2228
+ if (cha.methodOwners.get(fn.qid) !== className) continue;
2229
+
2230
+ // Call-string precision (mirrors the cross-class pass below): narrow
2231
+ // `fields` to only the ones fn's own discoverable callers can
2232
+ // actually supply as tainted. Fails closed (keeps the field seeded)
2233
+ // whenever fn has no discoverable caller, a caller outside this
2234
+ // class, or any caller not positively confirmed as a non-tainting
2235
+ // writer — this can ONLY ever narrow what was already being seeded,
2236
+ // never widen it, so it cannot introduce a new false negative beyond
2237
+ // what the pre-existing blanket behavior already accepted.
2238
+ const callers = callersOfQid(fn.qid);
2239
+ let seedFields = fields;
2240
+ if (callers && callers.size) {
2241
+ const effectiveFields = new Set();
2242
+ for (const f of fields) {
2243
+ const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
2244
+ let keep = false;
2245
+ for (const callerQid of callers) {
2246
+ if (writers && writers.has(callerQid)) { keep = true; break; }
2247
+ if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
2248
+ }
2249
+ if (keep) effectiveFields.add(f);
2250
+ }
2251
+ seedFields = effectiveFields;
2252
+ }
2253
+ if (!seedFields.size) continue; // every discoverable caller of fn provably never taints any of these fields
2254
+
2255
+ if (summaryCache.has(fn.qid, seedFields)) continue;
1613
2256
  const ctx = {
1614
2257
  _findings: [], _taintSources: [], _returnTainted: false,
1615
2258
  _stack: new Set(), deadlineMs,
@@ -1619,21 +2262,138 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1619
2262
  _cha: opts._cha,
1620
2263
  _pointsTo: opts._pointsTo,
1621
2264
  };
1622
- try { analyzeFunction(fn, _unionAnnotationTaint(fn, fields), ctx); } catch {}
1623
- // `findings` carries the REAL findings from this probe (was hardcoded
1624
- // `[]`, discarding them) — but this pass is speculative (every field
1625
- // in `fields` is assumed simultaneously tainted; nothing here confirms
1626
- // this exact method is ever reached with that state), so it must not
1627
- // report them itself. They ride on the cached summary and are only
1628
- // surfaced by _mergeSummaryFindings when a REAL call site (assign,
1629
- // plain-call, or higher-order) actually consults this qid+entry —
1630
- // at that point a genuine reachable caller has been established.
1631
- summaryCache.set(fn.qid, fields, {
2265
+ // Seed every spelling of every tainted field so a reading method sees
2266
+ // the taint regardless of which form it uses to reference the field
2267
+ // — mirrors the write-side check above, `$this.` (PHP, W5.10) included.
2268
+ const seeded = new Set();
2269
+ for (const f of seedFields) { seeded.add(f); seeded.add(`this.${f}`); seeded.add(`_this_.${f}`); seeded.add(`$this.${f}`); }
2270
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, seeded), ctx); } catch {}
2271
+ // Unlike the k=2 pass below, this is NOT speculative: `seedFields` was
2272
+ // derived from a REAL, already-observed write elsewhere in this exact
2273
+ // class (the loop above), so a sink this re-analysis finds is a real,
2274
+ // reachable flow — report it directly instead of waiting for a call
2275
+ // site that will never come (most sibling methods still have no
2276
+ // caller/callee relationship for the taint engine to discover; the
2277
+ // call-string narrowing just above only ever removes fields when one
2278
+ // WAS discoverable).
2279
+ _collectFindings(fn, ctx._findings);
2280
+ // `findings` still rides on the cached summary too, for the (rarer)
2281
+ // case where a real caller ALSO consults this exact qid+entry pair via
2282
+ // _mergeSummaryFindings — dedup in _collectFindings makes this safe.
2283
+ const fieldSummary = {
1632
2284
  returnTainted: !!ctx._returnTainted,
1633
2285
  mutatedParams: ctx._mutatedParamsOut || new Set(),
2286
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
1634
2287
  taintedGlobals: new Set(),
1635
2288
  findings: ctx._findings,
1636
- });
2289
+ };
2290
+ summaryCache.set(fn.qid, seedFields, fieldSummary);
2291
+ // SARD_80_F1 W3.x — the getter+return interprocedural-composition gap
2292
+ // documented at PHP W5.10: an EXTERNAL, no-argument caller of this
2293
+ // method (`$tainted = $obj->getInput();`) never consults the
2294
+ // `fields`-keyed entry above — it looks up the ORDINARY empty-context
2295
+ // summary (`summaryCache.get(fn.qid, new Set())`), which the earlier
2296
+ // fixed-point pre-pass computed WITHOUT knowing the field was tainted
2297
+ // (that fact is only established by the class-scan just above, which
2298
+ // runs once, after the pre-pass). `entryContext = new Set()` means
2299
+ // "no CALLER-supplied parameter is tainted" — true here regardless of
2300
+ // how many params `fn` declares, since this re-analysis seeded ONLY
2301
+ // the class's own already-tainted fields, no parameters — so this
2302
+ // result is exactly the correct value for that key, not a guess.
2303
+ // Safe to overwrite unconditionally: this model has no un-taint step,
2304
+ // so a field-seeded re-analysis can only find a superset of whatever
2305
+ // the plain empty-context pre-pass already found. The final per-
2306
+ // function reporting pass (the `for (const fn of fnList)` loop that
2307
+ // does the REAL, non-speculative analysis of every function,
2308
+ // including whatever caller invokes this getter) runs strictly AFTER
2309
+ // this whole class-field block, so it is guaranteed to see the
2310
+ // corrected summary — no fixed-point re-iteration needed for THIS
2311
+ // specific external-caller shape.
2312
+ summaryCache.set(fn.qid, new Set(), fieldSummary);
2313
+ }
2314
+ }
2315
+
2316
+ // Cross-class static-field taint (SARD_80_F1_EXECUTION_PRD.md — Juliet flow
2317
+ // variant 68: a driver class writes a tainted value to its OWN static
2318
+ // field, then a COMPLETELY DIFFERENT class reads it back via a qualified
2319
+ // reference — `String data = OtherClass.field;` — with no parameter
2320
+ // passed at all. The same-class pass just above is deliberately scoped to
2321
+ // "a sibling method of the SAME class" (see its own header comment); this
2322
+ // is the same mechanism widened one hop further, to any function anywhere
2323
+ // that references `<taintedClassName>.<taintedField>` by name.
2324
+ //
2325
+ // Bounded by a cheap TEXT pre-filter rather than re-analyzing every
2326
+ // function in the project for every tainted class: only a function whose
2327
+ // OWN source text contains the literal `<ClassName>.` substring is a
2328
+ // candidate at all (Juliet's own per-test-case files reference at most a
2329
+ // couple of sibling classes each, so this stays cheap even across a
2330
+ // 17000+ function corpus). `opts.fileContents` is the same map already
2331
+ // threaded through for incremental-cache hashing; falls back to
2332
+ // no-op when absent (in-process callers that don't pass it), same
2333
+ // graceful-degradation convention as this file's other opt-in passes.
2334
+ const fileContents = opts.fileContents;
2335
+ if (cha && fileContents && classTaintedFields.size) {
2336
+ // `callersOfQid` (SARD_80_F1 W5.48) is hoisted above the same-class pass
2337
+ // now that pass ALSO consults it (W5.50) — reused here rather than
2338
+ // re-derived, same memoized map, zero extra cost.
2339
+ for (const [className, fields] of classTaintedFields) {
2340
+ if (Date.now() > deadlineMs) break;
2341
+ const needle = `${className}.`;
2342
+ for (const fn of fnList) {
2343
+ if (Date.now() > deadlineMs) break;
2344
+ if (cha.methodOwners.get(fn.qid) === className) continue; // already covered above
2345
+ const src = fileContents[fn.file];
2346
+ if (typeof src !== 'string' || !src.includes(needle)) continue;
2347
+
2348
+ // Call-string precision: narrow `fields` to only the ones fn should
2349
+ // actually be seeded with. A field drops out only when EVERY direct
2350
+ // caller of fn is a same-class method we POSITIVELY confirmed does
2351
+ // NOT taint that field (absent from `fieldWriterQids`) — never when
2352
+ // a caller is unresolved, ambiguous, or outside this class, since
2353
+ // those cases carry no evidence either way. This is deliberately a
2354
+ // narrow, local instance of call-string sensitivity (Juliet's own
2355
+ // "write-then-immediately-call-a-uniquely-named-sink" idiom, one
2356
+ // direct hop) rather than a general k>1 CFA — see this file's own
2357
+ // CLAUDE.md "Scope — what we still do NOT model" for why the general
2358
+ // form is out of scope. Fails closed (keeps the field seeded) the
2359
+ // instant precision can't be established, same direction every
2360
+ // other heuristic in this pass already takes.
2361
+ const callers = callersOfQid(fn.qid);
2362
+ const effectiveFields = new Set();
2363
+ for (const f of fields) {
2364
+ if (!callers || !callers.size) { effectiveFields.add(f); continue; }
2365
+ const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
2366
+ let keep = false;
2367
+ for (const callerQid of callers) {
2368
+ if (writers && writers.has(callerQid)) { keep = true; break; }
2369
+ if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
2370
+ }
2371
+ if (keep) effectiveFields.add(f);
2372
+ }
2373
+ if (!effectiveFields.size) continue; // every caller of fn provably never taints any of these fields
2374
+
2375
+ const qualifiedSeeds = new Set();
2376
+ for (const f of effectiveFields) qualifiedSeeds.add(`${className}.${f}`);
2377
+ if (summaryCache.has(fn.qid, qualifiedSeeds)) continue;
2378
+ const ctx = {
2379
+ _findings: [], _taintSources: [], _returnTainted: false,
2380
+ _stack: new Set(), deadlineMs,
2381
+ _summaryCache: summaryCache, _callGraph: callGraph,
2382
+ _mutatedParamsOut: new Set(),
2383
+ _currentFnQid: fn.qid,
2384
+ _cha: opts._cha,
2385
+ _pointsTo: opts._pointsTo,
2386
+ };
2387
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, qualifiedSeeds), ctx); } catch {}
2388
+ _collectFindings(fn, ctx._findings);
2389
+ summaryCache.set(fn.qid, qualifiedSeeds, {
2390
+ returnTainted: !!ctx._returnTainted,
2391
+ mutatedParams: ctx._mutatedParamsOut || new Set(),
2392
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
2393
+ taintedGlobals: new Set(),
2394
+ findings: ctx._findings,
2395
+ });
2396
+ }
1637
2397
  }
1638
2398
  }
1639
2399
 
@@ -1674,6 +2434,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1674
2434
  summaryCache.set(fn.qid, taintedEntry, {
1675
2435
  returnTainted: !!ctx._returnTainted,
1676
2436
  mutatedParams: ctx._mutatedParamsOut || new Set(),
2437
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
1677
2438
  taintedGlobals: new Set(),
1678
2439
  findings: ctx._findings,
1679
2440
  });
@@ -1745,6 +2506,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1745
2506
  return {
1746
2507
  returnTainted: !!inner._returnTainted,
1747
2508
  mutatedParams: inner._mutatedParamsOut || new Set(),
2509
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
1748
2510
  taintedGlobals: new Set(),
1749
2511
  findings: inner._findings,
1750
2512
  };
@@ -1789,13 +2551,26 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1789
2551
  // genuine caller is established.
1790
2552
  function _collectFindings(attributedFn, srcFindings) {
1791
2553
  for (const f of srcFindings) {
1792
- const key = `${f.sinkId}:${attributedFn.file}:${f.line}`;
2554
+ // SARD_80_F1 W4.C21/C22 — prefer the finding's OWN `file` (now stamped
2555
+ // at discovery time inside analyzeFunction, using the function that
2556
+ // ACTUALLY produced it) over `attributedFn.file`. `attributedFn` here
2557
+ // is whichever function the CALLER of `_collectFindings` happens to
2558
+ // be processing — correct for a finding discovered directly in that
2559
+ // function's own CFG walk, but WRONG for one merged in from a
2560
+ // callee's summary via `_mergeSummaryFindings` (most commonly a
2561
+ // cross-file interprocedural call): that finding genuinely belongs to
2562
+ // the callee's own file/line, which `f.file` now carries correctly.
2563
+ // The `attributedFn.file` fallback stays for defense-in-depth only —
2564
+ // every path pushing into `_findings` sets `file` explicitly as of
2565
+ // this fix.
2566
+ const file = f.file || attributedFn.file;
2567
+ const key = `${f.sinkId}:${file}:${f.line}`;
1793
2568
  if (seen.has(key)) continue;
1794
2569
  seen.add(key);
1795
2570
  const fn = attributedFn;
1796
2571
  all.push({
1797
- id: `ir-taint:${fn.file}:${f.line}:${f.sinkId}`,
1798
- file: fn.file,
2572
+ id: `ir-taint:${file}:${f.line}:${f.sinkId}`,
2573
+ file,
1799
2574
  line: f.line,
1800
2575
  vuln: f.vuln,
1801
2576
  severity: f.severity,