@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9

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 (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
@@ -22,6 +22,7 @@
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
24
  import { callSitesFromCfg } from './call-sites.js';
25
+ import { matchBalancedCall } from './balanced-call.js';
25
26
 
26
27
  const FUNC_RE = new RegExp(
27
28
  '(?:^|[\\n;{}])\\s*func\\s+' +
@@ -76,7 +77,11 @@ function _lowerExpr(text) {
76
77
  const s = String(text || '').trim();
77
78
  if (!s) return { kind: 'unknown' };
78
79
  if (/^fmt\.Sprintf\s*\(/.test(s)) {
79
- const inner = s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
80
+ // Same balanced-paren concern as the call patterns below — a chained
81
+ // call after fmt.Sprintf(...) (rare but not impossible via a wrapper)
82
+ // would otherwise have its args text swallow the chain's own parens.
83
+ const sprintf = matchBalancedCall(s, /^fmt\.Sprintf/);
84
+ const inner = sprintf ? sprintf.argsText : s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
80
85
  const parts = _splitTopLevelCommas(inner).map(_lowerExpr);
81
86
  return { kind: 'tpl', parts };
82
87
  }
@@ -91,17 +96,30 @@ function _lowerExpr(text) {
91
96
  if (/^"/.test(s) || /^`/.test(s)) return { kind: 'literal', value: s };
92
97
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
93
98
  if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
94
- // Call: foo.Bar(args) or Bar(args)
95
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
99
+ // Call: foo.Bar(args) or Bar(args). matchBalancedCall finds the paren
100
+ // that actually balances the FIRST '(' — not the greedy-to-end-of-string
101
+ // match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
102
+ // for a chained call (`Sanitize(x).Trim()` produced args="x).Trim(",
103
+ // which then fell through to {kind:'unknown'} and silently dropped x).
104
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
96
105
  if (callMatch) {
97
- const callee = callMatch[1];
98
- const args = _splitTopLevelCommas(callMatch[2]).map(_lowerExpr);
99
- return { kind: 'call', callee, args };
106
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
107
+ return { kind: 'call', callee: callMatch.callee, args };
100
108
  }
101
- // String concat with +
109
+ // String concat with + — a SECOND occurrence of the same check as above
110
+ // (line ~92), reached when the expression didn't match any branch in
111
+ // between (calls, literals, etc.). It's missing the `parts.length > 1`
112
+ // guard the first occurrence has: when the `+` sits inside an extra
113
+ // pair of parens (`("SELECT " + name)` — e.g. an over-parenthesized
114
+ // argument), `_splitTopLevelPlus` returns the input unchanged as ONE
115
+ // part (depth never returns to 0 at the `+`), and `.map(_lowerExpr)`
116
+ // then recurses on the IDENTICAL string forever — a stack overflow,
117
+ // uncaught here (only caught by buildProjectIR's per-file try/catch, so
118
+ // it surfaces only as "this file has no IR"). Same bug class, same fix,
119
+ // as parser-cs.js's `_splitTopLevelPlus` guard for `new Type(concat)`.
102
120
  if (s.includes('+') && /["'`]/.test(s)) {
103
- const parts = _splitTopLevelPlus(s).map(_lowerExpr);
104
- return { kind: 'tpl', parts };
121
+ const parts = _splitTopLevelPlus(s);
122
+ if (parts.length > 1) return { kind: 'tpl', parts: parts.map(_lowerExpr) };
105
123
  }
106
124
  // Member: a.b.c
107
125
  if (/^[A-Za-z_][\w.]*$/.test(s)) {
@@ -220,9 +238,9 @@ function _lowerStmt(stmt, line) {
220
238
  return { kind: 'assign', line, target: varDecl[1], source: _lowerExpr(varDecl[2]) };
221
239
  }
222
240
  // Statement-form call: obj.Method(args) or Method(args)
223
- const cm = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
241
+ const cm = matchBalancedCall(s, /^([\w.]+)/);
224
242
  if (cm) {
225
- return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
243
+ return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
226
244
  }
227
245
  return null;
228
246
  }
@@ -58,6 +58,27 @@ function exprFromCst(node) {
58
58
  // CST node with named children — recurse into the most informative one.
59
59
  // Method invocation
60
60
  if (node.children.methodInvocation) return _methodInvocation(node.children.methodInvocation[0]);
61
+ // `primary` is how java-parser actually models a call: the name lives in
62
+ // primaryPrefix (an fqnOrRefType) and the invocation itself in
63
+ // primarySuffix.methodInvocationSuffix. Without this branch the fall-through
64
+ // below recursed into the prefix and returned the NAME — so
65
+ // `req.getParameter("q")` lowered to a member access, never a call, and no
66
+ // source or sink in the Java catalog could ever match.
67
+ if (node.children.primaryPrefix) {
68
+ const prefix = node.children.primaryPrefix[0];
69
+ const suffixes = node.children.primarySuffix || [];
70
+ const invocation = suffixes
71
+ .map(s => s.children?.methodInvocationSuffix?.[0])
72
+ .find(Boolean);
73
+ if (invocation) {
74
+ const fqn = prefix?.children?.fqnOrRefType?.[0];
75
+ const callee = fqn ? _flattenFqnToString(fqn) : 'unknown';
76
+ const args = (invocation.children?.argumentList?.[0]?.children?.expression || [])
77
+ .map(exprFromCst);
78
+ return { kind: 'call', callee, args };
79
+ }
80
+ return exprFromCst(prefix);
81
+ }
61
82
  // FQN ref
62
83
  if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
63
84
  if (node.children.unqualifiedClassInstanceCreationExpression) {
@@ -89,12 +110,37 @@ function exprFromCst(node) {
89
110
  return { kind: 'unknown' };
90
111
  }
91
112
 
113
+ /**
114
+ * The identifier images of an `fqnOrRefType`, in order.
115
+ *
116
+ * java-parser does NOT put `Identifier` directly on `fqnOrRefType`. The real
117
+ * shape is fqnOrRefTypePartFirst / fqnOrRefTypePartRest → fqnOrRefTypePartCommon
118
+ * → Identifier, so reading `children.Identifier` returned nothing for every
119
+ * real-world expression and both callers below degraded to 'unknown'. The
120
+ * direct-Identifier form is still accepted as a fallback.
121
+ */
122
+ function _fqnIdents(node) {
123
+ if (!node || !node.children) return [];
124
+ const direct = node.children.Identifier;
125
+ if (Array.isArray(direct) && direct.length) return direct.map(t => t.image);
126
+ const out = [];
127
+ const parts = [
128
+ ...(node.children.fqnOrRefTypePartFirst || []),
129
+ ...(node.children.fqnOrRefTypePartRest || []),
130
+ ];
131
+ for (const p of parts) {
132
+ const common = p.children?.fqnOrRefTypePartCommon?.[0];
133
+ const id = common?.children?.Identifier?.[0]?.image;
134
+ if (id) out.push(id);
135
+ }
136
+ return out;
137
+ }
138
+
92
139
  function _fqnExpr(node) {
93
- // java-parser fqnOrRefType has children { Identifier: [...] } sometimes
94
- // and Dot tokens between them.
95
140
  if (!node || !node.children) return { kind: 'unknown' };
96
- const ids = node.children.Identifier;
97
- if (!ids || !ids.length) return { kind: 'unknown' };
141
+ const idImages = _fqnIdents(node);
142
+ if (!idImages.length) return { kind: 'unknown' };
143
+ const ids = idImages.map(image => ({ image }));
98
144
  let cur = { kind: 'ident', name: ids[0].image };
99
145
  for (let i = 1; i < ids.length; i++) {
100
146
  cur = { kind: 'member', object: cur, prop: ids[i].image };
@@ -118,10 +164,28 @@ function _methodInvocation(node) {
118
164
  }
119
165
 
120
166
  function _flattenFqnToString(node) {
121
- if (!node || !node.children) return 'unknown';
122
- const ids = node.children.Identifier;
123
- if (!ids) return 'unknown';
124
- return ids.map(t => t.image).join('.');
167
+ const ids = _fqnIdents(node);
168
+ return ids.length ? ids.join('.') : 'unknown';
169
+ }
170
+
171
+ // Descend to the first leaf token in a CST subtree and return its
172
+ // startLine. java-parser (chevrotain) leaf tokens carry startLine directly;
173
+ // rule nodes only carry `children`. Returns 0 when nothing is found (e.g.
174
+ // an empty node), which callers already treat as "unknown".
175
+ function _lineOf(node) {
176
+ if (!node) return 0;
177
+ if (Array.isArray(node)) {
178
+ for (const n of node) { const l = _lineOf(n); if (l) return l; }
179
+ return 0;
180
+ }
181
+ if (typeof node.startLine === 'number') return node.startLine;
182
+ if (node.children) {
183
+ for (const k of Object.keys(node.children)) {
184
+ const l = _lineOf(node.children[k]);
185
+ if (l) return l;
186
+ }
187
+ }
188
+ return 0;
125
189
  }
126
190
 
127
191
  /**
@@ -132,7 +196,7 @@ function _flattenFqnToString(node) {
132
196
  * `if` / `loop-header` node and the body falls through linearly. This is
133
197
  * coarser than the JS frontend; v2 will branch the succ array.
134
198
  */
135
- function buildCfgFromBody(bodyNode, line) {
199
+ function buildCfgFromBody(bodyNode) {
136
200
  const nodes = {};
137
201
  const entry = nextNodeId();
138
202
  const exit = nextNodeId();
@@ -157,6 +221,14 @@ function buildCfgFromBody(bodyNode, line) {
157
221
  function walkStmts(stmtNode) {
158
222
  if (!stmtNode || !stmtNode.children) return;
159
223
  const kids = stmtNode.children;
224
+ // java-parser nests a `block` as block → blockStatements (PLURAL, an
225
+ // intermediate rule node) → blockStatement. Walking only `blockStatement`
226
+ // from the block node therefore descended into nothing and every method
227
+ // body lowered to an empty CFG — which is why Java had IR functions with
228
+ // entry/exit and no statements, and taint could never fire.
229
+ if (kids.blockStatements) {
230
+ for (const bss of kids.blockStatements) walkStmts(bss);
231
+ }
160
232
  // Block statement children
161
233
  if (kids.blockStatement) {
162
234
  for (const bs of kids.blockStatement) walkStmts(bs);
@@ -170,7 +242,7 @@ function buildCfgFromBody(bodyNode, line) {
170
242
  const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
171
243
  const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0];
172
244
  if (target) {
173
- emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: line || 0, succ: [] });
245
+ emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(lv), succ: [] });
174
246
  }
175
247
  }
176
248
  }
@@ -186,34 +258,34 @@ function buildCfgFromBody(bodyNode, line) {
186
258
  const e = kids.expressionStatement[0]?.children?.statementExpression?.[0]?.children?.expression?.[0];
187
259
  if (e) {
188
260
  const expr = exprFromCst(e);
189
- if (expr.kind === 'call') emit({ ...expr, line: line || 0, succ: [] });
261
+ if (expr.kind === 'call') emit({ ...expr, line: _lineOf(kids.expressionStatement[0]), succ: [] });
190
262
  else if (expr.kind === 'binary' && expr.op === '=') {
191
263
  // assignment expr `x = y;`
192
- emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: line || 0, succ: [] });
264
+ emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(kids.expressionStatement[0]), succ: [] });
193
265
  }
194
266
  }
195
267
  }
196
268
  if (kids.returnStatement) {
197
269
  const r = kids.returnStatement[0];
198
270
  const expr = r.children?.expression?.[0];
199
- emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: line || 0, succ: [] });
271
+ emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: _lineOf(r), succ: [] });
200
272
  }
201
273
  if (kids.throwStatement) {
202
274
  const t = kids.throwStatement[0];
203
275
  const expr = t.children?.expression?.[0];
204
- emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: line || 0, succ: [] });
276
+ emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: _lineOf(t), succ: [] });
205
277
  }
206
278
  if (kids.ifStatement) {
207
279
  const i = kids.ifStatement[0];
208
280
  const cond = i.children?.expression?.[0];
209
- emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: line || 0, succ: [] });
281
+ emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(i), succ: [] });
210
282
  // Then branch body falls through linearly; v1 simplification.
211
283
  for (const sub of (i.children?.statement || [])) walkStmts(sub);
212
284
  }
213
285
  if (kids.whileStatement) {
214
286
  const w = kids.whileStatement[0];
215
287
  const cond = w.children?.expression?.[0];
216
- emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: line || 0, succ: [] });
288
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(w), succ: [] });
217
289
  for (const sub of (w.children?.statement || [])) walkStmts(sub);
218
290
  }
219
291
  }
@@ -266,12 +338,17 @@ export async function parseJavaFile(file, raw) {
266
338
  const params = []; // params extraction deferred
267
339
  const body = md.children?.methodBody?.[0]?.children?.block?.[0];
268
340
  if (body) {
341
+ const methodLine = _lineOf(md);
269
342
  functions.push({
270
- qid: `${file}::${className || 'class'}::${name}`,
343
+ // Sibling frontends (parser-js.js) suffix the qid with `@line`
344
+ // so overloaded/same-named methods don't collide — without it,
345
+ // callgraph.js's `functions.set(fn.qid, fn)` silently drops
346
+ // every overload but the last one declared.
347
+ qid: `${file}::${className || 'class'}::${name}@${methodLine}`,
271
348
  name: className ? `${className}.${name}` : name,
272
- line: 0,
349
+ line: methodLine,
273
350
  params,
274
- cfg: buildCfgFromBody(body, 0),
351
+ cfg: buildCfgFromBody(body),
275
352
  file,
276
353
  });
277
354
  }
@@ -98,7 +98,22 @@ function lhsPath(n) {
98
98
  if (n.type === 'ThisExpression') return '_this_';
99
99
  if (n.type === 'MemberExpression') {
100
100
  const base = lhsPath(n.object);
101
- const prop = n.computed ? '*' : (n.property?.name || '*');
101
+ // Stage 3 correctness audit (detection depth, path-feasibility): a
102
+ // computed WRITE with a literal key (`obj['secret'] = tainted`) used
103
+ // to collapse straight to the wildcard '*' — never trying to extract
104
+ // the literal, unlike exprOf's MemberExpression case just above,
105
+ // which DOES extract it for reads. That asymmetry meant a tainted
106
+ // write via bracket notation with a literal key produced access path
107
+ // "obj.*", while any later read of that same key (`obj.secret` or
108
+ // `obj['secret']`) resolves via exprOf to the specific path
109
+ // "obj.secret" — isCoveredBy has no wildcard semantics (`'*'` is a
110
+ // literal property name here, not a match-anything token), so the two
111
+ // paths never matched and the taint was silently unreachable from any
112
+ // correctly-computed read. Mirrors exprOf's extraction exactly so a
113
+ // write and a read of the same literal key always agree.
114
+ const prop = n.computed
115
+ ? (n.property?.value != null ? String(n.property.value) : '*')
116
+ : (n.property?.name || '*');
102
117
  if (!base) return null;
103
118
  return base + '.' + prop;
104
119
  }
@@ -138,16 +153,25 @@ export function parseJsFile(file, code) {
138
153
  const exitId = nextNodeId();
139
154
  const fn = {
140
155
  qid, name: name || 'anon', line,
156
+ // Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
157
+ // ['arg1', 'arg2', ...]") — every other parser (py, cs, rb, cpp, ...)
158
+ // already emits this shape. This one used to emit {name,kind[,props]}
159
+ // objects instead, which every consumer (access-paths.js's
160
+ // isCoveredBy, summaries.js's paramNames.indexOf, entryStateFromCall,
161
+ // the k=2 pass's `new Set(fn.params)`) silently failed to match against
162
+ // — Set/string-equality checks against an object never succeed, so
163
+ // mutated-parameter taint and context-sensitive entry states were both
164
+ // unconditionally inert for every JS/TS function. Nothing in src/
165
+ // reads a param's .kind or .props, so the richer shape bought nothing.
141
166
  params: (params || []).map(p => {
142
167
  if (!p) return null;
143
- if (p.type === 'Identifier') return { name: p.name, kind: 'ident' };
144
- if (p.type === 'ObjectPattern') return { name: '<obj>', kind: 'object-pattern',
145
- props: p.properties.map(pp => ({
146
- key: pp.key?.name || (pp.key?.value != null ? String(pp.key.value) : '*'),
147
- alias: lhsPath(pp.value),
148
- })) };
149
- if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return { name: p.left.name, kind: 'ident' };
150
- if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return { name: p.argument.name, kind: 'rest' };
168
+ if (p.type === 'Identifier') return p.name;
169
+ if (p.type === 'ObjectPattern') return '<obj>';
170
+ if (p.type === 'ArrayPattern') return '<arr>';
171
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return p.left.name;
172
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'ObjectPattern') return '<obj>';
173
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'ArrayPattern') return '<arr>';
174
+ if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return p.argument.name;
151
175
  return null;
152
176
  }).filter(Boolean),
153
177
  cfg: { entry: entryId, exit: exitId, nodes: new Map() },
@@ -189,6 +213,38 @@ export function parseJsFile(file, code) {
189
213
  return node.id;
190
214
  };
191
215
 
216
+ // Babel visits an IfStatement's consequent then its alternate as ordinary
217
+ // children of the same enter/exit pair, with no boundary hook between
218
+ // them — so fn._cursor was never reset before the alternate was
219
+ // traversed: the alternate's first node was linked as a successor of the
220
+ // CONSEQUENT's tail (a false predecessor edge corrupting any taint-state
221
+ // reasoning across the join) rather than a true second branch off the
222
+ // condition (also silently dropping the condition's "false" outgoing
223
+ // edge, which made applyPathFeasibility's constant-condition pruning
224
+ // treat the `if` as unconditional and delete its only edge).
225
+ //
226
+ // Called from the generic `Statement` visitor below AND from the top of
227
+ // every handler whose node type can itself be the direct (unbraced) root
228
+ // of an if's alternate (ReturnStatement, ThrowStatement, IfStatement for
229
+ // else-if chains, the loop statements, TryStatement) — Babel merges
230
+ // alias-derived and type-specific visitors for the same node but runs the
231
+ // type-specific one FIRST, so relying on the `Statement` alias alone
232
+ // misses every alternate root that also has its own specific handler.
233
+ // The one-shot flag makes the (redundant, second) `Statement` firing a
234
+ // safe no-op once the type-specific handler already did the reset.
235
+ const maybeResetAtAlternateBoundary = (fn, path) => {
236
+ const parent = path.parentPath && path.parentPath.node;
237
+ if (!parent || parent.type !== 'IfStatement' || parent.alternate !== path.node) return;
238
+ if (path.node._alternateBoundaryHandled) return;
239
+ path.node._alternateBoundaryHandled = true;
240
+ // fn._cursor is still the consequent's tail (or the condition node
241
+ // itself, if the consequent added none) — converge it into the join
242
+ // before abandoning it, then start the alternate from the condition,
243
+ // exactly like the consequent did.
244
+ linkCfg(fn, fn._cursor, parent._asJoin);
245
+ fn._cursor = parent._asCond;
246
+ };
247
+
192
248
  const recordWrite = (fn, target, source, nodeId) => {
193
249
  if (!target || typeof target !== 'string') return;
194
250
  if (!fn.writes.has(target)) fn.writes.set(target, []);
@@ -266,17 +322,45 @@ export function parseJsFile(file, code) {
266
322
  const id = lhsPath(path.node.id);
267
323
  if (!id) return;
268
324
  const initExpr = exprOf(path.node.init);
269
- const nodeId = nextNodeId();
270
325
  const line = path.node.loc?.start?.line || 0;
271
- addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
272
- if (typeof id === 'string') recordWrite(fn, id, initExpr, nodeId);
326
+ if (typeof id === 'string') {
327
+ const nodeId = nextNodeId();
328
+ addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
329
+ recordWrite(fn, id, initExpr, nodeId);
330
+ return;
331
+ }
332
+ // Destructuring: `const {a, b: renamed} = obj;` / `const [a, b] = arr;`.
333
+ // Emit one REAL 'assign' CFG node per bound name — not just a
334
+ // recordWrite() bookkeeping entry — because the taint engine's
335
+ // step() 'assign' case reads `node.target` directly and requires
336
+ // a plain string (`typeof node.target === 'string'`); the single
337
+ // node this used to emit had target=the whole {kind:'object-
338
+ // pattern'|'array-pattern', ...} OBJECT, which step() always
339
+ // treated as `target=null` — so taint from `obj`/`arr` never
340
+ // reached ANY destructured binding, for every project that uses
341
+ // this extremely common pattern. fn.writes/recordWrite (used
342
+ // below too) is bookkeeping nothing in dataflow/ ever reads;
343
+ // only real CFG nodes matter to the walk.
273
344
  if (id && typeof id === 'object' && id.kind === 'object-pattern') {
274
- // x = { foo: a, bar: b } — emit one write per property.
275
345
  for (const p of id.props) {
276
346
  const alias = typeof p.alias === 'string' ? p.alias : null;
277
347
  if (!alias) continue;
278
- recordWrite(fn, alias, { kind: 'member', object: initExpr, prop: p.key }, nodeId);
348
+ const memberSrc = { kind: 'member', object: initExpr, prop: p.key };
349
+ const nodeId = nextNodeId();
350
+ addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
351
+ recordWrite(fn, alias, memberSrc, nodeId);
279
352
  }
353
+ return;
354
+ }
355
+ if (id && typeof id === 'object' && id.kind === 'array-pattern') {
356
+ id.elements.forEach((el, i) => {
357
+ const alias = typeof el === 'string' ? el : null;
358
+ if (!alias) return; // elision (`const [, b] = arr`) or nested pattern — not modeled
359
+ const memberSrc = { kind: 'member', object: initExpr, prop: String(i) };
360
+ const nodeId = nextNodeId();
361
+ addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
362
+ recordWrite(fn, alias, memberSrc, nodeId);
363
+ });
280
364
  }
281
365
  },
282
366
 
@@ -298,7 +382,29 @@ export function parseJsFile(file, code) {
298
382
  const parent = path.parent;
299
383
  if (parent && (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression')) return;
300
384
  const calleeExpr = exprOf(path.node.callee);
301
- const args = (path.node.arguments || []).map(exprOf);
385
+ // An inline arrow/function-expression argument (`arr.map(x => ...)`)
386
+ // becomes {kind:'function-value', qid} instead of exprOf's generic
387
+ // {kind:'unknown'} fallback (exprOf has no case for either node
388
+ // type) — dataflow/higher-order.js's calleeIsResolvableCallback and
389
+ // engine.js's higher-order-invocation push site both special-case
390
+ // 'function-value', but nothing ever produced one for JS/TS, so
391
+ // higher-order taint flow only ever worked for a by-reference
392
+ // callback (`arr.map(processItem)`), never the far more common
393
+ // inline-callback shape. The qid is computed to match EXACTLY what
394
+ // this same node's own ArrowFunctionExpression/FunctionExpression
395
+ // visitor (below) will independently compute when Babel's
396
+ // traversal reaches it: for a function literal passed directly as
397
+ // a call argument, none of that visitor's naming heuristics
398
+ // (VariableDeclarator/AssignmentExpression/ObjectProperty parent)
399
+ // match, so it always resolves to name 'anon' scoped under the
400
+ // CURRENT function — exactly what's available here.
401
+ const args = (path.node.arguments || []).map(a => {
402
+ if (a && (a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression')) {
403
+ const argLine = a.loc?.start?.line || 1; // matches enterFn's own fallback
404
+ return { kind: 'function-value', qid: fnQid(file, fn.name, 'anon', argLine) };
405
+ }
406
+ return exprOf(a);
407
+ });
302
408
  const line = path.node.loc?.start?.line || 0;
303
409
  const nodeId = nextNodeId();
304
410
  addNode(fn, { id: nodeId, kind: 'call', callee: calleeExpr, args, line, succ: [], pred: [] });
@@ -313,6 +419,7 @@ export function parseJsFile(file, code) {
313
419
 
314
420
  ReturnStatement(path) {
315
421
  const fn = currentFn(); if (!fn) return;
422
+ maybeResetAtAlternateBoundary(fn, path);
316
423
  const expr = path.node.argument ? exprOf(path.node.argument) : null;
317
424
  const nodeId = nextNodeId();
318
425
  const line = path.node.loc?.start?.line || 0;
@@ -322,12 +429,30 @@ export function parseJsFile(file, code) {
322
429
  linkCfg(fn, nodeId, fn.cfg.exit);
323
430
  },
324
431
 
432
+ // Fires for every statement node, via Babel's built-in "Statement"
433
+ // alias — the fallback path for whichever concrete type an if's
434
+ // `alternate` turns out to be when that type has no specific
435
+ // handler of its own (BlockStatement, ExpressionStatement,
436
+ // VariableDeclaration, ...). Types that DO have a specific handler
437
+ // below (ReturnStatement, IfStatement, the loop statements,
438
+ // TryStatement) call `maybeResetAtAlternateBoundary` themselves,
439
+ // since Babel runs a node's type-specific visitor BEFORE its
440
+ // alias-derived one for the same node — relying on this alone would
441
+ // miss those cases entirely.
442
+ Statement: {
443
+ enter(path) {
444
+ const fn = currentFn(); if (!fn) return;
445
+ maybeResetAtAlternateBoundary(fn, path);
446
+ },
447
+ },
448
+
325
449
  IfStatement: {
326
450
  enter(path) {
327
451
  // We model branches by inserting a noop "join" after the if; both
328
452
  // branches link to it. Without this, the linear cursor model would
329
453
  // miss that statements after the if are reachable from either branch.
330
454
  const fn = currentFn(); if (!fn) return;
455
+ maybeResetAtAlternateBoundary(fn, path);
331
456
  const condNodeId = nextNodeId();
332
457
  const joinId = nextNodeId();
333
458
  const line = path.node.loc?.start?.line || 0;
@@ -335,7 +460,6 @@ export function parseJsFile(file, code) {
335
460
  fn.cfg.nodes.set(joinId, { id: joinId, kind: 'noop', succ: [], pred: [], line });
336
461
  path.node._asJoin = joinId;
337
462
  path.node._asCond = condNodeId;
338
- path.node._asBranchSavedCursor = fn._cursor; // == condNodeId
339
463
  },
340
464
  exit(path) {
341
465
  const fn = currentFn(); if (!fn) return;
@@ -344,9 +468,10 @@ export function parseJsFile(file, code) {
344
468
  if (!joinId || !condId) return;
345
469
  // The visitor visited the body of the if — Babel's body visit ran
346
470
  // *after* the enter(), so fn._cursor now points to the tail of the
347
- // consequent. Connect it to the join, then if no else branch
348
- // existed, connect the cond directly to the join (representing
349
- // the "false" edge).
471
+ // consequent (or, thanks to maybeResetAtAlternateBoundary, of the
472
+ // alternate when one exists). Connect it to the join, then if no
473
+ // else branch existed, connect the cond directly to the join
474
+ // (representing the "false" edge).
350
475
  linkCfg(fn, fn._cursor, joinId);
351
476
  if (!path.node.alternate) linkCfg(fn, condId, joinId);
352
477
  fn._cursor = joinId;
@@ -359,6 +484,7 @@ export function parseJsFile(file, code) {
359
484
  'WhileStatement|ForStatement|DoWhileStatement|ForInStatement|ForOfStatement': {
360
485
  enter(path) {
361
486
  const fn = currentFn(); if (!fn) return;
487
+ maybeResetAtAlternateBoundary(fn, path);
362
488
  const headerId = nextNodeId();
363
489
  const exitId = nextNodeId();
364
490
  const line = path.node.loc?.start?.line || 0;
@@ -379,11 +505,16 @@ export function parseJsFile(file, code) {
379
505
  },
380
506
 
381
507
  TryStatement: {
382
- enter() { /* approximate try/catch as sequential — taint flows through both */ },
508
+ enter(path) {
509
+ const fn = currentFn(); if (!fn) return;
510
+ maybeResetAtAlternateBoundary(fn, path);
511
+ /* approximate try/catch as sequential — taint flows through both */
512
+ },
383
513
  },
384
514
 
385
515
  ThrowStatement(path) {
386
516
  const fn = currentFn(); if (!fn) return;
517
+ maybeResetAtAlternateBoundary(fn, path);
387
518
  const expr = exprOf(path.node.argument);
388
519
  const nodeId = nextNodeId();
389
520
  const line = path.node.loc?.start?.line || 0;
@@ -19,6 +19,7 @@
19
19
 
20
20
  import * as crypto from 'node:crypto';
21
21
  import { callSitesFromCfg } from './call-sites.js';
22
+ import { matchBalancedCall } from './balanced-call.js';
22
23
 
23
24
  const FUNC_RE = new RegExp(
24
25
  '(?:^|[\\n;{}]|<\\?php|<\\?)\\s*' +
@@ -66,6 +67,35 @@ function _splitStatements(body) {
66
67
  function _lowerExpr(text) {
67
68
  const s = String(text || '').trim();
68
69
  if (!s) return { kind: 'unknown' };
70
+ // Stage 3 correctness audit (detection depth, per-language-IR): PHP
71
+ // double-quoted strings interpolate variables directly
72
+ // ("SELECT ... WHERE id=$id", "hi {$user->name}") — single-quoted
73
+ // strings never do. This must run BEFORE the generic string-literal
74
+ // fallback just below, which otherwise treated ANY quoted string
75
+ // (including a double-quoted one containing a live variable) as an
76
+ // opaque clean literal — silently dropping the interpolated variable's
77
+ // taint. This is exactly `"SELECT ... WHERE id=$id"`, one of the most
78
+ // common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
79
+ // `$var[key]`) and complex (`{$expr}`) interpolation forms are both
80
+ // lowered into a template, same shape as the `.`-concat branch below.
81
+ if (/^"/.test(s) && s.includes('$')) {
82
+ const inner = s.slice(1, -1);
83
+ const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
84
+ let lastIndex = 0;
85
+ const parts = [];
86
+ let matched = false;
87
+ let m;
88
+ while ((m = re.exec(inner)) !== null) {
89
+ matched = true;
90
+ if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
91
+ parts.push(_lowerExpr(m[1] !== undefined ? m[1] : m[2]));
92
+ lastIndex = re.lastIndex;
93
+ }
94
+ if (matched) {
95
+ if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
96
+ return { kind: 'tpl', parts };
97
+ }
98
+ }
69
99
  if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
70
100
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
71
101
  if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
@@ -81,17 +111,22 @@ function _lowerExpr(text) {
81
111
  }
82
112
  // Variable
83
113
  if (/^\$[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
84
- // Method call: $obj->method(args) or ClassName::method(args)
85
- const methodCall = s.match(/^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)\s*\((.*)\)\s*$/s);
114
+ // Method call: $obj->method(args) or ClassName::method(args).
115
+ // matchBalancedCall finds the paren that actually balances the FIRST
116
+ // '(' — not the greedy-to-end-of-string match the old `/\((.*)\)\s*$/`
117
+ // used, which corrupted the argument text for a chained call
118
+ // (`sanitize($x)->trim()` produced args="$x)->trim(", which then fell
119
+ // through to {kind:'unknown'} and silently dropped $x).
120
+ const methodCall = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
86
121
  if (methodCall) {
87
- const callee = methodCall[1].replace(/->/g, '.').replace(/::/g, '.');
88
- const args = _splitTopLevelCommas(methodCall[2]).map(_lowerExpr);
122
+ const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
123
+ const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
89
124
  return { kind: 'call', callee, args };
90
125
  }
91
126
  // Function call: func(args)
92
- const funcCall = s.match(/^([A-Za-z_][\w]*)\s*\((.*)\)\s*$/s);
127
+ const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
93
128
  if (funcCall) {
94
- return { kind: 'call', callee: funcCall[1], args: _splitTopLevelCommas(funcCall[2]).map(_lowerExpr) };
129
+ return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
95
130
  }
96
131
  // Concat with .
97
132
  if (s.includes('.') && /["'\$]/.test(s)) {
@@ -170,10 +205,10 @@ function _lowerStmt(stmt, line) {
170
205
  return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
171
206
  }
172
207
  // Statement-form call
173
- const call = s.match(/^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)\s*\((.*)\)\s*$/s);
208
+ const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
174
209
  if (call) {
175
- const callee = call[1].replace(/->/g, '.').replace(/::/g, '.');
176
- return { kind: 'call', line, callee, args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
210
+ const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
211
+ return { kind: 'call', line, callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
177
212
  }
178
213
  return null;
179
214
  }