circle-ir 3.124.0 → 3.126.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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"language-sources-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/language-sources-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAwB,WAAW,EAAO,MAAM,sBAAsB,CAAC;AAC3H,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAqB9E,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;IA0C/B,CAAC;AAiCF,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,WAAW,EAAE,CAAC;IACjC,eAAe,EAAE,SAAS,EAAE,CAAC;IAC7B;;;;OAIG;IACH,oBAAoB,EAAE,cAAc,EAAE,CAAC;IACvC;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;OAGG;IACH,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAMD,qBAAa,mBAAoB,YAAW,YAAY,CAAC,qBAAqB,CAAC;IAC7E,QAAQ,CAAC,IAAI,sBAAsB;IACnC,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,qBAAqB;
|
|
1
|
+
{"version":3,"file":"language-sources-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/language-sources-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAwB,WAAW,EAAO,MAAM,sBAAsB,CAAC;AAC3H,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAqB9E,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;IA0C/B,CAAC;AAiCF,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,WAAW,EAAE,CAAC;IACjC,eAAe,EAAE,SAAS,EAAE,CAAC;IAC7B;;;;OAIG;IACH,oBAAoB,EAAE,cAAc,EAAE,CAAC;IACvC;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;OAGG;IACH,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAMD,qBAAa,mBAAoB,YAAW,YAAY,CAAC,qBAAqB,CAAC;IAC7E,QAAQ,CAAC,IAAI,sBAAsB;IACnC,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,qBAAqB;CAwM7C;AAgkBD,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAkG9E;AAED,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,CAAC,CAwC5G;AAED,wBAAgB,iCAAiC,CAC/C,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,KAAK,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAoBjD;AAyKD,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBpG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GACpB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAmCrB;AA+MD,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAsKvF;AA0+CD,wBAAgB,sCAAsC,CACpD,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,WAAW,EAAE,CAcf;AAoLD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CA+HnF;AAMD;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CA2BjF"}
|
|
@@ -211,6 +211,12 @@ export class LanguageSourcesPass {
|
|
|
211
211
|
if (language === 'python') {
|
|
212
212
|
additionalSanitizers.push(...findPythonNetlocAllowlistGuardSanitizers(code));
|
|
213
213
|
additionalSanitizers.push(...findPythonRangeCheckGuardSanitizers(code));
|
|
214
|
+
// Sprint 74 (#216 Pattern B): regex-allowlist wrapper functions,
|
|
215
|
+
// var-aware set-membership xss guard, and defusedxml import-alias
|
|
216
|
+
// recognition.
|
|
217
|
+
additionalSanitizers.push(...findPythonRegexAllowlistWrapperSanitizers(code));
|
|
218
|
+
additionalSanitizers.push(...findPythonSetMembershipXssGuardSanitizers(code));
|
|
219
|
+
additionalSanitizers.push(...findPythonDefusedXmlSanitizers(code));
|
|
214
220
|
// Sprint 71 (#190): pattern-based misconfig findings for subscript/context
|
|
215
221
|
// assignment shapes (cors-wildcard-origin, xfo-csp-mismatch, tls-verify-
|
|
216
222
|
// disabled) that the language-agnostic detectors miss in Python.
|
|
@@ -238,6 +244,8 @@ export class LanguageSourcesPass {
|
|
|
238
244
|
additionalSanitizers.push(...findJsCryptoHashSanitizers(code));
|
|
239
245
|
additionalSanitizers.push(...findJsCsvFormulaPrefixSanitizers(code));
|
|
240
246
|
additionalSanitizers.push(...findJsWrapperFunctionSanitizers(code));
|
|
247
|
+
// Sprint 75 (#216 Pattern D): JS SSRF allow-list guard (var-aware).
|
|
248
|
+
additionalSanitizers.push(...findJsSsrfAllowlistGuardSanitizers(code));
|
|
241
249
|
}
|
|
242
250
|
// -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
|
|
243
251
|
// fromJson recognized as ETE terminator (does not affect
|
|
@@ -2105,6 +2113,339 @@ function findPythonRangeCheckGuardSanitizers(code) {
|
|
|
2105
2113
|
}
|
|
2106
2114
|
return sanitizers;
|
|
2107
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Detect Python regex-fullmatch allow-list wrapper functions
|
|
2118
|
+
* (cognium-dev #216 Sprint 74 Pattern B).
|
|
2119
|
+
*
|
|
2120
|
+
* Pattern recognized:
|
|
2121
|
+
*
|
|
2122
|
+
* def checked_uid(uid):
|
|
2123
|
+
* if not re.fullmatch(r"[a-zA-Z0-9_-]+", uid):
|
|
2124
|
+
* abort(400)
|
|
2125
|
+
* return uid
|
|
2126
|
+
*
|
|
2127
|
+
* Two-pass: (1) scan for `def NAME(arg):` declarations whose body
|
|
2128
|
+
* contains `if not re.fullmatch(<TIGHT_REGEX>, arg): <terminator>`
|
|
2129
|
+
* and returns `arg`; (2) for every line containing `NAME(...)` outside
|
|
2130
|
+
* the declaration, emit a sanitizer.
|
|
2131
|
+
*
|
|
2132
|
+
* "Tight" regex means a character-class allow-list that admits only
|
|
2133
|
+
* alphanumerics and a small fixed set of safe separators
|
|
2134
|
+
* ([A-Za-z0-9], \w, plus optional `-`, `_`, `.`). Such an allow-list
|
|
2135
|
+
* strips every injection metacharacter, so the sanitizer lists
|
|
2136
|
+
* ldap/xpath/sql/command/path-traversal/xss + ETE.
|
|
2137
|
+
*
|
|
2138
|
+
* Loose patterns like `.*` or `.+` are rejected (TP-controls cover
|
|
2139
|
+
* the bypass case where the wrapper return value is not what reaches
|
|
2140
|
+
* the sink).
|
|
2141
|
+
*/
|
|
2142
|
+
function findPythonRegexAllowlistWrapperSanitizers(code) {
|
|
2143
|
+
const sanitizers = [];
|
|
2144
|
+
const lines = code.split('\n');
|
|
2145
|
+
// Match `def NAME(arg):` — single argument, no default.
|
|
2146
|
+
const defOpen = /^(\s*)def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*:\s*$/;
|
|
2147
|
+
// Tight char-class allow-list: starts with `[` followed by safe ranges
|
|
2148
|
+
// and an explicit `+` / `*` / `{n,m}` quantifier; or `\w+`-style.
|
|
2149
|
+
const tightRegex = /r?["'](?:\^)?(?:\\w[+*]|\\d[+*]|\[[A-Za-z0-9_\\\-\.\s]+\][+*]|[A-Za-z0-9_\-\.]+)(?:\$)?["']/;
|
|
2150
|
+
const terminator = /\b(return\s+(?:None|"|'|\(|\[|\{|False|jsonify|make_response|redirect|abort)|raise\s+\w|abort\s*\(|sys\.exit\s*\()/;
|
|
2151
|
+
const wrappers = [];
|
|
2152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2153
|
+
const m = defOpen.exec(lines[i]);
|
|
2154
|
+
if (!m)
|
|
2155
|
+
continue;
|
|
2156
|
+
const defIndent = m[1].length;
|
|
2157
|
+
const wrapperName = m[2];
|
|
2158
|
+
const argName = m[3];
|
|
2159
|
+
// Walk the indented body of the def. Look for
|
|
2160
|
+
// `if not re.fullmatch(<tight>, <arg>): <terminator>` and `return arg`.
|
|
2161
|
+
let foundGuard = false;
|
|
2162
|
+
let returnsArg = false;
|
|
2163
|
+
let blockEnd = -1;
|
|
2164
|
+
const maxScan = Math.min(lines.length, i + 40);
|
|
2165
|
+
for (let j = i + 1; j < maxScan; j++) {
|
|
2166
|
+
const line = lines[j];
|
|
2167
|
+
if (line.trim() === '')
|
|
2168
|
+
continue;
|
|
2169
|
+
const indent = line.length - line.trimStart().length;
|
|
2170
|
+
if (indent <= defIndent) {
|
|
2171
|
+
blockEnd = j - 1;
|
|
2172
|
+
break;
|
|
2173
|
+
}
|
|
2174
|
+
if (!foundGuard) {
|
|
2175
|
+
const guard = new RegExp(`if\\s+not\\s+re\\.(?:fullmatch|match)\\s*\\(\\s*(${tightRegex.source})\\s*,\\s*${argName}\\s*\\)\\s*:`);
|
|
2176
|
+
if (guard.test(line)) {
|
|
2177
|
+
// Confirm the next deeper-indented line is a terminator.
|
|
2178
|
+
for (let k = j + 1; k < maxScan; k++) {
|
|
2179
|
+
const klin = lines[k];
|
|
2180
|
+
if (klin.trim() === '')
|
|
2181
|
+
continue;
|
|
2182
|
+
const kindent = klin.length - klin.trimStart().length;
|
|
2183
|
+
if (kindent <= indent)
|
|
2184
|
+
break;
|
|
2185
|
+
if (terminator.test(klin))
|
|
2186
|
+
foundGuard = true;
|
|
2187
|
+
break;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
if (new RegExp(`^\\s*return\\s+${argName}\\s*$`).test(line)) {
|
|
2192
|
+
returnsArg = true;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
if (blockEnd === -1)
|
|
2196
|
+
blockEnd = Math.min(lines.length - 1, i + 39);
|
|
2197
|
+
if (!foundGuard || !returnsArg)
|
|
2198
|
+
continue;
|
|
2199
|
+
wrappers.push({ name: wrapperName, defLine: i, defIndent });
|
|
2200
|
+
}
|
|
2201
|
+
if (wrappers.length === 0)
|
|
2202
|
+
return sanitizers;
|
|
2203
|
+
// The kinds a tight-regex wrapper sanitizes.
|
|
2204
|
+
const wrapperKinds = [
|
|
2205
|
+
'ldap_injection',
|
|
2206
|
+
'xpath_injection',
|
|
2207
|
+
'sql_injection',
|
|
2208
|
+
'command_injection',
|
|
2209
|
+
'path_traversal',
|
|
2210
|
+
'xss',
|
|
2211
|
+
'external_taint_escape',
|
|
2212
|
+
];
|
|
2213
|
+
// Pass 2: var-aware emission. For each call of the form
|
|
2214
|
+
// `<VALIDATED> = NAME(...)`
|
|
2215
|
+
// mark every subsequent line in the same function block that
|
|
2216
|
+
// references `<VALIDATED>` as a sanitizer for the wrapper kinds.
|
|
2217
|
+
// This avoids over-suppressing unrelated tainted vars introduced
|
|
2218
|
+
// later in the same function.
|
|
2219
|
+
for (const w of wrappers) {
|
|
2220
|
+
const assignedCallRe = new RegExp(`^(\\s*)([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${w.name}\\s*\\(`);
|
|
2221
|
+
const bareCallRe = new RegExp(`\\b${w.name}\\s*\\(`);
|
|
2222
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2223
|
+
if (i === w.defLine)
|
|
2224
|
+
continue;
|
|
2225
|
+
const line = lines[i];
|
|
2226
|
+
if (!bareCallRe.test(line))
|
|
2227
|
+
continue;
|
|
2228
|
+
// Skip calls inside the wrapper's own body.
|
|
2229
|
+
if (i > w.defLine) {
|
|
2230
|
+
const lineIndent = line.length - line.trimStart().length;
|
|
2231
|
+
if (lineIndent > w.defIndent) {
|
|
2232
|
+
let stillInside = true;
|
|
2233
|
+
for (let k = w.defLine + 1; k < i; k++) {
|
|
2234
|
+
const kline = lines[k];
|
|
2235
|
+
if (kline.trim() === '')
|
|
2236
|
+
continue;
|
|
2237
|
+
const kindent = kline.length - kline.trimStart().length;
|
|
2238
|
+
if (kindent <= w.defIndent) {
|
|
2239
|
+
stillInside = false;
|
|
2240
|
+
break;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
if (stillInside)
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
// Emit a sanitizer at the call line itself (covers ETE on a
|
|
2248
|
+
// single-line `NAME(req.args.get(...))` shape).
|
|
2249
|
+
sanitizers.push({
|
|
2250
|
+
type: 'python_regex_allowlist_wrapper',
|
|
2251
|
+
method: w.name,
|
|
2252
|
+
line: i + 1,
|
|
2253
|
+
sanitizes: [...wrapperKinds],
|
|
2254
|
+
});
|
|
2255
|
+
// If the call result is assigned to a variable, track it and
|
|
2256
|
+
// emit sanitizers on every later line in the same function
|
|
2257
|
+
// block that mentions that variable name. End the scan when
|
|
2258
|
+
// we leave the call's enclosing block.
|
|
2259
|
+
const am = assignedCallRe.exec(line);
|
|
2260
|
+
if (!am)
|
|
2261
|
+
continue;
|
|
2262
|
+
const callIndent = am[1].length;
|
|
2263
|
+
const validated = am[2];
|
|
2264
|
+
const validatedRe = new RegExp(`\\b${validated}\\b`);
|
|
2265
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
2266
|
+
const jline = lines[j];
|
|
2267
|
+
if (jline.trim() === '')
|
|
2268
|
+
continue;
|
|
2269
|
+
const jindent = jline.length - jline.trimStart().length;
|
|
2270
|
+
if (jindent < callIndent)
|
|
2271
|
+
break;
|
|
2272
|
+
if (validatedRe.test(jline)) {
|
|
2273
|
+
sanitizers.push({
|
|
2274
|
+
type: 'python_regex_allowlist_wrapper',
|
|
2275
|
+
method: w.name,
|
|
2276
|
+
line: j + 1,
|
|
2277
|
+
sanitizes: [...wrapperKinds],
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
return sanitizers;
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Detect Python set-membership allow-list guards that should sanitize
|
|
2287
|
+
* `xss` flows (cognium-dev #216 Sprint 74 Pattern B, SSTI fixture).
|
|
2288
|
+
*
|
|
2289
|
+
* Pattern recognized:
|
|
2290
|
+
*
|
|
2291
|
+
* t = request.args.get("t", "")
|
|
2292
|
+
* if t not in ALLOWED:
|
|
2293
|
+
* abort(403)
|
|
2294
|
+
* env.from_string("{{ " + t + " }}").render()
|
|
2295
|
+
*
|
|
2296
|
+
* The existing `findPythonNetlocAllowlistGuardSanitizers` already
|
|
2297
|
+
* handles the source pattern but cannot list `xss` as a blanket
|
|
2298
|
+
* sanitizer (over-suppression risk: a different unguarded variable
|
|
2299
|
+
* later in the same handler would be incorrectly cleared). This
|
|
2300
|
+
* detector is variable-aware: it only emits an `xss` sanitizer on
|
|
2301
|
+
* lines that actually reference the guarded variable.
|
|
2302
|
+
*/
|
|
2303
|
+
function findPythonSetMembershipXssGuardSanitizers(code) {
|
|
2304
|
+
const sanitizers = [];
|
|
2305
|
+
const lines = code.split('\n');
|
|
2306
|
+
// `if <ident> not in <ALLOWLIST>:`
|
|
2307
|
+
const guardOpen = /^(\s*)if\s+([A-Za-z_][A-Za-z0-9_]*)\s+not\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*$/;
|
|
2308
|
+
const allowlistName = /^(?:[A-Z][A-Z0-9_]+|.*?(allowed|accepted|whitelist|permitted|valid|approved).*)$/i;
|
|
2309
|
+
const terminator = /\b(return|raise|abort\s*\(|sys\.exit\s*\()/;
|
|
2310
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2311
|
+
const m = guardOpen.exec(lines[i]);
|
|
2312
|
+
if (!m)
|
|
2313
|
+
continue;
|
|
2314
|
+
const guardIndent = m[1].length;
|
|
2315
|
+
const guardedVar = m[2];
|
|
2316
|
+
const allowName = m[3];
|
|
2317
|
+
if (!allowlistName.test(allowName))
|
|
2318
|
+
continue;
|
|
2319
|
+
let bodyHasTerminator = false;
|
|
2320
|
+
let blockEnd = -1;
|
|
2321
|
+
const maxScan = Math.min(lines.length, i + 26);
|
|
2322
|
+
for (let j = i + 1; j < maxScan; j++) {
|
|
2323
|
+
const line = lines[j];
|
|
2324
|
+
if (line.trim() === '')
|
|
2325
|
+
continue;
|
|
2326
|
+
const indent = line.length - line.trimStart().length;
|
|
2327
|
+
if (indent <= guardIndent) {
|
|
2328
|
+
blockEnd = j - 1;
|
|
2329
|
+
break;
|
|
2330
|
+
}
|
|
2331
|
+
if (terminator.test(line))
|
|
2332
|
+
bodyHasTerminator = true;
|
|
2333
|
+
}
|
|
2334
|
+
if (blockEnd === -1)
|
|
2335
|
+
blockEnd = Math.min(lines.length - 1, i + 25);
|
|
2336
|
+
if (!bodyHasTerminator)
|
|
2337
|
+
continue;
|
|
2338
|
+
// Var-aware emission: only lines that reference the guarded var.
|
|
2339
|
+
const varRe = new RegExp(`\\b${guardedVar}\\b`);
|
|
2340
|
+
for (let l = blockEnd + 2; l <= lines.length; l++) {
|
|
2341
|
+
const lineText = lines[l - 1];
|
|
2342
|
+
if (!varRe.test(lineText))
|
|
2343
|
+
continue;
|
|
2344
|
+
sanitizers.push({
|
|
2345
|
+
type: 'python_set_membership_xss_guard',
|
|
2346
|
+
method: 'if',
|
|
2347
|
+
line: l,
|
|
2348
|
+
sanitizes: ['xss', 'external_taint_escape'],
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
return sanitizers;
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* Detect Python `defusedxml` import-alias usage as xxe sanitizer
|
|
2356
|
+
* (cognium-dev #216 Sprint 74 Pattern B).
|
|
2357
|
+
*
|
|
2358
|
+
* defusedxml is the canonical Python defense against XML External
|
|
2359
|
+
* Entity (CWE-611) attacks — its parsers reject DTD and entity
|
|
2360
|
+
* expansion by design. Recognize three import shapes:
|
|
2361
|
+
*
|
|
2362
|
+
* import defusedxml.ElementTree as ET
|
|
2363
|
+
* from defusedxml.ElementTree import fromstring
|
|
2364
|
+
* import defusedxml as DX
|
|
2365
|
+
*
|
|
2366
|
+
* For every line that calls `<alias>.<method>(...)` (module-alias
|
|
2367
|
+
* shape) or bare `<name>(...)` (from-import shape) emit a sanitizer
|
|
2368
|
+
* with `sanitizes: ['xxe', 'external_taint_escape']`.
|
|
2369
|
+
*/
|
|
2370
|
+
function findPythonDefusedXmlSanitizers(code) {
|
|
2371
|
+
const sanitizers = [];
|
|
2372
|
+
const lines = code.split('\n');
|
|
2373
|
+
// Aliases bound to a defusedxml.* module (or defusedxml itself).
|
|
2374
|
+
const moduleAliases = new Set();
|
|
2375
|
+
// Names imported FROM defusedxml.*.
|
|
2376
|
+
const fromNames = new Set();
|
|
2377
|
+
const importAs = /^\s*import\s+defusedxml(?:\.[A-Za-z_][A-Za-z0-9_.]*)?\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/;
|
|
2378
|
+
const importBare = /^\s*import\s+defusedxml(?:\.([A-Za-z_][A-Za-z0-9_.]*))?\s*$/;
|
|
2379
|
+
const fromImport = /^\s*from\s+defusedxml(?:\.[A-Za-z_][A-Za-z0-9_.]*)?\s+import\s+(.+)$/;
|
|
2380
|
+
for (const raw of lines) {
|
|
2381
|
+
const line = raw.replace(/#.*$/, '');
|
|
2382
|
+
let m;
|
|
2383
|
+
if ((m = importAs.exec(line))) {
|
|
2384
|
+
moduleAliases.add(m[1]);
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
if ((m = importBare.exec(line))) {
|
|
2388
|
+
// `import defusedxml.ElementTree` → callers use full
|
|
2389
|
+
// `defusedxml.ElementTree.fromstring`. Bind the leaf segment
|
|
2390
|
+
// when present, else `defusedxml`.
|
|
2391
|
+
if (m[1]) {
|
|
2392
|
+
moduleAliases.add(m[1].split('.').pop());
|
|
2393
|
+
}
|
|
2394
|
+
moduleAliases.add('defusedxml');
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
if ((m = fromImport.exec(line))) {
|
|
2398
|
+
const items = m[1].split(',');
|
|
2399
|
+
for (const item of items) {
|
|
2400
|
+
const trimmed = item.trim().replace(/[()\\]/g, '');
|
|
2401
|
+
if (!trimmed)
|
|
2402
|
+
continue;
|
|
2403
|
+
const asMatch = /^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
|
|
2404
|
+
if (asMatch) {
|
|
2405
|
+
fromNames.add(asMatch[2]);
|
|
2406
|
+
}
|
|
2407
|
+
else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
|
|
2408
|
+
fromNames.add(trimmed);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
if (moduleAliases.size === 0 && fromNames.size === 0)
|
|
2414
|
+
return sanitizers;
|
|
2415
|
+
// Emit per-line sanitizers at each call site.
|
|
2416
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2417
|
+
const line = lines[i];
|
|
2418
|
+
// Skip import lines themselves.
|
|
2419
|
+
if (/^\s*(?:import|from)\s+/.test(line))
|
|
2420
|
+
continue;
|
|
2421
|
+
let matched = false;
|
|
2422
|
+
for (const alias of moduleAliases) {
|
|
2423
|
+
const re = new RegExp(`\\b${alias}\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*\\s*\\(`);
|
|
2424
|
+
if (re.test(line)) {
|
|
2425
|
+
matched = true;
|
|
2426
|
+
break;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
if (!matched) {
|
|
2430
|
+
for (const name of fromNames) {
|
|
2431
|
+
const re = new RegExp(`\\b${name}\\s*\\(`);
|
|
2432
|
+
if (re.test(line)) {
|
|
2433
|
+
matched = true;
|
|
2434
|
+
break;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
if (!matched)
|
|
2439
|
+
continue;
|
|
2440
|
+
sanitizers.push({
|
|
2441
|
+
type: 'python_defusedxml_import',
|
|
2442
|
+
method: 'defusedxml',
|
|
2443
|
+
line: i + 1,
|
|
2444
|
+
sanitizes: ['xxe', 'external_taint_escape'],
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
return sanitizers;
|
|
2448
|
+
}
|
|
2108
2449
|
/**
|
|
2109
2450
|
* Detect Rust HashSet/HashMap host allow-list guards (cognium-dev #115 FP-23).
|
|
2110
2451
|
*
|
|
@@ -2425,6 +2766,168 @@ function findJsWrapperFunctionSanitizers(code) {
|
|
|
2425
2766
|
}
|
|
2426
2767
|
return sanitizers;
|
|
2427
2768
|
}
|
|
2769
|
+
/**
|
|
2770
|
+
* JS/TS: SSRF allow-list guard sanitizer (cognium-dev #216 Sprint 75
|
|
2771
|
+
* Pattern D).
|
|
2772
|
+
*
|
|
2773
|
+
* Pattern recognized:
|
|
2774
|
+
*
|
|
2775
|
+
* const ALLOWED = new Set(['api.example.com', 'cdn.example.com']);
|
|
2776
|
+
* const url = new URL(req.query.target);
|
|
2777
|
+
* if (!ALLOWED.has(url.hostname)) {
|
|
2778
|
+
* return res.status(400).send('blocked');
|
|
2779
|
+
* }
|
|
2780
|
+
* fetch(url); // safe — host is one of N fixed strings
|
|
2781
|
+
*
|
|
2782
|
+
* Or the bare-host shape:
|
|
2783
|
+
*
|
|
2784
|
+
* const ALLOWED_HOSTS = ['api.example.com'];
|
|
2785
|
+
* const host = req.query.host;
|
|
2786
|
+
* if (!ALLOWED_HOSTS.includes(host)) {
|
|
2787
|
+
* return res.status(400).end();
|
|
2788
|
+
* }
|
|
2789
|
+
* fetch(`https://${host}/data`);
|
|
2790
|
+
*
|
|
2791
|
+
* Set-membership against a fixed allow-list proves the value is byte-
|
|
2792
|
+
* identical to one of N developer-chosen literals. The detector is
|
|
2793
|
+
* **variable-aware**: it captures the guarded variable name AND any
|
|
2794
|
+
* upstream aliases (`new URL(<v>).hostname` / `.host`) and only emits
|
|
2795
|
+
* sanitizers on subsequent lines in the same block that reference one
|
|
2796
|
+
* of those names. This prevents over-suppressing an unrelated unguarded
|
|
2797
|
+
* variable later in the same handler (Sprint 74 TP-2 lesson).
|
|
2798
|
+
*
|
|
2799
|
+
* Existing `sink-filter-pass.ts:775-812` Stage 8 guard handles
|
|
2800
|
+
* `open_redirect` / `crlf` at the sink line; this detector expands the
|
|
2801
|
+
* mechanism to `ssrf` via the language-sources sanitizer path.
|
|
2802
|
+
*
|
|
2803
|
+
* Conservative shape:
|
|
2804
|
+
* - guard call must be `<ALLOW>.has(<v>)` or `<ALLOW>.includes(<v>)`
|
|
2805
|
+
* or `<ALLOW>.indexOf(<v>) < 0` (negation `!` or `< 0` required)
|
|
2806
|
+
* - <ALLOW> identifier matches UPPER_SNAKE or allowed|whitelist|...
|
|
2807
|
+
* - body must contain a terminator (return / throw / res.status...end)
|
|
2808
|
+
* - String#includes substring check (`host.includes('example.com')`)
|
|
2809
|
+
* does NOT qualify — TP-2 control proves this.
|
|
2810
|
+
*/
|
|
2811
|
+
function findJsSsrfAllowlistGuardSanitizers(code) {
|
|
2812
|
+
const sanitizers = [];
|
|
2813
|
+
const lines = code.split('\n');
|
|
2814
|
+
// Build alias map: `const <v> = new URL(<src>).hostname` (or .host).
|
|
2815
|
+
// Maps alias name → source identifier (e.g. `url` → req.query.target chain).
|
|
2816
|
+
// For the SSRF detector we only need the alias name itself so a Set is
|
|
2817
|
+
// sufficient — once an alias is allow-list-checked, the original `new URL`
|
|
2818
|
+
// expression input is also considered guarded by transitivity through it.
|
|
2819
|
+
const urlAliasToHostAlias = new Map();
|
|
2820
|
+
const urlAliasDecl = /\b(?:const|let|var)\s+([A-Za-z_]\w*)\s*=\s*new\s+URL\s*\(\s*([A-Za-z_][\w.[\]'"`]*)\s*\)/;
|
|
2821
|
+
const hostAliasDecl = /\b(?:const|let|var)\s+([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*)\.(?:hostname|host)\b/;
|
|
2822
|
+
// alias → upstream url-alias
|
|
2823
|
+
const hostFromUrl = new Map();
|
|
2824
|
+
for (const line of lines) {
|
|
2825
|
+
const ua = urlAliasDecl.exec(line);
|
|
2826
|
+
if (ua)
|
|
2827
|
+
urlAliasToHostAlias.set(ua[1], ua[2]);
|
|
2828
|
+
const ha = hostAliasDecl.exec(line);
|
|
2829
|
+
if (ha)
|
|
2830
|
+
hostFromUrl.set(ha[1], ha[2]);
|
|
2831
|
+
}
|
|
2832
|
+
// `ALLOW.has(<v>)`, `ALLOW.includes(<v>)`, `ALLOW.indexOf(<v>)`
|
|
2833
|
+
// (with leading `!` for has/includes, or `< 0` for indexOf).
|
|
2834
|
+
const allowlistName = /^(?:[A-Z][A-Z0-9_]+|.*?(allowed|accepted|whitelist|permitted|valid|approved).*)$/i;
|
|
2835
|
+
const guardHas = /if\s*\(\s*!\s*([A-Za-z_]\w*)\s*\.\s*(?:has|includes)\s*\(\s*([A-Za-z_]\w*)(?:\s*\.\s*(?:hostname|host))?\s*\)\s*\)/;
|
|
2836
|
+
const guardIndexOf = /if\s*\(\s*([A-Za-z_]\w*)\s*\.\s*indexOf\s*\(\s*([A-Za-z_]\w*)(?:\s*\.\s*(?:hostname|host))?\s*\)\s*<\s*0\s*\)/;
|
|
2837
|
+
const terminator = /\b(return|throw|res\s*\.\s*status\s*\([^)]*\)\s*\.\s*(?:send|end|json)\s*\()/;
|
|
2838
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2839
|
+
const line = lines[i];
|
|
2840
|
+
let m = guardHas.exec(line);
|
|
2841
|
+
let allow = null;
|
|
2842
|
+
let guardedVar = null;
|
|
2843
|
+
if (m) {
|
|
2844
|
+
allow = m[1];
|
|
2845
|
+
guardedVar = m[2];
|
|
2846
|
+
}
|
|
2847
|
+
else {
|
|
2848
|
+
m = guardIndexOf.exec(line);
|
|
2849
|
+
if (m) {
|
|
2850
|
+
allow = m[1];
|
|
2851
|
+
guardedVar = m[2];
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
if (!allow || !guardedVar)
|
|
2855
|
+
continue;
|
|
2856
|
+
if (!allowlistName.test(allow))
|
|
2857
|
+
continue;
|
|
2858
|
+
// Find the guard block opening brace and matching close.
|
|
2859
|
+
// The brace may be on the same line (`if (...) {`) or the next.
|
|
2860
|
+
// We scan forward for a body terminator within ~25 lines.
|
|
2861
|
+
let bodyHasTerminator = false;
|
|
2862
|
+
let blockEnd = -1;
|
|
2863
|
+
const maxScan = Math.min(lines.length, i + 26);
|
|
2864
|
+
// Naive brace tracking: count `{` and `}` starting at the guard line.
|
|
2865
|
+
let braceDepth = 0;
|
|
2866
|
+
let started = false;
|
|
2867
|
+
for (let j = i; j < maxScan; j++) {
|
|
2868
|
+
const ln = lines[j];
|
|
2869
|
+
for (const ch of ln) {
|
|
2870
|
+
if (ch === '{') {
|
|
2871
|
+
braceDepth++;
|
|
2872
|
+
started = true;
|
|
2873
|
+
}
|
|
2874
|
+
else if (ch === '}') {
|
|
2875
|
+
braceDepth--;
|
|
2876
|
+
if (started && braceDepth === 0) {
|
|
2877
|
+
blockEnd = j;
|
|
2878
|
+
break;
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
if (started && j > i && terminator.test(ln))
|
|
2883
|
+
bodyHasTerminator = true;
|
|
2884
|
+
if (blockEnd !== -1)
|
|
2885
|
+
break;
|
|
2886
|
+
}
|
|
2887
|
+
if (!started)
|
|
2888
|
+
continue;
|
|
2889
|
+
if (blockEnd === -1)
|
|
2890
|
+
continue;
|
|
2891
|
+
if (!bodyHasTerminator)
|
|
2892
|
+
continue;
|
|
2893
|
+
// Build set of names whose subsequent references are sanitized.
|
|
2894
|
+
// Start with guardedVar. When the inline guard reads `.hostname` /
|
|
2895
|
+
// `.host` of a known `new URL(<src>)` alias, guardedVar IS the url
|
|
2896
|
+
// alias itself (captured by `([A-Za-z_]\w*)` before `(?:\.hostname)?`),
|
|
2897
|
+
// so `fetch(url)` later naturally matches.
|
|
2898
|
+
const safeNames = new Set([guardedVar]);
|
|
2899
|
+
// If guardedVar is itself a host-alias declared as `const h = u.hostname`,
|
|
2900
|
+
// the upstream url alias is also safe.
|
|
2901
|
+
if (hostFromUrl.has(guardedVar)) {
|
|
2902
|
+
safeNames.add(hostFromUrl.get(guardedVar));
|
|
2903
|
+
}
|
|
2904
|
+
// If guardedVar IS a url alias, any host-alias derived from it is safe.
|
|
2905
|
+
for (const [hostName, urlName] of hostFromUrl) {
|
|
2906
|
+
if (urlName === guardedVar)
|
|
2907
|
+
safeNames.add(hostName);
|
|
2908
|
+
}
|
|
2909
|
+
// Silence unused-var lint for the upstream-url map: it is intentionally
|
|
2910
|
+
// built but not directly consulted in the current emission path
|
|
2911
|
+
// (guardedVar covers the url alias because the guard regex matches the
|
|
2912
|
+
// url identifier itself when `.hostname` is the inline accessor).
|
|
2913
|
+
void urlAliasToHostAlias;
|
|
2914
|
+
const refRes = [...safeNames].map((n) => new RegExp(`\\b${n}\\b`));
|
|
2915
|
+
// Var-aware emission: lines after the guard block referencing one of
|
|
2916
|
+
// the safe names get a sanitizer.
|
|
2917
|
+
for (let l = blockEnd + 2; l <= lines.length; l++) {
|
|
2918
|
+
const lineText = lines[l - 1];
|
|
2919
|
+
if (!refRes.some((re) => re.test(lineText)))
|
|
2920
|
+
continue;
|
|
2921
|
+
sanitizers.push({
|
|
2922
|
+
type: 'js_ssrf_allowlist_guard',
|
|
2923
|
+
method: 'if',
|
|
2924
|
+
line: l,
|
|
2925
|
+
sanitizes: ['ssrf', 'external_taint_escape'],
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
return sanitizers;
|
|
2930
|
+
}
|
|
2428
2931
|
// ---------------------------------------------------------------------------
|
|
2429
2932
|
// Sprint 70 (#151) — external-secret-exfiltration composed-flow detection.
|
|
2430
2933
|
//
|