@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
@@ -123,6 +123,18 @@ function _findClassRanges(code) {
123
123
  let m;
124
124
  while ((m = CLASS_RE.exec(code)) !== null) {
125
125
  const name = m[1];
126
+ // `CLASS_RE`'s leading `(?:^|[\s;{}])` consumes exactly one boundary
127
+ // character into `m[0]` (or zero, at `^`). When that boundary
128
+ // character is itself the newline ending the PREVIOUS line — the
129
+ // common case for a top-level declaration with no leading
130
+ // indentation — `m.index` points at that newline, and `_lineAt`
131
+ // (which counts newlines strictly before its offset) undercounts by
132
+ // one line as a result. Stepping one character past a real boundary
133
+ // char lands on the declaration's own first character, which is
134
+ // always on the correct line regardless of which boundary character
135
+ // matched (a same-line boundary like a space or `{` is unaffected by
136
+ // the shift).
137
+ const declStart = m.index + (/^[\s;{}]/.test(m[0][0]) ? 1 : 0);
126
138
  // Skip past an optional generic parameter list and/or base/interface
127
139
  // list to find the REAL opening brace — `class Foo<T> : Base<T>, IBar`
128
140
  // has two `<...>` regions before the body even starts. Track angle-
@@ -130,26 +142,131 @@ function _findClassRanges(code) {
130
142
  // generics don't nest with unbalanced `<`/`>` in valid code), and a
131
143
  // `{` encountered while depth > 0 (an unlikely default-value-in-
132
144
  // generic-constraint edge case) is simply skipped rather than
133
- // mis-treated as the class body.
145
+ // mis-treated as the class body. `colonAt` records where the
146
+ // base/interface list starts (the FIRST top-level `:`, C# allows only
147
+ // one) so it can be sliced out once the real body brace is found.
134
148
  let i = m.index + m[0].length;
135
149
  let angleDepth = 0;
150
+ let colonAt = -1;
136
151
  while (i < code.length) {
137
152
  const c = code[i];
138
153
  if (c === '<') angleDepth++;
139
154
  else if (c === '>') { if (angleDepth > 0) angleDepth--; }
155
+ else if (c === ':' && angleDepth === 0 && colonAt === -1) colonAt = i;
140
156
  else if (c === '{' && angleDepth === 0) break;
141
157
  else if ((c === ';') && angleDepth === 0) { i = -1; break; } // forward-declaration-shaped or malformed; bail
142
158
  i++;
143
159
  }
144
160
  if (i < 0 || i >= code.length || code[i] !== '{') continue;
161
+ let bases = [];
162
+ if (colonAt !== -1) {
163
+ // A generic constraint clause (`where T : class`) can follow the
164
+ // base list before the body brace and would otherwise be read as
165
+ // more base names; it always starts with a top-level `where` after
166
+ // the base list, so truncate there first.
167
+ let baseText = code.slice(colonAt + 1, i);
168
+ const whereIdx = baseText.search(/\bwhere\b/);
169
+ if (whereIdx !== -1) baseText = baseText.slice(0, whereIdx);
170
+ // Each base/interface name, stripped of its own generic argument
171
+ // list (`BaseRepo<T>` → `BaseRepo`) — `class-hierarchy.js`-style
172
+ // consumers key allocation/dispatch types by simple name, same as
173
+ // every other class-metadata producer in this codebase.
174
+ bases = _splitTopLevelCommas(baseText).map(p => p.trim().split(/[<\s]/)[0]).filter(Boolean);
175
+ }
145
176
  const extracted = _extractBody(code, i);
146
177
  if (!extracted) continue;
147
- ranges.push({ name, start: i, end: extracted.end });
178
+ ranges.push({ name, start: i, end: extracted.end, bases, line: _lineAt(code, declStart) });
148
179
  CLASS_RE.lastIndex = i + 1; // resume scanning INSIDE the class body too, for nested classes
149
180
  }
150
181
  return ranges;
151
182
  }
152
183
 
184
+ // Scan a member statement (as produced by `_splitStatements` over a class
185
+ // body) to decide whether it's method/constructor-SHAPED (a `(` appears at
186
+ // the statement's own top level, before any `=` or `{`) versus a field or
187
+ // auto-property declaration. Attributes (`[Foo] [Bar(...)]`) prefixing the
188
+ // member push `[`/`]` onto the SAME depth counter `{`/`(` use, so they are
189
+ // transparently skipped without any special-casing — by the time a real
190
+ // modifier/type token appears, depth is back to 0.
191
+ function _memberHeadInfo(stmt) {
192
+ let depth = 0;
193
+ let inStr = null;
194
+ let escape = false;
195
+ for (let i = 0; i < stmt.length; i++) {
196
+ const c = stmt[i];
197
+ if (escape) { escape = false; continue; }
198
+ if (inStr) {
199
+ if (c === '\\') { escape = true; continue; }
200
+ if (c === inStr) inStr = null;
201
+ continue;
202
+ }
203
+ if (c === '"' || c === "'") { inStr = c; continue; }
204
+ if (c === '(' && depth === 0) return { isMethodLike: true };
205
+ if (c === '{' && depth === 0) return { isMethodLike: false, headEnd: i };
206
+ if (c === '(' || c === '{' || c === '[' || c === '<') { depth++; continue; }
207
+ if (c === ')' || c === '}' || c === ']' || c === '>') { if (depth > 0) depth--; continue; }
208
+ // A real assignment `=`, not `==`/`!=`/`<=`/`>=`/`=>`.
209
+ if (depth === 0 && c === '=' && stmt[i + 1] !== '=' && stmt[i + 1] !== '>' &&
210
+ !['=', '!', '<', '>'].includes(stmt[i - 1])) {
211
+ return { isMethodLike: false, headEnd: i };
212
+ }
213
+ }
214
+ return { isMethodLike: false, headEnd: stmt.length };
215
+ }
216
+
217
+ // The last bare identifier in `text` — used to pull a declared name off
218
+ // the tail of a type/modifier clause (`private static string sf` → `sf`,
219
+ // `public readonly Dictionary<string, T> map` → `map`) without needing to
220
+ // parse the type itself, which can contain internal whitespace (generic
221
+ // argument lists) that a naive whitespace-split would mis-split on.
222
+ function _lastIdent(text) {
223
+ const m = String(text || '').match(/([A-Za-z_]\w*)\s*$/);
224
+ return m ? m[1] : null;
225
+ }
226
+
227
+ const _NESTED_TYPE_RE = /^(?:(?:public|private|protected|internal|static|sealed|abstract|partial|new|readonly)\s+)*(?:class|struct|interface|enum)\s/;
228
+
229
+ // Declared field/auto-property names for one class range, from the SAME
230
+ // statement split `_buildCfg` uses for method bodies (`_splitStatements`
231
+ // already treats a balanced `{...}` — a method body, or a property's
232
+ // `{ get; set; }` accessor block — as part of ONE flushed statement, so
233
+ // method declarations and multi-statement bodies never need to be
234
+ // specially excluded here: `_memberHeadInfo` rejects anything method-
235
+ // shaped outright). Nested type declarations are recognized and skipped
236
+ // as a whole (their own fields are not modeled — out of scope for the
237
+ // cross-method field-taint use this exists for).
238
+ function _extractClassFields(code, range) {
239
+ const bodyText = code.slice(range.start + 1, range.end);
240
+ const fields = [];
241
+ for (const { text: stmt } of _splitStatements(bodyText)) {
242
+ if (!stmt || stmt.startsWith('//') || stmt.startsWith('/*')) continue;
243
+ if (_NESTED_TYPE_RE.test(stmt)) continue;
244
+ const info = _memberHeadInfo(stmt);
245
+ if (info.isMethodLike) continue;
246
+ const head = stmt.slice(0, info.headEnd);
247
+ if (stmt[info.headEnd] === '{') {
248
+ // Auto-property: `public string Name { get; set; }` — one name, no
249
+ // multi-declarator syntax exists for properties.
250
+ const name = _lastIdent(head);
251
+ if (name) fields.push(name);
252
+ continue;
253
+ }
254
+ // Field declaration, possibly multiple comma-separated declarators
255
+ // (`protected int a, b;`), each optionally carrying its own `= init`.
256
+ // `_splitTopLevelCommas` is already `<`/`(`/`{`/`[`-depth-aware (used
257
+ // elsewhere in this file for argument lists), so a comma inside a
258
+ // generic type argument or an initializer's own call/object literal
259
+ // does not fracture the declarator list.
260
+ for (const part of _splitTopLevelCommas(stmt)) {
261
+ const eq = part.indexOf('=');
262
+ const before = eq === -1 ? part : part.slice(0, eq);
263
+ const name = _lastIdent(before);
264
+ if (name) fields.push(name);
265
+ }
266
+ }
267
+ return fields;
268
+ }
269
+
153
270
  // Innermost class whose range contains `pos` (a method declaration's match
154
271
  // offset), or null when the method sits outside any class (top-level
155
272
  // statements — legal but rare).
@@ -180,12 +297,33 @@ function _enclosingClassName(ranges, pos) {
180
297
  // is (re)assigned to more than one distinct constructed type in the same
181
298
  // function — ambiguous, same "refuse rather than fabricate an edge"
182
299
  // convention `bareTailInFile` already uses.
300
+ // SARD_80_F1 W2.3 — ported from the identical fix in parser-java.js (see
301
+ // its own comment for the full story, found first for Java): a BCL
302
+ // collection type (`List`, `Dictionary`, `Stack`, `Queue`, `HashSet`, …)
303
+ // is never an entry in `callgraph.js`'s `classMethods` index (built only
304
+ // from THIS project's own `ir.classes`), so class-qualifying
305
+ // `list.Add(data)` to `List.Add(data)` buys dispatch resolution nothing —
306
+ // while breaking `engine.js`'s mutator rule, which needs the REAL
307
+ // variable name to attribute taint correctly. A `foreach (var s in list)`
308
+ // read over the container itself lowers to a bare-identifier assign
309
+ // (`s = list`, no callee to rewrite), so it stays keyed on "list" while
310
+ // the (rewritten) write lands on the fake receiver "List" — the taint is
311
+ // silently lost. Confirmed via a direct CFG dump: `List<string> list =
312
+ // new List<string>(); list.Add(data);` didn't even trigger the bug (this
313
+ // parser's generic-constructor lowering produces `{kind:'unknown'}` for
314
+ // `new List<string>()`, so it's never added to `varTypes` at all) — but
315
+ // the non-generic `ArrayList`/`Hashtable`-style BCL collections DO lower
316
+ // to a proper `isNew:true` call and DO exhibit the exact same desync as
317
+ // Java's `ArrayList`.
318
+ const _BCL_CONTAINER_TYPES = /^(?:List|ArrayList|LinkedList|Stack|Queue|Dictionary|Hashtable|SortedList|SortedDictionary|HashSet|SortedSet|ArrayDeque|ConcurrentBag|ConcurrentQueue|ConcurrentStack|ConcurrentDictionary|BlockingCollection|ObservableCollection|Collection|CollectionBase)$/;
319
+
183
320
  function _localVarConstructedTypes(nodes) {
184
321
  const varTypes = new Map(); // varName -> className | null (ambiguous)
185
322
  for (const node of Object.values(nodes)) {
186
323
  if (node.kind !== 'assign') continue;
187
324
  const src = node.source;
188
325
  if (!src || src.kind !== 'call' || !src.isNew || typeof src.callee !== 'string') continue;
326
+ if (_BCL_CONTAINER_TYPES.test(src.callee)) continue;
189
327
  const varName = node.target;
190
328
  if (!varName || varName.includes('.')) continue; // only a bare local, never a member-write target
191
329
  if (varTypes.has(varName)) {
@@ -468,16 +606,70 @@ function _followChain(s, endIdx, calleeSoFar, argsSoFar, isNew) {
468
606
  return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar), false);
469
607
  }
470
608
 
609
+ // A C-style cast `(Type)expr` / `(Type) expr` is transparent for taint: the
610
+ // value is the operand. Juliet's collection variants (`Object o = data;
611
+ // string d = (string)o;`) and every `(string)reader.GetValue(0)` read hit
612
+ // this shape; before it was recognised the whole expression fell through to
613
+ // `{kind:'unknown'}` and the taint died at the cast. The type token must
614
+ // look like a type (identifier, optional generics/array/nullable) so a
615
+ // parenthesised arithmetic expression like `(a + b)` is not mistaken for a
616
+ // cast.
617
+ const _CAST_RE = /^\(\s*[A-Za-z_][\w.]*(?:<[^()]*>)?(?:\[\s*\])*\??\s*\)\s*(?=[A-Za-z_("@$])/;
618
+
471
619
  function _lowerExpr(text) {
472
620
  const s = String(text || '').trim();
473
621
  if (!s) return { kind: 'unknown' };
474
- // Member access: a.b.c["foo"]
475
- if (/^[A-Za-z_][\w.]*\[[^\]]*\]$/.test(s)) {
476
- // E.g. Request.Form["name"]. Split on first '[' to isolate index.
477
- const lb = s.indexOf('[');
478
- const base = s.slice(0, lb);
479
- const dots = base.split('.');
480
- return _buildMemberChain(dots, /*indexed*/ s.slice(lb));
622
+ const castM = s.match(_CAST_RE);
623
+ if (castM) return _lowerExpr(s.slice(castM[0].length));
624
+ // Parenthesised object creation starting a chain: `(new X()).M(args)`.
625
+ // Same shape as the un-parenthesised `new X().M(args)` branch below,
626
+ // which cannot see it because the leading `(` blocks the `^new` anchor.
627
+ if (s.startsWith('(')) {
628
+ const closeIdx = _matchDelim(s, 0, '(', ')');
629
+ if (closeIdx !== -1 && /^\s*new\s/.test(s.slice(1, closeIdx))) {
630
+ const inner = _lowerExpr(s.slice(1, closeIdx).trim());
631
+ if (inner.kind === 'call' && s.slice(closeIdx + 1).trim().startsWith('.')) {
632
+ return _followChain(s, closeIdx + 1, inner.callee, inner.args, inner.isNew);
633
+ }
634
+ return inner;
635
+ }
636
+ }
637
+ // Member access: a.b.c["foo"], and a.b.c["foo"].d.e — an indexer
638
+ // followed by further property access. The indexer-only form (E.g.
639
+ // `Request.Form["name"]`) used to be the only shape recognized, requiring
640
+ // the bracket to end the whole expression; anything indexed-then-accessed
641
+ // (`cookieSources[0].Value`) fell through every later branch to
642
+ // `{kind:'unknown'}`, silently dropping the base's source taint. Confirmed
643
+ // via the public C# Juliet mirror's own ASP.NET cookie-collection idiom
644
+ // (`req.Cookies[0].Value`,
645
+ // `CWE113_HTTP_Response_Splitting__Web_Get_Cookies_Web_setHeader_01.cs`) —
646
+ // a real, structural recall gap, not a scoring quirk: `runScan` produced
647
+ // zero findings on this exact shape before this fix. `_matchDelim` (bracket-
648
+ // depth- and string-aware) replaces the old `[^\]]*` scan, which could not
649
+ // have handled a nested bracket correctly either.
650
+ const idxBaseM = s.match(/^([A-Za-z_][\w.]*)\[/);
651
+ if (idxBaseM) {
652
+ const lb = idxBaseM[1].length;
653
+ const rb = _matchDelim(s, lb, '[', ']');
654
+ if (rb !== -1) {
655
+ const dots = idxBaseM[1].split('.');
656
+ let cur = _buildMemberChain(dots, s.slice(lb, rb + 1));
657
+ let rest = s.slice(rb + 1);
658
+ // Follow zero or more trailing `.prop` continuations (a bare property
659
+ // read, no call parens) — a `.Method(...)` continuation is a
660
+ // genuinely different shape (handled by matchBalancedCall/_followChain
661
+ // elsewhere) and is deliberately not duplicated here.
662
+ let restM;
663
+ while ((restM = rest.match(/^\.([A-Za-z_]\w*)(?!\s*\()/))) {
664
+ cur = { kind: 'member', object: cur, prop: restM[1] };
665
+ rest = rest.slice(restM[0].length);
666
+ }
667
+ if (rest === '') return cur;
668
+ // Anything left (a call, another bracket) is out of scope for this
669
+ // branch — fall through to the rest of `_lowerExpr` below, which will
670
+ // most likely land on `{kind:'unknown'}` for it, same as before this
671
+ // fix (recall-preserving: never worse than the pre-fix behavior).
672
+ }
481
673
  }
482
674
  // Plain dotted ident: Request.Form / Request.QueryString
483
675
  if (/^[A-Za-z_][\w.]*$/.test(s)) {
@@ -493,7 +685,7 @@ function _lowerExpr(text) {
493
685
  // per-file, so the crash surfaced only as "this file has no IR" — 12 of 21 C#
494
686
  // corpus entries, and the catalog's own `cs-sqlcommand` rule ("SQL Injection
495
687
  // (new SqlCommand with concatenated user input)") could never fire.
496
- const newMatch = matchBalancedCall(s, /^new\s+([\w.]+)/);
688
+ const newMatch = matchBalancedCall(s, /^new\s+([\w.]+)/, { skipGenerics: true });
497
689
  if (newMatch) {
498
690
  const callee = newMatch.callee.split('.').pop();
499
691
  const args = _splitTopLevelCommas(newMatch.argsText).map(_lowerExpr);
@@ -516,7 +708,24 @@ function _lowerExpr(text) {
516
708
  // the input as a single part, and mapping `_lowerExpr` over it recurses on the
517
709
  // identical string forever. Any future expression form that reaches here
518
710
  // unsplit would otherwise reintroduce the same stack overflow.
519
- if (s.includes('+') && /["']/.test(s)) {
711
+ //
712
+ // SARD 80% F1 push: this branch previously ALSO required a quote character
713
+ // (`"`/`'`) to appear SOMEWHERE in `s` before even attempting the split —
714
+ // meant to avoid misreading plain numeric addition (`a + b`) as string
715
+ // concatenation, but it silently excluded the equally common shape where
716
+ // BOTH operands are already-built string variables with no inline literal
717
+ // (`Process.Start(osCommand + data)`, Juliet's own canonical C# CWE-78
718
+ // shape — confirmed via the public Juliet C# mirror this project's SARD
719
+ // manifest pins, not assumed). `osCommand + data` has no quote anywhere,
720
+ // so the whole expression fell through every later branch to
721
+ // `{kind:'unknown'}`, silently dropping `data`'s taint. Removed: treating
722
+ // a numeric `a + b` as a 2-part template is harmless for taint purposes
723
+ // (the engine only cares whether either operand is tainted, not whether
724
+ // the runtime operation is numeric addition or string concatenation), and
725
+ // the `rawParts.length > 1` guard immediately below already fully
726
+ // prevents the specific stack-overflow this comment documents,
727
+ // independent of whether a quote was ever present.
728
+ if (s.includes('+')) {
520
729
  const rawParts = _splitTopLevelPlus(s);
521
730
  if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
522
731
  }
@@ -618,12 +827,82 @@ function _lowerStmt(stmt, line) {
618
827
  }
619
828
  // throw
620
829
  if (/^throw\b/.test(s)) return { kind: 'throw', line, value: _lowerExpr(s.replace(/^throw\s*/, '')) };
830
+ // compound assign: `x += y` `x.y -= z` etc. SARD_80_F1 W4.C11 — this
831
+ // was previously unrecognized entirely (the plain-assign regex below
832
+ // requires a literal `=` with nothing but `[\w.]` before it, which a
833
+ // compound operator's own leading char, e.g. `+`, never satisfies), so
834
+ // the whole statement fell through every branch in this function and
835
+ // was silently dropped from the CFG — no assign, no call, nothing.
836
+ // Juliet's C# corpus builds `CommandText` this way UNIVERSALLY
837
+ // (`cmd.CommandText += "..." + tainted + "...";`), so this was a
838
+ // total blackout for that shape, not a rare corner case.
839
+ // Modeled as `x = x <op-without-'='> y` (a self-referencing binary),
840
+ // NOT as a plain `x = y` reassignment — `case 'assign'` in engine.js
841
+ // calls `removePathAndDescendants` on a clean RHS, and a naive
842
+ // "just treat it like `=`" lowering would WRONGLY clear x's own
843
+ // pre-existing taint whenever this particular append happens to be
844
+ // clean (e.g. a literal SQL fragment appended after an earlier
845
+ // tainted append). The self-reference makes taint the OR of "x was
846
+ // already tainted" and "this RHS is tainted", which is what `+=`
847
+ // actually means.
848
+ const cm2 = s.match(/^([A-Za-z_][\w.]*)\s*(\+=|-=|\*=|\/=|%=|&=|\|=|\^=|<<=|>>=|\?\?=)\s*(.+)$/s);
849
+ if (cm2) {
850
+ const target = cm2[1];
851
+ const rhs = _lowerExpr(cm2[3]);
852
+ return {
853
+ kind: 'assign', line, target,
854
+ source: { kind: 'binary', op: cm2[2].slice(0, -1), left: { kind: 'ident', name: target }, right: rhs },
855
+ };
856
+ }
621
857
  // assign: `var x = …` `Type x = …` `x = …` `x.y = …`
622
- const m = s.match(/^(?:(?:var|[A-Za-z_][\w<>?,\s.]*)\s+)?([A-Za-z_][\w.]*?)\s*=\s*(.+)$/s);
858
+ // The leading type/`var` clause is now its own capture group: when it's
859
+ // present the regex engine could only have reached a valid overall match
860
+ // by consuming a real type token before the target (a plain reassignment
861
+ // like `data = other` never lets that branch match, per the backtracking
862
+ // this file's other capture-group comments already document — the
863
+ // optional group fails to close at any point before `=` when the target
864
+ // itself carries no leading type). That gives a free, correct signal for
865
+ // `decl` — a local variable's DECLARATION vs. a later reassignment —
866
+ // needed by cross-method field-taint analysis to tell a genuine field
867
+ // write (`sf = data;`, no type clause, class field) from a shadowing
868
+ // local declaration of the same name.
869
+ const m = s.match(/^(?:((?:var|[A-Za-z_][\w<>?,\s.]*))\s+)?([A-Za-z_][\w.]*?)\s*=\s*(.+)$/s);
623
870
  if (m) {
624
- const target = m[1];
625
- const sourceText = m[2];
626
- return { kind: 'assign', line, target, source: _lowerExpr(sourceText) };
871
+ const typeClause = m[1];
872
+ const target = m[2];
873
+ const sourceText = m[3];
874
+ const node = { kind: 'assign', line, target, source: _lowerExpr(sourceText) };
875
+ if (typeClause) {
876
+ node.decl = true;
877
+ // SARD_80_F1 W4 — mirrors parser-java.js's identical `declaredType`
878
+ // field (see its own comment for the full rationale): the declared
879
+ // TYPE of a local, not just the fact that this is a declaration.
880
+ // Consumed by class-hierarchy.js's `typeOfVar` (ADDITIVE only, via
881
+ // `receiverTypeIn` — never a destructive callee rewrite, per the
882
+ // regression that mechanism caused for Java and was reverted).
883
+ // Strips generics (`List<string>` -> `List`), array brackets
884
+ // (`Foo[]` -> `Foo`), and a namespace-qualified prefix (last dot
885
+ // segment); `var` is not a real type name and yields undefined.
886
+ const bare = typeClause.replace(/<.*$/, '').replace(/\[\]$/, '').trim();
887
+ if (bare && bare !== 'var') {
888
+ const segs = bare.split('.');
889
+ node.declaredType = segs[segs.length - 1];
890
+ }
891
+ }
892
+ return node;
893
+ }
894
+ // Bare declaration with no initializer: `string data;`, `int i;`,
895
+ // `List<T> xs;`. `_splitStatements` has already stripped the trailing
896
+ // `;`, so a genuine no-initializer declaration is exactly two
897
+ // whitespace-separated tokens (type, name) with none of `=(){}` anywhere
898
+ // — no other valid C# statement shape looks like that. Modeled as an
899
+ // assign with an unknown source (there is nothing to propagate yet) so
900
+ // `decl:true` on it is visible to the same consumers that read the
901
+ // initializer-bearing case above, rather than being silently absent as
902
+ // an `unknown`-kind node.
903
+ const declOnly = s.match(/^(?:var|[A-Za-z_][\w<>?[\],.]*?)\s+([A-Za-z_]\w*)$/);
904
+ if (declOnly && !/[=(){}]/.test(s)) {
905
+ return { kind: 'assign', line, target: declOnly[1], source: { kind: 'unknown' }, decl: true };
627
906
  }
628
907
  // statement-form call
629
908
  const cm = matchBalancedCall(s, /^([A-Za-z_][\w.]*)/);
@@ -641,7 +920,7 @@ function _lowerStmt(stmt, line) {
641
920
  // has (this statement-form path is otherwise a strict subset of that
642
921
  // one), and inherits `classMethods`'s cross-class resolution for free
643
922
  // once the chain's callee dot-joins to `ClassName.method`.
644
- const nm = matchBalancedCall(s, /^new\s+([\w.]+)/);
923
+ const nm = matchBalancedCall(s, /^new\s+([\w.]+)/, { skipGenerics: true });
645
924
  if (nm) {
646
925
  const ctorArgs = _splitTopLevelCommas(nm.argsText).map(_lowerExpr);
647
926
  const chained = _followChain(s, nm.endIdx, nm.callee.split('.').pop(), ctorArgs, true);
@@ -653,6 +932,17 @@ function _lowerStmt(stmt, line) {
653
932
  // existing "nothing to lower" convention for a no-op statement.
654
933
  if (chained.callee.includes('.')) return { kind: 'call', line, callee: chained.callee, args: chained.args };
655
934
  }
935
+ // Parenthesised form of the same idiom: `(new Helper()).BadSink(data);`.
936
+ // The statement-form call branch above anchors on an identifier and the
937
+ // `new` branch anchors on the keyword, so a leading `(` matched neither
938
+ // and the statement was dropped whole. `_lowerExpr` already understands
939
+ // the shape; only a chained call (dotted callee) is worth a node.
940
+ if (s.startsWith('(')) {
941
+ const e = _lowerExpr(s);
942
+ if (e.kind === 'call' && typeof e.callee === 'string' && e.callee.includes('.')) {
943
+ return { kind: 'call', line, callee: e.callee, args: e.args };
944
+ }
945
+ }
656
946
  return { kind: 'unknown', line, text: s };
657
947
  }
658
948
 
@@ -746,10 +1036,232 @@ function _linkNodes(nodes, src, dst) {
746
1036
  // already-trimmed/reconstructed text can. That drift is exactly what cost
747
1037
  // the PHP port of this same task 3 fix rounds; getting the exact-offset
748
1038
  // version right from the start avoids repeating it here.
749
- function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs, depth = 0) {
1039
+ // SARD_80_F1 W4.C36 — Juliet's OWN "how is the constant represented" sweep
1040
+ // (the same exhaustive variant family this session already fixed 4 times
1041
+ // for Java: literal → `final`/`const` field → private/effectively-final
1042
+ // field → equality comparison) has a direct C# counterpart the W4.C28 fix
1043
+ // above never covered: "Flow Variant 04: Control flow: if(PRIVATE_CONST_TRUE)
1044
+ // and if(PRIVATE_CONST_FALSE)" (confirmed via the public C# Juliet mirror's
1045
+ // own `CWE80_XSS__CWE182_Web_Connect_tcp_04.cs`) uses `private const bool
1046
+ // PRIVATE_CONST_TRUE = true;` — a genuine, provable compile-time constant
1047
+ // exactly like the literal keyword, but W4.C28's own guard only matched the
1048
+ // bare `true`/`false` token, so this idiom fell straight through to the
1049
+ // GENERAL (non-literal) if/else path — the one W4.C27 already documented
1050
+ // as losing taint entirely when a dead arm's clean assignment sequentially
1051
+ // clobbers the live arm's genuine one. Confirmed by direct reproduction:
1052
+ // the file above produced ZERO findings at all (not just a suppressed
1053
+ // `good()` — `bad()`'s own genuine sink was invisible too), matching that
1054
+ // exact documented failure mode precisely. File-scoped (not per-class) for
1055
+ // simplicity, matching Juliet's own convention of never reusing a constant
1056
+ // name across classes in one file: a `const`/`static readonly` field is
1057
+ // unconditionally safe to fold (it cannot be reassigned by definition,
1058
+ // unlike Java's separate "effectively final" extension, which needed a
1059
+ // reassignment scan precisely because ordinary fields CAN be reassigned).
1060
+ // SARD_80_F1 W4.C38 — the direct C# counterpart of Java's W4.J36 extension.
1061
+ // CWE-80 remained BY FAR C#'s worst meaningful-support CWE even after
1062
+ // W4.C36 (fn=108 of support=130, tp only 20→22) because W4.C36 covers only
1063
+ // the `const`/`readonly` sub-variant (Flow Variant 04); Juliet's own
1064
+ // "Flow Variant 05: if(privateTrue) and if(privateFalse)" (confirmed via
1065
+ // the public mirror's `CWE80_XSS__CWE182_Web_Connect_tcp_05.cs`) uses a
1066
+ // PLAIN, non-const, non-readonly `private bool privateTrue = true;`
1067
+ // instance field — not a compile-time constant in the general C# sense,
1068
+ // but "effectively final" (written to exactly once, at its own
1069
+ // declaration) covers it exactly as soundly as Java's own extension does,
1070
+ // for the identical reason: a field that is never reassigned anywhere in
1071
+ // the file can never actually change. `_isEffectivelyFinal` is a
1072
+ // deliberately simple, conservative TEXT scan (not a full alias/call-graph
1073
+ // analysis): counting `\bname\s*=(?!=)` occurrences across the WHOLE file
1074
+ // can only ever OVER-count (an unrelated same-named field/local elsewhere)
1075
+ // — which only means the optimization is missed, never that a genuinely
1076
+ // reassignable field gets folded.
1077
+ function _isEffectivelyFinal(code, name) {
1078
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1079
+ const assignRe = new RegExp(`\\b${escaped}\\s*=(?!=)`, 'g');
1080
+ const matches = code.match(assignRe) || [];
1081
+ return matches.length <= 1;
1082
+ }
1083
+
1084
+ function _collectCsConstants(code) {
1085
+ const consts = new Map();
1086
+ const re = /\b(?:const\s+(?:static\s+)?|static\s+readonly\s+|readonly\s+static\s+)(?:bool|int)\s+(\w+)\s*=\s*(true|false|-?\d+)\s*;/g;
1087
+ let m;
1088
+ while ((m = re.exec(code))) {
1089
+ const raw = m[2];
1090
+ consts.set(m[1], raw === 'true' ? true : raw === 'false' ? false : parseInt(raw, 10));
1091
+ }
1092
+ // Effectively-final plain fields — deliberately a SEPARATE, second pass:
1093
+ // a field already captured as a real const/readonly constant above must
1094
+ // not be re-evaluated here (harmless either way since the value would
1095
+ // match, but scanning only fields NOT already known keeps this pass's
1096
+ // own regex simpler and its intent — "plain fields only" — explicit).
1097
+ // Requires at least one real ACCESS modifier (private/public/protected/
1098
+ // internal) — C# forbids those on a local variable, so this shape can
1099
+ // only ever be a field declaration, never an incidentally-similar local
1100
+ // (`bool ok = true;` inside a method body is deliberately NOT matched).
1101
+ const plainRe = /\b(?:private|public|protected|internal)\s+(?:static\s+)?(?:bool|int)\s+(\w+)\s*=\s*(true|false|-?\d+)\s*;/g;
1102
+ while ((m = plainRe.exec(code))) {
1103
+ const name = m[1];
1104
+ if (consts.has(name)) continue;
1105
+ if (!_isEffectivelyFinal(code, name)) continue;
1106
+ const raw = m[2];
1107
+ consts.set(name, raw === 'true' ? true : raw === 'false' ? false : parseInt(raw, 10));
1108
+ }
1109
+ return consts;
1110
+ }
1111
+
1112
+ // SARD_80_F1 W5.28 — C#'s own counterpart to Java's W5.27 (java-ast-folding
1113
+ // .js's `_collectClassMethodConstants`): Juliet's "Flow Variant 08/11:
1114
+ // Control flow: if(PrivateReturnsTrue()) and if(PrivateReturnsFalse())"
1115
+ // idiom branches on a CALL to a same-class, zero-arg, single-`return true;`
1116
+ // /`return false;`-bodied private helper (confirmed via the public C#
1117
+ // mirror's own `CWE80_XSS__CWE182_Web_Connect_tcp_08.cs`:
1118
+ // `private static bool PrivateReturnsTrue() { return true; }`) — a shape
1119
+ // `_resolveConstCondition` never modeled (literal / field / int-comparison
1120
+ // only). Namespaced under `()<name>` in the SAME `classConsts` map
1121
+ // `_collectCsConstants` already builds, mirroring the Java fix's identical
1122
+ // collision-avoidance reasoning (C# also permits a field and a method to
1123
+ // share one identifier, since a call always needs its own parens). Fails
1124
+ // closed on anything but a body that is EXACTLY one `return <expr>;`
1125
+ // statement (2+ statements, or a body with side effects, is ambiguous —
1126
+ // `body.match` requires the WHOLE trimmed body to be just that one line);
1127
+ // the returned expression resolves through `consts` too, so `return
1128
+ // SOME_KNOWN_CONST;` also folds correctly.
1129
+ function _collectCsMethodConstants(code, consts) {
1130
+ const declRe = /\b(?:private|public|protected|internal)\s+(?:static\s+)?bool\s+(\w+)\s*\(\s*\)\s*\{/g;
1131
+ let m;
1132
+ while ((m = declRe.exec(code))) {
1133
+ const name = m[1];
1134
+ const openIdx = m.index + m[0].length - 1;
1135
+ const closeIdx = _matchDelim(code, openIdx, '{', '}');
1136
+ if (closeIdx === -1) continue;
1137
+ const body = code.slice(openIdx + 1, closeIdx).trim();
1138
+ const retM = body.match(/^return\s+([^;]+);$/);
1139
+ if (!retM) continue;
1140
+ const expr = retM[1].trim();
1141
+ if (expr === 'true') consts.set(`()${name}`, true);
1142
+ else if (expr === 'false') consts.set(`()${name}`, false);
1143
+ else if (consts.has(expr)) {
1144
+ const v = consts.get(expr);
1145
+ if (typeof v === 'boolean') consts.set(`()${name}`, v);
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // Resolves `condRaw` to a boolean when it's a literal `true`/`false`, a bare
1151
+ // reference to a known boolean constant, a bare zero-arg call to a known
1152
+ // boolean-constant helper (W5.28), a LITERAL-vs-LITERAL `==`/`!=` comparison
1153
+ // (W5.29 — see below), or an `==`/`!=` comparison of a known int constant
1154
+ // against a literal — Juliet's own Flow Variant 03/04/08/09/11/13-style
1155
+ // idioms. Returns `null` (not `false`) when unresolvable, so callers can
1156
+ // distinguish "genuinely false" from "not a constant at all".
1157
+ function _resolveConstCondition(condRaw, classConsts) {
1158
+ const trimmed = condRaw.trim();
1159
+ if (trimmed === 'true') return true;
1160
+ if (trimmed === 'false') return false;
1161
+ if (classConsts && classConsts.has(trimmed)) {
1162
+ const v = classConsts.get(trimmed);
1163
+ return typeof v === 'boolean' ? v : null;
1164
+ }
1165
+ const callM = trimmed.match(/^(\w+)\s*\(\s*\)$/);
1166
+ if (callM && classConsts && classConsts.has(`()${callM[1]}`)) {
1167
+ const v = classConsts.get(`()${callM[1]}`);
1168
+ return typeof v === 'boolean' ? v : null;
1169
+ }
1170
+ // SARD_80_F1 W5.29 — Juliet's "Flow Variant 03: if(5==5) and if(5!=5)"
1171
+ // (confirmed via the public C# mirror's own
1172
+ // CWE80_XSS__CWE182_Web_Connect_tcp_03.cs): BOTH sides are bare integer
1173
+ // literals, needing no `classConsts` lookup at all — Java's own AST
1174
+ // evaluator (`evalBinary`) already resolves this shape for free (it
1175
+ // evaluates both operands recursively regardless of whether either is a
1176
+ // known identifier), but this hand-rolled regex evaluator's own `cmp`
1177
+ // check two lines below requires the LHS to be an identifier
1178
+ // (`(\w+)`), so a pure literal-vs-literal comparison never matched.
1179
+ // Checked BEFORE the identifier-based `cmp` check since `\d+` is also
1180
+ // valid `\w+` text and would otherwise shadow this branch.
1181
+ const litCmp = trimmed.match(/^(-?\d+)\s*(==|!=)\s*(-?\d+)$/);
1182
+ if (litCmp) {
1183
+ const lhs = parseInt(litCmp[1], 10);
1184
+ const rhs = parseInt(litCmp[3], 10);
1185
+ return litCmp[2] === '==' ? lhs === rhs : lhs !== rhs;
1186
+ }
1187
+ const cmp = trimmed.match(/^(\w+)\s*(==|!=)\s*(-?\d+)$/);
1188
+ if (cmp && classConsts && classConsts.has(cmp[1])) {
1189
+ const v = classConsts.get(cmp[1]);
1190
+ if (typeof v !== 'number') return null;
1191
+ const rhs = parseInt(cmp[3], 10);
1192
+ return cmp[2] === '==' ? v === rhs : v !== rhs;
1193
+ }
1194
+ return null;
1195
+ }
1196
+
1197
+ // SARD_80_F1 W5.36 — resolves a `switch` scrutinee to a constant INT value
1198
+ // when it's a bare integer literal or a known int constant (reusing the SAME
1199
+ // `classConsts` map `_resolveConstCondition`'s own `cmp` branch already
1200
+ // consults) — the switch-scrutinee analog of that function's boolean
1201
+ // resolution, needed separately because a switch's scrutinee is a VALUE to
1202
+ // match against case labels, not a boolean condition. Returns `null` (not
1203
+ // a number) when unresolvable, matching that function's own convention.
1204
+ function _resolveConstIntValue(expr, classConsts) {
1205
+ const trimmed = expr.trim();
1206
+ if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
1207
+ if (classConsts && classConsts.has(trimmed)) {
1208
+ const v = classConsts.get(trimmed);
1209
+ return typeof v === 'number' ? v : null;
1210
+ }
1211
+ return null;
1212
+ }
1213
+
1214
+ // Splits a switch statement's braced BODY text into label segments —
1215
+ // `{kind:'case', value, bodyStart, bodyEnd}` or `{kind:'default', bodyStart,
1216
+ // bodyEnd}` — scanning at bracket/string-aware DEPTH 0 so a `case`/`default`
1217
+ // keyword appearing inside a nested block, string, or (extremely rare in
1218
+ // this corpus) nested switch is never mistaken for a real label. `bodyStart`
1219
+ // is the offset immediately after the label's own `:`; `bodyEnd` is the next
1220
+ // label's own start (or the whole body's length for the last label) — the
1221
+ // exact text a live case's statements occupy, `break;` and all (never
1222
+ // stripped: it is simply never reached during recursion since nothing reads
1223
+ // past the live section's own end).
1224
+ function _splitSwitchCases(bodyText) {
1225
+ const labels = [];
1226
+ let depth = 0, inStr = null, escape = false;
1227
+ for (let i = 0; i < bodyText.length; i++) {
1228
+ const c = bodyText[i];
1229
+ if (escape) { escape = false; continue; }
1230
+ if (inStr) {
1231
+ if (c === '\\') { escape = true; continue; }
1232
+ if (c === inStr) inStr = null;
1233
+ continue;
1234
+ }
1235
+ if (c === '"' || c === "'") { inStr = c; continue; }
1236
+ if (c === '{' || c === '(' || c === '[') { depth++; continue; }
1237
+ if (c === '}' || c === ')' || c === ']') { depth--; continue; }
1238
+ if (depth !== 0) continue;
1239
+ const atBoundary = i === 0 || /[\s;}]/.test(bodyText[i - 1]);
1240
+ if (!atBoundary) continue;
1241
+ const rest = bodyText.slice(i);
1242
+ const caseM = rest.match(/^case\s+(-?\d+)\s*:/);
1243
+ if (caseM) {
1244
+ labels.push({ kind: 'case', value: parseInt(caseM[1], 10), labelStart: i, bodyStart: i + caseM[0].length });
1245
+ continue;
1246
+ }
1247
+ const defM = rest.match(/^default\s*:/);
1248
+ if (defM) {
1249
+ labels.push({ kind: 'default', labelStart: i, bodyStart: i + defM[0].length });
1250
+ continue;
1251
+ }
1252
+ }
1253
+ for (let idx = 0; idx < labels.length; idx++) {
1254
+ labels[idx].bodyEnd = idx + 1 < labels.length ? labels[idx + 1].labelStart : bodyText.length;
1255
+ }
1256
+ return labels;
1257
+ }
1258
+
1259
+ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs, depth = 0, classConsts = null) {
750
1260
  if (depth > 12) return prevId;
751
1261
  let prev = prevId;
752
- for (const { text: s, start } of _splitStatements(bodyText)) {
1262
+ const stmts = _splitStatements(bodyText);
1263
+ for (let stmtIdx = 0; stmtIdx < stmts.length; stmtIdx++) {
1264
+ const { text: s, start } = stmts[stmtIdx];
753
1265
  if (!s) continue;
754
1266
  const absStart = baseAbs + start;
755
1267
  const line = funcStartLine + _lineForOffset(lineStarts, absStart) - 1;
@@ -811,6 +1323,145 @@ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs,
811
1323
  prev = assignId;
812
1324
  }
813
1325
  }
1326
+ } else if (kwNorm === 'if' && condRaw !== null && _resolveConstCondition(condRaw, classConsts) !== null) {
1327
+ // Juliet's `if (true) { … } else { … }` / `if (false) { … } else
1328
+ // { … }` constant-condition idiom — its OWN "Flow Variant 02"
1329
+ // naming convention, used across dozens of CWEs, not just one —
1330
+ // must be pruned HERE, at IR-build time, not left to the general
1331
+ // if/else handling below. The general path deliberately builds a
1332
+ // STRAIGHT-LINE CFG for if/else (recurse into the if-body, then
1333
+ // — because bare `else` has no `needsCond` match and isn't linked
1334
+ // as a real second branch of the `if` node — treat the following
1335
+ // `else { … }` as an unconditionally-sequential block that runs
1336
+ // AFTER the if-body). That's a deliberate recall-preserving
1337
+ // tradeoff for a REAL runtime condition (never drop a sink hidden
1338
+ // in either arm) — but it is actively WRONG for a LITERAL
1339
+ // true/false condition, where the dead arm provably never runs at
1340
+ // all: a clean/null assignment in the dead arm was overwriting —
1341
+ // "clobbering" at the CFG-sequencing level, not a taint-engine
1342
+ // merge — the live arm's genuine taint by the time a later sink
1343
+ // read the variable. Confirmed via the public C# Juliet mirror's
1344
+ // own `CWE78_..._NetClient_02.cs`: `if (true) { data =
1345
+ // sr.ReadLine(); } else { data = null; }` lost ALL taint on
1346
+ // `data` before this fix, while the same source with no `else`
1347
+ // (or no `if` at all) worked correctly — isolating the defect to
1348
+ // exactly this construct. `_lowerExpr` never models C#'s `true`/
1349
+ // `false` keywords as `{kind:'literal'}` (they fall through to
1350
+ // `{kind:'ident', name:'true'|'false'}`), so `evalConst` in
1351
+ // `dataflow/path-feasibility.js` — which only prunes an already-
1352
+ // built two-successor `if` node — never even gets the chance;
1353
+ // the straight-line shape here never HAD two successors to prune.
1354
+ // No `if` CFG node is emitted at all for a literal condition:
1355
+ // there is no real branch to represent, only dead code to skip.
1356
+ const isTrue = _resolveConstCondition(condRaw, classConsts);
1357
+ const restIf = s.slice(afterHeader);
1358
+ const leadIf = restIf.match(/^\s*/)[0].length;
1359
+ let liveText = null, liveBaseAbs = null;
1360
+ if (restIf[leadIf] === '{') {
1361
+ const closeRel = _matchDelim(restIf, leadIf, '{', '}');
1362
+ if (closeRel !== -1) {
1363
+ liveText = restIf.slice(leadIf + 1, closeRel);
1364
+ liveBaseAbs = absStart + afterHeader + leadIf + 1;
1365
+ }
1366
+ } else if (restIf.trim()) {
1367
+ liveText = restIf;
1368
+ liveBaseAbs = absStart + afterHeader;
1369
+ }
1370
+ // Peek at the immediately-following statement: a bare `else` (NOT
1371
+ // `else if`, which is a fresh conditional the general path must
1372
+ // still handle) belongs to THIS if and must be consumed here —
1373
+ // either as the live branch (condition false) or discarded
1374
+ // outright (condition true) — never left for the outer loop to
1375
+ // fall through to the generic bare-`else`-as-sequential-block
1376
+ // handling that IS the bug this branch exists to avoid.
1377
+ let elseText = null, elseBaseAbs = null;
1378
+ const next = stmts[stmtIdx + 1];
1379
+ if (next) {
1380
+ const em = next.text.match(/^else\b(?!\s*if\b)/);
1381
+ if (em) {
1382
+ let ep = em[0].length;
1383
+ while (ep < next.text.length && /\s/.test(next.text[ep])) ep++;
1384
+ const erest = next.text.slice(ep);
1385
+ const elead = erest.match(/^\s*/)[0].length;
1386
+ const eAbsStart = baseAbs + next.start;
1387
+ if (erest[elead] === '{') {
1388
+ const closeRel = _matchDelim(erest, elead, '{', '}');
1389
+ if (closeRel !== -1) {
1390
+ elseText = erest.slice(elead + 1, closeRel);
1391
+ elseBaseAbs = eAbsStart + ep + elead + 1;
1392
+ }
1393
+ } else if (erest.trim()) {
1394
+ elseText = erest;
1395
+ elseBaseAbs = eAbsStart + ep;
1396
+ }
1397
+ stmtIdx++; // consumed — the outer loop must not process it again
1398
+ }
1399
+ }
1400
+ if (isTrue) {
1401
+ if (liveText !== null) prev = _buildCfg(liveText, nodes, prev, funcStartLine, lineStarts, liveBaseAbs, depth + 1, classConsts);
1402
+ } else if (elseText !== null) {
1403
+ prev = _buildCfg(elseText, nodes, prev, funcStartLine, lineStarts, elseBaseAbs, depth + 1, classConsts);
1404
+ }
1405
+ continue;
1406
+ } else if (kwNorm === 'switch' && condRaw !== null && _resolveConstIntValue(condRaw, classConsts) !== null) {
1407
+ // SARD_80_F1 W5.36 — Juliet's own "Flow Variant 15: switch(6) and
1408
+ // switch(7)" idiom (confirmed via the public C# mirror's own
1409
+ // CWE78_OS_Command_Injection__NetClient_15.cs), the switch-scrutinee
1410
+ // sibling of the already-modeled `if(true)/if(false)` idiom just
1411
+ // above. Before this fix, `switch` had NO constant-folding at all —
1412
+ // the general fallback below builds an `if`-shaped CFG node whose
1413
+ // `cond` is never consulted for branching, then recurses into the
1414
+ // WHOLE braced body as ONE STRAIGHT-LINE SEQUENTIAL block, meaning
1415
+ // `case 6: data = tainted; break; default: data = null; break;`
1416
+ // executes BOTH assignments in sequence, with whichever is
1417
+ // TEXTUALLY LAST always clobbering the other — silently destroying
1418
+ // real taint (a false negative when the tainted case is textually
1419
+ // first) or fabricating a false positive/negative for the opposite
1420
+ // ordering, entirely independent of which case the switch would
1421
+ // ACTUALLY take at runtime. Resolves ONLY when the scrutinee is a
1422
+ // constant int (bare literal or a known int class-constant, via
1423
+ // `_resolveConstIntValue` — the switch-scrutinee analog of
1424
+ // `_resolveConstCondition`'s own boolean resolution); when it is,
1425
+ // finds the matching `case`, falls back to `default` if no case
1426
+ // matches, and recurses ONLY into that one section's own text —
1427
+ // exactly mirroring the if(true)/if(false) pattern's "no CFG node
1428
+ // for a literal condition, only the live branch's text" shape. A
1429
+ // switch with neither a matching case nor any `default` is a
1430
+ // genuine C# no-op (nothing runs) and is silently skipped, matching
1431
+ // real language semantics.
1432
+ const restSw = s.slice(afterHeader);
1433
+ const leadSw = restSw.match(/^\s*/)[0].length;
1434
+ if (restSw[leadSw] === '{') {
1435
+ const closeRel = _matchDelim(restSw, leadSw, '{', '}');
1436
+ if (closeRel !== -1) {
1437
+ const scrutineeVal = _resolveConstIntValue(condRaw, classConsts);
1438
+ const bodyText = restSw.slice(leadSw + 1, closeRel);
1439
+ const bodyBaseAbs = absStart + afterHeader + leadSw + 1;
1440
+ const cases = _splitSwitchCases(bodyText);
1441
+ let live = cases.find(c => c.kind === 'case' && c.value === scrutineeVal);
1442
+ if (!live) live = cases.find(c => c.kind === 'default');
1443
+ if (live) {
1444
+ const liveText = bodyText.slice(live.bodyStart, live.bodyEnd);
1445
+ prev = _buildCfg(liveText, nodes, prev, funcStartLine, lineStarts, bodyBaseAbs + live.bodyStart, depth + 1, classConsts);
1446
+ }
1447
+ }
1448
+ }
1449
+ continue;
1450
+ } else if (kwNorm === 'using' && condRaw !== null) {
1451
+ // `using (SqlCommand cmd = new SqlCommand(query, conn)) { … }`: the
1452
+ // resource clause is a real declaration, and in ADO.NET it is where
1453
+ // the command text (the SQL-injection sink argument) is bound. It
1454
+ // was previously discarded with the header, so `cmd` had no
1455
+ // provenance and the constructor sink never saw its argument.
1456
+ // Lowered as an ordinary assign ahead of the body; a bare
1457
+ // expression clause (`using (GetLock())`) has no target and is
1458
+ // skipped as before.
1459
+ const declNode = _lowerStmt(condRaw.trim(), line);
1460
+ if (declNode && declNode.kind === 'assign') {
1461
+ const declId = _addNode(nodes, declNode);
1462
+ _linkNodes(nodes, prev, declId);
1463
+ prev = declId;
1464
+ }
814
1465
  } else {
815
1466
  const needsCond = /^(?:if|while|for|switch|else if|catch)$/.test(kwNorm);
816
1467
  if (needsCond && condRaw !== null) {
@@ -849,11 +1500,11 @@ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs,
849
1500
  const closeRel = _matchDelim(rest, lead, '{', '}');
850
1501
  if (closeRel !== -1) {
851
1502
  const innerBaseAbs = absStart + afterHeader + lead + 1;
852
- prev = _buildCfg(rest.slice(lead + 1, closeRel), nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1);
1503
+ prev = _buildCfg(rest.slice(lead + 1, closeRel), nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1, classConsts);
853
1504
  }
854
1505
  } else if (rest.trim()) {
855
1506
  const innerBaseAbs = absStart + afterHeader;
856
- prev = _buildCfg(rest, nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1);
1507
+ prev = _buildCfg(rest, nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1, classConsts);
857
1508
  }
858
1509
  continue;
859
1510
  }
@@ -862,7 +1513,7 @@ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs,
862
1513
  const bare = s.match(/^\{([\s\S]*)\}$/);
863
1514
  if (bare) {
864
1515
  const innerBaseAbs = absStart + 1;
865
- prev = _buildCfg(bare[1], nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1);
1516
+ prev = _buildCfg(bare[1], nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1, classConsts);
866
1517
  continue;
867
1518
  }
868
1519
 
@@ -879,6 +1530,8 @@ export function parseCSharpFile(file, code) {
879
1530
  if (!file || typeof code !== 'string') return null;
880
1531
  const functions = [];
881
1532
  const classRanges = _findClassRanges(code);
1533
+ const classConsts = _collectCsConstants(code);
1534
+ _collectCsMethodConstants(code, classConsts);
882
1535
  METHOD_RE.lastIndex = 0;
883
1536
  let m;
884
1537
  while ((m = METHOD_RE.exec(code)) !== null) {
@@ -886,6 +1539,7 @@ export function parseCSharpFile(file, code) {
886
1539
  const className = _enclosingClassName(classRanges, m.index);
887
1540
  const paramsText = m[3] || '';
888
1541
  const paramAnnotations = [];
1542
+ const paramTypes = {};
889
1543
  // `keptIdx` tracks the parameter's position in the FILTERED array — the
890
1544
  // same array `fn.params` ends up being — not the raw pre-filter split
891
1545
  // position (`idx` below). It only advances when a fragment actually
@@ -932,8 +1586,28 @@ export function parseCSharpFile(file, code) {
932
1586
  remaining = remaining.slice(match[0].length).trim();
933
1587
  }
934
1588
  // "Type name" → name. "Type<T> name" → name. "Type[] name = default" → name.
935
- const last = remaining.replace(/=.*$/, '').trim().split(/\s+/).pop();
1589
+ const beforeDefault = remaining.replace(/=.*$/, '').trim();
1590
+ const nameTokens = beforeDefault.split(/\s+/);
1591
+ const last = nameTokens.pop();
936
1592
  const paramName = last && /^[A-Za-z_][\w]*$/.test(last) ? last : null;
1593
+ // Declared TYPE (SARD_80_F1 W4 — same rationale as parser-java.js's
1594
+ // `paramTypes`/parser-cs.js's own local-var `declaredType` above):
1595
+ // Web Forms' dominant `Render(HtmlTextWriter writer)` override idiom
1596
+ // is a PARAMETER, not a local declaration, and a param's type was
1597
+ // never captured before this. `nameTokens` (everything before the
1598
+ // param name, after stripping attributes and a default value) is
1599
+ // the type clause — `ref`/`out`/`params`/`in` modifiers are stripped
1600
+ // as bare keywords, generics/arrays reduced to the base name, same
1601
+ // normalization as the local-variable case.
1602
+ let paramType;
1603
+ if (paramName && nameTokens.length) {
1604
+ const typeText = nameTokens.filter(w => !/^(?:ref|out|params|in|this)$/.test(w)).join(' ');
1605
+ const bare = typeText.replace(/<.*$/, '').replace(/\[\]$/, '').trim();
1606
+ if (bare && bare !== 'var') {
1607
+ const segs = bare.split('.');
1608
+ paramType = segs[segs.length - 1];
1609
+ }
1610
+ }
937
1611
  // Add an entry for each decorator found, indexed by position in the
938
1612
  // FILTERED params array (see `keptIdx` comment above) — not `idx`,
939
1613
  // the raw pre-filter split position.
@@ -941,6 +1615,7 @@ export function parseCSharpFile(file, code) {
941
1615
  for (const decorator of decorators) {
942
1616
  paramAnnotations.push({ index: keptIdx, name: paramName, decorator });
943
1617
  }
1618
+ if (paramType) paramTypes[paramName] = paramType;
944
1619
  keptIdx++;
945
1620
  }
946
1621
  return paramName;
@@ -969,7 +1644,7 @@ export function parseCSharpFile(file, code) {
969
1644
  nodes.entry = { kind: 'entry', line: startLine, succ: [], pred: [] };
970
1645
  nodes.exit = { kind: 'exit', line: startLine, succ: [], pred: [] };
971
1646
  _csNid = 0;
972
- const tail = _buildCfg(extracted.body, nodes, 'entry', bodyStartLine, lineStarts, 0, 0);
1647
+ const tail = _buildCfg(extracted.body, nodes, 'entry', bodyStartLine, lineStarts, 0, 0, classConsts);
973
1648
  nodes[tail].succ.push('exit');
974
1649
  nodes.exit.pred.push(tail);
975
1650
  _applyVarTypeRewrite(nodes);
@@ -980,8 +1655,15 @@ export function parseCSharpFile(file, code) {
980
1655
  cfg,
981
1656
  calls: callSitesFromCfg(cfg),
982
1657
  ...(paramAnnotations.length ? { paramAnnotations } : {}),
1658
+ ...(Object.keys(paramTypes).length ? { paramTypes } : {}),
983
1659
  });
984
1660
  METHOD_RE.lastIndex = extracted.end + 1;
985
1661
  }
986
- return { file, functions, topLevel: null };
1662
+ const classes = classRanges.map(r => ({
1663
+ name: r.name,
1664
+ line: r.line,
1665
+ bases: r.bases,
1666
+ fields: _extractClassFields(code, r),
1667
+ }));
1668
+ return { file, functions, classes, topLevel: null };
987
1669
  }