@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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.
- package/CHANGELOG.md +125 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +875 -90
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +29 -6
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +168 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +118 -9
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +261 -7
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +498 -17
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/report/index.js +12 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
- package/src/dataflow/smt-feasibility.js +0 -189
package/src/ir/parser-cs.js
CHANGED
|
@@ -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
|
-
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
625
|
-
const
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|