circle-ir 4.12.0 → 4.13.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/README.md +9 -9
- package/dist/analysis/constant-propagation/propagator.d.ts +8 -0
- package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/propagator.js +96 -3
- package/dist/analysis/constant-propagation/propagator.js.map +1 -1
- package/dist/analysis/interprocedural.d.ts.map +1 -1
- package/dist/analysis/interprocedural.js +4 -0
- package/dist/analysis/interprocedural.js.map +1 -1
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +168 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.js +657 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -0
- package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
- package/dist/analysis/passes/interprocedural-pass.js +90 -0
- package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.js +51 -54
- package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.js +328 -51
- package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
- package/dist/browser/circle-ir.js +922 -86
- package/dist/core/circle-ir-core.cjs +89 -3
- package/dist/core/circle-ir-core.js +89 -3
- package/dist/core/extractors/dfg.js +17 -0
- package/dist/core/extractors/dfg.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C# sanitizer and guard credit (cognium-dev C#/.NET Phase-1, #518).
|
|
3
|
+
*
|
|
4
|
+
* C# rides the text-scan propagation, which knows nothing about sanitizers, so
|
|
5
|
+
* `SinkFilterPass` Stage 15k drops a sink when the value reaching it has been
|
|
6
|
+
* made safe. This module answers that question for one sink at a time, inside
|
|
7
|
+
* the method that contains it:
|
|
8
|
+
*
|
|
9
|
+
* - `sanitizedVarsAt` the value passed through a sanitizer for the sink's
|
|
10
|
+
* type, directly or through a same-file helper;
|
|
11
|
+
* - `guardedVarsAt` a check that dominates the sink proves the value
|
|
12
|
+
* inert (allowlist, numeric parse, host allowlist);
|
|
13
|
+
* - `fixedHostRequest` the request URL has a constant host.
|
|
14
|
+
*
|
|
15
|
+
* Everything is matched on source text and is deliberately narrow: when a
|
|
16
|
+
* shape is not recognised the sink stays, so a miss here is a false positive,
|
|
17
|
+
* never a silently dropped finding.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* `Regex.Replace(x, "[^<allowlist>]", "")` — an allowlist character strip
|
|
21
|
+
* (cognium-dev#272, the LDAP-strip cluster).
|
|
22
|
+
*
|
|
23
|
+
* A **negated** class is the whole point: `[^a-zA-Z0-9]` deletes everything
|
|
24
|
+
* *not* in the set, so the surviving characters are exactly the set. That makes
|
|
25
|
+
* the result provably inert for the metacharacter-driven injection families
|
|
26
|
+
* below. A blacklist replace (`"[<>]"`) proves nothing — it enumerates what to
|
|
27
|
+
* remove and silently misses the rest — and must not match.
|
|
28
|
+
*
|
|
29
|
+
* Two conditions, both load-bearing:
|
|
30
|
+
*
|
|
31
|
+
* (a) the pattern is a string literal beginning `[^`. A computed pattern could
|
|
32
|
+
* be anything, including attacker-influenced.
|
|
33
|
+
* (b) the surviving set contains **no metacharacters**. `[^a-zA-Z0-9']` would
|
|
34
|
+
* keep the single quote alive, which breaks SQL and LDAP escaping outright
|
|
35
|
+
* — so the class body is restricted to alphanumerics, `\w`, `\d`, `\s`,
|
|
36
|
+
* underscore, hyphen and spaces. Anything else (quote, paren, backslash,
|
|
37
|
+
* dot, asterisk, semicolon) fails the match and the sink still fires.
|
|
38
|
+
*
|
|
39
|
+
* The replacement must be empty or a single safe character, so
|
|
40
|
+
* `Regex.Replace(x, "[^a-z]", "';DROP")` cannot qualify.
|
|
41
|
+
*/
|
|
42
|
+
export const CSHARP_ALLOWLIST_STRIP_RE = /\bRegex\s*\.\s*Replace\s*\([^,]*,\s*@?"\[\^(?:[A-Za-z0-9_\- ]|\\w|\\d|\\s)+\]"\s*,\s*@?"[A-Za-z0-9_]?"\s*\)/;
|
|
43
|
+
/**
|
|
44
|
+
* `new string(x.Where(char.IsLetterOrDigit).ToArray())` — the LINQ spelling of
|
|
45
|
+
* the same allowlist strip: only letters and digits survive.
|
|
46
|
+
*/
|
|
47
|
+
const CSHARP_ALLOWLIST_FILTER_RE = /\bnew\s+string\s*\(\s*[A-Za-z_]\w*\s*\.\s*Where\s*\(\s*char\s*\.\s*Is(?:LetterOrDigit|Digit|Letter)\s*\)\s*\.\s*ToArray\s*\(\s*\)\s*\)/;
|
|
48
|
+
// C# sanitizers and the sink types each neutralises. A tainted value that
|
|
49
|
+
// flows through one of these before reaching a sink of the matching type is
|
|
50
|
+
// safe. Method names are C#-distinctive (see config-loader C# sanitizers).
|
|
51
|
+
export const CSHARP_SANITIZER_RES = [
|
|
52
|
+
{ re: /\b(?:HtmlEncode|JavaScriptStringEncode)\s*\(/, type: 'xss' },
|
|
53
|
+
// `HtmlEncoder.Default.Encode(x)` is the ASP.NET Core spelling (#520).
|
|
54
|
+
{ re: /\bHtmlEncoder\s*\.\s*(?:Default\s*\.\s*)?Encode\s*\(/, type: 'xss' },
|
|
55
|
+
{ re: /\bPath\s*\.\s*GetFileName\s*\(/, type: 'path_traversal' },
|
|
56
|
+
// XML-escapes `< > & " '`. A value escaped this way cannot close the quoted
|
|
57
|
+
// literal it is placed in, which is what XPath injection needs.
|
|
58
|
+
{ re: /\bSecurityElement\s*\.\s*Escape\s*\(/, type: 'xpath_injection' },
|
|
59
|
+
// An allowlist strip leaves only inert characters, so it covers every family
|
|
60
|
+
// whose exploitation needs a metacharacter. Each has a fixture; see
|
|
61
|
+
// `issue-272-csharp-allowlist-strip.test.ts`.
|
|
62
|
+
{ re: CSHARP_ALLOWLIST_STRIP_RE, type: 'ldap_injection' },
|
|
63
|
+
{ re: CSHARP_ALLOWLIST_STRIP_RE, type: 'xpath_injection' },
|
|
64
|
+
{ re: CSHARP_ALLOWLIST_STRIP_RE, type: 'command_injection' },
|
|
65
|
+
{ re: CSHARP_ALLOWLIST_STRIP_RE, type: 'sql_injection' },
|
|
66
|
+
{ re: CSHARP_ALLOWLIST_STRIP_RE, type: 'crlf' },
|
|
67
|
+
{ re: CSHARP_ALLOWLIST_FILTER_RE, type: 'ldap_injection' },
|
|
68
|
+
{ re: CSHARP_ALLOWLIST_FILTER_RE, type: 'xpath_injection' },
|
|
69
|
+
{ re: CSHARP_ALLOWLIST_FILTER_RE, type: 'command_injection' },
|
|
70
|
+
{ re: CSHARP_ALLOWLIST_FILTER_RE, type: 'sql_injection' },
|
|
71
|
+
{ re: CSHARP_ALLOWLIST_FILTER_RE, type: 'crlf' },
|
|
72
|
+
];
|
|
73
|
+
/**
|
|
74
|
+
* Sink types for which a value made only of letters, digits, `_` and `-` is
|
|
75
|
+
* inert: each needs a metacharacter (quote, paren, slash, angle bracket, CR/LF)
|
|
76
|
+
* to be exploited.
|
|
77
|
+
*/
|
|
78
|
+
const INERT_WHEN_ALPHANUMERIC = new Set([
|
|
79
|
+
'ldap_injection', 'xpath_injection', 'command_injection', 'sql_injection',
|
|
80
|
+
'crlf', 'xss', 'path_traversal', 'code_injection',
|
|
81
|
+
]);
|
|
82
|
+
const IDENT = '[A-Za-z_]\\w*';
|
|
83
|
+
const ASSIGN_RE = new RegExp(`^\\s*(?:var\\s+|[A-Za-z_][\\w.<>\\[\\]?]*\\s+)?(${IDENT})\\s*=(?!=)\\s*(.+?);?\\s*$`);
|
|
84
|
+
const ENDS_STATEMENT_RE = /[;{}]\s*(?:\/\/.*)?$/;
|
|
85
|
+
function escapeRegex(s) {
|
|
86
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
87
|
+
}
|
|
88
|
+
function withoutStringLiterals(text) {
|
|
89
|
+
return text.replace(/@"(?:[^"]|"")*"|"(?:[^"\\]|\\.)*"/g, '""');
|
|
90
|
+
}
|
|
91
|
+
function mentions(text, name) {
|
|
92
|
+
return new RegExp(`(?<![\\w])${escapeRegex(name)}(?![\\w])`).test(text);
|
|
93
|
+
}
|
|
94
|
+
/** Text inside the parentheses opening at `open`, or null when unbalanced. */
|
|
95
|
+
function insideParens(text, open) {
|
|
96
|
+
let depth = 0;
|
|
97
|
+
for (let i = open; i < text.length; i++) {
|
|
98
|
+
if (text[i] === '(')
|
|
99
|
+
depth++;
|
|
100
|
+
else if (text[i] === ')' && --depth === 0)
|
|
101
|
+
return text.slice(open + 1, i);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/** The argument text of the last call on a line: `a.B(c).D(e + f);` gives `e + f`. */
|
|
106
|
+
function lastCallArguments(text) {
|
|
107
|
+
const close = text.lastIndexOf(')');
|
|
108
|
+
if (close < 0)
|
|
109
|
+
return null;
|
|
110
|
+
let depth = 0;
|
|
111
|
+
for (let i = close; i >= 0; i--) {
|
|
112
|
+
if (text[i] === ')')
|
|
113
|
+
depth++;
|
|
114
|
+
else if (text[i] === '(' && --depth === 0)
|
|
115
|
+
return text.slice(i + 1, close);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** `if (COND) rest` at the start of `text`. */
|
|
120
|
+
function leadingIf(text) {
|
|
121
|
+
const m = /^\s*(?:\}\s*)?(?:else\s+)?if\s*\(/.exec(text);
|
|
122
|
+
if (!m)
|
|
123
|
+
return null;
|
|
124
|
+
const open = m[0].length - 1;
|
|
125
|
+
const cond = insideParens(text, open);
|
|
126
|
+
if (cond === null)
|
|
127
|
+
return null;
|
|
128
|
+
return { cond: cond.trim(), rest: text.slice(open + cond.length + 2).trim() };
|
|
129
|
+
}
|
|
130
|
+
const EXITS_RE = /^(?:\{\s*)?(?:return\b|throw\b|continue\b|break\b)/;
|
|
131
|
+
export class CSharpCredit {
|
|
132
|
+
methods;
|
|
133
|
+
lines;
|
|
134
|
+
helperCache = new Map();
|
|
135
|
+
guardHelperCache = new Map();
|
|
136
|
+
constructor(code, methods) {
|
|
137
|
+
this.methods = methods;
|
|
138
|
+
// CRLF files leave `\r` on every line, which `.` and `$` will not cross.
|
|
139
|
+
this.lines = code.split(/\r?\n/);
|
|
140
|
+
}
|
|
141
|
+
/** The innermost method containing `line`, or null. */
|
|
142
|
+
methodAt(line) {
|
|
143
|
+
let best = null;
|
|
144
|
+
for (const m of this.methods) {
|
|
145
|
+
if (line < m.start_line || line > m.end_line)
|
|
146
|
+
continue;
|
|
147
|
+
if (!best || m.end_line - m.start_line < best.end_line - best.start_line)
|
|
148
|
+
best = m;
|
|
149
|
+
}
|
|
150
|
+
return best;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Statements of lines `[from, to)`, 1-based, with a statement wrapped over
|
|
154
|
+
* several lines joined up to its `;`. `line` is where the statement starts.
|
|
155
|
+
*/
|
|
156
|
+
statements(from, to) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let i = Math.max(from, 1); i < to; i++) {
|
|
159
|
+
let text = (this.lines[i - 1] ?? '').trimEnd();
|
|
160
|
+
const first = i;
|
|
161
|
+
if (/^\s*(?:[\w.<>[\]?]+\s+)?[A-Za-z_]\w*\s*=(?!=)/.test(text) && !ENDS_STATEMENT_RE.test(text)) {
|
|
162
|
+
while (i < to - 1 && i < first + 12) {
|
|
163
|
+
i++;
|
|
164
|
+
text += ' ' + (this.lines[i - 1] ?? '').trim();
|
|
165
|
+
if (ENDS_STATEMENT_RE.test(this.lines[i - 1] ?? ''))
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
out.push({ line: first, text });
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** Does `text` apply a built-in sanitizer for `type`? */
|
|
174
|
+
builtinSanitizes(type, text) {
|
|
175
|
+
for (const { re, type: t } of CSHARP_SANITIZER_RES) {
|
|
176
|
+
if (t === type && re.test(text))
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (type === 'ldap_injection') {
|
|
180
|
+
// RFC 4515 escaping, or stripping, of the filter metacharacters. Both
|
|
181
|
+
// parentheses must be handled: they are what lets input restructure a
|
|
182
|
+
// filter. `x.Replace("(", "\\28").Replace(")", "\\29")`.
|
|
183
|
+
if (/\.\s*Replace\s*\(\s*"\("\s*,/.test(text) && /\.\s*Replace\s*\(\s*"\)"\s*,/.test(text))
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (type === 'path_traversal') {
|
|
187
|
+
// `full.StartsWith(prefix) ? full : null` — the value is handed back
|
|
188
|
+
// only when it is under the prefix.
|
|
189
|
+
if (/\b([A-Za-z_]\w*)\s*\.\s*StartsWith\s*\([^)]*\)\s*\?\s*\1\s*:\s*null\b/.test(text))
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Same-file methods that sanitize for `type`: every `return` hands back a
|
|
196
|
+
* sanitized expression. `static string Clean(string x) { return HtmlEncode(x); }`
|
|
197
|
+
* makes `Clean(input)` an xss sanitizer at its call sites.
|
|
198
|
+
*/
|
|
199
|
+
helpers(type) {
|
|
200
|
+
const cached = this.helperCache.get(type);
|
|
201
|
+
if (cached)
|
|
202
|
+
return cached;
|
|
203
|
+
const set = new Set();
|
|
204
|
+
this.helperCache.set(type, set);
|
|
205
|
+
for (const m of this.methods) {
|
|
206
|
+
const returns = this.returnExprs(m);
|
|
207
|
+
if (returns.length === 0)
|
|
208
|
+
continue;
|
|
209
|
+
const ok = returns.every(r => {
|
|
210
|
+
if (this.builtinSanitizes(type, r.expr))
|
|
211
|
+
return true;
|
|
212
|
+
const v = /^([A-Za-z_]\w*)$/.exec(r.expr.trim());
|
|
213
|
+
return !!v && this.sanitizedVarsIn(type, m.start_line, r.line, false).has(v[1]);
|
|
214
|
+
});
|
|
215
|
+
if (ok)
|
|
216
|
+
set.add(m.name);
|
|
217
|
+
}
|
|
218
|
+
return set;
|
|
219
|
+
}
|
|
220
|
+
/** Every returned expression of a method: `return e;` and expression bodies. */
|
|
221
|
+
returnExprs(m) {
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const st of this.statements(m.start_line, m.end_line + 1)) {
|
|
224
|
+
for (const r of st.text.matchAll(/\breturn\s+([^;]+);/g))
|
|
225
|
+
out.push({ line: st.line, expr: r[1] });
|
|
226
|
+
if (st.line === m.start_line) {
|
|
227
|
+
const body = /\)\s*=>\s*(.+?);\s*$/.exec(st.text);
|
|
228
|
+
if (body)
|
|
229
|
+
out.push({ line: st.line, expr: body[1] });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/** Does `text` apply a sanitizer for `type`, built in or a same-file helper? */
|
|
235
|
+
sanitizes(type, text) {
|
|
236
|
+
if (this.builtinSanitizes(type, text))
|
|
237
|
+
return true;
|
|
238
|
+
for (const name of this.helpers(type)) {
|
|
239
|
+
if (new RegExp(`(?<![\\w.])${escapeRegex(name)}\\s*\\(`).test(text))
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
sanitizedVarsIn(type, from, to, withHelpers) {
|
|
245
|
+
const sanitized = new Set();
|
|
246
|
+
for (const st of this.statements(from, to)) {
|
|
247
|
+
const m = ASSIGN_RE.exec(st.text);
|
|
248
|
+
if (!m)
|
|
249
|
+
continue;
|
|
250
|
+
const [, lhs, rhs] = m;
|
|
251
|
+
const credited = (withHelpers ? this.sanitizes(type, rhs) : this.builtinSanitizes(type, rhs)) ||
|
|
252
|
+
[...sanitized].some(v => mentions(rhs, v));
|
|
253
|
+
if (credited)
|
|
254
|
+
sanitized.add(lhs);
|
|
255
|
+
else
|
|
256
|
+
sanitized.delete(lhs);
|
|
257
|
+
}
|
|
258
|
+
return sanitized;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The variables sanitized for `type` at `sinkLine`, judged inside the method
|
|
262
|
+
* that contains the sink (cognium-dev#579). The body is read top to bottom up
|
|
263
|
+
* to the sink and the last assignment to a name decides: a sanitizer call or
|
|
264
|
+
* a derivation from a sanitized name credits it, anything else removes it.
|
|
265
|
+
* Class fields are the caller's concern: they can be sanitized in one method
|
|
266
|
+
* and read in another.
|
|
267
|
+
*/
|
|
268
|
+
sanitizedVarsAt(type, sinkLine) {
|
|
269
|
+
return this.sanitizedVarsIn(type, this.methodAt(sinkLine)?.start_line ?? 1, sinkLine, true);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* True when the sink on `sinkLine` also receives a tainted value that no
|
|
273
|
+
* sanitizer covers. In `Write(HtmlEncode(a) + b)` the encoder covers `a`
|
|
274
|
+
* only: `b` arrives raw, so the sanitizer on the line must not clear the
|
|
275
|
+
* sink. `credited` are the names already known safe for `type` there;
|
|
276
|
+
* `seeds` are the taint sources that name a variable.
|
|
277
|
+
*
|
|
278
|
+
* Taint is followed through the sink's method top to bottom, last
|
|
279
|
+
* assignment wins. The sink text is then read without its string literals
|
|
280
|
+
* and without the sanitizer calls for `type`; a tainted, uncredited name
|
|
281
|
+
* left over is a raw operand.
|
|
282
|
+
*/
|
|
283
|
+
rawTaintReaches(type, sinkLine, credited, seeds) {
|
|
284
|
+
if (seeds.length === 0)
|
|
285
|
+
return false;
|
|
286
|
+
const method = this.methodAt(sinkLine);
|
|
287
|
+
const from = method?.start_line ?? 1;
|
|
288
|
+
const tainted = new Set();
|
|
289
|
+
const stmts = this.statements(from, sinkLine);
|
|
290
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
291
|
+
const st = stmts[i];
|
|
292
|
+
const end = i + 1 < stmts.length ? stmts[i + 1].line : sinkLine;
|
|
293
|
+
const m = ASSIGN_RE.exec(st.text);
|
|
294
|
+
if (m) {
|
|
295
|
+
const rhs = withoutStringLiterals(m[2]);
|
|
296
|
+
if ([...tainted].some(v => mentions(rhs, v)))
|
|
297
|
+
tainted.add(m[1]);
|
|
298
|
+
else
|
|
299
|
+
tainted.delete(m[1]);
|
|
300
|
+
}
|
|
301
|
+
for (const s of seeds)
|
|
302
|
+
if (s.line >= st.line && s.line < end)
|
|
303
|
+
tainted.add(s.variable);
|
|
304
|
+
}
|
|
305
|
+
if (tainted.size === 0)
|
|
306
|
+
return false;
|
|
307
|
+
// Only what the call is given counts: a tainted receiver or cast target
|
|
308
|
+
// (`((XmlDocument)ctx).SelectSingleNode(q)`) is not an operand.
|
|
309
|
+
const call = withoutStringLiterals(this.sinkText(sinkLine));
|
|
310
|
+
const rest = this.withoutSanitizerCalls(type, lastCallArguments(call) ?? call);
|
|
311
|
+
// A sanitizer spelled as a chain or a ternary on the name itself
|
|
312
|
+
// (`x.Replace("(", …)`, `p.StartsWith(root) ? p : null`) is not a call
|
|
313
|
+
// that can be cut out; it covers the name it is written on.
|
|
314
|
+
if (this.builtinSanitizes(type, rest))
|
|
315
|
+
return false;
|
|
316
|
+
for (const v of tainted)
|
|
317
|
+
if (!credited.has(v) && mentions(rest, v))
|
|
318
|
+
return true;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
/** `text` with every sanitizer call for `type`, arguments included, removed. */
|
|
322
|
+
withoutSanitizerCalls(type, text) {
|
|
323
|
+
const res = [];
|
|
324
|
+
for (const { re, type: t } of CSHARP_SANITIZER_RES)
|
|
325
|
+
if (t === type)
|
|
326
|
+
res.push(re);
|
|
327
|
+
for (const name of this.helpers(type))
|
|
328
|
+
res.push(new RegExp(`(?<![\\w.])${escapeRegex(name)}\\s*\\(`));
|
|
329
|
+
let out = text;
|
|
330
|
+
for (const re of res) {
|
|
331
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
332
|
+
const m = re.exec(out);
|
|
333
|
+
if (!m)
|
|
334
|
+
break;
|
|
335
|
+
if (!m[0].endsWith('(')) {
|
|
336
|
+
// The pattern matched the whole call.
|
|
337
|
+
out = out.slice(0, m.index) + out.slice(m.index + m[0].length);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const open = m.index + m[0].length - 1;
|
|
341
|
+
const inner = insideParens(out, open);
|
|
342
|
+
if (inner === null)
|
|
343
|
+
break;
|
|
344
|
+
out = out.slice(0, m.index) + out.slice(open + inner.length + 2);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The text of the statement at `line` that a sink call can be in: for
|
|
351
|
+
* `if (COND) sink(x);` that is `sink(x);`, without the condition. A name
|
|
352
|
+
* that only appears in the condition is not something the sink receives.
|
|
353
|
+
*/
|
|
354
|
+
sinkText(line) {
|
|
355
|
+
const text = this.lines[line - 1] ?? '';
|
|
356
|
+
const guarded = leadingIf(text);
|
|
357
|
+
return guarded && guarded.rest !== '' ? guarded.rest : text;
|
|
358
|
+
}
|
|
359
|
+
// -------------------------------------------------------------------------
|
|
360
|
+
// Guards
|
|
361
|
+
// -------------------------------------------------------------------------
|
|
362
|
+
/** Variables a condition proves to hold only letters, digits, `_` and `-`. */
|
|
363
|
+
allowlistVars(cond) {
|
|
364
|
+
const out = [];
|
|
365
|
+
const anchored = new RegExp(`\\bRegex\\s*\\.\\s*IsMatch\\s*\\(\\s*(${IDENT})\\s*,\\s*@?"\\^\\[(?:[A-Za-z0-9_\\- ]|\\\\w|\\\\d)+\\](?:[+*]|\\{\\d+(?:,\\d*)?\\})\\$"`, 'g');
|
|
366
|
+
for (const m of cond.matchAll(anchored))
|
|
367
|
+
out.push(m[1]);
|
|
368
|
+
const methodGroup = new RegExp(`(${IDENT})\\s*\\.\\s*All\\s*\\(\\s*char\\s*\\.\\s*Is(?:LetterOrDigit|Digit|Letter)\\s*\\)`, 'g');
|
|
369
|
+
for (const m of cond.matchAll(methodGroup))
|
|
370
|
+
out.push(m[1]);
|
|
371
|
+
const lambda = new RegExp(`(${IDENT})\\s*\\.\\s*All\\s*\\(\\s*(${IDENT})\\s*=>`, 'g');
|
|
372
|
+
for (const m of cond.matchAll(lambda)) {
|
|
373
|
+
const open = cond.indexOf('(', m.index + m[1].length);
|
|
374
|
+
const body = insideParens(cond, open)?.replace(new RegExp(`^\\s*${m[2]}\\s*=>`), '') ?? '';
|
|
375
|
+
const c = escapeRegex(m[2]);
|
|
376
|
+
const residue = body
|
|
377
|
+
.replace(new RegExp(`char\\s*\\.\\s*Is(?:LetterOrDigit|Digit|Letter)\\s*\\(\\s*${c}\\s*\\)`, 'g'), '')
|
|
378
|
+
.replace(new RegExp(`${c}\\s*==\\s*'[_\\-]'`, 'g'), '')
|
|
379
|
+
.replace(/\|\||\s/g, '');
|
|
380
|
+
if (residue === '')
|
|
381
|
+
out.push(m[1]);
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
/** Variables a condition proves to be numeric (`int.TryParse(x, out _)`). */
|
|
386
|
+
numericVars(cond, parsedFrom) {
|
|
387
|
+
const out = [];
|
|
388
|
+
const tryParse = new RegExp(`\\b(?:int|long|short|byte|uint|ulong|decimal|double|float|Int32|Int64|Guid)\\s*\\.\\s*TryParse\\s*\\(\\s*(${IDENT})\\s*,`, 'g');
|
|
389
|
+
for (const m of cond.matchAll(tryParse))
|
|
390
|
+
out.push(m[1]);
|
|
391
|
+
for (const [parsed, from] of parsedFrom) {
|
|
392
|
+
if (new RegExp(`(?<![\\w])${escapeRegex(parsed)}\\s*(?:!=\\s*null|\\.\\s*HasValue)\\b`).test(cond))
|
|
393
|
+
out.push(from);
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
/** Variables whose host a condition compares against a constant or an allowlist. */
|
|
398
|
+
hostVars(cond, hostOf) {
|
|
399
|
+
const out = [];
|
|
400
|
+
const compares = (expr) => {
|
|
401
|
+
const e = escapeRegex(expr).replace(/\\ /g, '\\s*');
|
|
402
|
+
return (new RegExp(`${e}\\s*==\\s*@?"[^"]+"`).test(cond) ||
|
|
403
|
+
new RegExp(`@?"[^"]+"\\s*==\\s*${e}`).test(cond) ||
|
|
404
|
+
new RegExp(`${e}\\s*\\.\\s*Equals\\s*\\(`).test(cond) ||
|
|
405
|
+
new RegExp(`\\.\\s*Contains\\s*\\(\\s*${e}\\s*[),]`).test(cond) ||
|
|
406
|
+
new RegExp(`\\bIndexOf\\s*\\([^()]*,\\s*${e}\\s*\\)\\s*>=\\s*0`).test(cond));
|
|
407
|
+
};
|
|
408
|
+
const direct = new RegExp(`new\\s+(?:System\\s*\\.\\s*)?Uri\\s*\\(\\s*(${IDENT})\\s*\\)\\s*\\.\\s*Host\\b`, 'g');
|
|
409
|
+
for (const m of cond.matchAll(direct)) {
|
|
410
|
+
if (compares(m[0]))
|
|
411
|
+
out.push(m[1]);
|
|
412
|
+
}
|
|
413
|
+
for (const [hostVar, from] of hostOf) {
|
|
414
|
+
if (mentions(cond, hostVar) && compares(hostVar))
|
|
415
|
+
out.push(from);
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Same-file methods that are guards of `kind` on one of their parameters:
|
|
421
|
+
* `static bool IsAllowed(string c) { return Array.IndexOf(Allowed, new Uri(c).Host) >= 0; }`.
|
|
422
|
+
* Maps the method name to the position of the validated parameter.
|
|
423
|
+
*/
|
|
424
|
+
guardHelpers(kind) {
|
|
425
|
+
const cached = this.guardHelperCache.get(kind);
|
|
426
|
+
if (cached)
|
|
427
|
+
return cached;
|
|
428
|
+
const map = new Map();
|
|
429
|
+
this.guardHelperCache.set(kind, map);
|
|
430
|
+
for (const m of this.methods) {
|
|
431
|
+
const signature = this.lines[m.start_line - 1] ?? '';
|
|
432
|
+
const open = signature.indexOf('(', signature.indexOf(m.name));
|
|
433
|
+
const params = open === -1 ? null : insideParens(signature, open);
|
|
434
|
+
if (!params)
|
|
435
|
+
continue;
|
|
436
|
+
const names = params.split(',').map(p => p.trim().split(/\s+/).pop() ?? '');
|
|
437
|
+
const returns = this.returnExprs(m);
|
|
438
|
+
if (returns.length !== 1)
|
|
439
|
+
continue;
|
|
440
|
+
const proven = this.provenBy(kind, returns[0].expr, new Map(), new Map(), false);
|
|
441
|
+
const pos = names.findIndex(n => proven.includes(n));
|
|
442
|
+
if (pos !== -1)
|
|
443
|
+
map.set(m.name, pos);
|
|
444
|
+
}
|
|
445
|
+
return map;
|
|
446
|
+
}
|
|
447
|
+
provenBy(kind, cond, parsedFrom, hostOf, withHelpers) {
|
|
448
|
+
const out = kind === 'allowlist' ? this.allowlistVars(cond)
|
|
449
|
+
: kind === 'numeric' ? this.numericVars(cond, parsedFrom)
|
|
450
|
+
: this.hostVars(cond, hostOf);
|
|
451
|
+
if (withHelpers) {
|
|
452
|
+
for (const [name, pos] of this.guardHelpers(kind)) {
|
|
453
|
+
for (const call of cond.matchAll(new RegExp(`(?<![\\w.])${escapeRegex(name)}\\s*\\(`, 'g'))) {
|
|
454
|
+
const args = insideParens(cond, call.index + call[0].length - 1)?.split(',') ?? [];
|
|
455
|
+
const arg = args[pos]?.trim();
|
|
456
|
+
if (arg && new RegExp(`^${IDENT}$`).test(arg))
|
|
457
|
+
out.push(arg);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The variables a check dominating `sinkLine` proves safe for `type`.
|
|
465
|
+
*
|
|
466
|
+
* Recognised checks:
|
|
467
|
+
* - allowlist: anchored `Regex.IsMatch(x, "^[A-Za-z0-9_]+$")`, `x.All(char.IsLetterOrDigit)`;
|
|
468
|
+
* - numeric: `int.TryParse(x, out _)`, or `p != null` where `p = int.Parse(x)`;
|
|
469
|
+
* - host (ssrf only): `new Uri(x).Host == "api.example.com"`, an allowlist
|
|
470
|
+
* `Contains` / `IndexOf` of that host.
|
|
471
|
+
* directly, through a `bool` or host variable, or through a same-file helper.
|
|
472
|
+
*
|
|
473
|
+
* A check dominates the sink when the sink is the body of the `if`, is
|
|
474
|
+
* inside its block, or follows an early exit on the negated check
|
|
475
|
+
* (`if (!ok) return;`).
|
|
476
|
+
*/
|
|
477
|
+
guardedVarsAt(type, sinkLine) {
|
|
478
|
+
const kinds = [];
|
|
479
|
+
if (INERT_WHEN_ALPHANUMERIC.has(type))
|
|
480
|
+
kinds.push('allowlist', 'numeric');
|
|
481
|
+
if (type === 'ssrf')
|
|
482
|
+
kinds.push('host');
|
|
483
|
+
const proven = new Set();
|
|
484
|
+
if (kinds.length === 0)
|
|
485
|
+
return proven;
|
|
486
|
+
const scopeStart = this.methodAt(sinkLine)?.start_line ?? 1;
|
|
487
|
+
const before = this.statements(scopeStart, sinkLine);
|
|
488
|
+
const boolOf = new Map(); // b = <condition>
|
|
489
|
+
const parsedFrom = new Map(); // p = int.Parse(x)
|
|
490
|
+
const hostOf = new Map(); // h = new Uri(x).Host
|
|
491
|
+
const parseAnywhere = new RegExp(`(${IDENT})\\s*=\\s*(?:\\([^)]*\\)\\s*)?(?:int|long|short|byte|uint|ulong|decimal|double|float|Int32|Int64)\\s*\\.\\s*Parse\\s*\\(\\s*(${IDENT})\\s*[,)]`, 'g');
|
|
492
|
+
for (const st of before) {
|
|
493
|
+
// `try { p = int.Parse(x); }` keeps the assignment off the line start.
|
|
494
|
+
for (const p of st.text.matchAll(parseAnywhere))
|
|
495
|
+
parsedFrom.set(p[1], p[2]);
|
|
496
|
+
const m = ASSIGN_RE.exec(st.text);
|
|
497
|
+
if (!m)
|
|
498
|
+
continue;
|
|
499
|
+
const [, lhs, rhs] = m;
|
|
500
|
+
const parse = new RegExp(`^(?:\\([^)]*\\)\\s*)?(?:int|long|short|byte|uint|ulong|decimal|double|float|Int32|Int64)\\s*\\.\\s*Parse\\s*\\(\\s*(${IDENT})\\s*[,)]`).exec(rhs);
|
|
501
|
+
if (parse)
|
|
502
|
+
parsedFrom.set(lhs, parse[1]);
|
|
503
|
+
const host = new RegExp(`^new\\s+(?:System\\s*\\.\\s*)?Uri\\s*\\(\\s*(${IDENT})\\s*\\)\\s*\\.\\s*Host$`).exec(rhs.trim());
|
|
504
|
+
if (host)
|
|
505
|
+
hostOf.set(lhs, host[1]);
|
|
506
|
+
boolOf.set(lhs, rhs);
|
|
507
|
+
}
|
|
508
|
+
const credit = (cond) => {
|
|
509
|
+
// A bare variable stands for the condition it was assigned.
|
|
510
|
+
const resolved = new RegExp(`^${IDENT}$`).test(cond) ? boolOf.get(cond) ?? cond : cond;
|
|
511
|
+
for (const kind of kinds) {
|
|
512
|
+
for (const v of this.provenBy(kind, resolved, parsedFrom, hostOf, true))
|
|
513
|
+
proven.add(v);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
// (i) the sink is the body of an `if` on its own line.
|
|
517
|
+
const own = leadingIf(this.lines[sinkLine - 1] ?? '');
|
|
518
|
+
if (own && !own.cond.startsWith('!'))
|
|
519
|
+
credit(own.cond);
|
|
520
|
+
// (ii) the sink is inside the block of an `if`.
|
|
521
|
+
let depth = 0;
|
|
522
|
+
for (let i = sinkLine - 1; i >= scopeStart; i--) {
|
|
523
|
+
const text = this.lines[i - 1] ?? '';
|
|
524
|
+
for (let c = text.length - 1; c >= 0; c--) {
|
|
525
|
+
if (text[c] === '}')
|
|
526
|
+
depth++;
|
|
527
|
+
else if (text[c] === '{') {
|
|
528
|
+
if (depth > 0) {
|
|
529
|
+
depth--;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
let header = text.slice(0, c);
|
|
533
|
+
for (let h = i - 1; header.trim() === '' && h >= scopeStart; h--)
|
|
534
|
+
header = this.lines[h - 1] ?? '';
|
|
535
|
+
const enclosing = leadingIf(header);
|
|
536
|
+
if (enclosing && !enclosing.cond.startsWith('!'))
|
|
537
|
+
credit(enclosing.cond);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// (iii) an early exit on the negated check, earlier in the method.
|
|
542
|
+
for (let i = scopeStart; i < sinkLine; i++) {
|
|
543
|
+
const guard = leadingIf(this.lines[i - 1] ?? '');
|
|
544
|
+
if (!guard)
|
|
545
|
+
continue;
|
|
546
|
+
let rest = guard.rest;
|
|
547
|
+
for (let n = i + 1; rest === '' && n < sinkLine; n++)
|
|
548
|
+
rest = (this.lines[n - 1] ?? '').trim();
|
|
549
|
+
if (!EXITS_RE.test(rest))
|
|
550
|
+
continue;
|
|
551
|
+
if (guard.cond.startsWith('!')) {
|
|
552
|
+
// `!(a == b)` drops the wrapping parentheses with the `!`; `!F(x)` keeps its call.
|
|
553
|
+
const inner = guard.cond.slice(1).trim();
|
|
554
|
+
const wrapped = inner.startsWith('(') && insideParens(inner, 0)?.length === inner.length - 2;
|
|
555
|
+
credit(wrapped ? inner.slice(1, -1) : inner);
|
|
556
|
+
}
|
|
557
|
+
else if (/!=/.test(guard.cond) && !/[|&]/.test(guard.cond))
|
|
558
|
+
credit(guard.cond.replace('!=', '=='));
|
|
559
|
+
}
|
|
560
|
+
return proven;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* `StringBuilder`s that hold only safe text at `sinkLine`: every write to
|
|
564
|
+
* the builder before the sink is made of string literals and `credited`
|
|
565
|
+
* names. One write with anything else and the builder is not credited, so
|
|
566
|
+
* `sb.Append(validated); sb.Append(raw);` stays tainted.
|
|
567
|
+
*
|
|
568
|
+
* This is how a guard on a value reaches a sink that receives the builder
|
|
569
|
+
* the value was appended to (`if (parsed != null) { sb.Append(data); Compile(sb.ToString()); }`).
|
|
570
|
+
*/
|
|
571
|
+
safeBuilders(credited, sinkLine) {
|
|
572
|
+
const scopeStart = this.methodAt(sinkLine)?.start_line ?? 1;
|
|
573
|
+
const onlySafe = (expr) => {
|
|
574
|
+
const idents = expr
|
|
575
|
+
.replace(/\$?@?"(?:[^"\\]|\\.)*"/g, ' ')
|
|
576
|
+
.replace(/'(?:[^'\\]|\\.)'/g, ' ')
|
|
577
|
+
.replace(/\.\s*[A-Za-z_]\w*\s*\(\s*\)/g, ' ') // x.ToString()
|
|
578
|
+
.match(/[A-Za-z_]\w*/g) ?? [];
|
|
579
|
+
return idents.every(id => credited.has(id));
|
|
580
|
+
};
|
|
581
|
+
const state = new Map(); // builder -> still safe
|
|
582
|
+
const declRe = new RegExp(`\\b(?:StringBuilder|var)\\s+(${IDENT})\\s*=\\s*new\\s+(?:System\\s*\\.\\s*Text\\s*\\.\\s*)?StringBuilder\\s*\\(`);
|
|
583
|
+
const writeRe = new RegExp(`^\\s*(${IDENT})\\s*\\.\\s*(?:Append|AppendLine|AppendFormat|Insert)\\s*\\(`);
|
|
584
|
+
for (const st of this.statements(scopeStart, sinkLine)) {
|
|
585
|
+
const decl = declRe.exec(st.text);
|
|
586
|
+
if (decl) {
|
|
587
|
+
const args = insideParens(st.text, decl.index + decl[0].length - 1) ?? '';
|
|
588
|
+
state.set(decl[1], onlySafe(args));
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const write = writeRe.exec(st.text);
|
|
592
|
+
if (write && state.has(write[1])) {
|
|
593
|
+
const args = insideParens(st.text, write[0].length - 1);
|
|
594
|
+
if (args === null || !onlySafe(args))
|
|
595
|
+
state.set(write[1], false);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return new Set([...state].filter(([, safe]) => safe).map(([name]) => name));
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* True when what leaves on `line` has been sanitized or validated for some
|
|
602
|
+
* sink type. Used for the untyped "tainted data reaches an external call"
|
|
603
|
+
* fallback, which has no sink type of its own to ask about.
|
|
604
|
+
*/
|
|
605
|
+
creditedOnLine(line, seeds = []) {
|
|
606
|
+
const text = this.sinkText(line);
|
|
607
|
+
const types = new Set(CSHARP_SANITIZER_RES.map(r => r.type));
|
|
608
|
+
for (const t of INERT_WHEN_ALPHANUMERIC)
|
|
609
|
+
types.add(t);
|
|
610
|
+
types.add('ssrf');
|
|
611
|
+
for (const type of types) {
|
|
612
|
+
const credited = new Set([...this.sanitizedVarsAt(type, line), ...this.guardedVarsAt(type, line)]);
|
|
613
|
+
for (const v of this.safeBuilders(credited, line))
|
|
614
|
+
credited.add(v);
|
|
615
|
+
const covered = this.sanitizes(type, text) || [...credited].some(v => mentions(text, v));
|
|
616
|
+
if (covered && !this.rawTaintReaches(type, line, credited, seeds))
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
return this.fixedHostRequest(line);
|
|
620
|
+
}
|
|
621
|
+
// -------------------------------------------------------------------------
|
|
622
|
+
// SSRF: constant host
|
|
623
|
+
// -------------------------------------------------------------------------
|
|
624
|
+
/**
|
|
625
|
+
* True when the request on `sinkLine` goes to a constant host: the URL
|
|
626
|
+
* argument starts with a string literal holding the whole authority, as in
|
|
627
|
+
* `GetAsync("https://api.internal.example.com/?x=" + v)`. Input can then only
|
|
628
|
+
* extend the path or query. The literal must be the first operand of the
|
|
629
|
+
* argument, and must end the authority (`/`, `?` or `#` after the host), or
|
|
630
|
+
* `"https://api.example.com" + x` would let `x = ".evil.org"` move the host.
|
|
631
|
+
*/
|
|
632
|
+
fixedHostRequest(sinkLine) {
|
|
633
|
+
const fixedLiteral = '\\$?@?"https?:\\/\\/[A-Za-z0-9.\\-]+(?::\\d+)?[\\/?#][^"]*"';
|
|
634
|
+
const text = this.lines[sinkLine - 1] ?? '';
|
|
635
|
+
const argStart = `\\(\\s*(?:new\\s+(?:System\\s*\\.\\s*)?Uri\\s*\\(\\s*)?`;
|
|
636
|
+
if (new RegExp(argStart + fixedLiteral).test(text))
|
|
637
|
+
return true;
|
|
638
|
+
// The URL was built into a variable first.
|
|
639
|
+
const scopeStart = this.methodAt(sinkLine)?.start_line ?? 1;
|
|
640
|
+
const fixed = new Set();
|
|
641
|
+
for (const st of this.statements(scopeStart, sinkLine)) {
|
|
642
|
+
const m = ASSIGN_RE.exec(st.text);
|
|
643
|
+
if (!m)
|
|
644
|
+
continue;
|
|
645
|
+
if (new RegExp('^(?:new\\s+(?:System\\s*\\.\\s*)?Uri\\s*\\(\\s*)?' + fixedLiteral).test(m[2].trim()))
|
|
646
|
+
fixed.add(m[1]);
|
|
647
|
+
else
|
|
648
|
+
fixed.delete(m[1]);
|
|
649
|
+
}
|
|
650
|
+
for (const v of fixed) {
|
|
651
|
+
if (new RegExp(argStart + escapeRegex(v) + '\\s*[,)]').test(text))
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
//# sourceMappingURL=csharp-sanitizer-credit.js.map
|