circle-ir 4.14.2 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/configs/sinks/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/const-table-lookup.d.ts +29 -0
- package/dist/analysis/const-table-lookup.d.ts.map +1 -0
- package/dist/analysis/const-table-lookup.js +145 -0
- package/dist/analysis/const-table-lookup.js.map +1 -0
- 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 +149 -4
- package/dist/analysis/findings.js.map +1 -1
- package/dist/analysis/go-strings-replacer.d.ts +21 -0
- package/dist/analysis/go-strings-replacer.d.ts.map +1 -0
- package/dist/analysis/go-strings-replacer.js +145 -0
- package/dist/analysis/go-strings-replacer.js.map +1 -0
- package/dist/analysis/interprocedural.d.ts.map +1 -1
- package/dist/analysis/interprocedural.js +148 -0
- 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/java-canonical-prefix.d.ts +42 -0
- package/dist/analysis/java-canonical-prefix.d.ts.map +1 -0
- package/dist/analysis/java-canonical-prefix.js +145 -0
- package/dist/analysis/java-canonical-prefix.js.map +1 -0
- package/dist/analysis/java-crlf-strip.d.ts +30 -0
- package/dist/analysis/java-crlf-strip.d.ts.map +1 -0
- package/dist/analysis/java-crlf-strip.js +195 -0
- package/dist/analysis/java-crlf-strip.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/cleartext-credential-transport-pass.d.ts +2 -0
- package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts.map +1 -1
- package/dist/analysis/passes/cleartext-credential-transport-pass.js +64 -12
- package/dist/analysis/passes/cleartext-credential-transport-pass.js.map +1 -1
- package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
- package/dist/analysis/passes/cross-file-pass.js +43 -0
- package/dist/analysis/passes/cross-file-pass.js.map +1 -1
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +12 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
- package/dist/analysis/passes/csharp-sanitizer-credit.js +127 -12
- package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
- package/dist/analysis/passes/interprocedural-pass.d.ts +28 -0
- package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
- package/dist/analysis/passes/interprocedural-pass.js +20 -1
- package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts +76 -2
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +2045 -156
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.d.ts +8 -0
- package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.js +214 -6
- 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 +509 -27
- 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 +1606 -60
- 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 +33 -0
- package/dist/analysis/scalar-derivation.d.ts.map +1 -0
- package/dist/analysis/scalar-derivation.js +368 -0
- package/dist/analysis/scalar-derivation.js.map +1 -0
- 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 +695 -34
- 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 +375 -24
- 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 +19 -2
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +25539 -19200
- package/dist/core/circle-ir-core.cjs +3945 -2126
- package/dist/core/circle-ir-core.js +3945 -2126
- 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 +336 -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/graph/code-graph.d.ts +9 -0
- package/dist/graph/code-graph.d.ts.map +1 -1
- package/dist/graph/code-graph.js +11 -0
- package/dist/graph/code-graph.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 +16 -0
- package/dist/resolution/cross-file.d.ts.map +1 -1
- package/dist/resolution/cross-file.js +110 -42
- 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/dist/wasm/web-tree-sitter.wasm +0 -0
- package/package.json +3 -3
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Depends on: sink-filter, constant-propagation
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { findJavaSpringReturnXSSSinks } from './language-sources-pass.js';
|
|
14
|
+
import { expressionFullyWrappedBySanitizer, identifierOutsideSanitizerCalls, identifierOutsideSanitizerCallsInText, propagateTaint } from '../taint-propagation.js';
|
|
14
15
|
import { isFalsePositive, isCorrelatedPredicateFP } from '../constant-propagation.js';
|
|
15
|
-
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';
|
|
16
18
|
import { canSourceReachSink, sourceSemanticsAllowed } from '../findings.js';
|
|
17
19
|
import { walkBackwardDefs } from '../dfg-walk.js';
|
|
18
20
|
import { sanitizerCoversSink } from '../sanitizer-index.js';
|
|
21
|
+
import { HTML_CONTEXT_ENCODER_METHODS } from '../constant-propagation/patterns.js';
|
|
22
|
+
import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
|
|
19
23
|
/**
|
|
20
24
|
* cognium-dev#328 — the const-prop per-step veto, with two scoped exemptions.
|
|
21
25
|
*
|
|
@@ -40,7 +44,352 @@ import { sanitizerCoversSink } from '../sanitizer-index.js';
|
|
|
40
44
|
* code, resolved constant) are unaffected.
|
|
41
45
|
*/
|
|
42
46
|
const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
|
|
43
|
-
|
|
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
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* #515 — find the real source a Python derived variable actually references.
|
|
137
|
+
* Reads the derivation's right-hand side at its origin line (f-string braces
|
|
138
|
+
* kept, plain quotes and trailing `#…` comments dropped), picks the real
|
|
139
|
+
* source variables named there (earliest line wins when several), and follows
|
|
140
|
+
* names that are themselves derived — including a later assignment of a name
|
|
141
|
+
* that was also a real source. Returns undefined when nothing can be
|
|
142
|
+
* resolved, so the caller keeps its earliest-source fallback.
|
|
143
|
+
*/
|
|
144
|
+
/** Drop a trailing `#…` comment, leaving `#` inside string literals alone. */
|
|
145
|
+
function stripPythonTrailingComment(text) {
|
|
146
|
+
let i = 0;
|
|
147
|
+
while (i < text.length) {
|
|
148
|
+
const ch = text[i];
|
|
149
|
+
if (ch === '"' || ch === "'") {
|
|
150
|
+
const triple = text.startsWith(ch.repeat(3), i);
|
|
151
|
+
const delim = triple ? ch.repeat(3) : ch;
|
|
152
|
+
i += delim.length;
|
|
153
|
+
while (i < text.length && !text.startsWith(delim, i)) {
|
|
154
|
+
if (text[i] === '\\') {
|
|
155
|
+
i += 2;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
i += delim.length;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === '#')
|
|
164
|
+
return text.slice(0, i);
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
return text;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Keep only the f-string interpolation expression: cut before a top-level
|
|
171
|
+
* `!conversion` or `:format_spec` (Python replacement_field grammar). A `!`
|
|
172
|
+
* followed by `=` is the `!=` operator and is not a cut point. Depth
|
|
173
|
+
* tracks `()[]{}`; string literals are skipped so a `:` inside a subscript
|
|
174
|
+
* key does not truncate the expression.
|
|
175
|
+
*/
|
|
176
|
+
function pythonFStringNestedFields(body) {
|
|
177
|
+
// Format-spec / conversion tails only: a `{` here always opens a nested
|
|
178
|
+
// replacement field (Python 3.12+). Do NOT treat `{{` / `}}` as literal-brace
|
|
179
|
+
// escapes — `f"{label:{{fmt}}}"` parses as a nested FormattedValue whose
|
|
180
|
+
// expression is the set `{fmt}`. The top-level f-string skip stays in
|
|
181
|
+
// braceExprs so a literal `f"{{fmt}}"` still does not credit `fmt`. Each
|
|
182
|
+
// nested `{…}` is itself an expression (and may carry its own conversion /
|
|
183
|
+
// format-spec), so recurse through pythonFStringExprOnly.
|
|
184
|
+
let acc = '';
|
|
185
|
+
for (let j = 0; j < body.length;) {
|
|
186
|
+
if (body[j] === '{') {
|
|
187
|
+
j++;
|
|
188
|
+
let depth = 1;
|
|
189
|
+
let inner = '';
|
|
190
|
+
while (j < body.length && depth > 0) {
|
|
191
|
+
if (body[j] === '{')
|
|
192
|
+
depth++;
|
|
193
|
+
else if (body[j] === '}') {
|
|
194
|
+
depth--;
|
|
195
|
+
if (depth === 0)
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
inner += body[j];
|
|
199
|
+
j++;
|
|
200
|
+
}
|
|
201
|
+
j++;
|
|
202
|
+
acc += ' ' + pythonFStringExprOnly(inner) + ' ';
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
j++;
|
|
206
|
+
}
|
|
207
|
+
return acc;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Keep the expression part of an f-string replacement field; drop literal
|
|
211
|
+
* conversion / format-spec text, but retain nested `{…}` fields from the
|
|
212
|
+
* discarded tail (format specs may contain nested replacement fields).
|
|
213
|
+
*/
|
|
214
|
+
function pythonFStringExprOnly(inner) {
|
|
215
|
+
let depth = 0;
|
|
216
|
+
let i = 0;
|
|
217
|
+
while (i < inner.length) {
|
|
218
|
+
const ch = inner[i];
|
|
219
|
+
if (ch === '"' || ch === "'") {
|
|
220
|
+
const triple = inner.startsWith(ch.repeat(3), i);
|
|
221
|
+
const delim = triple ? ch.repeat(3) : ch;
|
|
222
|
+
i += delim.length;
|
|
223
|
+
while (i < inner.length && !inner.startsWith(delim, i)) {
|
|
224
|
+
if (inner[i] === '\\') {
|
|
225
|
+
i += 2;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
i += delim.length;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === '(' || ch === '[' || ch === '{') {
|
|
234
|
+
depth++;
|
|
235
|
+
i++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (ch === ')' || ch === ']' || ch === '}') {
|
|
239
|
+
depth--;
|
|
240
|
+
i++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (depth === 0 && ch === '!') {
|
|
244
|
+
// `!=` is the comparison operator, not a `!conversion`; keep scanning.
|
|
245
|
+
let j = i + 1;
|
|
246
|
+
while (j < inner.length && (inner[j] === ' ' || inner[j] === '\t'))
|
|
247
|
+
j++;
|
|
248
|
+
if (inner[j] === '=') {
|
|
249
|
+
i = j + 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const expr = inner.slice(0, i);
|
|
253
|
+
const nested = pythonFStringNestedFields(inner.slice(i + 1));
|
|
254
|
+
return nested ? expr + nested : expr;
|
|
255
|
+
}
|
|
256
|
+
if (depth === 0 && ch === ':') {
|
|
257
|
+
const expr = inner.slice(0, i);
|
|
258
|
+
const nested = pythonFStringNestedFields(inner.slice(i + 1));
|
|
259
|
+
return nested ? expr + nested : expr;
|
|
260
|
+
}
|
|
261
|
+
i++;
|
|
262
|
+
}
|
|
263
|
+
return inner;
|
|
264
|
+
}
|
|
265
|
+
function resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines, visiting) {
|
|
266
|
+
if (visiting.has(varName))
|
|
267
|
+
return undefined;
|
|
268
|
+
visiting.add(varName);
|
|
269
|
+
const originLine = derived.get(varName);
|
|
270
|
+
if (originLine === undefined)
|
|
271
|
+
return undefined;
|
|
272
|
+
const text = codeLines[originLine - 1];
|
|
273
|
+
if (!text)
|
|
274
|
+
return undefined;
|
|
275
|
+
const eq = text.search(/(?<![=!<>+\-*/%&|^:])=(?!=)/);
|
|
276
|
+
if (eq < 0)
|
|
277
|
+
return undefined;
|
|
278
|
+
// Drop a real trailing `#…` comment first (outside strings only), then lift
|
|
279
|
+
// f-string `{…}` names. Format specs / conversion flags are cut inside
|
|
280
|
+
// braceExprs so a `#` in `{width:#04x}` cannot eat later names, and `!s`
|
|
281
|
+
// cannot become a scanned identifier; nested `{…}` fields in the format
|
|
282
|
+
// spec (e.g. `{label:{fmt}}`) are kept.
|
|
283
|
+
const rhs = maskStringLiteralsForTokenScan(stripPythonTrailingComment(text.slice(eq + 1)));
|
|
284
|
+
let best;
|
|
285
|
+
for (const m of rhs.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
|
|
286
|
+
const id = m[0];
|
|
287
|
+
if (id === varName)
|
|
288
|
+
continue;
|
|
289
|
+
const real = realSourceByVar.get(id);
|
|
290
|
+
const derivedLine = derived.get(id);
|
|
291
|
+
// A later assignment of the same name (recorded in `derived` after the
|
|
292
|
+
// real source line) is the value that flows — resolve it first. Fall
|
|
293
|
+
// back to the real source when that derivation does not resolve (e.g.
|
|
294
|
+
// `fmt = request.form.get("fmt")`, whose RHS names no source variable).
|
|
295
|
+
let cand;
|
|
296
|
+
if (derivedLine !== undefined && (!real || derivedLine > real.line)) {
|
|
297
|
+
cand = resolvePythonDerivedAnchor(id, derived, realSourceByVar, codeLines, visiting);
|
|
298
|
+
}
|
|
299
|
+
if (!cand)
|
|
300
|
+
cand = real;
|
|
301
|
+
if (cand && (!best || cand.line < best.line))
|
|
302
|
+
best = cand;
|
|
303
|
+
}
|
|
304
|
+
return best;
|
|
305
|
+
}
|
|
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(code, localVar, seedVar) {
|
|
373
|
+
if (!seedVar || localVar === seedVar)
|
|
374
|
+
return false;
|
|
375
|
+
const esc = seedVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
376
|
+
const receiverOnRhs = new RegExp(`(?:^|[^\\w.$])${esc}\\s*\\.`, 'u');
|
|
377
|
+
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*$/;
|
|
378
|
+
const assignRe = /^\s*([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
|
|
379
|
+
for (const line of code.split('\n')) {
|
|
380
|
+
const trimmed = line.trimStart();
|
|
381
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*'))
|
|
382
|
+
continue;
|
|
383
|
+
const m = declRe.exec(line) ?? assignRe.exec(line);
|
|
384
|
+
if (!m || m[1] !== localVar)
|
|
385
|
+
continue;
|
|
386
|
+
const rhs = stripJavaTrailingComment(m[2]);
|
|
387
|
+
if (receiverOnRhs.test(rhs))
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
function makeConstPropVeto(constProp, graph, sources, code, language, sanitizers) {
|
|
44
393
|
const lines = typeof code === 'string' ? code.split('\n') : [];
|
|
45
394
|
const selfReassign = new Map();
|
|
46
395
|
const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -72,12 +421,40 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
|
|
|
72
421
|
const use = (graph.usesByLine.get(line) ?? []).find(u => u.variable === v && u.def_id != null);
|
|
73
422
|
return use ? graph.defById.get(use.def_id) : undefined;
|
|
74
423
|
};
|
|
75
|
-
return (step, sourceLine, sinkLine) => {
|
|
76
|
-
const
|
|
424
|
+
return (step, sourceLine, sinkLine, sinkType) => {
|
|
425
|
+
const method = methodAtLine(graph, step.line);
|
|
426
|
+
const scopedVar = method ? `${method}:${step.variable}` : step.variable;
|
|
427
|
+
// cognium-dev#751: Go locals are recorded method-scoped only (no unscoped
|
|
428
|
+
// alias) by handleGoAssignment, so Go looks up the scoped name. Other
|
|
429
|
+
// languages keep the unscoped lookup (#614).
|
|
430
|
+
const r = isFalsePositive(constProp, step.line, language === 'go' ? scopedVar : step.variable);
|
|
77
431
|
if (!r.isFalsePositive)
|
|
78
432
|
return false;
|
|
79
433
|
if (r.reason !== 'variable_not_tainted')
|
|
80
434
|
return true;
|
|
435
|
+
// #589: the step variable came from a context-specific encoder
|
|
436
|
+
// (`escapeHtml`, `encodeForHTML`, …), which does not clean `trust_boundary`
|
|
437
|
+
// (a `session.setAttribute` sink). It is "not tainted" only for the other
|
|
438
|
+
// sink types, so this flow is not vetoed.
|
|
439
|
+
// Uncovered entries are scoped (`method:var`). A bare `step.variable` looks
|
|
440
|
+
// up only that step's own method — never any sibling `*:name`.
|
|
441
|
+
if (sinkType !== undefined && constProp.sanitizedVarUncovered) {
|
|
442
|
+
const unc = constProp.sanitizedVarUncovered;
|
|
443
|
+
if (step.variable.includes(':')) {
|
|
444
|
+
if (unc.get(step.variable)?.has(sinkType))
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const method = methodAtLine(graph, step.line);
|
|
449
|
+
if (method) {
|
|
450
|
+
if (unc.get(`${method}:${step.variable}`)?.has(sinkType))
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
else if (unc.get(step.variable)?.has(sinkType)) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
81
458
|
// cognium-dev#579: const-prop has no C# source model, so its tainted set is
|
|
82
459
|
// empty for C# and "not tainted" there means "never looked", not "clean".
|
|
83
460
|
// It still records a plain reassignment (`s = q;`) as a tracked symbol,
|
|
@@ -85,6 +462,43 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
|
|
|
85
462
|
// code and resolved constants (the reasons above) still apply.
|
|
86
463
|
if (language === 'csharp')
|
|
87
464
|
return false;
|
|
465
|
+
// cognium-dev#582: in JS/TS a use after an `if` / `else` / `switch` has the
|
|
466
|
+
// outer def and the block's def both reaching it (the DFG lists one use per
|
|
467
|
+
// reaching def). const-prop has no model of the block's assignment, so its
|
|
468
|
+
// "untainted" is only the outer def's value; when any reaching def is the
|
|
469
|
+
// source's own assignment the verdict is "never looked", not "clean".
|
|
470
|
+
// Keep the veto when that source assignment itself passes through a
|
|
471
|
+
// sanitizer (`n = escapeHtml(req.query.n)` in a branch): skipping would
|
|
472
|
+
// report xss while main and the straight-line form stay clean.
|
|
473
|
+
if ((language === 'javascript' || language === 'typescript')) {
|
|
474
|
+
const reaching = (graph.usesByLine.get(step.line) ?? [])
|
|
475
|
+
.filter(u => u.variable === step.variable && u.def_id != null)
|
|
476
|
+
.map(u => graph.defById.get(u.def_id))
|
|
477
|
+
.filter((d) => !!d);
|
|
478
|
+
const sourceReaches = reaching.filter(d => d.line === sourceLine &&
|
|
479
|
+
sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)));
|
|
480
|
+
if (reaching.length > 1 && sourceReaches.length > 0) {
|
|
481
|
+
const sansAtSource = (sanitizers ?? []).filter(s => s.line === sourceLine);
|
|
482
|
+
const srcText = lines[sourceLine - 1] ?? '';
|
|
483
|
+
const sourceSanitized = sourceReaches.some(d => {
|
|
484
|
+
const relevant = sinkType
|
|
485
|
+
? sansAtSource.filter(s => sanitizerCoversSink(s, sinkType))
|
|
486
|
+
: sansAtSource;
|
|
487
|
+
if (relevant.length === 0)
|
|
488
|
+
return false;
|
|
489
|
+
const v = escape(d.variable);
|
|
490
|
+
const dm = new RegExp(`(?:^|[^\\w$.])${v}\\s*=(?!=)(.*)$`).exec(srcText);
|
|
491
|
+
if (!dm)
|
|
492
|
+
return false;
|
|
493
|
+
const wrapType = sinkType ?? relevant[0]?.sanitizes?.[0];
|
|
494
|
+
if (!wrapType)
|
|
495
|
+
return false;
|
|
496
|
+
return expressionFullyWrappedBySanitizer(dm[1], relevant, wrapType);
|
|
497
|
+
});
|
|
498
|
+
if (!sourceSanitized)
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
88
502
|
const lo = Math.min(sourceLine, sinkLine);
|
|
89
503
|
// Walk the reaching-def chain back from this step, stepping through
|
|
90
504
|
// self-derived reassignments only. Reaching defs are per method, so a
|
|
@@ -106,8 +520,20 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
|
|
|
106
520
|
// "untainted" can only mean it has no pattern for this source. Container
|
|
107
521
|
// reads are excluded: const-prop tracks collection keys, and there its
|
|
108
522
|
// verdict is informed (`bar = map.get("safeKey")` — 10 OWASP FP files).
|
|
109
|
-
if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)))
|
|
523
|
+
if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type))) {
|
|
524
|
+
// cognium-dev#751: sink use after `uid := constantHelper(tainted)` must veto
|
|
525
|
+
// when the helper is in `constantReturningMethods`, even if the flow's
|
|
526
|
+
// source_line is the assignment line (rule E would otherwise keep it).
|
|
527
|
+
if (step.line === sinkLine && !constProp.tainted.has(scopedVar)) {
|
|
528
|
+
for (let ln = sourceLine; ln <= sinkLine; ln++) {
|
|
529
|
+
const calleeMatch = (lines[ln - 1] ?? '').match(/:=\s*(\w+)\s*\(/);
|
|
530
|
+
if (calleeMatch && constProp.constantReturningMethods?.has(calleeMatch[1])) {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
110
535
|
return false;
|
|
536
|
+
}
|
|
111
537
|
// (A) the chain crossed at least one `x = f(x)` and ends inside the span.
|
|
112
538
|
if (selfHops > 0 && d.line >= lo)
|
|
113
539
|
return false;
|
|
@@ -123,6 +549,23 @@ export class TaintPropagationPass {
|
|
|
123
549
|
const constProp = ctx.getResult('constant-propagation');
|
|
124
550
|
const sinkFilter = ctx.getResult('sink-filter');
|
|
125
551
|
const { sources, sinks, sanitizers } = sinkFilter;
|
|
552
|
+
// cognium-dev#624: seed receiver-chain derived locals (`label = page.getName()`)
|
|
553
|
+
// into const-prop's tainted set so DFG + expression-scan vetoes keep them.
|
|
554
|
+
// Per-source seeding + receiver filter avoids argument-style derivations
|
|
555
|
+
// (`shown = encodeForPage(nick)`) that must stay vetoed for sanitizer credit.
|
|
556
|
+
if (ctx.language === 'java' && typeof ctx.code === 'string') {
|
|
557
|
+
for (const s of sources) {
|
|
558
|
+
if (!s.variable)
|
|
559
|
+
continue;
|
|
560
|
+
for (const [v] of buildJavaTaintedVars(ctx.code, new Set([s.variable]))) {
|
|
561
|
+
if (v === s.variable)
|
|
562
|
+
continue;
|
|
563
|
+
if (javaReceiverChainDerivedVar(ctx.code, v, s.variable)) {
|
|
564
|
+
constProp.tainted.add(v);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
126
569
|
if (sinks.length === 0) {
|
|
127
570
|
return { flows: [] };
|
|
128
571
|
}
|
|
@@ -134,16 +577,16 @@ export class TaintPropagationPass {
|
|
|
134
577
|
return { flows: [] };
|
|
135
578
|
}
|
|
136
579
|
// DFG-based taint propagation
|
|
137
|
-
const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
|
|
580
|
+
const propagationResult = propagateTaint(graph, sources, sinks, sanitizers, undefined, ctx.code);
|
|
138
581
|
// cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
|
|
139
582
|
// veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
|
|
140
|
-
const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language);
|
|
583
|
+
const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language, sanitizers);
|
|
141
584
|
// Filter flows: eliminate dead-code paths and constant-propagation FPs
|
|
142
585
|
const verifiedFlows = propagationResult.flows.filter(flow => {
|
|
143
586
|
if (constProp.unreachableLines.has(flow.sink.line))
|
|
144
587
|
return false;
|
|
145
588
|
for (const step of flow.path) {
|
|
146
|
-
if (isVetoed(step, flow.source.line, flow.sink.line))
|
|
589
|
+
if (isVetoed(step, flow.source.line, flow.sink.line, flow.sink.type))
|
|
147
590
|
return false;
|
|
148
591
|
}
|
|
149
592
|
if (isCorrelatedPredicateFP(constProp, flow))
|
|
@@ -186,6 +629,38 @@ export class TaintPropagationPass {
|
|
|
186
629
|
flows.push(f);
|
|
187
630
|
return true;
|
|
188
631
|
};
|
|
632
|
+
// Supplement: Spring MVC return-as-body XSS flows — cognium-dev#566. The
|
|
633
|
+
// sink `LanguageSourcesPass` synthesises at the `return` has no method or
|
|
634
|
+
// argument, so no argument matcher reaches it; the detector already knows
|
|
635
|
+
// both ends, so the flow is emitted here (as for Python #368 below).
|
|
636
|
+
if (ctx.language === 'java' && typeof ctx.code === 'string') {
|
|
637
|
+
const xssSanitizerLines = sanitizers
|
|
638
|
+
.filter(sa => sa.sanitizes.includes('xss'))
|
|
639
|
+
.map(sa => sa.line);
|
|
640
|
+
for (const rx of findJavaSpringReturnXSSSinks(ctx.code, types, sources, xssSanitizerLines)) {
|
|
641
|
+
if (constProp.unreachableLines.has(rx.sinkLine))
|
|
642
|
+
continue;
|
|
643
|
+
const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
|
|
644
|
+
if (!sink)
|
|
645
|
+
continue;
|
|
646
|
+
const atLine = sources.filter(sc => sc.line === rx.sourceLine);
|
|
647
|
+
const src = atLine.find(sc => sc.type !== 'interprocedural_param') ?? atLine[0];
|
|
648
|
+
if (!src)
|
|
649
|
+
continue;
|
|
650
|
+
pushIfNew({
|
|
651
|
+
source_line: src.line,
|
|
652
|
+
sink_line: rx.sinkLine,
|
|
653
|
+
source_type: src.type,
|
|
654
|
+
sink_type: 'xss',
|
|
655
|
+
path: [
|
|
656
|
+
{ variable: src.variable ?? 'response', line: src.line, type: 'source' },
|
|
657
|
+
{ variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
|
|
658
|
+
],
|
|
659
|
+
confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
|
|
660
|
+
sanitized: false,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
189
664
|
// Supplement: Python trust-boundary (CWE-501) flows — cognium-dev#363.
|
|
190
665
|
//
|
|
191
666
|
// `LanguageSourcesPass` detects `session[<tainted>] = …` and synthesises a
|
|
@@ -293,6 +768,42 @@ export class TaintPropagationPass {
|
|
|
293
768
|
}
|
|
294
769
|
}
|
|
295
770
|
}
|
|
771
|
+
// Supplement: Java JSPWiki plugin return-XSS flows — cognium-dev#624. The
|
|
772
|
+
// sink synthesised by `LanguageSourcesPass` has no method or argument, so
|
|
773
|
+
// no argument matcher can reach it; link it to its source here, as for the
|
|
774
|
+
// Python return-XSS shape above. The helper already applies its own
|
|
775
|
+
// statement-level sanitizer check, so these flows skip the line-keyed
|
|
776
|
+
// def-chain credit below (a sanitizer elsewhere on a def line of the
|
|
777
|
+
// returned name does not cover it).
|
|
778
|
+
const wikiPluginFlowKeys = new Set();
|
|
779
|
+
if (ctx.language === 'java' && typeof ctx.code === 'string') {
|
|
780
|
+
const xssSanitizers = sanitizers
|
|
781
|
+
.filter(sa => sa.sanitizes.includes('xss'))
|
|
782
|
+
.map(sa => ({ line: sa.line, method: sa.method }));
|
|
783
|
+
for (const rx of findJavaWikiPluginReturnXSSSinks(ctx.code, types, graph.ir.imports, sources, xssSanitizers)) {
|
|
784
|
+
if (constProp.unreachableLines.has(rx.sinkLine))
|
|
785
|
+
continue;
|
|
786
|
+
const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
|
|
787
|
+
if (!sink)
|
|
788
|
+
continue;
|
|
789
|
+
const src = sources.find(sc => sc.line === rx.sourceLine);
|
|
790
|
+
if (!src)
|
|
791
|
+
continue;
|
|
792
|
+
wikiPluginFlowKeys.add(`${src.line}:${rx.sinkLine}`);
|
|
793
|
+
pushIfNew({
|
|
794
|
+
source_line: src.line,
|
|
795
|
+
sink_line: rx.sinkLine,
|
|
796
|
+
source_type: src.type,
|
|
797
|
+
sink_type: 'xss',
|
|
798
|
+
path: [
|
|
799
|
+
{ variable: src.variable ?? 'response', line: src.line, type: 'source' },
|
|
800
|
+
{ variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
|
|
801
|
+
],
|
|
802
|
+
confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
|
|
803
|
+
sanitized: false,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
296
807
|
// Supplement: array element flows
|
|
297
808
|
// Sprint 82 (#189) — dedup keys on (source_line, sink_line, sink_type) so
|
|
298
809
|
// distinct sink-types at the same call site (e.g. `res.redirect` registered
|
|
@@ -389,19 +900,47 @@ export class TaintPropagationPass {
|
|
|
389
900
|
// explicitly marked sanitized by a guard (e.g. regex-allowlist).
|
|
390
901
|
// Applied to ALL flow generators (DFG-built and the four supplements)
|
|
391
902
|
// so the suppression is uniform regardless of which path emitted the flow.
|
|
903
|
+
//
|
|
904
|
+
// #589: a variable produced by a context-specific encoder (`escapeHtml`,
|
|
905
|
+
// `encodeForHTML`, …) is not clean for `trust_boundary`
|
|
906
|
+
// (`session.setAttribute`), so that flow from the variable stays.
|
|
392
907
|
const sanitizedNames = constProp.sanitizedVars;
|
|
908
|
+
const sanitizedUncovered = constProp.sanitizedVarUncovered;
|
|
393
909
|
let finalFlows = sanitizedNames.size === 0 ? flows : flows.filter(f => {
|
|
394
910
|
if (f.path.length === 0)
|
|
395
911
|
return true;
|
|
396
912
|
const sourceVar = f.path[0].variable;
|
|
397
913
|
if (!sourceVar)
|
|
398
914
|
return true;
|
|
399
|
-
|
|
400
|
-
return
|
|
915
|
+
const coversFlow = (name) => {
|
|
916
|
+
return !(sanitizedUncovered?.get(name)?.has(f.sink_type));
|
|
917
|
+
};
|
|
918
|
+
// #589 review: when any `method:var` sanitizer exists, only that flow's
|
|
919
|
+
// own method can drop it — never a sibling's scoped name (one encoder in
|
|
920
|
+
// doPost must not clear xss in other()), and never the bare alias alone.
|
|
921
|
+
const suffix = `:${sourceVar}`;
|
|
922
|
+
let hasScopedSanitizer = false;
|
|
401
923
|
for (const s of sanitizedNames) {
|
|
402
|
-
if (s.endsWith(
|
|
924
|
+
if (s !== sourceVar && s.endsWith(suffix)) {
|
|
925
|
+
hasScopedSanitizer = true;
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (hasScopedSanitizer) {
|
|
930
|
+
if (sourceVar.includes(':') && sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
|
|
403
931
|
return false;
|
|
932
|
+
const method = methodAtLine(graph, f.sink_line) ?? methodAtLine(graph, f.source_line);
|
|
933
|
+
if (method) {
|
|
934
|
+
const scoped = `${method}:${sourceVar}`;
|
|
935
|
+
if (sanitizedNames.has(scoped) && coversFlow(scoped))
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
// Scoped sanitizers exist for this name, but none proved to be this
|
|
939
|
+
// flow's method — keep the flow.
|
|
940
|
+
return true;
|
|
404
941
|
}
|
|
942
|
+
if (sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
|
|
943
|
+
return false;
|
|
405
944
|
return true;
|
|
406
945
|
});
|
|
407
946
|
// Sprint 24 (cognium-dev #102) — uniform line-keyed sanitizer
|
|
@@ -421,6 +960,25 @@ export class TaintPropagationPass {
|
|
|
421
960
|
arr.push(san);
|
|
422
961
|
sanitizersByLine.set(san.line, arr);
|
|
423
962
|
}
|
|
963
|
+
const codeLines = typeof ctx.code === 'string' ? ctx.code.split('\n') : undefined;
|
|
964
|
+
// True unless every def at `line` among `visited` is provably off this
|
|
965
|
+
// flow: not a variable on its path, and a complete backward walk from it
|
|
966
|
+
// never reaches `sourceLine`.
|
|
967
|
+
const sanitizedDefOnFlow = (visited, line, f) => {
|
|
968
|
+
let sawDef = false;
|
|
969
|
+
for (const id of visited) {
|
|
970
|
+
const def = graph.defById.get(id);
|
|
971
|
+
if (!def || def.line !== line)
|
|
972
|
+
continue;
|
|
973
|
+
sawDef = true;
|
|
974
|
+
if (f.path.some(step => step.variable === def.variable))
|
|
975
|
+
return true;
|
|
976
|
+
const back = walkBackwardDefs(id, graph.chainsByToDef, graph.defById, { maxHops: 32 });
|
|
977
|
+
if (back.hopCapReached || back.lines.has(f.source_line))
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
return !sawDef;
|
|
981
|
+
};
|
|
424
982
|
finalFlows = finalFlows.filter(f => {
|
|
425
983
|
// Two-tier filter:
|
|
426
984
|
// - `external_taint_escape` (synthetic CWE-668 fallback): no
|
|
@@ -447,6 +1005,20 @@ export class TaintPropagationPass {
|
|
|
447
1005
|
}
|
|
448
1006
|
return true;
|
|
449
1007
|
}
|
|
1008
|
+
// #582: Python has no branch-aware def chain, so a literal in a sibling
|
|
1009
|
+
// arm can hide the one def whose sanitizer the walk below would find.
|
|
1010
|
+
// The flow's own source def is the whole answer when its right-hand side
|
|
1011
|
+
// is wrapped, end to end, in a covering sanitizer.
|
|
1012
|
+
if (ctx.language === 'python' && codeLines && f.path.length > 0) {
|
|
1013
|
+
const first = f.path[0];
|
|
1014
|
+
const defText = first.line === f.source_line ? codeLines[f.source_line - 1] : undefined;
|
|
1015
|
+
const sansAtSource = sanitizersByLine.get(f.source_line);
|
|
1016
|
+
if (defText && first.variable && sansAtSource && /^[A-Za-z_]\w*$/.test(first.variable)) {
|
|
1017
|
+
const dm = new RegExp(`^\\s*${first.variable}\\s*=(?!=)(.*)$`).exec(defText);
|
|
1018
|
+
if (dm && expressionFullyWrappedBySanitizer(dm[1], sansAtSource, f.sink_type))
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
450
1022
|
const sansAtSink = sanitizersByLine.get(f.sink_line);
|
|
451
1023
|
if (sansAtSink && sansAtSink.length > 0) {
|
|
452
1024
|
for (const san of sansAtSink) {
|
|
@@ -475,6 +1047,8 @@ export class TaintPropagationPass {
|
|
|
475
1047
|
// in taint-propagation.ts checkSanitized. Doesn't over-suppress
|
|
476
1048
|
// bare `shlex.quote(host)` on unrelated non-sink lines because
|
|
477
1049
|
// those calls don't create a def in the sink var's chain.
|
|
1050
|
+
if (f.sink_type === 'xss' && wikiPluginFlowKeys.has(`${f.source_line}:${f.sink_line}`))
|
|
1051
|
+
return true;
|
|
478
1052
|
const usesAtSink = graph.usesByLine.get(f.sink_line);
|
|
479
1053
|
if (!usesAtSink || usesAtSink.length === 0)
|
|
480
1054
|
return true;
|
|
@@ -483,12 +1057,29 @@ export class TaintPropagationPass {
|
|
|
483
1057
|
: undefined;
|
|
484
1058
|
if (!sinkVar)
|
|
485
1059
|
return true;
|
|
1060
|
+
// #589: an HTML encoder is credited only when every chain from the
|
|
1061
|
+
// sink back to this flow's source passes through a covering
|
|
1062
|
+
// sanitizer. Computed once per flow, over every reaching def of the
|
|
1063
|
+
// sink variable, and only when an HTML encoder is the candidate.
|
|
1064
|
+
let everyChainCovered;
|
|
1065
|
+
const htmlEncoderCoversEveryChain = () => {
|
|
1066
|
+
if (everyChainCovered === undefined) {
|
|
1067
|
+
const starts = [];
|
|
1068
|
+
for (const u of usesAtSink) {
|
|
1069
|
+
if (u.variable === sinkVar && u.def_id !== null && u.def_id !== undefined)
|
|
1070
|
+
starts.push(u.def_id);
|
|
1071
|
+
}
|
|
1072
|
+
everyChainCovered = starts.length > 0 && starts.every(id => !hasUncoveredChain(id, f.sink_type, f.source_line, graph, sanitizersByLine, codeLines));
|
|
1073
|
+
}
|
|
1074
|
+
return everyChainCovered;
|
|
1075
|
+
};
|
|
486
1076
|
for (const use of usesAtSink) {
|
|
487
1077
|
if (use.variable !== sinkVar)
|
|
488
1078
|
continue;
|
|
489
1079
|
if (use.def_id === null || use.def_id === undefined)
|
|
490
1080
|
continue;
|
|
491
|
-
const
|
|
1081
|
+
const sinkDefId = use.def_id;
|
|
1082
|
+
const walk = walkBackwardDefs(sinkDefId, graph.chainsByToDef, graph.defById, { maxHops: 32 });
|
|
492
1083
|
for (const line of walk.lines) {
|
|
493
1084
|
if (line === f.sink_line)
|
|
494
1085
|
continue; // already checked above
|
|
@@ -507,10 +1098,21 @@ export class TaintPropagationPass {
|
|
|
507
1098
|
const sansAtLine = sanitizersByLine.get(line);
|
|
508
1099
|
if (!sansAtLine || sansAtLine.length === 0)
|
|
509
1100
|
continue;
|
|
1101
|
+
// cognium-dev#348: the walk visits every contributor of the sink
|
|
1102
|
+
// value. In `clean = a.replaceAll(..); msg = clean + b; log(msg)`
|
|
1103
|
+
// the strip on `a` must not credit the flow from `b`. Skip a
|
|
1104
|
+
// sanitized def that does not derive from this flow's source.
|
|
1105
|
+
if (line > f.source_line && !sanitizedDefOnFlow(walk.visited, line, f))
|
|
1106
|
+
continue;
|
|
510
1107
|
for (const san of sansAtLine) {
|
|
511
|
-
if (sanitizerCoversSink(san, f.sink_type))
|
|
512
|
-
|
|
513
|
-
|
|
1108
|
+
if (!sanitizerCoversSink(san, f.sink_type))
|
|
1109
|
+
continue;
|
|
1110
|
+
// #589: a join anywhere between the sink and the encoder
|
|
1111
|
+
// (`mixed = shown + b`, then `copy = mixed`) must not let an
|
|
1112
|
+
// encoder on one operand drop unrestricted taint from another.
|
|
1113
|
+
if (isHtmlContextEncoderSanitizer(san) && !htmlEncoderCoversEveryChain())
|
|
1114
|
+
continue;
|
|
1115
|
+
return false;
|
|
514
1116
|
}
|
|
515
1117
|
}
|
|
516
1118
|
}
|
|
@@ -665,6 +1267,28 @@ export class TaintPropagationPass {
|
|
|
665
1267
|
});
|
|
666
1268
|
}
|
|
667
1269
|
}
|
|
1270
|
+
// cognium-dev#475: DFG may keep a flow when a local (`v`) was rewritten to
|
|
1271
|
+
// a literal before the sink even though the sink reads a copy (`cmd`) built
|
|
1272
|
+
// earlier. Walk backward from the sink through assignments into the sink
|
|
1273
|
+
// argument; for each upstream local, literal-kill only on lines strictly
|
|
1274
|
+
// before the assignment that copied it into that chain (so `v = "safe"`
|
|
1275
|
+
// after `new SqlCommand(... + v)` does not drop the finding). Unrelated
|
|
1276
|
+
// locals and conditional / re-taint shapes stay sound.
|
|
1277
|
+
if (ctx.language === 'csharp' && typeof ctx.code === 'string') {
|
|
1278
|
+
const code = ctx.code;
|
|
1279
|
+
finalFlows = finalFlows.filter(f => {
|
|
1280
|
+
const deriveLine = csharpSinkDependencyKillVars(code, f);
|
|
1281
|
+
const carriers = csharpFlowTaintCarriers(code, f);
|
|
1282
|
+
for (const [v, beforeLine] of deriveLine) {
|
|
1283
|
+
if (!carriers.has(v))
|
|
1284
|
+
continue;
|
|
1285
|
+
if (csharpLastUnconditionalAssignIsLiteral(code, v, f.source_line, beforeLine, constProp.unreachableLines)) {
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return true;
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
668
1292
|
return { flows: finalFlows };
|
|
669
1293
|
}
|
|
670
1294
|
}
|
|
@@ -829,6 +1453,10 @@ function isInJavaSanitizedMethod(code, types, sinkLine, sinkType) {
|
|
|
829
1453
|
return false;
|
|
830
1454
|
if (!/\.startsWith\s*\([^)]*getCanonicalPath/.test(body))
|
|
831
1455
|
return false;
|
|
1456
|
+
// cognium-dev#618: `startsWith(parent.getCanonicalPath())` with no
|
|
1457
|
+
// trailing separator is the prefix-confusion bypass, not a guard.
|
|
1458
|
+
if (javaCanonicalStartsWithAllBare(body))
|
|
1459
|
+
return false;
|
|
832
1460
|
if (!/\bthrow\s+new\b/.test(body))
|
|
833
1461
|
return false;
|
|
834
1462
|
return true;
|
|
@@ -992,8 +1620,118 @@ function detectGoPackageGlobalFlows(code, _calls, sources, sinks, unreachableLin
|
|
|
992
1620
|
}
|
|
993
1621
|
return flows;
|
|
994
1622
|
}
|
|
1623
|
+
/**
|
|
1624
|
+
* #614 — is `varName` tainted in the sink's own method?
|
|
1625
|
+
*
|
|
1626
|
+
* The const-prop result carries scoped names (`method:var`) and, for backward
|
|
1627
|
+
* compatibility, an unscoped alias of each. The unscoped alias says only that
|
|
1628
|
+
* SOME method tainted a local of that name. When any scoped entry for the name
|
|
1629
|
+
* exists, membership is decided by the sink's own scoped name, so a same-named
|
|
1630
|
+
* local in a safe sibling method (the Juliet `bad()` / `good*()` layout) is not
|
|
1631
|
+
* treated as tainted. A name with no scoped entry (a field, a parameter) keeps
|
|
1632
|
+
* the unscoped match. A field use (`this.name` / bare field read) is shared by
|
|
1633
|
+
* every method of its class (SecuriBench Inter4); a local that merely shares a
|
|
1634
|
+
* field's name is not.
|
|
1635
|
+
*/
|
|
1636
|
+
/**
|
|
1637
|
+
* True when `methodName` declares a local (or param) named `varName`. With
|
|
1638
|
+
* overloads, the declaration that encloses `atLine` is the one checked (the
|
|
1639
|
+
* first declaration of that name is only a fallback when no line is known or
|
|
1640
|
+
* none encloses it).
|
|
1641
|
+
*/
|
|
1642
|
+
function methodHasLocalDecl(code, methodName, varName, atLine) {
|
|
1643
|
+
if (!code || !methodName)
|
|
1644
|
+
return false;
|
|
1645
|
+
// Find `methodName(...) [throws ...] {` then scan the body for a local decl.
|
|
1646
|
+
const esc = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1647
|
+
const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1648
|
+
const methodRe = new RegExp('(?:^|[\\s;{}])' + esc + '\\s*\\([^;{]*\\)\\s*(?:throws\\s+[^{]+)?\\{', 'gm');
|
|
1649
|
+
const lineAt = (offset) => {
|
|
1650
|
+
let n = 1;
|
|
1651
|
+
for (let k = 0; k < offset; k++)
|
|
1652
|
+
if (code.charCodeAt(k) === 10)
|
|
1653
|
+
n++;
|
|
1654
|
+
return n;
|
|
1655
|
+
};
|
|
1656
|
+
let chosen = null;
|
|
1657
|
+
let first = null;
|
|
1658
|
+
for (let m = methodRe.exec(code); m; m = methodRe.exec(code)) {
|
|
1659
|
+
const bodyStart = m.index + m[0].length;
|
|
1660
|
+
let i = bodyStart;
|
|
1661
|
+
let depth = 1;
|
|
1662
|
+
while (i < code.length && depth > 0) {
|
|
1663
|
+
const c = code[i++];
|
|
1664
|
+
if (c === '{')
|
|
1665
|
+
depth++;
|
|
1666
|
+
else if (c === '}')
|
|
1667
|
+
depth--;
|
|
1668
|
+
}
|
|
1669
|
+
const cand = { header: m[0], bodyStart, bodyEnd: i - 1 };
|
|
1670
|
+
if (!first)
|
|
1671
|
+
first = cand;
|
|
1672
|
+
if (atLine === undefined)
|
|
1673
|
+
break;
|
|
1674
|
+
if (atLine >= lineAt(m.index) && atLine <= lineAt(cand.bodyEnd)) {
|
|
1675
|
+
chosen = cand;
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
const hit = chosen ?? first;
|
|
1680
|
+
if (!hit)
|
|
1681
|
+
return false;
|
|
1682
|
+
const body = code.slice(hit.bodyStart, hit.bodyEnd);
|
|
1683
|
+
// Parameter named varName counts as shadowing the field.
|
|
1684
|
+
if (new RegExp('[(,]\\s*(?:final\\s+)?[\\w.<>,\\[\\]\\s]+\\s+' + v + '\\s*[,)]').test(hit.header))
|
|
1685
|
+
return true;
|
|
1686
|
+
const declRe = new RegExp('(?:^|[;{}])\\s*(?:(?:final|volatile)\\s+)?(?:[A-Za-z_][\\w.<>,\\[\\]\\s]*?)\\s+' + v + '\\s*(?:=|;|,)', 'm');
|
|
1687
|
+
return declRe.test(body);
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* True when `expression` reads this class's own field `varName`: `this.name`,
|
|
1691
|
+
* `self.name`, or `Enclosing.name` (static access through an enclosing type).
|
|
1692
|
+
* Another object's member (`item.name`, `file.name`) is not this class's field.
|
|
1693
|
+
*/
|
|
1694
|
+
function isFieldUseExpression(expression, varName, enclosingTypeNames) {
|
|
1695
|
+
if (!expression)
|
|
1696
|
+
return false;
|
|
1697
|
+
const e = expression.trim();
|
|
1698
|
+
if (e === `this.${varName}` || e === `self.${varName}`)
|
|
1699
|
+
return true;
|
|
1700
|
+
const m = new RegExp('^([A-Za-z_]\\w*)\\.' + varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$').exec(e);
|
|
1701
|
+
return !!m && enclosingTypeNames.has(m[1]);
|
|
1702
|
+
}
|
|
1703
|
+
/** Names of the types whose range encloses `line` (outer and nested). */
|
|
1704
|
+
function enclosingTypeNamesAt(types, line) {
|
|
1705
|
+
const names = new Set();
|
|
1706
|
+
for (const t of types ?? []) {
|
|
1707
|
+
if (line >= t.start_line && line <= t.end_line)
|
|
1708
|
+
names.add(t.name);
|
|
1709
|
+
}
|
|
1710
|
+
return names;
|
|
1711
|
+
}
|
|
1712
|
+
function isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse) {
|
|
1713
|
+
if (taintedVars.has(scopedName))
|
|
1714
|
+
return true;
|
|
1715
|
+
if (!taintedVars.has(varName))
|
|
1716
|
+
return false;
|
|
1717
|
+
if (scopedName === varName)
|
|
1718
|
+
return true;
|
|
1719
|
+
// Field exception only when THIS use is a field, not when any field has the name.
|
|
1720
|
+
if (isFieldUse && fieldNames.has(varName))
|
|
1721
|
+
return true;
|
|
1722
|
+
const suffix = `:${varName}`;
|
|
1723
|
+
for (const t of taintedVars) {
|
|
1724
|
+
if (t.endsWith(suffix))
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
return true;
|
|
1728
|
+
}
|
|
995
1729
|
function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLines, code, types) {
|
|
996
1730
|
const flows = [];
|
|
1731
|
+
const fieldNames = new Set();
|
|
1732
|
+
for (const t of types ?? [])
|
|
1733
|
+
for (const f of t.fields ?? [])
|
|
1734
|
+
fieldNames.add(f.name);
|
|
997
1735
|
const callsByLine = new Map();
|
|
998
1736
|
for (const call of calls) {
|
|
999
1737
|
const existing = callsByLine.get(call.location.line) ?? [];
|
|
@@ -1014,7 +1752,16 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
|
|
|
1014
1752
|
if (arg.variable) {
|
|
1015
1753
|
const varName = arg.variable;
|
|
1016
1754
|
const scopedName = call.in_method ? `${call.in_method}:${varName}` : varName;
|
|
1017
|
-
|
|
1755
|
+
// Bare `name` that is a field read (no local of that name in this
|
|
1756
|
+
// method) still counts as a field use — Inter4-style. A shadowing
|
|
1757
|
+
// local declaration in the method does not.
|
|
1758
|
+
const expr = arg.expression ?? '';
|
|
1759
|
+
const explicitField = isFieldUseExpression(expr, varName, enclosingTypeNamesAt(types, call.location.line));
|
|
1760
|
+
const bareFieldRead = fieldNames.has(varName)
|
|
1761
|
+
&& expr === varName
|
|
1762
|
+
&& !methodHasLocalDecl(code, call.in_method, varName, call.location.line);
|
|
1763
|
+
const isFieldUse = explicitField || bareFieldRead;
|
|
1764
|
+
if (isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse)) {
|
|
1018
1765
|
// Sprint 13 #70 — pick a source in the same method scope as the
|
|
1019
1766
|
// sink, not blanket sources[0]. The varName-based match is
|
|
1020
1767
|
// primary; method-scope is the tiebreaker; sources[0] is the
|
|
@@ -1132,23 +1879,22 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
|
|
|
1132
1879
|
!sink.argPositions.includes(arg.position)) {
|
|
1133
1880
|
continue;
|
|
1134
1881
|
}
|
|
1135
|
-
const
|
|
1136
|
-
if (
|
|
1137
|
-
const arrayName =
|
|
1138
|
-
const indexStr = arrayAccessMatch[2];
|
|
1882
|
+
const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
|
|
1883
|
+
if (arrayAccess) {
|
|
1884
|
+
const { base, arrayName, indexKey, displayVar } = arrayAccess;
|
|
1139
1885
|
const taintedIndices = taintedArrayElements.get(arrayName);
|
|
1140
1886
|
if (taintedIndices) {
|
|
1141
|
-
const isTainted = taintedIndices.has(
|
|
1887
|
+
const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
|
|
1142
1888
|
if (isTainted) {
|
|
1143
1889
|
// Sprint 13 #70 — method-scoped source picker (see detectCollectionFlows).
|
|
1144
|
-
const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types,
|
|
1890
|
+
const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types, base);
|
|
1145
1891
|
if (source) {
|
|
1146
1892
|
flows.push({
|
|
1147
1893
|
source_line: source.line, sink_line: sink.line,
|
|
1148
1894
|
source_type: source.type, sink_type: sink.type,
|
|
1149
1895
|
path: [
|
|
1150
|
-
{ variable:
|
|
1151
|
-
{ variable:
|
|
1896
|
+
{ variable: base, line: source.line, type: 'source' },
|
|
1897
|
+
{ variable: displayVar, line: sink.line, type: 'sink' },
|
|
1152
1898
|
],
|
|
1153
1899
|
confidence: 0.85, sanitized: false,
|
|
1154
1900
|
});
|
|
@@ -1161,6 +1907,20 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
|
|
|
1161
1907
|
}
|
|
1162
1908
|
return flows;
|
|
1163
1909
|
}
|
|
1910
|
+
/** Parameter names referenced as identifiers inside a compound sink argument (#685). */
|
|
1911
|
+
function compoundArgParamNames(expression, paramNames) {
|
|
1912
|
+
const stripped = expression.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => ' '.repeat(m.length));
|
|
1913
|
+
const matched = [];
|
|
1914
|
+
for (const raw of paramNames) {
|
|
1915
|
+
const name = raw.startsWith('*') ? raw.slice(1) : raw;
|
|
1916
|
+
if (!/^[\w$]+$/.test(name))
|
|
1917
|
+
continue;
|
|
1918
|
+
const re = new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`);
|
|
1919
|
+
if (re.test(stripped))
|
|
1920
|
+
matched.push(name);
|
|
1921
|
+
}
|
|
1922
|
+
return matched;
|
|
1923
|
+
}
|
|
1164
1924
|
function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines, tainted, code) {
|
|
1165
1925
|
const flows = [];
|
|
1166
1926
|
const paramSourcesByMethod = new Map();
|
|
@@ -1199,14 +1959,17 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
|
|
|
1199
1959
|
if (!methodParamSources)
|
|
1200
1960
|
continue;
|
|
1201
1961
|
for (const arg of call.arguments) {
|
|
1202
|
-
if (
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1962
|
+
if (sink.argPositions && sink.argPositions.length > 0 &&
|
|
1963
|
+
!sink.argPositions.includes(arg.position)) {
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
const varNames = arg.variable
|
|
1967
|
+
? [arg.variable]
|
|
1968
|
+
: (arg.expression
|
|
1969
|
+
? compoundArgParamNames(arg.expression, methodParamSources.keys())
|
|
1970
|
+
: []);
|
|
1971
|
+
for (const varName of varNames) {
|
|
1972
|
+
const paramSource = methodParamSources.get(varName);
|
|
1210
1973
|
if (paramSource) {
|
|
1211
1974
|
// Sprint 82 (#189) — sink-type-aware dedup so a sink registered
|
|
1212
1975
|
// under multiple types at the same call site (e.g. `redirect` as
|
|
@@ -1214,7 +1977,7 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
|
|
|
1214
1977
|
const exists = flows.some(f => f.source_line === paramSource.line && f.sink_line === sink.line && f.sink_type === sink.type);
|
|
1215
1978
|
if (!exists) {
|
|
1216
1979
|
if (typeof code === 'string' &&
|
|
1217
|
-
isReassignedToLiteralBetween(code,
|
|
1980
|
+
isReassignedToLiteralBetween(code, varName, paramSource.line, sink.line, unreachableLines)) {
|
|
1218
1981
|
continue;
|
|
1219
1982
|
}
|
|
1220
1983
|
// Note: DFG-flow filter handles sanitizer-guard suppression
|
|
@@ -1223,8 +1986,8 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
|
|
|
1223
1986
|
source_line: paramSource.line, sink_line: sink.line,
|
|
1224
1987
|
source_type: paramSource.type, sink_type: sink.type,
|
|
1225
1988
|
path: [
|
|
1226
|
-
{ variable:
|
|
1227
|
-
{ variable:
|
|
1989
|
+
{ variable: varName, line: paramSource.line, type: 'source' },
|
|
1990
|
+
{ variable: varName, line: sink.line, type: 'sink' },
|
|
1228
1991
|
],
|
|
1229
1992
|
confidence: 0.75, sanitized: false,
|
|
1230
1993
|
});
|
|
@@ -1390,7 +2153,18 @@ function csharpMethodScopedAliases(code, seeds, methods, fieldNames) {
|
|
|
1390
2153
|
}
|
|
1391
2154
|
continue;
|
|
1392
2155
|
}
|
|
1393
|
-
|
|
2156
|
+
let d = declRe.exec(text) ?? assignRe.exec(text);
|
|
2157
|
+
if (!d && text.includes('{')) {
|
|
2158
|
+
const inner = text.slice(text.indexOf('{') + 1, text.lastIndexOf('}'));
|
|
2159
|
+
for (const stmt of inner.split(';')) {
|
|
2160
|
+
const trimmedStmt = stmt.trim();
|
|
2161
|
+
if (!trimmedStmt)
|
|
2162
|
+
continue;
|
|
2163
|
+
d = declRe.exec(`${trimmedStmt};`) ?? assignRe.exec(`${trimmedStmt};`);
|
|
2164
|
+
if (d)
|
|
2165
|
+
break;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
1394
2168
|
if (!d)
|
|
1395
2169
|
continue;
|
|
1396
2170
|
const [, lhs, rhs] = d;
|
|
@@ -1425,6 +2199,211 @@ function splitLinesCached(code) {
|
|
|
1425
2199
|
_reassignSplitLines = code.split('\n');
|
|
1426
2200
|
return _reassignSplitLines;
|
|
1427
2201
|
}
|
|
2202
|
+
const CSHARP_ID_KW = new Set([
|
|
2203
|
+
'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default', 'break',
|
|
2204
|
+
'return', 'new', 'var', 'string', 'int', 'bool', 'void', 'static', 'public', 'class',
|
|
2205
|
+
'using', 'lock', 'try', 'catch', 'finally', 'throw', 'null', 'true', 'false',
|
|
2206
|
+
]);
|
|
2207
|
+
function csharpStripStringLiterals(text) {
|
|
2208
|
+
return text
|
|
2209
|
+
.replace(/\$@?"(?:[^"\\]|\\.)*"/g, ' ')
|
|
2210
|
+
.replace(/@?"(?:[^"\\]|\\.)*"/g, ' ')
|
|
2211
|
+
.replace(/'(?:[^'\\]|\\.)'/g, ' ')
|
|
2212
|
+
.replace(/`(?:[^`\\]|\\.)*`/g, ' ');
|
|
2213
|
+
}
|
|
2214
|
+
function csharpIdentifiersInExpr(text) {
|
|
2215
|
+
const ids = csharpStripStringLiterals(text).match(/\b[A-Za-z_][\w]*\b/g) ?? [];
|
|
2216
|
+
return ids.filter(id => !CSHARP_ID_KW.has(id));
|
|
2217
|
+
}
|
|
2218
|
+
const CSHARP_ASSIGN_RE = /^\s*(?:(?:var|string|String|[\w.<>\[\]?]+\s+)?([A-Za-z_]\w*)|([A-Za-z_]\w*))\s*=\s*(.+)$/;
|
|
2219
|
+
/**
|
|
2220
|
+
* Variables that carry THIS C# flow's taint (path locals plus forward/backward
|
|
2221
|
+
* derivation). Pure-literal helpers (`table = "users"` next to tainted `input`)
|
|
2222
|
+
* are excluded so their literal assignments cannot drop the flow (#475).
|
|
2223
|
+
*/
|
|
2224
|
+
function csharpFlowTaintCarriers(code, flow) {
|
|
2225
|
+
const carriers = new Set();
|
|
2226
|
+
for (const step of flow.path) {
|
|
2227
|
+
if (step.variable)
|
|
2228
|
+
carriers.add(step.variable);
|
|
2229
|
+
}
|
|
2230
|
+
if (carriers.size === 0)
|
|
2231
|
+
return carriers;
|
|
2232
|
+
const lines = splitLinesCached(code);
|
|
2233
|
+
const lo = Math.max(0, flow.source_line - 1);
|
|
2234
|
+
const hi = Math.min(lines.length, flow.sink_line);
|
|
2235
|
+
const stripLits = (rhs) => csharpStripStringLiterals(rhs);
|
|
2236
|
+
const assigns = [];
|
|
2237
|
+
for (let i = lo; i < hi; i++) {
|
|
2238
|
+
const line = lines[i] ?? '';
|
|
2239
|
+
if (/^\s*if\b/.test(line))
|
|
2240
|
+
continue;
|
|
2241
|
+
for (const seg of line.split(';')) {
|
|
2242
|
+
const t = seg.trim();
|
|
2243
|
+
if (!t)
|
|
2244
|
+
continue;
|
|
2245
|
+
const m = CSHARP_ASSIGN_RE.exec(t);
|
|
2246
|
+
if (!m)
|
|
2247
|
+
continue;
|
|
2248
|
+
const lhs = m[1] ?? m[2];
|
|
2249
|
+
const rhs = m[3];
|
|
2250
|
+
if (!lhs)
|
|
2251
|
+
continue;
|
|
2252
|
+
const rhsCode = stripLits(rhs);
|
|
2253
|
+
const literal = rhsCode.replace(/\s+/g, '') === '';
|
|
2254
|
+
const rhsIdents = rhsCode.match(/\b[A-Za-z_][\w]*\b/g) ?? [];
|
|
2255
|
+
assigns.push({ lhs, rhsIdents, literal });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
const hadNonLiteral = new Set();
|
|
2259
|
+
for (const a of assigns) {
|
|
2260
|
+
if (!a.literal)
|
|
2261
|
+
hadNonLiteral.add(a.lhs);
|
|
2262
|
+
}
|
|
2263
|
+
let changed = true;
|
|
2264
|
+
for (let guard = 0; changed && guard < assigns.length + 2; guard++) {
|
|
2265
|
+
changed = false;
|
|
2266
|
+
for (const a of assigns) {
|
|
2267
|
+
if (!carriers.has(a.lhs) && a.rhsIdents.some(id => carriers.has(id))) {
|
|
2268
|
+
carriers.add(a.lhs);
|
|
2269
|
+
changed = true;
|
|
2270
|
+
}
|
|
2271
|
+
if (carriers.has(a.lhs)) {
|
|
2272
|
+
for (const id of a.rhsIdents) {
|
|
2273
|
+
if (!carriers.has(id) && hadNonLiteral.has(id)) {
|
|
2274
|
+
carriers.add(id);
|
|
2275
|
+
changed = true;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
return carriers;
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* For each upstream local, the 1-based line of the assignment that copied it
|
|
2285
|
+
* into the sink dependency chain (closest to the sink). A literal reset of
|
|
2286
|
+
* `v` after `var q = ... + v` must not kill a flow that only reads `q` (#475).
|
|
2287
|
+
*/
|
|
2288
|
+
function csharpSinkDependencyKillVars(code, flow) {
|
|
2289
|
+
const lines = splitLinesCached(code);
|
|
2290
|
+
const sinkIdx = flow.sink_line - 1;
|
|
2291
|
+
const deriveLine = new Map();
|
|
2292
|
+
if (sinkIdx < 0 || sinkIdx >= lines.length)
|
|
2293
|
+
return deriveLine;
|
|
2294
|
+
const need = new Set(csharpIdentifiersInExpr(lines[sinkIdx] ?? ''));
|
|
2295
|
+
for (let i = sinkIdx - 1; i >= flow.source_line - 1; i--) {
|
|
2296
|
+
const line = lines[i] ?? '';
|
|
2297
|
+
for (const seg of line.split(';')) {
|
|
2298
|
+
const t = seg.trim();
|
|
2299
|
+
if (!t)
|
|
2300
|
+
continue;
|
|
2301
|
+
const m = CSHARP_ASSIGN_RE.exec(t);
|
|
2302
|
+
if (!m)
|
|
2303
|
+
continue;
|
|
2304
|
+
const lhs = m[1] ?? m[2];
|
|
2305
|
+
if (!lhs || !need.has(lhs))
|
|
2306
|
+
continue;
|
|
2307
|
+
for (const id of csharpIdentifiersInExpr(m[3])) {
|
|
2308
|
+
if (!deriveLine.has(id))
|
|
2309
|
+
deriveLine.set(id, i + 1);
|
|
2310
|
+
need.add(id);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
return deriveLine;
|
|
2315
|
+
}
|
|
2316
|
+
function csharpLastUnconditionalAssignIsLiteral(code, variable, srcLine,
|
|
2317
|
+
/** Scan assignments on lines strictly before this 1-based line. */
|
|
2318
|
+
beforeLine, unreachableLines) {
|
|
2319
|
+
if (!variable || !/^[A-Za-z_][\w]*$/.test(variable))
|
|
2320
|
+
return false;
|
|
2321
|
+
if (beforeLine - srcLine < 1)
|
|
2322
|
+
return false;
|
|
2323
|
+
const lines = splitLinesCached(code);
|
|
2324
|
+
const reLit = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)\\s*$');
|
|
2325
|
+
const rePlain = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(.+)\\s*$');
|
|
2326
|
+
const reCompound = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*[+\\-*/%&|^]=\\s*(.+)\\s*$');
|
|
2327
|
+
const reRefOut = new RegExp('\\b(?:ref|out)\\s+' + variable + '\\b');
|
|
2328
|
+
let last = null;
|
|
2329
|
+
const lo = Math.max(0, srcLine);
|
|
2330
|
+
const hi = Math.min(lines.length, beforeLine - 1);
|
|
2331
|
+
for (let i = lo; i < hi; i++) {
|
|
2332
|
+
if (unreachableLines?.has(i + 1))
|
|
2333
|
+
continue;
|
|
2334
|
+
const line = lines[i];
|
|
2335
|
+
if (!line)
|
|
2336
|
+
continue;
|
|
2337
|
+
if (/^\s*if\b/.test(line))
|
|
2338
|
+
continue;
|
|
2339
|
+
for (const seg of line.split(';')) {
|
|
2340
|
+
const t = seg.trim();
|
|
2341
|
+
if (!t)
|
|
2342
|
+
continue;
|
|
2343
|
+
if (reCompound.test(t)) {
|
|
2344
|
+
last = 'other';
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
if (reRefOut.test(csharpStripStringLiterals(t))) {
|
|
2348
|
+
last = 'other';
|
|
2349
|
+
continue;
|
|
2350
|
+
}
|
|
2351
|
+
if (!rePlain.test(t))
|
|
2352
|
+
continue;
|
|
2353
|
+
if (csharpAssignInConditionalBlock(lines, i))
|
|
2354
|
+
continue;
|
|
2355
|
+
if (reLit.test(t)) {
|
|
2356
|
+
last = 'literal';
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
last = 'other';
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
return last === 'literal';
|
|
2364
|
+
}
|
|
2365
|
+
/** True when `header` opens a non-bare control-flow block (not a method/type body). */
|
|
2366
|
+
function csharpBlockHeaderIsConditional(header) {
|
|
2367
|
+
const h = header.trim();
|
|
2368
|
+
if (!h || h === '{')
|
|
2369
|
+
return false;
|
|
2370
|
+
if (/^(?:case\b.*|default\s*):$/.test(h))
|
|
2371
|
+
return true;
|
|
2372
|
+
if (/\bswitch\b/.test(h))
|
|
2373
|
+
return true;
|
|
2374
|
+
if (/^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(h))
|
|
2375
|
+
return true;
|
|
2376
|
+
if (/^(?:\}\s*)?else\b/.test(h))
|
|
2377
|
+
return true;
|
|
2378
|
+
if (/\b(?:for|foreach|while|do|using|lock|try|catch|finally)\b/.test(h))
|
|
2379
|
+
return true;
|
|
2380
|
+
return false;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* True when line `idx` sits in a control-flow block. Walks outward through
|
|
2384
|
+
* enclosing braces so an Allman `for` / `while` header above a lone `{` line
|
|
2385
|
+
* is recognised (#475).
|
|
2386
|
+
*/
|
|
2387
|
+
function csharpAssignInConditionalBlock(lines, idx) {
|
|
2388
|
+
let at = idx;
|
|
2389
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
2390
|
+
const block = enclosingBrace(lines, at, 0);
|
|
2391
|
+
if (!block)
|
|
2392
|
+
return false;
|
|
2393
|
+
let header = block.header.trim();
|
|
2394
|
+
if (!header || header === '{') {
|
|
2395
|
+
const prev = block.headerLine > 0 ? (lines[block.headerLine - 1] ?? '').trim() : '';
|
|
2396
|
+
if (prev)
|
|
2397
|
+
header = prev;
|
|
2398
|
+
}
|
|
2399
|
+
if (csharpBlockHeaderIsConditional(header))
|
|
2400
|
+
return true;
|
|
2401
|
+
if (block.headerLine <= 0)
|
|
2402
|
+
return false;
|
|
2403
|
+
at = block.headerLine - 1;
|
|
2404
|
+
}
|
|
2405
|
+
return false;
|
|
2406
|
+
}
|
|
1428
2407
|
function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreachableLines) {
|
|
1429
2408
|
if (!variable || sinkLine - srcLine < 2)
|
|
1430
2409
|
return false;
|
|
@@ -1455,17 +2434,33 @@ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreach
|
|
|
1455
2434
|
// The fall-through `break;` is optional. Used by cognium-dev #101
|
|
1456
2435
|
// to suppress the switch→constant FP corpus.
|
|
1457
2436
|
const reSwitchCase = new RegExp(`^\\s*(?:case\\b.*?|default\\s*):\\s*${variable}\\s*=\\s*${strLit}\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
|
|
2437
|
+
const lineHasNakedLiteralReassign = (line) => {
|
|
2438
|
+
if (reNaked.test(line) || reSwitchCase.test(line))
|
|
2439
|
+
return true;
|
|
2440
|
+
// cognium-dev#475: `var v = input; v = "const";` on one line — only the
|
|
2441
|
+
// trailing statement is a literal kill, not the whole physical line.
|
|
2442
|
+
for (const seg of line.split(';')) {
|
|
2443
|
+
const t = seg.trim();
|
|
2444
|
+
if (!t)
|
|
2445
|
+
continue;
|
|
2446
|
+
if (reNaked.test(t) || reSwitchCase.test(t))
|
|
2447
|
+
return true;
|
|
2448
|
+
}
|
|
2449
|
+
return false;
|
|
2450
|
+
};
|
|
1458
2451
|
for (let i = lo; i < hi; i++) {
|
|
1459
2452
|
const line = lines[i];
|
|
1460
2453
|
if (!line)
|
|
1461
2454
|
continue;
|
|
1462
2455
|
if (reGuarded.test(line))
|
|
1463
2456
|
return true;
|
|
1464
|
-
if (!
|
|
2457
|
+
if (!lineHasNakedLiteralReassign(line))
|
|
1465
2458
|
continue;
|
|
1466
2459
|
// A literal assigned in one case of a switch only overwrites the value
|
|
1467
2460
|
// when no case gives the variable anything else. `case 'A': bar = param;`
|
|
1468
2461
|
// next to `default: bar = "safe";` leaves `bar` tainted on the 'A' path.
|
|
2462
|
+
if (javaCatchLiteralHasTaintedTryArm(lines, i, variable, strLit, unreachableLines))
|
|
2463
|
+
continue;
|
|
1469
2464
|
if (siblingBranchAssignsNonLiteral(lines, i, variable, strLit, unreachableLines))
|
|
1470
2465
|
continue;
|
|
1471
2466
|
return true;
|
|
@@ -1512,6 +2507,46 @@ function matchingClose(lines, line, col) {
|
|
|
1512
2507
|
}
|
|
1513
2508
|
return null;
|
|
1514
2509
|
}
|
|
2510
|
+
/**
|
|
2511
|
+
* True when line `idx` is a literal assignment in a `catch` block and the
|
|
2512
|
+
* paired `try` assigns `variable` a non-literal value (cognium-dev#753).
|
|
2513
|
+
*/
|
|
2514
|
+
function javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines) {
|
|
2515
|
+
const block = enclosingBrace(lines, idx, 0);
|
|
2516
|
+
if (!block || !/\bcatch\b/.test(block.header))
|
|
2517
|
+
return false;
|
|
2518
|
+
let h = block.headerLine;
|
|
2519
|
+
while (h >= 0 && !/\btry\b/.test(lines[h]))
|
|
2520
|
+
h--;
|
|
2521
|
+
if (h < 0)
|
|
2522
|
+
return false;
|
|
2523
|
+
const tryIdx = lines[h].search(/\btry\b/);
|
|
2524
|
+
let openLine = h;
|
|
2525
|
+
let openCol = lines[h].indexOf('{', tryIdx);
|
|
2526
|
+
if (openCol < 0) {
|
|
2527
|
+
openLine = h + 1;
|
|
2528
|
+
while (openLine < lines.length && lines[openLine].trim() === '')
|
|
2529
|
+
openLine++;
|
|
2530
|
+
if (openLine >= lines.length)
|
|
2531
|
+
return false;
|
|
2532
|
+
openCol = lines[openLine].indexOf('{');
|
|
2533
|
+
if (openCol < 0)
|
|
2534
|
+
return false;
|
|
2535
|
+
}
|
|
2536
|
+
const tryEnd = matchingClose(lines, openLine, openCol);
|
|
2537
|
+
if (!tryEnd)
|
|
2538
|
+
return false;
|
|
2539
|
+
const assignTry = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
|
|
2540
|
+
const literalOnly = new RegExp(`^${strLit}$`);
|
|
2541
|
+
for (let i = openLine; i <= tryEnd.line; i++) {
|
|
2542
|
+
if (unreachableLines?.has(i + 1))
|
|
2543
|
+
continue;
|
|
2544
|
+
const m = assignTry.exec(lines[i]);
|
|
2545
|
+
if (m && m[1] && !literalOnly.test(m[1]))
|
|
2546
|
+
return true;
|
|
2547
|
+
}
|
|
2548
|
+
return false;
|
|
2549
|
+
}
|
|
1515
2550
|
/**
|
|
1516
2551
|
* True when line `idx` assigns `variable` in one arm of a `switch` or one
|
|
1517
2552
|
* branch of an `if`/`else` chain, and a sibling arm or branch assigns it a
|
|
@@ -1534,7 +2569,7 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
|
|
|
1534
2569
|
block = enclosingBrace(lines, block.line, block.col);
|
|
1535
2570
|
}
|
|
1536
2571
|
if (!block)
|
|
1537
|
-
return
|
|
2572
|
+
return pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines);
|
|
1538
2573
|
let first = block.headerLine;
|
|
1539
2574
|
let last;
|
|
1540
2575
|
const end = matchingClose(lines, block.line, block.col);
|
|
@@ -1594,14 +2629,103 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
|
|
|
1594
2629
|
last = next.line;
|
|
1595
2630
|
}
|
|
1596
2631
|
}
|
|
2632
|
+
else if (/\bcatch\b/.test(block.header)) {
|
|
2633
|
+
return javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines);
|
|
2634
|
+
}
|
|
1597
2635
|
else {
|
|
1598
2636
|
return false;
|
|
1599
2637
|
}
|
|
1600
2638
|
const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
|
|
1601
2639
|
const literalOnly = new RegExp(`^${strLit}$`);
|
|
2640
|
+
// For an if/else chain the literal's own arm is skipped: an earlier
|
|
2641
|
+
// assignment in that same arm (`if (a) { if (b) x = input; x = "safe"; }`) is
|
|
2642
|
+
// overwritten by the literal, it is not a sibling branch. A switch body shares
|
|
2643
|
+
// one brace, so every line of it stays a sibling.
|
|
2644
|
+
const isSwitch = /\bswitch\b/.test(block.header);
|
|
2645
|
+
const ownStart = block.line;
|
|
2646
|
+
const ownEnd = end.line;
|
|
1602
2647
|
for (let i = first; i <= last; i++) {
|
|
1603
2648
|
if (i === idx || unreachableLines?.has(i + 1))
|
|
1604
2649
|
continue;
|
|
2650
|
+
if (!isSwitch && i > ownStart && i < ownEnd)
|
|
2651
|
+
continue;
|
|
2652
|
+
const m = assign.exec(lines[i]);
|
|
2653
|
+
if (m && m[1] && !literalOnly.test(m[1]))
|
|
2654
|
+
return true;
|
|
2655
|
+
}
|
|
2656
|
+
return false;
|
|
2657
|
+
}
|
|
2658
|
+
const PY_ARM_OPEN = /^(?:(?:if|elif)\b.*|else\s*):\s*(?:#.*)?$/;
|
|
2659
|
+
/**
|
|
2660
|
+
* The indentation-delimited counterpart of `siblingBranchAssignsNonLiteral`.
|
|
2661
|
+
* The arms of one `if` / `elif` / `else` chain are the header lines at one
|
|
2662
|
+
* indentation plus the more deeply indented lines under each. True when line
|
|
2663
|
+
* `idx` sits directly in one arm of such a chain and another arm assigns
|
|
2664
|
+
* `variable` a value that is not a string literal (cognium-dev#582). Answers
|
|
2665
|
+
* false, keeping the literal as an overwrite, for anything else: a
|
|
2666
|
+
* straight-line assignment, a `for` / `while` / `try` / `def` body, or a header
|
|
2667
|
+
* that is not part of an `if` chain.
|
|
2668
|
+
*/
|
|
2669
|
+
function pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines) {
|
|
2670
|
+
const indentOf = (t) => t.length - t.trimStart().length;
|
|
2671
|
+
const skippable = (t) => t.trim() === '' || t.trimStart().startsWith('#');
|
|
2672
|
+
const own = lines[idx];
|
|
2673
|
+
if (own === undefined)
|
|
2674
|
+
return false;
|
|
2675
|
+
const bodyIndent = indentOf(own);
|
|
2676
|
+
if (bodyIndent === 0)
|
|
2677
|
+
return false;
|
|
2678
|
+
// The header of the arm that holds the literal: the nearest line above with less indentation.
|
|
2679
|
+
let h = idx - 1;
|
|
2680
|
+
while (h >= 0 && (skippable(lines[h]) || indentOf(lines[h]) >= bodyIndent))
|
|
2681
|
+
h--;
|
|
2682
|
+
if (h < 0 || !PY_ARM_OPEN.test(lines[h].trim()))
|
|
2683
|
+
return false;
|
|
2684
|
+
const armIndent = indentOf(lines[h]);
|
|
2685
|
+
// The last line of the arm whose header is at `header`.
|
|
2686
|
+
const armEnd = (header) => {
|
|
2687
|
+
let lastBody = header;
|
|
2688
|
+
for (let e = header + 1; e < lines.length; e++) {
|
|
2689
|
+
if (skippable(lines[e]))
|
|
2690
|
+
continue;
|
|
2691
|
+
if (indentOf(lines[e]) <= armIndent)
|
|
2692
|
+
break;
|
|
2693
|
+
lastBody = e;
|
|
2694
|
+
}
|
|
2695
|
+
return lastBody;
|
|
2696
|
+
};
|
|
2697
|
+
// Walk up through `elif` / `else` to the opening `if`.
|
|
2698
|
+
let first = h;
|
|
2699
|
+
while (/^(?:elif\b|else\s*:)/.test(lines[first].trim())) {
|
|
2700
|
+
let p = first - 1;
|
|
2701
|
+
while (p >= 0 && (skippable(lines[p]) || indentOf(lines[p]) > armIndent))
|
|
2702
|
+
p--;
|
|
2703
|
+
if (p < 0 || indentOf(lines[p]) !== armIndent || !PY_ARM_OPEN.test(lines[p].trim()))
|
|
2704
|
+
return false;
|
|
2705
|
+
first = p;
|
|
2706
|
+
}
|
|
2707
|
+
if (!/^if\b/.test(lines[first].trim()))
|
|
2708
|
+
return false;
|
|
2709
|
+
// Walk down through the following `elif` / `else` arms.
|
|
2710
|
+
const ownEnd = armEnd(h);
|
|
2711
|
+
let last = ownEnd;
|
|
2712
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
2713
|
+
let n = last + 1;
|
|
2714
|
+
while (n < lines.length && skippable(lines[n]))
|
|
2715
|
+
n++;
|
|
2716
|
+
if (n >= lines.length || indentOf(lines[n]) !== armIndent)
|
|
2717
|
+
break;
|
|
2718
|
+
if (!/^(?:elif\b|else\s*:)/.test(lines[n].trim()) || !PY_ARM_OPEN.test(lines[n].trim()))
|
|
2719
|
+
break;
|
|
2720
|
+
last = armEnd(n);
|
|
2721
|
+
}
|
|
2722
|
+
const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
|
|
2723
|
+
const literalOnly = new RegExp(`^${strLit}$`);
|
|
2724
|
+
for (let i = first; i <= last; i++) {
|
|
2725
|
+
if (i === idx || unreachableLines?.has(i + 1))
|
|
2726
|
+
continue;
|
|
2727
|
+
if (i >= h && i <= ownEnd)
|
|
2728
|
+
continue; // the literal's own arm is not a sibling
|
|
1605
2729
|
const m = assign.exec(lines[i]);
|
|
1606
2730
|
if (m && m[1] && !literalOnly.test(m[1]))
|
|
1607
2731
|
return true;
|
|
@@ -1692,7 +2816,7 @@ export function maskStringLiteralsForTokenScan(expr) {
|
|
|
1692
2816
|
j++;
|
|
1693
2817
|
}
|
|
1694
2818
|
j++;
|
|
1695
|
-
acc += ' ' + maskStringLiteralsForTokenScan(inner) + ' ';
|
|
2819
|
+
acc += ' ' + maskStringLiteralsForTokenScan(pythonFStringExprOnly(inner)) + ' ';
|
|
1696
2820
|
continue;
|
|
1697
2821
|
}
|
|
1698
2822
|
j++;
|
|
@@ -1815,6 +2939,8 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
1815
2939
|
// is every Juliet C# file.
|
|
1816
2940
|
const csFnRanges = methodRanges(['csharp']);
|
|
1817
2941
|
const csFnStart = (line) => enclosingStart(csFnRanges, line);
|
|
2942
|
+
const jsFnRanges = methodRanges(['javascript', 'typescript']);
|
|
2943
|
+
const jsFnStart = (line) => enclosingStart(jsFnRanges, line);
|
|
1818
2944
|
// cognium-dev#548 — names declared as class fields. A field is the one kind
|
|
1819
2945
|
// of variable whose value legitimately crosses from one method to another, so
|
|
1820
2946
|
// the per-method gate below must not apply to it.
|
|
@@ -1885,6 +3011,15 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
1885
3011
|
anchor = s;
|
|
1886
3012
|
}
|
|
1887
3013
|
}
|
|
3014
|
+
// #515: real (non-derived) sources by variable name, earliest first, and
|
|
3015
|
+
// the file's lines for reading each derivation's right-hand side.
|
|
3016
|
+
const realSourceByVar = new Map();
|
|
3017
|
+
for (const s of sourcesWithVar) {
|
|
3018
|
+
const prev = realSourceByVar.get(s.variable);
|
|
3019
|
+
if (!prev || s.line < prev.line)
|
|
3020
|
+
realSourceByVar.set(s.variable, s);
|
|
3021
|
+
}
|
|
3022
|
+
const codeLines = code.split('\n');
|
|
1888
3023
|
for (const [varName, originLine] of derived) {
|
|
1889
3024
|
if (!varName || existingVars.has(varName))
|
|
1890
3025
|
continue;
|
|
@@ -1894,8 +3029,12 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
1894
3029
|
// `http_param` type — the for-loop / bare-inline patterns we want
|
|
1895
3030
|
// to recover here are all web-request-derived in practice.
|
|
1896
3031
|
if (hasRealSource && anchor) {
|
|
3032
|
+
// #515: anchor to the real source this derivation actually
|
|
3033
|
+
// references (through earlier derived vars); the earliest-source
|
|
3034
|
+
// anchor stays the fallback when none can be found.
|
|
3035
|
+
const referenced = resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines, new Set());
|
|
1897
3036
|
sourcesWithVar.push({
|
|
1898
|
-
...anchor,
|
|
3037
|
+
...(referenced ?? anchor),
|
|
1899
3038
|
variable: varName,
|
|
1900
3039
|
});
|
|
1901
3040
|
}
|
|
@@ -2245,6 +3384,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2245
3384
|
const aliases = csharpMethodScopedAliases(code, seeds, csMethods, csFieldNames);
|
|
2246
3385
|
const fileAnchor = sourcesWithVar.reduce((a, sv) => (sv.line < a.line ? sv : a), sourcesWithVar[0]);
|
|
2247
3386
|
const have = new Set(sourcesWithVar.map(sv => `${sv.variable}@${methodStartOf(sv.line)}`));
|
|
3387
|
+
const seedVars = new Set(sourcesWithVar.map(s => s.variable));
|
|
3388
|
+
const derivedForAnchor = buildJavaTaintedVars(code, seedVars);
|
|
3389
|
+
const codeLinesForAnchor = code.split('\n');
|
|
2248
3390
|
for (const alias of aliases) {
|
|
2249
3391
|
const fn = methodStartOf(alias.line);
|
|
2250
3392
|
const isField = csFieldNames.has(alias.variable);
|
|
@@ -2257,14 +3399,34 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2257
3399
|
// call), which also keeps it ahead of a sink in a method declared
|
|
2258
3400
|
// earlier in the file than the method holding the source.
|
|
2259
3401
|
const inMethod = sourcesWithVar.filter(sv => methodStartOf(sv.line) === fn && sv.line <= alias.line);
|
|
2260
|
-
|
|
3402
|
+
let from = !alias.carried && inMethod.length > 0
|
|
2261
3403
|
? inMethod.reduce((a, sv) => (sv.line < a.line ? sv : a), inMethod[0])
|
|
2262
3404
|
: fileAnchor;
|
|
2263
|
-
|
|
3405
|
+
// cognium-dev#515: same RHS anchor as Java — do not credit an earlier decoy read.
|
|
3406
|
+
if (!alias.carried && derivedForAnchor.size > 0) {
|
|
3407
|
+
const methodRealSourceByVar = new Map();
|
|
3408
|
+
for (const s of sourcesWithVar) {
|
|
3409
|
+
if (methodStartOf(s.line) !== fn)
|
|
3410
|
+
continue;
|
|
3411
|
+
const prev = methodRealSourceByVar.get(s.variable);
|
|
3412
|
+
if (!prev || s.line < prev.line)
|
|
3413
|
+
methodRealSourceByVar.set(s.variable, s);
|
|
3414
|
+
}
|
|
3415
|
+
const referenced = resolveJavaDerivedAnchor(alias.variable, derivedForAnchor, methodRealSourceByVar, codeLinesForAnchor, new Set());
|
|
3416
|
+
if (referenced)
|
|
3417
|
+
from = referenced;
|
|
3418
|
+
}
|
|
3419
|
+
const aliasEntry = {
|
|
2264
3420
|
...from,
|
|
2265
3421
|
variable: alias.variable,
|
|
2266
3422
|
...(alias.carried || inMethod.length === 0 ? { line: alias.line } : {}),
|
|
2267
|
-
}
|
|
3423
|
+
};
|
|
3424
|
+
// cognium-dev#327: field aliases must not keep the writing method's
|
|
3425
|
+
// `in_method` tag — readers in other methods need to see the taint.
|
|
3426
|
+
if (isField) {
|
|
3427
|
+
delete aliasEntry.in_method;
|
|
3428
|
+
}
|
|
3429
|
+
sourcesWithVar.push(aliasEntry);
|
|
2268
3430
|
}
|
|
2269
3431
|
}
|
|
2270
3432
|
else if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
|
|
@@ -2295,29 +3457,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2295
3457
|
}
|
|
2296
3458
|
return null;
|
|
2297
3459
|
};
|
|
3460
|
+
const typeAt = (line) => {
|
|
3461
|
+
for (const t of types ?? []) {
|
|
3462
|
+
if (line >= t.start_line && line <= t.end_line)
|
|
3463
|
+
return t;
|
|
3464
|
+
}
|
|
3465
|
+
return undefined;
|
|
3466
|
+
};
|
|
3467
|
+
const isDeclaredField = (line, name) => {
|
|
3468
|
+
const t = typeAt(line);
|
|
3469
|
+
return t?.fields?.some(f => f.name === name) ?? false;
|
|
3470
|
+
};
|
|
2298
3471
|
const existingVars = new Set(sourcesWithVar.map(s => s.variable));
|
|
3472
|
+
const codeLines = code.split('\n');
|
|
3473
|
+
const realSourceByVar = new Map();
|
|
3474
|
+
for (const s of sourcesWithVar) {
|
|
3475
|
+
const prev = realSourceByVar.get(s.variable);
|
|
3476
|
+
if (!prev || s.line < prev.line)
|
|
3477
|
+
realSourceByVar.set(s.variable, s);
|
|
3478
|
+
}
|
|
2299
3479
|
for (const [varName, derivedLine] of derived) {
|
|
2300
3480
|
if (!varName || existingVars.has(varName))
|
|
2301
3481
|
continue;
|
|
2302
3482
|
const owner = methodAt(derivedLine);
|
|
3483
|
+
const fieldAlias = isDeclaredField(derivedLine, varName);
|
|
2303
3484
|
let scopedAnchor = anchor;
|
|
2304
3485
|
if (owner) {
|
|
2305
3486
|
let best;
|
|
3487
|
+
const methodRealSourceByVar = new Map();
|
|
2306
3488
|
for (const s of sourcesWithVar) {
|
|
2307
3489
|
const sOwner = s.in_method ?? methodAt(s.line);
|
|
2308
3490
|
if (sOwner !== owner)
|
|
2309
3491
|
continue;
|
|
3492
|
+
const prev = methodRealSourceByVar.get(s.variable);
|
|
3493
|
+
if (!prev || s.line < prev.line)
|
|
3494
|
+
methodRealSourceByVar.set(s.variable, s);
|
|
2310
3495
|
if (!best || s.line < best.line)
|
|
2311
3496
|
best = s;
|
|
2312
3497
|
}
|
|
2313
|
-
|
|
3498
|
+
const referenced = resolveJavaDerivedAnchor(varName, derived, methodRealSourceByVar, codeLines, new Set());
|
|
3499
|
+
if (referenced)
|
|
3500
|
+
scopedAnchor = referenced;
|
|
3501
|
+
else if (best)
|
|
2314
3502
|
scopedAnchor = best;
|
|
2315
3503
|
}
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
3504
|
+
// Keep scopedAnchor.line for in_method / preceding-source matching
|
|
3505
|
+
// (#614). Pass derivedLine only for literal-kill (#753), so a safe
|
|
3506
|
+
// sibling method's earlier sink cannot pick up this alias by line.
|
|
3507
|
+
// cognium-dev#327: a declared field written from a method parameter
|
|
3508
|
+
// must not inherit the writing method as `in_method` — same exemption
|
|
3509
|
+
// as request sources on fields (4.12.0), so readers in other methods
|
|
3510
|
+
// can see the taint.
|
|
3511
|
+
if (fieldAlias) {
|
|
3512
|
+
const { in_method: _drop, ...rest } = scopedAnchor;
|
|
3513
|
+
sourcesWithVar.push({
|
|
3514
|
+
...rest,
|
|
3515
|
+
variable: varName,
|
|
3516
|
+
literal_kill_line: derivedLine,
|
|
3517
|
+
});
|
|
3518
|
+
}
|
|
3519
|
+
else {
|
|
3520
|
+
sourcesWithVar.push({
|
|
3521
|
+
...scopedAnchor,
|
|
3522
|
+
variable: varName,
|
|
3523
|
+
literal_kill_line: derivedLine,
|
|
3524
|
+
...(owner ? { in_method: owner } : {}),
|
|
3525
|
+
});
|
|
3526
|
+
}
|
|
2321
3527
|
existingVars.add(varName);
|
|
2322
3528
|
}
|
|
2323
3529
|
}
|
|
@@ -2331,7 +3537,217 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2331
3537
|
// tainted var (at the CommandText line) so the arg-scan links the Execute sink.
|
|
2332
3538
|
if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
|
|
2333
3539
|
const lines = code.split('\n');
|
|
2334
|
-
|
|
3540
|
+
// cognium-dev#594: `X.StartInfo.FileName = <tainted>` is the same shape for a
|
|
3541
|
+
// `System.Diagnostics.Process`: the executable path rides the process object
|
|
3542
|
+
// and the zero-argument `X.Start()` is the injection point. Only the
|
|
3543
|
+
// `FileName` write seeds it, and an `Arguments` write when the program is a
|
|
3544
|
+
// shell (`ArgumentList` is a separate follow-up), so a constant `FileName`
|
|
3545
|
+
// beside an unrelated tainted parameter stays clean.
|
|
3546
|
+
// A write only: `+=` or a single `=` that is not part of `==`, `!=`, `<=`, `>=`
|
|
3547
|
+
// (`if (p.StartInfo.FileName == tool)` is a comparison, not an assignment).
|
|
3548
|
+
// `StartInfo.Arguments` seeds too, but only behind a shell (or a non-constant
|
|
3549
|
+
// program), see `argumentsReachShell` below.
|
|
3550
|
+
const ctRe = /(\w+)\s*\.\s*(?:CommandText|StartInfo\s*\.\s*(?:FileName|(Arguments)))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
|
|
3551
|
+
// cognium-dev#594: the same two writes on a local `ProcessStartInfo psi`
|
|
3552
|
+
// (`psi.FileName = …`, `psi.Arguments = …`), read back by `Process.Start(psi)`.
|
|
3553
|
+
// Gated on the receiver being declared as a `ProcessStartInfo`, so a `FileName`
|
|
3554
|
+
// or `Arguments` property of any other type seeds nothing.
|
|
3555
|
+
const psiVars = new Set();
|
|
3556
|
+
for (const line of lines) {
|
|
3557
|
+
for (const pm of line.matchAll(/\bProcessStartInfo\s+([\p{L}_][\p{L}\p{N}_]*)/gu))
|
|
3558
|
+
psiVars.add(pm[1]);
|
|
3559
|
+
for (const pm of line.matchAll(/\b([\p{L}_][\p{L}\p{N}_]*)\s*=\s*new\s+ProcessStartInfo\b/gu))
|
|
3560
|
+
psiVars.add(pm[1]);
|
|
3561
|
+
}
|
|
3562
|
+
const psiRe = /(?<![\w.])(\w+)\s*\.\s*(?:FileName|(Arguments))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
|
|
3563
|
+
const psiOverwrittenByLiteral = (obj, prop, at) => {
|
|
3564
|
+
const fn = csFnStart(at + 1);
|
|
3565
|
+
const re = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*${prop}\\s*=(?!=)\\s*@?"[^"]*"\\s*;\\s*(?://.*)?$`);
|
|
3566
|
+
for (let j = at + 1; j < lines.length; j++) {
|
|
3567
|
+
if (csFnStart(j + 1) !== fn)
|
|
3568
|
+
break;
|
|
3569
|
+
if (re.test(lines[j]))
|
|
3570
|
+
return true;
|
|
3571
|
+
}
|
|
3572
|
+
return false;
|
|
3573
|
+
};
|
|
3574
|
+
const fileNameRe = (obj) => new RegExp(`\\b${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?FileName\\s*=(?!=)\\s*(.+)`);
|
|
3575
|
+
const shellProgramRe = /^(?:cmd|powershell|pwsh|sh|bash|zsh|dash|ksh|csh|tcsh|fish)(?:\.exe)?$/i;
|
|
3576
|
+
// cognium-dev#594: with a constant, non-shell program the argument string is
|
|
3577
|
+
// argv (the #272 / #328 safe shape), so a tainted `Arguments` does not by itself
|
|
3578
|
+
// reach a shell. A constant shell program, or a program that is not a constant,
|
|
3579
|
+
// lets the argument text choose what runs. No `FileName` write in the method
|
|
3580
|
+
// (for example a `ProcessStartInfo` built elsewhere) seeds nothing.
|
|
3581
|
+
const argumentsReachShell = (obj, line) => {
|
|
3582
|
+
const re = fileNameRe(obj);
|
|
3583
|
+
const fnStart = csFnStart(line);
|
|
3584
|
+
for (let j = 0; j < lines.length; j++) {
|
|
3585
|
+
if (csFnStart(j + 1) !== fnStart)
|
|
3586
|
+
continue;
|
|
3587
|
+
const fm = re.exec(lines[j]);
|
|
3588
|
+
if (!fm)
|
|
3589
|
+
continue;
|
|
3590
|
+
const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
|
|
3591
|
+
if (!lit)
|
|
3592
|
+
return true;
|
|
3593
|
+
const base = lit[1].split(/[\\/]/).pop() ?? '';
|
|
3594
|
+
return shellProgramRe.test(base);
|
|
3595
|
+
}
|
|
3596
|
+
return false;
|
|
3597
|
+
};
|
|
3598
|
+
// cognium-dev#594: `X.StartInfo.ArgumentList.Add(<tainted>)` (or `psi.ArgumentList.Add(…)`
|
|
3599
|
+
// on a local `ProcessStartInfo`) is argv, so it only reaches a command when the
|
|
3600
|
+
// program is a shell that is told to run a command string. Seed only when the
|
|
3601
|
+
// method writes `FileName` exactly once, to a constant shell program, and an
|
|
3602
|
+
// `ArgumentList.Add` of a command flag (`-c`, `/c`, …) is in the same method.
|
|
3603
|
+
// A non-constant or non-shell program, a second `FileName` write, an
|
|
3604
|
+
// `ArgumentList.Clear()`, or an `Add(` that wraps over several lines seeds nothing.
|
|
3605
|
+
const argListRe = /(?<![\w.])(\w+)\s*\.\s*(StartInfo\s*\.\s*)?ArgumentList\s*\.\s*Add\s*\(\s*(.+?)\s*\)\s*;?\s*(?:\/\/.*)?$/;
|
|
3606
|
+
const shellFlagRe = /^(?:[-/](?:c|k|command|encodedcommand|ec))$/i;
|
|
3607
|
+
// cognium-dev#750: the program may also be written by the `ProcessStartInfo`
|
|
3608
|
+
// constructor's single argument or by a `FileName = …` entry of its object
|
|
3609
|
+
// initializer, on the statement that creates `obj` (`var psi = new
|
|
3610
|
+
// ProcessStartInfo("/bin/sh");`, `new ProcessStartInfo { FileName = "/bin/sh",
|
|
3611
|
+
// … }`, possibly wrapped over several lines). Each counts as one `FileName`
|
|
3612
|
+
// write, so the exactly-one-write rule still holds, and they apply only when the
|
|
3613
|
+
// method has no explicit `obj.FileName = …` write. Any shape that is not
|
|
3614
|
+
// plainly one constant program (two constructor arguments, an `Arguments`
|
|
3615
|
+
// entry, a non-literal value) counts as a non-constant write.
|
|
3616
|
+
const splitTopLevel = (text) => {
|
|
3617
|
+
const parts = [];
|
|
3618
|
+
let depth = 0;
|
|
3619
|
+
let quote = '';
|
|
3620
|
+
let cur = '';
|
|
3621
|
+
for (let k = 0; k < text.length; k++) {
|
|
3622
|
+
const ch = text[k];
|
|
3623
|
+
if (quote) {
|
|
3624
|
+
cur += ch;
|
|
3625
|
+
if (ch === '\\' && quote === '"') {
|
|
3626
|
+
cur += text[++k] ?? '';
|
|
3627
|
+
continue;
|
|
3628
|
+
}
|
|
3629
|
+
if (ch === quote)
|
|
3630
|
+
quote = '';
|
|
3631
|
+
continue;
|
|
3632
|
+
}
|
|
3633
|
+
if (ch === '"') {
|
|
3634
|
+
quote = ch;
|
|
3635
|
+
cur += ch;
|
|
3636
|
+
continue;
|
|
3637
|
+
}
|
|
3638
|
+
if (ch === '(' || ch === '{' || ch === '[')
|
|
3639
|
+
depth++;
|
|
3640
|
+
if (ch === ')' || ch === '}' || ch === ']')
|
|
3641
|
+
depth--;
|
|
3642
|
+
if (ch === ',' && depth === 0) {
|
|
3643
|
+
parts.push(cur.trim());
|
|
3644
|
+
cur = '';
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
cur += ch;
|
|
3648
|
+
}
|
|
3649
|
+
if (cur.trim())
|
|
3650
|
+
parts.push(cur.trim());
|
|
3651
|
+
return parts;
|
|
3652
|
+
};
|
|
3653
|
+
const constLiteral = (expr) => {
|
|
3654
|
+
const m = /^@?"([^"]*)"$/.exec(expr.trim());
|
|
3655
|
+
return m ? (m[1].split(/[\\/]/).pop() ?? '') : null;
|
|
3656
|
+
};
|
|
3657
|
+
// Returns null when line j does not create `obj` or the creation writes no program.
|
|
3658
|
+
const startInfoCreation = (obj, j) => {
|
|
3659
|
+
const head = new RegExp(`(?<![\\w.])${obj}(?:\\s*\\.\\s*StartInfo)?\\s*=(?!=)\\s*new\\s+ProcessStartInfo\\b`).exec(lines[j]);
|
|
3660
|
+
if (!head)
|
|
3661
|
+
return null;
|
|
3662
|
+
let text = '';
|
|
3663
|
+
for (let k = j; k < lines.length && k < j + 40; k++) {
|
|
3664
|
+
text += (k === j ? lines[k].slice(head.index) : ' ' + lines[k]).replace(/\/\/.*$/, '');
|
|
3665
|
+
if (text.includes(';'))
|
|
3666
|
+
break;
|
|
3667
|
+
}
|
|
3668
|
+
const m = /new\s+ProcessStartInfo\s*(?:\(([^()]*)\))?\s*(?:\{([\s\S]*)\})?\s*;/.exec(text);
|
|
3669
|
+
if (!m)
|
|
3670
|
+
return { program: null };
|
|
3671
|
+
let program;
|
|
3672
|
+
const ctorArgs = splitTopLevel(m[1] ?? '');
|
|
3673
|
+
if (ctorArgs.length > 1)
|
|
3674
|
+
return { program: null };
|
|
3675
|
+
if (ctorArgs.length === 1)
|
|
3676
|
+
program = constLiteral(ctorArgs[0]);
|
|
3677
|
+
for (const member of splitTopLevel(m[2] ?? '')) {
|
|
3678
|
+
const em = /^(\w+)\s*=(?!=)\s*([\s\S]*)$/.exec(member);
|
|
3679
|
+
if (!em)
|
|
3680
|
+
return { program: null };
|
|
3681
|
+
if (em[1] === 'FileName') {
|
|
3682
|
+
if (program !== undefined)
|
|
3683
|
+
return { program: null };
|
|
3684
|
+
program = constLiteral(em[2]);
|
|
3685
|
+
}
|
|
3686
|
+
else if (em[1] === 'Arguments' || em[1] === 'ArgumentList') {
|
|
3687
|
+
return { program: null };
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
return program === undefined ? null : { program };
|
|
3691
|
+
};
|
|
3692
|
+
const constStringFieldProgram = (fieldName) => {
|
|
3693
|
+
const declRe = new RegExp(`\\b(?:\\w+\\s+)*const\\s+string\\s+${fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*@?"([^"]*)"\\s*;`);
|
|
3694
|
+
let hits = 0;
|
|
3695
|
+
let base = null;
|
|
3696
|
+
for (const srcLine of lines) {
|
|
3697
|
+
const dm = declRe.exec(srcLine);
|
|
3698
|
+
if (!dm)
|
|
3699
|
+
continue;
|
|
3700
|
+
hits++;
|
|
3701
|
+
base = dm[1].split(/[\\/]/).pop() ?? '';
|
|
3702
|
+
}
|
|
3703
|
+
return hits === 1 ? base : null;
|
|
3704
|
+
};
|
|
3705
|
+
const argListReachesShell = (obj, line) => {
|
|
3706
|
+
const fnStart = csFnStart(line);
|
|
3707
|
+
const nameRe = fileNameRe(obj);
|
|
3708
|
+
const clearRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*(?:Clear|Remove\\w*|Insert\\w*)\\s*\\(`);
|
|
3709
|
+
const addRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*Add\\s*\\(\\s*@?"([^"]*)"\\s*\\)`);
|
|
3710
|
+
let program = null;
|
|
3711
|
+
let writes = 0;
|
|
3712
|
+
let creations = 0;
|
|
3713
|
+
let createdProgram = null;
|
|
3714
|
+
let flag = false;
|
|
3715
|
+
for (let j = 0; j < lines.length; j++) {
|
|
3716
|
+
if (csFnStart(j + 1) !== fnStart)
|
|
3717
|
+
continue;
|
|
3718
|
+
if (clearRe.test(lines[j]))
|
|
3719
|
+
return false;
|
|
3720
|
+
const fm = nameRe.exec(lines[j]);
|
|
3721
|
+
if (fm) {
|
|
3722
|
+
writes++;
|
|
3723
|
+
const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
|
|
3724
|
+
if (lit) {
|
|
3725
|
+
program = lit[1].split(/[\\/]/).pop() ?? '';
|
|
3726
|
+
}
|
|
3727
|
+
else {
|
|
3728
|
+
const id = /^\s*(\w+)\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
|
|
3729
|
+
program = id ? constStringFieldProgram(id[1]) : null;
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
const created = startInfoCreation(obj, j);
|
|
3733
|
+
if (created) {
|
|
3734
|
+
creations++;
|
|
3735
|
+
createdProgram = created.program;
|
|
3736
|
+
}
|
|
3737
|
+
const am = addRe.exec(lines[j]);
|
|
3738
|
+
if (am && shellFlagRe.test(am[1]))
|
|
3739
|
+
flag = true;
|
|
3740
|
+
}
|
|
3741
|
+
// An explicit `obj.FileName = …` decides the program, as before. Only when the
|
|
3742
|
+
// method has none does the creating statement's constant program count, and
|
|
3743
|
+
// then it must be the only creation (a second `new ProcessStartInfo(…)` for
|
|
3744
|
+
// the same variable could replace it).
|
|
3745
|
+
if (writes === 0 && creations > 0) {
|
|
3746
|
+
writes = creations;
|
|
3747
|
+
program = createdProgram;
|
|
3748
|
+
}
|
|
3749
|
+
return writes === 1 && program !== null && shellProgramRe.test(program) && flag;
|
|
3750
|
+
};
|
|
2335
3751
|
// cognium-dev#548: seed per method, from sources in that method. The seed
|
|
2336
3752
|
// used to be made once per file and matched by name, so `cmd.CommandText =
|
|
2337
3753
|
// … + data` in `Bad()` tainted `cmd.ExecuteScalar()` in `GoodG2B()`, whose
|
|
@@ -2344,15 +3760,60 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2344
3760
|
added = false;
|
|
2345
3761
|
guard++;
|
|
2346
3762
|
for (let i = 0; i < lines.length; i++) {
|
|
2347
|
-
const
|
|
3763
|
+
const am = argListRe.exec(lines[i]);
|
|
3764
|
+
if (am) {
|
|
3765
|
+
const [, aobj, viaStartInfo, aargs] = am;
|
|
3766
|
+
const akey = `${aobj}@${csFnStart(i + 1)}`;
|
|
3767
|
+
if ((viaStartInfo || psiVars.has(aobj)) && !seeded.has(akey) && argListReachesShell(aobj, i + 1)) {
|
|
3768
|
+
// A value set to a literal after the source and before this `Add` no longer
|
|
3769
|
+
// carries it (`cmd = name; cmd = "uptime"; …Add(cmd)`).
|
|
3770
|
+
const literalSince = (v, from) => {
|
|
3771
|
+
const re = new RegExp(`(?<![\\w.])${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)\\s*@?"[^"]*"\\s*;`);
|
|
3772
|
+
const fn = csFnStart(i + 1);
|
|
3773
|
+
for (let j = from; j < i; j++)
|
|
3774
|
+
if (csFnStart(j + 1) === fn && re.test(lines[j]))
|
|
3775
|
+
return true;
|
|
3776
|
+
return false;
|
|
3777
|
+
};
|
|
3778
|
+
const reachableA = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1) && !literalSince(sv.variable, sv.line));
|
|
3779
|
+
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');
|
|
3780
|
+
if (reachableA.length > 0 && argsReferenceTaintedVar(maskedArg, reachableA)) {
|
|
3781
|
+
const fromA = reachableA.reduce((a, sv) => (sv.line < a.line ? sv : a), reachableA[0]);
|
|
3782
|
+
sourcesWithVar.push({ ...fromA, variable: aobj, line: i + 1 });
|
|
3783
|
+
seeded.add(akey);
|
|
3784
|
+
added = true;
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
continue;
|
|
3788
|
+
}
|
|
3789
|
+
let m = ctRe.exec(lines[i]);
|
|
3790
|
+
let viaLocal = false;
|
|
3791
|
+
if (!m) {
|
|
3792
|
+
const pm = psiRe.exec(lines[i]);
|
|
3793
|
+
if (pm && psiVars.has(pm[1])) {
|
|
3794
|
+
m = pm;
|
|
3795
|
+
viaLocal = true;
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
2348
3798
|
if (!m)
|
|
2349
3799
|
continue;
|
|
2350
|
-
const [, obj, rhs] = m;
|
|
3800
|
+
const [, obj, isArguments, rhs] = m;
|
|
3801
|
+
// A later plain-literal write of the same property in this method replaces
|
|
3802
|
+
// the tainted value before `Process.Start(psi)` reads it: do not credit it.
|
|
3803
|
+
if (viaLocal && psiOverwrittenByLiteral(obj, isArguments ? 'Arguments' : 'FileName', i))
|
|
3804
|
+
continue;
|
|
2351
3805
|
const key = `${obj}@${csFnStart(i + 1)}`;
|
|
2352
3806
|
if (seeded.has(key))
|
|
2353
3807
|
continue;
|
|
3808
|
+
if (isArguments && !argumentsReachShell(obj, i + 1))
|
|
3809
|
+
continue;
|
|
2354
3810
|
const reachable = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1));
|
|
2355
|
-
|
|
3811
|
+
// A value converted to a number / Guid / bool cannot carry a command; the
|
|
3812
|
+
// local-`psi` form masks such conversions so a raw operand beside one still counts.
|
|
3813
|
+
const rhsForTaint = viaLocal
|
|
3814
|
+
? 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')
|
|
3815
|
+
: rhs;
|
|
3816
|
+
if (reachable.length === 0 || !argsReferenceTaintedVar(rhsForTaint, reachable))
|
|
2356
3817
|
continue;
|
|
2357
3818
|
const from = reachable.reduce((a, sv) => (sv.line < a.line ? sv : a), reachable[0]);
|
|
2358
3819
|
sourcesWithVar.push({ ...from, variable: obj, line: i + 1 });
|
|
@@ -2552,6 +4013,19 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2552
4013
|
if (srcFn !== -1 && sinkFn !== -1 && srcFn !== sinkFn)
|
|
2553
4014
|
continue;
|
|
2554
4015
|
}
|
|
4016
|
+
// cognium-dev#752: GraphQL resolver args are seeded by name only; scope
|
|
4017
|
+
// them to the resolver (or factory) they came from so a same-named
|
|
4018
|
+
// local in another recorded function does not inherit the seed.
|
|
4019
|
+
if (jsFnRanges.length > 0 &&
|
|
4020
|
+
typeof source.location === 'string' &&
|
|
4021
|
+
source.location.startsWith('GraphQL ')) {
|
|
4022
|
+
const sinkFn = jsFnStart(sink.line);
|
|
4023
|
+
if (sinkFn !== -1) {
|
|
4024
|
+
const range = jsFnRanges.find(([start]) => start === sinkFn);
|
|
4025
|
+
if (range && (source.line < range[0] || source.line > range[1]))
|
|
4026
|
+
continue;
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
2555
4029
|
// cognium-dev#530: a builder-seeded source must not escape its method.
|
|
2556
4030
|
// The seed stands for "this StringBuilder now holds tainted text",
|
|
2557
4031
|
// which is true only of the one object in the one method where the
|
|
@@ -2605,14 +4079,16 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2605
4079
|
if (aliasSanitizedFor.get(source.variable)?.has(sink.type)) {
|
|
2606
4080
|
break;
|
|
2607
4081
|
}
|
|
2608
|
-
// Sprint 9 #58.3 / #56: between
|
|
2609
|
-
// the tainted variable is reassigned to a
|
|
2610
|
-
// (either naked `var = "lit"` or guarded by an
|
|
2611
|
-
// such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
|
|
4082
|
+
// Sprint 9 #58.3 / #56: between the alias derivation (or source)
|
|
4083
|
+
// line and sink.line, if the tainted variable is reassigned to a
|
|
4084
|
+
// pure string literal (either naked `var = "lit"` or guarded by an
|
|
4085
|
+
// allowlist check such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
|
|
2612
4086
|
// `if var not in ALLOWLIST: var = "lit"`), the original taint
|
|
2613
|
-
// no longer reaches the sink — suppress the flow.
|
|
4087
|
+
// no longer reaches the sink — suppress the flow. Derived aliases
|
|
4088
|
+
// carry `literal_kill_line` so kill starts at the assignment that
|
|
4089
|
+
// created them (#753), while `source.line` stays on the anchor (#614).
|
|
2614
4090
|
if (typeof code === 'string' &&
|
|
2615
|
-
isReassignedToLiteralBetween(code, source.variable, source.line, sink.line, unreachableLines)) {
|
|
4091
|
+
isReassignedToLiteralBetween(code, source.variable, source.literal_kill_line ?? source.line, sink.line, unreachableLines)) {
|
|
2616
4092
|
break;
|
|
2617
4093
|
}
|
|
2618
4094
|
// Note: DFG-flow filter handles sanitizer-guard suppression
|
|
@@ -2635,6 +4111,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2635
4111
|
}
|
|
2636
4112
|
}
|
|
2637
4113
|
}
|
|
4114
|
+
// cognium-dev#490 — same helper as `splitCSharpDepth0Statements` in language-sources-pass.
|
|
4115
|
+
const splitCSharpDepth0Statements = (line) => {
|
|
4116
|
+
if (line.includes('{'))
|
|
4117
|
+
return [line];
|
|
4118
|
+
const statements = [];
|
|
4119
|
+
let start = 0;
|
|
4120
|
+
let depth = 0;
|
|
4121
|
+
let inStr = null;
|
|
4122
|
+
let esc = false;
|
|
4123
|
+
for (let i = 0; i < line.length; i++) {
|
|
4124
|
+
const c = line[i];
|
|
4125
|
+
if (inStr) {
|
|
4126
|
+
if (esc) {
|
|
4127
|
+
esc = false;
|
|
4128
|
+
continue;
|
|
4129
|
+
}
|
|
4130
|
+
if (c === '\\') {
|
|
4131
|
+
esc = true;
|
|
4132
|
+
continue;
|
|
4133
|
+
}
|
|
4134
|
+
if (c === inStr)
|
|
4135
|
+
inStr = null;
|
|
4136
|
+
continue;
|
|
4137
|
+
}
|
|
4138
|
+
if (c === '"' || c === "'") {
|
|
4139
|
+
inStr = c;
|
|
4140
|
+
continue;
|
|
4141
|
+
}
|
|
4142
|
+
if (c === '(' || c === '[')
|
|
4143
|
+
depth++;
|
|
4144
|
+
else if (c === ')' || c === ']')
|
|
4145
|
+
depth = Math.max(0, depth - 1);
|
|
4146
|
+
else if (c === ';' && depth === 0) {
|
|
4147
|
+
const part = line.slice(start, i + 1).trim();
|
|
4148
|
+
if (part)
|
|
4149
|
+
statements.push(part);
|
|
4150
|
+
start = i + 1;
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
const tail = line.slice(start).trim();
|
|
4154
|
+
if (tail)
|
|
4155
|
+
statements.push(tail);
|
|
4156
|
+
return statements.length > 1 ? statements : [line];
|
|
4157
|
+
};
|
|
4158
|
+
const boundInEarlierStatement = (code, variable, sinkMethod) => {
|
|
4159
|
+
if (!variable || variable === '<inline>' || !sinkMethod)
|
|
4160
|
+
return false;
|
|
4161
|
+
const stmts = splitCSharpDepth0Statements(code);
|
|
4162
|
+
if (stmts.length < 2)
|
|
4163
|
+
return false;
|
|
4164
|
+
const methodRe = new RegExp(`\\b${sinkMethod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
|
|
4165
|
+
let sinkIdx = -1;
|
|
4166
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
4167
|
+
if (methodRe.test(stmts[i])) {
|
|
4168
|
+
sinkIdx = i;
|
|
4169
|
+
break;
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
if (sinkIdx <= 0)
|
|
4173
|
+
return false;
|
|
4174
|
+
const varAssign = new RegExp(`\\b${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)`);
|
|
4175
|
+
for (let i = 0; i < sinkIdx; i++) {
|
|
4176
|
+
if (varAssign.test(stmts[i]))
|
|
4177
|
+
return true;
|
|
4178
|
+
}
|
|
4179
|
+
return false;
|
|
4180
|
+
};
|
|
2638
4181
|
// cognium-dev #83: inline-source colocation.
|
|
2639
4182
|
//
|
|
2640
4183
|
// When a source expression is used INLINE as a sink argument — e.g.
|
|
@@ -2723,6 +4266,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
|
|
|
2723
4266
|
if (new RegExp(`\\b${sourceVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(rhs)) {
|
|
2724
4267
|
continue;
|
|
2725
4268
|
}
|
|
4269
|
+
if (boundInEarlierStatement(sinkCode, sourceVar, sink.method)) {
|
|
4270
|
+
continue;
|
|
4271
|
+
}
|
|
2726
4272
|
}
|
|
2727
4273
|
// Skip the degenerate `file_input` → `path_traversal` colocation
|
|
2728
4274
|
// where the source and sink describe the SAME call (one being the
|