circle-ir 3.194.0 → 3.197.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.
Files changed (53) hide show
  1. package/configs/README.md +39 -0
  2. package/dist/analysis/config-loader.d.ts.map +1 -1
  3. package/dist/analysis/config-loader.js +43 -3
  4. package/dist/analysis/config-loader.js.map +1 -1
  5. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  6. package/dist/analysis/passes/language-sources-pass.js +624 -2
  7. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  8. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  9. package/dist/analysis/passes/sink-filter-pass.js +68 -1
  10. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  11. package/dist/analysis/passes/taint-matcher-pass.d.ts.map +1 -1
  12. package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
  13. package/dist/analysis/rules.d.ts.map +1 -1
  14. package/dist/analysis/rules.js +42 -0
  15. package/dist/analysis/rules.js.map +1 -1
  16. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  17. package/dist/analysis/taint-matcher.js +24 -8
  18. package/dist/analysis/taint-matcher.js.map +1 -1
  19. package/dist/browser/circle-ir.js +582 -604
  20. package/dist/core/circle-ir-core.cjs +59 -13
  21. package/dist/core/circle-ir-core.js +59 -13
  22. package/dist/languages/plugins/bash.d.ts.map +1 -1
  23. package/dist/languages/plugins/bash.js +12 -16
  24. package/dist/languages/plugins/bash.js.map +1 -1
  25. package/dist/languages/plugins/go.d.ts +4 -0
  26. package/dist/languages/plugins/go.d.ts.map +1 -1
  27. package/dist/languages/plugins/go.js +16 -32
  28. package/dist/languages/plugins/go.js.map +1 -1
  29. package/dist/languages/plugins/java.d.ts +9 -1
  30. package/dist/languages/plugins/java.d.ts.map +1 -1
  31. package/dist/languages/plugins/java.js +16 -127
  32. package/dist/languages/plugins/java.js.map +1 -1
  33. package/dist/languages/plugins/javascript.d.ts +7 -0
  34. package/dist/languages/plugins/javascript.d.ts.map +1 -1
  35. package/dist/languages/plugins/javascript.js +10 -117
  36. package/dist/languages/plugins/javascript.js.map +1 -1
  37. package/dist/languages/plugins/python.d.ts +7 -0
  38. package/dist/languages/plugins/python.d.ts.map +1 -1
  39. package/dist/languages/plugins/python.js +24 -177
  40. package/dist/languages/plugins/python.js.map +1 -1
  41. package/dist/languages/plugins/rust.d.ts +9 -0
  42. package/dist/languages/plugins/rust.d.ts.map +1 -1
  43. package/dist/languages/plugins/rust.js +22 -110
  44. package/dist/languages/plugins/rust.js.map +1 -1
  45. package/dist/languages/types.d.ts +3 -3
  46. package/dist/languages/types.d.ts.map +1 -1
  47. package/dist/types/config.d.ts +15 -0
  48. package/dist/types/config.d.ts.map +1 -1
  49. package/dist/types/index.d.ts +13 -2
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/types/index.js +37 -1
  52. package/dist/types/index.js.map +1 -1
  53. package/package.json +3 -2
@@ -312,6 +312,10 @@ export class LanguageSourcesPass {
312
312
  // recognition.
313
313
  additionalSanitizers.push(...findPythonRegexAllowlistWrapperSanitizers(code));
314
314
  additionalSanitizers.push(...findPythonSetMembershipXssGuardSanitizers(code));
315
+ additionalSanitizers.push(...findPythonTraversalRejectGuardSanitizers(code));
316
+ additionalSanitizers.push(...findPythonStringLiteralGuardSanitizers(code));
317
+ additionalSanitizers.push(...findPythonDefaultSaxParserXxeSanitizers(code));
318
+ additionalSanitizers.push(...findPythonUrlAllowlistRedirectSanitizers(code));
315
319
  additionalSanitizers.push(...findPythonDefusedXmlSanitizers(code));
316
320
  // Sprint 77a (#216 Pattern X): Jinja2 Environment(autoescape=...) +
317
321
  // .render(...) sanitizer.
@@ -1164,14 +1168,205 @@ function findPythonAssignmentSources(sourceCode, language) {
1164
1168
  }
1165
1169
  return sources;
1166
1170
  }
1171
+ /**
1172
+ * Evaluate an integer arithmetic / comparison expression built only from
1173
+ * literals, known integer constants, `+ - * / // %`, parentheses and one
1174
+ * comparison operator. Returns `undefined` for anything it cannot decide —
1175
+ * an unknown name, a non-integer, a call, a string, division by zero.
1176
+ *
1177
+ * Deliberately hand-rolled rather than `eval`-ed: the input is untrusted
1178
+ * source text, and the analyzer must stay browser-safe.
1179
+ */
1180
+ function evaluatePythonConstExpression(expr, consts) {
1181
+ const COMPARISON = /(<=|>=|==|!=|<|>)/;
1182
+ const parts = expr.split(COMPARISON);
1183
+ if (parts.length === 3) {
1184
+ const left = evaluatePythonConstExpression(parts[0], consts);
1185
+ const right = evaluatePythonConstExpression(parts[2], consts);
1186
+ if (typeof left !== 'number' || typeof right !== 'number')
1187
+ return undefined;
1188
+ switch (parts[1]) {
1189
+ case '<': return left < right;
1190
+ case '>': return left > right;
1191
+ case '<=': return left <= right;
1192
+ case '>=': return left >= right;
1193
+ case '==': return left === right;
1194
+ case '!=': return left !== right;
1195
+ default: return undefined;
1196
+ }
1197
+ }
1198
+ if (parts.length !== 1)
1199
+ return undefined;
1200
+ const matched = expr.match(/\d+|[A-Za-z_][A-Za-z0-9_]*|\/\/|[-+*/%()]|\S/g);
1201
+ if (!matched)
1202
+ return undefined;
1203
+ // Explicit binding so the hoisted `parseSum` declaration keeps the
1204
+ // non-null narrowing.
1205
+ const tokens = matched;
1206
+ let pos = 0;
1207
+ const peek = () => tokens[pos];
1208
+ // term := factor (('*' | '/' | '//' | '%') factor)*
1209
+ // sum := term (('+' | '-') term)*
1210
+ const parseFactor = () => {
1211
+ const tok = tokens[pos++];
1212
+ if (tok === undefined)
1213
+ return undefined;
1214
+ if (tok === '(') {
1215
+ const inner = parseSum();
1216
+ if (tokens[pos++] !== ')')
1217
+ return undefined;
1218
+ return inner;
1219
+ }
1220
+ if (tok === '-') {
1221
+ const operand = parseFactor();
1222
+ return operand === undefined ? undefined : -operand;
1223
+ }
1224
+ if (/^\d+$/.test(tok))
1225
+ return Number(tok);
1226
+ if (/^[A-Za-z_]/.test(tok))
1227
+ return consts.get(tok);
1228
+ return undefined;
1229
+ };
1230
+ const parseTerm = () => {
1231
+ let value = parseFactor();
1232
+ if (value === undefined)
1233
+ return undefined;
1234
+ while (peek() === '*' || peek() === '/' || peek() === '//' || peek() === '%') {
1235
+ const op = tokens[pos++];
1236
+ const rhs = parseFactor();
1237
+ if (rhs === undefined)
1238
+ return undefined;
1239
+ if ((op === '/' || op === '//' || op === '%') && rhs === 0)
1240
+ return undefined;
1241
+ if (op === '*')
1242
+ value *= rhs;
1243
+ else if (op === '/')
1244
+ value /= rhs;
1245
+ else if (op === '//')
1246
+ value = Math.floor(value / rhs);
1247
+ else
1248
+ value = ((value % rhs) + rhs) % rhs;
1249
+ }
1250
+ return value;
1251
+ };
1252
+ function parseSum() {
1253
+ let value = parseTerm();
1254
+ if (value === undefined)
1255
+ return undefined;
1256
+ while (peek() === '+' || peek() === '-') {
1257
+ const op = tokens[pos++];
1258
+ const rhs = parseTerm();
1259
+ if (rhs === undefined)
1260
+ return undefined;
1261
+ value = op === '+' ? value + rhs : value - rhs;
1262
+ }
1263
+ return value;
1264
+ }
1265
+ const result = parseSum();
1266
+ return pos === tokens.length ? result : undefined;
1267
+ }
1268
+ /**
1269
+ * Resolve a Python list index (which may be negative) against a known length.
1270
+ * Returns `undefined` when it falls outside the list, in which case the caller
1271
+ * stops tracking that list rather than guessing.
1272
+ */
1273
+ function normalizeListIndex(index, length) {
1274
+ const resolved = index < 0 ? length + index : index;
1275
+ return resolved >= 0 && resolved < length ? resolved : undefined;
1276
+ }
1277
+ /**
1278
+ * Line indices (0-based) belonging to a branch that cannot execute, because
1279
+ * its `if` condition is decidable from tracked integer constants:
1280
+ *
1281
+ * num = 86
1282
+ * if 7 * 42 - num > 200: # 208 > 200 → always true
1283
+ * bar = 'safe'
1284
+ * else:
1285
+ * bar = param # dead — must not seed taint
1286
+ *
1287
+ * The statement-level sibling of the conditional-expression folding in
1288
+ * `buildPythonTaintedVars`. Same evaluator, same conservatism: an undecidable
1289
+ * condition marks nothing dead.
1290
+ *
1291
+ * `elif` chains are skipped entirely — deciding one arm says nothing about the
1292
+ * rest without evaluating every preceding condition, and the corpus shapes
1293
+ * this targets are plain if/else.
1294
+ */
1295
+ function computeDeadPythonBranchLines(lines) {
1296
+ const dead = new Set();
1297
+ const consts = new Map();
1298
+ const indentOf = (line) => line.length - line.trimStart().length;
1299
+ for (let i = 0; i < lines.length; i++) {
1300
+ const line = lines[i];
1301
+ const assign = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)\s*(.+)$/);
1302
+ if (assign) {
1303
+ const value = evaluatePythonConstExpression(assign[2].trim(), consts);
1304
+ if (typeof value === 'number')
1305
+ consts.set(assign[1], value);
1306
+ else
1307
+ consts.delete(assign[1]);
1308
+ }
1309
+ const ifMatch = line.match(/^(\s*)if\s+(.+?)\s*:\s*$/);
1310
+ if (!ifMatch)
1311
+ continue;
1312
+ const indent = ifMatch[1].length;
1313
+ const decided = evaluatePythonConstExpression(ifMatch[2].trim(), consts);
1314
+ if (decided !== true && decided !== false)
1315
+ continue;
1316
+ // Then-block: everything more deeply indented than the `if`.
1317
+ const thenStart = i + 1;
1318
+ let cursor = thenStart;
1319
+ while (cursor < lines.length && (lines[cursor].trim() === '' || indentOf(lines[cursor]) > indent)) {
1320
+ cursor++;
1321
+ }
1322
+ const thenEnd = cursor - 1;
1323
+ let elseStart = -1;
1324
+ let elseEnd = -1;
1325
+ if (cursor < lines.length && indentOf(lines[cursor]) === indent) {
1326
+ if (/^\s*elif\b/.test(lines[cursor]))
1327
+ continue;
1328
+ if (/^\s*else\s*:\s*$/.test(lines[cursor])) {
1329
+ elseStart = cursor + 1;
1330
+ let k = elseStart;
1331
+ while (k < lines.length && (lines[k].trim() === '' || indentOf(lines[k]) > indent))
1332
+ k++;
1333
+ elseEnd = k - 1;
1334
+ }
1335
+ }
1336
+ if (decided === true) {
1337
+ for (let l = elseStart; l >= 0 && l <= elseEnd; l++)
1338
+ dead.add(l);
1339
+ }
1340
+ else {
1341
+ for (let l = thenStart; l <= thenEnd; l++)
1342
+ dead.add(l);
1343
+ }
1344
+ }
1345
+ return dead;
1346
+ }
1167
1347
  export function buildPythonTaintedVars(sourceCode) {
1168
1348
  const tainted = new Map();
1169
1349
  const containerTainted = new Map();
1170
1350
  const lines = sourceCode.split('\n');
1351
+ const deadBranchLines = computeDeadPythonBranchLines(lines);
1352
+ // Per-element taint for lists followed since construction (`x = []` or a
1353
+ // literal). Only append / insert / pop / del are modelled; any other
1354
+ // mutation removes the list from the map and the coarse
1355
+ // whole-container behaviour takes over.
1356
+ const listElems = new Map();
1357
+ // Integer constants in scope, for folding `X = A if <const cond> else B`.
1358
+ // Only literal-valued and constant-derived integers are tracked; anything
1359
+ // else (call result, string, tainted read) removes the name from the map so
1360
+ // a later condition mentioning it stays undecided.
1361
+ const intConsts = new Map();
1171
1362
  for (let i = 0; i < lines.length; i++) {
1172
1363
  const line = lines[i];
1173
1364
  if (line.trimStart().startsWith('#'))
1174
1365
  continue;
1366
+ // A branch whose `if` condition is decidably false cannot assign anything,
1367
+ // so its lines must not seed or clobber taint.
1368
+ if (deadBranchLines.has(i))
1369
+ continue;
1175
1370
  const subscriptAssign = line.match(/^\s*([\p{L}\p{N}_]+)\[(['"])([^'"]+)\2\]\s*=\s*(.+)$/u);
1176
1371
  if (subscriptAssign) {
1177
1372
  const [, container, , key, rhs2] = subscriptAssign;
@@ -1196,11 +1391,67 @@ export function buildPythonTaintedVars(sourceCode) {
1196
1391
  // word-boundary scan below.
1197
1392
  const containerAppendMatch = line.match(/^\s*([\p{L}\p{N}_]+)\.(append|extend|insert|add|push|put|appendleft)\s*\(\s*(.+?)\s*\)\s*$/u);
1198
1393
  if (containerAppendMatch) {
1199
- const [, receiver, , argExpr] = containerAppendMatch;
1394
+ const [, receiver, method, argExpr] = containerAppendMatch;
1200
1395
  const argIsTainted = [...tainted.keys()].some(v => new RegExp(`(?<![\\p{L}\\p{N}_])${v}(?![\\p{L}\\p{N}_])`, 'u').test(argExpr));
1201
1396
  const argIsDirectSource = PYTHON_TAINTED_PATTERNS.some(p => p.pattern.test(argExpr));
1202
1397
  if (argIsTainted || argIsDirectSource)
1203
1398
  tainted.set(receiver, tainted.get(receiver) ?? (i + 1));
1399
+ // Per-element tracking for lists we have followed since construction.
1400
+ // `append` pushes; `insert(i, x)` splices at a literal index; anything
1401
+ // else (extend, an unparsable index) makes the element map unreliable,
1402
+ // so the list drops back to the coarse whole-container behaviour above.
1403
+ const elems = listElems.get(receiver);
1404
+ if (elems) {
1405
+ if (method === 'append') {
1406
+ elems.push(argIsTainted || argIsDirectSource);
1407
+ }
1408
+ else if (method === 'insert') {
1409
+ const insertAt = argExpr.match(/^(-?\d+)\s*,\s*(.+)$/);
1410
+ if (insertAt) {
1411
+ const idx = normalizeListIndex(Number(insertAt[1]), elems.length);
1412
+ const valueExpr = insertAt[2];
1413
+ const valueTainted = [...tainted.keys()].some(v => new RegExp(`(?<![\\p{L}\\p{N}_])${v}(?![\\p{L}\\p{N}_])`, 'u').test(valueExpr)) ||
1414
+ PYTHON_TAINTED_PATTERNS.some(p => p.pattern.test(valueExpr));
1415
+ if (idx !== undefined)
1416
+ elems.splice(idx, 0, valueTainted);
1417
+ else
1418
+ listElems.delete(receiver);
1419
+ }
1420
+ else {
1421
+ listElems.delete(receiver);
1422
+ }
1423
+ }
1424
+ else {
1425
+ listElems.delete(receiver);
1426
+ }
1427
+ }
1428
+ continue;
1429
+ }
1430
+ // `lst.pop(i)` / `lst.pop()` / `del lst[i]` — removal shifts every later
1431
+ // element down one, which is exactly what makes `lst[1]` safe in
1432
+ // lst.append('safe'); lst.append(param); lst.append('moresafe')
1433
+ // lst.pop(0); bar = lst[1] # 'moresafe', not param
1434
+ const popMatch = line.match(/^\s*(?:([\p{L}\p{N}_]+)\s*=\s*)?([\p{L}\p{N}_]+)\.pop\s*\(\s*(-?\d+)?\s*\)\s*$/u);
1435
+ const delMatch = line.match(/^\s*del\s+([\p{L}\p{N}_]+)\s*\[\s*(-?\d+)\s*\]\s*$/u);
1436
+ if (popMatch || delMatch) {
1437
+ const receiver = popMatch ? popMatch[2] : delMatch[1];
1438
+ const rawIndex = popMatch ? (popMatch[3] !== undefined ? Number(popMatch[3]) : -1) : Number(delMatch[2]);
1439
+ const elems = listElems.get(receiver);
1440
+ if (elems) {
1441
+ const idx = normalizeListIndex(rawIndex, elems.length);
1442
+ if (idx === undefined)
1443
+ listElems.delete(receiver);
1444
+ else {
1445
+ const [removed] = elems.splice(idx, 1);
1446
+ // `x = lst.pop(i)` binds the removed element's taint to x.
1447
+ if (popMatch && popMatch[1]) {
1448
+ if (removed)
1449
+ tainted.set(popMatch[1], i + 1);
1450
+ else
1451
+ tainted.delete(popMatch[1]);
1452
+ }
1453
+ }
1454
+ }
1204
1455
  continue;
1205
1456
  }
1206
1457
  const augAssign = line.match(/^\s*([\p{L}\p{N}_]+)\s*\+=\s*(.+)$/u);
@@ -1223,7 +1474,70 @@ export function buildPythonTaintedVars(sourceCode) {
1223
1474
  const assignMatch = line.match(/^\s*([\p{L}\p{N}_]+)\s*=\s*(.+)$/u);
1224
1475
  if (!assignMatch)
1225
1476
  continue;
1226
- const [, lhs, rhs] = assignMatch;
1477
+ const [, lhs, rhsRaw] = assignMatch;
1478
+ // Maintain the integer-constant map used to fold conditional expressions.
1479
+ const constValue = evaluatePythonConstExpression(rhsRaw.trim(), intConsts);
1480
+ if (typeof constValue === 'number')
1481
+ intConsts.set(lhs, constValue);
1482
+ else
1483
+ intConsts.delete(lhs);
1484
+ // Fold `X = <then> if <condition> else <else>` when the condition is
1485
+ // decidable from those constants. The dead branch cannot contribute taint,
1486
+ // so scanning the whole RHS for tainted names — as the generic path below
1487
+ // does — reports a flow that can never execute. This is the second-largest
1488
+ // FP shape on OWASP BenchmarkPython (`bar = "safe" if 7 * 18 + num > 200
1489
+ // else param`, where num is 106 and the safe literal always wins).
1490
+ //
1491
+ // Only the *condition* is folded, never the branches: `bar = a if c else b`
1492
+ // keeps whichever branch is live, taint and all. An undecidable condition
1493
+ // leaves the RHS untouched, so unknown values stay conservative.
1494
+ let rhs = rhsRaw;
1495
+ const ternary = rhsRaw.match(/^(.+?)\s+if\s+(.+?)\s+else\s+(.+)$/);
1496
+ if (ternary) {
1497
+ const decided = evaluatePythonConstExpression(ternary[2].trim(), intConsts);
1498
+ if (decided === true)
1499
+ rhs = ternary[1].trim();
1500
+ else if (decided === false)
1501
+ rhs = ternary[3].trim();
1502
+ }
1503
+ // Track list construction so the element map has a starting point, and
1504
+ // invalidate it whenever the name is rebound to something else.
1505
+ const listLiteral = rhs.trim().match(/^\[\s*(.*?)\s*\]$/);
1506
+ if (listLiteral) {
1507
+ const inner = listLiteral[1];
1508
+ if (inner === '') {
1509
+ listElems.set(lhs, []);
1510
+ }
1511
+ else if (!inner.includes('[') && !inner.includes('(')) {
1512
+ listElems.set(lhs, inner.split(',').map(part => {
1513
+ const expr = part.trim();
1514
+ return ([...tainted.keys()].some(v => new RegExp(`(?<![\\p{L}\\p{N}_])${v}(?![\\p{L}\\p{N}_])`, 'u').test(expr)) ||
1515
+ PYTHON_TAINTED_PATTERNS.some(p => p.pattern.test(expr)));
1516
+ }));
1517
+ }
1518
+ else {
1519
+ listElems.delete(lhs);
1520
+ }
1521
+ }
1522
+ else {
1523
+ listElems.delete(lhs);
1524
+ }
1525
+ // Indexed read of a tracked list: `bar = lst[1]` takes that element's
1526
+ // taint precisely instead of inheriting the whole container's.
1527
+ const indexRead = rhs.trim().match(/^([\p{L}\p{N}_]+)\s*\[\s*(-?\d+)\s*\]$/u);
1528
+ if (indexRead) {
1529
+ const elems = listElems.get(indexRead[1]);
1530
+ if (elems) {
1531
+ const idx = normalizeListIndex(Number(indexRead[2]), elems.length);
1532
+ if (idx !== undefined) {
1533
+ if (elems[idx])
1534
+ tainted.set(lhs, i + 1);
1535
+ else
1536
+ tainted.delete(lhs);
1537
+ continue;
1538
+ }
1539
+ }
1540
+ }
1227
1541
  const isDirectSource = PYTHON_TAINTED_PATTERNS.some(p => p.pattern.test(rhs));
1228
1542
  let propagatedFrom;
1229
1543
  const dictAccessMatch = rhs.trim().match(/^([\p{L}\p{N}_]+)\[(['"])([^'"]+)\2\]$/u);
@@ -2954,6 +3268,314 @@ function findPythonSetMembershipXssGuardSanitizers(code) {
2954
3268
  }
2955
3269
  return sanitizers;
2956
3270
  }
3271
+ /**
3272
+ * Detect the Python "reject-then-return" traversal guard (cognium-dev #4
3273
+ * residual-FP audit):
3274
+ *
3275
+ * if '../' in name:
3276
+ * return "File name must not include '../'"
3277
+ * path = f'{BASE_DIR}/{name}'
3278
+ * fd = open(path, 'rb')
3279
+ *
3280
+ * A guard that rejects the traversal token and leaves the function cannot be
3281
+ * followed by a traversal payload in that variable, so downstream `open()` /
3282
+ * `os.path.join()` uses of it are not CWE-22. This is the single largest FP
3283
+ * shape on OWASP BenchmarkPython (18 of 72 flow-level FPs, all pathtraver).
3284
+ *
3285
+ * Deliberately narrow, because an over-broad rule here erases real findings:
3286
+ * - only the *reject* polarity (`if '<..>' in v:` + terminator). The inverse
3287
+ * (`if '..' not in v:`) puts the safe path inside the block, which this
3288
+ * line-keyed emission cannot express.
3289
+ * - only `path_traversal` is sanitized. The guard says nothing about the
3290
+ * value reaching a shell, a query, or a template.
3291
+ * - the token must be a traversal token (`..`, `../`, `..\\`). A guard on
3292
+ * some other substring is a different check with different guarantees.
3293
+ *
3294
+ * Known gap (accepted): a guard on the decoded string does not stop an
3295
+ * encoded payload (`..%2f`) if decoding happens *after* the check. The
3296
+ * benchmark shape decodes first, and the alternative — never crediting the
3297
+ * guard — is the status quo that produces the FPs.
3298
+ */
3299
+ function findPythonTraversalRejectGuardSanitizers(code) {
3300
+ const sanitizers = [];
3301
+ const lines = code.split('\n');
3302
+ // `if '../' in <ident>:` — quote style, and the `..` / `../` / `..\` token.
3303
+ const guardOpen = /^(\s*)if\s+(['"])(\.\.[/\\]?)\2\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*$/;
3304
+ const terminator = /\b(return|raise|abort\s*\(|sys\.exit\s*\()/;
3305
+ for (let i = 0; i < lines.length; i++) {
3306
+ const m = guardOpen.exec(lines[i]);
3307
+ if (!m)
3308
+ continue;
3309
+ const guardIndent = m[1].length;
3310
+ const guardedVar = m[4];
3311
+ // Walk the guard body: it must leave the function on this path.
3312
+ let bodyHasTerminator = false;
3313
+ let blockEnd = -1;
3314
+ const maxScan = Math.min(lines.length, i + 26);
3315
+ for (let j = i + 1; j < maxScan; j++) {
3316
+ const line = lines[j];
3317
+ if (line.trim() === '')
3318
+ continue;
3319
+ const indent = line.length - line.trimStart().length;
3320
+ if (indent <= guardIndent) {
3321
+ blockEnd = j - 1;
3322
+ break;
3323
+ }
3324
+ if (terminator.test(line))
3325
+ bodyHasTerminator = true;
3326
+ }
3327
+ if (blockEnd === -1)
3328
+ blockEnd = Math.min(lines.length - 1, i + 25);
3329
+ if (!bodyHasTerminator)
3330
+ continue;
3331
+ // Var-aware emission from the first line after the guard block, following
3332
+ // one-hop derivations: `path = f'{BASE}/{name}'` makes `path` guarded too,
3333
+ // because the traversal token cannot be in `name`. Without this the credit
3334
+ // stops at the assignment line while the sink sits on the next one
3335
+ // (`open(path)`), which is precisely the benchmark shape.
3336
+ //
3337
+ // A later assignment that does NOT mention a guarded name clobbers it —
3338
+ // `path = request.args['p']` must not stay credited.
3339
+ const guarded = new Set([guardedVar]);
3340
+ const assignRe = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)\s*(.*)$/;
3341
+ const mentionsGuarded = (text) => {
3342
+ for (const name of guarded) {
3343
+ if (new RegExp(`\\b${name}\\b`).test(text))
3344
+ return true;
3345
+ }
3346
+ return false;
3347
+ };
3348
+ for (let l = blockEnd + 2; l <= lines.length; l++) {
3349
+ const lineText = lines[l - 1];
3350
+ const assign = assignRe.exec(lineText);
3351
+ if (assign) {
3352
+ const target = assign[1];
3353
+ const rhs = assign[2];
3354
+ if (mentionsGuarded(rhs))
3355
+ guarded.add(target);
3356
+ else if (target !== guardedVar)
3357
+ guarded.delete(target);
3358
+ }
3359
+ if (!mentionsGuarded(lineText))
3360
+ continue;
3361
+ sanitizers.push({
3362
+ type: 'python_traversal_reject_guard',
3363
+ method: 'if',
3364
+ line: l,
3365
+ sanitizes: ['path_traversal'],
3366
+ });
3367
+ }
3368
+ }
3369
+ return sanitizers;
3370
+ }
3371
+ /**
3372
+ * Detect the Python "must be a plain string literal" validation guard
3373
+ * (cognium-dev #4 residual-FP audit):
3374
+ *
3375
+ * if not bar.startswith("'") or not bar.endswith("'") or "'" in bar[1:-1]:
3376
+ * return "Exec argument must be a plain string literal."
3377
+ * exec(bar)
3378
+ *
3379
+ * Past that guard `bar` is a quoted literal whose interior contains no quote,
3380
+ * so `exec`/`eval` sees a string-literal expression and cannot be steered into
3381
+ * executing attacker code — there is no way to close the quote and append a
3382
+ * statement. This is the whole codeinj FP bucket on OWASP BenchmarkPython.
3383
+ *
3384
+ * All three clauses are required. Any two of them leave an escape: without the
3385
+ * interior check, `'a' + evil() + '` satisfies startswith/endswith while still
3386
+ * breaking out of the literal. Only `code_injection` is sanitized — proving a
3387
+ * value is a quoted literal says nothing about SQL, a path, or HTML, where the
3388
+ * quote characters themselves are the payload.
3389
+ */
3390
+ function findPythonStringLiteralGuardSanitizers(code) {
3391
+ const sanitizers = [];
3392
+ const lines = code.split('\n');
3393
+ const terminator = /\b(return|raise|abort\s*\(|sys\.exit\s*\()/;
3394
+ // Quote literal in Python source: '\'' , "'" , '"' , "\"".
3395
+ const Q = `(?:'(?:\\\\')'|"'"|'"'|"(?:\\\\")")`;
3396
+ for (let i = 0; i < lines.length; i++) {
3397
+ const guard = lines[i].match(new RegExp(`^(\\s*)if\\s+not\\s+([A-Za-z_][A-Za-z0-9_]*)\\.startswith\\(\\s*${Q}\\s*\\)` +
3398
+ `\\s+or\\s+not\\s+\\2\\.endswith\\(\\s*${Q}\\s*\\)` +
3399
+ `\\s+or\\s+${Q}\\s+in\\s+\\2\\[1:-1\\]\\s*:\\s*$`));
3400
+ if (!guard)
3401
+ continue;
3402
+ const guardIndent = guard[1].length;
3403
+ const guardedVar = guard[2];
3404
+ let bodyHasTerminator = false;
3405
+ let blockEnd = -1;
3406
+ const maxScan = Math.min(lines.length, i + 26);
3407
+ for (let j = i + 1; j < maxScan; j++) {
3408
+ const line = lines[j];
3409
+ if (line.trim() === '')
3410
+ continue;
3411
+ const indent = line.length - line.trimStart().length;
3412
+ if (indent <= guardIndent) {
3413
+ blockEnd = j - 1;
3414
+ break;
3415
+ }
3416
+ if (terminator.test(line))
3417
+ bodyHasTerminator = true;
3418
+ }
3419
+ if (blockEnd === -1)
3420
+ blockEnd = Math.min(lines.length - 1, i + 25);
3421
+ if (!bodyHasTerminator)
3422
+ continue;
3423
+ const varRe = new RegExp(`\\b${guardedVar}\\b`);
3424
+ for (let l = blockEnd + 2; l <= lines.length; l++) {
3425
+ if (!varRe.test(lines[l - 1]))
3426
+ continue;
3427
+ sanitizers.push({
3428
+ type: 'python_string_literal_guard',
3429
+ method: 'if',
3430
+ line: l,
3431
+ sanitizes: ['code_injection'],
3432
+ });
3433
+ }
3434
+ }
3435
+ return sanitizers;
3436
+ }
3437
+ /**
3438
+ * Detect a default-configured `xml.sax` parser being used to parse untrusted
3439
+ * XML (cognium-dev #4 residual-FP audit):
3440
+ *
3441
+ * parser = xml.sax.make_parser()
3442
+ * doc = xml.dom.minidom.parseString(bar, parser) # NOT XXE
3443
+ *
3444
+ * versus the vulnerable form, which differs by one line:
3445
+ *
3446
+ * parser = xml.sax.make_parser()
3447
+ * parser.setFeature(xml.sax.handler.feature_external_ges, True)
3448
+ * doc = xml.dom.minidom.parseString(bar, parser) # XXE
3449
+ *
3450
+ * Since Python 3.7.1 (bpo-17239) `xml.sax` and `xml.dom.minidom` do not
3451
+ * process external entities unless `feature_external_ges` is switched on or a
3452
+ * custom `EntityResolver` is installed. Without one of those, untrusted XML
3453
+ * cannot pull in an external entity, so CWE-611 does not apply. Residual DoS
3454
+ * risk (billion laughs / quadratic blowup) is a different weakness, CWE-776,
3455
+ * and is unaffected by this gate.
3456
+ *
3457
+ * The check is per-parser-variable and file-scoped: if the parser is enabled
3458
+ * anywhere, reassigned, or passed somewhere this scan cannot follow, no
3459
+ * sanitizer is emitted and the sink stands. In the OWASP BenchmarkPython
3460
+ * corpus this separates all 8 XXE true positives (which set the feature) from
3461
+ * the 3 false positives (which do not) — both groups call the same API.
3462
+ */
3463
+ function findPythonDefaultSaxParserXxeSanitizers(code) {
3464
+ const sanitizers = [];
3465
+ const lines = code.split('\n');
3466
+ const parserVars = new Set();
3467
+ for (const line of lines) {
3468
+ const made = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:xml\.)?sax\.make_parser\s*\(/);
3469
+ if (made)
3470
+ parserVars.add(made[1]);
3471
+ }
3472
+ if (parserVars.size === 0)
3473
+ return sanitizers;
3474
+ // Drop any parser that has entity processing switched on, gets a custom
3475
+ // resolver, or is rebound to something else.
3476
+ for (const line of lines) {
3477
+ for (const parser of [...parserVars]) {
3478
+ const enabled = new RegExp(`\\b${parser}\\.setFeature\\s*\\([^,]*external_(?:ges|pes)[^,]*,\\s*True\\s*\\)`);
3479
+ const resolver = new RegExp(`\\b${parser}\\.setEntityResolver\\s*\\(`);
3480
+ const rebound = new RegExp(`^\\s*${parser}\\s*=(?!=)`);
3481
+ if (enabled.test(line) || resolver.test(line))
3482
+ parserVars.delete(parser);
3483
+ else if (rebound.test(line) && !/make_parser\s*\(/.test(line))
3484
+ parserVars.delete(parser);
3485
+ }
3486
+ }
3487
+ if (parserVars.size === 0)
3488
+ return sanitizers;
3489
+ // Emit on parse calls that hand the XML to one of those parsers.
3490
+ for (let i = 0; i < lines.length; i++) {
3491
+ const line = lines[i];
3492
+ const parseCall = /\b(?:minidom\.)?parseString\s*\(|\b(?:minidom\.)?parse\s*\(|\bsax\.parse(?:String)?\s*\(/;
3493
+ if (!parseCall.test(line))
3494
+ continue;
3495
+ const usesDefaultParser = [...parserVars].some(p => new RegExp(`\\b${p}\\b`).test(line));
3496
+ if (!usesDefaultParser)
3497
+ continue;
3498
+ sanitizers.push({
3499
+ type: 'python_default_sax_parser',
3500
+ method: 'make_parser',
3501
+ line: i + 1,
3502
+ sanitizes: ['xxe'],
3503
+ });
3504
+ }
3505
+ return sanitizers;
3506
+ }
3507
+ /**
3508
+ * Detect the parse-and-allowlist open-redirect guard (cognium-dev #4
3509
+ * residual-FP audit):
3510
+ *
3511
+ * url = urllib.parse.urlparse(bar)
3512
+ * if url.netloc not in ['google.com'] or url.scheme != 'https':
3513
+ * return "Invalid URL."
3514
+ * return flask.redirect(bar)
3515
+ *
3516
+ * Past the guard the target's host is one of a fixed set, which is the
3517
+ * canonical CWE-601 mitigation — an attacker cannot steer the redirect
3518
+ * off-site. Requires the reject polarity with a terminator, a `netloc`
3519
+ * membership test against a literal collection, and a `urlparse` whose
3520
+ * argument is the value later redirected to.
3521
+ *
3522
+ * Only `open_redirect` is sanitized: knowing the host is allow-listed says
3523
+ * nothing about the path or query, so SSRF and injection sinks are untouched.
3524
+ */
3525
+ function findPythonUrlAllowlistRedirectSanitizers(code) {
3526
+ const sanitizers = [];
3527
+ const lines = code.split('\n');
3528
+ // `url = urlparse(bar)` → parsed name maps back to the raw URL variable.
3529
+ const parsedFrom = new Map();
3530
+ for (const line of lines) {
3531
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:urllib\.parse\.)?urlparse\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
3532
+ if (m)
3533
+ parsedFrom.set(m[1], m[2]);
3534
+ }
3535
+ if (parsedFrom.size === 0)
3536
+ return sanitizers;
3537
+ const terminator = /\b(return|raise|abort\s*\(|sys\.exit\s*\()/;
3538
+ for (let i = 0; i < lines.length; i++) {
3539
+ const guard = lines[i].match(/^(\s*)if\s+([A-Za-z_][A-Za-z0-9_]*)\.netloc\s+not\s+in\s+[[({]/);
3540
+ if (!guard)
3541
+ continue;
3542
+ const guardIndent = guard[1].length;
3543
+ const rawVar = parsedFrom.get(guard[2]);
3544
+ if (!rawVar)
3545
+ continue;
3546
+ let bodyHasTerminator = false;
3547
+ let blockEnd = -1;
3548
+ const maxScan = Math.min(lines.length, i + 26);
3549
+ for (let j = i + 1; j < maxScan; j++) {
3550
+ const line = lines[j];
3551
+ if (line.trim() === '')
3552
+ continue;
3553
+ const indent = line.length - line.trimStart().length;
3554
+ if (indent <= guardIndent) {
3555
+ blockEnd = j - 1;
3556
+ break;
3557
+ }
3558
+ if (terminator.test(line))
3559
+ bodyHasTerminator = true;
3560
+ }
3561
+ if (blockEnd === -1)
3562
+ blockEnd = Math.min(lines.length - 1, i + 25);
3563
+ if (!bodyHasTerminator)
3564
+ continue;
3565
+ const varRe = new RegExp(`\\b${rawVar}\\b`);
3566
+ for (let l = blockEnd + 2; l <= lines.length; l++) {
3567
+ if (!varRe.test(lines[l - 1]))
3568
+ continue;
3569
+ sanitizers.push({
3570
+ type: 'python_url_allowlist_guard',
3571
+ method: 'if',
3572
+ line: l,
3573
+ sanitizes: ['open_redirect'],
3574
+ });
3575
+ }
3576
+ }
3577
+ return sanitizers;
3578
+ }
2957
3579
  /**
2958
3580
  * Detect Python `defusedxml` import-alias usage as xxe sanitizer
2959
3581
  * (cognium-dev #216 Sprint 74 Pattern B).