circle-ir 3.108.0 → 3.111.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":"sink-filter-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/sink-filter-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AACzG,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAye9E,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,sBAAsB;IACtB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,qBAAa,cAAe,YAAW,YAAY,CAAC,gBAAgB,CAAC;IACnE,QAAQ,CAAC,IAAI,iBAAiB;IAC9B,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,gBAAgB;CA8pBxC;AAMD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,KAAK,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AA4I1G,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,EACxB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EACxB,OAAO,EAAE,OAAO,EAChB,GAAG,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC,EACrB,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC3B,iBAAiB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,CAAC,EAAE,MAAM,GAChB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA6E5B;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,YAAY,CAAC,EAC3C,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GACvB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA2C5B"}
1
+ {"version":3,"file":"sink-filter-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/sink-filter-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AACzG,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AA6iB9E,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,sBAAsB;IACtB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,qBAAa,cAAe,YAAW,YAAY,CAAC,gBAAgB,CAAC;IACnE,QAAQ,CAAC,IAAI,iBAAiB;IAC9B,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,gBAAgB;CA8zBxC;AAkRD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,KAAK,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AA4I1G,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,EACxB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EACxB,OAAO,EAAE,OAAO,EAChB,GAAG,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC,EACrB,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC3B,iBAAiB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,CAAC,EAAE,MAAM,GAChB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA6E5B;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,YAAY,CAAC,EAC3C,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GACvB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA2C5B"}
@@ -39,6 +39,65 @@ const JS_XSS_SANITIZERS = [
39
39
  /\bbypassSecurityTrust/, // Angular
40
40
  ];
41
41
  // ---------------------------------------------------------------------------
42
+ // Stage 16 — JS log_injection (CWE-117) sanitizer patterns.
43
+ // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
44
+ // ---------------------------------------------------------------------------
45
+ //
46
+ // Common CRLF-stripping / log-sanitization helpers. Used by Stage 16 both as
47
+ // an inline check on the sink line AND as a backward-scan check via the
48
+ // `isAssignedFromSanitizerPattern` helper.
49
+ const JS_LOG_INJECTION_SANITIZERS = [
50
+ /\bstripCrlf\s*\(/,
51
+ /\bstripCRLF\s*\(/,
52
+ /\bremoveNewlines\s*\(/,
53
+ /\bsanitizeLogValue\s*\(/,
54
+ // Inline CRLF-stripping regex literal: .replace(/[\r\n]/g, '')
55
+ /\.replace\(\s*\/\[\s*\\r\s*\\n\s*\]\/[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
56
+ // Inline CRLF sequence: .replace(/\r\n/g, '')
57
+ /\.replace\(\s*\/\\r\\n\/[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
58
+ ];
59
+ // ---------------------------------------------------------------------------
60
+ // Stage 17 — Python ldap_injection (CWE-90) regex-strip wrapper recognition.
61
+ // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
62
+ // ---------------------------------------------------------------------------
63
+ //
64
+ // Built-in Python LDAP sanitizer call-site names. Augmented at scan time with
65
+ // derived wrapper functions detected via `findPythonLdapStripWrappers`.
66
+ const PY_BUILTIN_LDAP_SANITIZERS = ['escape_filter_chars', 'filter_format'];
67
+ // Python `def name(param):` followed (within a few lines) by
68
+ // `return re.sub(r"[<class>]", "", param)` where the character class contains
69
+ // at least three of the LDAP filter metacharacters from RFC 4515.
70
+ const PY_LDAP_METACHARS = ['(', ')', '=', '*', '\\'];
71
+ const PY_DEF_RE = /^\s*def\s+([A-Za-z_]\w*)\s*\(\s*([A-Za-z_]\w*)\s*\)\s*:\s*$/;
72
+ const PY_LDAP_STRIP_RETURN_RE = /^\s*return\s+re\.sub\(\s*r?["']\[([^"'\]]+)\]["']\s*,\s*r?["']["']\s*,\s*([A-Za-z_]\w*)\s*\)\s*$/;
73
+ // ---------------------------------------------------------------------------
74
+ // Stage 18 — Python xxe (CWE-611) parser-variable / wrapper recognition.
75
+ // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
76
+ // ---------------------------------------------------------------------------
77
+ //
78
+ // `XMLParser(...resolve_entities=False...)` constructor — when present in the
79
+ // 30 lines above an xxe sink (same enclosing scope), the parser is hardened
80
+ // and the sink is safe.
81
+ const PY_XML_PARSER_HARDENED_RE = /\bXMLParser\s*\([^)]*\bresolve_entities\s*=\s*False\b[^)]*\)/;
82
+ // ---------------------------------------------------------------------------
83
+ // Stage 19 — Python sql_injection regex-allowlist-quoter wrapper suppression.
84
+ // (cognium-dev #215 — Sprint 53; Python port of Java Stage 15 #191)
85
+ // ---------------------------------------------------------------------------
86
+ //
87
+ // Python f-string SQL where identifier interpolations route through an
88
+ // in-file helper that validates with `re.fullmatch(allowlist, name)`
89
+ // + `raise`, and values flow through bind placeholders (?, %s, :name),
90
+ // is a parameterized query with an identifier-interpolation wrapper.
91
+ // Helper is the sanitizer; bind placeholder proves values do not concat.
92
+ const PY_SQL_EXEC_METHODS = new Set(['execute', 'executemany']);
93
+ // Recognises Python `re.fullmatch(r"<regex>", …)` / `re.match(r"^<…>$", …)`
94
+ // — fullmatch is implicitly anchored; match requires explicit `^…$`.
95
+ const PY_INLINE_FULLMATCH_RE = /\bre\s*\.\s*fullmatch\s*\(\s*r?"((?:[^"\\]|\\.)*)"/g;
96
+ const PY_INLINE_MATCH_ANCHORED_RE = /\bre\s*\.\s*match\s*\(\s*r?"\^((?:[^"\\]|\\.)*)\$"/g;
97
+ // Bind placeholders: `?` (sqlite3/odbc), `%s` (psycopg2/mysqlclient),
98
+ // `:name` (named placeholders). All proof of bind-arg routing.
99
+ const PY_BIND_PLACEHOLDER_RE = /\?|%s|:[A-Za-z_]\w*/;
100
+ // ---------------------------------------------------------------------------
42
101
  // Stage 9 — Java code_injection (CWE-094) FP reduction allowlists.
43
102
  // (cognium-dev #155, #156, #159, #160 — Sprint 42)
44
103
  // ---------------------------------------------------------------------------
@@ -1167,9 +1226,446 @@ export class SinkFilterPass {
1167
1226
  return true;
1168
1227
  });
1169
1228
  }
1229
+ // Stage 16 — JS log_injection (CWE-117) sanitizer suppression.
1230
+ // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
1231
+ //
1232
+ // Gate (any suppresses the sink):
1233
+ // (a) sink line contains an inline call to a known CRLF-stripping
1234
+ // helper (stripCrlf, sanitizeLogValue, .replace(/[\r\n]/g, ''))
1235
+ // (b) sink line references a variable assigned within 30 lines
1236
+ // above from a sanitizer-pattern RHS
1237
+ if (['javascript', 'typescript'].includes(language)) {
1238
+ const sourceLines = ctx.code.split('\n');
1239
+ filtered = filtered.filter(sink => {
1240
+ if (sink.type !== 'log_injection')
1241
+ return true;
1242
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1243
+ if (JS_LOG_INJECTION_SANITIZERS.some(p => p.test(sinkLineText)))
1244
+ return false;
1245
+ const varNames = extractSourceIdentifiers(sinkLineText);
1246
+ for (const v of varNames) {
1247
+ if (isAssignedFromSanitizerPattern(sourceLines, sink.line, v, JS_LOG_INJECTION_SANITIZERS)) {
1248
+ return false;
1249
+ }
1250
+ }
1251
+ return true;
1252
+ });
1253
+ }
1254
+ // Stage 17 — Python ldap_injection (CWE-90) regex-strip wrapper
1255
+ // recognition. (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
1256
+ //
1257
+ // Scan file for module-level wrapper functions whose body is
1258
+ // return re.sub(r"[<class>]", "", <param>)
1259
+ // where the character class contains at least three of the LDAP filter
1260
+ // metacharacters ( ) = * \. Treat such wrappers (plus the built-in
1261
+ // `escape_filter_chars` / `filter_format`) as LDAP sanitizers.
1262
+ //
1263
+ // Gate (any suppresses the sink):
1264
+ // (a) sink line contains a direct call to a known LDAP sanitizer
1265
+ // (b) sink line references a variable assigned within 30 lines above
1266
+ // from a known LDAP sanitizer call
1267
+ if (language === 'python') {
1268
+ const sourceLines = ctx.code.split('\n');
1269
+ const wrappers = findPythonLdapStripWrappers(sourceLines);
1270
+ const allLdapSanitizers = [...PY_BUILTIN_LDAP_SANITIZERS, ...wrappers];
1271
+ if (allLdapSanitizers.length > 0) {
1272
+ const ldapPatterns = allLdapSanitizers.map(name => new RegExp(`\\b${escapeRegex(name)}\\s*\\(`));
1273
+ filtered = filtered.filter(sink => {
1274
+ if (sink.type !== 'ldap_injection')
1275
+ return true;
1276
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1277
+ if (ldapPatterns.some(p => p.test(sinkLineText)))
1278
+ return false;
1279
+ const varNames = extractSourceIdentifiers(sinkLineText);
1280
+ for (const v of varNames) {
1281
+ if (isAssignedFromSanitizerPattern(sourceLines, sink.line, v, ldapPatterns)) {
1282
+ return false;
1283
+ }
1284
+ }
1285
+ return true;
1286
+ });
1287
+ }
1288
+ }
1289
+ // Stage 18 — Python xxe (CWE-611) parser-variable / wrapper recognition.
1290
+ // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
1291
+ //
1292
+ // When an xxe sink (fromstring/parse/etc.) appears within the same
1293
+ // enclosing function as an `XMLParser(...resolve_entities=False...)`
1294
+ // constructor, the parser is hardened and the sink is safe. Covers:
1295
+ // - wrapper functions (`def safe_parse(...): parser = XMLParser(...);
1296
+ // return ET.fromstring(b, parser)`) — sink inside wrapper body
1297
+ // - class methods (`def parse_direct(self): parser = XMLParser(...);
1298
+ // return ET.fromstring(self.xml, parser)`)
1299
+ //
1300
+ // Scope safety: the backward scan stops at the first `def` line so the
1301
+ // safe parser in a sibling/preceding function never suppresses an unsafe
1302
+ // sink in a different function. Recall lock: `XMLParser(resolve_entities
1303
+ // =True)` does NOT match the hardened regex, so /wrong /fake routes
1304
+ // (which build their own unsafe parsers) still fire.
1305
+ if (language === 'python') {
1306
+ const sourceLines = ctx.code.split('\n');
1307
+ filtered = filtered.filter(sink => {
1308
+ if (sink.type !== 'xxe')
1309
+ return true;
1310
+ const lookback = 30;
1311
+ const lo = Math.max(0, sink.line - 1 - lookback);
1312
+ for (let i = sink.line - 2; i >= lo; i--) {
1313
+ const ln = sourceLines[i] ?? '';
1314
+ if (PY_XML_PARSER_HARDENED_RE.test(ln))
1315
+ return false;
1316
+ // Function boundary: stop scanning when we leave the sink's
1317
+ // enclosing def, so a hardened parser in a sibling function never
1318
+ // suppresses an unsafe sink in this function.
1319
+ if (/^\s*def\s+\w+\s*\(/.test(ln))
1320
+ break;
1321
+ }
1322
+ return true;
1323
+ });
1324
+ }
1325
+ // Stage 19 — Python sql_injection regex-allowlist-quoter wrapper
1326
+ // suppression. (cognium-dev #215 — Sprint 53; port of Java Stage 15)
1327
+ //
1328
+ // Gate (all must hold to drop the sink):
1329
+ // (a) sink method is cursor.execute / cursor.executemany
1330
+ // (b) sink first arg is an f-string (`f"…{x}…"`) with ≥1 interpolation
1331
+ // (c) every `{…}` interpolation is a literal OR a bare identifier
1332
+ // whose assignment within 30 lines above is `<helper>(<arg>)`
1333
+ // — an in-file helper call
1334
+ // (d) the f-string literal segments contain a bind placeholder
1335
+ // (`?`, `%s`, or `:name`)
1336
+ // (e) at least one such helper's body contains `re.fullmatch(<allowlist>,…)`
1337
+ // (or anchored `re.match(^…$,…)`) AND a `raise` statement
1338
+ //
1339
+ // Together (a)–(e) prove the SQL is parameterized for values with
1340
+ // identifier interpolation routed through a validated quoter.
1341
+ if (language === 'python') {
1342
+ const sourceLines = ctx.code.split('\n');
1343
+ filtered = filtered.filter(sink => {
1344
+ if (sink.type !== 'sql_injection')
1345
+ return true;
1346
+ const method = sink.method ?? '';
1347
+ if (!PY_SQL_EXEC_METHODS.has(method))
1348
+ return true;
1349
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1350
+ // Find the f-string first argument: f"…" or f'…'
1351
+ const fstrMatch = sinkLineText.match(/\bf"((?:[^"\\]|\\.)*)"|\bf'((?:[^'\\]|\\.)*)'/);
1352
+ if (!fstrMatch)
1353
+ return true;
1354
+ const fstrBody = fstrMatch[1] ?? fstrMatch[2] ?? '';
1355
+ const { literals, interps } = splitPyFstringTokens(fstrBody);
1356
+ if (interps.length === 0)
1357
+ return true;
1358
+ // Gate (d): bind placeholder in literal segments
1359
+ if (!literals.some(lit => PY_BIND_PLACEHOLDER_RE.test(lit)))
1360
+ return true;
1361
+ // Gate (c) + (e): every interpolation must be a literal or
1362
+ // resolve through an in-file helper with the regex-allowlist+raise
1363
+ // shape. At least one helper match is required.
1364
+ let sawHelperWithGuard = false;
1365
+ for (const interp of interps) {
1366
+ const t = interp.trim();
1367
+ if (t === '')
1368
+ return true;
1369
+ // Literal-shape interp: bare number / quoted string — accept.
1370
+ if (/^-?\d+$/.test(t) || /^"(?:[^"\\]|\\.)*"$/.test(t) || /^'(?:[^'\\]|\\.)*'$/.test(t)) {
1371
+ continue;
1372
+ }
1373
+ // Direct in-line helper call: `{helper(arg)}` — extract name.
1374
+ let helperName = null;
1375
+ const directCall = t.match(/^([A-Za-z_]\w*)\s*\(/);
1376
+ if (directCall) {
1377
+ helperName = directCall[1] ?? null;
1378
+ }
1379
+ else if (/^[A-Za-z_]\w*$/.test(t)) {
1380
+ // Bare identifier — scan backward for assignment from helper call.
1381
+ helperName = findPythonAssignedHelperCall(t, sink.line, sourceLines);
1382
+ if (helperName === null)
1383
+ return true;
1384
+ }
1385
+ else {
1386
+ // Any other expression shape (attribute access, arithmetic,
1387
+ // method chain) disqualifies — too permissive otherwise.
1388
+ return true;
1389
+ }
1390
+ const body = findPythonFunctionBody(helperName, sourceLines);
1391
+ if (body && pythonBodyHasInlineRegexAllowlistRaise(body)) {
1392
+ sawHelperWithGuard = true;
1393
+ }
1394
+ }
1395
+ if (!sawHelperWithGuard)
1396
+ return true;
1397
+ return false;
1398
+ });
1399
+ }
1170
1400
  return { sources, sinks: filtered, sanitizers };
1171
1401
  }
1172
1402
  }
1403
+ // ---------------------------------------------------------------------------
1404
+ // Sprint 52 helpers — shared sanitizer-assignment backward scan utilities.
1405
+ // ---------------------------------------------------------------------------
1406
+ /**
1407
+ * Escape regex metacharacters in an identifier or literal for use inside a
1408
+ * RegExp template. Identifiers from `def NAME(...)` matches are already
1409
+ * `[A-Za-z_]\w*`-restricted, but kept defensively.
1410
+ */
1411
+ function escapeRegex(s) {
1412
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1413
+ }
1414
+ /**
1415
+ * Reserved keywords across JS/TS/Python that should not be treated as
1416
+ * source-identifier candidates by `extractSourceIdentifiers`.
1417
+ */
1418
+ const RESERVED_KEYWORDS = new Set([
1419
+ // JS/TS
1420
+ 'if', 'else', 'return', 'const', 'let', 'var', 'function', 'class', 'new',
1421
+ 'this', 'for', 'while', 'do', 'break', 'continue', 'switch', 'case',
1422
+ 'default', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof',
1423
+ 'in', 'of', 'await', 'async', 'import', 'export', 'from', 'as', 'void',
1424
+ 'null', 'undefined', 'true', 'false', 'super', 'extends', 'static',
1425
+ // Python
1426
+ 'def', 'lambda', 'yield', 'pass', 'with', 'global', 'nonlocal', 'and',
1427
+ 'or', 'not', 'is', 'None', 'True', 'False', 'raise', 'except', 'elif',
1428
+ 'self', 'cls', 'print',
1429
+ // Common stdlib roots that show up everywhere and would balloon scans.
1430
+ 'console', 'req', 'res', 'request', 'response',
1431
+ ]);
1432
+ /**
1433
+ * Extract identifier-shaped tokens from a source line. Skips keywords and
1434
+ * single-character names. Used by Stages 16-17 to find candidate variables
1435
+ * whose assignment lines should be scanned backward.
1436
+ */
1437
+ function extractSourceIdentifiers(text) {
1438
+ const out = [];
1439
+ const re = /\b([A-Za-z_]\w+)\b/g;
1440
+ let m;
1441
+ while ((m = re.exec(text)) !== null) {
1442
+ const name = m[1];
1443
+ if (!RESERVED_KEYWORDS.has(name))
1444
+ out.push(name);
1445
+ }
1446
+ return out;
1447
+ }
1448
+ /**
1449
+ * Scan upward from `sinkLine` (1-based) for an assignment to `varName`. If
1450
+ * the RHS of that assignment matches any of the supplied sanitizer regexes,
1451
+ * return true. Stops at the first assignment found (no `varName` reassignment
1452
+ * tracking — a benign reassignment after a tainted one is the common shape,
1453
+ * so first-assignment-found-above-the-sink is the precision-correct choice).
1454
+ *
1455
+ * Shared by Stages 16 (JS log_injection) and 17 (Python ldap_injection).
1456
+ */
1457
+ function isAssignedFromSanitizerPattern(sourceLines, sinkLine, varName, sanitizerPatterns, lookback = 30) {
1458
+ const escapedVar = escapeRegex(varName);
1459
+ // Match `[<keyword>?] varName = <rhs>` for both JS (`const|let|var`) and
1460
+ // Python (bare assignment) shapes.
1461
+ const assignRe = new RegExp(`(?:\\b(?:const|let|var|final|String)\\s+)?\\b${escapedVar}\\s*=\\s*(.+)`);
1462
+ // Scope boundary: stop at a function declaration so a sibling function's
1463
+ // assignment to the same variable name never crosses scope.
1464
+ const fnBoundaryRe = /^\s*(?:def\s+\w+\s*\(|function\s+\w+\s*\(|\w+\s*[:=]\s*(?:async\s+)?\(?[^)]*\)?\s*=>)/;
1465
+ const lo = Math.max(0, sinkLine - 1 - lookback);
1466
+ for (let i = sinkLine - 2; i >= lo; i--) {
1467
+ const ln = sourceLines[i] ?? '';
1468
+ const m = ln.match(assignRe);
1469
+ if (m) {
1470
+ const rhs = (m[1] ?? '').trim();
1471
+ return sanitizerPatterns.some(p => p.test(rhs));
1472
+ }
1473
+ if (fnBoundaryRe.test(ln))
1474
+ break;
1475
+ }
1476
+ return false;
1477
+ }
1478
+ /**
1479
+ * Find module-level Python functions whose body is a single
1480
+ * `return re.sub(r"[<class>]", "", param)` line where the character class
1481
+ * contains at least three of the LDAP filter metacharacters `( ) = * \`.
1482
+ *
1483
+ * Returns the function names. Conservative scan window: looks at the first
1484
+ * three non-blank lines after the `def` to find the `return re.sub(...)`.
1485
+ */
1486
+ function findPythonLdapStripWrappers(sourceLines) {
1487
+ const out = [];
1488
+ for (let i = 0; i < sourceLines.length; i++) {
1489
+ const defMatch = sourceLines[i].match(PY_DEF_RE);
1490
+ if (!defMatch)
1491
+ continue;
1492
+ const fnName = defMatch[1];
1493
+ const param = defMatch[2];
1494
+ // Scan next non-blank/comment line for the return re.sub(...) pattern.
1495
+ for (let j = i + 1; j < Math.min(i + 4, sourceLines.length); j++) {
1496
+ const body = sourceLines[j];
1497
+ if (/^\s*$/.test(body) || /^\s*#/.test(body))
1498
+ continue;
1499
+ const ret = body.match(PY_LDAP_STRIP_RETURN_RE);
1500
+ if (!ret)
1501
+ break;
1502
+ const charClass = ret[1];
1503
+ const argName = ret[2];
1504
+ if (argName !== param)
1505
+ break;
1506
+ const metaCount = PY_LDAP_METACHARS.filter(c => charClass.includes(c)).length;
1507
+ if (metaCount >= 3)
1508
+ out.push(fnName);
1509
+ break;
1510
+ }
1511
+ }
1512
+ return out;
1513
+ }
1514
+ // ---------------------------------------------------------------------------
1515
+ // Stage 19 (#215) Python helpers — f-string interp split, function body
1516
+ // scan via indent depth, and inline regex-allowlist+raise recognition.
1517
+ // ---------------------------------------------------------------------------
1518
+ /**
1519
+ * Stage 19 (#215) — split a Python f-string body into literal segments and
1520
+ * brace interpolations. Returns parallel arrays. Conservative: only flat
1521
+ * `{<expr>}` interpolations are recognised (no nested f-strings, no
1522
+ * format-specs after `:` since `:` overlaps with named-bind placeholders).
1523
+ * Escape sequence `{{`/`}}` is preserved as literal text.
1524
+ */
1525
+ function splitPyFstringTokens(body) {
1526
+ const literals = [];
1527
+ const interps = [];
1528
+ let i = 0;
1529
+ let cur = '';
1530
+ while (i < body.length) {
1531
+ const ch = body[i] ?? '';
1532
+ if (ch === '{' && body[i + 1] === '{') {
1533
+ cur += '{';
1534
+ i += 2;
1535
+ continue;
1536
+ }
1537
+ if (ch === '}' && body[i + 1] === '}') {
1538
+ cur += '}';
1539
+ i += 2;
1540
+ continue;
1541
+ }
1542
+ if (ch === '{') {
1543
+ literals.push(cur);
1544
+ cur = '';
1545
+ i++;
1546
+ let depth = 1;
1547
+ let expr = '';
1548
+ while (i < body.length && depth > 0) {
1549
+ const ch2 = body[i] ?? '';
1550
+ if (ch2 === '{') {
1551
+ depth++;
1552
+ expr += ch2;
1553
+ i++;
1554
+ continue;
1555
+ }
1556
+ if (ch2 === '}') {
1557
+ depth--;
1558
+ if (depth === 0) {
1559
+ i++;
1560
+ break;
1561
+ }
1562
+ expr += ch2;
1563
+ i++;
1564
+ continue;
1565
+ }
1566
+ expr += ch2;
1567
+ i++;
1568
+ }
1569
+ // Strip any trailing format-spec (after `:` at depth 0) — but only
1570
+ // when there's a non-trivial spec so we don't false-strip identifiers
1571
+ // containing `:` (which shouldn't happen syntactically anyway).
1572
+ const colonIdx = expr.indexOf(':');
1573
+ const interp = colonIdx >= 0 ? expr.substring(0, colonIdx) : expr;
1574
+ interps.push(interp);
1575
+ continue;
1576
+ }
1577
+ cur += ch;
1578
+ i++;
1579
+ }
1580
+ literals.push(cur);
1581
+ return { literals, interps };
1582
+ }
1583
+ /**
1584
+ * Stage 19 (#215) — find the helper-name assigned to `varName` within 30
1585
+ * lines above `sinkLine` (1-based). Returns the helper function name if
1586
+ * the RHS is a bare `helper(<arg>)` call, otherwise null.
1587
+ */
1588
+ function findPythonAssignedHelperCall(varName, sinkLine, sourceLines) {
1589
+ if (!/^[A-Za-z_]\w*$/.test(varName))
1590
+ return null;
1591
+ const lo = Math.max(0, sinkLine - 31);
1592
+ const assignRe = new RegExp(`^\\s*${varName}\\s*=\\s*([A-Za-z_]\\w*)\\s*\\(`);
1593
+ for (let i = sinkLine - 2; i >= lo; i--) {
1594
+ const ln = sourceLines[i] ?? '';
1595
+ const m = ln.match(assignRe);
1596
+ if (m)
1597
+ return m[1] ?? null;
1598
+ }
1599
+ return null;
1600
+ }
1601
+ /**
1602
+ * Stage 19 (#215) — locate a Python function body by name using indent
1603
+ * depth. Returns the body lines (between the `def` and the first line whose
1604
+ * indent is ≤ the `def` line's indent) or null when not found.
1605
+ *
1606
+ * Conservative textual scan — does not handle decorators across multiple
1607
+ * lines or nested functions with the same name. Sufficient for the
1608
+ * regex-allowlist guard recognition.
1609
+ */
1610
+ function findPythonFunctionBody(funcName, sourceLines) {
1611
+ if (!/^[A-Za-z_]\w*$/.test(funcName))
1612
+ return null;
1613
+ const sigRe = new RegExp(`^(\\s*)def\\s+${funcName}\\s*\\(`);
1614
+ for (let i = 0; i < sourceLines.length; i++) {
1615
+ const ln = sourceLines[i] ?? '';
1616
+ const m = ln.match(sigRe);
1617
+ if (!m)
1618
+ continue;
1619
+ const baseIndent = (m[1] ?? '').length;
1620
+ const body = [];
1621
+ for (let j = i + 1; j < sourceLines.length; j++) {
1622
+ const ln2 = sourceLines[j] ?? '';
1623
+ if (ln2.trim() === '') {
1624
+ body.push(ln2);
1625
+ continue;
1626
+ }
1627
+ const indentMatch = ln2.match(/^(\s*)/);
1628
+ const indent = (indentMatch?.[1] ?? '').length;
1629
+ if (indent <= baseIndent)
1630
+ break;
1631
+ body.push(ln2);
1632
+ }
1633
+ return body;
1634
+ }
1635
+ return null;
1636
+ }
1637
+ /**
1638
+ * Stage 19 (#215) — true if the Python function body contains
1639
+ * `re.fullmatch(r"<strict-anchored-regex>", …)` OR
1640
+ * `re.match(r"^<…>$", …)`
1641
+ * AND a `raise` statement. The two need not be in the same `if`; the
1642
+ * combination proves the parameter is validated and any non-allowlisted
1643
+ * input terminates execution before the function returns.
1644
+ */
1645
+ function pythonBodyHasInlineRegexAllowlistRaise(bodyLines) {
1646
+ // Strip inline `# …` comments from each line so words like "raise" or
1647
+ // `re.fullmatch` appearing in commentary don't trigger a false guard
1648
+ // detection. Conservative: this does not handle `#` inside string
1649
+ // literals, but the surrounding code already operates on textual lines
1650
+ // and the false-suppression risk from a `#` inside a string is bounded
1651
+ // by the other gates (must also have anchored regex + bind placeholder).
1652
+ const stripped = bodyLines.map(ln => ln.replace(/#.*$/, ''));
1653
+ const text = stripped.join('\n');
1654
+ if (!/\braise\s+/.test(text))
1655
+ return false;
1656
+ PY_INLINE_FULLMATCH_RE.lastIndex = 0;
1657
+ let m;
1658
+ while ((m = PY_INLINE_FULLMATCH_RE.exec(text)) !== null) {
1659
+ if (isImplicitlyAnchoredAllowlistRegex(m[1] ?? ''))
1660
+ return true;
1661
+ }
1662
+ PY_INLINE_MATCH_ANCHORED_RE.lastIndex = 0;
1663
+ while ((m = PY_INLINE_MATCH_ANCHORED_RE.exec(text)) !== null) {
1664
+ if (isImplicitlyAnchoredAllowlistRegex(m[1] ?? ''))
1665
+ return true;
1666
+ }
1667
+ return false;
1668
+ }
1173
1669
  /**
1174
1670
  * Evaluate a simple arithmetic expression containing only digits, spaces, and
1175
1671
  * the operators +, -, *, /, and parentheses. Uses a recursive descent parser