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;CAgM7C;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;AA++BD,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"}
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
  //