@clear-capabilities/agentic-security-scanner 0.137.1 → 0.139.1

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 (44) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/dist/113.index.js +2 -2
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/435.index.js +29 -1
  6. package/dist/526.index.js +2 -2
  7. package/dist/637.index.js +1 -1
  8. package/dist/agentic-security.mjs +14 -14
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +20 -7
  11. package/src/dataflow/CLAUDE.md +30 -0
  12. package/src/dataflow/catalog.js +512 -14
  13. package/src/dataflow/engine.js +275 -27
  14. package/src/dataflow/summaries.js +30 -5
  15. package/src/engine.js +512 -120
  16. package/src/ir/CLAUDE.md +20 -5
  17. package/src/ir/balanced-call.js +11 -1
  18. package/src/ir/callgraph.js +34 -0
  19. package/src/ir/parser-cs.js +55 -6
  20. package/src/ir/parser-go.js +106 -2
  21. package/src/ir/parser-java.js +111 -10
  22. package/src/ir/parser-js.js +40 -0
  23. package/src/ir/parser-kt.js +194 -10
  24. package/src/ir/parser-php.js +108 -6
  25. package/src/ir/parser-py.helper.py +199 -10
  26. package/src/ir/parser-rb.js +405 -31
  27. package/src/mcp/tools.js +29 -1
  28. package/src/posture/accuracy-scorecard.js +103 -0
  29. package/src/runScan.js +5 -2
  30. package/src/sast/CLAUDE.md +1 -1
  31. package/src/sast/_auth-signals.js +141 -0
  32. package/src/sast/_comment-strip.js +80 -13
  33. package/src/sast/codegen-sink.js +110 -0
  34. package/src/sast/convention-deviation.js +235 -0
  35. package/src/sast/fastapi-hardening.js +45 -6
  36. package/src/sast/file-upload.js +29 -1
  37. package/src/sast/ownership-authz.js +245 -0
  38. package/src/sast/php.js +12 -2
  39. package/src/sast/rate-limit.js +2 -0
  40. package/src/sast/rbac-consistency.js +1 -1
  41. package/src/sast/redirect-toctou.js +167 -0
  42. package/src/sast/resource-exhaustion.js +217 -0
  43. package/src/sast/sibling-guard.js +176 -0
  44. package/src/sast/zip-slip.js +53 -2
@@ -65,6 +65,27 @@ import { resolveMethod, classOfVar } from '../ir/class-hierarchy.js';
65
65
  // AGENTIC_SECURITY_POINTS_TO=1).
66
66
  function _addPathAliasAware(state, path, callContext) {
67
67
  let s = addPath(state, path);
68
+ // T3.3 — a write through an UNKNOWN key taints the whole container.
69
+ //
70
+ // `parser-js.js` lowers a computed write whose key is not a literal
71
+ // (`bag[k] = tainted`) to the access path `bag.*`, and — as that parser's own
72
+ // comment states — `'*'` is a LITERAL property name here, not a
73
+ // match-anything token. `isCoveredBy` only propagates DOWN from a prefix, so
74
+ // `bag.*` covers nothing: a later read of `bag.anything` resolves to a
75
+ // different path entirely and the taint is unreachable from every correctly
76
+ // computed read.
77
+ //
78
+ // Since the key is statically unknown, the write may have landed on ANY
79
+ // property, so the sound abstraction is the container itself. Widening to the
80
+ // base is what makes the value observable again, and it is the same
81
+ // over-approximate direction the lattice already takes at branch joins.
82
+ //
83
+ // Scoped deliberately to a TRAILING `.*`. An interior wildcard
84
+ // (`bag.*.inner`) still describes a definite final property and does not
85
+ // justify tainting the root.
86
+ if (typeof path === 'string' && path.length > 2 && path.endsWith('.*')) {
87
+ s = addPath(s, path.slice(0, -2));
88
+ }
68
89
  const pt = callContext && callContext._pointsTo;
69
90
  const fnQid = callContext && callContext._currentFnQid;
70
91
  if (!pt || !fnQid || typeof path !== 'string') return s;
@@ -268,7 +289,7 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
268
289
  const { qid, fn } = target;
269
290
  const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
270
291
  const entry = paramNames.length
271
- ? entryStateFromCall(paramNames, argExprs || [], state)
292
+ ? entryStateFromCall(paramNames, argExprs || [], state, (a) => exprTaint(a, state, callContext))
272
293
  : new Set();
273
294
  let sum = callContext._summaryCache.get(qid, entry);
274
295
  if (!sum && fn && fn.cfg) {
@@ -294,8 +315,32 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
294
315
  return !!(sum && sum.returnTainted);
295
316
  }
296
317
 
318
+ // Taint-recall PRD (80%): is a call's own RECEIVER tainted? Handles the two
319
+ // distinct shapes this codebase's IR frontends use for a call's `callee`:
320
+ // parser-js.js (Babel) emits a structured {kind:'member', object, prop}
321
+ // node, so the receiver is `callee.object`, checked via a normal exprTaint
322
+ // recursion. parser-cs.js/parser-go.js/parser-kt.js/parser-php.js/
323
+ // parser-rb.js/parser-java.js/parser-py.js instead emit a flat, dot-joined
324
+ // STRING callee ("it.toString", "user.getName") — there is no sub-expression
325
+ // to recurse into, so the receiver is recovered by slicing off the string
326
+ // after its LAST '.' and checking that prefix directly against the access-
327
+ // path lattice via `isCoveredBy` (handles both a bare var and a longer
328
+ // chain, e.g. "req.session.user.toString" -> receiver "req.session.user").
329
+ function _calleeReceiverTainted(callee, state, callContext) {
330
+ if (!callee) return false;
331
+ if (typeof callee === 'string') {
332
+ const idx = callee.lastIndexOf('.');
333
+ if (idx <= 0) return false;
334
+ return isCoveredBy(state, callee.slice(0, idx));
335
+ }
336
+ if (callee.kind === 'member' && callee.object) {
337
+ return exprTaint(callee.object, state, callContext);
338
+ }
339
+ return false;
340
+ }
341
+
297
342
  function exprTaint(expr, state, callContext) {
298
- if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
343
+ if (expr && (expr.kind === 'member' || expr.kind === 'call' || expr.kind === 'ident') && exprIsSource(expr)) return true;
299
344
  if (!expr) return false;
300
345
  // Constant propagation: variables assigned from literals are never tainted
301
346
  if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
@@ -314,12 +359,44 @@ function exprTaint(expr, state, callContext) {
314
359
  case 'object': return (expr.props || []).some(p => exprTaint(p.value, state, callContext));
315
360
  case 'array': return (expr.elements || []).some(e => exprTaint(e, state, callContext));
316
361
  case 'call': {
317
- // The call's own arguments (unchanged from before) OR — PRD R10 — the
318
- // resolved callee's own return-taint summary. Args-tainted is checked
319
- // FIRST and short-circuits: it's the cheap, no-resolve-attempt case and
320
- // was already correct.
321
- if ((expr.args || []).some(a => exprTaint(a, state, callContext))) return true;
322
- return _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
362
+ // The call's own arguments OR — PRD R10 — the resolved callee's own
363
+ // return-taint summary. Taint-recall PRD (80%): this used to
364
+ // short-circuit on args-tainted and SKIP _nestedCallReturnTainted
365
+ // entirely but that call's real job isn't just the boolean it
366
+ // returns, it's the _mergeSummaryFindings side effect that surfaces
367
+ // the CALLEE's own internal sink findings (e.g. `return
368
+ // helper(taintedArg)` where `helper`'s body itself contains
369
+ // `sink(param)`). Short-circuiting on "args are tainted" (the
370
+ // overwhelmingly common interprocedural shape — a tainted value IS
371
+ // usually passed as an argument) silently dropped exactly the
372
+ // findings this mechanism exists to surface. Confirmed via direct
373
+ // fixture debugging (PHP: `$name = get_name(); return
374
+ // find_user($doc, $name);` produced zero findings until this fix,
375
+ // even though find_user's own body has a cataloged sink fed by
376
+ // $name) — not language-specific, this is generic exprTaint logic.
377
+ // Both sides always evaluated now (no short-circuit either
378
+ // direction) so the merge always runs when a call expression is
379
+ // visited; _resolveCalleeForSummary + SummaryCache make repeat
380
+ // resolution/computation for the same (qid, entry-state) cheap.
381
+ const argsTainted = (expr.args || []).some(a => exprTaint(a, state, callContext));
382
+ const nestedTainted = _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
383
+ // Taint-recall PRD (80%): a call's RECEIVER can itself be tainted
384
+ // independent of its arguments — `tainted.toString()`, `tainted.trim()`,
385
+ // `it.getBytes()` — and neither argsTainted (there are no/unrelated
386
+ // args) nor nestedTainted (that resolves a FUNCTION-NAME callee via the
387
+ // call graph; a bare method name like "toString" never resolves) sees
388
+ // it. Confirmed via `req.getBytes()` (Java @RequestBody InputStream) and
389
+ // the general shape this codebase's five hand-rolled parsers all
390
+ // produce for a chained call: `expr.callee` there is a flat DOT-JOINED
391
+ // STRING ("it.toString"), not a structured {kind:'member', object,
392
+ // prop} node like parser-js.js emits — so the two shapes need distinct
393
+ // handling. Deliberately receiver-taint-propagates for ANY call on a
394
+ // tainted receiver (not just no-arg ones): `tainted.replace(a,b)`'s
395
+ // receiver taint matters exactly as much as `tainted.toString()`'s, and
396
+ // this is already OR'd with argsTainted so it only WIDENS recall,
397
+ // matching this engine's recall-preserving precedent everywhere else.
398
+ const receiverTainted = _calleeReceiverTainted(expr.callee, state, callContext);
399
+ return argsTainted || nestedTainted || receiverTainted;
323
400
  }
324
401
  case 'unknown': return false;
325
402
  default: return false;
@@ -396,6 +473,24 @@ function exprIsSource(expr) {
396
473
  const hit = matchSource(expr, _currentFile);
397
474
  if (hit) return hit;
398
475
  }
476
+ // Taint-recall PRD (80%): bare-identifier GLOBAL sources — PHP's $_GET/
477
+ // $_POST/$_REQUEST/$_SERVER, Ruby's params/cookies/session/ENV, JS's
478
+ // location — referenced DIRECTLY (not first assigned to a local, not a
479
+ // member-read off them) were completely unreachable here. matchSource
480
+ // itself has always supported a bare-ident branch (GLOBAL_INDEX lookup by
481
+ // the identifier's own name), but neither this function nor exprTaint's
482
+ // early check ever called it for expr.kind === 'ident' — only
483
+ // 'member'/'call'. So `header("X: " . $_GET)` (or any shape where a
484
+ // global is read directly rather than through member/subscript access)
485
+ // never tainted anything, no matter how the value was actually used.
486
+ // Confirmed via a real corpus fixture whose PHP parser also mis-splits
487
+ // `$_GET["trace"]`'s string literal, leaving a bare `$_GET` ident in a
488
+ // template's parts — this fix makes that residue still count, which is
489
+ // the right behavior regardless of that separate mis-split bug.
490
+ if (expr.kind === 'ident') {
491
+ const hit = matchSource(expr, _currentFile);
492
+ if (hit) return hit;
493
+ }
399
494
  if (expr.kind === 'member' && expr.object) {
400
495
  return exprIsSource(expr.object);
401
496
  }
@@ -404,7 +499,6 @@ function exprIsSource(expr) {
404
499
 
405
500
  const _SQL_KEYWORDS = /\b(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|UNION|WHERE|FROM|JOIN|INTO|VALUES|SET|EXEC|EXECUTE)\b/i;
406
501
  const _HTML_META = /[<>'"&]|innerHTML|outerHTML|document\.write/;
407
- const _SHELL_META = /[;|`$(){}]|&&|\|\|/;
408
502
 
409
503
  function _literalPartsOfExpr(expr) {
410
504
  if (!expr) return [];
@@ -421,7 +515,25 @@ function literalSkeletonMatchesFamily(expr, cwe) {
421
515
  if (!joined.trim()) return true;
422
516
  if (cwe === 'CWE-89' || cwe === 'CWE-943') return _SQL_KEYWORDS.test(joined);
423
517
  if (cwe === 'CWE-79') return _HTML_META.test(joined);
424
- if (cwe === 'CWE-78') return _SHELL_META.test(joined);
518
+ // Taint-recall PRD (80%): CWE-78 (command injection) deliberately does NOT
519
+ // require the STATIC portion of the concat to contain a shell
520
+ // metacharacter. The SQL/HTML checks above make sense because their sinks
521
+ // (a generic `.raw()`/`.query()` call, an `innerHTML` assignment) can
522
+ // legitimately carry non-SQL, non-HTML strings — the skeleton check filters
523
+ // those out. Command-injection's sink catalog has no such ambiguity: every
524
+ // CWE-78 entry (`os.system`, `subprocess.call`, `Runtime.exec`,
525
+ // `child_process.exec`, …) is an unambiguous shell-execution API, so a
526
+ // tainted argument reaching one is inherently suspicious regardless of what
527
+ // the static prefix looks like. Requiring a metacharacter in the STATIC
528
+ // portion gets the vulnerability backwards: the textbook shape is
529
+ // `exec("ping " + host)` — the attacker supplies the metacharacter via the
530
+ // TAINTED value, not the template, so the static prefix is legitimately
531
+ // metacharacter-free in the overwhelmingly common case. Confirmed this was
532
+ // blocking CVE-2016-10033-java-cmdi's exact real-world shape; found while
533
+ // investigating this PRD's Java chained-call fix and left deferred until
534
+ // now, when the corpus's Tier 2/3 audit showed it plausibly gates a large
535
+ // fraction of the whole command-injection family, not just one entry.
536
+ if (cwe === 'CWE-78') return true;
425
537
  return true;
426
538
  }
427
539
 
@@ -503,7 +615,7 @@ function _sanitizersForExpr(expr, callContext) {
503
615
  // expression (via callContext._taintSources, not the state Set itself).
504
616
  // line: source line to attach to any emitted finding.
505
617
  // Returns { findings }.
506
- function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callContext, line) {
618
+ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callContext, line, callKwargs) {
507
619
  const findings = [];
508
620
  if (cat) {
509
621
  for (const e of cat) {
@@ -516,6 +628,57 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
516
628
  const taintedArgExpr = (argExprs || [])[taintedArgIdx];
517
629
  // String content analysis: skip if literal skeleton doesn't match injection family
518
630
  if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
631
+ // Taint-recall PRD (80%): `match.requireLiteralArg` — a precision
632
+ // gate for sinks whose danger depends on a SIBLING argument's
633
+ // literal value, not the tainted one. Go's `exec.Command(path,
634
+ // args...)` is only actually shell-interpreted (so a metacharacter
635
+ // in a tainted arg is dangerous) when `path` is "/bin/sh"/"bash"/
636
+ // "sh" and the next arg is "-c" — the array-execve form
637
+ // (`exec.Command("ping", "-c", "1", host)`) never invokes a shell
638
+ // at all, so `host` becomes one opaque argv element regardless of
639
+ // its content and is SAFE even when tainted. `argIndex: 'all'`
640
+ // alone can't express this (it only asks "is ANY arg tainted", not
641
+ // "is the INVOCATION FORM itself dangerous"). Fails CLOSED on
642
+ // precision: if the checked arg isn't a literal at all (a
643
+ // variable — we can't statically know its value), the requirement
644
+ // is NOT satisfied, same direction proof-gate.js and every other
645
+ // precision annotator in this codebase take when evidence is
646
+ // simply unavailable rather than affirmatively clean.
647
+ if (e.match && e.match.requireLiteralArg) {
648
+ const { index, pattern } = e.match.requireLiteralArg;
649
+ const checkArg = (argExprs || [])[index];
650
+ if (!checkArg || checkArg.kind !== 'literal' || !new RegExp(pattern).test(String(checkArg.value))) continue;
651
+ }
652
+ // `match.requireKeyword` — the KEYWORD-argument analogue of the
653
+ // positional gate above, for languages where the dangerous form is
654
+ // selected by a named argument rather than a positional literal.
655
+ //
656
+ // Python's `subprocess.run(cmd, shell=True)` is command injection;
657
+ // `subprocess.run([cmd], capture_output=True)` is an argv-array
658
+ // execve that cannot be, however tainted `cmd` is. requireLiteralArg
659
+ // cannot express the difference — it matches a POSITIONAL literal,
660
+ // and shell is a keyword. Measured cost of not having this: enabling
661
+ // the argparse CLI source surfaced 15 findings across 9 of this
662
+ // repository's own hand-reviewed scripts, on argv-array calls,
663
+ // labelled "shell=True".
664
+ //
665
+ // Fails CLOSED exactly like requireLiteralArg: a keyword that is
666
+ // absent, or present but not a statically-known literal matching the
667
+ // pattern, does NOT satisfy the requirement.
668
+ if (e.match && e.match.requireKeyword) {
669
+ const { name, pattern } = e.match.requireKeyword;
670
+ // A `**opts` splat means the keyword set is not enumerable: we
671
+ // cannot prove `shell` is absent, so we must not suppress. Failing
672
+ // closed here would be a FALSE NEGATIVE on a genuinely exploitable
673
+ // call — bench/cve-replay/deep/py-interproc-cmdi-shape exists
674
+ // precisely to pin that case (`subprocess.call(cmd, **SHELL_OPTS)`)
675
+ // and caught this the first time the gate was written without it.
676
+ const enumerable = !(callKwargs && callKwargs['**']);
677
+ if (enumerable) {
678
+ const kw = callKwargs && callKwargs[name];
679
+ if (!kw || kw.kind !== 'literal' || !new RegExp(pattern).test(String(kw.value))) continue;
680
+ }
681
+ }
519
682
  // Premortem #10: attribute the source for THIS sink to the
520
683
  // source(s) that taint the actual argument expression — not the
521
684
  // first source the worklist happened to record. We walk the
@@ -552,6 +715,36 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
552
715
  return { findings };
553
716
  }
554
717
 
718
+ // Taint-recall PRD (80%): a sink call NESTED inside another call's own
719
+ // argument (`render(File.read(tainted))`, Rails' idiomatic wrap-and-render
720
+ // pattern) was invisible to sink-matching entirely. `_sinkFindingsForCall`
721
+ // only ever checks the CFG NODE'S OWN callee/args — a node representing
722
+ // `render(...)` never independently examines whether one of `render`'s OWN
723
+ // arguments is itself a sink-shaped call expression. Confirmed via a real
724
+ // corpus fixture: `File.read` is a correctly-cataloged sink and `name` a
725
+ // correctly-cataloged source, but nested one level inside `render`'s own
726
+ // arg list, neither `case 'call'` nor `case 'assign'` ever checked it — the
727
+ // exact "sink nested inside another call's own argument" limitation
728
+ // documented in this PRD's Ruby/C#/Go/Kotlin corpus notes, now closed for
729
+ // the general case rather than left as a permanent gap. Recall-preserving:
730
+ // walks every arg looking for a nested call expression, checks each
731
+ // independently against the catalog, and recurses further (an arg's arg
732
+ // can itself be a call) — bounded by JS's own call-stack depth on
733
+ // pathologically deep expressions, no explicit cap needed at this level of
734
+ // real-world nesting. Deliberately does NOT re-check the TOP-LEVEL call
735
+ // itself (the caller already did that via its own _matchCallCatalog +
736
+ // _sinkFindingsForCall), so this never duplicates a finding.
737
+ function _nestedSinkFindings(argExprs, state, callContext, line) {
738
+ const findings = [];
739
+ for (const arg of (argExprs || [])) {
740
+ if (!arg || arg.kind !== 'call') continue;
741
+ const { cat, argTaints } = _matchCallCatalog(arg.callee, arg.args, state, callContext);
742
+ findings.push(..._sinkFindingsForCall(arg.callee, arg.args, cat, argTaints, state, callContext, line).findings);
743
+ findings.push(..._nestedSinkFindings(arg.args, state, callContext, line));
744
+ }
745
+ return findings;
746
+ }
747
+
555
748
  // PRD R13(a): finding shape for a member-write sink match (el.innerHTML =
556
749
  // tainted). Distinct from _sinkFindingsForCall because there is no call
557
750
  // argument list to index into — the "argument" of interest is the whole
@@ -639,7 +832,8 @@ function step(node, stateIn, callContext) {
639
832
  _matchCallCatalog(node.source.callee, node.source.args, state, callContext);
640
833
  findings.push(..._sinkFindingsForCall(
641
834
  node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
642
- state, callContext, node.line).findings);
835
+ state, callContext, node.line, node.source.kwargs).findings);
836
+ findings.push(..._nestedSinkFindings(node.source.args, state, callContext, node.line));
643
837
  }
644
838
  // PRD R13(a): the assignment TARGET can itself be a sink shape
645
839
  // (el.innerHTML = tainted) — additive to the RHS-call-sink check
@@ -694,7 +888,7 @@ function step(node, stateIn, callContext) {
694
888
  const callArgs = (node.source.args || []);
695
889
  const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
696
890
  const entry = paramNames.length
697
- ? entryStateFromCall(paramNames, callArgs, callerTainted)
891
+ ? entryStateFromCall(paramNames, callArgs, callerTainted, (a) => exprTaint(a, callerTainted, callContext))
698
892
  : new Set();
699
893
  let sum = callContext._summaryCache.get(qid, entry);
700
894
  if (!sum && fn && fn.cfg) {
@@ -825,7 +1019,7 @@ function step(node, stateIn, callContext) {
825
1019
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
826
1020
  const paramNames = fn.params;
827
1021
  const entry = paramNames.length
828
- ? entryStateFromCall(paramNames, node.args || [], state)
1022
+ ? entryStateFromCall(paramNames, node.args || [], state, (a) => exprTaint(a, state, callContext))
829
1023
  : new Set();
830
1024
  let sum = callContext._summaryCache.get(qid, entry);
831
1025
  // FR-SEM-2: context-sensitive lazy compute at the plain-call site,
@@ -882,21 +1076,56 @@ function step(node, stateIn, callContext) {
882
1076
  });
883
1077
  }
884
1078
  }
885
- // R4 (PRD §5): array-element taint. A mutating array method (push/unshift/
886
- // splice/fill/copyWithin) called with a tainted argument taints the
887
- // receiver array; an index read (a[0] → access path "a.0") is then covered
888
- // by the receiver prefix. Object-property taint already flows via the
889
- // access-path lattice — this closes the array case.
890
- if (node.callee && node.callee.kind === 'member' && typeof node.callee.prop === 'string'
891
- && /^(?:push|unshift|splice|fill|copyWithin)$/.test(node.callee.prop)
892
- && Array.isArray(argTaints) && argTaints.some(Boolean)) {
1079
+ // R4 (PRD §5) + T3.3: collection-element taint. A mutating collection
1080
+ // method called with a tainted argument taints the receiver; an index or
1081
+ // key read (`a[0]` → access path "a.0", `m.get(k)` receiver taint via
1082
+ // _calleeReceiverTainted) is then covered by the receiver prefix.
1083
+ // Object-property taint already flows via the access-path lattice — this
1084
+ // closes the container case.
1085
+ //
1086
+ // R4 covered JS arrays only, in both of its dimensions, and T3.3 widens
1087
+ // each:
1088
+ // - METHODS: keyed collections mutate through `set`/`add`, and the
1089
+ // non-JS containers through `append`/`extend`/`insert`/`update`/
1090
+ // `addAll`/`putAll`/`put`. These are writes exactly as `push` is.
1091
+ // - CALLEE SHAPE: the `callee.kind === 'member'` test only ever matched
1092
+ // `parser-js.js` (Babel), the one frontend emitting a structured
1093
+ // callee. The seven hand-rolled parsers emit a flat dot-joined STRING
1094
+ // ("items.append"), so Python/Ruby/PHP/Go/Java/C#/Kotlin containers
1095
+ // never matched at all — measured: python's `items.append(tainted)`
1096
+ // produced no IR-TAINT finding, and only a PY-SAST pattern rule
1097
+ // caught the sink, which masked the taint-layer miss entirely. Same
1098
+ // frontend duality `_calleeReceiverTainted` documents and handles.
1099
+ //
1100
+ // `add`/`put` are deliberately NOT extended to bare-name calls: the
1101
+ // receiver is what gets tainted, so a call with no receiver has nothing
1102
+ // to taint and is skipped by both branches below.
1103
+ if (Array.isArray(argTaints) && argTaints.some(Boolean)) {
1104
+ // `__setitem__` is not a method anyone writes: `parser-py.js` lowers a
1105
+ // subscript assignment (`bag['k'] = v`, `arr[i] = v`) to a CALL node
1106
+ // with that flat callee rather than to an assign node with a member
1107
+ // target, so it reaches this rule instead of the assign path. The
1108
+ // matching read lowers to `bag.[]`, already covered by the tainted
1109
+ // receiver prefix.
1110
+ const _MUTATORS = /^(?:push|unshift|splice|fill|copyWithin|set|add|append|extend|insert|update|addAll|putAll|put|__setitem__)$/;
893
1111
  // Mutate the state Set IN PLACE (the binding is const; the call case
894
- // returns this same Set ref). Avoids touching the unrelated mutated-param
895
- // paths in this case, keeping the blast radius to array-element taint only.
896
- const _arrRecv = accessPathOf(node.callee.object);
897
- if (_arrRecv) state.add(_arrRecv);
1112
+ // returns this same Set ref). Avoids touching the unrelated
1113
+ // mutated-param paths in this case, keeping the blast radius to
1114
+ // collection-element taint only.
1115
+ let _recv = null;
1116
+ if (node.callee && node.callee.kind === 'member' && typeof node.callee.prop === 'string'
1117
+ && _MUTATORS.test(node.callee.prop)) {
1118
+ _recv = accessPathOf(node.callee.object);
1119
+ } else if (typeof _plainCallCalleeName === 'string') {
1120
+ const _dot = _plainCallCalleeName.lastIndexOf('.');
1121
+ if (_dot > 0 && _MUTATORS.test(_plainCallCalleeName.slice(_dot + 1))) {
1122
+ _recv = _plainCallCalleeName.slice(0, _dot);
1123
+ }
1124
+ }
1125
+ if (_recv) state.add(_recv);
898
1126
  }
899
- findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line).findings);
1127
+ findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line, node.kwargs).findings);
1128
+ findings.push(..._nestedSinkFindings(node.args, state, callContext, node.line));
900
1129
  // 2. P1.3 — higher-order taint flow. When the call is `arr.map(fn)` or
901
1130
  // `promise.then(fn)` and the receiver is tainted, propagate taint
902
1131
  // into the callback's first parameter. v1: we propagate AT THE
@@ -960,6 +1189,25 @@ function step(node, stateIn, callContext) {
960
1189
  }
961
1190
 
962
1191
  case 'return': {
1192
+ // Taint-engine PRD P1: `return sink(x)` was invisible — this only ever
1193
+ // asked whether the returned value is TAINTED (for interprocedural
1194
+ // callers, below), never whether the call expression itself is a sink.
1195
+ // JS is accidentally immune (Babel emits a redundant standalone 'call'
1196
+ // node for every CallExpression, including ones nested in a return
1197
+ // argument, so case 'call' above already caught it there). Every
1198
+ // hand-rolled parser does not do that, so `return
1199
+ // File.ReadAllText(path)` — idiomatic ASP.NET Core — was structurally
1200
+ // blind. Mirrors case 'call''s sink-check exactly; deliberately does
1201
+ // NOT mirror its summary-cache/mutation/higher-order machinery, which
1202
+ // is about a callee's OWN internal findings and mutated params — an
1203
+ // unrelated concern from whether this return statement's own call
1204
+ // expression is directly a sink.
1205
+ if (node.value && node.value.kind === 'call') {
1206
+ const { cat, argTaints } = _matchCallCatalog(node.value.callee, node.value.args, state, callContext);
1207
+ findings.push(..._sinkFindingsForCall(
1208
+ node.value.callee, node.value.args, cat, argTaints, state, callContext, node.line, node.value.kwargs).findings);
1209
+ findings.push(..._nestedSinkFindings(node.value.args, state, callContext, node.line));
1210
+ }
963
1211
  if (exprTaint(node.value, state, callContext)) {
964
1212
  callContext._returnTainted = true;
965
1213
  }
@@ -214,20 +214,45 @@ function _summaryEq(a, b) {
214
214
  // Build the entry-taint-state for a callee from a call site:
215
215
  // given the callee's param names + the caller's tainted-var set + the
216
216
  // call args, return a Set of param names that are tainted at entry.
217
- export function entryStateFromCall(paramNames, callArgs, callerTaintedVars) {
217
+ //
218
+ // `isArgTaintedExpr` (optional) — Taint-recall PRD (80%): the ident/member
219
+ // checks above only recognize an arg that is (or reads off) an ALREADY
220
+ // state-tracked local variable. A source used directly INLINE as a call
221
+ // argument — `helper(request.getParameter("id"))`, never first assigned to
222
+ // a local — has no entry in `callerTaintedVars` at all (that set tracks
223
+ // variable PROVENANCE via assignment, not "is this arbitrary expression a
224
+ // source when evaluated"), so it was silently invisible here even though
225
+ // the engine's own exprTaint/exprIsSource would recognize it immediately.
226
+ // Confirmed via a real corpus fixture: `find_user($doc, $_GET['name'])`
227
+ // with no intermediate variable produced zero interprocedural findings.
228
+ // Callers pass a closure over their own exprTaint (this module has no
229
+ // access to matchSource/exprIsSource — engine.js does) rather than this
230
+ // module reaching across the layering boundary. Falls back to the
231
+ // ident/member-only checks above when not provided, so every other caller
232
+ // of this function is unaffected.
233
+ export function entryStateFromCall(paramNames, callArgs, callerTaintedVars, isArgTaintedExpr) {
218
234
  const out = new Set();
219
235
  if (!Array.isArray(paramNames) || !Array.isArray(callArgs)) return out;
220
236
  for (let i = 0; i < paramNames.length && i < callArgs.length; i++) {
221
237
  const arg = callArgs[i];
222
238
  if (!arg) continue;
239
+ // Each check is independent — arg SHAPE (ident vs. member) selecting a
240
+ // branch must not skip the fallback when that branch's own membership
241
+ // check comes back false. An earlier version used if/else-if keyed on
242
+ // shape alone, so `helper($_GET['name'])` (a member arg whose base
243
+ // "$_GET" is never itself in callerTaintedVars — that set tracks
244
+ // ASSIGNED locals, not raw globals) took the member branch, found
245
+ // nothing, and — being an else-if — never reached isArgTaintedExpr at
246
+ // all. Confirmed via a real corpus fixture.
247
+ let tainted = false;
223
248
  if (arg.kind === 'ident' && callerTaintedVars.has(arg.name)) {
224
- out.add(paramNames[i]);
249
+ tainted = true;
225
250
  } else if (arg.kind === 'member' && arg.object?.kind === 'ident') {
226
251
  const base = arg.object.name;
227
- if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) {
228
- out.add(paramNames[i]);
229
- }
252
+ if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) tainted = true;
230
253
  }
254
+ if (!tainted && typeof isArgTaintedExpr === 'function' && isArgTaintedExpr(arg)) tainted = true;
255
+ if (tainted) out.add(paramNames[i]);
231
256
  }
232
257
  return out;
233
258
  }