circle-ir 4.15.0 → 4.16.1
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/golang.json +4 -4
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +11 -7
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/constant-caller-gate.d.ts +39 -0
- package/dist/analysis/constant-caller-gate.d.ts.map +1 -0
- package/dist/analysis/constant-caller-gate.js +298 -0
- package/dist/analysis/constant-caller-gate.js.map +1 -0
- package/dist/analysis/constant-propagation/evaluator.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/evaluator.js +3 -1
- package/dist/analysis/constant-propagation/evaluator.js.map +1 -1
- package/dist/analysis/constant-propagation/patterns.d.ts +19 -0
- package/dist/analysis/constant-propagation/patterns.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/patterns.js +33 -0
- package/dist/analysis/constant-propagation/patterns.js.map +1 -1
- package/dist/analysis/constant-propagation/propagator.d.ts +59 -0
- package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/propagator.js +396 -49
- package/dist/analysis/constant-propagation/propagator.js.map +1 -1
- package/dist/analysis/constant-propagation/types.d.ts +9 -0
- package/dist/analysis/constant-propagation/types.d.ts.map +1 -1
- package/dist/analysis/findings.d.ts.map +1 -1
- package/dist/analysis/findings.js +134 -0
- package/dist/analysis/findings.js.map +1 -1
- package/dist/analysis/interprocedural.d.ts.map +1 -1
- package/dist/analysis/interprocedural.js +127 -3
- package/dist/analysis/interprocedural.js.map +1 -1
- package/dist/analysis/java-array-element-keys.d.ts +14 -0
- package/dist/analysis/java-array-element-keys.d.ts.map +1 -0
- package/dist/analysis/java-array-element-keys.js +36 -0
- package/dist/analysis/java-array-element-keys.js.map +1 -0
- package/dist/analysis/js-crlf-strip.d.ts +26 -0
- package/dist/analysis/js-crlf-strip.d.ts.map +1 -0
- package/dist/analysis/js-crlf-strip.js +244 -0
- package/dist/analysis/js-crlf-strip.js.map +1 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +25 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
- package/dist/analysis/passes/csharp-sanitizer-credit.js +157 -12
- package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts +38 -3
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +1234 -127
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.js +53 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
- package/dist/analysis/passes/plaintext-password-storage-pass.d.ts +6 -0
- package/dist/analysis/passes/plaintext-password-storage-pass.d.ts.map +1 -1
- package/dist/analysis/passes/plaintext-password-storage-pass.js +160 -0
- package/dist/analysis/passes/plaintext-password-storage-pass.js.map +1 -1
- package/dist/analysis/passes/scan-secrets-pass.d.ts +1 -1
- package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
- package/dist/analysis/passes/scan-secrets-pass.js +7 -1
- package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.js +326 -34
- 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 +2 -2
- package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.js +1376 -58
- package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
- package/dist/analysis/passes/weak-hash-pass.d.ts +6 -0
- package/dist/analysis/passes/weak-hash-pass.d.ts.map +1 -1
- package/dist/analysis/passes/weak-hash-pass.js +191 -4
- package/dist/analysis/passes/weak-hash-pass.js.map +1 -1
- package/dist/analysis/passes/weak-random-pass.d.ts +5 -0
- package/dist/analysis/passes/weak-random-pass.d.ts.map +1 -1
- package/dist/analysis/passes/weak-random-pass.js +30 -0
- package/dist/analysis/passes/weak-random-pass.js.map +1 -1
- package/dist/analysis/project-callee-escape-gate.d.ts +40 -0
- package/dist/analysis/project-callee-escape-gate.d.ts.map +1 -0
- package/dist/analysis/project-callee-escape-gate.js +243 -0
- package/dist/analysis/project-callee-escape-gate.js.map +1 -0
- package/dist/analysis/rust-literal-guard-rebind.d.ts +34 -0
- package/dist/analysis/rust-literal-guard-rebind.d.ts.map +1 -0
- package/dist/analysis/rust-literal-guard-rebind.js +197 -0
- package/dist/analysis/rust-literal-guard-rebind.js.map +1 -0
- package/dist/analysis/rust-literal-guard.d.ts +33 -0
- package/dist/analysis/rust-literal-guard.d.ts.map +1 -0
- package/dist/analysis/rust-literal-guard.js +422 -0
- package/dist/analysis/rust-literal-guard.js.map +1 -0
- package/dist/analysis/scalar-derivation.d.ts +8 -0
- package/dist/analysis/scalar-derivation.d.ts.map +1 -1
- package/dist/analysis/scalar-derivation.js +172 -0
- package/dist/analysis/scalar-derivation.js.map +1 -1
- package/dist/analysis/taint-matcher.d.ts +2 -2
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +531 -31
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/analysis/taint-propagation.d.ts +26 -1
- package/dist/analysis/taint-propagation.d.ts.map +1 -1
- package/dist/analysis/taint-propagation.js +234 -13
- package/dist/analysis/taint-propagation.js.map +1 -1
- package/dist/analyzer.d.ts +16 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +13 -3
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +24432 -19928
- package/dist/core/circle-ir-core.cjs +4936 -3575
- package/dist/core/circle-ir-core.js +4936 -3575
- package/dist/core/extractors/calls.d.ts +12 -1
- package/dist/core/extractors/calls.d.ts.map +1 -1
- package/dist/core/extractors/calls.js +332 -22
- package/dist/core/extractors/calls.js.map +1 -1
- package/dist/core/extractors/dfg.js +102 -10
- package/dist/core/extractors/dfg.js.map +1 -1
- package/dist/core/extractors/types.d.ts +5 -1
- package/dist/core/extractors/types.d.ts.map +1 -1
- package/dist/core/extractors/types.js +62 -0
- package/dist/core/extractors/types.js.map +1 -1
- package/dist/graph/analysis-pass.d.ts +6 -1
- package/dist/graph/analysis-pass.d.ts.map +1 -1
- package/dist/graph/analysis-pass.js +2 -1
- package/dist/graph/analysis-pass.js.map +1 -1
- package/dist/navigation/navigation-index.d.ts +134 -14
- package/dist/navigation/navigation-index.d.ts.map +1 -1
- package/dist/navigation/navigation-index.js +1158 -104
- package/dist/navigation/navigation-index.js.map +1 -1
- package/dist/navigation/types.d.ts +59 -2
- package/dist/navigation/types.d.ts.map +1 -1
- package/dist/resolution/cross-file.d.ts +9 -0
- package/dist/resolution/cross-file.d.ts.map +1 -1
- package/dist/resolution/cross-file.js +102 -43
- package/dist/resolution/cross-file.js.map +1 -1
- package/dist/resolution/type-hierarchy.d.ts +3 -0
- package/dist/resolution/type-hierarchy.d.ts.map +1 -1
- package/dist/resolution/type-hierarchy.js +45 -1
- package/dist/resolution/type-hierarchy.js.map +1 -1
- package/dist/types/index.d.ts +33 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* Depends on: sink-filter, constant-propagation
|
|
12
12
|
*/
|
|
13
13
|
import { findJavaSpringReturnXSSSinks } from './language-sources-pass.js';
|
|
14
|
-
import { identifierOutsideSanitizerCalls, propagateTaint } from '../taint-propagation.js';
|
|
14
|
+
import { expressionFullyWrappedBySanitizer, identifierOutsideSanitizerCalls, identifierOutsideSanitizerCallsInText, propagateTaint } from '../taint-propagation.js';
|
|
15
15
|
import { isFalsePositive, isCorrelatedPredicateFP } from '../constant-propagation.js';
|
|
16
|
-
import { buildJavaTaintedVars, buildPythonTaintedVars, buildRustTaintedVars, findPythonTrustBoundaryViolations, findPythonReturnXSSSinks } from './language-sources-pass.js';
|
|
16
|
+
import { buildJavaTaintedVars, buildPythonTaintedVars, buildRustTaintedVars, findPythonTrustBoundaryViolations, findPythonReturnXSSSinks, findJavaWikiPluginReturnXSSSinks } from './language-sources-pass.js';
|
|
17
|
+
import { parseJavaArrayAccessExpression } from '../java-array-element-keys.js';
|
|
17
18
|
import { canSourceReachSink, sourceSemanticsAllowed } from '../findings.js';
|
|
18
19
|
import { walkBackwardDefs } from '../dfg-walk.js';
|
|
19
20
|
import { sanitizerCoversSink } from '../sanitizer-index.js';
|
|
21
|
+
import { HTML_CONTEXT_ENCODER_METHODS } from '../constant-propagation/patterns.js';
|
|
20
22
|
import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
|
|
21
23
|
/**
|
|
22
24
|
* cognium-dev#328 — the const-prop per-step veto, with two scoped exemptions.
|
|
@@ -42,6 +44,94 @@ import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
|
|
|
42
44
|
* code, resolved constant) are unaffected.
|
|
43
45
|
*/
|
|
44
46
|
const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
|
|
47
|
+
/** Method name enclosing `line`, from IR type ranges; null when unknown. */
|
|
48
|
+
function methodAtLine(graph, line) {
|
|
49
|
+
for (const t of graph.ir.types ?? []) {
|
|
50
|
+
for (const m of t.methods) {
|
|
51
|
+
if (line >= m.start_line && line <= m.end_line)
|
|
52
|
+
return m.name;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** #589: method-call sanitizer that is a context-specific HTML / markup encoder. */
|
|
58
|
+
function isHtmlContextEncoderSanitizer(san) {
|
|
59
|
+
if (san.type === 'derived_html_encoder')
|
|
60
|
+
return true; // same-file wrapper of an HTML encoder (#589)
|
|
61
|
+
if (san.type !== 'method_call' || !san.method)
|
|
62
|
+
return false;
|
|
63
|
+
const tail = san.method.replace(/\(\)$/, '').split('.').pop() ?? '';
|
|
64
|
+
return HTML_CONTEXT_ENCODER_METHODS.has(tail);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* #589: true when some def-use chain from `startDefId` back to the flow's
|
|
68
|
+
* source reaches it without passing through a def that a covering sanitizer
|
|
69
|
+
* cleans. A def is covered when a sanitizer for `sinkType` sits on its line
|
|
70
|
+
* (at or after the source line) and none of its predecessor variables
|
|
71
|
+
* appears on that line outside the sanitizer call. Every def on the walk is
|
|
72
|
+
* tested, so a join one or more assignments before the sink is still seen.
|
|
73
|
+
* Unknown defs, chains that reach the source line raw, chains that end in a
|
|
74
|
+
* def with no predecessor, and an exhausted budget all count as uncovered.
|
|
75
|
+
*/
|
|
76
|
+
function hasUncoveredChain(startDefId, sinkType, sourceLine, graph, sanitizersByLine, codeLines) {
|
|
77
|
+
const visited = new Set();
|
|
78
|
+
let budget = 256;
|
|
79
|
+
const covered = (defId, line) => {
|
|
80
|
+
if (line < sourceLine)
|
|
81
|
+
return false;
|
|
82
|
+
const sans = (sanitizersByLine.get(line) ?? []).filter(s => sanitizerCoversSink(s, sinkType));
|
|
83
|
+
if (sans.length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
const preds = graph.chainsByToDef.get(defId) ?? [];
|
|
86
|
+
if (preds.length === 0)
|
|
87
|
+
return true;
|
|
88
|
+
const def = graph.defById.get(defId);
|
|
89
|
+
const text = codeLines?.[line - 1];
|
|
90
|
+
let rhs = text;
|
|
91
|
+
if (text && def) {
|
|
92
|
+
const v = def.variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
+
const m = new RegExp(`(?:^|[^\\w$.])${v}\\s*=(?!=)`).exec(text);
|
|
94
|
+
if (m)
|
|
95
|
+
rhs = text.slice(m.index + m[0].length);
|
|
96
|
+
}
|
|
97
|
+
for (const chain of preds) {
|
|
98
|
+
const predVar = graph.defById.get(chain.from_def)?.variable ?? chain.via;
|
|
99
|
+
const outside = rhs ? identifierOutsideSanitizerCallsInText(predVar, rhs, sans, sinkType) : null;
|
|
100
|
+
// No sanitizer call found in the text: fall back to the single-chain
|
|
101
|
+
// credit (one predecessor that the sanitizer line consumes).
|
|
102
|
+
if (outside === null) {
|
|
103
|
+
if (preds.length > 1)
|
|
104
|
+
return false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (outside)
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
const stack = [startDefId];
|
|
113
|
+
while (stack.length > 0) {
|
|
114
|
+
if (--budget < 0)
|
|
115
|
+
return true;
|
|
116
|
+
const id = stack.pop();
|
|
117
|
+
if (visited.has(id))
|
|
118
|
+
continue;
|
|
119
|
+
visited.add(id);
|
|
120
|
+
const def = graph.defById.get(id);
|
|
121
|
+
if (!def)
|
|
122
|
+
return true;
|
|
123
|
+
if (covered(id, def.line))
|
|
124
|
+
continue;
|
|
125
|
+
if (def.line <= sourceLine)
|
|
126
|
+
return true;
|
|
127
|
+
const preds = graph.chainsByToDef.get(id) ?? [];
|
|
128
|
+
if (preds.length === 0)
|
|
129
|
+
return true;
|
|
130
|
+
for (const chain of preds)
|
|
131
|
+
stack.push(chain.from_def);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
45
135
|
/**
|
|
46
136
|
* #515 — find the real source a Python derived variable actually references.
|
|
47
137
|
* Reads the derivation's right-hand side at its origin line (f-string braces
|
|
@@ -213,7 +303,112 @@ function resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines
|
|
|
213
303
|
}
|
|
214
304
|
return best;
|
|
215
305
|
}
|
|
216
|
-
|
|
306
|
+
/** Drop a trailing `//…` comment, leaving `//` inside string literals alone. */
|
|
307
|
+
function stripJavaTrailingComment(text) {
|
|
308
|
+
let i = 0;
|
|
309
|
+
while (i < text.length) {
|
|
310
|
+
const ch = text[i];
|
|
311
|
+
if (ch === '"') {
|
|
312
|
+
i++;
|
|
313
|
+
while (i < text.length && text[i] !== '"') {
|
|
314
|
+
if (text[i] === '\\') {
|
|
315
|
+
i += 2;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
i++;
|
|
319
|
+
}
|
|
320
|
+
if (i < text.length)
|
|
321
|
+
i++;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === '/' && text[i + 1] === '/')
|
|
325
|
+
return text.slice(0, i);
|
|
326
|
+
i++;
|
|
327
|
+
}
|
|
328
|
+
return text;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* #515 (Java/C#) — like `resolvePythonDerivedAnchor`, pick the real source
|
|
332
|
+
* named on the derivation's right-hand side (following derived chains).
|
|
333
|
+
*/
|
|
334
|
+
function resolveJavaDerivedAnchor(varName, derived, realSourceByVar, codeLines, visiting) {
|
|
335
|
+
if (visiting.has(varName))
|
|
336
|
+
return undefined;
|
|
337
|
+
visiting.add(varName);
|
|
338
|
+
const originLine = derived.get(varName);
|
|
339
|
+
if (originLine === undefined)
|
|
340
|
+
return undefined;
|
|
341
|
+
const text = codeLines[originLine - 1];
|
|
342
|
+
if (!text)
|
|
343
|
+
return undefined;
|
|
344
|
+
const eq = text.search(/(?<![=!<>+\-*/%&|^:])=(?!=)/);
|
|
345
|
+
if (eq < 0)
|
|
346
|
+
return undefined;
|
|
347
|
+
const rhs = maskStringLiteralsForTokenScan(stripJavaTrailingComment(text.slice(eq + 1)));
|
|
348
|
+
let best;
|
|
349
|
+
for (const m of rhs.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
|
|
350
|
+
const id = m[0];
|
|
351
|
+
if (id === varName)
|
|
352
|
+
continue;
|
|
353
|
+
const real = realSourceByVar.get(id);
|
|
354
|
+
const derivedLine = derived.get(id);
|
|
355
|
+
let cand;
|
|
356
|
+
if (derivedLine !== undefined && (!real || derivedLine > real.line)) {
|
|
357
|
+
cand = resolveJavaDerivedAnchor(id, derived, realSourceByVar, codeLines, visiting);
|
|
358
|
+
}
|
|
359
|
+
if (!cand)
|
|
360
|
+
cand = real;
|
|
361
|
+
if (cand && (!best || cand.line < best.line))
|
|
362
|
+
best = cand;
|
|
363
|
+
}
|
|
364
|
+
return best;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* cognium-dev#624 — expression-scan flows through `lhs = seed.receiverCall()`
|
|
368
|
+
* (e.g. `label = page.getName()`) are vetoed as `variable_not_tainted` because
|
|
369
|
+
* const-prop never seeds those derived names. Argument-style derivations
|
|
370
|
+
* (`shown = encodeForPage(nick)`) must keep the veto so sanitizer credit holds.
|
|
371
|
+
*/
|
|
372
|
+
function javaReceiverChainDerivedVar(rhsByLocal, localVar, seedVar) {
|
|
373
|
+
if (!seedVar || localVar === seedVar)
|
|
374
|
+
return false;
|
|
375
|
+
const rhsList = rhsByLocal.get(localVar);
|
|
376
|
+
if (!rhsList)
|
|
377
|
+
return false;
|
|
378
|
+
const esc = seedVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
379
|
+
const receiverOnRhs = new RegExp(`(?:^|[^\\w.$])${esc}\\s*\\.`, 'u');
|
|
380
|
+
return rhsList.some(rhs => receiverOnRhs.test(rhs));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* cognium-dev#806 — the assignment RHS texts of each Java local, indexed once
|
|
384
|
+
* per file. `javaReceiverChainDerivedVar` used to re-run its declaration regex
|
|
385
|
+
* over every line for every (source, derived local) pair, which made large
|
|
386
|
+
* files take seconds. Same per-line matching, done once.
|
|
387
|
+
*/
|
|
388
|
+
function indexJavaLocalAssignments(code) {
|
|
389
|
+
const declRe = /^\s*(?:public|private|protected|static|final|volatile|transient|\s)*\s*(?:[A-Za-z_][\w.]*(?:\s*<[^>]*>)?(?:\s*\[\s*\])*)\s+([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
|
|
390
|
+
const assignRe = /^\s*([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
|
|
391
|
+
const index = new Map();
|
|
392
|
+
for (const line of code.split('\n')) {
|
|
393
|
+
const trimmed = line.trimStart();
|
|
394
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*'))
|
|
395
|
+
continue;
|
|
396
|
+
// Cheap pre-check: neither regex can match a line without `=` and `;`.
|
|
397
|
+
if (!line.includes('=') || !line.includes(';'))
|
|
398
|
+
continue;
|
|
399
|
+
const m = declRe.exec(line) ?? assignRe.exec(line);
|
|
400
|
+
if (!m)
|
|
401
|
+
continue;
|
|
402
|
+
const list = index.get(m[1]);
|
|
403
|
+
const rhs = stripJavaTrailingComment(m[2]);
|
|
404
|
+
if (list)
|
|
405
|
+
list.push(rhs);
|
|
406
|
+
else
|
|
407
|
+
index.set(m[1], [rhs]);
|
|
408
|
+
}
|
|
409
|
+
return index;
|
|
410
|
+
}
|
|
411
|
+
function makeConstPropVeto(constProp, graph, sources, code, language, sanitizers) {
|
|
217
412
|
const lines = typeof code === 'string' ? code.split('\n') : [];
|
|
218
413
|
const selfReassign = new Map();
|
|
219
414
|
const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -245,12 +440,40 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
|
|
|
245
440
|
const use = (graph.usesByLine.get(line) ?? []).find(u => u.variable === v && u.def_id != null);
|
|
246
441
|
return use ? graph.defById.get(use.def_id) : undefined;
|
|
247
442
|
};
|
|
248
|
-
return (step, sourceLine, sinkLine) => {
|
|
249
|
-
const
|
|
443
|
+
return (step, sourceLine, sinkLine, sinkType) => {
|
|
444
|
+
const method = methodAtLine(graph, step.line);
|
|
445
|
+
const scopedVar = method ? `${method}:${step.variable}` : step.variable;
|
|
446
|
+
// cognium-dev#751: Go locals are recorded method-scoped only (no unscoped
|
|
447
|
+
// alias) by handleGoAssignment, so Go looks up the scoped name. Other
|
|
448
|
+
// languages keep the unscoped lookup (#614).
|
|
449
|
+
const r = isFalsePositive(constProp, step.line, language === 'go' ? scopedVar : step.variable);
|
|
250
450
|
if (!r.isFalsePositive)
|
|
251
451
|
return false;
|
|
252
452
|
if (r.reason !== 'variable_not_tainted')
|
|
253
453
|
return true;
|
|
454
|
+
// #589: the step variable came from a context-specific encoder
|
|
455
|
+
// (`escapeHtml`, `encodeForHTML`, …), which does not clean `trust_boundary`
|
|
456
|
+
// (a `session.setAttribute` sink). It is "not tainted" only for the other
|
|
457
|
+
// sink types, so this flow is not vetoed.
|
|
458
|
+
// Uncovered entries are scoped (`method:var`). A bare `step.variable` looks
|
|
459
|
+
// up only that step's own method — never any sibling `*:name`.
|
|
460
|
+
if (sinkType !== undefined && constProp.sanitizedVarUncovered) {
|
|
461
|
+
const unc = constProp.sanitizedVarUncovered;
|
|
462
|
+
if (step.variable.includes(':')) {
|
|
463
|
+
if (unc.get(step.variable)?.has(sinkType))
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
const method = methodAtLine(graph, step.line);
|
|
468
|
+
if (method) {
|
|
469
|
+
if (unc.get(`${method}:${step.variable}`)?.has(sinkType))
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
else if (unc.get(step.variable)?.has(sinkType)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
254
477
|
// cognium-dev#579: const-prop has no C# source model, so its tainted set is
|
|
255
478
|
// empty for C# and "not tainted" there means "never looked", not "clean".
|
|
256
479
|
// It still records a plain reassignment (`s = q;`) as a tracked symbol,
|
|
@@ -258,6 +481,43 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
|
|
|
258
481
|
// code and resolved constants (the reasons above) still apply.
|
|
259
482
|
if (language === 'csharp')
|
|
260
483
|
return false;
|
|
484
|
+
// cognium-dev#582: in JS/TS a use after an `if` / `else` / `switch` has the
|
|
485
|
+
// outer def and the block's def both reaching it (the DFG lists one use per
|
|
486
|
+
// reaching def). const-prop has no model of the block's assignment, so its
|
|
487
|
+
// "untainted" is only the outer def's value; when any reaching def is the
|
|
488
|
+
// source's own assignment the verdict is "never looked", not "clean".
|
|
489
|
+
// Keep the veto when that source assignment itself passes through a
|
|
490
|
+
// sanitizer (`n = escapeHtml(req.query.n)` in a branch): skipping would
|
|
491
|
+
// report xss while main and the straight-line form stay clean.
|
|
492
|
+
if ((language === 'javascript' || language === 'typescript')) {
|
|
493
|
+
const reaching = (graph.usesByLine.get(step.line) ?? [])
|
|
494
|
+
.filter(u => u.variable === step.variable && u.def_id != null)
|
|
495
|
+
.map(u => graph.defById.get(u.def_id))
|
|
496
|
+
.filter((d) => !!d);
|
|
497
|
+
const sourceReaches = reaching.filter(d => d.line === sourceLine &&
|
|
498
|
+
sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)));
|
|
499
|
+
if (reaching.length > 1 && sourceReaches.length > 0) {
|
|
500
|
+
const sansAtSource = (sanitizers ?? []).filter(s => s.line === sourceLine);
|
|
501
|
+
const srcText = lines[sourceLine - 1] ?? '';
|
|
502
|
+
const sourceSanitized = sourceReaches.some(d => {
|
|
503
|
+
const relevant = sinkType
|
|
504
|
+
? sansAtSource.filter(s => sanitizerCoversSink(s, sinkType))
|
|
505
|
+
: sansAtSource;
|
|
506
|
+
if (relevant.length === 0)
|
|
507
|
+
return false;
|
|
508
|
+
const v = escape(d.variable);
|
|
509
|
+
const dm = new RegExp(`(?:^|[^\\w$.])${v}\\s*=(?!=)(.*)$`).exec(srcText);
|
|
510
|
+
if (!dm)
|
|
511
|
+
return false;
|
|
512
|
+
const wrapType = sinkType ?? relevant[0]?.sanitizes?.[0];
|
|
513
|
+
if (!wrapType)
|
|
514
|
+
return false;
|
|
515
|
+
return expressionFullyWrappedBySanitizer(dm[1], relevant, wrapType);
|
|
516
|
+
});
|
|
517
|
+
if (!sourceSanitized)
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
261
521
|
const lo = Math.min(sourceLine, sinkLine);
|
|
262
522
|
// Walk the reaching-def chain back from this step, stepping through
|
|
263
523
|
// self-derived reassignments only. Reaching defs are per method, so a
|
|
@@ -279,8 +539,20 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
|
|
|
279
539
|
// "untainted" can only mean it has no pattern for this source. Container
|
|
280
540
|
// reads are excluded: const-prop tracks collection keys, and there its
|
|
281
541
|
// verdict is informed (`bar = map.get("safeKey")` — 10 OWASP FP files).
|
|
282
|
-
if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)))
|
|
542
|
+
if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type))) {
|
|
543
|
+
// cognium-dev#751: sink use after `uid := constantHelper(tainted)` must veto
|
|
544
|
+
// when the helper is in `constantReturningMethods`, even if the flow's
|
|
545
|
+
// source_line is the assignment line (rule E would otherwise keep it).
|
|
546
|
+
if (step.line === sinkLine && !constProp.tainted.has(scopedVar)) {
|
|
547
|
+
for (let ln = sourceLine; ln <= sinkLine; ln++) {
|
|
548
|
+
const calleeMatch = (lines[ln - 1] ?? '').match(/:=\s*(\w+)\s*\(/);
|
|
549
|
+
if (calleeMatch && constProp.constantReturningMethods?.has(calleeMatch[1])) {
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
283
554
|
return false;
|
|
555
|
+
}
|
|
284
556
|
// (A) the chain crossed at least one `x = f(x)` and ends inside the span.
|
|
285
557
|
if (selfHops > 0 && d.line >= lo)
|
|
286
558
|
return false;
|
|
@@ -296,6 +568,31 @@ export class TaintPropagationPass {
|
|
|
296
568
|
const constProp = ctx.getResult('constant-propagation');
|
|
297
569
|
const sinkFilter = ctx.getResult('sink-filter');
|
|
298
570
|
const { sources, sinks, sanitizers } = sinkFilter;
|
|
571
|
+
// cognium-dev#624: seed receiver-chain derived locals (`label = page.getName()`)
|
|
572
|
+
// into const-prop's tainted set so DFG + expression-scan vetoes keep them.
|
|
573
|
+
// Per-source seeding + receiver filter avoids argument-style derivations
|
|
574
|
+
// (`shown = encodeForPage(nick)`) that must stay vetoed for sanitizer credit.
|
|
575
|
+
// cognium-dev#806: index the assignments once, visit each seed name once,
|
|
576
|
+
// and skip the file-wide derivation when no local not already tainted has
|
|
577
|
+
// a `seed.` receiver on its RHS (only such locals can be added).
|
|
578
|
+
if (ctx.language === 'java' && typeof ctx.code === 'string') {
|
|
579
|
+
const rhsByLocal = indexJavaLocalAssignments(ctx.code);
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
for (const s of sources) {
|
|
582
|
+
if (!s.variable || seen.has(s.variable))
|
|
583
|
+
continue;
|
|
584
|
+
seen.add(s.variable);
|
|
585
|
+
const seedVar = s.variable;
|
|
586
|
+
const candidates = [...rhsByLocal.keys()].filter(v => !constProp.tainted.has(v) && javaReceiverChainDerivedVar(rhsByLocal, v, seedVar));
|
|
587
|
+
if (candidates.length === 0)
|
|
588
|
+
continue;
|
|
589
|
+
const derived = buildJavaTaintedVars(ctx.code, new Set([seedVar]));
|
|
590
|
+
for (const v of candidates) {
|
|
591
|
+
if (derived.has(v))
|
|
592
|
+
constProp.tainted.add(v);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
299
596
|
if (sinks.length === 0) {
|
|
300
597
|
return { flows: [] };
|
|
301
598
|
}
|
|
@@ -307,16 +604,16 @@ export class TaintPropagationPass {
|
|
|
307
604
|
return { flows: [] };
|
|
308
605
|
}
|
|
309
606
|
// DFG-based taint propagation
|
|
310
|
-
const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
|
|
607
|
+
const propagationResult = propagateTaint(graph, sources, sinks, sanitizers, undefined, ctx.code);
|
|
311
608
|
// cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
|
|
312
609
|
// veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
|
|
313
|
-
const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language);
|
|
610
|
+
const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language, sanitizers);
|
|
314
611
|
// Filter flows: eliminate dead-code paths and constant-propagation FPs
|
|
315
612
|
const verifiedFlows = propagationResult.flows.filter(flow => {
|
|
316
613
|
if (constProp.unreachableLines.has(flow.sink.line))
|
|
317
614
|
return false;
|
|
318
615
|
for (const step of flow.path) {
|
|
319
|
-
if (isVetoed(step, flow.source.line, flow.sink.line))
|
|
616
|
+
if (isVetoed(step, flow.source.line, flow.sink.line, flow.sink.type))
|
|
320
617
|
return false;
|
|
321
618
|
}
|
|
322
619
|
if (isCorrelatedPredicateFP(constProp, flow))
|
|
@@ -498,6 +795,42 @@ export class TaintPropagationPass {
|
|
|
498
795
|
}
|
|
499
796
|
}
|
|
500
797
|
}
|
|
798
|
+
// Supplement: Java JSPWiki plugin return-XSS flows — cognium-dev#624. The
|
|
799
|
+
// sink synthesised by `LanguageSourcesPass` has no method or argument, so
|
|
800
|
+
// no argument matcher can reach it; link it to its source here, as for the
|
|
801
|
+
// Python return-XSS shape above. The helper already applies its own
|
|
802
|
+
// statement-level sanitizer check, so these flows skip the line-keyed
|
|
803
|
+
// def-chain credit below (a sanitizer elsewhere on a def line of the
|
|
804
|
+
// returned name does not cover it).
|
|
805
|
+
const wikiPluginFlowKeys = new Set();
|
|
806
|
+
if (ctx.language === 'java' && typeof ctx.code === 'string') {
|
|
807
|
+
const xssSanitizers = sanitizers
|
|
808
|
+
.filter(sa => sa.sanitizes.includes('xss'))
|
|
809
|
+
.map(sa => ({ line: sa.line, method: sa.method }));
|
|
810
|
+
for (const rx of findJavaWikiPluginReturnXSSSinks(ctx.code, types, graph.ir.imports, sources, xssSanitizers)) {
|
|
811
|
+
if (constProp.unreachableLines.has(rx.sinkLine))
|
|
812
|
+
continue;
|
|
813
|
+
const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
|
|
814
|
+
if (!sink)
|
|
815
|
+
continue;
|
|
816
|
+
const src = sources.find(sc => sc.line === rx.sourceLine);
|
|
817
|
+
if (!src)
|
|
818
|
+
continue;
|
|
819
|
+
wikiPluginFlowKeys.add(`${src.line}:${rx.sinkLine}`);
|
|
820
|
+
pushIfNew({
|
|
821
|
+
source_line: src.line,
|
|
822
|
+
sink_line: rx.sinkLine,
|
|
823
|
+
source_type: src.type,
|
|
824
|
+
sink_type: 'xss',
|
|
825
|
+
path: [
|
|
826
|
+
{ variable: src.variable ?? 'response', line: src.line, type: 'source' },
|
|
827
|
+
{ variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
|
|
828
|
+
],
|
|
829
|
+
confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
|
|
830
|
+
sanitized: false,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
501
834
|
// Supplement: array element flows
|
|
502
835
|
// Sprint 82 (#189) — dedup keys on (source_line, sink_line, sink_type) so
|
|
503
836
|
// distinct sink-types at the same call site (e.g. `res.redirect` registered
|
|
@@ -594,19 +927,47 @@ export class TaintPropagationPass {
|
|
|
594
927
|
// explicitly marked sanitized by a guard (e.g. regex-allowlist).
|
|
595
928
|
// Applied to ALL flow generators (DFG-built and the four supplements)
|
|
596
929
|
// so the suppression is uniform regardless of which path emitted the flow.
|
|
930
|
+
//
|
|
931
|
+
// #589: a variable produced by a context-specific encoder (`escapeHtml`,
|
|
932
|
+
// `encodeForHTML`, …) is not clean for `trust_boundary`
|
|
933
|
+
// (`session.setAttribute`), so that flow from the variable stays.
|
|
597
934
|
const sanitizedNames = constProp.sanitizedVars;
|
|
935
|
+
const sanitizedUncovered = constProp.sanitizedVarUncovered;
|
|
598
936
|
let finalFlows = sanitizedNames.size === 0 ? flows : flows.filter(f => {
|
|
599
937
|
if (f.path.length === 0)
|
|
600
938
|
return true;
|
|
601
939
|
const sourceVar = f.path[0].variable;
|
|
602
940
|
if (!sourceVar)
|
|
603
941
|
return true;
|
|
604
|
-
|
|
605
|
-
return
|
|
942
|
+
const coversFlow = (name) => {
|
|
943
|
+
return !(sanitizedUncovered?.get(name)?.has(f.sink_type));
|
|
944
|
+
};
|
|
945
|
+
// #589 review: when any `method:var` sanitizer exists, only that flow's
|
|
946
|
+
// own method can drop it — never a sibling's scoped name (one encoder in
|
|
947
|
+
// doPost must not clear xss in other()), and never the bare alias alone.
|
|
948
|
+
const suffix = `:${sourceVar}`;
|
|
949
|
+
let hasScopedSanitizer = false;
|
|
606
950
|
for (const s of sanitizedNames) {
|
|
607
|
-
if (s.endsWith(
|
|
951
|
+
if (s !== sourceVar && s.endsWith(suffix)) {
|
|
952
|
+
hasScopedSanitizer = true;
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (hasScopedSanitizer) {
|
|
957
|
+
if (sourceVar.includes(':') && sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
|
|
608
958
|
return false;
|
|
959
|
+
const method = methodAtLine(graph, f.sink_line) ?? methodAtLine(graph, f.source_line);
|
|
960
|
+
if (method) {
|
|
961
|
+
const scoped = `${method}:${sourceVar}`;
|
|
962
|
+
if (sanitizedNames.has(scoped) && coversFlow(scoped))
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
// Scoped sanitizers exist for this name, but none proved to be this
|
|
966
|
+
// flow's method — keep the flow.
|
|
967
|
+
return true;
|
|
609
968
|
}
|
|
969
|
+
if (sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
|
|
970
|
+
return false;
|
|
610
971
|
return true;
|
|
611
972
|
});
|
|
612
973
|
// Sprint 24 (cognium-dev #102) — uniform line-keyed sanitizer
|
|
@@ -626,6 +987,7 @@ export class TaintPropagationPass {
|
|
|
626
987
|
arr.push(san);
|
|
627
988
|
sanitizersByLine.set(san.line, arr);
|
|
628
989
|
}
|
|
990
|
+
const codeLines = typeof ctx.code === 'string' ? ctx.code.split('\n') : undefined;
|
|
629
991
|
// True unless every def at `line` among `visited` is provably off this
|
|
630
992
|
// flow: not a variable on its path, and a complete backward walk from it
|
|
631
993
|
// never reaches `sourceLine`.
|
|
@@ -670,6 +1032,20 @@ export class TaintPropagationPass {
|
|
|
670
1032
|
}
|
|
671
1033
|
return true;
|
|
672
1034
|
}
|
|
1035
|
+
// #582: Python has no branch-aware def chain, so a literal in a sibling
|
|
1036
|
+
// arm can hide the one def whose sanitizer the walk below would find.
|
|
1037
|
+
// The flow's own source def is the whole answer when its right-hand side
|
|
1038
|
+
// is wrapped, end to end, in a covering sanitizer.
|
|
1039
|
+
if (ctx.language === 'python' && codeLines && f.path.length > 0) {
|
|
1040
|
+
const first = f.path[0];
|
|
1041
|
+
const defText = first.line === f.source_line ? codeLines[f.source_line - 1] : undefined;
|
|
1042
|
+
const sansAtSource = sanitizersByLine.get(f.source_line);
|
|
1043
|
+
if (defText && first.variable && sansAtSource && /^[A-Za-z_]\w*$/.test(first.variable)) {
|
|
1044
|
+
const dm = new RegExp(`^\\s*${first.variable}\\s*=(?!=)(.*)$`).exec(defText);
|
|
1045
|
+
if (dm && expressionFullyWrappedBySanitizer(dm[1], sansAtSource, f.sink_type))
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
673
1049
|
const sansAtSink = sanitizersByLine.get(f.sink_line);
|
|
674
1050
|
if (sansAtSink && sansAtSink.length > 0) {
|
|
675
1051
|
for (const san of sansAtSink) {
|
|
@@ -698,6 +1074,8 @@ export class TaintPropagationPass {
|
|
|
698
1074
|
// in taint-propagation.ts checkSanitized. Doesn't over-suppress
|
|
699
1075
|
// bare `shlex.quote(host)` on unrelated non-sink lines because
|
|
700
1076
|
// those calls don't create a def in the sink var's chain.
|
|
1077
|
+
if (f.sink_type === 'xss' && wikiPluginFlowKeys.has(`${f.source_line}:${f.sink_line}`))
|
|
1078
|
+
return true;
|
|
701
1079
|
const usesAtSink = graph.usesByLine.get(f.sink_line);
|
|
702
1080
|
if (!usesAtSink || usesAtSink.length === 0)
|
|
703
1081
|
return true;
|
|
@@ -706,12 +1084,29 @@ export class TaintPropagationPass {
|
|
|
706
1084
|
: undefined;
|
|
707
1085
|
if (!sinkVar)
|
|
708
1086
|
return true;
|
|
1087
|
+
// #589: an HTML encoder is credited only when every chain from the
|
|
1088
|
+
// sink back to this flow's source passes through a covering
|
|
1089
|
+
// sanitizer. Computed once per flow, over every reaching def of the
|
|
1090
|
+
// sink variable, and only when an HTML encoder is the candidate.
|
|
1091
|
+
let everyChainCovered;
|
|
1092
|
+
const htmlEncoderCoversEveryChain = () => {
|
|
1093
|
+
if (everyChainCovered === undefined) {
|
|
1094
|
+
const starts = [];
|
|
1095
|
+
for (const u of usesAtSink) {
|
|
1096
|
+
if (u.variable === sinkVar && u.def_id !== null && u.def_id !== undefined)
|
|
1097
|
+
starts.push(u.def_id);
|
|
1098
|
+
}
|
|
1099
|
+
everyChainCovered = starts.length > 0 && starts.every(id => !hasUncoveredChain(id, f.sink_type, f.source_line, graph, sanitizersByLine, codeLines));
|
|
1100
|
+
}
|
|
1101
|
+
return everyChainCovered;
|
|
1102
|
+
};
|
|
709
1103
|
for (const use of usesAtSink) {
|
|
710
1104
|
if (use.variable !== sinkVar)
|
|
711
1105
|
continue;
|
|
712
1106
|
if (use.def_id === null || use.def_id === undefined)
|
|
713
1107
|
continue;
|
|
714
|
-
const
|
|
1108
|
+
const sinkDefId = use.def_id;
|
|
1109
|
+
const walk = walkBackwardDefs(sinkDefId, graph.chainsByToDef, graph.defById, { maxHops: 32 });
|
|
715
1110
|
for (const line of walk.lines) {
|
|
716
1111
|
if (line === f.sink_line)
|
|
717
1112
|
continue; // already checked above
|
|
@@ -737,9 +1132,14 @@ export class TaintPropagationPass {
|
|
|
737
1132
|
if (line > f.source_line && !sanitizedDefOnFlow(walk.visited, line, f))
|
|
738
1133
|
continue;
|
|
739
1134
|
for (const san of sansAtLine) {
|
|
740
|
-
if (sanitizerCoversSink(san, f.sink_type))
|
|
741
|
-
|
|
742
|
-
|
|
1135
|
+
if (!sanitizerCoversSink(san, f.sink_type))
|
|
1136
|
+
continue;
|
|
1137
|
+
// #589: a join anywhere between the sink and the encoder
|
|
1138
|
+
// (`mixed = shown + b`, then `copy = mixed`) must not let an
|
|
1139
|
+
// encoder on one operand drop unrestricted taint from another.
|
|
1140
|
+
if (isHtmlContextEncoderSanitizer(san) && !htmlEncoderCoversEveryChain())
|
|
1141
|
+
continue;
|
|
1142
|
+
return false;
|
|
743
1143
|
}
|
|
744
1144
|
}
|
|
745
1145
|
}
|
|
@@ -894,6 +1294,28 @@ export class TaintPropagationPass {
|
|
|
894
1294
|
});
|
|
895
1295
|
}
|
|
896
1296
|
}
|
|
1297
|
+
// cognium-dev#475: DFG may keep a flow when a local (`v`) was rewritten to
|
|
1298
|
+
// a literal before the sink even though the sink reads a copy (`cmd`) built
|
|
1299
|
+
// earlier. Walk backward from the sink through assignments into the sink
|
|
1300
|
+
// argument; for each upstream local, literal-kill only on lines strictly
|
|
1301
|
+
// before the assignment that copied it into that chain (so `v = "safe"`
|
|
1302
|
+
// after `new SqlCommand(... + v)` does not drop the finding). Unrelated
|
|
1303
|
+
// locals and conditional / re-taint shapes stay sound.
|
|
1304
|
+
if (ctx.language === 'csharp' && typeof ctx.code === 'string') {
|
|
1305
|
+
const code = ctx.code;
|
|
1306
|
+
finalFlows = finalFlows.filter(f => {
|
|
1307
|
+
const deriveLine = csharpSinkDependencyKillVars(code, f);
|
|
1308
|
+
const carriers = csharpFlowTaintCarriers(code, f);
|
|
1309
|
+
for (const [v, beforeLine] of deriveLine) {
|
|
1310
|
+
if (!carriers.has(v))
|
|
1311
|
+
continue;
|
|
1312
|
+
if (csharpLastUnconditionalAssignIsLiteral(code, v, f.source_line, beforeLine, constProp.unreachableLines)) {
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return true;
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
897
1319
|
return { flows: finalFlows };
|
|
898
1320
|
}
|
|
899
1321
|
}
|
|
@@ -1225,8 +1647,118 @@ function detectGoPackageGlobalFlows(code, _calls, sources, sinks, unreachableLin
|
|
|
1225
1647
|
}
|
|
1226
1648
|
return flows;
|
|
1227
1649
|
}
|
|
1650
|
+
/**
|
|
1651
|
+
* #614 — is `varName` tainted in the sink's own method?
|
|
1652
|
+
*
|
|
1653
|
+
* The const-prop result carries scoped names (`method:var`) and, for backward
|
|
1654
|
+
* compatibility, an unscoped alias of each. The unscoped alias says only that
|
|
1655
|
+
* SOME method tainted a local of that name. When any scoped entry for the name
|
|
1656
|
+
* exists, membership is decided by the sink's own scoped name, so a same-named
|
|
1657
|
+
* local in a safe sibling method (the Juliet `bad()` / `good*()` layout) is not
|
|
1658
|
+
* treated as tainted. A name with no scoped entry (a field, a parameter) keeps
|
|
1659
|
+
* the unscoped match. A field use (`this.name` / bare field read) is shared by
|
|
1660
|
+
* every method of its class (SecuriBench Inter4); a local that merely shares a
|
|
1661
|
+
* field's name is not.
|
|
1662
|
+
*/
|
|
1663
|
+
/**
|
|
1664
|
+
* True when `methodName` declares a local (or param) named `varName`. With
|
|
1665
|
+
* overloads, the declaration that encloses `atLine` is the one checked (the
|
|
1666
|
+
* first declaration of that name is only a fallback when no line is known or
|
|
1667
|
+
* none encloses it).
|
|
1668
|
+
*/
|
|
1669
|
+
function methodHasLocalDecl(code, methodName, varName, atLine) {
|
|
1670
|
+
if (!code || !methodName)
|
|
1671
|
+
return false;
|
|
1672
|
+
// Find `methodName(...) [throws ...] {` then scan the body for a local decl.
|
|
1673
|
+
const esc = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1674
|
+
const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1675
|
+
const methodRe = new RegExp('(?:^|[\\s;{}])' + esc + '\\s*\\([^;{]*\\)\\s*(?:throws\\s+[^{]+)?\\{', 'gm');
|
|
1676
|
+
const lineAt = (offset) => {
|
|
1677
|
+
let n = 1;
|
|
1678
|
+
for (let k = 0; k < offset; k++)
|
|
1679
|
+
if (code.charCodeAt(k) === 10)
|
|
1680
|
+
n++;
|
|
1681
|
+
return n;
|
|
1682
|
+
};
|
|
1683
|
+
let chosen = null;
|
|
1684
|
+
let first = null;
|
|
1685
|
+
for (let m = methodRe.exec(code); m; m = methodRe.exec(code)) {
|
|
1686
|
+
const bodyStart = m.index + m[0].length;
|
|
1687
|
+
let i = bodyStart;
|
|
1688
|
+
let depth = 1;
|
|
1689
|
+
while (i < code.length && depth > 0) {
|
|
1690
|
+
const c = code[i++];
|
|
1691
|
+
if (c === '{')
|
|
1692
|
+
depth++;
|
|
1693
|
+
else if (c === '}')
|
|
1694
|
+
depth--;
|
|
1695
|
+
}
|
|
1696
|
+
const cand = { header: m[0], bodyStart, bodyEnd: i - 1 };
|
|
1697
|
+
if (!first)
|
|
1698
|
+
first = cand;
|
|
1699
|
+
if (atLine === undefined)
|
|
1700
|
+
break;
|
|
1701
|
+
if (atLine >= lineAt(m.index) && atLine <= lineAt(cand.bodyEnd)) {
|
|
1702
|
+
chosen = cand;
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
const hit = chosen ?? first;
|
|
1707
|
+
if (!hit)
|
|
1708
|
+
return false;
|
|
1709
|
+
const body = code.slice(hit.bodyStart, hit.bodyEnd);
|
|
1710
|
+
// Parameter named varName counts as shadowing the field.
|
|
1711
|
+
if (new RegExp('[(,]\\s*(?:final\\s+)?[\\w.<>,\\[\\]\\s]+\\s+' + v + '\\s*[,)]').test(hit.header))
|
|
1712
|
+
return true;
|
|
1713
|
+
const declRe = new RegExp('(?:^|[;{}])\\s*(?:(?:final|volatile)\\s+)?(?:[A-Za-z_][\\w.<>,\\[\\]\\s]*?)\\s+' + v + '\\s*(?:=|;|,)', 'm');
|
|
1714
|
+
return declRe.test(body);
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* True when `expression` reads this class's own field `varName`: `this.name`,
|
|
1718
|
+
* `self.name`, or `Enclosing.name` (static access through an enclosing type).
|
|
1719
|
+
* Another object's member (`item.name`, `file.name`) is not this class's field.
|
|
1720
|
+
*/
|
|
1721
|
+
function isFieldUseExpression(expression, varName, enclosingTypeNames) {
|
|
1722
|
+
if (!expression)
|
|
1723
|
+
return false;
|
|
1724
|
+
const e = expression.trim();
|
|
1725
|
+
if (e === `this.${varName}` || e === `self.${varName}`)
|
|
1726
|
+
return true;
|
|
1727
|
+
const m = new RegExp('^([A-Za-z_]\\w*)\\.' + varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$').exec(e);
|
|
1728
|
+
return !!m && enclosingTypeNames.has(m[1]);
|
|
1729
|
+
}
|
|
1730
|
+
/** Names of the types whose range encloses `line` (outer and nested). */
|
|
1731
|
+
function enclosingTypeNamesAt(types, line) {
|
|
1732
|
+
const names = new Set();
|
|
1733
|
+
for (const t of types ?? []) {
|
|
1734
|
+
if (line >= t.start_line && line <= t.end_line)
|
|
1735
|
+
names.add(t.name);
|
|
1736
|
+
}
|
|
1737
|
+
return names;
|
|
1738
|
+
}
|
|
1739
|
+
function isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse) {
|
|
1740
|
+
if (taintedVars.has(scopedName))
|
|
1741
|
+
return true;
|
|
1742
|
+
if (!taintedVars.has(varName))
|
|
1743
|
+
return false;
|
|
1744
|
+
if (scopedName === varName)
|
|
1745
|
+
return true;
|
|
1746
|
+
// Field exception only when THIS use is a field, not when any field has the name.
|
|
1747
|
+
if (isFieldUse && fieldNames.has(varName))
|
|
1748
|
+
return true;
|
|
1749
|
+
const suffix = `:${varName}`;
|
|
1750
|
+
for (const t of taintedVars) {
|
|
1751
|
+
if (t.endsWith(suffix))
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
return true;
|
|
1755
|
+
}
|
|
1228
1756
|
function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLines, code, types) {
|
|
1229
1757
|
const flows = [];
|
|
1758
|
+
const fieldNames = new Set();
|
|
1759
|
+
for (const t of types ?? [])
|
|
1760
|
+
for (const f of t.fields ?? [])
|
|
1761
|
+
fieldNames.add(f.name);
|
|
1230
1762
|
const callsByLine = new Map();
|
|
1231
1763
|
for (const call of calls) {
|
|
1232
1764
|
const existing = callsByLine.get(call.location.line) ?? [];
|
|
@@ -1247,7 +1779,16 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
|
|
|
1247
1779
|
if (arg.variable) {
|
|
1248
1780
|
const varName = arg.variable;
|
|
1249
1781
|
const scopedName = call.in_method ? `${call.in_method}:${varName}` : varName;
|
|
1250
|
-
|
|
1782
|
+
// Bare `name` that is a field read (no local of that name in this
|
|
1783
|
+
// method) still counts as a field use — Inter4-style. A shadowing
|
|
1784
|
+
// local declaration in the method does not.
|
|
1785
|
+
const expr = arg.expression ?? '';
|
|
1786
|
+
const explicitField = isFieldUseExpression(expr, varName, enclosingTypeNamesAt(types, call.location.line));
|
|
1787
|
+
const bareFieldRead = fieldNames.has(varName)
|
|
1788
|
+
&& expr === varName
|
|
1789
|
+
&& !methodHasLocalDecl(code, call.in_method, varName, call.location.line);
|
|
1790
|
+
const isFieldUse = explicitField || bareFieldRead;
|
|
1791
|
+
if (isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse)) {
|
|
1251
1792
|
// Sprint 13 #70 — pick a source in the same method scope as the
|
|
1252
1793
|
// sink, not blanket sources[0]. The varName-based match is
|
|
1253
1794
|
// primary; method-scope is the tiebreaker; sources[0] is the
|
|
@@ -1365,23 +1906,22 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
|
|
|
1365
1906
|
!sink.argPositions.includes(arg.position)) {
|
|
1366
1907
|
continue;
|
|
1367
1908
|
}
|
|
1368
|
-
const
|
|
1369
|
-
if (
|
|
1370
|
-
const arrayName =
|
|
1371
|
-
const indexStr = arrayAccessMatch[2];
|
|
1909
|
+
const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
|
|
1910
|
+
if (arrayAccess) {
|
|
1911
|
+
const { base, arrayName, indexKey, displayVar } = arrayAccess;
|
|
1372
1912
|
const taintedIndices = taintedArrayElements.get(arrayName);
|
|
1373
1913
|
if (taintedIndices) {
|
|
1374
|
-
const isTainted = taintedIndices.has(
|
|
1914
|
+
const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
|
|
1375
1915
|
if (isTainted) {
|
|
1376
1916
|
// Sprint 13 #70 — method-scoped source picker (see detectCollectionFlows).
|
|
1377
|
-
const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types,
|
|
1917
|
+
const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types, base);
|
|
1378
1918
|
if (source) {
|
|
1379
1919
|
flows.push({
|
|
1380
1920
|
source_line: source.line, sink_line: sink.line,
|
|
1381
1921
|
source_type: source.type, sink_type: sink.type,
|
|
1382
1922
|
path: [
|
|
1383
|
-
{ variable:
|
|
1384
|
-
{ variable:
|
|
1923
|
+
{ variable: base, line: source.line, type: 'source' },
|
|
1924
|
+
{ variable: displayVar, line: sink.line, type: 'sink' },
|
|
1385
1925
|
],
|
|
1386
1926
|
confidence: 0.85, sanitized: false,
|
|
1387
1927
|
});
|
|
@@ -1394,6 +1934,20 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
|
|
|
1394
1934
|
}
|
|
1395
1935
|
return flows;
|
|
1396
1936
|
}
|
|
1937
|
+
/** Parameter names referenced as identifiers inside a compound sink argument (#685). */
|
|
1938
|
+
function compoundArgParamNames(expression, paramNames) {
|
|
1939
|
+
const stripped = expression.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => ' '.repeat(m.length));
|
|
1940
|
+
const matched = [];
|
|
1941
|
+
for (const raw of paramNames) {
|
|
1942
|
+
const name = raw.startsWith('*') ? raw.slice(1) : raw;
|
|
1943
|
+
if (!/^[\w$]+$/.test(name))
|
|
1944
|
+
continue;
|
|
1945
|
+
const re = new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`);
|
|
1946
|
+
if (re.test(stripped))
|
|
1947
|
+
matched.push(name);
|
|
1948
|
+
}
|
|
1949
|
+
return matched;
|
|
1950
|
+
}
|
|
1397
1951
|
function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines, tainted, code) {
|
|
1398
1952
|
const flows = [];
|
|
1399
1953
|
const paramSourcesByMethod = new Map();
|
|
@@ -1432,14 +1986,17 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
|
|
|
1432
1986
|
if (!methodParamSources)
|
|
1433
1987
|
continue;
|
|
1434
1988
|
for (const arg of call.arguments) {
|
|
1435
|
-
if (
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1989
|
+
if (sink.argPositions && sink.argPositions.length > 0 &&
|
|
1990
|
+
!sink.argPositions.includes(arg.position)) {
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
const varNames = arg.variable
|
|
1994
|
+
? [arg.variable]
|
|
1995
|
+
: (arg.expression
|
|
1996
|
+
? compoundArgParamNames(arg.expression, methodParamSources.keys())
|
|
1997
|
+
: []);
|
|
1998
|
+
for (const varName of varNames) {
|
|
1999
|
+
const paramSource = methodParamSources.get(varName);
|
|
1443
2000
|
if (paramSource) {
|
|
1444
2001
|
// Sprint 82 (#189) — sink-type-aware dedup so a sink registered
|
|
1445
2002
|
// under multiple types at the same call site (e.g. `redirect` as
|
|
@@ -1447,7 +2004,7 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
|
|
|
1447
2004
|
const exists = flows.some(f => f.source_line === paramSource.line && f.sink_line === sink.line && f.sink_type === sink.type);
|
|
1448
2005
|
if (!exists) {
|
|
1449
2006
|
if (typeof code === 'string' &&
|
|
1450
|
-
isReassignedToLiteralBetween(code,
|
|
2007
|
+
isReassignedToLiteralBetween(code, varName, paramSource.line, sink.line, unreachableLines)) {
|
|
1451
2008
|
continue;
|
|
1452
2009
|
}
|
|
1453
2010
|
// Note: DFG-flow filter handles sanitizer-guard suppression
|
|
@@ -1456,8 +2013,8 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
|
|
|
1456
2013
|
source_line: paramSource.line, sink_line: sink.line,
|
|
1457
2014
|
source_type: paramSource.type, sink_type: sink.type,
|
|
1458
2015
|
path: [
|
|
1459
|
-
{ variable:
|
|
1460
|
-
{ variable:
|
|
2016
|
+
{ variable: varName, line: paramSource.line, type: 'source' },
|
|
2017
|
+
{ variable: varName, line: sink.line, type: 'sink' },
|
|
1461
2018
|
],
|
|
1462
2019
|
confidence: 0.75, sanitized: false,
|
|
1463
2020
|
});
|
|
@@ -1623,7 +2180,18 @@ function csharpMethodScopedAliases(code, seeds, methods, fieldNames) {
|
|
|
1623
2180
|
}
|
|
1624
2181
|
continue;
|
|
1625
2182
|
}
|
|
1626
|
-
|
|
2183
|
+
let d = declRe.exec(text) ?? assignRe.exec(text);
|
|
2184
|
+
if (!d && text.includes('{')) {
|
|
2185
|
+
const inner = text.slice(text.indexOf('{') + 1, text.lastIndexOf('}'));
|
|
2186
|
+
for (const stmt of inner.split(';')) {
|
|
2187
|
+
const trimmedStmt = stmt.trim();
|
|
2188
|
+
if (!trimmedStmt)
|
|
2189
|
+
continue;
|
|
2190
|
+
d = declRe.exec(`${trimmedStmt};`) ?? assignRe.exec(`${trimmedStmt};`);
|
|
2191
|
+
if (d)
|
|
2192
|
+
break;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
1627
2195
|
if (!d)
|
|
1628
2196
|
continue;
|
|
1629
2197
|
const [, lhs, rhs] = d;
|
|
@@ -1658,6 +2226,211 @@ function splitLinesCached(code) {
|
|
|
1658
2226
|
_reassignSplitLines = code.split('\n');
|
|
1659
2227
|
return _reassignSplitLines;
|
|
1660
2228
|
}
|
|
2229
|
+
const CSHARP_ID_KW = new Set([
|
|
2230
|
+
'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default', 'break',
|
|
2231
|
+
'return', 'new', 'var', 'string', 'int', 'bool', 'void', 'static', 'public', 'class',
|
|
2232
|
+
'using', 'lock', 'try', 'catch', 'finally', 'throw', 'null', 'true', 'false',
|
|
2233
|
+
]);
|
|
2234
|
+
function csharpStripStringLiterals(text) {
|
|
2235
|
+
return text
|
|
2236
|
+
.replace(/\$@?"(?:[^"\\]|\\.)*"/g, ' ')
|
|
2237
|
+
.replace(/@?"(?:[^"\\]|\\.)*"/g, ' ')
|
|
2238
|
+
.replace(/'(?:[^'\\]|\\.)'/g, ' ')
|
|
2239
|
+
.replace(/`(?:[^`\\]|\\.)*`/g, ' ');
|
|
2240
|
+
}
|
|
2241
|
+
function csharpIdentifiersInExpr(text) {
|
|
2242
|
+
const ids = csharpStripStringLiterals(text).match(/\b[A-Za-z_][\w]*\b/g) ?? [];
|
|
2243
|
+
return ids.filter(id => !CSHARP_ID_KW.has(id));
|
|
2244
|
+
}
|
|
2245
|
+
const CSHARP_ASSIGN_RE = /^\s*(?:(?:var|string|String|[\w.<>\[\]?]+\s+)?([A-Za-z_]\w*)|([A-Za-z_]\w*))\s*=\s*(.+)$/;
|
|
2246
|
+
/**
|
|
2247
|
+
* Variables that carry THIS C# flow's taint (path locals plus forward/backward
|
|
2248
|
+
* derivation). Pure-literal helpers (`table = "users"` next to tainted `input`)
|
|
2249
|
+
* are excluded so their literal assignments cannot drop the flow (#475).
|
|
2250
|
+
*/
|
|
2251
|
+
function csharpFlowTaintCarriers(code, flow) {
|
|
2252
|
+
const carriers = new Set();
|
|
2253
|
+
for (const step of flow.path) {
|
|
2254
|
+
if (step.variable)
|
|
2255
|
+
carriers.add(step.variable);
|
|
2256
|
+
}
|
|
2257
|
+
if (carriers.size === 0)
|
|
2258
|
+
return carriers;
|
|
2259
|
+
const lines = splitLinesCached(code);
|
|
2260
|
+
const lo = Math.max(0, flow.source_line - 1);
|
|
2261
|
+
const hi = Math.min(lines.length, flow.sink_line);
|
|
2262
|
+
const stripLits = (rhs) => csharpStripStringLiterals(rhs);
|
|
2263
|
+
const assigns = [];
|
|
2264
|
+
for (let i = lo; i < hi; i++) {
|
|
2265
|
+
const line = lines[i] ?? '';
|
|
2266
|
+
if (/^\s*if\b/.test(line))
|
|
2267
|
+
continue;
|
|
2268
|
+
for (const seg of line.split(';')) {
|
|
2269
|
+
const t = seg.trim();
|
|
2270
|
+
if (!t)
|
|
2271
|
+
continue;
|
|
2272
|
+
const m = CSHARP_ASSIGN_RE.exec(t);
|
|
2273
|
+
if (!m)
|
|
2274
|
+
continue;
|
|
2275
|
+
const lhs = m[1] ?? m[2];
|
|
2276
|
+
const rhs = m[3];
|
|
2277
|
+
if (!lhs)
|
|
2278
|
+
continue;
|
|
2279
|
+
const rhsCode = stripLits(rhs);
|
|
2280
|
+
const literal = rhsCode.replace(/\s+/g, '') === '';
|
|
2281
|
+
const rhsIdents = rhsCode.match(/\b[A-Za-z_][\w]*\b/g) ?? [];
|
|
2282
|
+
assigns.push({ lhs, rhsIdents, literal });
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
const hadNonLiteral = new Set();
|
|
2286
|
+
for (const a of assigns) {
|
|
2287
|
+
if (!a.literal)
|
|
2288
|
+
hadNonLiteral.add(a.lhs);
|
|
2289
|
+
}
|
|
2290
|
+
let changed = true;
|
|
2291
|
+
for (let guard = 0; changed && guard < assigns.length + 2; guard++) {
|
|
2292
|
+
changed = false;
|
|
2293
|
+
for (const a of assigns) {
|
|
2294
|
+
if (!carriers.has(a.lhs) && a.rhsIdents.some(id => carriers.has(id))) {
|
|
2295
|
+
carriers.add(a.lhs);
|
|
2296
|
+
changed = true;
|
|
2297
|
+
}
|
|
2298
|
+
if (carriers.has(a.lhs)) {
|
|
2299
|
+
for (const id of a.rhsIdents) {
|
|
2300
|
+
if (!carriers.has(id) && hadNonLiteral.has(id)) {
|
|
2301
|
+
carriers.add(id);
|
|
2302
|
+
changed = true;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
return carriers;
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* For each upstream local, the 1-based line of the assignment that copied it
|
|
2312
|
+
* into the sink dependency chain (closest to the sink). A literal reset of
|
|
2313
|
+
* `v` after `var q = ... + v` must not kill a flow that only reads `q` (#475).
|
|
2314
|
+
*/
|
|
2315
|
+
function csharpSinkDependencyKillVars(code, flow) {
|
|
2316
|
+
const lines = splitLinesCached(code);
|
|
2317
|
+
const sinkIdx = flow.sink_line - 1;
|
|
2318
|
+
const deriveLine = new Map();
|
|
2319
|
+
if (sinkIdx < 0 || sinkIdx >= lines.length)
|
|
2320
|
+
return deriveLine;
|
|
2321
|
+
const need = new Set(csharpIdentifiersInExpr(lines[sinkIdx] ?? ''));
|
|
2322
|
+
for (let i = sinkIdx - 1; i >= flow.source_line - 1; i--) {
|
|
2323
|
+
const line = lines[i] ?? '';
|
|
2324
|
+
for (const seg of line.split(';')) {
|
|
2325
|
+
const t = seg.trim();
|
|
2326
|
+
if (!t)
|
|
2327
|
+
continue;
|
|
2328
|
+
const m = CSHARP_ASSIGN_RE.exec(t);
|
|
2329
|
+
if (!m)
|
|
2330
|
+
continue;
|
|
2331
|
+
const lhs = m[1] ?? m[2];
|
|
2332
|
+
if (!lhs || !need.has(lhs))
|
|
2333
|
+
continue;
|
|
2334
|
+
for (const id of csharpIdentifiersInExpr(m[3])) {
|
|
2335
|
+
if (!deriveLine.has(id))
|
|
2336
|
+
deriveLine.set(id, i + 1);
|
|
2337
|
+
need.add(id);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
return deriveLine;
|
|
2342
|
+
}
|
|
2343
|
+
function csharpLastUnconditionalAssignIsLiteral(code, variable, srcLine,
|
|
2344
|
+
/** Scan assignments on lines strictly before this 1-based line. */
|
|
2345
|
+
beforeLine, unreachableLines) {
|
|
2346
|
+
if (!variable || !/^[A-Za-z_][\w]*$/.test(variable))
|
|
2347
|
+
return false;
|
|
2348
|
+
if (beforeLine - srcLine < 1)
|
|
2349
|
+
return false;
|
|
2350
|
+
const lines = splitLinesCached(code);
|
|
2351
|
+
const reLit = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)\\s*$');
|
|
2352
|
+
const rePlain = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(.+)\\s*$');
|
|
2353
|
+
const reCompound = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*[+\\-*/%&|^]=\\s*(.+)\\s*$');
|
|
2354
|
+
const reRefOut = new RegExp('\\b(?:ref|out)\\s+' + variable + '\\b');
|
|
2355
|
+
let last = null;
|
|
2356
|
+
const lo = Math.max(0, srcLine);
|
|
2357
|
+
const hi = Math.min(lines.length, beforeLine - 1);
|
|
2358
|
+
for (let i = lo; i < hi; i++) {
|
|
2359
|
+
if (unreachableLines?.has(i + 1))
|
|
2360
|
+
continue;
|
|
2361
|
+
const line = lines[i];
|
|
2362
|
+
if (!line)
|
|
2363
|
+
continue;
|
|
2364
|
+
if (/^\s*if\b/.test(line))
|
|
2365
|
+
continue;
|
|
2366
|
+
for (const seg of line.split(';')) {
|
|
2367
|
+
const t = seg.trim();
|
|
2368
|
+
if (!t)
|
|
2369
|
+
continue;
|
|
2370
|
+
if (reCompound.test(t)) {
|
|
2371
|
+
last = 'other';
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
if (reRefOut.test(csharpStripStringLiterals(t))) {
|
|
2375
|
+
last = 'other';
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
if (!rePlain.test(t))
|
|
2379
|
+
continue;
|
|
2380
|
+
if (csharpAssignInConditionalBlock(lines, i))
|
|
2381
|
+
continue;
|
|
2382
|
+
if (reLit.test(t)) {
|
|
2383
|
+
last = 'literal';
|
|
2384
|
+
}
|
|
2385
|
+
else {
|
|
2386
|
+
last = 'other';
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
return last === 'literal';
|
|
2391
|
+
}
|
|
2392
|
+
/** True when `header` opens a non-bare control-flow block (not a method/type body). */
|
|
2393
|
+
function csharpBlockHeaderIsConditional(header) {
|
|
2394
|
+
const h = header.trim();
|
|
2395
|
+
if (!h || h === '{')
|
|
2396
|
+
return false;
|
|
2397
|
+
if (/^(?:case\b.*|default\s*):$/.test(h))
|
|
2398
|
+
return true;
|
|
2399
|
+
if (/\bswitch\b/.test(h))
|
|
2400
|
+
return true;
|
|
2401
|
+
if (/^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(h))
|
|
2402
|
+
return true;
|
|
2403
|
+
if (/^(?:\}\s*)?else\b/.test(h))
|
|
2404
|
+
return true;
|
|
2405
|
+
if (/\b(?:for|foreach|while|do|using|lock|try|catch|finally)\b/.test(h))
|
|
2406
|
+
return true;
|
|
2407
|
+
return false;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* True when line `idx` sits in a control-flow block. Walks outward through
|
|
2411
|
+
* enclosing braces so an Allman `for` / `while` header above a lone `{` line
|
|
2412
|
+
* is recognised (#475).
|
|
2413
|
+
*/
|
|
2414
|
+
function csharpAssignInConditionalBlock(lines, idx) {
|
|
2415
|
+
let at = idx;
|
|
2416
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
2417
|
+
const block = enclosingBrace(lines, at, 0);
|
|
2418
|
+
if (!block)
|
|
2419
|
+
return false;
|
|
2420
|
+
let header = block.header.trim();
|
|
2421
|
+
if (!header || header === '{') {
|
|
2422
|
+
const prev = block.headerLine > 0 ? (lines[block.headerLine - 1] ?? '').trim() : '';
|
|
2423
|
+
if (prev)
|
|
2424
|
+
header = prev;
|
|
2425
|
+
}
|
|
2426
|
+
if (csharpBlockHeaderIsConditional(header))
|
|
2427
|
+
return true;
|
|
2428
|
+
if (block.headerLine <= 0)
|
|
2429
|
+
return false;
|
|
2430
|
+
at = block.headerLine - 1;
|
|
2431
|
+
}
|
|
2432
|
+
return false;
|
|
2433
|
+
}
|
|
1661
2434
|
function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreachableLines) {
|
|
1662
2435
|
if (!variable || sinkLine - srcLine < 2)
|
|
1663
2436
|
return false;
|
|
@@ -1688,17 +2461,33 @@ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreach
|
|
|
1688
2461
|
// The fall-through `break;` is optional. Used by cognium-dev #101
|
|
1689
2462
|
// to suppress the switch→constant FP corpus.
|
|
1690
2463
|
const reSwitchCase = new RegExp(`^\\s*(?:case\\b.*?|default\\s*):\\s*${variable}\\s*=\\s*${strLit}\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
|
|
2464
|
+
const lineHasNakedLiteralReassign = (line) => {
|
|
2465
|
+
if (reNaked.test(line) || reSwitchCase.test(line))
|
|
2466
|
+
return true;
|
|
2467
|
+
// cognium-dev#475: `var v = input; v = "const";` on one line — only the
|
|
2468
|
+
// trailing statement is a literal kill, not the whole physical line.
|
|
2469
|
+
for (const seg of line.split(';')) {
|
|
2470
|
+
const t = seg.trim();
|
|
2471
|
+
if (!t)
|
|
2472
|
+
continue;
|
|
2473
|
+
if (reNaked.test(t) || reSwitchCase.test(t))
|
|
2474
|
+
return true;
|
|
2475
|
+
}
|
|
2476
|
+
return false;
|
|
2477
|
+
};
|
|
1691
2478
|
for (let i = lo; i < hi; i++) {
|
|
1692
2479
|
const line = lines[i];
|
|
1693
2480
|
if (!line)
|
|
1694
2481
|
continue;
|
|
1695
2482
|
if (reGuarded.test(line))
|
|
1696
2483
|
return true;
|
|
1697
|
-
if (!
|
|
2484
|
+
if (!lineHasNakedLiteralReassign(line))
|
|
1698
2485
|
continue;
|
|
1699
2486
|
// A literal assigned in one case of a switch only overwrites the value
|
|
1700
2487
|
// when no case gives the variable anything else. `case 'A': bar = param;`
|
|
1701
2488
|
// next to `default: bar = "safe";` leaves `bar` tainted on the 'A' path.
|
|
2489
|
+
if (javaCatchLiteralHasTaintedTryArm(lines, i, variable, strLit, unreachableLines))
|
|
2490
|
+
continue;
|
|
1702
2491
|
if (siblingBranchAssignsNonLiteral(lines, i, variable, strLit, unreachableLines))
|
|
1703
2492
|
continue;
|
|
1704
2493
|
return true;
|
|
@@ -1745,6 +2534,46 @@ function matchingClose(lines, line, col) {
|
|
|
1745
2534
|
}
|
|
1746
2535
|
return null;
|
|
1747
2536
|
}
|
|
2537
|
+
/**
|
|
2538
|
+
* True when line `idx` is a literal assignment in a `catch` block and the
|
|
2539
|
+
* paired `try` assigns `variable` a non-literal value (cognium-dev#753).
|
|
2540
|
+
*/
|
|
2541
|
+
function javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines) {
|
|
2542
|
+
const block = enclosingBrace(lines, idx, 0);
|
|
2543
|
+
if (!block || !/\bcatch\b/.test(block.header))
|
|
2544
|
+
return false;
|
|
2545
|
+
let h = block.headerLine;
|
|
2546
|
+
while (h >= 0 && !/\btry\b/.test(lines[h]))
|
|
2547
|
+
h--;
|
|
2548
|
+
if (h < 0)
|
|
2549
|
+
return false;
|
|
2550
|
+
const tryIdx = lines[h].search(/\btry\b/);
|
|
2551
|
+
let openLine = h;
|
|
2552
|
+
let openCol = lines[h].indexOf('{', tryIdx);
|
|
2553
|
+
if (openCol < 0) {
|
|
2554
|
+
openLine = h + 1;
|
|
2555
|
+
while (openLine < lines.length && lines[openLine].trim() === '')
|
|
2556
|
+
openLine++;
|
|
2557
|
+
if (openLine >= lines.length)
|
|
2558
|
+
return false;
|
|
2559
|
+
openCol = lines[openLine].indexOf('{');
|
|
2560
|
+
if (openCol < 0)
|
|
2561
|
+
return false;
|
|
2562
|
+
}
|
|
2563
|
+
const tryEnd = matchingClose(lines, openLine, openCol);
|
|
2564
|
+
if (!tryEnd)
|
|
2565
|
+
return false;
|
|
2566
|
+
const assignTry = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
|
|
2567
|
+
const literalOnly = new RegExp(`^${strLit}$`);
|
|
2568
|
+
for (let i = openLine; i <= tryEnd.line; i++) {
|
|
2569
|
+
if (unreachableLines?.has(i + 1))
|
|
2570
|
+
continue;
|
|
2571
|
+
const m = assignTry.exec(lines[i]);
|
|
2572
|
+
if (m && m[1] && !literalOnly.test(m[1]))
|
|
2573
|
+
return true;
|
|
2574
|
+
}
|
|
2575
|
+
return false;
|
|
2576
|
+
}
|
|
1748
2577
|
/**
|
|
1749
2578
|
* True when line `idx` assigns `variable` in one arm of a `switch` or one
|
|
1750
2579
|
* branch of an `if`/`else` chain, and a sibling arm or branch assigns it a
|
|
@@ -1767,7 +2596,7 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
|
|
|
1767
2596
|
block = enclosingBrace(lines, block.line, block.col);
|
|
1768
2597
|
}
|
|
1769
2598
|
if (!block)
|
|
1770
|
-
return
|
|
2599
|
+
return pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines);
|
|
1771
2600
|
let first = block.headerLine;
|
|
1772
2601
|
let last;
|
|
1773
2602
|
const end = matchingClose(lines, block.line, block.col);
|
|
@@ -1827,6 +2656,9 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
|
|
|
1827
2656
|
last = next.line;
|
|
1828
2657
|
}
|
|
1829
2658
|
}
|
|
2659
|
+
else if (/\bcatch\b/.test(block.header)) {
|
|
2660
|
+
return javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines);
|
|
2661
|
+
}
|
|
1830
2662
|
else {
|
|
1831
2663
|
return false;
|
|
1832
2664
|
}
|
|
@@ -1850,6 +2682,83 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
|
|
|
1850
2682
|
}
|
|
1851
2683
|
return false;
|
|
1852
2684
|
}
|
|
2685
|
+
const PY_ARM_OPEN = /^(?:(?:if|elif)\b.*|else\s*):\s*(?:#.*)?$/;
|
|
2686
|
+
/**
|
|
2687
|
+
* The indentation-delimited counterpart of `siblingBranchAssignsNonLiteral`.
|
|
2688
|
+
* The arms of one `if` / `elif` / `else` chain are the header lines at one
|
|
2689
|
+
* indentation plus the more deeply indented lines under each. True when line
|
|
2690
|
+
* `idx` sits directly in one arm of such a chain and another arm assigns
|
|
2691
|
+
* `variable` a value that is not a string literal (cognium-dev#582). Answers
|
|
2692
|
+
* false, keeping the literal as an overwrite, for anything else: a
|
|
2693
|
+
* straight-line assignment, a `for` / `while` / `try` / `def` body, or a header
|
|
2694
|
+
* that is not part of an `if` chain.
|
|
2695
|
+
*/
|
|
2696
|
+
function pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines) {
|
|
2697
|
+
const indentOf = (t) => t.length - t.trimStart().length;
|
|
2698
|
+
const skippable = (t) => t.trim() === '' || t.trimStart().startsWith('#');
|
|
2699
|
+
const own = lines[idx];
|
|
2700
|
+
if (own === undefined)
|
|
2701
|
+
return false;
|
|
2702
|
+
const bodyIndent = indentOf(own);
|
|
2703
|
+
if (bodyIndent === 0)
|
|
2704
|
+
return false;
|
|
2705
|
+
// The header of the arm that holds the literal: the nearest line above with less indentation.
|
|
2706
|
+
let h = idx - 1;
|
|
2707
|
+
while (h >= 0 && (skippable(lines[h]) || indentOf(lines[h]) >= bodyIndent))
|
|
2708
|
+
h--;
|
|
2709
|
+
if (h < 0 || !PY_ARM_OPEN.test(lines[h].trim()))
|
|
2710
|
+
return false;
|
|
2711
|
+
const armIndent = indentOf(lines[h]);
|
|
2712
|
+
// The last line of the arm whose header is at `header`.
|
|
2713
|
+
const armEnd = (header) => {
|
|
2714
|
+
let lastBody = header;
|
|
2715
|
+
for (let e = header + 1; e < lines.length; e++) {
|
|
2716
|
+
if (skippable(lines[e]))
|
|
2717
|
+
continue;
|
|
2718
|
+
if (indentOf(lines[e]) <= armIndent)
|
|
2719
|
+
break;
|
|
2720
|
+
lastBody = e;
|
|
2721
|
+
}
|
|
2722
|
+
return lastBody;
|
|
2723
|
+
};
|
|
2724
|
+
// Walk up through `elif` / `else` to the opening `if`.
|
|
2725
|
+
let first = h;
|
|
2726
|
+
while (/^(?:elif\b|else\s*:)/.test(lines[first].trim())) {
|
|
2727
|
+
let p = first - 1;
|
|
2728
|
+
while (p >= 0 && (skippable(lines[p]) || indentOf(lines[p]) > armIndent))
|
|
2729
|
+
p--;
|
|
2730
|
+
if (p < 0 || indentOf(lines[p]) !== armIndent || !PY_ARM_OPEN.test(lines[p].trim()))
|
|
2731
|
+
return false;
|
|
2732
|
+
first = p;
|
|
2733
|
+
}
|
|
2734
|
+
if (!/^if\b/.test(lines[first].trim()))
|
|
2735
|
+
return false;
|
|
2736
|
+
// Walk down through the following `elif` / `else` arms.
|
|
2737
|
+
const ownEnd = armEnd(h);
|
|
2738
|
+
let last = ownEnd;
|
|
2739
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
2740
|
+
let n = last + 1;
|
|
2741
|
+
while (n < lines.length && skippable(lines[n]))
|
|
2742
|
+
n++;
|
|
2743
|
+
if (n >= lines.length || indentOf(lines[n]) !== armIndent)
|
|
2744
|
+
break;
|
|
2745
|
+
if (!/^(?:elif\b|else\s*:)/.test(lines[n].trim()) || !PY_ARM_OPEN.test(lines[n].trim()))
|
|
2746
|
+
break;
|
|
2747
|
+
last = armEnd(n);
|
|
2748
|
+
}
|
|
2749
|
+
const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
|
|
2750
|
+
const literalOnly = new RegExp(`^${strLit}$`);
|
|
2751
|
+
for (let i = first; i <= last; i++) {
|
|
2752
|
+
if (i === idx || unreachableLines?.has(i + 1))
|
|
2753
|
+
continue;
|
|
2754
|
+
if (i >= h && i <= ownEnd)
|
|
2755
|
+
continue; // the literal's own arm is not a sibling
|
|
2756
|
+
const m = assign.exec(lines[i]);
|
|
2757
|
+
if (m && m[1] && !literalOnly.test(m[1]))
|
|
2758
|
+
return true;
|
|
2759
|
+
}
|
|
2760
|
+
return false;
|
|
2761
|
+
}
|
|
1853
2762
|
/**
|
|
1854
2763
|
* Languages whose plain quoted strings cannot interpolate, so blanking the
|
|
1855
2764
|
* literal text before identifier tokenisation is sound (cognium-dev#353).
|
|
@@ -2057,6 +2966,8 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2057
2966
|
// is every Juliet C# file.
|
|
2058
2967
|
const csFnRanges = methodRanges(['csharp']);
|
|
2059
2968
|
const csFnStart = (line) => enclosingStart(csFnRanges, line);
|
|
2969
|
+
const jsFnRanges = methodRanges(['javascript', 'typescript']);
|
|
2970
|
+
const jsFnStart = (line) => enclosingStart(jsFnRanges, line);
|
|
2060
2971
|
// cognium-dev#548 — names declared as class fields. A field is the one kind
|
|
2061
2972
|
// of variable whose value legitimately crosses from one method to another, so
|
|
2062
2973
|
// the per-method gate below must not apply to it.
|
|
@@ -2500,6 +3411,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2500
3411
|
const aliases = csharpMethodScopedAliases(code, seeds, csMethods, csFieldNames);
|
|
2501
3412
|
const fileAnchor = sourcesWithVar.reduce((a, sv) => (sv.line < a.line ? sv : a), sourcesWithVar[0]);
|
|
2502
3413
|
const have = new Set(sourcesWithVar.map(sv => `${sv.variable}@${methodStartOf(sv.line)}`));
|
|
3414
|
+
const seedVars = new Set(sourcesWithVar.map(s => s.variable));
|
|
3415
|
+
const derivedForAnchor = buildJavaTaintedVars(code, seedVars);
|
|
3416
|
+
const codeLinesForAnchor = code.split('\n');
|
|
2503
3417
|
for (const alias of aliases) {
|
|
2504
3418
|
const fn = methodStartOf(alias.line);
|
|
2505
3419
|
const isField = csFieldNames.has(alias.variable);
|
|
@@ -2512,14 +3426,34 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2512
3426
|
// call), which also keeps it ahead of a sink in a method declared
|
|
2513
3427
|
// earlier in the file than the method holding the source.
|
|
2514
3428
|
const inMethod = sourcesWithVar.filter(sv => methodStartOf(sv.line) === fn && sv.line <= alias.line);
|
|
2515
|
-
|
|
3429
|
+
let from = !alias.carried && inMethod.length > 0
|
|
2516
3430
|
? inMethod.reduce((a, sv) => (sv.line < a.line ? sv : a), inMethod[0])
|
|
2517
3431
|
: fileAnchor;
|
|
2518
|
-
|
|
3432
|
+
// cognium-dev#515: same RHS anchor as Java — do not credit an earlier decoy read.
|
|
3433
|
+
if (!alias.carried && derivedForAnchor.size > 0) {
|
|
3434
|
+
const methodRealSourceByVar = new Map();
|
|
3435
|
+
for (const s of sourcesWithVar) {
|
|
3436
|
+
if (methodStartOf(s.line) !== fn)
|
|
3437
|
+
continue;
|
|
3438
|
+
const prev = methodRealSourceByVar.get(s.variable);
|
|
3439
|
+
if (!prev || s.line < prev.line)
|
|
3440
|
+
methodRealSourceByVar.set(s.variable, s);
|
|
3441
|
+
}
|
|
3442
|
+
const referenced = resolveJavaDerivedAnchor(alias.variable, derivedForAnchor, methodRealSourceByVar, codeLinesForAnchor, new Set());
|
|
3443
|
+
if (referenced)
|
|
3444
|
+
from = referenced;
|
|
3445
|
+
}
|
|
3446
|
+
const aliasEntry = {
|
|
2519
3447
|
...from,
|
|
2520
3448
|
variable: alias.variable,
|
|
2521
3449
|
...(alias.carried || inMethod.length === 0 ? { line: alias.line } : {}),
|
|
2522
|
-
}
|
|
3450
|
+
};
|
|
3451
|
+
// cognium-dev#327: field aliases must not keep the writing method's
|
|
3452
|
+
// `in_method` tag — readers in other methods need to see the taint.
|
|
3453
|
+
if (isField) {
|
|
3454
|
+
delete aliasEntry.in_method;
|
|
3455
|
+
}
|
|
3456
|
+
sourcesWithVar.push(aliasEntry);
|
|
2523
3457
|
}
|
|
2524
3458
|
}
|
|
2525
3459
|
else if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
|
|
@@ -2550,29 +3484,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2550
3484
|
}
|
|
2551
3485
|
return null;
|
|
2552
3486
|
};
|
|
3487
|
+
const typeAt = (line) => {
|
|
3488
|
+
for (const t of types ?? []) {
|
|
3489
|
+
if (line >= t.start_line && line <= t.end_line)
|
|
3490
|
+
return t;
|
|
3491
|
+
}
|
|
3492
|
+
return undefined;
|
|
3493
|
+
};
|
|
3494
|
+
const isDeclaredField = (line, name) => {
|
|
3495
|
+
const t = typeAt(line);
|
|
3496
|
+
return t?.fields?.some(f => f.name === name) ?? false;
|
|
3497
|
+
};
|
|
2553
3498
|
const existingVars = new Set(sourcesWithVar.map(s => s.variable));
|
|
3499
|
+
const codeLines = code.split('\n');
|
|
3500
|
+
const realSourceByVar = new Map();
|
|
3501
|
+
for (const s of sourcesWithVar) {
|
|
3502
|
+
const prev = realSourceByVar.get(s.variable);
|
|
3503
|
+
if (!prev || s.line < prev.line)
|
|
3504
|
+
realSourceByVar.set(s.variable, s);
|
|
3505
|
+
}
|
|
2554
3506
|
for (const [varName, derivedLine] of derived) {
|
|
2555
3507
|
if (!varName || existingVars.has(varName))
|
|
2556
3508
|
continue;
|
|
2557
3509
|
const owner = methodAt(derivedLine);
|
|
3510
|
+
const fieldAlias = isDeclaredField(derivedLine, varName);
|
|
2558
3511
|
let scopedAnchor = anchor;
|
|
2559
3512
|
if (owner) {
|
|
2560
3513
|
let best;
|
|
3514
|
+
const methodRealSourceByVar = new Map();
|
|
2561
3515
|
for (const s of sourcesWithVar) {
|
|
2562
3516
|
const sOwner = s.in_method ?? methodAt(s.line);
|
|
2563
3517
|
if (sOwner !== owner)
|
|
2564
3518
|
continue;
|
|
3519
|
+
const prev = methodRealSourceByVar.get(s.variable);
|
|
3520
|
+
if (!prev || s.line < prev.line)
|
|
3521
|
+
methodRealSourceByVar.set(s.variable, s);
|
|
2565
3522
|
if (!best || s.line < best.line)
|
|
2566
3523
|
best = s;
|
|
2567
3524
|
}
|
|
2568
|
-
|
|
3525
|
+
const referenced = resolveJavaDerivedAnchor(varName, derived, methodRealSourceByVar, codeLines, new Set());
|
|
3526
|
+
if (referenced)
|
|
3527
|
+
scopedAnchor = referenced;
|
|
3528
|
+
else if (best)
|
|
2569
3529
|
scopedAnchor = best;
|
|
2570
3530
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
3531
|
+
// Keep scopedAnchor.line for in_method / preceding-source matching
|
|
3532
|
+
// (#614). Pass derivedLine only for literal-kill (#753), so a safe
|
|
3533
|
+
// sibling method's earlier sink cannot pick up this alias by line.
|
|
3534
|
+
// cognium-dev#327: a declared field written from a method parameter
|
|
3535
|
+
// must not inherit the writing method as `in_method` — same exemption
|
|
3536
|
+
// as request sources on fields (4.12.0), so readers in other methods
|
|
3537
|
+
// can see the taint.
|
|
3538
|
+
if (fieldAlias) {
|
|
3539
|
+
const { in_method: _drop, ...rest } = scopedAnchor;
|
|
3540
|
+
sourcesWithVar.push({
|
|
3541
|
+
...rest,
|
|
3542
|
+
variable: varName,
|
|
3543
|
+
literal_kill_line: derivedLine,
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
else {
|
|
3547
|
+
sourcesWithVar.push({
|
|
3548
|
+
...scopedAnchor,
|
|
3549
|
+
variable: varName,
|
|
3550
|
+
literal_kill_line: derivedLine,
|
|
3551
|
+
...(owner ? { in_method: owner } : {}),
|
|
3552
|
+
});
|
|
3553
|
+
}
|
|
2576
3554
|
existingVars.add(varName);
|
|
2577
3555
|
}
|
|
2578
3556
|
}
|
|
@@ -2586,7 +3564,217 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2586
3564
|
// tainted var (at the CommandText line) so the arg-scan links the Execute sink.
|
|
2587
3565
|
if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
|
|
2588
3566
|
const lines = code.split('\n');
|
|
2589
|
-
|
|
3567
|
+
// cognium-dev#594: `X.StartInfo.FileName = <tainted>` is the same shape for a
|
|
3568
|
+
// `System.Diagnostics.Process`: the executable path rides the process object
|
|
3569
|
+
// and the zero-argument `X.Start()` is the injection point. Only the
|
|
3570
|
+
// `FileName` write seeds it, and an `Arguments` write when the program is a
|
|
3571
|
+
// shell (`ArgumentList` is a separate follow-up), so a constant `FileName`
|
|
3572
|
+
// beside an unrelated tainted parameter stays clean.
|
|
3573
|
+
// A write only: `+=` or a single `=` that is not part of `==`, `!=`, `<=`, `>=`
|
|
3574
|
+
// (`if (p.StartInfo.FileName == tool)` is a comparison, not an assignment).
|
|
3575
|
+
// `StartInfo.Arguments` seeds too, but only behind a shell (or a non-constant
|
|
3576
|
+
// program), see `argumentsReachShell` below.
|
|
3577
|
+
const ctRe = /(\w+)\s*\.\s*(?:CommandText|StartInfo\s*\.\s*(?:FileName|(Arguments)))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
|
|
3578
|
+
// cognium-dev#594: the same two writes on a local `ProcessStartInfo psi`
|
|
3579
|
+
// (`psi.FileName = …`, `psi.Arguments = …`), read back by `Process.Start(psi)`.
|
|
3580
|
+
// Gated on the receiver being declared as a `ProcessStartInfo`, so a `FileName`
|
|
3581
|
+
// or `Arguments` property of any other type seeds nothing.
|
|
3582
|
+
const psiVars = new Set();
|
|
3583
|
+
for (const line of lines) {
|
|
3584
|
+
for (const pm of line.matchAll(/\bProcessStartInfo\s+([\p{L}_][\p{L}\p{N}_]*)/gu))
|
|
3585
|
+
psiVars.add(pm[1]);
|
|
3586
|
+
for (const pm of line.matchAll(/\b([\p{L}_][\p{L}\p{N}_]*)\s*=\s*new\s+ProcessStartInfo\b/gu))
|
|
3587
|
+
psiVars.add(pm[1]);
|
|
3588
|
+
}
|
|
3589
|
+
const psiRe = /(?<![\w.])(\w+)\s*\.\s*(?:FileName|(Arguments))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
|
|
3590
|
+
const psiOverwrittenByLiteral = (obj, prop, at) => {
|
|
3591
|
+
const fn = csFnStart(at + 1);
|
|
3592
|
+
const re = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*${prop}\\s*=(?!=)\\s*@?"[^"]*"\\s*;\\s*(?://.*)?$`);
|
|
3593
|
+
for (let j = at + 1; j < lines.length; j++) {
|
|
3594
|
+
if (csFnStart(j + 1) !== fn)
|
|
3595
|
+
break;
|
|
3596
|
+
if (re.test(lines[j]))
|
|
3597
|
+
return true;
|
|
3598
|
+
}
|
|
3599
|
+
return false;
|
|
3600
|
+
};
|
|
3601
|
+
const fileNameRe = (obj) => new RegExp(`\\b${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?FileName\\s*=(?!=)\\s*(.+)`);
|
|
3602
|
+
const shellProgramRe = /^(?:cmd|powershell|pwsh|sh|bash|zsh|dash|ksh|csh|tcsh|fish)(?:\.exe)?$/i;
|
|
3603
|
+
// cognium-dev#594: with a constant, non-shell program the argument string is
|
|
3604
|
+
// argv (the #272 / #328 safe shape), so a tainted `Arguments` does not by itself
|
|
3605
|
+
// reach a shell. A constant shell program, or a program that is not a constant,
|
|
3606
|
+
// lets the argument text choose what runs. No `FileName` write in the method
|
|
3607
|
+
// (for example a `ProcessStartInfo` built elsewhere) seeds nothing.
|
|
3608
|
+
const argumentsReachShell = (obj, line) => {
|
|
3609
|
+
const re = fileNameRe(obj);
|
|
3610
|
+
const fnStart = csFnStart(line);
|
|
3611
|
+
for (let j = 0; j < lines.length; j++) {
|
|
3612
|
+
if (csFnStart(j + 1) !== fnStart)
|
|
3613
|
+
continue;
|
|
3614
|
+
const fm = re.exec(lines[j]);
|
|
3615
|
+
if (!fm)
|
|
3616
|
+
continue;
|
|
3617
|
+
const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
|
|
3618
|
+
if (!lit)
|
|
3619
|
+
return true;
|
|
3620
|
+
const base = lit[1].split(/[\\/]/).pop() ?? '';
|
|
3621
|
+
return shellProgramRe.test(base);
|
|
3622
|
+
}
|
|
3623
|
+
return false;
|
|
3624
|
+
};
|
|
3625
|
+
// cognium-dev#594: `X.StartInfo.ArgumentList.Add(<tainted>)` (or `psi.ArgumentList.Add(…)`
|
|
3626
|
+
// on a local `ProcessStartInfo`) is argv, so it only reaches a command when the
|
|
3627
|
+
// program is a shell that is told to run a command string. Seed only when the
|
|
3628
|
+
// method writes `FileName` exactly once, to a constant shell program, and an
|
|
3629
|
+
// `ArgumentList.Add` of a command flag (`-c`, `/c`, …) is in the same method.
|
|
3630
|
+
// A non-constant or non-shell program, a second `FileName` write, an
|
|
3631
|
+
// `ArgumentList.Clear()`, or an `Add(` that wraps over several lines seeds nothing.
|
|
3632
|
+
const argListRe = /(?<![\w.])(\w+)\s*\.\s*(StartInfo\s*\.\s*)?ArgumentList\s*\.\s*Add\s*\(\s*(.+?)\s*\)\s*;?\s*(?:\/\/.*)?$/;
|
|
3633
|
+
const shellFlagRe = /^(?:[-/](?:c|k|command|encodedcommand|ec))$/i;
|
|
3634
|
+
// cognium-dev#750: the program may also be written by the `ProcessStartInfo`
|
|
3635
|
+
// constructor's single argument or by a `FileName = …` entry of its object
|
|
3636
|
+
// initializer, on the statement that creates `obj` (`var psi = new
|
|
3637
|
+
// ProcessStartInfo("/bin/sh");`, `new ProcessStartInfo { FileName = "/bin/sh",
|
|
3638
|
+
// … }`, possibly wrapped over several lines). Each counts as one `FileName`
|
|
3639
|
+
// write, so the exactly-one-write rule still holds, and they apply only when the
|
|
3640
|
+
// method has no explicit `obj.FileName = …` write. Any shape that is not
|
|
3641
|
+
// plainly one constant program (two constructor arguments, an `Arguments`
|
|
3642
|
+
// entry, a non-literal value) counts as a non-constant write.
|
|
3643
|
+
const splitTopLevel = (text) => {
|
|
3644
|
+
const parts = [];
|
|
3645
|
+
let depth = 0;
|
|
3646
|
+
let quote = '';
|
|
3647
|
+
let cur = '';
|
|
3648
|
+
for (let k = 0; k < text.length; k++) {
|
|
3649
|
+
const ch = text[k];
|
|
3650
|
+
if (quote) {
|
|
3651
|
+
cur += ch;
|
|
3652
|
+
if (ch === '\\' && quote === '"') {
|
|
3653
|
+
cur += text[++k] ?? '';
|
|
3654
|
+
continue;
|
|
3655
|
+
}
|
|
3656
|
+
if (ch === quote)
|
|
3657
|
+
quote = '';
|
|
3658
|
+
continue;
|
|
3659
|
+
}
|
|
3660
|
+
if (ch === '"') {
|
|
3661
|
+
quote = ch;
|
|
3662
|
+
cur += ch;
|
|
3663
|
+
continue;
|
|
3664
|
+
}
|
|
3665
|
+
if (ch === '(' || ch === '{' || ch === '[')
|
|
3666
|
+
depth++;
|
|
3667
|
+
if (ch === ')' || ch === '}' || ch === ']')
|
|
3668
|
+
depth--;
|
|
3669
|
+
if (ch === ',' && depth === 0) {
|
|
3670
|
+
parts.push(cur.trim());
|
|
3671
|
+
cur = '';
|
|
3672
|
+
continue;
|
|
3673
|
+
}
|
|
3674
|
+
cur += ch;
|
|
3675
|
+
}
|
|
3676
|
+
if (cur.trim())
|
|
3677
|
+
parts.push(cur.trim());
|
|
3678
|
+
return parts;
|
|
3679
|
+
};
|
|
3680
|
+
const constLiteral = (expr) => {
|
|
3681
|
+
const m = /^@?"([^"]*)"$/.exec(expr.trim());
|
|
3682
|
+
return m ? (m[1].split(/[\\/]/).pop() ?? '') : null;
|
|
3683
|
+
};
|
|
3684
|
+
// Returns null when line j does not create `obj` or the creation writes no program.
|
|
3685
|
+
const startInfoCreation = (obj, j) => {
|
|
3686
|
+
const head = new RegExp(`(?<![\\w.])${obj}(?:\\s*\\.\\s*StartInfo)?\\s*=(?!=)\\s*new\\s+ProcessStartInfo\\b`).exec(lines[j]);
|
|
3687
|
+
if (!head)
|
|
3688
|
+
return null;
|
|
3689
|
+
let text = '';
|
|
3690
|
+
for (let k = j; k < lines.length && k < j + 40; k++) {
|
|
3691
|
+
text += (k === j ? lines[k].slice(head.index) : ' ' + lines[k]).replace(/\/\/.*$/, '');
|
|
3692
|
+
if (text.includes(';'))
|
|
3693
|
+
break;
|
|
3694
|
+
}
|
|
3695
|
+
const m = /new\s+ProcessStartInfo\s*(?:\(([^()]*)\))?\s*(?:\{([\s\S]*)\})?\s*;/.exec(text);
|
|
3696
|
+
if (!m)
|
|
3697
|
+
return { program: null };
|
|
3698
|
+
let program;
|
|
3699
|
+
const ctorArgs = splitTopLevel(m[1] ?? '');
|
|
3700
|
+
if (ctorArgs.length > 1)
|
|
3701
|
+
return { program: null };
|
|
3702
|
+
if (ctorArgs.length === 1)
|
|
3703
|
+
program = constLiteral(ctorArgs[0]);
|
|
3704
|
+
for (const member of splitTopLevel(m[2] ?? '')) {
|
|
3705
|
+
const em = /^(\w+)\s*=(?!=)\s*([\s\S]*)$/.exec(member);
|
|
3706
|
+
if (!em)
|
|
3707
|
+
return { program: null };
|
|
3708
|
+
if (em[1] === 'FileName') {
|
|
3709
|
+
if (program !== undefined)
|
|
3710
|
+
return { program: null };
|
|
3711
|
+
program = constLiteral(em[2]);
|
|
3712
|
+
}
|
|
3713
|
+
else if (em[1] === 'Arguments' || em[1] === 'ArgumentList') {
|
|
3714
|
+
return { program: null };
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
return program === undefined ? null : { program };
|
|
3718
|
+
};
|
|
3719
|
+
const constStringFieldProgram = (fieldName) => {
|
|
3720
|
+
const declRe = new RegExp(`\\b(?:\\w+\\s+)*const\\s+string\\s+${fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*@?"([^"]*)"\\s*;`);
|
|
3721
|
+
let hits = 0;
|
|
3722
|
+
let base = null;
|
|
3723
|
+
for (const srcLine of lines) {
|
|
3724
|
+
const dm = declRe.exec(srcLine);
|
|
3725
|
+
if (!dm)
|
|
3726
|
+
continue;
|
|
3727
|
+
hits++;
|
|
3728
|
+
base = dm[1].split(/[\\/]/).pop() ?? '';
|
|
3729
|
+
}
|
|
3730
|
+
return hits === 1 ? base : null;
|
|
3731
|
+
};
|
|
3732
|
+
const argListReachesShell = (obj, line) => {
|
|
3733
|
+
const fnStart = csFnStart(line);
|
|
3734
|
+
const nameRe = fileNameRe(obj);
|
|
3735
|
+
const clearRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*(?:Clear|Remove\\w*|Insert\\w*)\\s*\\(`);
|
|
3736
|
+
const addRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*Add\\s*\\(\\s*@?"([^"]*)"\\s*\\)`);
|
|
3737
|
+
let program = null;
|
|
3738
|
+
let writes = 0;
|
|
3739
|
+
let creations = 0;
|
|
3740
|
+
let createdProgram = null;
|
|
3741
|
+
let flag = false;
|
|
3742
|
+
for (let j = 0; j < lines.length; j++) {
|
|
3743
|
+
if (csFnStart(j + 1) !== fnStart)
|
|
3744
|
+
continue;
|
|
3745
|
+
if (clearRe.test(lines[j]))
|
|
3746
|
+
return false;
|
|
3747
|
+
const fm = nameRe.exec(lines[j]);
|
|
3748
|
+
if (fm) {
|
|
3749
|
+
writes++;
|
|
3750
|
+
const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
|
|
3751
|
+
if (lit) {
|
|
3752
|
+
program = lit[1].split(/[\\/]/).pop() ?? '';
|
|
3753
|
+
}
|
|
3754
|
+
else {
|
|
3755
|
+
const id = /^\s*(\w+)\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
|
|
3756
|
+
program = id ? constStringFieldProgram(id[1]) : null;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
const created = startInfoCreation(obj, j);
|
|
3760
|
+
if (created) {
|
|
3761
|
+
creations++;
|
|
3762
|
+
createdProgram = created.program;
|
|
3763
|
+
}
|
|
3764
|
+
const am = addRe.exec(lines[j]);
|
|
3765
|
+
if (am && shellFlagRe.test(am[1]))
|
|
3766
|
+
flag = true;
|
|
3767
|
+
}
|
|
3768
|
+
// An explicit `obj.FileName = …` decides the program, as before. Only when the
|
|
3769
|
+
// method has none does the creating statement's constant program count, and
|
|
3770
|
+
// then it must be the only creation (a second `new ProcessStartInfo(…)` for
|
|
3771
|
+
// the same variable could replace it).
|
|
3772
|
+
if (writes === 0 && creations > 0) {
|
|
3773
|
+
writes = creations;
|
|
3774
|
+
program = createdProgram;
|
|
3775
|
+
}
|
|
3776
|
+
return writes === 1 && program !== null && shellProgramRe.test(program) && flag;
|
|
3777
|
+
};
|
|
2590
3778
|
// cognium-dev#548: seed per method, from sources in that method. The seed
|
|
2591
3779
|
// used to be made once per file and matched by name, so `cmd.CommandText =
|
|
2592
3780
|
// … + data` in `Bad()` tainted `cmd.ExecuteScalar()` in `GoodG2B()`, whose
|
|
@@ -2599,15 +3787,60 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2599
3787
|
added = false;
|
|
2600
3788
|
guard++;
|
|
2601
3789
|
for (let i = 0; i < lines.length; i++) {
|
|
2602
|
-
const
|
|
3790
|
+
const am = argListRe.exec(lines[i]);
|
|
3791
|
+
if (am) {
|
|
3792
|
+
const [, aobj, viaStartInfo, aargs] = am;
|
|
3793
|
+
const akey = `${aobj}@${csFnStart(i + 1)}`;
|
|
3794
|
+
if ((viaStartInfo || psiVars.has(aobj)) && !seeded.has(akey) && argListReachesShell(aobj, i + 1)) {
|
|
3795
|
+
// A value set to a literal after the source and before this `Add` no longer
|
|
3796
|
+
// carries it (`cmd = name; cmd = "uptime"; …Add(cmd)`).
|
|
3797
|
+
const literalSince = (v, from) => {
|
|
3798
|
+
const re = new RegExp(`(?<![\\w.])${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)\\s*@?"[^"]*"\\s*;`);
|
|
3799
|
+
const fn = csFnStart(i + 1);
|
|
3800
|
+
for (let j = from; j < i; j++)
|
|
3801
|
+
if (csFnStart(j + 1) === fn && re.test(lines[j]))
|
|
3802
|
+
return true;
|
|
3803
|
+
return false;
|
|
3804
|
+
};
|
|
3805
|
+
const reachableA = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1) && !literalSince(sv.variable, sv.line));
|
|
3806
|
+
const maskedArg = aargs.replace(/\b(?:int|uint|long|ulong|short|ushort|byte|double|float|decimal|bool|Int16|Int32|Int64|Double|Decimal|Boolean|Guid)\s*\.\s*(?:Try)?Parse\s*\((?:[^()]|\([^()]*\))*\)|\bConvert\s*\.\s*To(?:Int16|Int32|Int64|Double|Decimal|Boolean|Byte)\s*\((?:[^()]|\([^()]*\))*\)/g, '0');
|
|
3807
|
+
if (reachableA.length > 0 && argsReferenceTaintedVar(maskedArg, reachableA)) {
|
|
3808
|
+
const fromA = reachableA.reduce((a, sv) => (sv.line < a.line ? sv : a), reachableA[0]);
|
|
3809
|
+
sourcesWithVar.push({ ...fromA, variable: aobj, line: i + 1 });
|
|
3810
|
+
seeded.add(akey);
|
|
3811
|
+
added = true;
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
continue;
|
|
3815
|
+
}
|
|
3816
|
+
let m = ctRe.exec(lines[i]);
|
|
3817
|
+
let viaLocal = false;
|
|
3818
|
+
if (!m) {
|
|
3819
|
+
const pm = psiRe.exec(lines[i]);
|
|
3820
|
+
if (pm && psiVars.has(pm[1])) {
|
|
3821
|
+
m = pm;
|
|
3822
|
+
viaLocal = true;
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
2603
3825
|
if (!m)
|
|
2604
3826
|
continue;
|
|
2605
|
-
const [, obj, rhs] = m;
|
|
3827
|
+
const [, obj, isArguments, rhs] = m;
|
|
3828
|
+
// A later plain-literal write of the same property in this method replaces
|
|
3829
|
+
// the tainted value before `Process.Start(psi)` reads it: do not credit it.
|
|
3830
|
+
if (viaLocal && psiOverwrittenByLiteral(obj, isArguments ? 'Arguments' : 'FileName', i))
|
|
3831
|
+
continue;
|
|
2606
3832
|
const key = `${obj}@${csFnStart(i + 1)}`;
|
|
2607
3833
|
if (seeded.has(key))
|
|
2608
3834
|
continue;
|
|
3835
|
+
if (isArguments && !argumentsReachShell(obj, i + 1))
|
|
3836
|
+
continue;
|
|
2609
3837
|
const reachable = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1));
|
|
2610
|
-
|
|
3838
|
+
// A value converted to a number / Guid / bool cannot carry a command; the
|
|
3839
|
+
// local-`psi` form masks such conversions so a raw operand beside one still counts.
|
|
3840
|
+
const rhsForTaint = viaLocal
|
|
3841
|
+
? rhs.replace(/\b(?:int|uint|long|ulong|short|ushort|byte|double|float|decimal|bool|Int16|Int32|Int64|Double|Decimal|Boolean|Guid)\s*\.\s*(?:Try)?Parse\s*\((?:[^()]|\([^()]*\))*\)|\bConvert\s*\.\s*To(?:Int16|Int32|Int64|Double|Decimal|Boolean|Byte)\s*\((?:[^()]|\([^()]*\))*\)/g, '0')
|
|
3842
|
+
: rhs;
|
|
3843
|
+
if (reachable.length === 0 || !argsReferenceTaintedVar(rhsForTaint, reachable))
|
|
2611
3844
|
continue;
|
|
2612
3845
|
const from = reachable.reduce((a, sv) => (sv.line < a.line ? sv : a), reachable[0]);
|
|
2613
3846
|
sourcesWithVar.push({ ...from, variable: obj, line: i + 1 });
|
|
@@ -2807,6 +4040,19 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2807
4040
|
if (srcFn !== -1 && sinkFn !== -1 && srcFn !== sinkFn)
|
|
2808
4041
|
continue;
|
|
2809
4042
|
}
|
|
4043
|
+
// cognium-dev#752: GraphQL resolver args are seeded by name only; scope
|
|
4044
|
+
// them to the resolver (or factory) they came from so a same-named
|
|
4045
|
+
// local in another recorded function does not inherit the seed.
|
|
4046
|
+
if (jsFnRanges.length > 0 &&
|
|
4047
|
+
typeof source.location === 'string' &&
|
|
4048
|
+
source.location.startsWith('GraphQL ')) {
|
|
4049
|
+
const sinkFn = jsFnStart(sink.line);
|
|
4050
|
+
if (sinkFn !== -1) {
|
|
4051
|
+
const range = jsFnRanges.find(([start]) => start === sinkFn);
|
|
4052
|
+
if (range && (source.line < range[0] || source.line > range[1]))
|
|
4053
|
+
continue;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
2810
4056
|
// cognium-dev#530: a builder-seeded source must not escape its method.
|
|
2811
4057
|
// The seed stands for "this StringBuilder now holds tainted text",
|
|
2812
4058
|
// which is true only of the one object in the one method where the
|
|
@@ -2860,14 +4106,16 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2860
4106
|
if (aliasSanitizedFor.get(source.variable)?.has(sink.type)) {
|
|
2861
4107
|
break;
|
|
2862
4108
|
}
|
|
2863
|
-
// Sprint 9 #58.3 / #56: between
|
|
2864
|
-
// the tainted variable is reassigned to a
|
|
2865
|
-
// (either naked `var = "lit"` or guarded by an
|
|
2866
|
-
// such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
|
|
4109
|
+
// Sprint 9 #58.3 / #56: between the alias derivation (or source)
|
|
4110
|
+
// line and sink.line, if the tainted variable is reassigned to a
|
|
4111
|
+
// pure string literal (either naked `var = "lit"` or guarded by an
|
|
4112
|
+
// allowlist check such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
|
|
2867
4113
|
// `if var not in ALLOWLIST: var = "lit"`), the original taint
|
|
2868
|
-
// no longer reaches the sink — suppress the flow.
|
|
4114
|
+
// no longer reaches the sink — suppress the flow. Derived aliases
|
|
4115
|
+
// carry `literal_kill_line` so kill starts at the assignment that
|
|
4116
|
+
// created them (#753), while `source.line` stays on the anchor (#614).
|
|
2869
4117
|
if (typeof code === 'string' &&
|
|
2870
|
-
isReassignedToLiteralBetween(code, source.variable, source.line, sink.line, unreachableLines)) {
|
|
4118
|
+
isReassignedToLiteralBetween(code, source.variable, source.literal_kill_line ?? source.line, sink.line, unreachableLines)) {
|
|
2871
4119
|
break;
|
|
2872
4120
|
}
|
|
2873
4121
|
// Note: DFG-flow filter handles sanitizer-guard suppression
|
|
@@ -2890,6 +4138,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2890
4138
|
}
|
|
2891
4139
|
}
|
|
2892
4140
|
}
|
|
4141
|
+
// cognium-dev#490 — same helper as `splitCSharpDepth0Statements` in language-sources-pass.
|
|
4142
|
+
const splitCSharpDepth0Statements = (line) => {
|
|
4143
|
+
if (line.includes('{'))
|
|
4144
|
+
return [line];
|
|
4145
|
+
const statements = [];
|
|
4146
|
+
let start = 0;
|
|
4147
|
+
let depth = 0;
|
|
4148
|
+
let inStr = null;
|
|
4149
|
+
let esc = false;
|
|
4150
|
+
for (let i = 0; i < line.length; i++) {
|
|
4151
|
+
const c = line[i];
|
|
4152
|
+
if (inStr) {
|
|
4153
|
+
if (esc) {
|
|
4154
|
+
esc = false;
|
|
4155
|
+
continue;
|
|
4156
|
+
}
|
|
4157
|
+
if (c === '\\') {
|
|
4158
|
+
esc = true;
|
|
4159
|
+
continue;
|
|
4160
|
+
}
|
|
4161
|
+
if (c === inStr)
|
|
4162
|
+
inStr = null;
|
|
4163
|
+
continue;
|
|
4164
|
+
}
|
|
4165
|
+
if (c === '"' || c === "'") {
|
|
4166
|
+
inStr = c;
|
|
4167
|
+
continue;
|
|
4168
|
+
}
|
|
4169
|
+
if (c === '(' || c === '[')
|
|
4170
|
+
depth++;
|
|
4171
|
+
else if (c === ')' || c === ']')
|
|
4172
|
+
depth = Math.max(0, depth - 1);
|
|
4173
|
+
else if (c === ';' && depth === 0) {
|
|
4174
|
+
const part = line.slice(start, i + 1).trim();
|
|
4175
|
+
if (part)
|
|
4176
|
+
statements.push(part);
|
|
4177
|
+
start = i + 1;
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
const tail = line.slice(start).trim();
|
|
4181
|
+
if (tail)
|
|
4182
|
+
statements.push(tail);
|
|
4183
|
+
return statements.length > 1 ? statements : [line];
|
|
4184
|
+
};
|
|
4185
|
+
const boundInEarlierStatement = (code, variable, sinkMethod) => {
|
|
4186
|
+
if (!variable || variable === '<inline>' || !sinkMethod)
|
|
4187
|
+
return false;
|
|
4188
|
+
const stmts = splitCSharpDepth0Statements(code);
|
|
4189
|
+
if (stmts.length < 2)
|
|
4190
|
+
return false;
|
|
4191
|
+
const methodRe = new RegExp(`\\b${sinkMethod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
|
|
4192
|
+
let sinkIdx = -1;
|
|
4193
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
4194
|
+
if (methodRe.test(stmts[i])) {
|
|
4195
|
+
sinkIdx = i;
|
|
4196
|
+
break;
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
if (sinkIdx <= 0)
|
|
4200
|
+
return false;
|
|
4201
|
+
const varAssign = new RegExp(`\\b${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)`);
|
|
4202
|
+
for (let i = 0; i < sinkIdx; i++) {
|
|
4203
|
+
if (varAssign.test(stmts[i]))
|
|
4204
|
+
return true;
|
|
4205
|
+
}
|
|
4206
|
+
return false;
|
|
4207
|
+
};
|
|
2893
4208
|
// cognium-dev #83: inline-source colocation.
|
|
2894
4209
|
//
|
|
2895
4210
|
// When a source expression is used INLINE as a sink argument — e.g.
|
|
@@ -2978,6 +4293,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2978
4293
|
if (new RegExp(`\\b${sourceVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(rhs)) {
|
|
2979
4294
|
continue;
|
|
2980
4295
|
}
|
|
4296
|
+
if (boundInEarlierStatement(sinkCode, sourceVar, sink.method)) {
|
|
4297
|
+
continue;
|
|
4298
|
+
}
|
|
2981
4299
|
}
|
|
2982
4300
|
// Skip the degenerate `file_input` → `path_traversal` colocation
|
|
2983
4301
|
// where the source and sink describe the SAME call (one being the
|