@clear-capabilities/agentic-security-scanner 0.137.0 → 0.139.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 (44) hide show
  1. package/CHANGELOG.md +219 -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 +10 -6
  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
@@ -68,24 +68,125 @@ function exprFromCst(node) {
68
68
  if (node.children.primaryPrefix) {
69
69
  const prefix = node.children.primaryPrefix[0];
70
70
  const suffixes = node.children.primarySuffix || [];
71
- const invocation = suffixes
72
- .map(s => s.children?.methodInvocationSuffix?.[0])
73
- .find(Boolean);
74
- if (invocation) {
75
- const fqn = prefix?.children?.fqnOrRefType?.[0];
76
- const callee = fqn ? _flattenFqnToString(fqn) : 'unknown';
77
- const args = (invocation.children?.argumentList?.[0]?.children?.expression || [])
78
- .map(exprFromCst);
79
- return { kind: 'call', callee, args };
71
+ // Taint-recall PRD (80%): java-parser models a CHAIN
72
+ // (`X().Y().Z(tainted)`) as ONE primary node with a FLAT array of
73
+ // primarySuffix entries — each is EITHER a methodInvocationSuffix
74
+ // (a `(...)` call) OR a {Dot, Identifier} member-access pair, in
75
+ // source order. The old code used `.find(Boolean)` to grab the
76
+ // FIRST methodInvocationSuffix in the whole array and stopped there
77
+ // for `DocumentBuilderFactory.newInstance().newDocumentBuilder()
78
+ // .parse(s)` this returned just the `newInstance()` call with
79
+ // args: [], silently dropping `.newDocumentBuilder().parse(s)`
80
+ // entirely (confirmed via a real corpus fixture; the same shape
81
+ // also explains the earlier-documented, never-fixed
82
+ // `Runtime.getRuntime().exec(...)` gap). Now walks every suffix in
83
+ // order, building the callee name from member-access suffixes and,
84
+ // at each invocation suffix, folding that call's args into the
85
+ // running result — outermost-first (the LATEST call's own args are
86
+ // prepended), same convention as _followChain in the other 5
87
+ // hand-rolled parsers, so an existing `argIndex: 0` catalog entry
88
+ // keyed to the outermost call is unaffected while `argIndex: 'all'`
89
+ // can still find taint an inner call in the chain carried.
90
+ let chainCallee = '';
91
+ let chainArgs = null;
92
+ const fqn = prefix?.children?.fqnOrRefType?.[0];
93
+ // Taint-recall PRD (80%): a `new X(args)` CONSTRUCTOR starting the
94
+ // chain (`new URL(url).openStream()`) is a DIFFERENT CST shape than
95
+ // an fqnOrRefType prefix — `prefix.children.fqnOrRefType` is absent,
96
+ // so `fqn` above is undefined and, before this fix, BOTH the
97
+ // constructor's own class name AND its own arguments were silently
98
+ // dropped: `chainCallee` stayed empty and the suffix walk below only
99
+ // ever contributes the CHAINED method's name, producing a callee
100
+ // like bare "openStream" with the constructor's tainted argument
101
+ // nowhere in the IR at all — not misattributed, genuinely absent, a
102
+ // strictly worse failure than the terminal-segment-shift class this
103
+ // PRD fixes elsewhere with a receiver-scoped catalog entry (there is
104
+ // no catalog fix possible for taint the IR never represents).
105
+ // Confirmed via a real corpus fixture (CVE-2019-3799-spring-ssrf-
106
+ // shape). The actual CST path is one level deeper than the analogous
107
+ // fqnOrRefType check above: `primaryPrefix.children.newExpression[0]
108
+ // .children.unqualifiedClassInstanceCreationExpression[0]` — found by
109
+ // dumping `prefix.children` directly rather than guessing (the
110
+ // standalone, NON-chained `new X(args)` branch further below reaches
111
+ // `unqualifiedClassInstanceCreationExpression` straight off `node`,
112
+ // one level shallower, which is what made the wrapping `newExpression`
113
+ // layer easy to miss here). Seeds `chainCallee` with the class name
114
+ // and `chainArgs` with the constructor's OWN args (same
115
+ // argumentList.expression shape that standalone branch already
116
+ // extracts) — the suffix loop's existing `chainArgs === null ? args :
117
+ // args.concat(chainArgs)` then correctly prepends each chained call's
118
+ // own args ahead of the constructor's, preserving the outermost-first
119
+ // convention.
120
+ const ctorPrefix = prefix?.children?.newExpression?.[0]?.children?.unqualifiedClassInstanceCreationExpression?.[0];
121
+ if (fqn) {
122
+ chainCallee = _flattenFqnToString(fqn);
123
+ } else if (ctorPrefix) {
124
+ chainCallee = ctorPrefix.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier?.[0]?.image || '';
125
+ chainArgs = (ctorPrefix.children?.argumentList?.[0]?.children?.expression || []).map(exprFromCst);
126
+ }
127
+ // No FQN/constructor prefix — e.g. `this.foo(x)`, `super.foo(x)`
128
+ // (prefix is a keyword expression). chainCallee starts empty; the
129
+ // walk below supplies the real name from the first member-access
130
+ // suffix.
131
+ let pendingName = [];
132
+ const appendPending = () => {
133
+ if (!pendingName.length) return;
134
+ chainCallee = chainCallee ? `${chainCallee}.${pendingName.join('.')}` : pendingName.join('.');
135
+ pendingName = [];
136
+ };
137
+ for (const suf of suffixes) {
138
+ const inv = suf.children?.methodInvocationSuffix?.[0];
139
+ if (inv) {
140
+ appendPending();
141
+ const args = (inv.children?.argumentList?.[0]?.children?.expression || []).map(exprFromCst);
142
+ chainArgs = chainArgs === null ? args : args.concat(chainArgs);
143
+ continue;
144
+ }
145
+ const ident = suf.children?.Identifier?.[0]?.image;
146
+ if (ident) pendingName.push(ident);
147
+ }
148
+ if (chainArgs !== null) {
149
+ appendPending(); // a trailing member access after the last call (e.g. a field read)
150
+ return { kind: 'call', callee: chainCallee || 'unknown', args: chainArgs };
80
151
  }
81
152
  return exprFromCst(prefix);
82
153
  }
154
+ // Taint-recall PRD (80%): a cast expression — `(String) xp.evaluate(...)`,
155
+ // `(int) computeVal(x)` — was falling through to the generic "recurse the
156
+ // first child" branch below, which for a castExpression's shape hits its
157
+ // own `primaryPrefix.children.castExpression` sub-node with no dedicated
158
+ // branch, and beneath THAT the raw `LBrace`/`LParen` token sorts first in
159
+ // key order — silently corrupting the parse into `{kind:'ident',
160
+ // name:'('}` and losing the entire operand (the actual call, and
161
+ // whatever tainted argument it carried). Confirmed via a real corpus
162
+ // fixture (CVE-2018-1320-xpath-injection). Casts are semantically
163
+ // transparent for taint purposes — unwrap to the operand, reference-type
164
+ // (`(String) x` → unaryExpressionNotPlusMinus) or primitive
165
+ // (`(int) x` → unaryExpression) shaped.
166
+ if (node.children.castExpression) {
167
+ const ce = node.children.castExpression[0];
168
+ const rtc = ce.children?.referenceTypeCastExpression?.[0];
169
+ const pc = ce.children?.primitiveCastExpression?.[0];
170
+ const operand = rtc?.children?.unaryExpressionNotPlusMinus?.[0]
171
+ || pc?.children?.unaryExpression?.[0];
172
+ if (operand) return exprFromCst(operand);
173
+ }
83
174
  // FQN ref
84
175
  if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
85
176
  if (node.children.unqualifiedClassInstanceCreationExpression) {
86
177
  const ci = node.children.unqualifiedClassInstanceCreationExpression[0];
87
178
  const callee = (ci.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier?.[0]?.image) || 'new';
88
- return { kind: 'call', callee, isNew: true, args: [] };
179
+ // Taint-recall PRD (80%): args was hardcoded to [] — every
180
+ // `new X(arg1, arg2)` constructor call lowered with its arguments
181
+ // silently discarded, so a sink modeled as a constructor call
182
+ // (argIndex-based) could never see a tainted constructor argument
183
+ // regardless of catalog correctness. Same argumentList.expression
184
+ // shape the methodInvocationSuffix branch above already extracts
185
+ // from. Confirmed via a real corpus fixture
186
+ // (`new ByteArrayInputStream(xml)` feeding `b.parse(...)`).
187
+ const args = (ci.children?.argumentList?.[0]?.children?.expression || [])
188
+ .map(exprFromCst);
189
+ return { kind: 'call', callee, isNew: true, args };
89
190
  }
90
191
  if (node.children.literal) return exprFromCst(node.children.literal[0]);
91
192
  if (node.children.Identifier) return { kind: 'ident', name: node.children.Identifier[0].image };
@@ -99,10 +99,50 @@ function exprOf(n) {
99
99
  case 'ArrayExpression': return { kind: 'array', elements: (n.elements || []).map(exprOf) };
100
100
  case 'SpreadElement': return exprOf(n.argument);
101
101
  case 'ThisExpression': return { kind: 'ident', name: '_this_' };
102
+ // Taint-recall PRD (80%) Tier 3: JSX had ZERO IR modeling at all — every
103
+ // JSXElement fell through to {kind:'unknown'}, so `return <div
104
+ // dangerouslySetInnerHTML={{__html: html}} />` (React's canonical XSS
105
+ // sink, and the shape of a real corpus miss) silently dropped `html`'s
106
+ // taint entirely. Deliberately narrow: only the `dangerouslySetInnerHTML`
107
+ // attribute is extracted (found on this element or, recursively, any
108
+ // descendant — the attribute can sit on a nested element, not just the
109
+ // one directly returned) and lowered to a synthetic call
110
+ // (`__jsx_dangerously_set_inner_html__`) carrying the `__html` object
111
+ // property's value, so the existing `react-dangerouslySetInnerHTML`
112
+ // member-write sink's SIBLING call-shaped catalog entry can target it.
113
+ // Full JSX modeling (arbitrary attributes, children, expressions) is
114
+ // explicitly out of scope — children are React-auto-escaped by default,
115
+ // so `<div>{unsafeText}</div>` is not itself a vulnerability the way
116
+ // `dangerouslySetInnerHTML` is.
117
+ case 'JSXElement': {
118
+ const found = _findDangerouslySetInnerHTML(n);
119
+ if (found) return { kind: 'call', callee: '__jsx_dangerously_set_inner_html__', args: [exprOf(found)] };
120
+ return { kind: 'unknown' };
121
+ }
102
122
  default: return { kind: 'unknown' };
103
123
  }
104
124
  }
105
125
 
126
+ function _findDangerouslySetInnerHTML(jsxElement) {
127
+ const attrs = jsxElement?.openingElement?.attributes || [];
128
+ for (const attr of attrs) {
129
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'dangerouslySetInnerHTML') continue;
130
+ const val = attr.value;
131
+ if (val?.type !== 'JSXExpressionContainer') continue;
132
+ const obj = val.expression;
133
+ if (obj?.type !== 'ObjectExpression') continue;
134
+ const htmlProp = (obj.properties || []).find(p => p.type === 'ObjectProperty' && (p.key?.name === '__html' || p.key?.value === '__html'));
135
+ if (htmlProp) return htmlProp.value;
136
+ }
137
+ for (const child of jsxElement?.children || []) {
138
+ if (child.type === 'JSXElement') {
139
+ const nested = _findDangerouslySetInnerHTML(child);
140
+ if (nested) return nested;
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+
106
146
  // Reduce a Babel LHS node to a string path used as a dataflow variable key.
107
147
  function lhsPath(n) {
108
148
  if (!n) return null;
@@ -67,6 +67,7 @@
67
67
 
68
68
  import * as crypto from 'node:crypto';
69
69
  import { callSitesFromCfg } from './call-sites.js';
70
+ import { matchBalancedCall } from './balanced-call.js';
70
71
 
71
72
  const FUN_RE = new RegExp(
72
73
  '(?:^|[\\s;{}])(?:public|private|internal|protected|inline|suspend|tailrec|operator|infix|open|abstract|override|final|external)?' +
@@ -169,6 +170,25 @@ function _buildMemberChain(parts) {
169
170
  return cur;
170
171
  }
171
172
 
173
+ // Taint-recall PRD (80%): same architectural fix as parser-cs.js/
174
+ // parser-go.js/parser-php.js/parser-rb.js — a chained call
175
+ // (`ScriptEngineManager().getEngineByName("js").eval(userCode)`) previously
176
+ // stopped at (or, here, actively corrupted the parse at) the FIRST call,
177
+ // leaving `.method(args)` continuations unconsumed or garbled. Args from
178
+ // EVERY level are kept, outermost-first — a first version kept only the
179
+ // outermost, which broke `xp.compile(taintedExpr).evaluate(doc,
180
+ // XPathConstants.NODESET)`: the tainted value sits on the INNER call
181
+ // (.compile), not the final one, and keeping only the outer args (doc,
182
+ // NODESET) silently dropped it — see parser-cs.js's twin function for the
183
+ // full reasoning.
184
+ function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
185
+ const rest = s.slice(endIdx);
186
+ const outer = matchBalancedCall(rest, /^\.(\w+)/);
187
+ if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
188
+ const outerArgs = _splitTopLevelCommas(outer.argsText).map(_lowerExpr);
189
+ return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar));
190
+ }
191
+
172
192
  function _lowerExpr(text) {
173
193
  const s = String(text || '').trim();
174
194
  if (!s) return { kind: 'unknown' };
@@ -192,14 +212,42 @@ function _lowerExpr(text) {
192
212
  if (parts.length === 1) return { kind: 'ident', name: parts[0] };
193
213
  return _buildMemberChain(parts);
194
214
  }
195
- // Call
196
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
215
+ // Taint-recall PRD (80%): subscript/bracket access (`map[key]`,
216
+ // `call.parameters["q"]`) had NO recognizer at all — Kotlin's
217
+ // `operator fun get(key)` bracket syntax, used pervasively for Maps,
218
+ // arrays, and (critically) Ktor's `call.parameters[...]`, fell through
219
+ // every branch below to {kind:'unknown'}, silently dropping the value —
220
+ // and any taint on it — entirely. Lowered to the same synthetic `'[]'`
221
+ // prop convention parser-go.js's own Indexing branch uses: the base
222
+ // (everything before the first top-level `[`) becomes a real member
223
+ // chain, wrapped in one more member layer with `prop: '[]'`. This is
224
+ // what lets a cataloged MEMBER source on the base (e.g. `call.parameters`
225
+ // — `kt-ktor-parameters`) still taint the subscripted read: `exprIsSource`
226
+ // recurses into `expr.object` when the outer member itself doesn't match,
227
+ // landing on the base member it already knows.
228
+ const subscriptMatch = s.match(/^([A-Za-z_][\w.]*)\[(.+)\]$/s);
229
+ if (subscriptMatch) {
230
+ const parts = subscriptMatch[1].split('.');
231
+ const base = parts.length === 1 ? { kind: 'ident', name: parts[0] } : _buildMemberChain(parts);
232
+ return { kind: 'member', object: base, prop: '[]' };
233
+ }
234
+ // Call. Taint-recall PRD (80%): this used to be the naive
235
+ // `/^([\w.]+)\s*\((.*)\)\s*$/s` pattern every OTHER hand-rolled parser
236
+ // (cs/go/php/rb) also started with and has since moved off of —
237
+ // `(.*)` matches GREEDILY against the LAST `)` in the string, not the
238
+ // one balancing the FIRST `(`, so a chained call
239
+ // (`ScriptEngineManager().getEngineByName("js").eval(userCode)`)
240
+ // corrupted the args text into garbage rather than just dropping the
241
+ // chain. Kotlin never got migrated to matchBalancedCall when the other
242
+ // four were — confirmed via a real corpus fixture (this exact
243
+ // ScriptEngineManager shape). Now uses the same balanced-scan +
244
+ // chain-following approach: only the OUTERMOST call's own arguments are
245
+ // kept (see parser-cs.js's _followChain comment for why), each level's
246
+ // name dot-joined into one callee string.
247
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
197
248
  if (callMatch) {
198
- return {
199
- kind: 'call',
200
- callee: callMatch[1],
201
- args: _splitTopLevelCommas(callMatch[2]).map(_lowerExpr),
202
- };
249
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
250
+ return _followChain(s, callMatch.endIdx, callMatch.callee, args);
203
251
  }
204
252
  // Concat
205
253
  if (s.includes('+') && /["']/.test(s)) {
@@ -227,9 +275,13 @@ function _lowerStmt(stmt, line) {
227
275
  if (assign && !/[=!<>]=/.test(s.slice(0, s.indexOf('=')+1).slice(0, -1))) {
228
276
  return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
229
277
  }
230
- // Statement-form call
231
- const cm = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
232
- if (cm) return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
278
+ // Statement-form call — same matchBalancedCall + chain-following as the
279
+ // expression-form case above (Taint-recall PRD 80%).
280
+ const cm = matchBalancedCall(s, /^([\w.]+)/);
281
+ if (cm) {
282
+ const chained = _followChain(s, cm.endIdx, cm.callee, _splitTopLevelCommas(cm.argsText).map(_lowerExpr));
283
+ return { kind: 'call', line, callee: chained.callee, args: chained.args };
284
+ }
233
285
  // R8: trailing-lambda call — `recv.method(args)? { lambda }`, Kotlin's
234
286
  // idiomatic collection-operator / scope-function syntax
235
287
  // (`xs.forEach { x -> … }`, `xs.reduce(0) { acc, x -> … }`). Without this
@@ -399,6 +451,43 @@ function _linkNodes(nodes, src, dst) {
399
451
  const HEADER_RE = /^(if|while|for|when|else\s+if|else|do|try|catch|finally)\b/;
400
452
  const NEEDS_COND_RE = /^(?:if|while|when|else if|catch)$/;
401
453
 
454
+ // Taint-engine PRD P1: trailing-lambda body recursion. Detects the START of
455
+ // a `recv.method(args)? { … }` call — deliberately NOT the whole statement
456
+ // (the pre-P1 `_lowerStmt` trigger's `[\s\S]*\}\s*$` greedy tail is exactly
457
+ // what mis-captured a chained `xs.filter { … }.forEach { … }` as one big
458
+ // opaque lambda with the wrong callee). `_matchDelim` finds the REAL
459
+ // matching `}` from here, the same balanced-scan primitive every keyword
460
+ // branch below already uses.
461
+ //
462
+ // Split into two alternatives — no-args and with-args — rather than one
463
+ // optional group between two `\s*`s: `bench:self-scan:check` caught this
464
+ // exact shape as a genuine quadratic ReDoS (confirmed by direct timing:
465
+ // 40000 whitespace chars with no trailing `{` took ~1s), the identical
466
+ // defect class this file's own R8 task already fixed once (see the CLAUDE.md
467
+ // note on the sibling `decl` regex) and parser-cs.js's `attrRegex` fixed the
468
+ // same way. Each alternative has its own capture group for the callee name
469
+ // (`m[1]`/`m[2]`); only one is ever set.
470
+ const TRAILING_LAMBDA_TRIGGER_RE = /^([\w.]+)\s*\{|^([\w.]+)\s*(\([^()]*\))\s*\{/;
471
+
472
+ // Stdlib scope functions whose lambda receives the RECEIVER (or an element
473
+ // of it) as its own parameter — these get a synthesized taint-binding
474
+ // assign before the body is recursed into, mirroring the for-loop's
475
+ // loop-variable binding immediately below. `reduce`/`fold` pass BOTH an
476
+ // accumulator and an element; this codebase's recall-preserving doctrine
477
+ // (favor a false positive over a silent false negative — see catalog.js's
478
+ // sanitizer-recording comment for the same tradeoff) argues for binding
479
+ // every declared param to the receiver rather than trying to disambiguate
480
+ // which one is the actual element.
481
+ //
482
+ // Deliberately EXCLUDED: `apply`/`run` — these pass the receiver as an
483
+ // IMPLICIT `this`, not a named/`it` lambda parameter (`T.() -> R`, not
484
+ // `T.(T) -> R`), so there is nothing to bind here without modeling
485
+ // implicit-receiver member calls. Their bodies are still recursed into
486
+ // below (so an already-tainted OUTER variable referenced inside still
487
+ // works), just without receiver-as-param binding — a real, documented
488
+ // scope boundary, not a silent gap.
489
+ const LAMBDA_BINDABLE_METHODS = new Set(['forEach', 'map', 'filter', 'reduce', 'fold', 'use', 'let', 'also']);
490
+
402
491
  // R8: recursive statement handler. `s` is ONE element returned by
403
492
  // `_splitStatements` — which, because Kotlin's splitter (unlike C#'s) does
404
493
  // not flush on a `}` reaching depth 0, may itself be a CHAIN of glued
@@ -422,6 +511,61 @@ function _consumeChunk(s, abs0, nodes, prevId, funcStartLine, lineStarts, depth)
422
511
  const rest = s.slice(skipTo);
423
512
  const hm = rest.match(HEADER_RE);
424
513
  if (!hm) {
514
+ // Taint-engine PRD P1: trailing-lambda body recursion. Checked before
515
+ // falling through to a leaf statement — `HEADER_RE` never matches an
516
+ // identifier-starting trailing-lambda call, so there is no ambiguity
517
+ // between the two triggers.
518
+ const lambdaMatch = rest.match(TRAILING_LAMBDA_TRIGGER_RE);
519
+ if (lambdaMatch) {
520
+ const braceIdxInS = skipTo + lambdaMatch[0].length - 1; // index of '{' within s
521
+ const closeRel = _matchDelim(s, braceIdxInS, '{', '}');
522
+ if (closeRel !== -1) {
523
+ first = false;
524
+ const callee = lambdaMatch[1] || lambdaMatch[2];
525
+ const parenGroup = lambdaMatch[3] || null;
526
+ const argsText = parenGroup ? parenGroup.slice(1, -1) : '';
527
+ const callArgs = argsText ? _splitTopLevelCommas(argsText).map(_lowerExpr) : [];
528
+ const line = _lineForAbs(lineStarts, funcStartLine, abs0 + skipTo);
529
+ const callId = _addNode(nodes, { kind: 'call', line, callee, args: callArgs });
530
+ _linkNodes(nodes, prev, callId);
531
+ prev = callId;
532
+
533
+ const dot = callee.lastIndexOf('.');
534
+ // A leading-dot callee (`.forEach { … }`, the second link of a
535
+ // CHAINED trailing lambda like `xs.filter{}.forEach{}`) yields an
536
+ // empty receiver here — deliberately falls into the same
537
+ // no-binding path as .apply/.run below, since there is no real
538
+ // identifier to bind from (the true receiver is the previous
539
+ // lambda's return value, which this file does not model as a
540
+ // synthetic variable). The call site and body are still captured.
541
+ const receiver = dot > 0 ? callee.slice(0, dot) : null;
542
+ const method = dot >= 0 ? callee.slice(dot + 1) : callee;
543
+
544
+ const bodyInner = s.slice(braceIdxInS + 1, closeRel);
545
+ const bodyAbs0 = abs0 + braceIdxInS + 1;
546
+
547
+ if (receiver && LAMBDA_BINDABLE_METHODS.has(method)) {
548
+ const arrowIdx = _findTopLevelArrow(bodyInner);
549
+ const paramNames = arrowIdx >= 0
550
+ ? bodyInner.slice(0, arrowIdx).split(',').map(p => p.trim()).filter(p => /^[A-Za-z_]\w*$/.test(p))
551
+ : ['it'];
552
+ for (const p of paramNames) {
553
+ const assignId = _addNode(nodes, { kind: 'assign', line, target: p, source: _lowerExpr(receiver) });
554
+ _linkNodes(nodes, prev, assignId);
555
+ prev = assignId;
556
+ }
557
+ const recurseFrom = arrowIdx >= 0 ? arrowIdx + 2 : 0;
558
+ prev = _buildCfg(bodyInner.slice(recurseFrom), nodes, prev, funcStartLine, lineStarts, bodyAbs0 + recurseFrom, depth + 1);
559
+ } else {
560
+ // .apply/.run, a chained continuation, or an unlisted scope
561
+ // function: body still reachable, no param binding.
562
+ prev = _buildCfg(bodyInner, nodes, prev, funcStartLine, lineStarts, bodyAbs0, depth + 1);
563
+ }
564
+ pos = closeRel + 1;
565
+ if (pos >= s.length || depth > 12) return prev;
566
+ continue;
567
+ }
568
+ }
425
569
  if (first) {
426
570
  return _lowerLeafOrBlock(s, abs0, nodes, prev, funcStartLine, lineStarts, depth);
427
571
  }
@@ -618,6 +762,45 @@ function _lineAt(src, idx) {
618
762
  return line;
619
763
  }
620
764
 
765
+ // Taint-recall PRD (80%): Kotlin's null-safety `?.` operator is invisible to
766
+ // every regex-based matcher in this file — the callee-matching regexes
767
+ // (`[\w.]+`-style), the plain-dotted-ident check, the trailing-lambda
768
+ // trigger, and `_followChain`'s continuation regex all key off a character
769
+ // class that excludes `?`. Unlike `::` (parser-rb.js) or a chain
770
+ // continuation, this is NOT limited to a later segment of a chain — the
771
+ // safe-call operator can appear on the very FIRST segment of an expression
772
+ // (`str?.trim()`, `xs?.forEach { … }`, even a bare property read `x?.y`),
773
+ // so patching individual regexes one at a time would miss call sites this
774
+ // file doesn't yet enumerate. Normalized once, globally, at the very top of
775
+ // the parse pipeline instead: string-literal-aware (so `"a?.b"` inside a
776
+ // literal string is left untouched) and strips only the `?` immediately
777
+ // before a `.` (so the elvis operator `?:` and a bare nullable-type marker
778
+ // `String?` are both unaffected — neither is followed by `.`). Dropping the
779
+ // `?` (not replacing with a same-length filler) is safe for this file's
780
+ // line-number bookkeeping specifically because `_lineStarts`/`_lineAt`/
781
+ // `_lineForOffset` are all purely newline-position-based — removing a
782
+ // non-newline character can shift a later character's COLUMN but never its
783
+ // LINE, and column offsets are never relied on anywhere in this file.
784
+ function _stripSafeCallOperator(code) {
785
+ let out = '';
786
+ let inStr = null;
787
+ let escape = false;
788
+ for (let i = 0; i < code.length; i++) {
789
+ const c = code[i];
790
+ if (escape) { out += c; escape = false; continue; }
791
+ if (inStr) {
792
+ out += c;
793
+ if (c === '\\') { escape = true; continue; }
794
+ if (c === inStr) inStr = null;
795
+ continue;
796
+ }
797
+ if (c === '"' || c === '\'') { inStr = c; out += c; continue; }
798
+ if (c === '?' && code[i + 1] === '.') { out += '.'; i++; continue; }
799
+ out += c;
800
+ }
801
+ return out;
802
+ }
803
+
621
804
  function _qid(file, name, line, body) {
622
805
  const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
623
806
  return `${file}::${name}@${line}#${sha}`;
@@ -625,6 +808,7 @@ function _qid(file, name, line, body) {
625
808
 
626
809
  export function parseKotlinFile(file, code) {
627
810
  if (!file || typeof code !== 'string') return null;
811
+ code = _stripSafeCallOperator(code);
628
812
  const functions = [];
629
813
  FUN_RE.lastIndex = 0;
630
814
  let m;
@@ -167,6 +167,19 @@ function _splitStatements(body) {
167
167
  if (i < body.length) { push('\n'); curLine++; }
168
168
  continue;
169
169
  }
170
+ // Taint-recall PRD (80%): PHP's THIRD line-comment form — `#`-comments
171
+ // were entirely invisible to this splitter (only `//`/`/* */` were
172
+ // handled), so a `#`-comment's text was treated as literal code:
173
+ // string-toggling apostrophes, brace/paren/bracket depth corruption,
174
+ // and — if it happened to contain a stray `;` — a bogus statement
175
+ // boundary. PHP 8 attributes (`#[Attribute]`) use the same leading `#`
176
+ // but are NOT a comment, so `#[` is excluded (checked via the next
177
+ // character) exactly like `_extractBody`'s twin fix does.
178
+ if (c === '#' && body[i + 1] !== '[') {
179
+ while (i < body.length && body[i] !== '\n') i++;
180
+ if (i < body.length) { push('\n'); curLine++; }
181
+ continue;
182
+ }
170
183
  if (c === '/' && body[i + 1] === '*') {
171
184
  // Block comment (incl. PHPDoc, e.g. `/** @param string $x */`).
172
185
  // Skip to the matching `*/`, counting any newlines crossed so line
@@ -271,6 +284,26 @@ function _splitStatements(body) {
271
284
  return out;
272
285
  }
273
286
 
287
+ // Taint-recall PRD (80%): same architectural fix as parser-cs.js/
288
+ // parser-go.js — a chained call (`sanitize($x)->encode()`, or a real sink
289
+ // shape like `$xp->query(...)` reached through a chain) previously stopped
290
+ // at the FIRST balanced call, leaving `->method(args)` unconsumed. `callee`
291
+ // arrives already normalized to dots (->/:: both become "."), so the
292
+ // continuation check looks for a literal "->" or "::" in the SOURCE text
293
+ // (PHP's own syntax) even though the join uses ".". Args from EVERY level
294
+ // are kept, outermost-first — see parser-cs.js's twin function for why
295
+ // (a first version that kept only the outermost broke a real chain shape
296
+ // where the tainted value sits on an INNER call).
297
+ function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
298
+ const rest = s.slice(endIdx);
299
+ const m = rest.match(/^(?:->|::)(\w+)/);
300
+ if (!m) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
301
+ const outer = matchBalancedCall(rest, /^(?:->|::)(\w+)/);
302
+ if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
303
+ const outerArgs = _splitTopLevelCommas(outer.argsText).map(_lowerExpr);
304
+ return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar));
305
+ }
306
+
274
307
  function _lowerExpr(text) {
275
308
  const s = String(text || '').trim();
276
309
  if (!s) return { kind: 'unknown' };
@@ -285,8 +318,25 @@ function _lowerExpr(text) {
285
318
  // common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
286
319
  // `$var[key]`) and complex (`{$expr}`) interpolation forms are both
287
320
  // lowered into a template, same shape as the `.`-concat branch below.
288
- if (/^"/.test(s) && s.includes('$')) {
289
- const inner = s.slice(1, -1);
321
+ // Taint-recall PRD (80%): the old guard (`/^"/.test(s) && s.includes('$')`)
322
+ // treated ANY string simply STARTING with `"` and containing a `$`
323
+ // ANYWHERE as a self-contained double-quoted literal, then blindly sliced
324
+ // off the first and last characters as its quotes (`s.slice(1, -1)`).
325
+ // For a concat expression like `"SELECT ... id = " . $id` — arguably the
326
+ // single most common real-world PHP SQL-injection shape — `s` starts
327
+ // with `"` and contains a `$` (in `$id`, OUTSIDE the string, after the
328
+ // `.`), so this branch fired and treated the WHOLE concat expression's
329
+ // text as interpolation content: `inner` became `SELECT ... id = " . $i`
330
+ // (sliced off the true FIRST character and the true LAST character,
331
+ // neither of which bounds the actual string literal), corrupting both
332
+ // the literal text and truncating `$id` to `$i`. Now requires the ENTIRE
333
+ // trimmed expression to be exactly one closed double-quoted literal
334
+ // (anchored start AND end) before treating it as interpolation-only —
335
+ // `"literal" . $var` fails this (trailing ` . $var` breaks the end
336
+ // anchor) and correctly falls through to the concat branch below instead.
337
+ const dqLiteral = s.match(/^"((?:[^"\\]|\\.)*)"$/);
338
+ if (dqLiteral && s.includes('$')) {
339
+ const inner = dqLiteral[1];
290
340
  const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
291
341
  let lastIndex = 0;
292
342
  const parts = [];
@@ -303,7 +353,16 @@ function _lowerExpr(text) {
303
353
  return { kind: 'tpl', parts };
304
354
  }
305
355
  }
306
- if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
356
+ // Taint-recall PRD (80%): same unanchored-prefix defect as the
357
+ // interpolation branch above, one layer down — `/^"/.test(s)` only checks
358
+ // that `s` STARTS with a quote, not that it IS (only) a closed literal.
359
+ // For `"literal" . $id`, this fallback used to catch what the (now fixed)
360
+ // interpolation branch missed and swallow the ENTIRE concat expression —
361
+ // trailing ` . $id` included — into one opaque literal `value` string,
362
+ // silently dropping `$id`'s taint just as badly, only one level later.
363
+ // Anchored at both ends so a genuinely unclosed/concatenated string falls
364
+ // through to the concat branch below instead of being misread as whole.
365
+ if (/^"(?:[^"\\]|\\.)*"$/.test(s) || /^'(?:[^'\\]|\\.)*'$/.test(s)) return { kind: 'literal', value: s };
307
366
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
308
367
  if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
309
368
  // Superglobals
@@ -328,12 +387,13 @@ function _lowerExpr(text) {
328
387
  if (methodCall) {
329
388
  const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
330
389
  const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
331
- return { kind: 'call', callee, args };
390
+ return _followChain(s, methodCall.endIdx, callee, args);
332
391
  }
333
392
  // Function call: func(args)
334
393
  const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
335
394
  if (funcCall) {
336
- return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
395
+ const args = _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr);
396
+ return _followChain(s, funcCall.endIdx, funcCall.callee, args);
337
397
  }
338
398
  // Concat with .
339
399
  //
@@ -418,6 +478,19 @@ function _lowerStmt(stmt, line) {
418
478
  if (/^throw\b/.test(s)) {
419
479
  return { kind: 'throw', line, value: _lowerExpr(s.replace(/^throw\s+/, '')) };
420
480
  }
481
+ // Taint-recall PRD (80%): `echo`/`print` are PHP LANGUAGE CONSTRUCTS, not
482
+ // function calls — `echo "<div>" . $_GET['q'] . "</div>";` has no `(`
483
+ // immediately after the keyword, so the statement-form call regex below
484
+ // never matched it, and the entire echoed expression (including any
485
+ // reflected taint) was silently dropped. `echo` can take multiple
486
+ // comma-separated expressions; lowered to a synthetic call
487
+ // (`__php_echo__`) carrying each as an argument, so a normal callee-keyed
488
+ // catalog sink can target it exactly like any other call-shaped sink —
489
+ // same convention as parser-rb.js's `__ruby_backtick_exec__`.
490
+ if (/^(?:echo|print)\b/.test(s)) {
491
+ const rest = s.replace(/^(?:echo|print)\s*/, '');
492
+ return { kind: 'call', line, callee: '__php_echo__', args: _splitTopLevelCommas(rest).map(_lowerExpr) };
493
+ }
421
494
  // Assignment: $var = expr
422
495
  const assign = s.match(/^(\$[\w]+(?:->[\w]+)*)\s*=\s*(.+)$/s);
423
496
  if (assign) {
@@ -427,11 +500,26 @@ function _lowerStmt(stmt, line) {
427
500
  const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
428
501
  if (call) {
429
502
  const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
430
- return { kind: 'call', line, callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
503
+ const chained = _followChain(s, call.endIdx, callee, _splitTopLevelCommas(call.argsText).map(_lowerExpr));
504
+ return { kind: 'call', line, callee: chained.callee, args: chained.args };
431
505
  }
432
506
  return null;
433
507
  }
434
508
 
509
+ // Taint-recall PRD (80%): `_extractBody` used to have ZERO comment
510
+ // awareness — worse than `_splitStatements` below, which at least skips
511
+ // `//`/`/* */` (though not `#`). An apostrophe inside ANY comment (`//`,
512
+ // `#`, or `/* */`) — "don't", "it's", "won't", all extremely common in real
513
+ // PHP — toggled `inStr` exactly as if a string literal had started. Every
514
+ // `{`/`}` from that point on was then invisible to depth-tracking until a
515
+ // SECOND apostrophe was found (typically much later, or never), so `depth`
516
+ // either never returns to 0 (the function's body extraction fails outright,
517
+ // returning `null` — silently dropping the ENTIRE FILE's IR, since a single
518
+ // failed top-level function match corrupts every span downstream of it) or
519
+ // returns to 0 at the wrong `}` (extracting a truncated or over-extended
520
+ // body). Now skips all three PHP comment forms exactly like
521
+ // `_splitStatements` already does for `//`/`/* */`, plus `#` (PHP 8
522
+ // attributes `#[...]` are NOT a comment — checked via the next character).
435
523
  function _extractBody(src, openBrace) {
436
524
  let depth = 1;
437
525
  let i = openBrace + 1;
@@ -445,6 +533,20 @@ function _extractBody(src, openBrace) {
445
533
  if (c === inStr) inStr = null;
446
534
  i++; continue;
447
535
  }
536
+ if (c === '/' && src[i + 1] === '/') {
537
+ while (i < src.length && src[i] !== '\n') i++;
538
+ continue;
539
+ }
540
+ if (c === '#' && src[i + 1] !== '[') {
541
+ while (i < src.length && src[i] !== '\n') i++;
542
+ continue;
543
+ }
544
+ if (c === '/' && src[i + 1] === '*') {
545
+ i += 2;
546
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
547
+ if (i < src.length) i += 2;
548
+ continue;
549
+ }
448
550
  if (c === '"' || c === '\'') { inStr = c; i++; continue; }
449
551
  if (c === '{') depth++;
450
552
  else if (c === '}') depth--;