@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -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
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -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()`,
@@ -407,7 +547,15 @@ function exprTaint(expr, state, callContext) {
407
547
  // Collects the variable / access-path roots referenced by `expr` and returns
408
548
  // the _taintSources entries whose varName matches one of those roots. This
409
549
  // replaces "first source we ever saw" with "sources tied to this argument."
410
- function _collectExprVars(expr, out) {
550
+ // Exported (next-gen taint capability #5, SMT path-feasibility rebuild) so
551
+ // backward.js's `annotateBackwardSlices` can reuse the SAME free-variable
552
+ // extraction for a COMPOUND tainted-sink-argument expression (`'SELECT...'
553
+ // + id`, not a bare identifier) — `accessPathOf` alone returns null for
554
+ // anything but a pure ident/member chain, which previously made
555
+ // `annotateBackwardSlices` fall back to an unmatchable placeholder string
556
+ // for the overwhelmingly common "string-concatenation into a sink" shape,
557
+ // silently degrading the backward slice to just the bare sink node.
558
+ export function _collectExprVars(expr, out) {
411
559
  if (!expr) return;
412
560
  if (typeof expr === 'string') { out.add(expr); return; }
413
561
  if (expr.kind === 'ident' && expr.name) { out.add(expr.name); return; }
@@ -460,7 +608,16 @@ function _sourcesReachingExpr(expr, _state, taintSources) {
460
608
  }
461
609
 
462
610
  // Heuristic: does this expression read a registered source?
463
- function exprIsSource(expr) {
611
+ // `callContext` (SARD_80_F1 W4.C39, optional) lets the CALL-shaped branch
612
+ // resolve a receiver's CHA-declared type (mirroring `_receiverTypeFor`'s use
613
+ // for sinks) and pass it to `matchSource` so a `receiverTypeIn`-bearing
614
+ // source entry can confirm a match even when the receiver's own NAME fails
615
+ // (`dr.GetString(1)` — Juliet's idiomatic short SqlDataReader variable name,
616
+ // no "reader" substring). Every caller that has a callContext in scope now
617
+ // passes it through; callers that don't (none currently) simply get the
618
+ // prior, name-only behavior — `_receiverTypeFor` itself degrades to null
619
+ // when callContext/CHA is absent, so this is purely additive.
620
+ function exprIsSource(expr, callContext) {
464
621
  if (!expr) return null;
465
622
  if (expr.kind === 'member') {
466
623
  const hit = matchSource(expr, _currentFile);
@@ -470,7 +627,8 @@ function exprIsSource(expr) {
470
627
  // Previously only member reads were recognized, so Go's call-style sources
471
628
  // never tainted the assignment target. matchSource now resolves call sources.
472
629
  if (expr.kind === 'call') {
473
- const hit = matchSource(expr, _currentFile);
630
+ const receiverType = _receiverTypeFor(expr.callee, callContext);
631
+ const hit = matchSource(expr, _currentFile, receiverType);
474
632
  if (hit) return hit;
475
633
  }
476
634
  // Taint-recall PRD (80%): bare-identifier GLOBAL sources — PHP's $_GET/
@@ -492,7 +650,7 @@ function exprIsSource(expr) {
492
650
  if (hit) return hit;
493
651
  }
494
652
  if (expr.kind === 'member' && expr.object) {
495
- return exprIsSource(expr.object);
653
+ return exprIsSource(expr.object, callContext);
496
654
  }
497
655
  return null;
498
656
  }
@@ -608,6 +766,93 @@ function _calleeNames(callee) {
608
766
  return [];
609
767
  }
610
768
 
769
+ // SARD_80_F1 W3.2 — guard-predicate narrowing. `_calleeNames` only handles
770
+ // an OBJECT-shaped callee (Babel's `{kind:'ident'|'member',...}`); the five
771
+ // hand-rolled parsers (C#/Go/Kotlin/PHP/Ruby) plus Java/Python instead emit
772
+ // a flat, dot-joined STRING callee (per ir/CLAUDE.md) — `is_numeric` or
773
+ // `int.TryParse`, never a structured node. This normalizes both shapes to
774
+ // the same {full, bare-leaf} name pair `GUARD_PREDICATES` matches against.
775
+ function _bareCalleeNames(callee) {
776
+ if (typeof callee === 'string') {
777
+ const parts = callee.split('.');
778
+ return [callee, parts[parts.length - 1]];
779
+ }
780
+ return _calleeNames(callee);
781
+ }
782
+
783
+ // A hand-picked, deliberately CONSERVATIVE set of validating predicates
784
+ // that, when used as an if-statement's CONDITION and observed true, prove
785
+ // the checked variable cannot carry injection metacharacters on the TRUE
786
+ // branch specifically — never on the false branch (a guard that FAILS says
787
+ // nothing about the value's safety, so that edge, and any code after an
788
+ // if-with-no-else, keeps the original tainted state unchanged). Each entry
789
+ // names the callees that count as this guard and which argument holds the
790
+ // checked variable. Deliberately narrow for this first landing: a regex-
791
+ // based guard (`preg_match('/^\d+$/', $x)`) needs the PATTERN itself
792
+ // validated as a fully-anchored numeric literal to be sound, and an
793
+ // allow-list guard (`in_array($x, $allow, true)`) needs `$allow` resolved
794
+ // to a literal array — both are real, documented follow-ups, not attempted
795
+ // here to keep this landing's soundness easy to verify.
796
+ const GUARD_PREDICATES = [
797
+ // PHP: is_numeric/ctype_* — a string that PASSES is provably digits-only
798
+ // (or alnum), which cannot carry SQL/XPath/shell/LDAP metacharacters.
799
+ { names: ['is_numeric', 'ctype_digit', 'ctype_alnum'], argIndex: 0 },
800
+ // Java: Apache Commons / Spring's StringUtils.isNumeric(x).
801
+ { names: ['StringUtils.isNumeric', 'isNumeric'], argIndex: 0 },
802
+ // C#: `if (int.TryParse(x, out n))` and its numeric-type siblings. Each
803
+ // full dotted name is listed explicitly (not a bare 'TryParse' catch-all)
804
+ // to avoid over-matching an unrelated same-named helper method.
805
+ {
806
+ names: [
807
+ 'int.TryParse', 'Int32.TryParse', 'long.TryParse', 'Int64.TryParse',
808
+ 'double.TryParse', 'decimal.TryParse', 'float.TryParse',
809
+ 'uint.TryParse', 'short.TryParse', 'byte.TryParse',
810
+ ],
811
+ argIndex: 0,
812
+ },
813
+ ];
814
+
815
+ // Does `cond` (an if-statement's condition expression) recognizably guard a
816
+ // currently-tainted variable per `GUARD_PREDICATES`? Returns that variable's
817
+ // access path (to narrow on the true-branch edge only) or null.
818
+ function _guardNarrowsVar(cond, state) {
819
+ if (!cond || cond.kind !== 'call') return null;
820
+ const names = _bareCalleeNames(cond.callee);
821
+ for (const g of GUARD_PREDICATES) {
822
+ if (!g.names.some((n) => names.includes(n))) continue;
823
+ const arg = cond.args && cond.args[g.argIndex];
824
+ const path = arg && accessPathOf(arg);
825
+ if (path && isCoveredBy(state, path)) return path;
826
+ }
827
+ return null;
828
+ }
829
+
830
+ // Next-gen taint capability #2 — regex-validation guard narrowing (JS/TS).
831
+ // Recognizes the idiom `if (<regex-literal>.test(x)) { ... }` — `parser-js.js`
832
+ // lowers a regex literal's own `.test(...)` call to `{kind:'call',
833
+ // callee:{kind:'member', object:{kind:'literal', value:RegExp, isRegex:true},
834
+ // prop:'test'}, args:[x]}`. When the literal's pattern is
835
+ // `isSafeValidationPattern`-safe, an OBSERVED true return proves `x` cannot
836
+ // carry injection metacharacters, same soundness contract and TRUE-branch-
837
+ // only narrowing as `GUARD_PREDICATES`/`_guardNarrowsVar` above. Deliberately
838
+ // narrow for this landing, same "documented follow-up" precedent as that
839
+ // function's own header comment: a regex bound to a variable first
840
+ // (`const re = /.../; if (re.test(x))`) is not resolved back to its literal —
841
+ // this engine has no constant-propagation map for local variable bindings to
842
+ // reuse here, and adding one is a real, separate piece of work.
843
+ function _regexTestGuardNarrowsVar(cond, state) {
844
+ if (!cond || cond.kind !== 'call') return null;
845
+ const callee = cond.callee;
846
+ if (!callee || typeof callee !== 'object' || callee.kind !== 'member' || callee.prop !== 'test') return null;
847
+ const obj = callee.object;
848
+ if (!obj || obj.kind !== 'literal' || !obj.isRegex || !(obj.value instanceof RegExp)) return null;
849
+ if (!isSafeValidationPattern(obj.value)) return null;
850
+ const arg = cond.args && cond.args[0];
851
+ const path = arg && accessPathOf(arg);
852
+ if (path && isCoveredBy(state, path)) return path;
853
+ return null;
854
+ }
855
+
611
856
  function _unsanitizersInExprTree(expr, out) {
612
857
  if (!expr || typeof expr !== 'object') return;
613
858
  if (expr.kind === 'call') {
@@ -684,6 +929,131 @@ function _sanitizersForExpr(expr, callContext) {
684
929
  return out;
685
930
  }
686
931
 
932
+ // Sound taint-kill for TYPE-COERCION sanitizers (SARD_80_F1_SCANNER_PRD.md).
933
+ //
934
+ // Every OTHER sanitizer in this engine only ever DEMOTES (sanitizer-gate.js /
935
+ // proof-gate.js), never kills taint outright — the well-documented reason is
936
+ // that a sanitizer's effect is family-specific (`htmlspecialchars` does
937
+ // nothing for SQLi), so killing taint on ANY sanitizer call would silently
938
+ // drop a real vulnerability whenever an unrelated escaper happened to sit on
939
+ // the path. A catalog entry tagged `appliesTo: ['*']` is categorically
940
+ // different: it is registered specifically because it changes the VALUE'S
941
+ // TYPE (intval, filter_var(..., FILTER_VALIDATE_INT), parseInt, Integer.parseInt,
942
+ // strconv.Atoi, ...), not because it neutralizes one syntax. A value that has
943
+ // gone through a genuine type coercion cannot carry injection syntax for ANY
944
+ // string-based family at once — the same reasoning that forbids a blanket
945
+ // kill for family-specific sanitizers affirmatively REQUIRES one here, or
946
+ // every one of these 17+ catalog entries does nothing but cost confidence
947
+ // point.
948
+ //
949
+ // Scoped tightly: only fires when `expr` IS ITSELF a direct call whose own
950
+ // callee catalog-matches a universal sanitizer — never a guess, never
951
+ // something buried deeper in an unrelated expression tree (that's what
952
+ // `_sanitizersForExpr` already recall-preservingly handles for the DEMOTE
953
+ // path). `data = intval($_GET['x']);` fires this; `data = "x=" . intval($y);`
954
+ // (the coercion is only PART of the value) does not, and correctly still
955
+ // gets full taint tracking on the concatenation.
956
+ // Shared with _sinkFindingsForCall's identical sink-side check (below): a
957
+ // `requireLiteralArg` gate applies equally to a SANITIZER entry whose
958
+ // effectiveness depends on which literal was passed (`filter_var($x,
959
+ // FILTER_VALIDATE_INT)` genuinely coerces; `filter_var($x,
960
+ // FILTER_SANITIZE_STRING)` or a bare `filter_var($x)` does not — both are the
961
+ // SAME callee, distinguished only by this argument). Fails CLOSED: a missing
962
+ // or non-literal arg does not satisfy the requirement, same direction every
963
+ // other precision gate in this file takes when evidence is unavailable.
964
+ function _literalArgSatisfied(argExprs, requireLiteralArg) {
965
+ const { index, pattern } = requireLiteralArg;
966
+ const checkArg = (argExprs || [])[index];
967
+ return !!checkArg && checkArg.kind === 'literal' && new RegExp(pattern).test(String(checkArg.value));
968
+ }
969
+
970
+ // SARD_80_F1 W5.49 — PHP's `sprintf("...%d...", $tainted)` is a PER-ARGUMENT
971
+ // coercion sanitizer, distinct from `_isCoercionCall`'s whole-call model
972
+ // (intval/floatval/etc, where the ENTIRE call's return value is the coerced
973
+ // scalar). `sprintf` returns a TEMPLATE string; only the specific argument(s)
974
+ // bound to a purely-numeric conversion (%d/%u/%f/%x/%o/%b/...) are coerced —
975
+ // an argument bound to %s passes through raw and is NOT coerced, and %c
976
+ // converts an integer to a single arbitrary BYTE (an attacker-controlled
977
+ // code point can still be a quote/semicolon/backslash), so it is
978
+ // deliberately excluded from the safe set even though it looks numeric-ish.
979
+ //
980
+ // Confirmed as a REAL, previously-unrecognized PHP SQL-injection false-
981
+ // positive source via the public generator source this session already has
982
+ // an established exception for (stivalet/PHP-Vuln-test-suite-generator's own
983
+ // `bin/XML/construction.xml`): its CWE-89 "safe" construction sample is
984
+ // LITERALLY `$query = sprintf("SELECT * FROM student where id=%d",
985
+ // $tainted);` (`safety flawType="CWE_89_Injection" ... safe="1"`) — a type-
986
+ // coercion mitigation this engine had no mechanism for at all before this
987
+ // fix, confirmed via a direct, from-scratch `runScan()` reproduction of this
988
+ // exact real-generator shape (not corpus/gold access).
989
+ //
990
+ // Positional-vs-explicit argnum handling matches PHP's own sprintf spec
991
+ // (`%2$d` explicitly names argument 2, 1-based); an unrecognized/malformed
992
+ // conversion is conservatively treated as unsafe (not added to the safe
993
+ // set) rather than guessed.
994
+ const _SPRINTF_SAFE_NUMERIC_SPECS = new Set(['d', 'u', 'f', 'F', 'e', 'E', 'g', 'G', 'x', 'X', 'o', 'b']);
995
+ const _SPRINTF_CONVERSION_RE = /%(\d+\$)?[-+ 0']*(\d+)?(\.\d+)?([bcdeEfFgGosuxX%])/g;
996
+ function _sprintfSafeArgIndices(fmt) {
997
+ if (typeof fmt !== 'string') return null;
998
+ const safe = new Set();
999
+ let positional = 0;
1000
+ const re = new RegExp(_SPRINTF_CONVERSION_RE.source, 'g');
1001
+ let m;
1002
+ while ((m = re.exec(fmt))) {
1003
+ const spec = m[4];
1004
+ if (spec === '%') continue; // literal %% consumes no argument
1005
+ const idx = m[1] ? (parseInt(m[1], 10) - 1) : positional++;
1006
+ if (_SPRINTF_SAFE_NUMERIC_SPECS.has(spec)) safe.add(idx);
1007
+ }
1008
+ return safe;
1009
+ }
1010
+ // Numeric coercion defeats SYNTAX-ESCAPING injection (SQL/XPath/LDAP/
1011
+ // command/eval all require the attacker to inject a quote/semicolon/
1012
+ // metacharacter to break out of a string literal — impossible once the
1013
+ // value is pure digits) but does NOT defeat RESOURCE-SELECTION vulnerabilities
1014
+ // (file inclusion, path traversal): a purely numeric value can still select
1015
+ // an unintended resource among many (`pages/5.php` vs `pages/999.php`), so
1016
+ // coercion alone does not make it safe. Confirmed via the SAME public
1017
+ // generator source this fix already relies on: `bin/XML/construction.xml`
1018
+ // marks `$var = include(sprintf("pages/'%d'.php", $tainted));` UNSAFE
1019
+ // (`safe="0"`) even though the value is %d-coerced — found via a real,
1020
+ // reproducible regression (CWE-98 tp -1) during this fix's own real-corpus
1021
+ // verification. `_isCoercionCall`'s existing intval/floatval mechanism has
1022
+ // the IDENTICAL blind spot already (confirmed via direct reproduction:
1023
+ // `include("pages/" . intval($_GET['page']) . ".php")` already produces
1024
+ // zero findings before this change) — this fix does not introduce a new
1025
+ // risk class, but it's common enough via sprintf specifically to be worth
1026
+ // closing here: skip the coercion whenever the format string's STATIC
1027
+ // (non-conversion) text looks like a file-path template, a narrow,
1028
+ // principled heuristic (a `/` path separator, or a trailing `.ext`-shaped
1029
+ // suffix) rather than a benchmark-fitted exclusion.
1030
+ function _sprintfLooksLikeFilePath(fmt) {
1031
+ const stripped = fmt.replace(new RegExp(_SPRINTF_CONVERSION_RE.source, 'g'), '');
1032
+ if (stripped.includes('/')) return true;
1033
+ return /\.[A-Za-z0-9]{1,5}['")\]]*\s*$/.test(stripped);
1034
+ }
1035
+ // `argExprIndex` is the index within `expr.args` (0 = the format string
1036
+ // itself, 1 = the first value argument) — matches how callers already index
1037
+ // `expr.args` elsewhere in this file (e.g. `_literalArgSatisfied`).
1038
+ function _isSprintfSafeArg(expr, argExprIndex) {
1039
+ if (!expr || expr.kind !== 'call' || argExprIndex < 1) return false;
1040
+ const name = typeof expr.callee === 'string' ? expr.callee.split('.').pop() : null;
1041
+ if (name !== 'sprintf') return false;
1042
+ const fmtArg = (expr.args || [])[0];
1043
+ if (!fmtArg || fmtArg.kind !== 'literal' || typeof fmtArg.value !== 'string') return false;
1044
+ if (_sprintfLooksLikeFilePath(fmtArg.value)) return false;
1045
+ const safe = _sprintfSafeArgIndices(fmtArg.value);
1046
+ return !!safe && safe.has(argExprIndex - 1);
1047
+ }
1048
+
1049
+ function _isCoercionCall(expr) {
1050
+ if (!expr || expr.kind !== 'call' || !expr.callee) return false;
1051
+ const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
1052
+ if (!Array.isArray(cat)) return false;
1053
+ return cat.some(e => e.kind === 'sanitizer' && Array.isArray(e.appliesTo) && e.appliesTo.includes('*')
1054
+ && (!e.match || !e.match.requireLiteralArg || _literalArgSatisfied(expr.args, e.match.requireLiteralArg)));
1055
+ }
1056
+
687
1057
  // cat / argTaints: the result of _matchCallCatalog (computed by the caller,
688
1058
  // at whatever point in its case is appropriate for its own state-mutation
689
1059
  // ordering).
@@ -720,11 +1090,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
720
1090
  // is NOT satisfied, same direction proof-gate.js and every other
721
1091
  // precision annotator in this codebase take when evidence is
722
1092
  // 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
- }
1093
+ if (e.match && e.match.requireLiteralArg && !_literalArgSatisfied(argExprs, e.match.requireLiteralArg)) continue;
728
1094
  // `match.requireKeyword` — the KEYWORD-argument analogue of the
729
1095
  // positional gate above, for languages where the dangerous form is
730
1096
  // selected by a named argument rather than a positional literal.
@@ -897,7 +1263,7 @@ function step(node, stateIn, callContext) {
897
1263
  return { state, findings };
898
1264
 
899
1265
  case 'assign': {
900
- const src = exprIsSource(node.source);
1266
+ const src = exprIsSource(node.source, callContext);
901
1267
  const target = typeof node.target === 'string' ? node.target : null;
902
1268
  // Sink matching is additive to this case's existing source/target/taint
903
1269
  // handling below: an assignment's RHS can itself be a sink call (e.g.
@@ -959,11 +1325,6 @@ function step(node, stateIn, callContext) {
959
1325
  if (_unsan.size) _unByVar.set(target, _unsan);
960
1326
  else _unByVar.delete(target);
961
1327
  }
962
- // Constant propagation: track variables assigned from literals
963
- if (target && _activeConstantVars) {
964
- if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
965
- else _activeConstantVars.delete(target);
966
- }
967
1328
  let newState = state;
968
1329
  // Premortem #7: interprocedural return-taint via SummaryCache. If the
969
1330
  // RHS is a call to a known callee whose empty-entry-state summary says
@@ -1007,6 +1368,7 @@ function step(node, stateIn, callContext) {
1007
1368
  return {
1008
1369
  returnTainted: !!inner._returnTainted,
1009
1370
  mutatedParams: inner._mutatedParamsOut || new Set(),
1371
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
1010
1372
  taintedGlobals: new Set(),
1011
1373
  // Real findings from the callee's own body — e.g.
1012
1374
  // `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
@@ -1021,7 +1383,12 @@ function step(node, stateIn, callContext) {
1021
1383
  });
1022
1384
  }
1023
1385
  _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
1024
- if (sum && sum.returnTainted) {
1386
+ // Next-gen taint capability #3: `x = bad.getData()` — a single-
1387
+ // field getter's return is tainted whenever THIS call's own
1388
+ // receiver already carries that field, independent of `sum`
1389
+ // (a zero-param getter's entry-state summary can never see it).
1390
+ const _getterTainted = _calleeGetterFieldTainted(node.source.callee, newState, callContext);
1391
+ if ((sum && sum.returnTainted) || _getterTainted) {
1025
1392
  newState = _addPathAliasAware(newState, target, callContext);
1026
1393
  callContext._taintSources.push({
1027
1394
  varName: target,
@@ -1031,13 +1398,17 @@ function step(node, stateIn, callContext) {
1031
1398
  line: node.line,
1032
1399
  });
1033
1400
  }
1034
- // applyAtCallSite — mutated params propagate to caller arg-vars.
1035
- if (sum && sum.mutatedParams && sum.mutatedParams.size && paramNames.length) {
1036
- const mutated = callContext._summaryCache.applyAtCallSite(
1037
- sum, paramNames, callArgs, callerTainted);
1038
- for (const v of mutated.mutated) newState = addPath(newState, v);
1401
+ // applyAtCallSite — mutated params (and, next-gen capability #3,
1402
+ // the implicit `this` receiver's own mutated fields) propagate to
1403
+ // the caller's state.
1404
+ if (sum && ((sum.mutatedParams && sum.mutatedParams.size && paramNames.length) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
1405
+ const receiverPath = _receiverAccessPathOf(node.source.callee);
1406
+ const applied = callContext._summaryCache.applyAtCallSite(
1407
+ sum, paramNames, callArgs, callerTainted, receiverPath);
1408
+ for (const v of applied.mutated) newState = addPath(newState, v);
1409
+ for (const p of applied.mutatedThisPaths) newState = addPath(newState, p);
1039
1410
  }
1040
- if (sum && sum.returnTainted) return { state: newState, findings };
1411
+ if ((sum && sum.returnTainted) || _getterTainted) return { state: newState, findings };
1041
1412
  } else if (target && calleeName) {
1042
1413
  // Fallback: check builtin summaries for unresolved external calls
1043
1414
  const builtin = lookupBuiltinSummary(calleeName);
@@ -1078,7 +1449,12 @@ function step(node, stateIn, callContext) {
1078
1449
  }
1079
1450
  }
1080
1451
  }
1081
- if (src && target) {
1452
+ if (target && _isCoercionCall(node.source)) {
1453
+ // See _isCoercionCall's header comment: a real type coercion kills
1454
+ // taint outright, unconditionally of sink family — the one case in
1455
+ // this engine where a sanitizer does more than demote.
1456
+ newState = removePathAndDescendants(newState, target);
1457
+ } else if (src && target) {
1082
1458
  newState = _addPathAliasAware(newState, target, callContext);
1083
1459
  const sourcePath = accessPathOf(node.source);
1084
1460
  if (sourcePath) newState = addPath(newState, sourcePath);
@@ -1141,6 +1517,7 @@ function step(node, stateIn, callContext) {
1141
1517
  return {
1142
1518
  returnTainted: !!inner._returnTainted,
1143
1519
  mutatedParams: inner._mutatedParamsOut || new Set(),
1520
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
1144
1521
  taintedGlobals: new Set(),
1145
1522
  // See the sibling assign-call-site compute() above — same
1146
1523
  // fix, same reason: this callee's own findings were
@@ -1150,10 +1527,17 @@ function step(node, stateIn, callContext) {
1150
1527
  });
1151
1528
  }
1152
1529
  _mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
1153
- if (sum && sum.mutatedParams && sum.mutatedParams.size) {
1154
- const mutated = callContext._summaryCache.applyAtCallSite(
1155
- sum, paramNames, node.args || [], state);
1156
- for (const v of mutated.mutated) state = addPath(state, v);
1530
+ // Next-gen taint capability #3: the plain-statement-position
1531
+ // counterpart of the assign-from-call site above — `bad.setData(
1532
+ // tainted);` never assigns anything, but the receiver's OWN field
1533
+ // still needs tainting so a LATER `bad.getData()`/`bad.data` read
1534
+ // sees it.
1535
+ if (sum && ((sum.mutatedParams && sum.mutatedParams.size) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
1536
+ const receiverPath = _receiverAccessPathOf(node.callee);
1537
+ const applied = callContext._summaryCache.applyAtCallSite(
1538
+ sum, paramNames, node.args || [], state, receiverPath);
1539
+ for (const v of applied.mutated) state = addPath(state, v);
1540
+ for (const p of applied.mutatedThisPaths) state = addPath(state, p);
1157
1541
  }
1158
1542
  }
1159
1543
  }
@@ -1206,7 +1590,28 @@ function step(node, stateIn, callContext) {
1206
1590
  // target, so it reaches this rule instead of the assign path. The
1207
1591
  // matching read lowers to `bag.[]`, already covered by the tainted
1208
1592
  // receiver prefix.
1209
- const _MUTATORS = /^(?:push|unshift|splice|fill|copyWithin|set|add|append|extend|insert|update|addAll|putAll|put|__setitem__)$/;
1593
+ // Case-insensitive: C#/Java collection APIs use PascalCase (`List.Add`,
1594
+ // `Dictionary.Insert`, `Stack.Push`) while JS/Python/Ruby use lowerCamel
1595
+ // (`push`, `append`, `add`) — a case-sensitive regex silently matched
1596
+ // only the latter group, so C#'s `list.Add(data)` (Juliet flow variants
1597
+ // 71-74, container-element taint) never registered as a mutator at all.
1598
+ // Widening to case-insensitive only ADDS matches (recall-preserving);
1599
+ // no existing lowercase mutator name collides with an unrelated,
1600
+ // dangerous PascalCase method by accident (checked against the C#/Java
1601
+ // catalog sink list). `push_str`/`extend_from_slice` are Rust's
1602
+ // String/Vec appenders (`push`/`extend`/`insert` already cover the
1603
+ // other languages and apply to Rust unchanged).
1604
+ // SARD_80_F1 W2.3: `addElement` (java.util.Vector's pre-Collections-
1605
+ // Framework method, still idiomatic in older code — Juliet's own age)
1606
+ // and `offer`/`offerFirst`/`offerLast` (the Queue/Deque interface's
1607
+ // mutator, implemented by ArrayDeque/LinkedList/PriorityQueue) were
1608
+ // both missing — found via direct probes showing `Vector.addElement`/
1609
+ // `ArrayDeque.offer` silently failed to taint their receiver while
1610
+ // every sibling API (`Vector`'s modern `add`, `Stack.push`,
1611
+ // `Hashtable.put`, `Properties.setProperty`, `TreeMap.put`) already
1612
+ // worked, an inconsistency with no principled reason — all six are
1613
+ // the same "write one element into a collection" shape.
1614
+ 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;
1210
1615
  // Mutate the state Set IN PLACE (the binding is const; the call case
1211
1616
  // returns this same Set ref). Avoids touching the unrelated
1212
1617
  // mutated-param paths in this case, keeping the blast radius to
@@ -1216,9 +1621,28 @@ function step(node, stateIn, callContext) {
1216
1621
  && _MUTATORS.test(node.callee.prop)) {
1217
1622
  _recv = accessPathOf(node.callee.object);
1218
1623
  } else if (typeof _plainCallCalleeName === 'string') {
1219
- const _dot = _plainCallCalleeName.lastIndexOf('.');
1220
- if (_dot > 0 && _MUTATORS.test(_plainCallCalleeName.slice(_dot + 1))) {
1221
- _recv = _plainCallCalleeName.slice(0, _dot);
1624
+ // SARD_80_F1 W2.3 follow-up: a REPEATED-method fluent chain
1625
+ // (`sb.append(a).append(b).append(c)`, StringBuilder/StringBuffer's
1626
+ // own idiomatic multi-line-SQL-building shape) dot-joins to
1627
+ // "StringBuilder.append.append.append" via the hand-rolled/CST
1628
+ // chain-flattening convention every frontend in this codebase
1629
+ // uses (correct for a chain ending in a DIFFERENT terminal method,
1630
+ // e.g. `DocumentBuilderFactory.newInstance().newDocumentBuilder()`
1631
+ // — wrong here, since `.append()` returning `this` means every
1632
+ // segment is really the SAME receiver, not a new one). Naively
1633
+ // taking everything before the LAST dot recovers
1634
+ // "StringBuilder.append.append" as the "receiver" — an
1635
+ // ever-growing, never-matching key that silently drops the
1636
+ // mutator's own taint tracking for every chained-append call.
1637
+ // Strip ALL trailing segments that repeat the SAME mutator method
1638
+ // name first, so `X.append.append.append` and plain `X.append`
1639
+ // resolve to the identical, real receiver "X".
1640
+ const _segs = _plainCallCalleeName.split('.');
1641
+ let _end = _segs.length - 1;
1642
+ const _lastSeg = _segs[_end];
1643
+ if (_end > 0 && _MUTATORS.test(_lastSeg)) {
1644
+ while (_end > 0 && _segs[_end] === _lastSeg) _end--;
1645
+ if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
1222
1646
  }
1223
1647
  }
1224
1648
  if (_recv) state.add(_recv);
@@ -1283,7 +1707,19 @@ function step(node, stateIn, callContext) {
1283
1707
  case 'if': {
1284
1708
  // Path-feasibility lite: if the condition is a literal false / unreachable,
1285
1709
  // mark the node so the CFG walker can skip the consequent edge.
1286
- // For now we simply propagate state to both branches.
1710
+ // For now we simply propagate state to both branches — EXCEPT for a
1711
+ // recognized guard-predicate condition (SARD_80_F1 W3.2), where the
1712
+ // TRUE branch specifically gets a narrowed (un-tainted-on-the-guarded-
1713
+ // var) state via `succOverride`; the false branch and any code after
1714
+ // an if-with-no-else still see the original, unnarrowed `state`. Per
1715
+ // this codebase's established CFG convention (path-feasibility.js),
1716
+ // `succ[0]` is the THEN edge and `succ[1+]` are the ELSE/fall-through
1717
+ // edge(s).
1718
+ const narrowedVar = _guardNarrowsVar(node.cond, state) || _regexTestGuardNarrowsVar(node.cond, state);
1719
+ if (narrowedVar && node.succ && node.succ.length > 0) {
1720
+ const thenState = removePathAndDescendants(state, narrowedVar);
1721
+ return { state, findings, succOverride: new Map([[node.succ[0], thenState]]) };
1722
+ }
1287
1723
  return { state, findings };
1288
1724
  }
1289
1725
 
@@ -1349,12 +1785,28 @@ function analyzeFunction(fn, entryState, callContext) {
1349
1785
  // No-op for the key unless AGENTIC_SECURITY_KCFA_CALLSTRING=1. Restored below.
1350
1786
  const _prevCallerCtx = (callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext)
1351
1787
  ? callContext._summaryCache.setCallerContext(fn.qid) : undefined;
1788
+ // SARD_80_F1 W4.C21/C22 — `_currentFile` (module-level, set just below) was
1789
+ // never saved/restored around a NESTED analyzeFunction call the way
1790
+ // `_prevCallerCtx` already is on the line above. A cross-FILE call site
1791
+ // (e.g. Bad() in file A calling a summary-computed callee in file B)
1792
+ // recurses into analyzeFunction(calleeFn, ..., inner) to compute the
1793
+ // callee's summary — that inner call sets `_currentFile = calleeFn.file`
1794
+ // (file B) and, until this fix, never set it back, so every subsequent
1795
+ // catalog lookup (`matchSinkOrSanitizer`/`matchSource`/`classOfVar`) in
1796
+ // the OUTER function's own remaining CFG nodes ran against the WRONG
1797
+ // file's language scoping for the rest of that outer function's walk.
1798
+ // Confirmed via a same-file-vs-cross-file A/B reproduction of a real
1799
+ // corpus false positive (see W4.C20/C21/C22) that isolated this leak as
1800
+ // one of two compounding defects. Saving/restoring here — the exact
1801
+ // pattern `_prevCallerCtx` already uses — makes `_currentFile` correctly
1802
+ // scoped to this function's own analysis regardless of what any callee's
1803
+ // nested summary computation does to it in between.
1804
+ const _prevFile = _currentFile;
1352
1805
  const work = [];
1353
1806
  const inStates = new Map();
1354
1807
  const outStates = new Map();
1355
1808
  inStates.set(fn.cfg.entry, new Set(entryState));
1356
1809
  work.push(fn.cfg.entry);
1357
- _activeConstantVars = new Map();
1358
1810
  _currentFile = fn.file || null;
1359
1811
  // v0.70 #2 — points-to context for the step() transfer. Setting it here
1360
1812
  // (instead of plumbing through step's signature) keeps the worklist loop
@@ -1375,15 +1827,32 @@ function analyzeFunction(fn, entryState, callContext) {
1375
1827
  const node = nodes[nid];
1376
1828
  if (!node) continue;
1377
1829
  const incoming = inStates.get(nid) || new Set();
1378
- const { state: out, findings } = step(node, incoming, callContext);
1379
- callContext._findings.push(...findings.map(f => ({ ...f, _funcQid: fn.qid })));
1830
+ const { state: out, findings, succOverride } = step(node, incoming, callContext);
1831
+ // SARD_80_F1 W4.C21/C22: stamp the file THIS finding actually belongs to
1832
+ // (fn's own file) at the moment of discovery — see `_collectFindings`'s
1833
+ // header comment for why this matters. `step()`/`_sinkFindingsForCall`
1834
+ // never set `file` themselves (only `line`), so every finding reaching
1835
+ // this line was, until now, implicitly reattributed later to whichever
1836
+ // function `_collectFindings` happened to be called with — correct only
1837
+ // when that's the SAME function, which a cross-file interprocedural
1838
+ // merge (`_mergeSummaryFindings`) is specifically the case where it is
1839
+ // NOT.
1840
+ callContext._findings.push(...findings.map(f => ({ ...f, _funcQid: fn.qid, file: f.file || fn.file })));
1380
1841
  const prevOut = outStates.get(nid);
1381
1842
  const merged = mergeStates(prevOut, out);
1382
1843
  if (!prevOut || !stateEq(prevOut, merged)) {
1383
1844
  outStates.set(nid, merged);
1384
1845
  for (const s of (node.succ || [])) {
1846
+ // SARD_80_F1 W3.2 — guard-predicate narrowing (case 'if' above): a
1847
+ // specific successor edge (the guard's TRUE branch) can carry a
1848
+ // NARROWER state than every other edge out of this node. `out` (not
1849
+ // `merged`) is what `succOverride` was derived from, and `out` is a
1850
+ // pure function of `incoming`, so gating this on `merged`'s own
1851
+ // convergence check above is sound — an unchanged `merged` means an
1852
+ // unchanged `incoming` means an unchanged override too.
1853
+ const edgeState = (succOverride && succOverride.has(s)) ? succOverride.get(s) : merged;
1385
1854
  const succIn = inStates.get(s);
1386
- const newIn = mergeStates(succIn, merged);
1855
+ const newIn = mergeStates(succIn, edgeState);
1387
1856
  if (!succIn || !stateEq(succIn, newIn)) {
1388
1857
  inStates.set(s, newIn);
1389
1858
  work.push(s);
@@ -1426,7 +1895,7 @@ function analyzeFunction(fn, entryState, callContext) {
1426
1895
  reportedNids.add(nid);
1427
1896
  callContext._findings.push({
1428
1897
  ...createImplicitFinding(node, conditionLabel),
1429
- _funcQid: fn.qid, sinkId: sink.id,
1898
+ _funcQid: fn.qid, sinkId: sink.id, file: fn.file,
1430
1899
  cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
1431
1900
  });
1432
1901
  };
@@ -1463,6 +1932,13 @@ function analyzeFunction(fn, entryState, callContext) {
1463
1932
  }
1464
1933
 
1465
1934
  const exit = outStates.get(fn.cfg.exit) || new Set();
1935
+ // Real cross-method field taint (SARD_80_F1_SCANNER_PRD.md, Juliet flow
1936
+ // variants 45/65-68): the runTaintEngine pre-pass needs the RAW exit state
1937
+ // (every tainted access path, not just the ones matching a declared param)
1938
+ // to tell whether THIS function's own body genuinely taints a class field —
1939
+ // see the pre-pass loop below for why this must be the real exit state, not
1940
+ // a guess.
1941
+ if (callContext) callContext._exitState = exit;
1466
1942
  // v0.66 — record which params are tainted at function exit so the
1467
1943
  // caller's applyAtCallSite can propagate that mutated taint back. We
1468
1944
  // intersect the exit-state with the function's declared params (only
@@ -1473,10 +1949,26 @@ function analyzeFunction(fn, entryState, callContext) {
1473
1949
  if (isCoveredBy(exit, p)) callContext._mutatedParamsOut.add(p);
1474
1950
  }
1475
1951
  }
1952
+ // Next-gen taint capability #3 — treat `this` as an implicit mutable
1953
+ // parameter, mirroring the block just above exactly: extract every field
1954
+ // name this function's own exit state taints on its receiver (any of the
1955
+ // three `this` spellings this codebase's frontends use), so the caller can
1956
+ // propagate that mutation onto ITS OWN receiver variable's access path
1957
+ // (`bad.data`), not a class-wide bucket. See this file's own header
1958
+ // comment above `_thisFieldGetterName` for the full rationale.
1959
+ if (callContext) {
1960
+ if (!callContext._mutatedThisFieldsOut) callContext._mutatedThisFieldsOut = new Set();
1961
+ for (const p of exit) {
1962
+ const m = _THIS_FIELD_PREFIX_RE.exec(p);
1963
+ if (m) callContext._mutatedThisFieldsOut.add(m[1]);
1964
+ }
1965
+ }
1476
1966
  // R2: restore the caller context for the enclosing function's analysis.
1477
1967
  if (_prevCallerCtx !== undefined && callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext) {
1478
1968
  callContext._summaryCache.setCallerContext(_prevCallerCtx);
1479
1969
  }
1970
+ // W4.C21/C22: restore _currentFile — see the save at this function's start.
1971
+ _currentFile = _prevFile;
1480
1972
  return exit;
1481
1973
  }
1482
1974
 
@@ -1533,12 +2025,40 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1533
2025
  const fnList = [...callGraph.functions.values()].sort((a, b) =>
1534
2026
  a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
1535
2027
  );
2028
+ // PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
2029
+ // silently `break`s once `n > fnLimit` (default 5000), which for a single
2030
+ // large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
2031
+ // functions) means roughly 70% of the corpus is never analyzed at all, no
2032
+ // matter how correct the resolution logic is for the functions it DOES
2033
+ // reach. This was completely invisible to every existing truncation
2034
+ // signal (wall-clock budget, per-file timeout/skip counts) — found while
2035
+ // investigating why a real, verified class-resolution fix (W1) showed
2036
+ // almost no aggregate SARD recall movement despite fixing individual
2037
+ // fixtures in isolation. Surfaced the same way the wall-clock budget
2038
+ // already is: a single info finding, not a return-shape change (this
2039
+ // function's return type — a flat array — is a wide, load-bearing
2040
+ // contract; changing it would touch every caller).
2041
+ if (fnList.length > fnLimit) {
2042
+ all.push({
2043
+ id: `ir-taint-fn-limit:${fnList[0] ? fnList[0].qid.split('::')[0] : ''}`,
2044
+ file: '(deep-engine)', line: 0,
2045
+ vuln: `IR-TAINT deep mode analyzed only ${fnLimit}/${fnList.length} functions (AGENTIC_SECURITY_DEEP_FN_LIMIT) — results are incomplete`,
2046
+ severity: 'info',
2047
+ parser: 'IR-TAINT',
2048
+ confidence: 0.5,
2049
+ });
2050
+ }
1536
2051
  // Pre-pass + fixed-point: compute empty-entry-state summaries for every
1537
2052
  // function, then re-run the pre-pass until the summary cache stabilizes
1538
2053
  // (capped at MAX_FP_ITERS so recursion and chains converge without
1539
2054
  // unbounded blowup). v0.66 — the inner ctx now records mutatedParams
1540
2055
  // via _mutatedParamsOut so cross-function param mutation propagates.
1541
2056
  const MAX_FP_ITERS = 3;
2057
+ // qid -> this function's own exit-state (every tainted access path at
2058
+ // return, from the LAST pre-pass iteration only — see the class-field
2059
+ // cross-taint pass below for why this must be a real, already-observed
2060
+ // exit state rather than a re-derived guess).
2061
+ const exitStateByQid = new Map();
1542
2062
  for (let it = 0; it < MAX_FP_ITERS; it++) {
1543
2063
  if (Date.now() > deadlineMs) break;
1544
2064
  // Tracks whether this iteration actually changed any cached summary's
@@ -1569,9 +2089,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1569
2089
  // (sinkId, file, line), so re-discovering the same empty-entry finding
1570
2090
  // multiple times collapses to one reported finding, never a duplicate.
1571
2091
  _collectFindings(fn, ctx._findings);
2092
+ exitStateByQid.set(fn.qid, ctx._exitState || new Set());
1572
2093
  const next = {
1573
2094
  returnTainted: !!ctx._returnTainted,
1574
2095
  mutatedParams: ctx._mutatedParamsOut || new Set(),
2096
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
1575
2097
  taintedGlobals: new Set(),
1576
2098
  findings: [],
1577
2099
  };
@@ -1602,26 +2124,143 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1602
2124
  // `changedThisIter` tracks actual value changes instead.
1603
2125
  if (!changedThisIter) break;
1604
2126
  }
1605
- // Class-field cross-taint pass: when a method writes tainted data to _this_.field,
1606
- // re-analyze other methods of the same class with those fields in the entry state.
1607
- const classTaintedFields = new Map();
1608
- for (const fn of fnList) {
1609
- if (Date.now() > deadlineMs) break;
1610
- const sum = summaryCache.get(fn.qid, new Set());
1611
- if (!sum || !sum.mutatedParams) continue;
1612
- for (const p of sum.mutatedParams) {
1613
- if (typeof p === 'string' && p.startsWith('_this_.')) {
1614
- const classPrefix = fn.qid.split('::')[0] + '::';
1615
- if (!classTaintedFields.has(classPrefix)) classTaintedFields.set(classPrefix, new Set());
1616
- classTaintedFields.get(classPrefix).add(p);
2127
+ // Class-field cross-taint pass (SARD_80_F1_SCANNER_PRD.md — Juliet flow
2128
+ // variants 45/65-68: a method writes a tainted value to a static or
2129
+ // instance field and a SIBLING method of the same class reads it into a
2130
+ // sink). The mechanism this replaced kept only the JS/Babel `_this_.field`
2131
+ // mutated-PARAM shape — but `_mutatedParamsOut` (above) only ever
2132
+ // intersects a function's own EXIT state against its declared PARAMS, and
2133
+ // a field is never a param, so that map was permanently empty for every
2134
+ // hand-rolled parser (C#, Java, PHP, Go, Ruby, Kotlin) — confirmed by
2135
+ // direct probing, not assumed: none of `sf = data;`, `this.inst = data;`
2136
+ // ever populated it, for any language.
2137
+ //
2138
+ // Real fix: `callContext._exitState` (stamped just above) is EVERY tainted
2139
+ // access path at a function's exit, not just the ones matching a param
2140
+ // name. Cross-reference it against the class's DECLARED FIELD NAMES (from
2141
+ // `ir.classes[].fields`, via `buildClassHierarchy` — only populated by a
2142
+ // parser that emits it; classes with no field list simply contribute
2143
+ // nothing here, which is a missed opportunity, never a wrong edge) to ask
2144
+ // "did this method's own body, taken alone, genuinely taint one of its
2145
+ // class's fields?" — a REAL, observed fact from the empty-entry pre-pass
2146
+ // above, not a guess. Both the bare form (`sf`) and the `this.`-qualified
2147
+ // form (`this.inst`, `_this_.inst`) are checked, since different parsers
2148
+ // lower a field write differently.
2149
+ const cha = opts._cha;
2150
+ const classTaintedFields = new Map(); // className -> Set(bareFieldName)
2151
+ // SARD_80_F1 W5.48 — per-WRITER record alongside the per-class union just
2152
+ // above: className -> field -> Set(qid of a method whose OWN exit state
2153
+ // taints this field). `classTaintedFields` only ever answers "is this
2154
+ // field EVER tainted somewhere in the class" (unioned across every
2155
+ // method), which is what made the cross-class pass below seed a reader
2156
+ // like `goodG2BSink()` as tainted even though its own real caller chain
2157
+ // (`goodG2B()`) only ever writes this field a LITERAL — Juliet's Flow
2158
+ // Variant 65-68 idiom (`_68a`/`_68b`) has bad()/goodG2B()/goodB2G() each
2159
+ // write the SAME static field then immediately call their OWN uniquely-
2160
+ // named sink (badSink/goodG2BSink/goodB2GSink), so "ever tainted
2161
+ // anywhere in the class" is true (via bad()) but says nothing about
2162
+ // which SPECIFIC caller reaches a SPECIFIC reader. This map lets the
2163
+ // cross-class pass ask the narrower, call-string-sensitive question
2164
+ // instead: "is EVERY discoverable direct caller of this reader a
2165
+ // confirmed NON-tainting writer of this field" — see its own use site.
2166
+ const fieldWriterQids = new Map();
2167
+ if (cha && cha.methodOwners && cha.classes) {
2168
+ for (const fn of fnList) {
2169
+ if (Date.now() > deadlineMs) break;
2170
+ const className = cha.methodOwners.get(fn.qid);
2171
+ if (!className) continue;
2172
+ const cls = cha.classes.get(className);
2173
+ const declaredFields = cls && cls.fields;
2174
+ if (!declaredFields || !declaredFields.size) continue;
2175
+ const exitState = exitStateByQid.get(fn.qid);
2176
+ if (!exitState || !exitState.size) continue;
2177
+ for (const field of declaredFields) {
2178
+ // SARD_80_F1 W5.10 — PHP's own `$this` reference keeps its `$`
2179
+ // sigil in every access path (`$this.field`, never bare `this.` —
2180
+ // parser-php.js's own `->`-to-`.` normalization, W5.9), a fourth
2181
+ // spelling alongside JS's `_this_.`/C#'s and Java's bare `this.`.
2182
+ //
2183
+ // SARD_80_F1 W5.17 — `isCoveredBy` deliberately never propagates UP
2184
+ // (its own doc: `set={"x.y.z"}` does NOT cover "x.y"), which is
2185
+ // correct for ordinary field reads but wrong here: an ARRAY-INDEXED
2186
+ // field write (`$this->input[1] = $_GET[...]`) taints the access
2187
+ // path `this.input.1`, not the bare `this.input`/`input` this loop
2188
+ // queries — so a getter method returning `$this->input[1]` (the
2189
+ // PHP-Vuln-test-suite-generator's own "object/Array" input-
2190
+ // indirection sample, confirmed via direct reproduction, not corpus
2191
+ // access) was invisible to this whole class-field pass, while the
2192
+ // scalar-field form one line below (`$this->input = $_GET[...]`)
2193
+ // already worked. `pathIsCoveredByPrefix(entry, field)` checks the
2194
+ // OPPOSITE direction — does `entry` sit AT OR BELOW `field` — which
2195
+ // is exactly "did the constructor taint SOME sub-path of this
2196
+ // field", independent of which specific index or nesting depth.
2197
+ const hasTaintedSubpath = (prefix) => { for (const p of exitState) { if (pathIsCoveredByPrefix(p, prefix)) return true; } return false; };
2198
+ if (isCoveredBy(exitState, field) || isCoveredBy(exitState, `this.${field}`) || isCoveredBy(exitState, `_this_.${field}`) || isCoveredBy(exitState, `$this.${field}`)
2199
+ || hasTaintedSubpath(field) || hasTaintedSubpath(`this.${field}`) || hasTaintedSubpath(`_this_.${field}`) || hasTaintedSubpath(`$this.${field}`)) {
2200
+ if (!classTaintedFields.has(className)) classTaintedFields.set(className, new Set());
2201
+ classTaintedFields.get(className).add(field);
2202
+ if (!fieldWriterQids.has(className)) fieldWriterQids.set(className, new Map());
2203
+ const fw = fieldWriterQids.get(className);
2204
+ if (!fw.has(field)) fw.set(field, new Set());
2205
+ fw.get(field).add(fn.qid);
2206
+ }
1617
2207
  }
1618
2208
  }
1619
2209
  }
1620
- for (const [classPrefix, fields] of classTaintedFields) {
2210
+ // SARD_80_F1 W5.50 — same reverse-call-graph lookup the cross-class pass
2211
+ // below uses (`callGraph.callersOf`, already built once by `buildCallGraph`
2212
+ // — see that pass's own header comment for why this reads the existing
2213
+ // index rather than re-deriving one), hoisted up so the SAME-class pass
2214
+ // immediately below can ALSO apply call-string precision. This pass had
2215
+ // the IDENTICAL over-approximation `classTaintedFields`'s own header
2216
+ // comment describes for the cross-class case — a sibling method reached
2217
+ // ONLY via a confirmed non-tainting writer was still blanket-seeded as
2218
+ // tainted, just scoped to siblings of the SAME class instead of a
2219
+ // different one. Genuinely a rarer shape here (per this pass's OWN
2220
+ // original comment, most sibling methods have no discoverable caller/
2221
+ // callee edge to each other at all — that remains true and still fails
2222
+ // closed correctly), but real whenever a sibling reader is directly
2223
+ // called from a KNOWN writer method (mirrors the cross-class idiom, just
2224
+ // without the class boundary).
2225
+ const _callerQidSetCache = new Map(); // fn.qid -> Set(callerQid)|null, memoized
2226
+ const callersOfQid = (qid) => {
2227
+ if (_callerQidSetCache.has(qid)) return _callerQidSetCache.get(qid);
2228
+ const edges = callGraph && callGraph.callersOf && callGraph.callersOf.get(qid);
2229
+ const set = edges && edges.length ? new Set(edges.map((e) => e.caller).filter(Boolean)) : null;
2230
+ _callerQidSetCache.set(qid, set);
2231
+ return set;
2232
+ };
2233
+ for (const [className, fields] of classTaintedFields) {
1621
2234
  if (Date.now() > deadlineMs) break;
1622
2235
  for (const fn of fnList) {
1623
- if (!fn.qid.startsWith(classPrefix)) continue;
1624
- if (summaryCache.has(fn.qid, fields)) continue;
2236
+ if (cha.methodOwners.get(fn.qid) !== className) continue;
2237
+
2238
+ // Call-string precision (mirrors the cross-class pass below): narrow
2239
+ // `fields` to only the ones fn's own discoverable callers can
2240
+ // actually supply as tainted. Fails closed (keeps the field seeded)
2241
+ // whenever fn has no discoverable caller, a caller outside this
2242
+ // class, or any caller not positively confirmed as a non-tainting
2243
+ // writer — this can ONLY ever narrow what was already being seeded,
2244
+ // never widen it, so it cannot introduce a new false negative beyond
2245
+ // what the pre-existing blanket behavior already accepted.
2246
+ const callers = callersOfQid(fn.qid);
2247
+ let seedFields = fields;
2248
+ if (callers && callers.size) {
2249
+ const effectiveFields = new Set();
2250
+ for (const f of fields) {
2251
+ const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
2252
+ let keep = false;
2253
+ for (const callerQid of callers) {
2254
+ if (writers && writers.has(callerQid)) { keep = true; break; }
2255
+ if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
2256
+ }
2257
+ if (keep) effectiveFields.add(f);
2258
+ }
2259
+ seedFields = effectiveFields;
2260
+ }
2261
+ if (!seedFields.size) continue; // every discoverable caller of fn provably never taints any of these fields
2262
+
2263
+ if (summaryCache.has(fn.qid, seedFields)) continue;
1625
2264
  const ctx = {
1626
2265
  _findings: [], _taintSources: [], _returnTainted: false,
1627
2266
  _stack: new Set(), deadlineMs,
@@ -1631,21 +2270,138 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1631
2270
  _cha: opts._cha,
1632
2271
  _pointsTo: opts._pointsTo,
1633
2272
  };
1634
- try { analyzeFunction(fn, _unionAnnotationTaint(fn, fields), ctx); } catch {}
1635
- // `findings` carries the REAL findings from this probe (was hardcoded
1636
- // `[]`, discarding them) — but this pass is speculative (every field
1637
- // in `fields` is assumed simultaneously tainted; nothing here confirms
1638
- // this exact method is ever reached with that state), so it must not
1639
- // report them itself. They ride on the cached summary and are only
1640
- // surfaced by _mergeSummaryFindings when a REAL call site (assign,
1641
- // plain-call, or higher-order) actually consults this qid+entry —
1642
- // at that point a genuine reachable caller has been established.
1643
- summaryCache.set(fn.qid, fields, {
2273
+ // Seed every spelling of every tainted field so a reading method sees
2274
+ // the taint regardless of which form it uses to reference the field
2275
+ // — mirrors the write-side check above, `$this.` (PHP, W5.10) included.
2276
+ const seeded = new Set();
2277
+ for (const f of seedFields) { seeded.add(f); seeded.add(`this.${f}`); seeded.add(`_this_.${f}`); seeded.add(`$this.${f}`); }
2278
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, seeded), ctx); } catch {}
2279
+ // Unlike the k=2 pass below, this is NOT speculative: `seedFields` was
2280
+ // derived from a REAL, already-observed write elsewhere in this exact
2281
+ // class (the loop above), so a sink this re-analysis finds is a real,
2282
+ // reachable flow — report it directly instead of waiting for a call
2283
+ // site that will never come (most sibling methods still have no
2284
+ // caller/callee relationship for the taint engine to discover; the
2285
+ // call-string narrowing just above only ever removes fields when one
2286
+ // WAS discoverable).
2287
+ _collectFindings(fn, ctx._findings);
2288
+ // `findings` still rides on the cached summary too, for the (rarer)
2289
+ // case where a real caller ALSO consults this exact qid+entry pair via
2290
+ // _mergeSummaryFindings — dedup in _collectFindings makes this safe.
2291
+ const fieldSummary = {
1644
2292
  returnTainted: !!ctx._returnTainted,
1645
2293
  mutatedParams: ctx._mutatedParamsOut || new Set(),
2294
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
1646
2295
  taintedGlobals: new Set(),
1647
2296
  findings: ctx._findings,
1648
- });
2297
+ };
2298
+ summaryCache.set(fn.qid, seedFields, fieldSummary);
2299
+ // SARD_80_F1 W3.x — the getter+return interprocedural-composition gap
2300
+ // documented at PHP W5.10: an EXTERNAL, no-argument caller of this
2301
+ // method (`$tainted = $obj->getInput();`) never consults the
2302
+ // `fields`-keyed entry above — it looks up the ORDINARY empty-context
2303
+ // summary (`summaryCache.get(fn.qid, new Set())`), which the earlier
2304
+ // fixed-point pre-pass computed WITHOUT knowing the field was tainted
2305
+ // (that fact is only established by the class-scan just above, which
2306
+ // runs once, after the pre-pass). `entryContext = new Set()` means
2307
+ // "no CALLER-supplied parameter is tainted" — true here regardless of
2308
+ // how many params `fn` declares, since this re-analysis seeded ONLY
2309
+ // the class's own already-tainted fields, no parameters — so this
2310
+ // result is exactly the correct value for that key, not a guess.
2311
+ // Safe to overwrite unconditionally: this model has no un-taint step,
2312
+ // so a field-seeded re-analysis can only find a superset of whatever
2313
+ // the plain empty-context pre-pass already found. The final per-
2314
+ // function reporting pass (the `for (const fn of fnList)` loop that
2315
+ // does the REAL, non-speculative analysis of every function,
2316
+ // including whatever caller invokes this getter) runs strictly AFTER
2317
+ // this whole class-field block, so it is guaranteed to see the
2318
+ // corrected summary — no fixed-point re-iteration needed for THIS
2319
+ // specific external-caller shape.
2320
+ summaryCache.set(fn.qid, new Set(), fieldSummary);
2321
+ }
2322
+ }
2323
+
2324
+ // Cross-class static-field taint (SARD_80_F1_EXECUTION_PRD.md — Juliet flow
2325
+ // variant 68: a driver class writes a tainted value to its OWN static
2326
+ // field, then a COMPLETELY DIFFERENT class reads it back via a qualified
2327
+ // reference — `String data = OtherClass.field;` — with no parameter
2328
+ // passed at all. The same-class pass just above is deliberately scoped to
2329
+ // "a sibling method of the SAME class" (see its own header comment); this
2330
+ // is the same mechanism widened one hop further, to any function anywhere
2331
+ // that references `<taintedClassName>.<taintedField>` by name.
2332
+ //
2333
+ // Bounded by a cheap TEXT pre-filter rather than re-analyzing every
2334
+ // function in the project for every tainted class: only a function whose
2335
+ // OWN source text contains the literal `<ClassName>.` substring is a
2336
+ // candidate at all (Juliet's own per-test-case files reference at most a
2337
+ // couple of sibling classes each, so this stays cheap even across a
2338
+ // 17000+ function corpus). `opts.fileContents` is the same map already
2339
+ // threaded through for incremental-cache hashing; falls back to
2340
+ // no-op when absent (in-process callers that don't pass it), same
2341
+ // graceful-degradation convention as this file's other opt-in passes.
2342
+ const fileContents = opts.fileContents;
2343
+ if (cha && fileContents && classTaintedFields.size) {
2344
+ // `callersOfQid` (SARD_80_F1 W5.48) is hoisted above the same-class pass
2345
+ // now that pass ALSO consults it (W5.50) — reused here rather than
2346
+ // re-derived, same memoized map, zero extra cost.
2347
+ for (const [className, fields] of classTaintedFields) {
2348
+ if (Date.now() > deadlineMs) break;
2349
+ const needle = `${className}.`;
2350
+ for (const fn of fnList) {
2351
+ if (Date.now() > deadlineMs) break;
2352
+ if (cha.methodOwners.get(fn.qid) === className) continue; // already covered above
2353
+ const src = fileContents[fn.file];
2354
+ if (typeof src !== 'string' || !src.includes(needle)) continue;
2355
+
2356
+ // Call-string precision: narrow `fields` to only the ones fn should
2357
+ // actually be seeded with. A field drops out only when EVERY direct
2358
+ // caller of fn is a same-class method we POSITIVELY confirmed does
2359
+ // NOT taint that field (absent from `fieldWriterQids`) — never when
2360
+ // a caller is unresolved, ambiguous, or outside this class, since
2361
+ // those cases carry no evidence either way. This is deliberately a
2362
+ // narrow, local instance of call-string sensitivity (Juliet's own
2363
+ // "write-then-immediately-call-a-uniquely-named-sink" idiom, one
2364
+ // direct hop) rather than a general k>1 CFA — see this file's own
2365
+ // CLAUDE.md "Scope — what we still do NOT model" for why the general
2366
+ // form is out of scope. Fails closed (keeps the field seeded) the
2367
+ // instant precision can't be established, same direction every
2368
+ // other heuristic in this pass already takes.
2369
+ const callers = callersOfQid(fn.qid);
2370
+ const effectiveFields = new Set();
2371
+ for (const f of fields) {
2372
+ if (!callers || !callers.size) { effectiveFields.add(f); continue; }
2373
+ const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
2374
+ let keep = false;
2375
+ for (const callerQid of callers) {
2376
+ if (writers && writers.has(callerQid)) { keep = true; break; }
2377
+ if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
2378
+ }
2379
+ if (keep) effectiveFields.add(f);
2380
+ }
2381
+ if (!effectiveFields.size) continue; // every caller of fn provably never taints any of these fields
2382
+
2383
+ const qualifiedSeeds = new Set();
2384
+ for (const f of effectiveFields) qualifiedSeeds.add(`${className}.${f}`);
2385
+ if (summaryCache.has(fn.qid, qualifiedSeeds)) continue;
2386
+ const ctx = {
2387
+ _findings: [], _taintSources: [], _returnTainted: false,
2388
+ _stack: new Set(), deadlineMs,
2389
+ _summaryCache: summaryCache, _callGraph: callGraph,
2390
+ _mutatedParamsOut: new Set(),
2391
+ _currentFnQid: fn.qid,
2392
+ _cha: opts._cha,
2393
+ _pointsTo: opts._pointsTo,
2394
+ };
2395
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, qualifiedSeeds), ctx); } catch {}
2396
+ _collectFindings(fn, ctx._findings);
2397
+ summaryCache.set(fn.qid, qualifiedSeeds, {
2398
+ returnTainted: !!ctx._returnTainted,
2399
+ mutatedParams: ctx._mutatedParamsOut || new Set(),
2400
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
2401
+ taintedGlobals: new Set(),
2402
+ findings: ctx._findings,
2403
+ });
2404
+ }
1649
2405
  }
1650
2406
  }
1651
2407
 
@@ -1686,6 +2442,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1686
2442
  summaryCache.set(fn.qid, taintedEntry, {
1687
2443
  returnTainted: !!ctx._returnTainted,
1688
2444
  mutatedParams: ctx._mutatedParamsOut || new Set(),
2445
+ mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
1689
2446
  taintedGlobals: new Set(),
1690
2447
  findings: ctx._findings,
1691
2448
  });
@@ -1757,6 +2514,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1757
2514
  return {
1758
2515
  returnTainted: !!inner._returnTainted,
1759
2516
  mutatedParams: inner._mutatedParamsOut || new Set(),
2517
+ mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
1760
2518
  taintedGlobals: new Set(),
1761
2519
  findings: inner._findings,
1762
2520
  };
@@ -1801,13 +2559,26 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1801
2559
  // genuine caller is established.
1802
2560
  function _collectFindings(attributedFn, srcFindings) {
1803
2561
  for (const f of srcFindings) {
1804
- const key = `${f.sinkId}:${attributedFn.file}:${f.line}`;
2562
+ // SARD_80_F1 W4.C21/C22 — prefer the finding's OWN `file` (now stamped
2563
+ // at discovery time inside analyzeFunction, using the function that
2564
+ // ACTUALLY produced it) over `attributedFn.file`. `attributedFn` here
2565
+ // is whichever function the CALLER of `_collectFindings` happens to
2566
+ // be processing — correct for a finding discovered directly in that
2567
+ // function's own CFG walk, but WRONG for one merged in from a
2568
+ // callee's summary via `_mergeSummaryFindings` (most commonly a
2569
+ // cross-file interprocedural call): that finding genuinely belongs to
2570
+ // the callee's own file/line, which `f.file` now carries correctly.
2571
+ // The `attributedFn.file` fallback stays for defense-in-depth only —
2572
+ // every path pushing into `_findings` sets `file` explicitly as of
2573
+ // this fix.
2574
+ const file = f.file || attributedFn.file;
2575
+ const key = `${f.sinkId}:${file}:${f.line}`;
1805
2576
  if (seen.has(key)) continue;
1806
2577
  seen.add(key);
1807
2578
  const fn = attributedFn;
1808
2579
  all.push({
1809
- id: `ir-taint:${fn.file}:${f.line}:${f.sinkId}`,
1810
- file: fn.file,
2580
+ id: `ir-taint:${file}:${f.line}:${f.sinkId}`,
2581
+ file,
1811
2582
  line: f.line,
1812
2583
  vuln: f.vuln,
1813
2584
  severity: f.severity,
@@ -1839,6 +2610,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1839
2610
  // backward-slice annotation was permanently a no-op regardless of
1840
2611
  // AGENTIC_SECURITY_BACKWARD_SLICE.
1841
2612
  ...(f._funcQid ? { _funcQid: f._funcQid } : {}),
2613
+ // argIndex: which sink argument is the tainted one (a number, or the
2614
+ // literal 'all'/'rhs') — set upstream at the finding's creation site
2615
+ // but, exactly like `_funcQid`/`callee` above, silently dropped by
2616
+ // this same allowlist before backward.js's `annotateBackwardSlices`
2617
+ // ever saw it. Without it, `annotateBackwardSlices` falls back to
2618
+ // the placeholder string `arg[undefined]` for EVERY finding (it can
2619
+ // never be a real access path, since no source ever names a
2620
+ // variable that literally), so `sliceBackward` always started and
2621
+ // ended at the bare sink node with no source/sanitize steps at all
2622
+ // — the backward-slice pass was a structural no-op for real findings
2623
+ // regardless of AGENTIC_SECURITY_BACKWARD_SLICE, discovered while
2624
+ // rebuilding the SMT path-feasibility mechanism (next-gen taint
2625
+ // capability #5) to depend on a REAL slice for a sound proof.
2626
+ ...(f.argIndex !== undefined ? { argIndex: f.argIndex } : {}),
1842
2627
  // callee: kept as plain `callee` (not underscore-prefixed) to match
1843
2628
  // backward.js's own contract, which reads `f.callee` on both real and
1844
2629
  // fake-fixture findings throughout its module and test suite. It is