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.
- package/configs/README.md +39 -0
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +43 -3
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +624 -2
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.js +68 -1
- package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
- package/dist/analysis/passes/taint-matcher-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
- package/dist/analysis/rules.d.ts.map +1 -1
- package/dist/analysis/rules.js +42 -0
- package/dist/analysis/rules.js.map +1 -1
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +24 -8
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/browser/circle-ir.js +582 -604
- package/dist/core/circle-ir-core.cjs +59 -13
- package/dist/core/circle-ir-core.js +59 -13
- package/dist/languages/plugins/bash.d.ts.map +1 -1
- package/dist/languages/plugins/bash.js +12 -16
- package/dist/languages/plugins/bash.js.map +1 -1
- package/dist/languages/plugins/go.d.ts +4 -0
- package/dist/languages/plugins/go.d.ts.map +1 -1
- package/dist/languages/plugins/go.js +16 -32
- package/dist/languages/plugins/go.js.map +1 -1
- package/dist/languages/plugins/java.d.ts +9 -1
- package/dist/languages/plugins/java.d.ts.map +1 -1
- package/dist/languages/plugins/java.js +16 -127
- package/dist/languages/plugins/java.js.map +1 -1
- package/dist/languages/plugins/javascript.d.ts +7 -0
- package/dist/languages/plugins/javascript.d.ts.map +1 -1
- package/dist/languages/plugins/javascript.js +10 -117
- package/dist/languages/plugins/javascript.js.map +1 -1
- package/dist/languages/plugins/python.d.ts +7 -0
- package/dist/languages/plugins/python.d.ts.map +1 -1
- package/dist/languages/plugins/python.js +24 -177
- package/dist/languages/plugins/python.js.map +1 -1
- package/dist/languages/plugins/rust.d.ts +9 -0
- package/dist/languages/plugins/rust.d.ts.map +1 -1
- package/dist/languages/plugins/rust.js +22 -110
- package/dist/languages/plugins/rust.js.map +1 -1
- package/dist/languages/types.d.ts +3 -3
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/types/config.d.ts +15 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/index.d.ts +13 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +37 -1
- package/dist/types/index.js.map +1 -1
- 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,
|
|
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).
|