circle-ir 3.6.0 → 3.8.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/sinks/python.json +9 -0
- package/configs/sources/python.json +57 -17
- package/dist/analysis/taint-matcher.js +43 -0
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/analyzer.js +372 -0
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +329 -0
- package/dist/core/circle-ir-core.cjs +37 -0
- package/dist/core/circle-ir-core.js +37 -0
- package/package.json +1 -1
package/dist/analyzer.js
CHANGED
|
@@ -119,6 +119,34 @@ const JS_TAINTED_PATTERNS = [
|
|
|
119
119
|
{ pattern: /\bdocument\.querySelector\b/, type: 'dom_input' },
|
|
120
120
|
{ pattern: /\.value\b/, type: 'dom_input' },
|
|
121
121
|
];
|
|
122
|
+
/**
|
|
123
|
+
* Python/Flask/Django tainted request access patterns.
|
|
124
|
+
* Used to detect sources in assignments like: user_id = request.args.get('id')
|
|
125
|
+
* Also covers subscript access: user_id = request.args['id']
|
|
126
|
+
*/
|
|
127
|
+
const PYTHON_TAINTED_PATTERNS = [
|
|
128
|
+
{ pattern: /\brequest\.args\b/, type: 'http_param' },
|
|
129
|
+
{ pattern: /\brequest\.form\b/, type: 'http_body' },
|
|
130
|
+
{ pattern: /\brequest\.json\b/, type: 'http_body' },
|
|
131
|
+
{ pattern: /\brequest\.data\b/, type: 'http_body' },
|
|
132
|
+
{ pattern: /\brequest\.files?\b/, type: 'file_input' },
|
|
133
|
+
{ pattern: /\brequest\.headers?\b/, type: 'http_header' },
|
|
134
|
+
{ pattern: /\brequest\.cookies\b/, type: 'http_cookie' },
|
|
135
|
+
{ pattern: /\brequest\.GET\b/, type: 'http_param' },
|
|
136
|
+
{ pattern: /\brequest\.POST\b/, type: 'http_body' },
|
|
137
|
+
{ pattern: /\brequest\.META\b/, type: 'http_header' },
|
|
138
|
+
{ pattern: /\brequest\.FILES\b/, type: 'file_input' },
|
|
139
|
+
{ pattern: /\brequest\.query_params\b/, type: 'http_param' },
|
|
140
|
+
{ pattern: /\brequest\.path_params\b/, type: 'http_param' },
|
|
141
|
+
// Flask raw query/body strings
|
|
142
|
+
{ pattern: /\brequest\.query_string\b/, type: 'http_param' },
|
|
143
|
+
{ pattern: /\brequest\.get_data\s*\(/, type: 'http_body' },
|
|
144
|
+
// Request wrapper helper methods (common in OWASP-style benchmarks and real wrappers)
|
|
145
|
+
{ pattern: /\bget_form_parameter\s*\(/, type: 'http_body' },
|
|
146
|
+
{ pattern: /\bget_query_parameter\s*\(/, type: 'http_param' },
|
|
147
|
+
{ pattern: /\bget_header_value\s*\(/, type: 'http_header' },
|
|
148
|
+
{ pattern: /\bget_cookie_value\s*\(/, type: 'http_cookie' },
|
|
149
|
+
];
|
|
122
150
|
/**
|
|
123
151
|
* Find JavaScript taint sources from variable assignments.
|
|
124
152
|
* Detects patterns like: var userId = req.query.id
|
|
@@ -161,6 +189,227 @@ function findJavaScriptAssignmentSources(sourceCode, language) {
|
|
|
161
189
|
}
|
|
162
190
|
return sources;
|
|
163
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Find Python taint sources from variable assignments and subscript access.
|
|
194
|
+
* Detects patterns like: user_id = request.args.get('id') or request.args['id']
|
|
195
|
+
*/
|
|
196
|
+
function findPythonAssignmentSources(sourceCode, language) {
|
|
197
|
+
const sources = [];
|
|
198
|
+
if (language !== 'python') {
|
|
199
|
+
return sources;
|
|
200
|
+
}
|
|
201
|
+
const lines = sourceCode.split('\n');
|
|
202
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
203
|
+
const line = lines[lineNum];
|
|
204
|
+
const lineNumber = lineNum + 1;
|
|
205
|
+
// Skip comment lines
|
|
206
|
+
if (line.trimStart().startsWith('#'))
|
|
207
|
+
continue;
|
|
208
|
+
// Look for assignments: x = ... or x: type = ...
|
|
209
|
+
const assignmentMatch = line.match(/^(\s*\w[\w.]*)\s*(?::\s*\w[\w\[\], .]*)?\s*=\s*(.+)/);
|
|
210
|
+
if (assignmentMatch) {
|
|
211
|
+
const rhs = assignmentMatch[2];
|
|
212
|
+
for (const { pattern, type } of PYTHON_TAINTED_PATTERNS) {
|
|
213
|
+
if (pattern.test(rhs)) {
|
|
214
|
+
const varMatch = line.match(/^\s*(\w+)\s*/);
|
|
215
|
+
const varName = varMatch ? varMatch[1] : 'unknown';
|
|
216
|
+
const alreadyExists = sources.some(s => s.line === lineNumber && s.type === type);
|
|
217
|
+
if (!alreadyExists) {
|
|
218
|
+
sources.push({
|
|
219
|
+
type,
|
|
220
|
+
location: `${varName} = ${rhs.trim().substring(0, 50)}${rhs.length > 50 ? '...' : ''}`,
|
|
221
|
+
severity: 'high',
|
|
222
|
+
line: lineNumber,
|
|
223
|
+
confidence: 0.95,
|
|
224
|
+
variable: varName,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return sources;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Build a map of tainted variable names → source line via simple forward
|
|
236
|
+
* line-by-line taint propagation for Python.
|
|
237
|
+
*
|
|
238
|
+
* Seeds from PYTHON_TAINTED_PATTERNS; propagates through assignments where the
|
|
239
|
+
* RHS contains a tainted variable. Uses per-key container taint to distinguish
|
|
240
|
+
* map['tainted_key'] from map['safe_key'] and conf.get(s,tainted_k) vs conf.get(s,safe_k).
|
|
241
|
+
*/
|
|
242
|
+
function buildPythonTaintedVars(sourceCode) {
|
|
243
|
+
const tainted = new Map();
|
|
244
|
+
// Per-key container taint: "map['key']" or "conf['section']['key']" → line number
|
|
245
|
+
const containerTainted = new Map();
|
|
246
|
+
const lines = sourceCode.split('\n');
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
const line = lines[i];
|
|
249
|
+
if (line.trimStart().startsWith('#'))
|
|
250
|
+
continue;
|
|
251
|
+
// Subscript assignment: container['key'] = value
|
|
252
|
+
// Tracks taint per-key so map['keyA']='safe' and map['keyB']=param are distinguished.
|
|
253
|
+
const subscriptAssign = line.match(/^\s*(\w+)\[(['"])([^'"]+)\2\]\s*=\s*(.+)$/);
|
|
254
|
+
if (subscriptAssign) {
|
|
255
|
+
const [, container, , key, rhs2] = subscriptAssign;
|
|
256
|
+
const isTaintedRhs = [...tainted.keys()].some(v => new RegExp(`\\b${v}\\b`).test(rhs2));
|
|
257
|
+
if (isTaintedRhs) {
|
|
258
|
+
containerTainted.set(`${container}['${key}']`, i + 1);
|
|
259
|
+
}
|
|
260
|
+
continue; // subscript assignments don't match simple variable regex below
|
|
261
|
+
}
|
|
262
|
+
// ConfigParser set: obj.set('section', 'key', value)
|
|
263
|
+
// Tracks per (section, key) so conf.get('s','keyA') and conf.get('s','keyB') are distinct.
|
|
264
|
+
const setCallMatch = line.match(/^\s*(\w+)\.set\s*\(\s*(['"])([^'"]+)\2\s*,\s*(['"])([^'"]+)\4\s*,\s*(.+?)\s*\)$/);
|
|
265
|
+
if (setCallMatch) {
|
|
266
|
+
const [, obj, , section, , key, rhs2] = setCallMatch;
|
|
267
|
+
const isTaintedRhs = [...tainted.keys()].some(v => new RegExp(`\\b${v}\\b`).test(rhs2));
|
|
268
|
+
if (isTaintedRhs) {
|
|
269
|
+
containerTainted.set(`${obj}['${section}']['${key}']`, i + 1);
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Augmented assignment: var += expr — taint if either side is tainted
|
|
274
|
+
const augAssign = line.match(/^\s*(\w+)\s*\+=\s*(.+)$/);
|
|
275
|
+
if (augAssign) {
|
|
276
|
+
const [, augLhs, augRhs] = augAssign;
|
|
277
|
+
const rhsTainted = [...tainted.keys()].some(v => new RegExp(`\\b${v}\\b`).test(augRhs));
|
|
278
|
+
if (rhsTainted || tainted.has(augLhs)) {
|
|
279
|
+
tainted.set(augLhs, tainted.get(augLhs) ?? (i + 1));
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// For loop: for var in tainted_source — seed loop variable as tainted
|
|
284
|
+
const forLoopMatch = line.match(/^\s*for\s+(\w+)\s+in\s+(.+?)(?:\s*:\s*)?$/);
|
|
285
|
+
if (forLoopMatch) {
|
|
286
|
+
const [, iterVar, iterExpr] = forLoopMatch;
|
|
287
|
+
const isDirectSource = PYTHON_TAINTED_PATTERNS.some(p => p.pattern.test(iterExpr));
|
|
288
|
+
const isPropagated = [...tainted.keys()].some(v => new RegExp(`\\b${v}\\b`).test(iterExpr));
|
|
289
|
+
if (isDirectSource || isPropagated) {
|
|
290
|
+
tainted.set(iterVar, i + 1);
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Regular assignment: var = expr
|
|
295
|
+
const assignMatch = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
|
|
296
|
+
if (!assignMatch)
|
|
297
|
+
continue;
|
|
298
|
+
const [, lhs, rhs] = assignMatch;
|
|
299
|
+
const isDirectSource = PYTHON_TAINTED_PATTERNS.some(p => p.pattern.test(rhs));
|
|
300
|
+
let propagatedFrom;
|
|
301
|
+
// Per-key dict access: bar = container['key']
|
|
302
|
+
const dictAccessMatch = rhs.trim().match(/^(\w+)\[(['"])([^'"]+)\2\]$/);
|
|
303
|
+
if (dictAccessMatch) {
|
|
304
|
+
const [, container, , key] = dictAccessMatch;
|
|
305
|
+
if (containerTainted.has(`${container}['${key}']`)) {
|
|
306
|
+
propagatedFrom = `${container}['${key}']`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Per-key configparser get: bar = conf.get('section', 'key')
|
|
310
|
+
if (!propagatedFrom) {
|
|
311
|
+
const confGetMatch = rhs.trim().match(/^(\w+)\.get\s*\(\s*(['"])([^'"]+)\2\s*,\s*(['"])([^'"]+)\4\s*\)$/);
|
|
312
|
+
if (confGetMatch) {
|
|
313
|
+
const [, obj, , section, , key] = confGetMatch;
|
|
314
|
+
if (containerTainted.has(`${obj}['${section}']['${key}']`)) {
|
|
315
|
+
propagatedFrom = `${obj}['${section}']['${key}']`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Standard variable propagation (skip os.environ/os.getenv — safe env reads)
|
|
320
|
+
if (!propagatedFrom) {
|
|
321
|
+
const isSafeEnvRead = /\bos\.environ\.get\s*\(/.test(rhs) || /\bos\.getenv\s*\(/.test(rhs);
|
|
322
|
+
if (!isSafeEnvRead) {
|
|
323
|
+
propagatedFrom = [...tainted.keys()].find(v => new RegExp(`\\b${v}\\b`).test(rhs));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (isDirectSource) {
|
|
327
|
+
tainted.set(lhs, i + 1);
|
|
328
|
+
}
|
|
329
|
+
else if (propagatedFrom !== undefined) {
|
|
330
|
+
tainted.set(lhs, i + 1);
|
|
331
|
+
}
|
|
332
|
+
else if (tainted.has(lhs)) {
|
|
333
|
+
// Variable overwritten — preserve taint for null-guard patterns like:
|
|
334
|
+
// if not param:
|
|
335
|
+
// param = ""
|
|
336
|
+
const prevNonBlank = lines.slice(0, i).reverse().find(l => l.trim() && !l.trimStart().startsWith('#'));
|
|
337
|
+
const isNullGuard = prevNonBlank !== undefined && (new RegExp(`^\\s*if\\s+not\\s+${lhs}\\s*:`).test(prevNonBlank) ||
|
|
338
|
+
new RegExp(`^\\s*if\\s+${lhs}\\s+is\\s+None\\s*:`).test(prevNonBlank));
|
|
339
|
+
if (!isNullGuard) {
|
|
340
|
+
tainted.delete(lhs);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return tainted;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Detect Python apostrophe-check sanitizer guards, e.g.:
|
|
348
|
+
* if "'" in bar:
|
|
349
|
+
* return # or raise / abort
|
|
350
|
+
* Returns the set of variable names that are guarded this way.
|
|
351
|
+
*/
|
|
352
|
+
function findPythonQuoteSanitizedVars(sourceCode) {
|
|
353
|
+
const sanitized = new Set();
|
|
354
|
+
const lines = sourceCode.split('\n');
|
|
355
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
356
|
+
// Match any apostrophe/quote check: if "'" in var:, if '\'' in var:, if '"' in var:
|
|
357
|
+
// Uses full quoted-string pattern to handle Python's various literal forms.
|
|
358
|
+
const m = lines[i].match(/^\s*if\s+(?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s+in\s+(\w+)\s*:/);
|
|
359
|
+
if (!m)
|
|
360
|
+
continue;
|
|
361
|
+
// Look ahead up to 5 lines for a return/raise/abort/continue/break
|
|
362
|
+
// The guard body may be multi-line (e.g. RESPONSE += (...) \n return).
|
|
363
|
+
// Stop early if we encounter a line at the same or lesser indentation as the if (block exit).
|
|
364
|
+
const ifIndent = (lines[i].match(/^(\s*)/) ?? ['', ''])[1].length;
|
|
365
|
+
let foundExit = false;
|
|
366
|
+
for (let j = i + 1; j <= Math.min(i + 5, lines.length - 1); j++) {
|
|
367
|
+
const jLine = lines[j] ?? '';
|
|
368
|
+
if (!jLine.trim())
|
|
369
|
+
continue; // skip blank lines
|
|
370
|
+
const jIndent = (jLine.match(/^(\s*)/) ?? ['', ''])[1].length;
|
|
371
|
+
if (jIndent <= ifIndent)
|
|
372
|
+
break; // left the if-block
|
|
373
|
+
if (/^(return|raise|abort|continue|break)\b/.test(jLine.trim())) {
|
|
374
|
+
foundExit = true;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (foundExit) {
|
|
379
|
+
sanitized.add(m[1]);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return sanitized;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Detect Python trust boundary violations:
|
|
386
|
+
* flask.session[key] = value (or session[key] = value)
|
|
387
|
+
* where key or value references a tainted variable.
|
|
388
|
+
*/
|
|
389
|
+
function findPythonTrustBoundaryViolations(sourceCode, language, taintedVars) {
|
|
390
|
+
if (language !== 'python' || taintedVars.size === 0)
|
|
391
|
+
return [];
|
|
392
|
+
const violations = [];
|
|
393
|
+
const lines = sourceCode.split('\n');
|
|
394
|
+
const SESSION_WRITE = /(?:flask\.)?session\[([^\]]+)\]\s*=\s*(.+)$/;
|
|
395
|
+
const taintedKeys = [...taintedVars.keys()];
|
|
396
|
+
const earliestSourceLine = Math.min(...[...taintedVars.values()]);
|
|
397
|
+
for (let i = 0; i < lines.length; i++) {
|
|
398
|
+
const line = lines[i];
|
|
399
|
+
if (line.trimStart().startsWith('#'))
|
|
400
|
+
continue;
|
|
401
|
+
const m = line.match(SESSION_WRITE);
|
|
402
|
+
if (!m)
|
|
403
|
+
continue;
|
|
404
|
+
const [, keyExpr, valueExpr] = m;
|
|
405
|
+
const keyTainted = taintedKeys.some(v => new RegExp(`\\b${v}\\b`).test(keyExpr));
|
|
406
|
+
const valueTainted = taintedKeys.some(v => new RegExp(`\\b${v}\\b`).test(valueExpr));
|
|
407
|
+
if (keyTainted || valueTainted) {
|
|
408
|
+
violations.push({ sourceLine: earliestSourceLine, sinkLine: i + 1 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return violations;
|
|
412
|
+
}
|
|
164
413
|
/**
|
|
165
414
|
* Find DOM XSS sinks from property assignments in JavaScript.
|
|
166
415
|
* Detects patterns like: element.innerHTML = userInput
|
|
@@ -459,6 +708,9 @@ export async function analyze(code, filePath, language, options = {}) {
|
|
|
459
708
|
// Add sources for JavaScript variable assignments with tainted patterns
|
|
460
709
|
const jsAssignmentSources = findJavaScriptAssignmentSources(code, language);
|
|
461
710
|
taint.sources.push(...jsAssignmentSources);
|
|
711
|
+
// Add sources for Python variable assignments with tainted request patterns
|
|
712
|
+
const pythonAssignmentSources = findPythonAssignmentSources(code, language);
|
|
713
|
+
taint.sources.push(...pythonAssignmentSources);
|
|
462
714
|
// Add sinks for JavaScript DOM XSS patterns (innerHTML, document.write, etc.)
|
|
463
715
|
const jsDOMSinks = findJavaScriptDOMSinks(code, language);
|
|
464
716
|
for (const domSink of jsDOMSinks) {
|
|
@@ -490,6 +742,64 @@ export async function analyze(code, filePath, language, options = {}) {
|
|
|
490
742
|
taint.sinks = filterCleanVariableSinks(taint.sinks, calls, constPropResult.tainted, constPropResult.symbols, dfg, constPropResult.sanitizedVars, constPropResult.synchronizedLines);
|
|
491
743
|
// Filter sinks that are wrapped by sanitizers on the same line
|
|
492
744
|
taint.sinks = filterSanitizedSinks(taint.sinks, taint.sanitizers ?? [], calls);
|
|
745
|
+
// Python: reduce XPath false-positives using forward taint propagation +
|
|
746
|
+
// apostrophe-guard sanitizer detection; also detect trust boundary violations
|
|
747
|
+
// (flask.session[key] = value) which are subscript assignments, not call nodes.
|
|
748
|
+
if (language === 'python') {
|
|
749
|
+
const pyTaintedVars = buildPythonTaintedVars(code);
|
|
750
|
+
const pySanitizedVars = findPythonQuoteSanitizedVars(code);
|
|
751
|
+
// Propagate sanitization: if bar is sanitized and query = f"...{bar}...", query is also sanitized
|
|
752
|
+
for (const line of code.split('\n')) {
|
|
753
|
+
const am = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
|
|
754
|
+
if (!am)
|
|
755
|
+
continue;
|
|
756
|
+
const [, lhs, rhs] = am;
|
|
757
|
+
if ([...pySanitizedVars].some(v => new RegExp(`\\b${v}\\b`).test(rhs))) {
|
|
758
|
+
pySanitizedVars.add(lhs);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Detect inline .replace() sanitizers: query = f"...{bar.replace('\'', ''')}..."
|
|
762
|
+
// The tainted var appears with .replace() in the rhs — treat lhs as XPath-safe
|
|
763
|
+
for (const line of code.split('\n')) {
|
|
764
|
+
const am = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
|
|
765
|
+
if (!am)
|
|
766
|
+
continue;
|
|
767
|
+
const [, lhs, rhs] = am;
|
|
768
|
+
const hasReplaceOnTainted = [...pyTaintedVars.keys()].some(v => new RegExp(`\\b${v}\\.replace\\s*\\(`).test(rhs));
|
|
769
|
+
if (hasReplaceOnTainted)
|
|
770
|
+
pySanitizedVars.add(lhs);
|
|
771
|
+
}
|
|
772
|
+
const pySourceLines = code.split('\n');
|
|
773
|
+
// Filter XPath sinks: keep only if a tainted var is used at the sink line
|
|
774
|
+
taint.sinks = taint.sinks.filter(sink => {
|
|
775
|
+
if (sink.type !== 'xpath_injection')
|
|
776
|
+
return true;
|
|
777
|
+
const sinkLineText = pySourceLines[sink.line - 1] ?? '';
|
|
778
|
+
const taintedVarOnLine = [...pyTaintedVars.keys()].find(v => new RegExp(`\\b${v}\\b`).test(sinkLineText));
|
|
779
|
+
if (!taintedVarOnLine)
|
|
780
|
+
return false;
|
|
781
|
+
if (pySanitizedVars.has(taintedVarOnLine))
|
|
782
|
+
return false;
|
|
783
|
+
// Suppress parameterized XPath: root.xpath(query, name=bar) where bar is a keyword arg
|
|
784
|
+
if (new RegExp(`\\.xpath\\s*\\([^)]*\\b\\w+\\s*=\\s*\\b${taintedVarOnLine}\\b`).test(sinkLineText))
|
|
785
|
+
return false;
|
|
786
|
+
return true;
|
|
787
|
+
});
|
|
788
|
+
// Add trust boundary sinks from session subscript assignments
|
|
789
|
+
const trustViolations = findPythonTrustBoundaryViolations(code, language, pyTaintedVars);
|
|
790
|
+
for (const v of trustViolations) {
|
|
791
|
+
const alreadyExists = taint.sinks.some(s => s.line === v.sinkLine && s.type === 'trust_boundary');
|
|
792
|
+
if (!alreadyExists) {
|
|
793
|
+
taint.sinks.push({
|
|
794
|
+
type: 'trust_boundary',
|
|
795
|
+
cwe: 'CWE-501',
|
|
796
|
+
line: v.sinkLine,
|
|
797
|
+
location: `session write at line ${v.sinkLine}`,
|
|
798
|
+
confidence: 0.85,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
493
803
|
// Propagate taint through dataflow to find verified flows
|
|
494
804
|
if (taint.sources.length > 0 && taint.sinks.length > 0) {
|
|
495
805
|
const propagationResult = propagateTaint(dfg, calls, taint.sources, taint.sinks, taint.sanitizers ?? []);
|
|
@@ -805,8 +1115,70 @@ export async function analyzeForAPI(code, filePath, language, options = {}) {
|
|
|
805
1115
|
filteredSinks = filterCleanVariableSinks(filteredSinks, calls, constPropResult.tainted, constPropResult.symbols, undefined, constPropResult.sanitizedVars, constPropResult.synchronizedLines);
|
|
806
1116
|
// Filter sinks wrapped by sanitizers on the same line
|
|
807
1117
|
filteredSinks = filterSanitizedSinks(filteredSinks, taint.sanitizers ?? [], calls);
|
|
1118
|
+
// Python: reduce XPath false-positives using forward taint propagation +
|
|
1119
|
+
// apostrophe-guard sanitizer detection.
|
|
1120
|
+
let pythonTaintedVars = new Map();
|
|
1121
|
+
if (language === 'python') {
|
|
1122
|
+
pythonTaintedVars = buildPythonTaintedVars(code);
|
|
1123
|
+
const pythonSanitizedVars = findPythonQuoteSanitizedVars(code);
|
|
1124
|
+
// Propagate sanitization: if bar is sanitized and query = f"...{bar}...", query is also sanitized
|
|
1125
|
+
for (const line of code.split('\n')) {
|
|
1126
|
+
const am = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
|
|
1127
|
+
if (!am)
|
|
1128
|
+
continue;
|
|
1129
|
+
const [, lhs, rhs] = am;
|
|
1130
|
+
if ([...pythonSanitizedVars].some(v => new RegExp(`\\b${v}\\b`).test(rhs))) {
|
|
1131
|
+
pythonSanitizedVars.add(lhs);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Detect inline .replace() sanitizers: query = f"...{bar.replace('\'', ''')}..."
|
|
1135
|
+
for (const line of code.split('\n')) {
|
|
1136
|
+
const am = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
|
|
1137
|
+
if (!am)
|
|
1138
|
+
continue;
|
|
1139
|
+
const [, lhs, rhs] = am;
|
|
1140
|
+
const hasReplaceOnTainted = [...pythonTaintedVars.keys()].some(v => new RegExp(`\\b${v}\\.replace\\s*\\(`).test(rhs));
|
|
1141
|
+
if (hasReplaceOnTainted)
|
|
1142
|
+
pythonSanitizedVars.add(lhs);
|
|
1143
|
+
}
|
|
1144
|
+
const sourceLines = code.split('\n');
|
|
1145
|
+
filteredSinks = filteredSinks.filter(sink => {
|
|
1146
|
+
if (sink.type !== 'xpath_injection')
|
|
1147
|
+
return true;
|
|
1148
|
+
// Keep XPath sink only if a tainted variable is used at the sink line
|
|
1149
|
+
const sinkLineText = sourceLines[sink.line - 1] ?? '';
|
|
1150
|
+
const taintedVarOnLine = [...pythonTaintedVars.keys()].find(v => new RegExp(`\\b${v}\\b`).test(sinkLineText));
|
|
1151
|
+
if (!taintedVarOnLine)
|
|
1152
|
+
return false;
|
|
1153
|
+
// Kill if the variable is protected by an apostrophe guard
|
|
1154
|
+
if (pythonSanitizedVars.has(taintedVarOnLine))
|
|
1155
|
+
return false;
|
|
1156
|
+
// Suppress parameterized XPath: root.xpath(query, name=bar) where bar is a keyword arg
|
|
1157
|
+
if (new RegExp(`\\.xpath\\s*\\([^)]*\\b\\w+\\s*=\\s*\\b${taintedVarOnLine}\\b`).test(sinkLineText))
|
|
1158
|
+
return false;
|
|
1159
|
+
return true;
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
808
1162
|
// Generate vulnerabilities from source-sink pairs
|
|
809
1163
|
const vulnerabilities = findVulnerabilities(taint.sources, filteredSinks, calls, constPropResult);
|
|
1164
|
+
// Python: detect trust boundary violations (flask.session[key] = taintedVal)
|
|
1165
|
+
if (language === 'python') {
|
|
1166
|
+
const trustViolations = findPythonTrustBoundaryViolations(code, language, pythonTaintedVars);
|
|
1167
|
+
for (const v of trustViolations) {
|
|
1168
|
+
// Avoid duplicate: only add if no existing vulnerability for same sink line
|
|
1169
|
+
const alreadyReported = vulnerabilities.some(existing => existing.sink.line === v.sinkLine && existing.type === 'trust_boundary');
|
|
1170
|
+
if (!alreadyReported) {
|
|
1171
|
+
vulnerabilities.push({
|
|
1172
|
+
type: 'trust_boundary',
|
|
1173
|
+
cwe: 'CWE-501',
|
|
1174
|
+
severity: 'medium',
|
|
1175
|
+
source: { line: v.sourceLine, type: 'http_param' },
|
|
1176
|
+
sink: { line: v.sinkLine, type: 'trust_boundary' },
|
|
1177
|
+
confidence: 0.85,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
810
1182
|
const analysisTime = performance.now() - analysisStart;
|
|
811
1183
|
const totalTime = performance.now() - startTime;
|
|
812
1184
|
return {
|