@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -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 +192 -15
  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 +15 -15
  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 +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -63,11 +63,23 @@ function exprOf(n) {
63
63
  prop: n.computed ? (n.property?.value != null ? String(n.property.value) : '*') : (n.property?.name || '*'),
64
64
  };
65
65
  case 'CallExpression':
66
- case 'OptionalCallExpression':
66
+ case 'OptionalCallExpression': return {
67
+ kind: 'call',
68
+ callee: exprOf(n.callee),
69
+ args: (n.arguments || []).map(exprOf),
70
+ };
71
+ // `new Foo()` is emitted as a call PLUS an `isNew: true` marker, matching
72
+ // what parser-java.js and parser-cs.js already emit. Without the marker a
73
+ // `new Foo()` is byte-identical in the IR to a plain `Foo()` call, and
74
+ // class-hierarchy.js's typeOfVar walker therefore typed `const x =
75
+ // SomeFactoryFn()` as class `SomeFactoryFn` — a fabricated type that then
76
+ // reaches the dataflow receiver-type gate and can suppress a real finding.
77
+ // Nothing else in the engine reads `isNew`, so this is purely additive.
67
78
  case 'NewExpression': return {
68
79
  kind: 'call',
69
80
  callee: exprOf(n.callee),
70
81
  args: (n.arguments || []).map(exprOf),
82
+ isNew: true,
71
83
  };
72
84
  case 'BinaryExpression': return { kind: 'binary', op: n.operator, left: exprOf(n.left), right: exprOf(n.right) };
73
85
  case 'LogicalExpression': return { kind: 'logical', op: n.operator, left: exprOf(n.left), right: exprOf(n.right) };
@@ -98,7 +110,22 @@ function lhsPath(n) {
98
110
  if (n.type === 'ThisExpression') return '_this_';
99
111
  if (n.type === 'MemberExpression') {
100
112
  const base = lhsPath(n.object);
101
- const prop = n.computed ? '*' : (n.property?.name || '*');
113
+ // Stage 3 correctness audit (detection depth, path-feasibility): a
114
+ // computed WRITE with a literal key (`obj['secret'] = tainted`) used
115
+ // to collapse straight to the wildcard '*' — never trying to extract
116
+ // the literal, unlike exprOf's MemberExpression case just above,
117
+ // which DOES extract it for reads. That asymmetry meant a tainted
118
+ // write via bracket notation with a literal key produced access path
119
+ // "obj.*", while any later read of that same key (`obj.secret` or
120
+ // `obj['secret']`) resolves via exprOf to the specific path
121
+ // "obj.secret" — isCoveredBy has no wildcard semantics (`'*'` is a
122
+ // literal property name here, not a match-anything token), so the two
123
+ // paths never matched and the taint was silently unreachable from any
124
+ // correctly-computed read. Mirrors exprOf's extraction exactly so a
125
+ // write and a read of the same literal key always agree.
126
+ const prop = n.computed
127
+ ? (n.property?.value != null ? String(n.property.value) : '*')
128
+ : (n.property?.name || '*');
102
129
  if (!base) return null;
103
130
  return base + '.' + prop;
104
131
  }
@@ -136,18 +163,54 @@ export function parseJsFile(file, code) {
136
163
  const qid = fnQid(file, scopeName, name, line);
137
164
  const entryId = nextNodeId();
138
165
  const exitId = nextNodeId();
166
+ const paramAnnotations = [];
139
167
  const fn = {
140
168
  qid, name: name || 'anon', line,
141
- params: (params || []).map(p => {
169
+ // Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
170
+ // ['arg1', 'arg2', ...]") — every other parser (py, cs, rb, cpp, ...)
171
+ // already emits this shape. This one used to emit {name,kind[,props]}
172
+ // objects instead, which every consumer (access-paths.js's
173
+ // isCoveredBy, summaries.js's paramNames.indexOf, entryStateFromCall,
174
+ // the k=2 pass's `new Set(fn.params)`) silently failed to match against
175
+ // — Set/string-equality checks against an object never succeed, so
176
+ // mutated-parameter taint and context-sensitive entry states were both
177
+ // unconditionally inert for every JS/TS function. Nothing in src/
178
+ // reads a param's .kind or .props, so the richer shape bought nothing.
179
+ params: (params || []).map((p, idx) => {
142
180
  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' };
181
+ // NestJS/Angular-style parameter decorators (@Query(), @Body(), etc.)
182
+ // Babel attaches these to the raw param node as `p.decorators`
183
+ // (or `p.left.decorators` for a defaulted param, e.g. `@Query() page
184
+ // = 1` a very common NestJS idiom). This is a real array; every
185
+ // entry must be captured, not just the first, since stacked
186
+ // decorators (@Query() @SomeOtherDecorator() x) are legal and
187
+ // dropping later ones silently loses the source-relevant one.
188
+ //
189
+ // For a defaulted param, `p.type` is 'AssignmentPattern', never
190
+ // 'Identifier' — the identifier that actually carries the decorator
191
+ // and the name lives at `p.left`. The guard below must check THAT
192
+ // resolved node's type, not `p.type` unconditionally, or every
193
+ // defaulted-identifier decorated parameter silently loses its
194
+ // decorator (fix round 1, R14(a) Task 4).
195
+ const resolvedIdent = p.type === 'AssignmentPattern' ? p.left : p;
196
+ const decoratorNodes = p.decorators || (p.left && p.left.decorators) || [];
197
+ for (const d of decoratorNodes) {
198
+ const expr = d.expression;
199
+ const decoratorName = expr?.type === 'CallExpression' ? expr.callee?.name : expr?.name;
200
+ // Only record when the parameter itself resolves to a plain
201
+ // identifier — decorators on destructured params are rare and
202
+ // out of scope for this plan.
203
+ if (decoratorName && resolvedIdent?.type === 'Identifier') {
204
+ paramAnnotations.push({ index: idx, name: resolvedIdent.name, decorator: decoratorName });
205
+ }
206
+ }
207
+ if (p.type === 'Identifier') return p.name;
208
+ if (p.type === 'ObjectPattern') return '<obj>';
209
+ if (p.type === 'ArrayPattern') return '<arr>';
210
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return p.left.name;
211
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'ObjectPattern') return '<obj>';
212
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'ArrayPattern') return '<arr>';
213
+ if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return p.argument.name;
151
214
  return null;
152
215
  }).filter(Boolean),
153
216
  cfg: { entry: entryId, exit: exitId, nodes: new Map() },
@@ -157,6 +220,7 @@ export function parseJsFile(file, code) {
157
220
  writes: new Map(),
158
221
  file,
159
222
  _cursor: entryId, // current node ID — next addNode() links from here
223
+ ...(paramAnnotations.length ? { paramAnnotations } : {}),
160
224
  };
161
225
  fn.cfg.nodes.set(entryId, { id: entryId, kind: 'entry', succ: [], pred: [], line });
162
226
  fn.cfg.nodes.set(exitId, { id: exitId, kind: 'exit', succ: [], pred: [], line });
@@ -189,6 +253,38 @@ export function parseJsFile(file, code) {
189
253
  return node.id;
190
254
  };
191
255
 
256
+ // Babel visits an IfStatement's consequent then its alternate as ordinary
257
+ // children of the same enter/exit pair, with no boundary hook between
258
+ // them — so fn._cursor was never reset before the alternate was
259
+ // traversed: the alternate's first node was linked as a successor of the
260
+ // CONSEQUENT's tail (a false predecessor edge corrupting any taint-state
261
+ // reasoning across the join) rather than a true second branch off the
262
+ // condition (also silently dropping the condition's "false" outgoing
263
+ // edge, which made applyPathFeasibility's constant-condition pruning
264
+ // treat the `if` as unconditional and delete its only edge).
265
+ //
266
+ // Called from the generic `Statement` visitor below AND from the top of
267
+ // every handler whose node type can itself be the direct (unbraced) root
268
+ // of an if's alternate (ReturnStatement, ThrowStatement, IfStatement for
269
+ // else-if chains, the loop statements, TryStatement) — Babel merges
270
+ // alias-derived and type-specific visitors for the same node but runs the
271
+ // type-specific one FIRST, so relying on the `Statement` alias alone
272
+ // misses every alternate root that also has its own specific handler.
273
+ // The one-shot flag makes the (redundant, second) `Statement` firing a
274
+ // safe no-op once the type-specific handler already did the reset.
275
+ const maybeResetAtAlternateBoundary = (fn, path) => {
276
+ const parent = path.parentPath && path.parentPath.node;
277
+ if (!parent || parent.type !== 'IfStatement' || parent.alternate !== path.node) return;
278
+ if (path.node._alternateBoundaryHandled) return;
279
+ path.node._alternateBoundaryHandled = true;
280
+ // fn._cursor is still the consequent's tail (or the condition node
281
+ // itself, if the consequent added none) — converge it into the join
282
+ // before abandoning it, then start the alternate from the condition,
283
+ // exactly like the consequent did.
284
+ linkCfg(fn, fn._cursor, parent._asJoin);
285
+ fn._cursor = parent._asCond;
286
+ };
287
+
192
288
  const recordWrite = (fn, target, source, nodeId) => {
193
289
  if (!target || typeof target !== 'string') return;
194
290
  if (!fn.writes.has(target)) fn.writes.set(target, []);
@@ -263,20 +359,77 @@ export function parseJsFile(file, code) {
263
359
 
264
360
  VariableDeclarator(path) {
265
361
  const fn = currentFn(); if (!fn) return;
362
+ // PRD R13(b): `for (const item of tainted)` is, structurally, also
363
+ // an ordinary VariableDeclarator (`item`, no `init`) — Babel visits
364
+ // it as a normal child of the ForOfStatement's `left` on the way
365
+ // into the loop body. Left to the general case below, this generic
366
+ // visit fires AFTER the loop visitor's enter() hook synthesizes
367
+ // `item = <iterated expr>`, re-assigning `item` from an absent
368
+ // `init` (source: unknown) and silently erasing the taint just
369
+ // synthesized. Skip it here ONLY for the simple-identifier shape
370
+ // the loop visitor actually owns and synthesizes for — a bare
371
+ // `for (const item of ...)` binding.
372
+ //
373
+ // A destructuring for-of binding (`for (const {a,b} of ...)` /
374
+ // `for (const [a,b] of ...)`) is explicitly NOT covered by the
375
+ // loop visitor's synthesis (see the ForOfStatement branch below —
376
+ // `loopVar` stays null and nothing is emitted for it), so it MUST
377
+ // fall through to the general destructuring handling further down
378
+ // in this same visitor. That handling was already here before this
379
+ // task and does something this guard must not break: it emits real
380
+ // taint-KILL assign nodes for each destructured name, which is what
381
+ // makes `let cmd = tainted; for (const {cmd} of SAFE) sink(cmd)`
382
+ // correctly clear cmd's stale outer taint. An earlier version of
383
+ // this guard matched on the ForOfStatement `left` alone (no
384
+ // Identifier check) and silently deleted those taint-kill nodes,
385
+ // producing a false positive on exactly that shadowing shape — see
386
+ // the regression test below.
387
+ const gpDecl = path.parentPath && path.parentPath.node;
388
+ const gpLoop = path.parentPath && path.parentPath.parentPath && path.parentPath.parentPath.node;
389
+ if (gpLoop && gpLoop.type === 'ForOfStatement' && gpLoop.left === gpDecl
390
+ && path.node.id?.type === 'Identifier') return;
266
391
  const id = lhsPath(path.node.id);
267
392
  if (!id) return;
268
393
  const initExpr = exprOf(path.node.init);
269
- const nodeId = nextNodeId();
270
394
  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);
395
+ if (typeof id === 'string') {
396
+ const nodeId = nextNodeId();
397
+ addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
398
+ recordWrite(fn, id, initExpr, nodeId);
399
+ return;
400
+ }
401
+ // Destructuring: `const {a, b: renamed} = obj;` / `const [a, b] = arr;`.
402
+ // Emit one REAL 'assign' CFG node per bound name — not just a
403
+ // recordWrite() bookkeeping entry — because the taint engine's
404
+ // step() 'assign' case reads `node.target` directly and requires
405
+ // a plain string (`typeof node.target === 'string'`); the single
406
+ // node this used to emit had target=the whole {kind:'object-
407
+ // pattern'|'array-pattern', ...} OBJECT, which step() always
408
+ // treated as `target=null` — so taint from `obj`/`arr` never
409
+ // reached ANY destructured binding, for every project that uses
410
+ // this extremely common pattern. fn.writes/recordWrite (used
411
+ // below too) is bookkeeping nothing in dataflow/ ever reads;
412
+ // only real CFG nodes matter to the walk.
273
413
  if (id && typeof id === 'object' && id.kind === 'object-pattern') {
274
- // x = { foo: a, bar: b } — emit one write per property.
275
414
  for (const p of id.props) {
276
415
  const alias = typeof p.alias === 'string' ? p.alias : null;
277
416
  if (!alias) continue;
278
- recordWrite(fn, alias, { kind: 'member', object: initExpr, prop: p.key }, nodeId);
417
+ const memberSrc = { kind: 'member', object: initExpr, prop: p.key };
418
+ const nodeId = nextNodeId();
419
+ addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
420
+ recordWrite(fn, alias, memberSrc, nodeId);
279
421
  }
422
+ return;
423
+ }
424
+ if (id && typeof id === 'object' && id.kind === 'array-pattern') {
425
+ id.elements.forEach((el, i) => {
426
+ const alias = typeof el === 'string' ? el : null;
427
+ if (!alias) return; // elision (`const [, b] = arr`) or nested pattern — not modeled
428
+ const memberSrc = { kind: 'member', object: initExpr, prop: String(i) };
429
+ const nodeId = nextNodeId();
430
+ addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
431
+ recordWrite(fn, alias, memberSrc, nodeId);
432
+ });
280
433
  }
281
434
  },
282
435
 
@@ -298,7 +451,29 @@ export function parseJsFile(file, code) {
298
451
  const parent = path.parent;
299
452
  if (parent && (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression')) return;
300
453
  const calleeExpr = exprOf(path.node.callee);
301
- const args = (path.node.arguments || []).map(exprOf);
454
+ // An inline arrow/function-expression argument (`arr.map(x => ...)`)
455
+ // becomes {kind:'function-value', qid} instead of exprOf's generic
456
+ // {kind:'unknown'} fallback (exprOf has no case for either node
457
+ // type) — dataflow/higher-order.js's calleeIsResolvableCallback and
458
+ // engine.js's higher-order-invocation push site both special-case
459
+ // 'function-value', but nothing ever produced one for JS/TS, so
460
+ // higher-order taint flow only ever worked for a by-reference
461
+ // callback (`arr.map(processItem)`), never the far more common
462
+ // inline-callback shape. The qid is computed to match EXACTLY what
463
+ // this same node's own ArrowFunctionExpression/FunctionExpression
464
+ // visitor (below) will independently compute when Babel's
465
+ // traversal reaches it: for a function literal passed directly as
466
+ // a call argument, none of that visitor's naming heuristics
467
+ // (VariableDeclarator/AssignmentExpression/ObjectProperty parent)
468
+ // match, so it always resolves to name 'anon' scoped under the
469
+ // CURRENT function — exactly what's available here.
470
+ const args = (path.node.arguments || []).map(a => {
471
+ if (a && (a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression')) {
472
+ const argLine = a.loc?.start?.line || 1; // matches enterFn's own fallback
473
+ return { kind: 'function-value', qid: fnQid(file, fn.name, 'anon', argLine) };
474
+ }
475
+ return exprOf(a);
476
+ });
302
477
  const line = path.node.loc?.start?.line || 0;
303
478
  const nodeId = nextNodeId();
304
479
  addNode(fn, { id: nodeId, kind: 'call', callee: calleeExpr, args, line, succ: [], pred: [] });
@@ -313,6 +488,7 @@ export function parseJsFile(file, code) {
313
488
 
314
489
  ReturnStatement(path) {
315
490
  const fn = currentFn(); if (!fn) return;
491
+ maybeResetAtAlternateBoundary(fn, path);
316
492
  const expr = path.node.argument ? exprOf(path.node.argument) : null;
317
493
  const nodeId = nextNodeId();
318
494
  const line = path.node.loc?.start?.line || 0;
@@ -322,12 +498,30 @@ export function parseJsFile(file, code) {
322
498
  linkCfg(fn, nodeId, fn.cfg.exit);
323
499
  },
324
500
 
501
+ // Fires for every statement node, via Babel's built-in "Statement"
502
+ // alias — the fallback path for whichever concrete type an if's
503
+ // `alternate` turns out to be when that type has no specific
504
+ // handler of its own (BlockStatement, ExpressionStatement,
505
+ // VariableDeclaration, ...). Types that DO have a specific handler
506
+ // below (ReturnStatement, IfStatement, the loop statements,
507
+ // TryStatement) call `maybeResetAtAlternateBoundary` themselves,
508
+ // since Babel runs a node's type-specific visitor BEFORE its
509
+ // alias-derived one for the same node — relying on this alone would
510
+ // miss those cases entirely.
511
+ Statement: {
512
+ enter(path) {
513
+ const fn = currentFn(); if (!fn) return;
514
+ maybeResetAtAlternateBoundary(fn, path);
515
+ },
516
+ },
517
+
325
518
  IfStatement: {
326
519
  enter(path) {
327
520
  // We model branches by inserting a noop "join" after the if; both
328
521
  // branches link to it. Without this, the linear cursor model would
329
522
  // miss that statements after the if are reachable from either branch.
330
523
  const fn = currentFn(); if (!fn) return;
524
+ maybeResetAtAlternateBoundary(fn, path);
331
525
  const condNodeId = nextNodeId();
332
526
  const joinId = nextNodeId();
333
527
  const line = path.node.loc?.start?.line || 0;
@@ -335,7 +529,6 @@ export function parseJsFile(file, code) {
335
529
  fn.cfg.nodes.set(joinId, { id: joinId, kind: 'noop', succ: [], pred: [], line });
336
530
  path.node._asJoin = joinId;
337
531
  path.node._asCond = condNodeId;
338
- path.node._asBranchSavedCursor = fn._cursor; // == condNodeId
339
532
  },
340
533
  exit(path) {
341
534
  const fn = currentFn(); if (!fn) return;
@@ -344,9 +537,10 @@ export function parseJsFile(file, code) {
344
537
  if (!joinId || !condId) return;
345
538
  // The visitor visited the body of the if — Babel's body visit ran
346
539
  // *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).
540
+ // consequent (or, thanks to maybeResetAtAlternateBoundary, of the
541
+ // alternate when one exists). Connect it to the join, then if no
542
+ // else branch existed, connect the cond directly to the join
543
+ // (representing the "false" edge).
350
544
  linkCfg(fn, fn._cursor, joinId);
351
545
  if (!path.node.alternate) linkCfg(fn, condId, joinId);
352
546
  fn._cursor = joinId;
@@ -359,6 +553,7 @@ export function parseJsFile(file, code) {
359
553
  'WhileStatement|ForStatement|DoWhileStatement|ForInStatement|ForOfStatement': {
360
554
  enter(path) {
361
555
  const fn = currentFn(); if (!fn) return;
556
+ maybeResetAtAlternateBoundary(fn, path);
362
557
  const headerId = nextNodeId();
363
558
  const exitId = nextNodeId();
364
559
  const line = path.node.loc?.start?.line || 0;
@@ -366,6 +561,64 @@ export function parseJsFile(file, code) {
366
561
  fn.cfg.nodes.set(exitId, { id: exitId, kind: 'noop', succ: [], pred: [], line });
367
562
  path.node._loopHeader = headerId;
368
563
  path.node._loopExit = exitId;
564
+ // PRD R13(b): for-of's binding variable is never connected to the
565
+ // iterated expression, so `for (const x of tainted) sink(x)` reads
566
+ // x as {kind:'unknown'} — clean. Synthesize an assign binding the
567
+ // loop variable to the iterated expression, exactly as the
568
+ // Python-CST/Go/PHP parsers already do for their own for-each
569
+ // constructs (parser-py.helper.py, parser-go.js, parser-php.js).
570
+ // Conservative, matching this file's own stated doctrine for
571
+ // loops in general ("any iteration could taint X"): the WHOLE
572
+ // loop variable is tainted if the iterable is tainted, not a
573
+ // specific element — element-level precision isn't modeled here
574
+ // any more than it is for the rest of this file's loop handling.
575
+ // Scoped to ForOfStatement only — While/For/DoWhile/ForIn have no
576
+ // "loop variable bound to an iterated collection" shape and must
577
+ // see zero behavior change from this addition.
578
+ if (path.node.type === 'ForOfStatement') {
579
+ const leftNode = path.node.left;
580
+ const declId = leftNode && leftNode.type === 'VariableDeclaration'
581
+ ? leftNode.declarations[0]?.id
582
+ : leftNode;
583
+ // Only the simple `for (const x of ...)` / `for (x of ...)`
584
+ // shape is synthesized. A destructuring binding
585
+ // (`for (const {a,b} of ...)`) has no single flat target name
586
+ // lhsPath-style logic could bind to — left unsynthesized rather
587
+ // than guessed at, matching this codebase's "refuse rather than
588
+ // invent an edge" doctrine elsewhere (see _resolvableCalleeName
589
+ // in dataflow/engine.js for the same principle applied to call
590
+ // resolution).
591
+ const loopVar = declId && declId.type === 'Identifier' ? declId.name : null;
592
+ if (loopVar) {
593
+ const iterExpr = exprOf(path.node.right);
594
+ const bindId = nextNodeId();
595
+ addNode(fn, { id: bindId, kind: 'assign', target: loopVar, source: iterExpr, line, succ: [], pred: [] });
596
+ // `for (const x of ...)` / `for (let x of ...)` declares a
597
+ // BLOCK-SCOPED binding: a same-named `x` outside the loop is a
598
+ // different variable and must be completely unaffected by
599
+ // whatever the loop's `x` held. This engine's taint model has
600
+ // no block scoping, so the synthesized binding above would
601
+ // otherwise flow past the loop's exit and over-taint an outer
602
+ // `x` (proven: `let item='safe'; for (const item of req.body
603
+ // .items){} eval(item)` reported a Code Injection finding that
604
+ // pre-R13(b) code correctly reported as clean — the generic
605
+ // VariableDeclarator visitor used to emit `assign x <-
606
+ // {kind:'unknown'}` here, a taint KILL, and the guard above now
607
+ // suppresses it for exactly this shape). Record the name so
608
+ // exit() can re-emit that kill AFTER the loop, restoring the
609
+ // pre-existing behavior for post-loop reads while keeping the
610
+ // new in-loop taint flow.
611
+ //
612
+ // Deliberately NOT done for the bare-assignment form
613
+ // (`for (x of ...)`, leftNode is not a VariableDeclaration):
614
+ // that binding is not block-scoped, it reuses an existing
615
+ // outer `x`, and its value legitimately survives the loop —
616
+ // killing it there would itself be a regression.
617
+ if (leftNode && leftNode.type === 'VariableDeclaration') {
618
+ path.node._loopBindVar = loopVar;
619
+ }
620
+ }
621
+ }
369
622
  },
370
623
  exit(path) {
371
624
  const fn = currentFn(); if (!fn) return;
@@ -375,15 +628,40 @@ export function parseJsFile(file, code) {
375
628
  linkCfg(fn, fn._cursor, headerId); // back-edge
376
629
  linkCfg(fn, headerId, exitId); // exit edge
377
630
  fn._cursor = exitId;
631
+ // See the _loopBindVar comment in enter(): restore the taint KILL
632
+ // that the ForOfStatement VariableDeclarator guard suppresses, so
633
+ // a block-scoped for-of binding's synthesized taint cannot leak
634
+ // past the loop onto a same-named outer variable. Emitted AFTER
635
+ // `fn._cursor = exitId` so it sits on the loop's normal exit path
636
+ // (header -> exit-noop -> kill -> whatever follows the loop) and
637
+ // therefore runs once on the way out — never inside the body, so
638
+ // in-loop taint reachability is unchanged.
639
+ if (path.node._loopBindVar) {
640
+ const killId = nextNodeId();
641
+ addNode(fn, {
642
+ id: killId,
643
+ kind: 'assign',
644
+ target: path.node._loopBindVar,
645
+ source: { kind: 'unknown' },
646
+ line: path.node.loc?.start?.line || 0,
647
+ succ: [],
648
+ pred: [],
649
+ });
650
+ }
378
651
  },
379
652
  },
380
653
 
381
654
  TryStatement: {
382
- enter() { /* approximate try/catch as sequential — taint flows through both */ },
655
+ enter(path) {
656
+ const fn = currentFn(); if (!fn) return;
657
+ maybeResetAtAlternateBoundary(fn, path);
658
+ /* approximate try/catch as sequential — taint flows through both */
659
+ },
383
660
  },
384
661
 
385
662
  ThrowStatement(path) {
386
663
  const fn = currentFn(); if (!fn) return;
664
+ maybeResetAtAlternateBoundary(fn, path);
387
665
  const expr = exprOf(path.node.argument);
388
666
  const nodeId = nextNodeId();
389
667
  const line = path.node.loc?.start?.line || 0;