@clear-capabilities/agentic-security-scanner 0.151.3 → 0.152.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 (45) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/agentic-security.mjs +80 -80
  3. package/dist/agentic-security.mjs.sha256 +1 -1
  4. package/package.json +5 -5
  5. package/src/dataflow/CLAUDE.md +1 -1
  6. package/src/dataflow/catalog.js +802 -13
  7. package/src/dataflow/engine.js +852 -89
  8. package/src/dataflow/incremental.js +13 -0
  9. package/src/dataflow/index.js +18 -5
  10. package/src/dataflow/path-feasibility.js +86 -9
  11. package/src/dataflow/string-domain.js +132 -0
  12. package/src/dataflow/summaries.js +69 -8
  13. package/src/engine.js +108 -3
  14. package/src/ir/CLAUDE.md +2 -1
  15. package/src/ir/balanced-call.js +33 -1
  16. package/src/ir/callgraph.js +26 -2
  17. package/src/ir/class-hierarchy.js +101 -6
  18. package/src/ir/csharp-ir.js +97 -8
  19. package/src/ir/index.js +21 -1
  20. package/src/ir/parser-cs.js +706 -24
  21. package/src/ir/parser-java.js +498 -17
  22. package/src/ir/parser-js.js +22 -0
  23. package/src/ir/parser-php.js +446 -43
  24. package/src/ir/parser-rust.js +1338 -0
  25. package/src/ir/php-include-merge.js +190 -0
  26. package/src/lineage/sink-registry.js +18 -0
  27. package/src/lineage/source-registry.js +23 -0
  28. package/src/posture/csharp-analysis.js +341 -43
  29. package/src/posture/finding-defaults.js +5 -1
  30. package/src/posture/fix-coverage.js +1 -0
  31. package/src/posture/proof-coverage.js +1 -0
  32. package/src/sast/CLAUDE.md +1 -1
  33. package/src/sast/authz.js +144 -1
  34. package/src/sast/crypto-protocol.js +65 -7
  35. package/src/sast/csharp-cross-file.js +125 -0
  36. package/src/sast/csharp-structural.js +78 -2
  37. package/src/sast/csharp.js +574 -42
  38. package/src/sast/java-ast-folding.js +268 -11
  39. package/src/sast/java-bench-extras.js +530 -2
  40. package/src/sast/java-deserialization.js +31 -2
  41. package/src/sast/java-structural-cross-file.js +146 -0
  42. package/src/sast/java-structural.js +499 -6
  43. package/src/sast/ldap-injection.js +133 -3
  44. package/src/sast/rust.js +57 -0
  45. package/src/sast/xss-reflected-multilang.js +252 -3
@@ -57,6 +57,18 @@ function exprFromCst(node) {
57
57
  }
58
58
  if (node.children) {
59
59
  // CST node with named children — recurse into the most informative one.
60
+ // Assignment: java-parser models `x = y` (and `x += y`) as a
61
+ // binaryExpression whose children are {unaryExpression (LHS),
62
+ // AssignmentOperator, expression (RHS)}. There is no BinaryOperator
63
+ // token, so this shape previously fell through to the generic
64
+ // "recurse the first child" branch and lowered to the LHS alone: every
65
+ // expression-statement assignment in a method body vanished from the CFG.
66
+ if (node.children.AssignmentOperator) {
67
+ const op = node.children.AssignmentOperator[0]?.image || '=';
68
+ const lhs = node.children.unaryExpression?.[0];
69
+ const rhs = node.children.expression?.[0];
70
+ return { kind: 'binary', op, left: exprFromCst(lhs), right: exprFromCst(rhs) };
71
+ }
60
72
  // Method invocation
61
73
  if (node.children.methodInvocation) return _methodInvocation(node.children.methodInvocation[0]);
62
74
  // `primary` is how java-parser actually models a call: the name lives in
@@ -118,11 +130,47 @@ function exprFromCst(node) {
118
130
  // own args ahead of the constructor's, preserving the outermost-first
119
131
  // convention.
120
132
  const ctorPrefix = prefix?.children?.newExpression?.[0]?.children?.unqualifiedClassInstanceCreationExpression?.[0];
133
+ // `(new X(args)).m(a)`: the parenthesized-constructor form Juliet's
134
+ // multi-file flow variants use for every cross-class sink call. The
135
+ // prefix is a parenthesisExpression wrapping the constructor, so
136
+ // neither the fqn nor the ctorPrefix branch below sees it and the
137
+ // chain used to lower to a bare `m` with the class name dropped.
138
+ // Seed the chain from the inner lowered constructor exactly like the
139
+ // direct `new X().m()` form so callgraph.js's `Class.method` index
140
+ // can resolve it.
141
+ const parenInner = prefix?.children?.parenthesisExpression?.[0]?.children?.expression?.[0];
142
+ let parenExpr = null;
143
+ // Tracks whether the chain so far is STILL exactly the bare
144
+ // constructor call with no method invoked on the result yet — cleared
145
+ // the moment the suffix loop below consumes a real
146
+ // methodInvocationSuffix. Only in that still-true state does the
147
+ // eventual return carry `isNew: true`, matching class-hierarchy.js's
148
+ // `typeOfVar` contract (`b.action(data)` for `A_81_base b = new
149
+ // A_81_bad();` needs the RHS lowered with isNew so allocation-typed
150
+ // dispatch can resolve `b`'s declared type); `(new B()).badSink(x)`
151
+ // must NOT carry it, since the resulting call IS `B.badSink`, not a
152
+ // constructor.
153
+ let isCtorSeed = false;
121
154
  if (fqn) {
122
155
  chainCallee = _flattenFqnToString(fqn);
123
156
  } else if (ctorPrefix) {
124
- chainCallee = ctorPrefix.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier?.[0]?.image || '';
157
+ chainCallee = _ctorClassName(ctorPrefix);
125
158
  chainArgs = (ctorPrefix.children?.argumentList?.[0]?.children?.expression || []).map(exprFromCst);
159
+ isCtorSeed = true;
160
+ } else if (parenInner) {
161
+ parenExpr = exprFromCst(parenInner);
162
+ if (parenExpr.kind === 'call' && parenExpr.isNew) {
163
+ chainCallee = parenExpr.callee;
164
+ chainArgs = parenExpr.args || [];
165
+ isCtorSeed = true;
166
+ } else if (!suffixes.length) {
167
+ return parenExpr;
168
+ } else {
169
+ // `(a + b).toString()`: no textual receiver name exists. Carry the
170
+ // inner expression as an extra argument so its taint still reaches
171
+ // the chained call (recall-preserving, mirrors receiver-taint).
172
+ chainArgs = [parenExpr];
173
+ }
126
174
  }
127
175
  // No FQN/constructor prefix — e.g. `this.foo(x)`, `super.foo(x)`
128
176
  // (prefix is a keyword expression). chainCallee starts empty; the
@@ -140,16 +188,30 @@ function exprFromCst(node) {
140
188
  appendPending();
141
189
  const args = (inv.children?.argumentList?.[0]?.children?.expression || []).map(exprFromCst);
142
190
  chainArgs = chainArgs === null ? args : args.concat(chainArgs);
191
+ isCtorSeed = false; // a real method is now being invoked on the constructed object
143
192
  continue;
144
193
  }
145
194
  const ident = suf.children?.Identifier?.[0]?.image;
146
- if (ident) pendingName.push(ident);
195
+ if (ident) { pendingName.push(ident); isCtorSeed = false; }
147
196
  }
148
197
  if (chainArgs !== null) {
149
198
  appendPending(); // a trailing member access after the last call (e.g. a field read)
150
- return { kind: 'call', callee: chainCallee || 'unknown', args: chainArgs };
199
+ return { kind: 'call', callee: chainCallee || 'unknown', args: chainArgs, ...(isCtorSeed ? { isNew: true } : {}) };
151
200
  }
152
- return exprFromCst(prefix);
201
+ // No call anywhere in the chain: a plain member read such as
202
+ // `this.inst` or `obj.field` (an fqn prefix already lowers to a member
203
+ // chain on its own; this covers the `this`/`super`/parenthesized
204
+ // prefix shapes whose member suffixes were previously discarded).
205
+ if (pendingName.length) {
206
+ let cur = parenExpr || exprFromCst(prefix);
207
+ for (const p of pendingName) cur = { kind: 'member', object: cur, prop: p };
208
+ return cur;
209
+ }
210
+ return parenExpr || exprFromCst(prefix);
211
+ }
212
+ if (node.children.parenthesisExpression) {
213
+ const inner = node.children.parenthesisExpression[0]?.children?.expression?.[0];
214
+ if (inner) return exprFromCst(inner);
153
215
  }
154
216
  // Taint-recall PRD (80%): a cast expression — `(String) xp.evaluate(...)`,
155
217
  // `(int) computeVal(x)` — was falling through to the generic "recurse the
@@ -175,7 +237,7 @@ function exprFromCst(node) {
175
237
  if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
176
238
  if (node.children.unqualifiedClassInstanceCreationExpression) {
177
239
  const ci = node.children.unqualifiedClassInstanceCreationExpression[0];
178
- const callee = (ci.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier?.[0]?.image) || 'new';
240
+ const callee = _ctorClassName(ci) || 'new';
179
241
  // Taint-recall PRD (80%): args was hardcoded to [] — every
180
242
  // `new X(arg1, arg2)` constructor call lowered with its arguments
181
243
  // silently discarded, so a sink modeled as a constructor call
@@ -260,6 +322,231 @@ function _fqnIdents(node) {
260
322
  return out;
261
323
  }
262
324
 
325
+ const _ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '>>>=']);
326
+ function _isAssignment(expr) {
327
+ return !!expr && expr.kind === 'binary' && _ASSIGN_OPS.has(expr.op);
328
+ }
329
+
330
+ // Dotted access-path string for a member chain rooted at an identifier or
331
+ // `this`; null when the chain is rooted at something with no name (a call
332
+ // result, a literal).
333
+ function _memberPath(expr) {
334
+ if (!expr) return null;
335
+ if (expr.kind === 'ident') return expr.name;
336
+ if (expr.kind === 'member') {
337
+ const base = _memberPath(expr.object);
338
+ return base ? `${base}.${expr.prop}` : null;
339
+ }
340
+ return null;
341
+ }
342
+
343
+ // Targets for one lowered assignment LHS. See emitAssignment for why a
344
+ // field write yields a bare alias alongside its qualified path.
345
+ function _assignTargets(left) {
346
+ const path = _memberPath(left);
347
+ if (!path) return [];
348
+ if (!path.includes('.')) return [path];
349
+ const segs = path.split('.');
350
+ const out = [path];
351
+ if (segs.length === 2 && (segs[0] === 'this' || /^[A-Z]/.test(segs[0]))) out.push(segs[1]);
352
+ return out;
353
+ }
354
+
355
+ // { name, line, bases, fields } for one class/interface declaration node.
356
+ // `classType` (extends) and each `interfaceType` (implements) both bottom
357
+ // out at a plain `Identifier` array — a fully-qualified supertype
358
+ // (`extends java.util.AbstractList`) yields multiple Identifier tokens, of
359
+ // which only the LAST is the actual type name, matching `_ctorClassName`'s
360
+ // same convention. Field names come from every `fieldDeclaration`'s
361
+ // `variableDeclaratorList` directly under `classBody` — one declaration can
362
+ // name several comma-separated fields (`private String inst, other;`).
363
+ function _classRecord(node, name) {
364
+ const bases = [];
365
+ const extendsIds = node.children?.classExtends?.[0]?.children?.classType?.[0]?.children?.Identifier;
366
+ if (Array.isArray(extendsIds) && extendsIds.length) bases.push(extendsIds[extendsIds.length - 1].image);
367
+ const interfaceTypes = node.children?.classImplements?.[0]?.children?.interfaceTypeList?.[0]?.children?.interfaceType || [];
368
+ for (const it of interfaceTypes) {
369
+ const ids = it.children?.classType?.[0]?.children?.Identifier;
370
+ if (Array.isArray(ids) && ids.length) bases.push(ids[ids.length - 1].image);
371
+ }
372
+ const fields = [];
373
+ const classBody = node.children?.classBody?.[0];
374
+ for (const cbd of classBody?.children?.classBodyDeclaration || []) {
375
+ const fd = cbd.children?.classMemberDeclaration?.[0]?.children?.fieldDeclaration?.[0];
376
+ const declarators = fd?.children?.variableDeclaratorList?.[0]?.children?.variableDeclarator || [];
377
+ for (const d of declarators) {
378
+ const fname = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
379
+ if (fname) fields.push(fname);
380
+ }
381
+ }
382
+ return { name, line: _lineOf(node), bases, fields };
383
+ }
384
+
385
+ // W2 (SARD_80_F1_EXECUTION_PRD.md) — exception-message taint (was the
386
+ // documented-but-deferred "Try/catch -> exception-flow scaffolding (P3.4
387
+ // will model)" gap at the top of this file). Juliet's CWE-81 "XSS Error
388
+ // Message" idiom throws a `new Exception(tainted)` inside a `try` and
389
+ // reflects it back via `catch (Exception e) { ...e.getMessage()... }` —
390
+ // completely invisible without this, since `throw`/`catch` is pure
391
+ // control flow in this IR with no data link between the thrown value and
392
+ // the caught variable. Recursively collects every `throwStatement`'s
393
+ // expression anywhere within a CST subtree (permissive: does not match
394
+ // the thrown type against the catch clause's declared type, since
395
+ // Juliet's dominant shape is one throw + one matching catch per method,
396
+ // and a wrong-type over-match only widens recall, it can't fabricate a
397
+ // source that wasn't already tainted). Does not descend into a nested
398
+ // class or lambda body (a distinct scope with its own catch semantics).
399
+ function _collectThrowExprs(node, out, depth = 0) {
400
+ if (!node || typeof node !== 'object' || depth > 40) return;
401
+ if (node.name === 'throwStatement') {
402
+ const expr = node.children?.expression?.[0];
403
+ if (expr) out.push(exprFromCst(expr));
404
+ return;
405
+ }
406
+ if (node.name === 'classDeclaration' || node.name === 'lambdaExpression') return;
407
+ for (const k of Object.keys(node.children || {})) {
408
+ const arr = node.children[k];
409
+ if (!Array.isArray(arr)) continue;
410
+ for (const child of arr) _collectThrowExprs(child, out, depth + 1);
411
+ }
412
+ }
413
+
414
+ // The simple class name of a `new X(...)` expression. A fully-qualified
415
+ // instantiation (`new java.util.LinkedList<String>()`) carries one Identifier
416
+ // token per package segment plus the class, so taking the FIRST token
417
+ // yielded "java"; the class is always the LAST segment, and type arguments
418
+ // live under a separate typeArgumentsOrDiamond child that is simply not
419
+ // consulted.
420
+ // Ported from parser-cs.js's identical mechanism (see its own header comment
421
+ // for the full rationale). Closes Juliet's dominant abstract/interface
422
+ // dispatch idiom (flow variants 81/82): `A_81_base b = new A_81_bad();
423
+ // b.action(data);` lowers `b.action`'s callee to the local-variable-named
424
+ // string `"b.action"`, which `callgraph.js`'s `classMethods` index (keyed by
425
+ // real class name, `"A_81_bad.action"`) can never match. Since a variable's
426
+ // constructed type is unambiguous within one function (once assigned via a
427
+ // `{kind:'call', isNew:true}` source — see emitAssignment/isCtorSeed above),
428
+ // rewriting `b.action` to `A_81_bad.action` in place lets the EXISTING
429
+ // project-wide `classMethods` index resolve it with no engine changes.
430
+ // Refuses to guess when a variable is (re)assigned two distinct constructed
431
+ // types in the same function, matching this file's own `bareTailInFile`
432
+ // ambiguity-refusal convention elsewhere.
433
+ // SARD_80_F1 W2.3 — JDK collection/container types are NEVER entries in
434
+ // `callgraph.js`'s `classMethods` index (that index is built from this
435
+ // PROJECT's own `ir.classes`, never JDK builtins), so class-qualifying a
436
+ // call to one of these buys the dispatch-resolution mechanism nothing —
437
+ // while actively BREAKING collection-element taint tracking. Found via a
438
+ // direct CFG dump: `ArrayList<String> list = new ArrayList<>(); list.add(data);
439
+ // for (String s : list) { sink(s); }` rewrites `list.add` to `ArrayList.add`
440
+ // (mutating the callee string in place, so the taint engine's mutator rule
441
+ // — engine.js's `_MUTATORS` — records the taint under the FAKE receiver key
442
+ // "ArrayList" instead of the real variable "list"), but the for-each's own
443
+ // synthesized loop-variable binding (`s = list`, a bare IDENTIFIER
444
+ // reference, never itself a `call` node) is NEVER touched by this rewrite —
445
+ // there is no callee to rewrite on a bare ident. The write and the read end
446
+ // up keyed on two DIFFERENT names ("ArrayList" vs "list"), and the taint is
447
+ // silently lost. `map.values()`-style for-each reads happened to keep
448
+ // working by accident: that read is ALSO a call (`map.values()` also gets
449
+ // rewritten to `LinkedHashMap.values()`), so write and read stay
450
+ // consistently (if wrongly) keyed on the same class name — a coincidence
451
+ // that does not hold for the far more common bare-variable for-each shape.
452
+ //
453
+ // SARD_80_F1 W5.32 — the SAME defect class, for a NON-container JDK utility
454
+ // wrapper: `StringTokenizer tok = new StringTokenizer(request.getQueryString(),
455
+ // "&"); data = tok.nextToken();` (Juliet's dominant "parse a request field
456
+ // without getParameter()" source idiom, used across dozens of CWE test-case
457
+ // families) rewrites `tok.nextToken` to `StringTokenizer.nextToken` for the
458
+ // exact reason given above — `StringTokenizer` is never a project class, so
459
+ // the rewrite buys `classMethods` nothing, while `_calleeReceiverTainted`
460
+ // (engine.js) can no longer recognize `tok` as tainted, since the taint state
461
+ // was recorded under the variable name, not the class name. Confirmed via a
462
+ // direct CFG dump reproducing the exact bug (`tok.nextToken` silently became
463
+ // `StringTokenizer.nextToken`), then a real real-corpus `runScan` reproduction
464
+ // showing zero findings before the fix. Generalizing the FIX from "containers
465
+ // only" to "any well-known JDK/stdlib type with no chance of being a project
466
+ // class" rather than special-casing `StringTokenizer` alone, since this is
467
+ // evidence that the whole rewrite mechanism's risk applies to any non-project
468
+ // class, not just collections — this list covers the other common
469
+ // wrapper/reader/parser JDK types whose own methods return or carry data
470
+ // derived from a tainted constructor argument, the same shape that made
471
+ // `StringTokenizer` a real gap.
472
+ const _JDK_CONTAINER_TYPES = /^(?:List|ArrayList|LinkedList|Vector|Stack|CopyOnWriteArrayList|Map|HashMap|LinkedHashMap|TreeMap|Hashtable|ConcurrentHashMap|Properties|Set|HashSet|LinkedHashSet|TreeSet|CopyOnWriteArraySet|Queue|Deque|ArrayDeque|PriorityQueue|ConcurrentLinkedQueue|ConcurrentLinkedDeque|BlockingQueue|LinkedBlockingQueue|Collection|StringTokenizer|Scanner|BufferedReader|InputStreamReader|StringReader|StringBuilder|StringBuffer|ObjectInputStream|ObjectOutputStream|PrintWriter|PrintStream)$/;
473
+
474
+ function _localVarConstructedTypes(nodes) {
475
+ const varTypes = new Map(); // varName -> className | null (ambiguous)
476
+ for (const node of Object.values(nodes)) {
477
+ if (node.kind !== 'assign') continue;
478
+ const src = node.source;
479
+ if (!src || src.kind !== 'call' || !src.isNew || typeof src.callee !== 'string') continue;
480
+ if (_JDK_CONTAINER_TYPES.test(src.callee)) continue;
481
+ const varName = node.target;
482
+ if (!varName || varName.includes('.')) continue; // only a bare local, never a field-write target
483
+ if (varTypes.has(varName)) {
484
+ if (varTypes.get(varName) !== src.callee) varTypes.set(varName, null);
485
+ } else {
486
+ varTypes.set(varName, src.callee);
487
+ }
488
+ }
489
+ // A local's DECLARED type (`PrintWriter out = response.getWriter();`) is
490
+ // deliberately NOT folded into this rewrite. This function's rewrite is a
491
+ // destructive STRING REPLACEMENT of the callee (`out.println` -> literally
492
+ // `PrintWriter.println`) applied everywhere the call appears — and MANY
493
+ // existing catalog entries key `match.receiver` on the ORIGINAL variable
494
+ // NAME convention (e.g. an XPath sink scoped to `receiver: '^(?:xp|xpath)$'`
495
+ // for `XPath xp = XPathFactory.newInstance().newXPath(); xp.evaluate(...)`).
496
+ // A first attempt folded declared types in here and broke that exact case
497
+ // (`test/java-taint-flow.test.js`'s cast-wrapped XPath test went from firing
498
+ // to silent) — confirmed by direct regression, not theorized. Declared-type
499
+ // resolution belongs in `class-hierarchy.js`'s `typeOfVar` instead (see its
500
+ // `fn.paramTypes` seeding below `buildClassHierarchy`), which is consulted
501
+ // ADDITIVELY via `match.receiverTypeIn`/`_receiverTypeConfirms` — a type
502
+ // match there can only ADD a finding a name-based entry missed, never
503
+ // replace the string a name-based entry still needs to see.
504
+ return varTypes;
505
+ }
506
+
507
+ function _rewriteVarTypeCallees(expr, varTypes) {
508
+ if (!expr || typeof expr !== 'object') return;
509
+ if (expr.kind === 'call') {
510
+ if (typeof expr.callee === 'string') {
511
+ const dot = expr.callee.indexOf('.');
512
+ if (dot > 0) {
513
+ const varName = expr.callee.slice(0, dot);
514
+ const cls = varTypes.get(varName);
515
+ if (cls) expr.callee = `${cls}${expr.callee.slice(dot)}`;
516
+ }
517
+ }
518
+ if (Array.isArray(expr.args)) for (const a of expr.args) _rewriteVarTypeCallees(a, varTypes);
519
+ return;
520
+ }
521
+ if (expr.kind === 'member') { _rewriteVarTypeCallees(expr.object, varTypes); return; }
522
+ if (expr.kind === 'binary' || expr.kind === 'logical') {
523
+ _rewriteVarTypeCallees(expr.left, varTypes); _rewriteVarTypeCallees(expr.right, varTypes); return;
524
+ }
525
+ if (expr.kind === 'tpl' && Array.isArray(expr.parts)) { for (const p of expr.parts) _rewriteVarTypeCallees(p, varTypes); return; }
526
+ if (expr.kind === 'union' && Array.isArray(expr.branches)) { for (const b of expr.branches) _rewriteVarTypeCallees(b, varTypes); return; }
527
+ if (expr.kind === 'array' && Array.isArray(expr.elements)) { for (const e of expr.elements) _rewriteVarTypeCallees(e, varTypes); return; }
528
+ }
529
+
530
+ function _applyVarTypeRewrite(nodes) {
531
+ const varTypes = _localVarConstructedTypes(nodes);
532
+ let any = false;
533
+ for (const t of varTypes.values()) if (t) { any = true; break; }
534
+ if (!any) return;
535
+ for (const node of Object.values(nodes)) {
536
+ if (node.kind === 'call') _rewriteVarTypeCallees(node, varTypes);
537
+ else if (node.kind === 'assign') _rewriteVarTypeCallees(node.source, varTypes);
538
+ else if (node.kind === 'return' || node.kind === 'throw') _rewriteVarTypeCallees(node.value, varTypes);
539
+ else if (node.kind === 'if') _rewriteVarTypeCallees(node.cond, varTypes);
540
+ }
541
+ }
542
+
543
+ // The simple class name of a `new X(...)` expression.
544
+ function _ctorClassName(ci) {
545
+ const ids = ci?.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier;
546
+ if (!Array.isArray(ids) || !ids.length) return '';
547
+ return ids[ids.length - 1].image || '';
548
+ }
549
+
263
550
  function _fqnExpr(node) {
264
551
  if (!node || !node.children) return { kind: 'unknown' };
265
552
  const idImages = _fqnIdents(node);
@@ -340,6 +627,40 @@ function buildCfgFromBody(bodyNode) {
340
627
  return id;
341
628
  }
342
629
 
630
+ // Lower one assignment expression (already through exprFromCst) to CFG
631
+ // assign node(s). A compound operator (`+=`) reads the target too, so its
632
+ // source is a tpl of [target, rhs], the same shape string interpolation
633
+ // lowers to. Field writes emit a bare alias next to the qualified target:
634
+ // `this.f = v` is read back as `f` elsewhere in the class, and a
635
+ // class-qualified static (`A.sf = v`) is read back as `sf`. An array
636
+ // element write taints the whole array (its read lowers to the array
637
+ // identifier, so the element-level path would never match).
638
+ function emitAssignment(expr, line) {
639
+ const targets = _assignTargets(expr.left);
640
+ if (!targets.length) return;
641
+ const source = expr.op === '='
642
+ ? expr.right
643
+ : { kind: 'tpl', parts: [expr.left, expr.right] };
644
+ for (const target of targets) emit({ kind: 'assign', target, source, line, succ: [] });
645
+ }
646
+
647
+ // `while ((line = r.readLine()) != null)` and the if-form: the assignment
648
+ // lives inside the condition expression, not in a statement of its own.
649
+ // Emit every assignment nested in the condition subtree ahead of the
650
+ // header node so the bound variable carries the call's taint into the
651
+ // body.
652
+ function emitCondAssignments(condCst, line) {
653
+ if (!condCst || typeof condCst !== 'object') return;
654
+ if (condCst.children?.AssignmentOperator) {
655
+ const expr = exprFromCst(condCst);
656
+ if (_isAssignment(expr)) emitAssignment(expr, line);
657
+ return;
658
+ }
659
+ for (const k of Object.keys(condCst.children || {})) {
660
+ for (const c of condCst.children[k]) emitCondAssignments(c, line);
661
+ }
662
+ }
663
+
343
664
  walkStmts(bodyNode);
344
665
 
345
666
  function walkStmts(stmtNode) {
@@ -362,11 +683,30 @@ function buildCfgFromBody(bodyNode) {
362
683
  const vdecl = lv.children?.localVariableDeclaration?.[0];
363
684
  const declarators = vdecl?.children?.variableDeclaratorList?.[0]?.children?.variableDeclarator;
364
685
  if (declarators) {
686
+ // Declared TYPE (not just name) — same PRD W2.5 rationale and CST
687
+ // path as `paramTypes` above, one level down (`localVariableType`
688
+ // instead of a `formalParameter`'s `variableParaRegularParameter`).
689
+ // A local declared as an INTERFACE/supertype (`PrintWriter out =
690
+ // response.getWriter();`) is exactly Java's dominant "get a writer,
691
+ // then use it" idiom — `_localVarConstructedTypes` below only ever
692
+ // sees an `isNew` constructor call, so this shape's receiver type
693
+ // (needed for `writer.println(x)` to resolve against a catalog
694
+ // sink keyed on the PrintWriter type) was completely invisible.
695
+ // `var` (Java 10+ inference) is not a real type name and is
696
+ // skipped, same as the `isVar` guard the hand-rolled parsers use
697
+ // elsewhere in this codebase.
698
+ const declTypeIds = vdecl?.children?.localVariableType?.[0]?.children?.unannType?.[0]
699
+ ?.children?.unannReferenceType?.[0]?.children?.unannClassOrInterfaceType?.[0]
700
+ ?.children?.unannClassType?.[0]?.children?.Identifier;
701
+ const rawDeclType = Array.isArray(declTypeIds) && declTypeIds.length ? declTypeIds[declTypeIds.length - 1]?.image : undefined;
702
+ const declType = rawDeclType && rawDeclType !== 'var' ? rawDeclType : undefined;
365
703
  for (const d of declarators) {
366
704
  const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
367
705
  const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0];
368
706
  if (target) {
369
- emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(lv), succ: [] });
707
+ const node = { kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(lv), succ: [], decl: true };
708
+ if (declType) node.declaredType = declType;
709
+ emit(node);
370
710
  }
371
711
  }
372
712
  }
@@ -382,11 +722,9 @@ function buildCfgFromBody(bodyNode) {
382
722
  const e = kids.expressionStatement[0]?.children?.statementExpression?.[0]?.children?.expression?.[0];
383
723
  if (e) {
384
724
  const expr = exprFromCst(e);
385
- if (expr.kind === 'call') emit({ ...expr, line: _lineOf(kids.expressionStatement[0]), succ: [] });
386
- else if (expr.kind === 'binary' && expr.op === '=') {
387
- // assignment expr `x = y;`
388
- emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(kids.expressionStatement[0]), succ: [] });
389
- }
725
+ const line = _lineOf(kids.expressionStatement[0]);
726
+ if (expr.kind === 'call') emit({ ...expr, line, succ: [] });
727
+ else if (_isAssignment(expr)) emitAssignment(expr, line);
390
728
  }
391
729
  }
392
730
  if (kids.returnStatement) {
@@ -402,13 +740,58 @@ function buildCfgFromBody(bodyNode) {
402
740
  if (kids.ifStatement) {
403
741
  const i = kids.ifStatement[0];
404
742
  const cond = i.children?.expression?.[0];
405
- emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(i), succ: [] });
406
- // Then branch body falls through linearly; v1 simplification.
407
- for (const sub of (i.children?.statement || [])) walkStmts(sub);
743
+ emitCondAssignments(cond, _lineOf(i));
744
+ const ifNodeId = emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(i), succ: [] });
745
+ // PRD W2 (SARD_80_F1_EXECUTION_PRD.md) — real branch + join, not a
746
+ // linear fall-through. The "v1 simplification" this replaces walked
747
+ // BOTH the then- and else-statement children (java-parser's
748
+ // ifStatement.children.statement is [thenStmt, elseStmt] in source
749
+ // order, confirmed via direct CST inspection) onto the SAME shared
750
+ // chain — so an else branch's assignment unconditionally overwrote
751
+ // whatever the then branch had just tainted, with no notion that the
752
+ // two are ALTERNATIVES. Found investigating why a real, verified
753
+ // taint chain (System.getenv/BufferedReader.readLine() -> concat ->
754
+ // sink) produced zero findings the instant it was wrapped in
755
+ // Juliet's own `if (true) { <tainted> } else { data = null; }`
756
+ // idiom — used throughout the ENTIRE corpus for control-flow-gated
757
+ // flow variants, so this was silently destroying an unknown but
758
+ // likely large fraction of Java's real recall, not a narrow gap.
759
+ // The then-branch starts right after the if-node (already linked by
760
+ // the emit() above); the else-branch is a SEPARATE second edge off
761
+ // the SAME if-node, not a continuation of the then-branch's tail.
762
+ // Both (and the no-else "condition false" case) converge on one
763
+ // synthetic join node, so a downstream read sees the union of what
764
+ // either branch could have left tainted — the same recall-preserving
765
+ // over-approximation every other control-flow join in this codebase
766
+ // relies on (case/when arms, try/catch/finally).
767
+ const stmts = i.children?.statement || [];
768
+ const thenStmt = stmts[0];
769
+ const elseStmt = stmts[1];
770
+ prev = ifNodeId;
771
+ if (thenStmt) walkStmts(thenStmt);
772
+ const thenExit = prev;
773
+ let elseExit = ifNodeId;
774
+ if (elseStmt) {
775
+ prev = ifNodeId;
776
+ walkStmts(elseStmt);
777
+ elseExit = prev;
778
+ }
779
+ const joinId = nextNodeId();
780
+ nodes[joinId] = { id: joinId, kind: 'noop', succ: [] };
781
+ const linkToJoin = (fromId) => {
782
+ if (!nodes[fromId]) return;
783
+ nodes[fromId].succ = nodes[fromId].succ || [];
784
+ if (!nodes[fromId].succ.includes(joinId)) nodes[fromId].succ.push(joinId);
785
+ };
786
+ linkToJoin(thenExit);
787
+ if (elseStmt) linkToJoin(elseExit);
788
+ else linkToJoin(ifNodeId); // no else: condition-false falls straight to the join
789
+ prev = joinId;
408
790
  }
409
791
  if (kids.whileStatement) {
410
792
  const w = kids.whileStatement[0];
411
793
  const cond = w.children?.expression?.[0];
794
+ emitCondAssignments(cond, _lineOf(w));
412
795
  emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(w), succ: [] });
413
796
  for (const sub of (w.children?.statement || [])) walkStmts(sub);
414
797
  }
@@ -460,6 +843,7 @@ function buildCfgFromBody(bodyNode) {
460
843
  if (kids.doStatement) {
461
844
  const d = kids.doStatement[0];
462
845
  const cond = d.children?.expression?.[0];
846
+ emitCondAssignments(cond, _lineOf(d));
463
847
  emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(d), succ: [] });
464
848
  for (const sub of (d.children?.statement || [])) walkStmts(sub);
465
849
  }
@@ -499,8 +883,35 @@ function buildCfgFromBody(bodyNode) {
499
883
  }
500
884
  const bodyBlock = container.children?.block?.[0];
501
885
  if (bodyBlock) walkStmts(bodyBlock);
886
+ const throwExprs = [];
887
+ if (bodyBlock) _collectThrowExprs(bodyBlock, throwExprs);
502
888
  const catches = container.children?.catches?.[0]?.children?.catchClause || [];
503
889
  for (const cc of catches) {
890
+ // Exception-message taint: bind the catch variable to whatever was
891
+ // thrown in the try body BEFORE walking the catch block, so a
892
+ // read of it inside (e.g. `e.getMessage()`) sees real taint via the
893
+ // engine's existing generic tainted-call-argument/receiver-taint
894
+ // mechanism — no catalog or engine change needed, since a `new
895
+ // Exception(x)` constructor call is already lowered to an ordinary
896
+ // `call` node and `exprTaint`'s case 'call' already treats a
897
+ // tainted arg as tainting the call's own result. `isNew` is
898
+ // stripped from each cloned expr: left as `true`, `_localVarConstructedTypes`
899
+ // (W2.5's abstract-dispatch fix, above) treats this assign as
900
+ // establishing `exceptCWE81`'s "constructed type" as `Exception`
901
+ // and rewrites every later `exceptCWE81.getMessage()` call to the
902
+ // class-qualified `Exception.getMessage` — which is correct for
903
+ // dispatch resolution but destroys the variable-name receiver this
904
+ // fix needs `_calleeReceiverTainted` to see. Confirmed via direct
905
+ // CFG dump: without stripping `isNew`, the rewrite fires and the
906
+ // probe fixture stays at zero findings even with the assign in place.
907
+ if (throwExprs.length) {
908
+ const paramName = cc.children?.catchFormalParameter?.[0]?.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
909
+ if (paramName) {
910
+ const cloned = throwExprs.map(e => ({ ...e, isNew: false }));
911
+ const source = cloned.length === 1 ? cloned[0] : { kind: 'union', branches: cloned };
912
+ emit({ kind: 'assign', target: paramName, source, line: _lineOf(cc), succ: [] });
913
+ }
914
+ }
504
915
  const cblock = cc.children?.block?.[0];
505
916
  if (cblock) walkStmts(cblock);
506
917
  }
@@ -514,13 +925,34 @@ function buildCfgFromBody(bodyNode) {
514
925
  if (kids.switchStatement) {
515
926
  const sw = kids.switchStatement[0];
516
927
  const cond = sw.children?.expression?.[0];
517
- emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(sw), succ: [] });
928
+ const swHeaderId = emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(sw), succ: [] });
518
929
  const swBlock = sw.children?.switchBlock?.[0];
930
+ // PRD W2 (SARD_80_F1_EXECUTION_PRD.md) — same defect class as the
931
+ // if/else fix directly above: each case group was walked onto the
932
+ // SAME shared linear chain, so `case 6: <tainted>; break;` and a
933
+ // later `default: data = null; break;` (Juliet's own constant-switch
934
+ // idiom, `switch(6){case 6:...break;default:...break;}`) fell
935
+ // through into each other exactly like if/else's then/else did.
936
+ // Every case group now starts as its OWN edge off the switch-header
937
+ // node (mirroring this codebase's existing case/when convention —
938
+ // see parser-rb.js/parser-py-cst.js's own case-arm handling, "every
939
+ // arm reachable directly from the construct's own entry point") and
940
+ // converges on one shared join node. Deliberately does not model
941
+ // fall-through-without-break between adjacent cases (rare in
942
+ // practice, and no other switch/case/when handling in this codebase
943
+ // models it either) — every case is treated as an independent
944
+ // alternative, which is recall-preserving even for the (uncommon)
945
+ // real fall-through case: the taint that WOULD have carried over is
946
+ // simply also visible via that case's own reachability from the
947
+ // header, just not chained through its neighbor.
948
+ const caseExits = [];
519
949
  // Classic colon-form (`case 1: ...; break;`).
520
950
  const groups = swBlock?.children?.switchBlockStatementGroup || [];
521
951
  for (const g of groups) {
522
952
  const bss = g.children?.blockStatements?.[0];
953
+ prev = swHeaderId;
523
954
  if (bss) walkStmts(bss);
955
+ caseExits.push(prev);
524
956
  }
525
957
  // R8 Task 1 fix round 1: arrow-form (`case 1 -> ...;`, Java 14+) is a
526
958
  // structurally distinct grammar rule — confirmed via direct CST
@@ -537,12 +969,16 @@ function buildCfgFromBody(bodyNode) {
537
969
  // confirmed 0 IR-TAINT findings for the idiomatic modern-Java shape.
538
970
  const rules = swBlock?.children?.switchRule || [];
539
971
  for (const r of rules) {
972
+ prev = swHeaderId;
540
973
  const rblock = r.children?.block?.[0];
541
- if (rblock) { walkStmts(rblock); continue; }
974
+ if (rblock) { walkStmts(rblock); caseExits.push(prev); continue; }
542
975
  const rthrow = r.children?.throwStatement?.[0];
543
976
  if (rthrow) {
544
977
  const texpr = rthrow.children?.expression?.[0];
545
978
  emit({ kind: 'throw', value: texpr ? exprFromCst(texpr) : null, line: _lineOf(rthrow), succ: [] });
979
+ // A throw never falls through to the join — matches this
980
+ // codebase's existing (elsewhere-tolerated) convention of not
981
+ // adding a successor after a return/throw node.
546
982
  continue;
547
983
  }
548
984
  const rexpr = r.children?.expression?.[0];
@@ -553,7 +989,21 @@ function buildCfgFromBody(bodyNode) {
553
989
  emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(r), succ: [] });
554
990
  }
555
991
  }
992
+ caseExits.push(prev);
556
993
  }
994
+ // Join: every case's exit (colon- or arrow-form) converges here,
995
+ // same as if/else's join above. A switch with zero case groups at
996
+ // all falls the header straight through to the join.
997
+ const swJoinId = nextNodeId();
998
+ nodes[swJoinId] = { id: swJoinId, kind: 'noop', succ: [] };
999
+ const linkSwToJoin = (fromId) => {
1000
+ if (!nodes[fromId]) return;
1001
+ nodes[fromId].succ = nodes[fromId].succ || [];
1002
+ if (!nodes[fromId].succ.includes(swJoinId)) nodes[fromId].succ.push(swJoinId);
1003
+ };
1004
+ if (caseExits.length) { for (const ex of caseExits) linkSwToJoin(ex); }
1005
+ else linkSwToJoin(swHeaderId);
1006
+ prev = swJoinId;
557
1007
  }
558
1008
  // Bare nested block `{ ... }` with no leading keyword. The
559
1009
  // `statementWithoutTrailingSubstatement` branch above already recurses
@@ -594,6 +1044,15 @@ export async function parseJavaFile(file, raw) {
594
1044
  if (!cst) return null;
595
1045
 
596
1046
  const functions = [];
1047
+ // Class records for class-hierarchy analysis: `bases` (extends + all
1048
+ // implements names) feeds abstract/interface-receiver dispatch (Juliet
1049
+ // flow variants 81/82 — `A_81_base b = new A_81_bad(); b.action(data)`
1050
+ // needs to know A_81_bad extends A_81_base to resolve the call), `fields`
1051
+ // (instance and static declared names) lets cross-method taint tell a
1052
+ // field write (`sf = data;`, live across the whole class) apart from an
1053
+ // unrelated local variable of the same name in another method. Same
1054
+ // optional per-file shape `parser-cpp.js` already attaches.
1055
+ const classes = [];
597
1056
  // Walk the CST for methodDeclaration nodes.
598
1057
  function walkForMethods(node, className) {
599
1058
  if (!node || !node.children) return;
@@ -605,6 +1064,7 @@ export async function parseJavaFile(file, raw) {
605
1064
  const newClassName = child.children?.typeIdentifier?.[0]?.children?.Identifier?.[0]?.image
606
1065
  || child.children?.Identifier?.[0]?.image
607
1066
  || className;
1067
+ if (newClassName) classes.push(_classRecord(child, newClassName));
608
1068
  walkForMethods(child, newClassName);
609
1069
  continue;
610
1070
  }
@@ -621,6 +1081,7 @@ export async function parseJavaFile(file, raw) {
621
1081
  const fpl = md.children?.methodHeader?.[0]?.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0]
622
1082
  || md.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0];
623
1083
  const paramAnnotations = [];
1084
+ const paramTypes = {};
624
1085
  const params = (fpl?.children?.formalParameter || []).map((fp, idx) => {
625
1086
  // Regular parameters nest under `variableParaRegularParameter`.
626
1087
  // Varargs (`String... args`) instead use a distinct
@@ -634,6 +1095,24 @@ export async function parseJavaFile(file, raw) {
634
1095
  // still yields a clean name.
635
1096
  const vp = fp.children?.variableParaRegularParameter?.[0];
636
1097
  const paramName = vp?.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image || null;
1098
+ // PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — declared parameter
1099
+ // TYPE, not just name. Juliet's dominant abstract/interface
1100
+ // dispatch idiom passes the constructed object as a PARAMETER
1101
+ // (`void helper(BaseType b, String data) { b.action(data); }`),
1102
+ // not a local `new` assignment — `_localVarConstructedTypes`
1103
+ // above only ever sees the latter, so this shape's receiver type
1104
+ // was completely unknown to every downstream resolver. Only a
1105
+ // simple class/interface type name is extracted (the
1106
+ // `unannClassType` leaf of `unannType`); a primitive
1107
+ // (`unannPrimitiveType`) or array/generic type some CST shapes
1108
+ // don't reduce to a single Identifier here degrades to
1109
+ // undefined, same graceful-drop convention as the varargs case
1110
+ // above.
1111
+ const typeIds = vp?.children?.unannType?.[0]?.children?.unannReferenceType?.[0]
1112
+ ?.children?.unannClassOrInterfaceType?.[0]?.children?.unannClassType?.[0]
1113
+ ?.children?.Identifier;
1114
+ const paramType = Array.isArray(typeIds) && typeIds.length ? typeIds[typeIds.length - 1]?.image : undefined;
1115
+ if (paramName && paramType) paramTypes[paramName] = paramType;
637
1116
  // Java allows multiple stacked annotations on one parameter
638
1117
  // (`@NotNull @RequestParam String q`), each its own
639
1118
  // `variableModifier` entry — loop over all of them, not just
@@ -663,6 +1142,7 @@ export async function parseJavaFile(file, raw) {
663
1142
  if (body) {
664
1143
  const methodLine = _lineOf(md);
665
1144
  const cfg = buildCfgFromBody(body);
1145
+ _applyVarTypeRewrite(cfg.nodes);
666
1146
  functions.push({
667
1147
  // Sibling frontends (parser-js.js) suffix the qid with `@line`
668
1148
  // so overloaded/same-named methods don't collide — without it,
@@ -676,6 +1156,7 @@ export async function parseJavaFile(file, raw) {
676
1156
  file,
677
1157
  calls: callSitesFromCfg(cfg),
678
1158
  ...(paramAnnotations.length ? { paramAnnotations } : {}),
1159
+ ...(Object.keys(paramTypes).length ? { paramTypes } : {}),
679
1160
  });
680
1161
  }
681
1162
  }
@@ -685,5 +1166,5 @@ export async function parseJavaFile(file, raw) {
685
1166
  }
686
1167
  walkForMethods(cst, null);
687
1168
 
688
- return { file, functions, topLevel: null };
1169
+ return { file, functions, topLevel: null, ...(classes.length ? { classes } : {}) };
689
1170
  }