@clear-capabilities/agentic-security-scanner 0.136.9 → 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.
@@ -22,6 +22,7 @@
22
22
  // when the shape is unfamiliar.
23
23
 
24
24
  import { blankComments } from '../sast/_comment-strip.js';
25
+ import { callSitesFromCfg } from './call-sites.js';
25
26
 
26
27
  let _nodeIdSeq = 0;
27
28
  function nextNodeId() { return 'jn' + (++_nodeIdSeq); }
@@ -288,6 +289,160 @@ function buildCfgFromBody(bodyNode) {
288
289
  emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(w), succ: [] });
289
290
  for (const sub of (w.children?.statement || [])) walkStmts(sub);
290
291
  }
292
+ if (kids.forStatement) {
293
+ const f = kids.forStatement[0];
294
+ // Both basicForStatement (`for(init;test;step)`) and
295
+ // enhancedForStatement (`for(T x : xs)`) wrap a `statement` child for
296
+ // the loop body — same shape whileStatement already walks. Confirmed
297
+ // via direct java-parser CST inspection (java-parser@3.0.1): each
298
+ // forStatement's `children` has exactly one of these two keys, never
299
+ // both, and each carries `statement` directly.
300
+ const basic = f.children?.basicForStatement?.[0];
301
+ const enhanced = f.children?.enhancedForStatement?.[0];
302
+ const inner = basic || enhanced;
303
+ const cond = basic?.children?.expression?.[0];
304
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(f), succ: [] });
305
+ // R8 Task 1 fix round 1: `for (T x : xs)` never bound `x` to `xs` —
306
+ // the loop body was reachable (the fix above) but the loop variable
307
+ // itself carried no taint provenance, so a genuinely tainted
308
+ // collection reaching a sink through the loop variable still
309
+ // couldn't fire. Confirmed via direct CST inspection:
310
+ // enhancedForStatement.children = {..., localVariableDeclaration,
311
+ // Colon, expression, ..., statement} — `localVariableDeclaration` is
312
+ // the loop var's declaration (same variableDeclaratorList shape used
313
+ // elsewhere in this file) and `expression` is the iterated
314
+ // collection. Mirrors the established pattern in parser-js.js's
315
+ // ForOfStatement handling (PRD R13(b)): synthesize
316
+ // `{kind:'assign', target: loopVar, source: iterExpr}` BEFORE
317
+ // recursing into the body, so any body statement reading the loop
318
+ // variable sees its taint provenance already established. Java's
319
+ // for-each always declares a fresh block-scoped variable (there is
320
+ // no bare-assignment form the way JS's `for (x of xs)` has), so —
321
+ // unlike parser-js.js — there is no pre-existing outer variable of
322
+ // the same name to protect with a post-loop kill; the Java CFG
323
+ // builder does not model block scoping elsewhere either, so adding
324
+ // one only for this binding would be new, inconsistent behavior
325
+ // rather than a fix for this gap.
326
+ if (enhanced) {
327
+ const lvd = enhanced.children?.localVariableDeclaration?.[0];
328
+ const declarator = lvd?.children?.variableDeclaratorList?.[0]?.children?.variableDeclarator?.[0];
329
+ const loopVar = declarator?.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
330
+ const iterExpr = enhanced.children?.expression?.[0];
331
+ if (loopVar) {
332
+ emit({ kind: 'assign', target: loopVar, source: iterExpr ? exprFromCst(iterExpr) : { kind: 'unknown' }, line: _lineOf(enhanced), succ: [] });
333
+ }
334
+ }
335
+ for (const sub of (inner?.children?.statement || [])) walkStmts(sub);
336
+ }
337
+ if (kids.doStatement) {
338
+ const d = kids.doStatement[0];
339
+ const cond = d.children?.expression?.[0];
340
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(d), succ: [] });
341
+ for (const sub of (d.children?.statement || [])) walkStmts(sub);
342
+ }
343
+ if (kids.tryStatement) {
344
+ const t = kids.tryStatement[0];
345
+ emit({ kind: 'noop', line: _lineOf(t), succ: [] });
346
+ // Plain `try { ... }` has direct `block`/`catches`/`finally` children.
347
+ // Try-with-resources (`try (Resource r = ...) { ... }` — the idiomatic
348
+ // JDBC shape) wraps in a distinct `tryWithResourcesStatement`
349
+ // intermediate node, and — confirmed via direct CST inspection, this
350
+ // is the one place the plan's illustrative code was wrong — that
351
+ // intermediate node carries its OWN `block`/`catches`/`finally`
352
+ // children; a plain tryStatement's outer `children` is just
353
+ // `{ tryWithResourcesStatement: [...] }` with nothing else, so
354
+ // reading `catches`/`finally` off the outer `t` (as the plan's draft
355
+ // did) silently drops the catch/finally bodies of every
356
+ // try-with-resources block. `container` below picks the node that
357
+ // actually owns them.
358
+ const twr = t.children?.tryWithResourcesStatement?.[0];
359
+ const container = twr || t;
360
+ if (twr) {
361
+ const resSpec = twr.children?.resourceSpecification?.[0];
362
+ // resourceSpecification -> resourceList -> resource[] -> each a
363
+ // localVariableDeclaration-shaped resource; reuse the existing
364
+ // localVariableDeclarationStatement lowering shape directly rather
365
+ // than duplicating it, by walking each resource as if it were one.
366
+ const resources = resSpec?.children?.resourceList?.[0]?.children?.resource || [];
367
+ for (const r of resources) {
368
+ const vdecl = r.children?.localVariableDeclaration?.[0] || r;
369
+ const declarators = vdecl?.children?.variableDeclaratorList?.[0]?.children?.variableDeclarator || [];
370
+ for (const d of declarators) {
371
+ const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
372
+ const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0] || d.children?.expression?.[0];
373
+ if (target) emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(r), succ: [] });
374
+ }
375
+ }
376
+ }
377
+ const bodyBlock = container.children?.block?.[0];
378
+ if (bodyBlock) walkStmts(bodyBlock);
379
+ const catches = container.children?.catches?.[0]?.children?.catchClause || [];
380
+ for (const cc of catches) {
381
+ const cblock = cc.children?.block?.[0];
382
+ if (cblock) walkStmts(cblock);
383
+ }
384
+ // Confirmed via direct CST inspection: the key is `finally`, no
385
+ // trailing underscore, in both the plain and try-with-resources
386
+ // shapes (java-parser@3.0.1).
387
+ const fin = container.children?.finally?.[0];
388
+ const finBlock = fin?.children?.block?.[0];
389
+ if (finBlock) walkStmts(finBlock);
390
+ }
391
+ if (kids.switchStatement) {
392
+ const sw = kids.switchStatement[0];
393
+ const cond = sw.children?.expression?.[0];
394
+ emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(sw), succ: [] });
395
+ const swBlock = sw.children?.switchBlock?.[0];
396
+ // Classic colon-form (`case 1: ...; break;`).
397
+ const groups = swBlock?.children?.switchBlockStatementGroup || [];
398
+ for (const g of groups) {
399
+ const bss = g.children?.blockStatements?.[0];
400
+ if (bss) walkStmts(bss);
401
+ }
402
+ // R8 Task 1 fix round 1: arrow-form (`case 1 -> ...;`, Java 14+) is a
403
+ // structurally distinct grammar rule — confirmed via direct CST
404
+ // inspection (java-parser@3.0.1) — not an alternate reading of
405
+ // switchBlockStatementGroup. A switchBlock's children carry EITHER
406
+ // switchBlockStatementGroup (colon-form) OR switchRule (arrow-form),
407
+ // never both (Java's grammar forbids mixing them in one switch
408
+ // block), so this is a separate, independent branch rather than a
409
+ // fallback path. Each switchRule carries exactly one body-shape key:
410
+ // `expression` (`case 1 -> sink(x);`), `block`
411
+ // (`case 1 -> { ...; }`), or `throwStatement`
412
+ // (`case 1 -> throw new X();`). Before this fix arrow-form switches
413
+ // fell through this whole branch producing zero walked statements —
414
+ // confirmed 0 IR-TAINT findings for the idiomatic modern-Java shape.
415
+ const rules = swBlock?.children?.switchRule || [];
416
+ for (const r of rules) {
417
+ const rblock = r.children?.block?.[0];
418
+ if (rblock) { walkStmts(rblock); continue; }
419
+ const rthrow = r.children?.throwStatement?.[0];
420
+ if (rthrow) {
421
+ const texpr = rthrow.children?.expression?.[0];
422
+ emit({ kind: 'throw', value: texpr ? exprFromCst(texpr) : null, line: _lineOf(rthrow), succ: [] });
423
+ continue;
424
+ }
425
+ const rexpr = r.children?.expression?.[0];
426
+ if (rexpr) {
427
+ const expr = exprFromCst(rexpr);
428
+ if (expr.kind === 'call') emit({ ...expr, line: _lineOf(r), succ: [] });
429
+ else if (expr.kind === 'binary' && expr.op === '=') {
430
+ emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(r), succ: [] });
431
+ }
432
+ }
433
+ }
434
+ }
435
+ // Bare nested block `{ ... }` with no leading keyword. The
436
+ // `statementWithoutTrailingSubstatement` branch above already recurses
437
+ // into each such node via walkStmts; confirmed via direct CST
438
+ // inspection that a bare block lands as
439
+ // `statementWithoutTrailingSubstatement[0].children.block[0]`, so this
440
+ // branch — which fires for any node with a `block` child, not just
441
+ // this one — picks it up on the recursive call without a dedicated
442
+ // bare-block branch.
443
+ if (kids.block) {
444
+ for (const b of kids.block) walkStmts(b);
445
+ }
291
446
  }
292
447
 
293
448
  if (nodes[prev]) {
@@ -335,10 +490,56 @@ export async function parseJavaFile(file, raw) {
335
490
  const name = md.children?.methodHeader?.[0]?.children?.methodDeclarator?.[0]?.children?.Identifier?.[0]?.image
336
491
  || md.children?.methodDeclarator?.[0]?.children?.Identifier?.[0]?.image
337
492
  || 'anonymous';
338
- const params = []; // params extraction deferred
493
+ // Parameter list + annotations live in the same CST subtree, so
494
+ // both are extracted in one walk (PRD R14(a) Task 5). Mirrors the
495
+ // dual-path fallback pattern used above for `name`: depending on
496
+ // which `k` matched in the outer loop, the params live nested
497
+ // under a `methodHeader` node OR directly under `methodDeclarator`.
498
+ const fpl = md.children?.methodHeader?.[0]?.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0]
499
+ || md.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0];
500
+ const paramAnnotations = [];
501
+ const params = (fpl?.children?.formalParameter || []).map((fp, idx) => {
502
+ // Regular parameters nest under `variableParaRegularParameter`.
503
+ // Varargs (`String... args`) instead use a distinct
504
+ // `variableArityParameter` node that this walk doesn't extract
505
+ // from — `vp` is undefined, so this degrades gracefully to a
506
+ // dropped (not corrupted/crashed) parameter, matching the
507
+ // codebase's existing "degrade gracefully rather than throw"
508
+ // convention for shapes that aren't cleanly extractable. Array
509
+ // types (`String[] args`) are unaffected — the `[]` lives in
510
+ // `unannType`, not the identifier, so `variableDeclaratorId`
511
+ // still yields a clean name.
512
+ const vp = fp.children?.variableParaRegularParameter?.[0];
513
+ const paramName = vp?.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image || null;
514
+ // Java allows multiple stacked annotations on one parameter
515
+ // (`@NotNull @RequestParam String q`), each its own
516
+ // `variableModifier` entry — loop over all of them, not just
517
+ // the first, or a stacked annotation is silently dropped (the
518
+ // same class of bug Task 3's C# regex needed a fix round for).
519
+ const variableModifiers = vp?.children?.variableModifier || [];
520
+ for (const vm of variableModifiers) {
521
+ const ann = vm.children?.annotation?.[0];
522
+ // `typeName.children.Identifier` is an array of ALL
523
+ // dot-separated segments for a fully-qualified annotation
524
+ // (`@org.springframework.web.bind.annotation.RequestParam`
525
+ // yields ['org','springframework','web','bind','annotation',
526
+ // 'RequestParam']) — the simple annotation name is the LAST
527
+ // segment, not the first. Taking [0] previously recorded the
528
+ // package root ("org") as the decorator name, a silent wrong
529
+ // value rather than the honest drop the C#/JS sibling parsers
530
+ // produce for the same shape (fix round 1, R14(a) Task 5).
531
+ const identifiers = ann?.children?.typeName?.[0]?.children?.Identifier;
532
+ const decoratorName = identifiers?.length ? identifiers[identifiers.length - 1]?.image : undefined;
533
+ if (decoratorName && paramName) {
534
+ paramAnnotations.push({ index: idx, name: paramName, decorator: decoratorName });
535
+ }
536
+ }
537
+ return paramName;
538
+ }).filter(Boolean);
339
539
  const body = md.children?.methodBody?.[0]?.children?.block?.[0];
340
540
  if (body) {
341
541
  const methodLine = _lineOf(md);
542
+ const cfg = buildCfgFromBody(body);
342
543
  functions.push({
343
544
  // Sibling frontends (parser-js.js) suffix the qid with `@line`
344
545
  // so overloaded/same-named methods don't collide — without it,
@@ -348,8 +549,10 @@ export async function parseJavaFile(file, raw) {
348
549
  name: className ? `${className}.${name}` : name,
349
550
  line: methodLine,
350
551
  params,
351
- cfg: buildCfgFromBody(body),
552
+ cfg,
352
553
  file,
554
+ calls: callSitesFromCfg(cfg),
555
+ ...(paramAnnotations.length ? { paramAnnotations } : {}),
353
556
  });
354
557
  }
355
558
  }
@@ -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) };
@@ -151,6 +163,7 @@ export function parseJsFile(file, code) {
151
163
  const qid = fnQid(file, scopeName, name, line);
152
164
  const entryId = nextNodeId();
153
165
  const exitId = nextNodeId();
166
+ const paramAnnotations = [];
154
167
  const fn = {
155
168
  qid, name: name || 'anon', line,
156
169
  // Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
@@ -163,8 +176,34 @@ export function parseJsFile(file, code) {
163
176
  // mutated-parameter taint and context-sensitive entry states were both
164
177
  // unconditionally inert for every JS/TS function. Nothing in src/
165
178
  // reads a param's .kind or .props, so the richer shape bought nothing.
166
- params: (params || []).map(p => {
179
+ params: (params || []).map((p, idx) => {
167
180
  if (!p) return null;
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
+ }
168
207
  if (p.type === 'Identifier') return p.name;
169
208
  if (p.type === 'ObjectPattern') return '<obj>';
170
209
  if (p.type === 'ArrayPattern') return '<arr>';
@@ -181,6 +220,7 @@ export function parseJsFile(file, code) {
181
220
  writes: new Map(),
182
221
  file,
183
222
  _cursor: entryId, // current node ID — next addNode() links from here
223
+ ...(paramAnnotations.length ? { paramAnnotations } : {}),
184
224
  };
185
225
  fn.cfg.nodes.set(entryId, { id: entryId, kind: 'entry', succ: [], pred: [], line });
186
226
  fn.cfg.nodes.set(exitId, { id: exitId, kind: 'exit', succ: [], pred: [], line });
@@ -319,6 +359,35 @@ export function parseJsFile(file, code) {
319
359
 
320
360
  VariableDeclarator(path) {
321
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;
322
391
  const id = lhsPath(path.node.id);
323
392
  if (!id) return;
324
393
  const initExpr = exprOf(path.node.init);
@@ -492,6 +561,64 @@ export function parseJsFile(file, code) {
492
561
  fn.cfg.nodes.set(exitId, { id: exitId, kind: 'noop', succ: [], pred: [], line });
493
562
  path.node._loopHeader = headerId;
494
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
+ }
495
622
  },
496
623
  exit(path) {
497
624
  const fn = currentFn(); if (!fn) return;
@@ -501,6 +628,26 @@ export function parseJsFile(file, code) {
501
628
  linkCfg(fn, fn._cursor, headerId); // back-edge
502
629
  linkCfg(fn, headerId, exitId); // exit edge
503
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
+ }
504
651
  },
505
652
  },
506
653