@clear-capabilities/agentic-security-scanner 0.136.9 → 0.137.1
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.
- package/CHANGELOG.md +659 -0
- package/bin/agentic-security.js +3 -0
- package/dist/435.index.js +9 -1
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +3 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +318 -55
- package/src/dataflow/index.js +15 -0
- package/src/dataflow/points-to.js +19 -6
- package/src/engine.js +281 -207
- package/src/ir/CLAUDE.md +14 -3
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +451 -31
- package/src/ir/parser-java.js +205 -2
- package/src/ir/parser-js.js +149 -2
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +587 -29
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +124 -19
- package/src/lsp/server.js +7 -1
- package/src/mcp/tools.js +9 -1
- package/src/posture/clustering.js +12 -1
- package/src/posture/sbom.js +2 -2
package/src/ir/parser-cs.js
CHANGED
|
@@ -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,8 +30,17 @@
|
|
|
19
30
|
// - generics on declarations beyond Type<...> name
|
|
20
31
|
// - attributes (skipped)
|
|
21
32
|
// - destructuring / tuples
|
|
22
|
-
// - control
|
|
23
|
-
//
|
|
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.
|
|
@@ -37,30 +57,195 @@ const METHOD_RE = new RegExp(
|
|
|
37
57
|
'\\s*\\(([^)]*)\\)' + // params (group 3)
|
|
38
58
|
'\\s*\\{', 'g');
|
|
39
59
|
|
|
40
|
-
// Matches a top-level statement inside a method body.
|
|
41
|
-
//
|
|
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.
|
|
42
121
|
function _splitStatements(body) {
|
|
43
122
|
const out = [];
|
|
44
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.
|
|
45
127
|
let depth = 0;
|
|
46
|
-
let inString = null; // null | '"' | "'"
|
|
128
|
+
let inString = null; // null | '"' | "'"
|
|
47
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
|
+
};
|
|
48
140
|
for (let i = 0; i < body.length; i++) {
|
|
49
141
|
const c = body[i];
|
|
50
|
-
if (
|
|
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; }
|
|
51
155
|
if (inString) {
|
|
52
|
-
|
|
156
|
+
push(c, i);
|
|
53
157
|
if (inString === '"' && c === '\\') { escape = true; continue; }
|
|
54
158
|
if (c === inString) inString = null;
|
|
55
159
|
continue;
|
|
56
160
|
}
|
|
57
|
-
if (c === '"' || c === "'") { inString = c;
|
|
58
|
-
if (c === '{' || c === '(' || c === '[') depth++;
|
|
59
|
-
if (c === '}' || c === ')' || c === ']')
|
|
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--;
|
|
60
245
|
if (c === ';' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
|
|
61
246
|
buf += c;
|
|
62
247
|
}
|
|
63
|
-
|
|
248
|
+
out.push(buf.trim());
|
|
64
249
|
return out;
|
|
65
250
|
}
|
|
66
251
|
|
|
@@ -263,6 +448,185 @@ function _qid(file, name, line, body) {
|
|
|
263
448
|
return `${file}::${name}@${line}#${sha}`;
|
|
264
449
|
}
|
|
265
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
|
+
|
|
266
630
|
export function parseCSharpFile(file, code) {
|
|
267
631
|
if (!file || typeof code !== 'string') return null;
|
|
268
632
|
const functions = [];
|
|
@@ -271,44 +635,100 @@ export function parseCSharpFile(file, code) {
|
|
|
271
635
|
while ((m = METHOD_RE.exec(code)) !== null) {
|
|
272
636
|
const name = m[2];
|
|
273
637
|
const paramsText = m[3] || '';
|
|
274
|
-
const
|
|
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) => {
|
|
275
656
|
const t = p.trim();
|
|
276
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
|
+
}
|
|
277
684
|
// "Type name" → name. "Type<T> name" → name. "Type[] name = default" → name.
|
|
278
|
-
const last =
|
|
279
|
-
|
|
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;
|
|
280
697
|
}).filter(Boolean);
|
|
281
698
|
const braceIdx = code.indexOf('{', m.index + m[0].length - 1);
|
|
282
699
|
if (braceIdx < 0) continue;
|
|
283
700
|
const extracted = _extractBody(code, braceIdx);
|
|
284
701
|
if (!extracted) continue;
|
|
285
702
|
const startLine = _lineAt(code, m.index);
|
|
286
|
-
|
|
287
|
-
//
|
|
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.
|
|
288
718
|
const nodes = {};
|
|
289
719
|
nodes.entry = { kind: 'entry', line: startLine, succ: [], pred: [] };
|
|
290
720
|
nodes.exit = { kind: 'exit', line: startLine, succ: [], pred: [] };
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (!node) continue;
|
|
296
|
-
const id = `n${idx}`;
|
|
297
|
-
nodes[id] = { ...node, succ: [], pred: [prev] };
|
|
298
|
-
nodes[prev].succ.push(id);
|
|
299
|
-
prev = id;
|
|
300
|
-
// Approximate per-statement line advance by counting '\n' in source.
|
|
301
|
-
// (Cheap, good-enough for finding line attribution.)
|
|
302
|
-
stmtLine += (stmts[idx].match(/\n/g) || []).length + 1;
|
|
303
|
-
}
|
|
304
|
-
nodes[prev].succ.push('exit');
|
|
305
|
-
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);
|
|
306
725
|
const cfg = { entry: 'entry', exit: 'exit', nodes };
|
|
307
726
|
functions.push({
|
|
308
727
|
qid: _qid(file, name, startLine, extracted.body),
|
|
309
728
|
name, line: startLine, params, file,
|
|
310
729
|
cfg,
|
|
311
730
|
calls: callSitesFromCfg(cfg),
|
|
731
|
+
...(paramAnnotations.length ? { paramAnnotations } : {}),
|
|
312
732
|
});
|
|
313
733
|
METHOD_RE.lastIndex = extracted.end + 1;
|
|
314
734
|
}
|