circle-ir 3.107.0 → 3.110.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.
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +24 -13
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.js +514 -11
- package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +11 -0
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/browser/circle-ir.js +383 -27
- package/dist/core/circle-ir-core.cjs +27 -13
- package/dist/core/circle-ir-core.js +27 -13
- package/package.json +1 -1
|
@@ -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;
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -1112,20 +1171,27 @@ export class SinkFilterPass {
|
|
|
1112
1171
|
if (!callArgs || callArgs.length === 0)
|
|
1113
1172
|
return true;
|
|
1114
1173
|
const sqlVar = callArgs[0]?.trim() ?? '';
|
|
1115
|
-
if (!/^[A-Za-z_]\w*$/.test(sqlVar))
|
|
1116
|
-
return true;
|
|
1117
|
-
// Scan backward for the SQL variable's assignment.
|
|
1118
|
-
const lo = Math.max(0, sink.line - 31);
|
|
1119
|
-
const assignRe = new RegExp(JAVA_SQL_ASSIGN_RE_TEMPLATE.replace('SQLVAR', sqlVar));
|
|
1120
1174
|
let rhs = null;
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1175
|
+
if (/^[A-Za-z_]\w*$/.test(sqlVar)) {
|
|
1176
|
+
// Indirect form: SQL assigned to a named variable above the sink.
|
|
1177
|
+
// Scan backward for the SQL variable's assignment.
|
|
1178
|
+
const lo = Math.max(0, sink.line - 31);
|
|
1179
|
+
const assignRe = new RegExp(JAVA_SQL_ASSIGN_RE_TEMPLATE.replace('SQLVAR', sqlVar));
|
|
1180
|
+
for (let i = sink.line - 2; i >= lo; i--) {
|
|
1181
|
+
const ln = sourceLines[i] ?? '';
|
|
1182
|
+
const m = ln.match(assignRe);
|
|
1183
|
+
if (m) {
|
|
1184
|
+
rhs = m[1] ?? null;
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1127
1187
|
}
|
|
1128
1188
|
}
|
|
1189
|
+
else if (/"[^"]*"/.test(sqlVar) && sqlVar.includes('+')) {
|
|
1190
|
+
// Inline form (cognium-dev #214): SQL concat passed directly to the
|
|
1191
|
+
// exec method. Treat the arg expression itself as the RHS and run
|
|
1192
|
+
// gates (c)–(e) against it.
|
|
1193
|
+
rhs = sqlVar;
|
|
1194
|
+
}
|
|
1129
1195
|
if (!rhs)
|
|
1130
1196
|
return true;
|
|
1131
1197
|
const tokens = splitJavaConcatTokens(rhs);
|
|
@@ -1160,9 +1226,446 @@ export class SinkFilterPass {
|
|
|
1160
1226
|
return true;
|
|
1161
1227
|
});
|
|
1162
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
|
+
}
|
|
1163
1400
|
return { sources, sinks: filtered, sanitizers };
|
|
1164
1401
|
}
|
|
1165
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
|
+
}
|
|
1166
1669
|
/**
|
|
1167
1670
|
* Evaluate a simple arithmetic expression containing only digits, spaces, and
|
|
1168
1671
|
* the operators +, -, *, /, and parentheses. Uses a recursive descent parser
|