circle-ir 3.123.0 → 3.125.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;CA+K7C;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+zBD,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;CAsM7C;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;AAq0CD,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.
@@ -230,6 +236,21 @@ export class LanguageSourcesPass {
230
236
  ctx.addFinding(finding);
231
237
  }
232
238
  }
239
+ // -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
240
+ // sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
241
+ // '-prefix) plus wrapper-function recognition for xss / log_injection.
242
+ if (language === 'javascript' || language === 'typescript' || language === 'htmljs') {
243
+ additionalSanitizers.push(...findJsSafeJsonParseSanitizers(code));
244
+ additionalSanitizers.push(...findJsCryptoHashSanitizers(code));
245
+ additionalSanitizers.push(...findJsCsvFormulaPrefixSanitizers(code));
246
+ additionalSanitizers.push(...findJsWrapperFunctionSanitizers(code));
247
+ }
248
+ // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
249
+ // fromJson recognized as ETE terminator (does not affect
250
+ // configured `deserialization` sinks).
251
+ if (language === 'java') {
252
+ additionalSanitizers.push(...findJavaSafeJsonParseSanitizers(code));
253
+ }
233
254
  // Sprint 70 (#151): cross-language env-secret → external-network exfiltration.
234
255
  // Pattern findings only — no taint flow required (composed-flow shape that
235
256
  // the engine misses because the env source and the egress sink are
@@ -2090,6 +2111,339 @@ function findPythonRangeCheckGuardSanitizers(code) {
2090
2111
  }
2091
2112
  return sanitizers;
2092
2113
  }
2114
+ /**
2115
+ * Detect Python regex-fullmatch allow-list wrapper functions
2116
+ * (cognium-dev #216 Sprint 74 Pattern B).
2117
+ *
2118
+ * Pattern recognized:
2119
+ *
2120
+ * def checked_uid(uid):
2121
+ * if not re.fullmatch(r"[a-zA-Z0-9_-]+", uid):
2122
+ * abort(400)
2123
+ * return uid
2124
+ *
2125
+ * Two-pass: (1) scan for `def NAME(arg):` declarations whose body
2126
+ * contains `if not re.fullmatch(<TIGHT_REGEX>, arg): <terminator>`
2127
+ * and returns `arg`; (2) for every line containing `NAME(...)` outside
2128
+ * the declaration, emit a sanitizer.
2129
+ *
2130
+ * "Tight" regex means a character-class allow-list that admits only
2131
+ * alphanumerics and a small fixed set of safe separators
2132
+ * ([A-Za-z0-9], \w, plus optional `-`, `_`, `.`). Such an allow-list
2133
+ * strips every injection metacharacter, so the sanitizer lists
2134
+ * ldap/xpath/sql/command/path-traversal/xss + ETE.
2135
+ *
2136
+ * Loose patterns like `.*` or `.+` are rejected (TP-controls cover
2137
+ * the bypass case where the wrapper return value is not what reaches
2138
+ * the sink).
2139
+ */
2140
+ function findPythonRegexAllowlistWrapperSanitizers(code) {
2141
+ const sanitizers = [];
2142
+ const lines = code.split('\n');
2143
+ // Match `def NAME(arg):` — single argument, no default.
2144
+ const defOpen = /^(\s*)def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*:\s*$/;
2145
+ // Tight char-class allow-list: starts with `[` followed by safe ranges
2146
+ // and an explicit `+` / `*` / `{n,m}` quantifier; or `\w+`-style.
2147
+ const tightRegex = /r?["'](?:\^)?(?:\\w[+*]|\\d[+*]|\[[A-Za-z0-9_\\\-\.\s]+\][+*]|[A-Za-z0-9_\-\.]+)(?:\$)?["']/;
2148
+ const terminator = /\b(return\s+(?:None|"|'|\(|\[|\{|False|jsonify|make_response|redirect|abort)|raise\s+\w|abort\s*\(|sys\.exit\s*\()/;
2149
+ const wrappers = [];
2150
+ for (let i = 0; i < lines.length; i++) {
2151
+ const m = defOpen.exec(lines[i]);
2152
+ if (!m)
2153
+ continue;
2154
+ const defIndent = m[1].length;
2155
+ const wrapperName = m[2];
2156
+ const argName = m[3];
2157
+ // Walk the indented body of the def. Look for
2158
+ // `if not re.fullmatch(<tight>, <arg>): <terminator>` and `return arg`.
2159
+ let foundGuard = false;
2160
+ let returnsArg = false;
2161
+ let blockEnd = -1;
2162
+ const maxScan = Math.min(lines.length, i + 40);
2163
+ for (let j = i + 1; j < maxScan; j++) {
2164
+ const line = lines[j];
2165
+ if (line.trim() === '')
2166
+ continue;
2167
+ const indent = line.length - line.trimStart().length;
2168
+ if (indent <= defIndent) {
2169
+ blockEnd = j - 1;
2170
+ break;
2171
+ }
2172
+ if (!foundGuard) {
2173
+ const guard = new RegExp(`if\\s+not\\s+re\\.(?:fullmatch|match)\\s*\\(\\s*(${tightRegex.source})\\s*,\\s*${argName}\\s*\\)\\s*:`);
2174
+ if (guard.test(line)) {
2175
+ // Confirm the next deeper-indented line is a terminator.
2176
+ for (let k = j + 1; k < maxScan; k++) {
2177
+ const klin = lines[k];
2178
+ if (klin.trim() === '')
2179
+ continue;
2180
+ const kindent = klin.length - klin.trimStart().length;
2181
+ if (kindent <= indent)
2182
+ break;
2183
+ if (terminator.test(klin))
2184
+ foundGuard = true;
2185
+ break;
2186
+ }
2187
+ }
2188
+ }
2189
+ if (new RegExp(`^\\s*return\\s+${argName}\\s*$`).test(line)) {
2190
+ returnsArg = true;
2191
+ }
2192
+ }
2193
+ if (blockEnd === -1)
2194
+ blockEnd = Math.min(lines.length - 1, i + 39);
2195
+ if (!foundGuard || !returnsArg)
2196
+ continue;
2197
+ wrappers.push({ name: wrapperName, defLine: i, defIndent });
2198
+ }
2199
+ if (wrappers.length === 0)
2200
+ return sanitizers;
2201
+ // The kinds a tight-regex wrapper sanitizes.
2202
+ const wrapperKinds = [
2203
+ 'ldap_injection',
2204
+ 'xpath_injection',
2205
+ 'sql_injection',
2206
+ 'command_injection',
2207
+ 'path_traversal',
2208
+ 'xss',
2209
+ 'external_taint_escape',
2210
+ ];
2211
+ // Pass 2: var-aware emission. For each call of the form
2212
+ // `<VALIDATED> = NAME(...)`
2213
+ // mark every subsequent line in the same function block that
2214
+ // references `<VALIDATED>` as a sanitizer for the wrapper kinds.
2215
+ // This avoids over-suppressing unrelated tainted vars introduced
2216
+ // later in the same function.
2217
+ for (const w of wrappers) {
2218
+ const assignedCallRe = new RegExp(`^(\\s*)([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${w.name}\\s*\\(`);
2219
+ const bareCallRe = new RegExp(`\\b${w.name}\\s*\\(`);
2220
+ for (let i = 0; i < lines.length; i++) {
2221
+ if (i === w.defLine)
2222
+ continue;
2223
+ const line = lines[i];
2224
+ if (!bareCallRe.test(line))
2225
+ continue;
2226
+ // Skip calls inside the wrapper's own body.
2227
+ if (i > w.defLine) {
2228
+ const lineIndent = line.length - line.trimStart().length;
2229
+ if (lineIndent > w.defIndent) {
2230
+ let stillInside = true;
2231
+ for (let k = w.defLine + 1; k < i; k++) {
2232
+ const kline = lines[k];
2233
+ if (kline.trim() === '')
2234
+ continue;
2235
+ const kindent = kline.length - kline.trimStart().length;
2236
+ if (kindent <= w.defIndent) {
2237
+ stillInside = false;
2238
+ break;
2239
+ }
2240
+ }
2241
+ if (stillInside)
2242
+ continue;
2243
+ }
2244
+ }
2245
+ // Emit a sanitizer at the call line itself (covers ETE on a
2246
+ // single-line `NAME(req.args.get(...))` shape).
2247
+ sanitizers.push({
2248
+ type: 'python_regex_allowlist_wrapper',
2249
+ method: w.name,
2250
+ line: i + 1,
2251
+ sanitizes: [...wrapperKinds],
2252
+ });
2253
+ // If the call result is assigned to a variable, track it and
2254
+ // emit sanitizers on every later line in the same function
2255
+ // block that mentions that variable name. End the scan when
2256
+ // we leave the call's enclosing block.
2257
+ const am = assignedCallRe.exec(line);
2258
+ if (!am)
2259
+ continue;
2260
+ const callIndent = am[1].length;
2261
+ const validated = am[2];
2262
+ const validatedRe = new RegExp(`\\b${validated}\\b`);
2263
+ for (let j = i + 1; j < lines.length; j++) {
2264
+ const jline = lines[j];
2265
+ if (jline.trim() === '')
2266
+ continue;
2267
+ const jindent = jline.length - jline.trimStart().length;
2268
+ if (jindent < callIndent)
2269
+ break;
2270
+ if (validatedRe.test(jline)) {
2271
+ sanitizers.push({
2272
+ type: 'python_regex_allowlist_wrapper',
2273
+ method: w.name,
2274
+ line: j + 1,
2275
+ sanitizes: [...wrapperKinds],
2276
+ });
2277
+ }
2278
+ }
2279
+ }
2280
+ }
2281
+ return sanitizers;
2282
+ }
2283
+ /**
2284
+ * Detect Python set-membership allow-list guards that should sanitize
2285
+ * `xss` flows (cognium-dev #216 Sprint 74 Pattern B, SSTI fixture).
2286
+ *
2287
+ * Pattern recognized:
2288
+ *
2289
+ * t = request.args.get("t", "")
2290
+ * if t not in ALLOWED:
2291
+ * abort(403)
2292
+ * env.from_string("{{ " + t + " }}").render()
2293
+ *
2294
+ * The existing `findPythonNetlocAllowlistGuardSanitizers` already
2295
+ * handles the source pattern but cannot list `xss` as a blanket
2296
+ * sanitizer (over-suppression risk: a different unguarded variable
2297
+ * later in the same handler would be incorrectly cleared). This
2298
+ * detector is variable-aware: it only emits an `xss` sanitizer on
2299
+ * lines that actually reference the guarded variable.
2300
+ */
2301
+ function findPythonSetMembershipXssGuardSanitizers(code) {
2302
+ const sanitizers = [];
2303
+ const lines = code.split('\n');
2304
+ // `if <ident> not in <ALLOWLIST>:`
2305
+ 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*$/;
2306
+ const allowlistName = /^(?:[A-Z][A-Z0-9_]+|.*?(allowed|accepted|whitelist|permitted|valid|approved).*)$/i;
2307
+ const terminator = /\b(return|raise|abort\s*\(|sys\.exit\s*\()/;
2308
+ for (let i = 0; i < lines.length; i++) {
2309
+ const m = guardOpen.exec(lines[i]);
2310
+ if (!m)
2311
+ continue;
2312
+ const guardIndent = m[1].length;
2313
+ const guardedVar = m[2];
2314
+ const allowName = m[3];
2315
+ if (!allowlistName.test(allowName))
2316
+ continue;
2317
+ let bodyHasTerminator = false;
2318
+ let blockEnd = -1;
2319
+ const maxScan = Math.min(lines.length, i + 26);
2320
+ for (let j = i + 1; j < maxScan; j++) {
2321
+ const line = lines[j];
2322
+ if (line.trim() === '')
2323
+ continue;
2324
+ const indent = line.length - line.trimStart().length;
2325
+ if (indent <= guardIndent) {
2326
+ blockEnd = j - 1;
2327
+ break;
2328
+ }
2329
+ if (terminator.test(line))
2330
+ bodyHasTerminator = true;
2331
+ }
2332
+ if (blockEnd === -1)
2333
+ blockEnd = Math.min(lines.length - 1, i + 25);
2334
+ if (!bodyHasTerminator)
2335
+ continue;
2336
+ // Var-aware emission: only lines that reference the guarded var.
2337
+ const varRe = new RegExp(`\\b${guardedVar}\\b`);
2338
+ for (let l = blockEnd + 2; l <= lines.length; l++) {
2339
+ const lineText = lines[l - 1];
2340
+ if (!varRe.test(lineText))
2341
+ continue;
2342
+ sanitizers.push({
2343
+ type: 'python_set_membership_xss_guard',
2344
+ method: 'if',
2345
+ line: l,
2346
+ sanitizes: ['xss', 'external_taint_escape'],
2347
+ });
2348
+ }
2349
+ }
2350
+ return sanitizers;
2351
+ }
2352
+ /**
2353
+ * Detect Python `defusedxml` import-alias usage as xxe sanitizer
2354
+ * (cognium-dev #216 Sprint 74 Pattern B).
2355
+ *
2356
+ * defusedxml is the canonical Python defense against XML External
2357
+ * Entity (CWE-611) attacks — its parsers reject DTD and entity
2358
+ * expansion by design. Recognize three import shapes:
2359
+ *
2360
+ * import defusedxml.ElementTree as ET
2361
+ * from defusedxml.ElementTree import fromstring
2362
+ * import defusedxml as DX
2363
+ *
2364
+ * For every line that calls `<alias>.<method>(...)` (module-alias
2365
+ * shape) or bare `<name>(...)` (from-import shape) emit a sanitizer
2366
+ * with `sanitizes: ['xxe', 'external_taint_escape']`.
2367
+ */
2368
+ function findPythonDefusedXmlSanitizers(code) {
2369
+ const sanitizers = [];
2370
+ const lines = code.split('\n');
2371
+ // Aliases bound to a defusedxml.* module (or defusedxml itself).
2372
+ const moduleAliases = new Set();
2373
+ // Names imported FROM defusedxml.*.
2374
+ const fromNames = new Set();
2375
+ const importAs = /^\s*import\s+defusedxml(?:\.[A-Za-z_][A-Za-z0-9_.]*)?\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/;
2376
+ const importBare = /^\s*import\s+defusedxml(?:\.([A-Za-z_][A-Za-z0-9_.]*))?\s*$/;
2377
+ const fromImport = /^\s*from\s+defusedxml(?:\.[A-Za-z_][A-Za-z0-9_.]*)?\s+import\s+(.+)$/;
2378
+ for (const raw of lines) {
2379
+ const line = raw.replace(/#.*$/, '');
2380
+ let m;
2381
+ if ((m = importAs.exec(line))) {
2382
+ moduleAliases.add(m[1]);
2383
+ continue;
2384
+ }
2385
+ if ((m = importBare.exec(line))) {
2386
+ // `import defusedxml.ElementTree` → callers use full
2387
+ // `defusedxml.ElementTree.fromstring`. Bind the leaf segment
2388
+ // when present, else `defusedxml`.
2389
+ if (m[1]) {
2390
+ moduleAliases.add(m[1].split('.').pop());
2391
+ }
2392
+ moduleAliases.add('defusedxml');
2393
+ continue;
2394
+ }
2395
+ if ((m = fromImport.exec(line))) {
2396
+ const items = m[1].split(',');
2397
+ for (const item of items) {
2398
+ const trimmed = item.trim().replace(/[()\\]/g, '');
2399
+ if (!trimmed)
2400
+ continue;
2401
+ const asMatch = /^([A-Za-z_][A-Za-z0-9_]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
2402
+ if (asMatch) {
2403
+ fromNames.add(asMatch[2]);
2404
+ }
2405
+ else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
2406
+ fromNames.add(trimmed);
2407
+ }
2408
+ }
2409
+ }
2410
+ }
2411
+ if (moduleAliases.size === 0 && fromNames.size === 0)
2412
+ return sanitizers;
2413
+ // Emit per-line sanitizers at each call site.
2414
+ for (let i = 0; i < lines.length; i++) {
2415
+ const line = lines[i];
2416
+ // Skip import lines themselves.
2417
+ if (/^\s*(?:import|from)\s+/.test(line))
2418
+ continue;
2419
+ let matched = false;
2420
+ for (const alias of moduleAliases) {
2421
+ const re = new RegExp(`\\b${alias}\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*\\s*\\(`);
2422
+ if (re.test(line)) {
2423
+ matched = true;
2424
+ break;
2425
+ }
2426
+ }
2427
+ if (!matched) {
2428
+ for (const name of fromNames) {
2429
+ const re = new RegExp(`\\b${name}\\s*\\(`);
2430
+ if (re.test(line)) {
2431
+ matched = true;
2432
+ break;
2433
+ }
2434
+ }
2435
+ }
2436
+ if (!matched)
2437
+ continue;
2438
+ sanitizers.push({
2439
+ type: 'python_defusedxml_import',
2440
+ method: 'defusedxml',
2441
+ line: i + 1,
2442
+ sanitizes: ['xxe', 'external_taint_escape'],
2443
+ });
2444
+ }
2445
+ return sanitizers;
2446
+ }
2093
2447
  /**
2094
2448
  * Detect Rust HashSet/HashMap host allow-list guards (cognium-dev #115 FP-23).
2095
2449
  *
@@ -2238,6 +2592,179 @@ function findRustCanonicalizeGuardSanitizers(code) {
2238
2592
  return sanitizers;
2239
2593
  }
2240
2594
  // ---------------------------------------------------------------------------
2595
+ // Sprint 73 (#216 Pattern A + B) — JS/Java safe-handler sanitizer
2596
+ // recognition for the synthetic `external_taint_escape` (CWE-668) fallback
2597
+ // flow, plus JS user-defined wrapper-function recognition for `xss` /
2598
+ // `log_injection`.
2599
+ //
2600
+ // Suppression mechanism: `taint-propagation-pass.ts:198-232` already
2601
+ // filters ETE flows when ANY sanitizer between source and sink lists
2602
+ // `external_taint_escape` in its `sanitizes` array. These detectors emit
2603
+ // the sanitizer entries so the existing filter can do its job.
2604
+ //
2605
+ // Conservative scoping: each detector restricts `sanitizes` to the
2606
+ // minimum justifiable set so configured (real) sinks are unaffected.
2607
+ // ---------------------------------------------------------------------------
2608
+ /**
2609
+ * Java: Jackson `mapper.readValue(...)` and Gson `gson.fromJson(...)`
2610
+ * construct typed POJOs from JSON. Without `enableDefaultTyping` they
2611
+ * cannot instantiate attacker-chosen classes, so the synthetic ETE
2612
+ * fallback over-fires. The configured `deserialization` sink remains
2613
+ * unaffected (different sink_type, gated at the sink line).
2614
+ */
2615
+ function findJavaSafeJsonParseSanitizers(code) {
2616
+ const sanitizers = [];
2617
+ const lines = code.split('\n');
2618
+ const safeParse = /\.(?:readValue|fromJson)\s*\(/;
2619
+ for (let i = 0; i < lines.length; i++) {
2620
+ if (!safeParse.test(lines[i]))
2621
+ continue;
2622
+ sanitizers.push({
2623
+ type: 'java_safe_json_parse',
2624
+ method: 'readValue',
2625
+ line: i + 1,
2626
+ sanitizes: ['external_taint_escape'],
2627
+ });
2628
+ }
2629
+ return sanitizers;
2630
+ }
2631
+ /**
2632
+ * JS: `JSON.parse(...)` produces only primitives / plain objects. It
2633
+ * cannot execute code (unlike `eval` / `Function` / `vm.runInNewContext`,
2634
+ * which remain configured sinks).
2635
+ */
2636
+ function findJsSafeJsonParseSanitizers(code) {
2637
+ const sanitizers = [];
2638
+ const lines = code.split('\n');
2639
+ const safeParse = /\bJSON\.parse\s*\(/;
2640
+ for (let i = 0; i < lines.length; i++) {
2641
+ if (!safeParse.test(lines[i]))
2642
+ continue;
2643
+ sanitizers.push({
2644
+ type: 'js_safe_json_parse',
2645
+ method: 'JSON.parse',
2646
+ line: i + 1,
2647
+ sanitizes: ['external_taint_escape'],
2648
+ });
2649
+ }
2650
+ return sanitizers;
2651
+ }
2652
+ /**
2653
+ * JS: one-way crypto hashes destroy original content. Bcrypt/argon2/
2654
+ * scrypt/createHash...digest cannot be reversed. The `weak-password-hash`
2655
+ * rule fires independently for MD5/SHA1 algorithms.
2656
+ */
2657
+ function findJsCryptoHashSanitizers(code) {
2658
+ const sanitizers = [];
2659
+ const lines = code.split('\n');
2660
+ // bcrypt.hash(...) / bcrypt.hashSync(...)
2661
+ // argon2.hash(...)
2662
+ // crypto.scrypt(...) / crypto.scryptSync(...)
2663
+ // crypto.createHash(...) / hash.digest(...)
2664
+ const hashCall = /\b(?:bcrypt|argon2)\s*\.\s*hash(?:Sync)?\s*\(|\bcrypto\s*\.\s*(?:scrypt(?:Sync)?|createHash)\s*\(|\.digest\s*\(/;
2665
+ for (let i = 0; i < lines.length; i++) {
2666
+ if (!hashCall.test(lines[i]))
2667
+ continue;
2668
+ sanitizers.push({
2669
+ type: 'js_crypto_hash',
2670
+ method: 'hash',
2671
+ line: i + 1,
2672
+ sanitizes: ['external_taint_escape'],
2673
+ });
2674
+ }
2675
+ return sanitizers;
2676
+ }
2677
+ /**
2678
+ * JS: Excel-formula-injection sanitizer. Prepending a literal single
2679
+ * quote (`'`) to a CSV cell prevents Excel/LibreOffice from interpreting
2680
+ * the value as a formula. Scoped to ETE only — the `'` prefix does NOT
2681
+ * mitigate xss, command_injection, sql_injection, etc.
2682
+ */
2683
+ function findJsCsvFormulaPrefixSanitizers(code) {
2684
+ const sanitizers = [];
2685
+ const lines = code.split('\n');
2686
+ // Template literal `'${x}...` — leading `'` inside backticks.
2687
+ const tickPrefix = /`'\$\{/;
2688
+ // Concat form "'" + x or '\'' + x
2689
+ const concatPrefix = /["']\\?'["']\s*\+\s*[A-Za-z_]/;
2690
+ for (let i = 0; i < lines.length; i++) {
2691
+ if (tickPrefix.test(lines[i]) || concatPrefix.test(lines[i])) {
2692
+ sanitizers.push({
2693
+ type: 'js_csv_formula_prefix',
2694
+ method: "'-prefix",
2695
+ line: i + 1,
2696
+ sanitizes: ['external_taint_escape'],
2697
+ });
2698
+ }
2699
+ }
2700
+ return sanitizers;
2701
+ }
2702
+ /**
2703
+ * JS: user-defined wrapper functions that perform a `.replace(...)`
2704
+ * against a recognizable threat-character class. Two-pass:
2705
+ *
2706
+ * 1. Discover wrappers — `function NAME(...) { ... .replace(/.../...) }`
2707
+ * or arrow form `const NAME = (...) => { ... .replace(...) }`.
2708
+ * Classify by the character class:
2709
+ * - `[&<>"']` → xss + external_taint_escape
2710
+ * - `[\r\n\t]` → log_injection + external_taint_escape
2711
+ * 2. For every call site `NAME(...)`, emit a sanitizer at that line
2712
+ * listing the wrapper's kinds.
2713
+ *
2714
+ * Conservative: requires explicit `.replace` against a recognizable
2715
+ * threat-set. Custom wrappers using different escape strategies (e.g.
2716
+ * URL encode) are not yet covered.
2717
+ */
2718
+ function findJsWrapperFunctionSanitizers(code) {
2719
+ const sanitizers = [];
2720
+ const lines = code.split('\n');
2721
+ const wrappers = new Map();
2722
+ const fnDeclRe = /\bfunction\s+([A-Za-z_]\w*)\s*\(/;
2723
+ const arrowDeclRe = /\b(?:const|let|var)\s+([A-Za-z_]\w*)\s*=\s*(?:\([^)]*\)|[A-Za-z_]\w*)\s*=>/;
2724
+ // Threat-char classes — must appear inside a .replace(/.../, ...) call.
2725
+ const xssCharClass = /\.replace\s*\(\s*\/[^/]*[&<>"'][^/]*\//;
2726
+ const crlfCharClass = /\.replace\s*\(\s*\/[^/]*(?:\\r|\\n|\\t)[^/]*\//;
2727
+ for (let i = 0; i < lines.length; i++) {
2728
+ const m = fnDeclRe.exec(lines[i]) ?? arrowDeclRe.exec(lines[i]);
2729
+ if (!m)
2730
+ continue;
2731
+ const name = m[1];
2732
+ // Scan up to 8 lines for the body's .replace(...) call.
2733
+ const body = lines.slice(i, Math.min(lines.length, i + 8)).join('\n');
2734
+ const kinds = new Set();
2735
+ if (xssCharClass.test(body)) {
2736
+ kinds.add('xss');
2737
+ kinds.add('external_taint_escape');
2738
+ }
2739
+ if (crlfCharClass.test(body)) {
2740
+ kinds.add('log_injection');
2741
+ kinds.add('external_taint_escape');
2742
+ }
2743
+ if (kinds.size > 0)
2744
+ wrappers.set(name, kinds);
2745
+ }
2746
+ if (wrappers.size === 0)
2747
+ return sanitizers;
2748
+ for (let i = 0; i < lines.length; i++) {
2749
+ for (const [name, kinds] of wrappers) {
2750
+ // Skip the declaration line itself.
2751
+ const declSelf = new RegExp(`\\b(?:function|const|let|var)\\s+${name}\\b`);
2752
+ if (declSelf.test(lines[i]))
2753
+ continue;
2754
+ const callRe = new RegExp(`\\b${name}\\s*\\(`);
2755
+ if (!callRe.test(lines[i]))
2756
+ continue;
2757
+ sanitizers.push({
2758
+ type: 'js_wrapper_function',
2759
+ method: name,
2760
+ line: i + 1,
2761
+ sanitizes: [...kinds],
2762
+ });
2763
+ }
2764
+ }
2765
+ return sanitizers;
2766
+ }
2767
+ // ---------------------------------------------------------------------------
2241
2768
  // Sprint 70 (#151) — external-secret-exfiltration composed-flow detection.
2242
2769
  //
2243
2770
  // Models the trust-corpus FN-TQ-01 shape: an env-read secret variable that