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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +192 -15
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +15 -15
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -11,6 +11,17 @@
11
11
  // - method calls (statement-form): `obj.Method(args);` / `Method(args);`
12
12
  // - return: `return expr;`
13
13
  // - ASP.NET source-like access: `Request.Form["x"]`, `Request.QueryString[...]`
14
+ // - control flow (R8): `if`/`else`/`else if`/`while`/`for`/`foreach`/
15
+ // `switch`/`do`/`try`/`catch`/`finally` bodies are recursed into by
16
+ // `_buildCfg` (ported from parser-cpp.js's proven keyword+balanced-scan
17
+ // pattern), so a sink several levels deep inside a braced body is
18
+ // reachable. A `for` header's init clause becomes a real assign node
19
+ // (not just its test clause as the condition); a `foreach` header
20
+ // binds its loop variable to the iterated collection before the body
21
+ // is recursed into, so the loop variable itself carries taint
22
+ // provenance. Line numbers through this recursion are computed via
23
+ // exact character-offset lookup (`_lineStarts`/`_lineForOffset`), not
24
+ // approximated — see `_buildCfg`'s header comment.
14
25
  //
15
26
  // What we do NOT model (regex-fallback class limits):
16
27
  // - LINQ expressions (treated as opaque expression)
@@ -19,14 +30,24 @@
19
30
  // - generics on declarations beyond Type<...> name
20
31
  // - attributes (skipped)
21
32
  // - destructuring / tuples
22
- // - control flow (if/for/while/switch) body is treated as straight-line;
23
- // this is enough for the source-reaches-sink shapes we care about.
33
+ // - control-flow BRANCHING semantics: `if`/`else`, `switch` cases, and
34
+ // `try`/`catch`/`finally` clauses are each recursed into and linked
35
+ // SEQUENTIALLY (matching parser-cpp.js's own "linear but complete"
36
+ // approximation) rather than as alternative/exceptional paths — every
37
+ // branch's body is reachable in the CFG, which is what taint analysis
38
+ // needs, but the graph does not model that only one branch executes
39
+ // per run.
40
+ // - a comment appearing MID-statement (after real content has already
41
+ // started) is left as literal text, not stripped — only a comment
42
+ // that precedes a statement (the common real-world shape) is skipped
43
+ // by `_splitStatements`; see that function's header comment.
24
44
  //
25
45
  // This is a v1. Promoted to a Roslyn-backed CST parser (analogous to
26
46
  // parser-py-cst.js) once we have a dotnet capability probe.
27
47
 
28
48
  import * as crypto from 'node:crypto';
29
49
  import { callSitesFromCfg } from './call-sites.js';
50
+ import { matchBalancedCall } from './balanced-call.js';
30
51
 
31
52
  const METHOD_RE = new RegExp(
32
53
  '(?:^|[\\s;{}])(?:public|private|protected|internal|static|virtual|override|async|sealed|abstract|new|readonly|partial)' +
@@ -36,30 +57,195 @@ const METHOD_RE = new RegExp(
36
57
  '\\s*\\(([^)]*)\\)' + // params (group 3)
37
58
  '\\s*\\{', 'g');
38
59
 
39
- // Matches a top-level statement inside a method body. We split on `;` only
40
- // at brace-depth 0; this keeps simple lambdas inside calls intact.
60
+ // Matches a top-level statement inside a method body. Splits on `;` at
61
+ // brace-depth 0 (keeping simple lambdas inside calls intact), AND — R8 —
62
+ // also flushes on a `}` that returns the SAME shared depth counter to 0.
63
+ // That second trigger is what makes a braced control-flow body
64
+ // (`if (...) { ... }`) come back as its OWN statement, ready for
65
+ // `_buildCfg` to recurse into, instead of staying glued to whatever `;`
66
+ // happens to terminate the NEXT statement (the old, pre-R8 behavior,
67
+ // which is why control flow was invisible before this task).
68
+ //
69
+ // This is safe for C#'s `{}`-based collection/object initializers
70
+ // (`new Foo { X = 1 }`) and lambda bodies passed as call arguments
71
+ // (`xs.ForEach(x => { Process(x); })`) because `depth` here is ONE shared
72
+ // counter across `{`, `(` and `[` (matching this file's pre-existing
73
+ // convention) — a `}` only reaches depth 0 when EVERY enclosing brace,
74
+ // paren and bracket has also closed, so a collection initializer's `}`
75
+ // (which closes while the surrounding `(...)` of a call, or the
76
+ // surrounding `;`-terminated `var x = ...` is still "open" only in the
77
+ // sense of not yet having hit a flush point) or a lambda body's `}`
78
+ // (which closes while the outer call's `(` is still open, i.e. depth > 0)
79
+ // never trips this trigger. Only a `}` that is truly the LAST unmatched
80
+ // delimiter does — precisely the shape a control-flow body's closing
81
+ // brace has.
82
+ //
83
+ // Returns `{ text, start }[]` — `start` is the absolute character offset,
84
+ // within `body`, of the first REAL (non-whitespace) character of `text`.
85
+ // This offset is tracked directly against the ORIGINAL, untouched `body`
86
+ // string (never against a reconstructed/trimmed copy), so `_buildCfg` can
87
+ // compute exact line numbers via a single `_lineStarts`/`_lineForOffset`
88
+ // pair built once per function body — see that pairing's comment. This
89
+ // is the R8 lesson from the PHP task (3 fix rounds, all ultimately about
90
+ // line-number precision): approximate offsets computed by re-counting
91
+ // newlines in text that has already been trimmed or reconstructed are
92
+ // lossy (a stripped comment, a dropped blank line) in ways that only
93
+ // surface on real multi-line source; an exact offset into the pristine
94
+ // original text cannot drift.
95
+ //
96
+ // Also splits on a `:` at depth 0 that terminates a `switch` body's
97
+ // `case <expr>:` / `default:` label — those labels are not otherwise
98
+ // separated by `;` or `}`, and without this a label stays glued onto
99
+ // whatever real statement follows it, which then fails every shape
100
+ // `_lowerStmt` recognizes and silently drops that statement. Scoped
101
+ // tightly (the accumulated text so far must be EXACTLY `case <expr>` or
102
+ // `default`) so an ordinary ternary's `:` can't mis-fire — a ternary's
103
+ // left-hand accumulated text is never going to equal one of those two
104
+ // shapes. `::` (the `global::`/qualified-name operator) is excluded via
105
+ // the adjacent-character check so a qualified name's colon can never be
106
+ // mistaken for a label terminator.
107
+ //
108
+ // Comment handling: a `//` or `/* */` comment is skipped outright (not
109
+ // merely blanked) while NO real content has been accumulated yet for the
110
+ // statement currently being scanned (i.e. only whitespace so far this
111
+ // cycle) — the common real-world shape of a standalone comment line
112
+ // immediately before a statement. Skipping it here, rather than letting
113
+ // it become part of the statement text, matters because `_lowerStmt`
114
+ // refuses (`startsWith('//')`) any statement text that begins with a
115
+ // comment: without this, a comment on its own line right before e.g.
116
+ // `var cmd = ...;` would glue onto it and silently drop that statement
117
+ // from the CFG. A comment appearing mid-statement (after real content has
118
+ // already started) is left as literal text — this parser has never
119
+ // modelled comments in general, and fixing that broader gap is out of
120
+ // scope for this task.
41
121
  function _splitStatements(body) {
42
122
  const out = [];
43
123
  let buf = '';
124
+ let contentStart = -1; // absolute offset of the first real char of the
125
+ // statement currently being accumulated, or -1
126
+ // if none seen yet.
44
127
  let depth = 0;
45
- let inString = null; // null | '"' | "'" | '@"' style
128
+ let inString = null; // null | '"' | "'"
46
129
  let escape = false;
130
+ const push = (c, idx) => {
131
+ buf += c;
132
+ if (contentStart === -1 && !/\s/.test(c)) contentStart = idx;
133
+ };
134
+ const flush = () => {
135
+ const trimmed = buf.trim();
136
+ if (trimmed) out.push({ text: trimmed, start: contentStart });
137
+ buf = '';
138
+ contentStart = -1;
139
+ };
47
140
  for (let i = 0; i < body.length; i++) {
48
141
  const c = body[i];
49
- if (escape) { buf += c; escape = false; continue; }
142
+ if (contentStart === -1) {
143
+ if (c === '/' && body[i + 1] === '/') {
144
+ while (i < body.length && body[i] !== '\n') i++;
145
+ continue;
146
+ }
147
+ if (c === '/' && body[i + 1] === '*') {
148
+ i += 2;
149
+ while (i < body.length && !(body[i] === '*' && body[i + 1] === '/')) i++;
150
+ if (i < body.length) i += 1;
151
+ continue;
152
+ }
153
+ }
154
+ if (escape) { push(c, i); escape = false; continue; }
50
155
  if (inString) {
51
- buf += c;
156
+ push(c, i);
52
157
  if (inString === '"' && c === '\\') { escape = true; continue; }
53
158
  if (c === inString) inString = null;
54
159
  continue;
55
160
  }
56
- if (c === '"' || c === "'") { inString = c; buf += c; continue; }
57
- if (c === '{' || c === '(' || c === '[') depth++;
58
- if (c === '}' || c === ')' || c === ']') depth--;
161
+ if (c === '"' || c === "'") { inString = c; push(c, i); continue; }
162
+ if (c === '{' || c === '(' || c === '[') { depth++; push(c, i); continue; }
163
+ if (c === '}' || c === ')' || c === ']') {
164
+ depth--;
165
+ push(c, i);
166
+ if (c === '}' && depth === 0) flush();
167
+ continue;
168
+ }
169
+ if (c === ';' && depth === 0) { flush(); continue; }
170
+ if (c === ':' && depth === 0 && body[i + 1] !== ':' && body[i - 1] !== ':') {
171
+ const t = buf.trim();
172
+ if (/^case\s+[\s\S]+$/.test(t) || t === 'default') { flush(); continue; }
173
+ }
174
+ push(c, i);
175
+ }
176
+ flush();
177
+ return out;
178
+ }
179
+
180
+ // Build a sorted array of line-start offsets for `text` (index 0 holds the
181
+ // start of line 1, i.e. always 0). Paired with `_lineForOffset` to turn a
182
+ // character offset into an exact 1-based line number in O(log n) — ported
183
+ // verbatim from parser-cpp.js's `_lineStarts`/`_lineForOffset`, the
184
+ // proven reference for this exact-offset-based line computation pattern.
185
+ function _lineStarts(text) {
186
+ const starts = [0];
187
+ for (let i = 0; i < text.length; i++) {
188
+ if (text[i] === '\n') starts.push(i + 1);
189
+ }
190
+ return starts;
191
+ }
192
+
193
+ function _lineForOffset(lineStarts, idx) {
194
+ let lo = 0, hi = lineStarts.length - 1;
195
+ while (lo < hi) {
196
+ const mid = (lo + hi + 1) >> 1;
197
+ if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1;
198
+ }
199
+ return lo + 1;
200
+ }
201
+
202
+ // Find the index of the delimiter in `openCh`/`closeCh` that matches the
203
+ // one at `openIdx`, respecting nesting and skipping string-literal
204
+ // content. Returns -1 if unmatched.
205
+ function _matchDelim(text, openIdx, openCh, closeCh) {
206
+ let depth = 0;
207
+ let inStr = null;
208
+ let escape = false;
209
+ for (let i = openIdx; i < text.length; i++) {
210
+ const c = text[i];
211
+ if (escape) { escape = false; continue; }
212
+ if (inStr) {
213
+ if (c === '\\') { escape = true; continue; }
214
+ if (c === inStr) inStr = null;
215
+ continue;
216
+ }
217
+ if (c === '"' || c === "'") { inStr = c; continue; }
218
+ if (c === openCh) depth++;
219
+ else if (c === closeCh) { depth--; if (depth === 0) return i; }
220
+ }
221
+ return -1;
222
+ }
223
+
224
+ // Split a `for` header's `init; test; step` on top-level `;` (respecting
225
+ // nested parens/brackets/strings) so the init clause can be surfaced as a
226
+ // real assign node and the test clause used as the loop's condition —
227
+ // mirroring parser-cpp.js's `_splitTopLevelAligned` use for the same
228
+ // C-style for-loop shape.
229
+ function _splitTopLevelSemi(s) {
230
+ const out = [];
231
+ let buf = '';
232
+ let depth = 0;
233
+ let inStr = null;
234
+ for (let i = 0; i < s.length; i++) {
235
+ const c = s[i];
236
+ if (inStr) {
237
+ buf += c;
238
+ if (c === '\\') { i++; buf += s[i] || ''; continue; }
239
+ if (c === inStr) inStr = null;
240
+ continue;
241
+ }
242
+ if (c === '"' || c === "'") { inStr = c; buf += c; continue; }
243
+ if (c === '(' || c === '{' || c === '[') depth++;
244
+ if (c === ')' || c === '}' || c === ']') depth--;
59
245
  if (c === ';' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
60
246
  buf += c;
61
247
  }
62
- if (buf.trim()) out.push(buf.trim());
248
+ out.push(buf.trim());
63
249
  return out;
64
250
  }
65
251
 
@@ -80,17 +266,69 @@ function _lowerExpr(text) {
80
266
  if (parts.length === 1) return { kind: 'ident', name: parts[0] };
81
267
  return _buildMemberChain(parts);
82
268
  }
83
- // Call: foo.bar(args) or Bar(args). Find the LAST '(' at depth 0.
84
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
269
+ // Object creation: `new Type(args)` lowered to a call so taint flows into
270
+ // constructor arguments. Without this branch the expression fell through to
271
+ // the concat heuristic below, and because the `+` sits INSIDE the parens
272
+ // `_splitTopLevelPlus` returned the input unchanged, so that branch recursed
273
+ // on the identical string until the stack blew. `buildProjectIR` catches
274
+ // per-file, so the crash surfaced only as "this file has no IR" — 12 of 21 C#
275
+ // corpus entries, and the catalog's own `cs-sqlcommand` rule ("SQL Injection
276
+ // (new SqlCommand with concatenated user input)") could never fire.
277
+ const newMatch = matchBalancedCall(s, /^new\s+([\w.]+)/);
278
+ if (newMatch) {
279
+ const callee = newMatch.callee.split('.').pop();
280
+ const args = _splitTopLevelCommas(newMatch.argsText).map(_lowerExpr);
281
+ return { kind: 'call', callee, args, isNew: true };
282
+ }
283
+ // Call: foo.bar(args) or Bar(args). matchBalancedCall finds the paren
284
+ // that actually balances the FIRST '(' — not the greedy-to-end-of-string
285
+ // match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
286
+ // for a chained call (`Sanitize(x).Trim()` produced argsText="x).Trim(",
287
+ // which then fell through to {kind:'unknown'} and silently dropped x).
288
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
85
289
  if (callMatch) {
86
- const callee = callMatch[1];
87
- const args = _splitTopLevelCommas(callMatch[2]).map(_lowerExpr);
88
- return { kind: 'call', callee, args };
290
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
291
+ return { kind: 'call', callee: callMatch.callee, args };
89
292
  }
90
293
  // String concat / interpolation — heuristic.
294
+ //
295
+ // The `parts.length > 1` guard is load-bearing, not defensive tidiness: when
296
+ // the `+` is nested inside parens or brackets, `_splitTopLevelPlus` returns
297
+ // the input as a single part, and mapping `_lowerExpr` over it recurses on the
298
+ // identical string forever. Any future expression form that reaches here
299
+ // unsplit would otherwise reintroduce the same stack overflow.
91
300
  if (s.includes('+') && /["']/.test(s)) {
92
- const parts = _splitTopLevelPlus(s).map(_lowerExpr);
93
- return { kind: 'tpl', parts };
301
+ const rawParts = _splitTopLevelPlus(s);
302
+ if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
303
+ }
304
+ // Stage 3 correctness audit (detection depth, per-language-IR):
305
+ // interpolated strings ($"id={id}", $@"...", @$"...") were entirely
306
+ // unrecognized by every branch above — not even treated as an opaque
307
+ // literal, since nothing here tested for the leading `$` prefix — so
308
+ // they fell all the way through to {kind:'unknown'}, silently dropping
309
+ // any interpolated variable's taint. This is exactly
310
+ // `new SqlCommand($"SELECT ... WHERE id={id}", conn)`, one of the most
311
+ // common real C# SQL-injection shapes. `{expr}` / `{expr:format}` are
312
+ // lowered into a template, same shape as the `+`-concat branch above.
313
+ if (/^\$@?"/.test(s) || /^@\$"/.test(s)) {
314
+ const bodyStart = s.indexOf('"') + 1;
315
+ const inner = s.slice(bodyStart, -1);
316
+ const re = /\{([^{}:]+)(?::[^{}]*)?\}/g;
317
+ let lastIndex = 0;
318
+ const parts = [];
319
+ let matched = false;
320
+ let m;
321
+ while ((m = re.exec(inner)) !== null) {
322
+ matched = true;
323
+ if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
324
+ parts.push(_lowerExpr(m[1].trim()));
325
+ lastIndex = re.lastIndex;
326
+ }
327
+ if (matched) {
328
+ if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
329
+ return { kind: 'tpl', parts };
330
+ }
331
+ return { kind: 'literal', value: s };
94
332
  }
95
333
  if (/^"|^@"/.test(s)) return { kind: 'literal', value: s };
96
334
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
@@ -169,9 +407,9 @@ function _lowerStmt(stmt, line) {
169
407
  return { kind: 'assign', line, target, source: _lowerExpr(sourceText) };
170
408
  }
171
409
  // statement-form call
172
- const cm = s.match(/^([A-Za-z_][\w.]*)\s*\((.*)\)\s*$/s);
410
+ const cm = matchBalancedCall(s, /^([A-Za-z_][\w.]*)/);
173
411
  if (cm) {
174
- return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
412
+ return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
175
413
  }
176
414
  return { kind: 'unknown', line, text: s };
177
415
  }
@@ -210,6 +448,185 @@ function _qid(file, name, line, body) {
210
448
  return `${file}::${name}@${line}#${sha}`;
211
449
  }
212
450
 
451
+ // Node-id counter for `_buildCfg`. Reset to 0 per function (see
452
+ // `parseCSharpFile`) so ids stay `n0`, `n1`, ... within a single
453
+ // function's `cfg.nodes` — matching the pre-R8 flat loop's `n${idx}`
454
+ // naming convention, just keyed off a running node COUNT now rather than
455
+ // the original statement array's index (a single top-level statement, an
456
+ // `if` block, can now expand into many CFG nodes, so id generation can no
457
+ // longer be tied to statement position). Confirmed by grep that nothing
458
+ // in this file, its tests, or the dataflow engine depends on the exact
459
+ // string shape of these ids.
460
+ let _csNid = 0;
461
+ function _nextNodeId() { return `n${_csNid++}`; }
462
+
463
+ function _addNode(nodes, node) {
464
+ const id = _nextNodeId();
465
+ node.succ = node.succ || [];
466
+ node.pred = node.pred || [];
467
+ nodes[id] = node;
468
+ return id;
469
+ }
470
+
471
+ function _linkNodes(nodes, src, dst) {
472
+ if (!nodes[src] || !nodes[dst]) return;
473
+ if (!nodes[src].succ.includes(dst)) nodes[src].succ.push(dst);
474
+ if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
475
+ }
476
+
477
+ // R8: recursive statement-splitting + CFG builder, replacing the previous
478
+ // flat single-pass loop. Ported from parser-cpp.js's `emit()` — the
479
+ // already-proven, working reference for exactly this shape of problem in
480
+ // this codebase's hand-rolled-parser style: match a leading control-flow
481
+ // keyword, balanced-scan its condition and its `{...}` body, and recurse
482
+ // ONLY into that matched body. Every other `}` (a collection initializer's
483
+ // or a lambda's) is left alone by this mechanism — see `_splitStatements`'
484
+ // header comment for why those are never mistaken for a control-flow
485
+ // body's close.
486
+ //
487
+ // Line numbers are computed EXACTLY, not approximated: `lineStarts` is
488
+ // built once, by the caller, from the function's whole (untouched) body
489
+ // text, and `baseAbs` is threaded through every recursive call as the
490
+ // absolute offset — within that SAME body text — of `bodyText[0]`. Every
491
+ // statement's line is then `funcStartLine + _lineForOffset(lineStarts,
492
+ // baseAbs + stmt.start) - 1`. Because `baseAbs` and every sub-offset used
493
+ // below (`afterHeader`, `lead`, matched-delimiter indices) are all
494
+ // measured directly against the statement's own text — which
495
+ // `_splitStatements` guarantees is a byte-for-byte contiguous slice of
496
+ // the original body from `stmt.start` onward (see that function's
497
+ // comment) — this cannot drift the way a newline-recount over
498
+ // already-trimmed/reconstructed text can. That drift is exactly what cost
499
+ // the PHP port of this same task 3 fix rounds; getting the exact-offset
500
+ // version right from the start avoids repeating it here.
501
+ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs, depth = 0) {
502
+ if (depth > 12) return prevId;
503
+ let prev = prevId;
504
+ for (const { text: s, start } of _splitStatements(bodyText)) {
505
+ if (!s) continue;
506
+ const absStart = baseAbs + start;
507
+ const line = funcStartLine + _lineForOffset(lineStarts, absStart) - 1;
508
+
509
+ // R8 fix round 1: `using (...) { }` and `lock (...) { }` were missing
510
+ // from this alternation entirely — both lowered to a bogus
511
+ // `call:using`/`call:lock` node via the generic `_lowerStmt` fallback,
512
+ // with their `{...}` body text (including a paren argument list that
513
+ // looks exactly like a call's) silently discarded. `using` is THE
514
+ // canonical C#/ADO.NET wrapper around the exact sinks this task
515
+ // targets (`SqlCommand`, `ExecuteReader`, file streams — anything
516
+ // `IDisposable`), so this was a significant real-world gap: a sink
517
+ // wrapped in `using` produced ZERO findings even after this task's
518
+ // main fix, same as if it were wrapped in `if` before this task
519
+ // existed at all. `using`/`lock` are deliberately NOT added to
520
+ // `needsCond` below — unlike `if`/`while`/`for`/`switch`, their
521
+ // parenthesised clause is a resource-acquisition declaration or a
522
+ // lock target, not a boolean expression, so lowering it via
523
+ // `_lowerExpr` would just produce `{kind:'unknown'}` and isn't worth
524
+ // a synthetic node; the body — where the real sink-bearing statements
525
+ // live — is what this fix makes reachable.
526
+ const hm = s.match(/^(if|while|for|foreach|switch|else\s+if|else|do|try|catch|finally|using|lock)\b/);
527
+ if (hm) {
528
+ const kwNorm = hm[1].replace(/\s+/g, ' ').trim();
529
+ let p = hm[0].length;
530
+ while (p < s.length && /\s/.test(s[p])) p++;
531
+ let condRaw = null, afterHeader = p;
532
+ if (s[p] === '(') {
533
+ const closeIdx = _matchDelim(s, p, '(', ')');
534
+ if (closeIdx !== -1) {
535
+ condRaw = s.slice(p + 1, closeIdx);
536
+ afterHeader = closeIdx + 1;
537
+ }
538
+ }
539
+
540
+ if (kwNorm === 'foreach' && condRaw !== null) {
541
+ // `foreach (var x in xs)` / `foreach (Type x in xs)`. Unlike the
542
+ // other headers below, foreach's parenthesised clause is not an
543
+ // expression — it's a declaration — so it gets its own
544
+ // loop-header node (no `cond`) plus, R8 fix-round lesson from
545
+ // Java's for-each gap: a synthesized assign binding the loop
546
+ // variable to the iterated collection BEFORE the body is
547
+ // recursed into. Without this, the body is reachable but the
548
+ // loop variable itself carries no taint provenance, so
549
+ // `foreach (var id in ids) { sink(id); }` could never fire even
550
+ // though `sink(x) { ... }` shapes elsewhere in the same function
551
+ // do.
552
+ const headerId = _addNode(nodes, { kind: 'loop-header', line });
553
+ _linkNodes(nodes, prev, headerId);
554
+ prev = headerId;
555
+ const fm = condRaw.match(/^([\s\S]+?)\s+in\s+([\s\S]+)$/);
556
+ if (fm) {
557
+ const declPart = fm[1].trim();
558
+ const loopVar = declPart.split(/\s+/).pop();
559
+ const iterExpr = fm[2].trim();
560
+ if (loopVar && /^[A-Za-z_]\w*$/.test(loopVar)) {
561
+ const assignId = _addNode(nodes, { kind: 'assign', line, target: loopVar, source: _lowerExpr(iterExpr) });
562
+ _linkNodes(nodes, prev, assignId);
563
+ prev = assignId;
564
+ }
565
+ }
566
+ } else {
567
+ const needsCond = /^(?:if|while|for|switch|else if|catch)$/.test(kwNorm);
568
+ if (needsCond && condRaw !== null) {
569
+ let condForNode = condRaw;
570
+ let initRaw = null;
571
+ if (kwNorm === 'for') {
572
+ // `for (init; test; step)` — surface the test as the
573
+ // condition and the init as a leading assign node (context
574
+ // (a): a C# for-loop commonly initializes a loop variable
575
+ // that the body then reads, e.g. `for (int i = 0; ...)`
576
+ // followed by `arr[i]` inside the body — without this the
577
+ // loop var's taint provenance is never established).
578
+ const parts = _splitTopLevelSemi(condRaw);
579
+ if (parts.length > 1) {
580
+ initRaw = parts[0];
581
+ condForNode = parts[1];
582
+ }
583
+ }
584
+ const ifId = _addNode(nodes, { kind: 'if', line, cond: _lowerExpr(condForNode) });
585
+ _linkNodes(nodes, prev, ifId);
586
+ prev = ifId;
587
+ if (kwNorm === 'for' && initRaw && initRaw.trim()) {
588
+ const initNode = _lowerStmt(initRaw.trim(), line);
589
+ if (initNode && initNode.kind === 'assign') {
590
+ const initId = _addNode(nodes, initNode);
591
+ _linkNodes(nodes, prev, initId);
592
+ prev = initId;
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ const rest = s.slice(afterHeader);
599
+ const lead = rest.match(/^\s*/)[0].length;
600
+ if (rest[lead] === '{') {
601
+ const closeRel = _matchDelim(rest, lead, '{', '}');
602
+ if (closeRel !== -1) {
603
+ const innerBaseAbs = absStart + afterHeader + lead + 1;
604
+ prev = _buildCfg(rest.slice(lead + 1, closeRel), nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1);
605
+ }
606
+ } else if (rest.trim()) {
607
+ const innerBaseAbs = absStart + afterHeader;
608
+ prev = _buildCfg(rest, nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1);
609
+ }
610
+ continue;
611
+ }
612
+
613
+ // A bare nested block `{ ... }` with no leading keyword.
614
+ const bare = s.match(/^\{([\s\S]*)\}$/);
615
+ if (bare) {
616
+ const innerBaseAbs = absStart + 1;
617
+ prev = _buildCfg(bare[1], nodes, prev, funcStartLine, lineStarts, innerBaseAbs, depth + 1);
618
+ continue;
619
+ }
620
+
621
+ const node = _lowerStmt(s, line);
622
+ if (!node) continue;
623
+ const id = _addNode(nodes, node);
624
+ _linkNodes(nodes, prev, id);
625
+ prev = id;
626
+ }
627
+ return prev;
628
+ }
629
+
213
630
  export function parseCSharpFile(file, code) {
214
631
  if (!file || typeof code !== 'string') return null;
215
632
  const functions = [];
@@ -218,44 +635,100 @@ export function parseCSharpFile(file, code) {
218
635
  while ((m = METHOD_RE.exec(code)) !== null) {
219
636
  const name = m[2];
220
637
  const paramsText = m[3] || '';
221
- const params = paramsText.split(',').map(p => {
638
+ const paramAnnotations = [];
639
+ // `keptIdx` tracks the parameter's position in the FILTERED array — the
640
+ // same array `fn.params` ends up being — not the raw pre-filter split
641
+ // position (`idx` below). It only advances when a fragment actually
642
+ // yields a kept parameter name, so an empty/unparseable comma fragment
643
+ // ahead of an annotated parameter doesn't shift that parameter's
644
+ // recorded `index` off of its real position in `fn.params`. R14(a)
645
+ // final-review fix: Java (`parser-java.js`) and JS/TS (`parser-js.js`)
646
+ // both already compute `index` this way (position in the final
647
+ // `fn.params`, per the plan's Global Constraints); C# was the only one
648
+ // of the three using the raw split index, which silently diverges
649
+ // whenever an earlier fragment is filtered out. Nothing reads
650
+ // `paramAnnotations[i].index` today (confirmed by grep), so this was
651
+ // latent — but the field is kept for future k>1 call-string work, so a
652
+ // silently-wrong producer here would become a real bug once something
653
+ // starts consuming it.
654
+ let keptIdx = 0;
655
+ const params = paramsText.split(',').map((p, idx) => {
222
656
  const t = p.trim();
223
657
  if (!t) return null;
658
+ // Extract ALL leading [AttributeName] or [AttributeName(...)] patterns (stacked or not).
659
+ // R14(a) Task 6 fix round 1: an earlier version of this regex used one
660
+ // optional `(?:\(...\))?` group with a `\s*` on each side, which left
661
+ // two independent quantifiers both able to consume the same
662
+ // whitespace run on a failed match (no closing `]`) — classic
663
+ // adjacent-quantifier ReDoS, confirmed quadratic (n=32000 chars took
664
+ // ~600ms; caught by this repo's own self-scan gate against its own
665
+ // new code). Rather than accept the engine's own `safe-regex`-backed
666
+ // heuristic flagging a merely-restructured-but-still-single-group
667
+ // version (it does — confirmed empirically linear but still flagged),
668
+ // this instead follows this repo's own precedent (commit `6bd394c`,
669
+ // `class-hierarchy.js`'s qid-tail-stripping fix): split into two
670
+ // alternatives — no-args and with-args — so no quantifier has two
671
+ // ways to consume the same text. Each alternative independently
672
+ // passes `safe-regex`, avoiding reliance on any one detector's
673
+ // judgment call. Verified O(n) (n=256000 chars in well under 1ms)
674
+ // with identical matches on every real attribute shape (stacked,
675
+ // spaced, empty-arg) against the prior single-group version.
676
+ const attrRegex = /^\[\s*([A-Za-z_]\w*)\s*\]|^\[\s*([A-Za-z_]\w*)\s*\([^)]*\)\s*\]/;
677
+ let remaining = t;
678
+ let match;
679
+ const decorators = [];
680
+ while ((match = attrRegex.exec(remaining)) !== null) {
681
+ decorators.push(match[1] || match[2]);
682
+ remaining = remaining.slice(match[0].length).trim();
683
+ }
224
684
  // "Type name" → name. "Type<T> name" → name. "Type[] name = default" → name.
225
- const last = t.replace(/=.*$/, '').trim().split(/\s+/).pop();
226
- return last && /^[A-Za-z_][\w]*$/.test(last) ? last : null;
685
+ const last = remaining.replace(/=.*$/, '').trim().split(/\s+/).pop();
686
+ const paramName = last && /^[A-Za-z_][\w]*$/.test(last) ? last : null;
687
+ // Add an entry for each decorator found, indexed by position in the
688
+ // FILTERED params array (see `keptIdx` comment above) — not `idx`,
689
+ // the raw pre-filter split position.
690
+ if (paramName) {
691
+ for (const decorator of decorators) {
692
+ paramAnnotations.push({ index: keptIdx, name: paramName, decorator });
693
+ }
694
+ keptIdx++;
695
+ }
696
+ return paramName;
227
697
  }).filter(Boolean);
228
698
  const braceIdx = code.indexOf('{', m.index + m[0].length - 1);
229
699
  if (braceIdx < 0) continue;
230
700
  const extracted = _extractBody(code, braceIdx);
231
701
  if (!extracted) continue;
232
702
  const startLine = _lineAt(code, m.index);
233
- const stmts = _splitStatements(extracted.body);
234
- // Build a linear CFG: entry s1 s2 ... → exit.
703
+ // R8 (lesson learned from the PHP port of this task, fix round 3): the
704
+ // body's own start line must be derived from `braceIdx` the
705
+ // method's ACTUAL opening `{` — not approximated as `startLine + 1`.
706
+ // `startLine` (above) is the line of the METHOD DECLARATION match,
707
+ // which only happens to be one line before the body for a same-line
708
+ // signature; a multi-line signature or Allman brace style would make
709
+ // that approximation wrong by however many lines the signature spans.
710
+ const bodyStartLine = _lineAt(code, braceIdx + 1);
711
+ // Built once per function body; `_buildCfg` looks up every node's
712
+ // line in O(log n) via `_lineForOffset` against this SAME array — see
713
+ // `_buildCfg`'s header comment for why this (not per-statement
714
+ // newline-recounting) is what keeps line numbers exact through
715
+ // arbitrarily deep recursion.
716
+ const lineStarts = _lineStarts(extracted.body);
717
+ // Build the CFG: entry → (recursive statement/control-flow walk) → exit.
235
718
  const nodes = {};
236
719
  nodes.entry = { kind: 'entry', line: startLine, succ: [], pred: [] };
237
720
  nodes.exit = { kind: 'exit', line: startLine, succ: [], pred: [] };
238
- let prev = 'entry';
239
- let stmtLine = startLine;
240
- for (let idx = 0; idx < stmts.length; idx++) {
241
- const node = _lowerStmt(stmts[idx], stmtLine);
242
- if (!node) continue;
243
- const id = `n${idx}`;
244
- nodes[id] = { ...node, succ: [], pred: [prev] };
245
- nodes[prev].succ.push(id);
246
- prev = id;
247
- // Approximate per-statement line advance by counting '\n' in source.
248
- // (Cheap, good-enough for finding line attribution.)
249
- stmtLine += (stmts[idx].match(/\n/g) || []).length + 1;
250
- }
251
- nodes[prev].succ.push('exit');
252
- nodes.exit.pred.push(prev);
721
+ _csNid = 0;
722
+ const tail = _buildCfg(extracted.body, nodes, 'entry', bodyStartLine, lineStarts, 0, 0);
723
+ nodes[tail].succ.push('exit');
724
+ nodes.exit.pred.push(tail);
253
725
  const cfg = { entry: 'entry', exit: 'exit', nodes };
254
726
  functions.push({
255
727
  qid: _qid(file, name, startLine, extracted.body),
256
728
  name, line: startLine, params, file,
257
729
  cfg,
258
730
  calls: callSitesFromCfg(cfg),
731
+ ...(paramAnnotations.length ? { paramAnnotations } : {}),
259
732
  });
260
733
  METHOD_RE.lastIndex = extracted.end + 1;
261
734
  }