@sikkuumiii/defuse 0.0.0-stage → 0.5.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/LICENSE +235 -0
- package/README.md +561 -2
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.js +619 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/analyze.d.ts +178 -0
- package/dist/src/core/analyze.js +834 -0
- package/dist/src/core/analyze.js.map +1 -0
- package/dist/src/core/coverage.d.ts +87 -0
- package/dist/src/core/coverage.js +181 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/finding.d.ts +196 -0
- package/dist/src/core/finding.js +178 -0
- package/dist/src/core/finding.js.map +1 -0
- package/dist/src/core/licence.d.ts +50 -0
- package/dist/src/core/licence.js +56 -0
- package/dist/src/core/licence.js.map +1 -0
- package/dist/src/core/paths.d.ts +21 -0
- package/dist/src/core/paths.js +58 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/score.d.ts +137 -0
- package/dist/src/core/score.js +232 -0
- package/dist/src/core/score.js.map +1 -0
- package/dist/src/dast/targets.d.ts +30 -0
- package/dist/src/dast/targets.js +79 -0
- package/dist/src/dast/targets.js.map +1 -0
- package/dist/src/dast/zap.d.ts +42 -0
- package/dist/src/dast/zap.js +184 -0
- package/dist/src/dast/zap.js.map +1 -0
- package/dist/src/engine/scan.d.ts +80 -0
- package/dist/src/engine/scan.js +129 -0
- package/dist/src/engine/scan.js.map +1 -0
- package/dist/src/engine/shapes.d.ts +47 -0
- package/dist/src/engine/shapes.js +328 -0
- package/dist/src/engine/shapes.js.map +1 -0
- package/dist/src/engine/walk.d.ts +60 -0
- package/dist/src/engine/walk.js +156 -0
- package/dist/src/engine/walk.js.map +1 -0
- package/dist/src/parse/astDump.d.ts +31 -0
- package/dist/src/parse/astDump.js +66 -0
- package/dist/src/parse/astDump.js.map +1 -0
- package/dist/src/parse/languages.d.ts +70 -0
- package/dist/src/parse/languages.js +174 -0
- package/dist/src/parse/languages.js.map +1 -0
- package/dist/src/parse/location.d.ts +17 -0
- package/dist/src/parse/location.js +38 -0
- package/dist/src/parse/location.js.map +1 -0
- package/dist/src/parse/node-grammars.d.ts +12 -0
- package/dist/src/parse/node-grammars.js +35 -0
- package/dist/src/parse/node-grammars.js.map +1 -0
- package/dist/src/parse/parser.d.ts +126 -0
- package/dist/src/parse/parser.js +202 -0
- package/dist/src/parse/parser.js.map +1 -0
- package/dist/src/parse/tree-store.d.ts +139 -0
- package/dist/src/parse/tree-store.js +236 -0
- package/dist/src/parse/tree-store.js.map +1 -0
- package/dist/src/platform/dependency-rule.d.ts +27 -0
- package/dist/src/platform/dependency-rule.js +21 -0
- package/dist/src/platform/dependency-rule.js.map +1 -0
- package/dist/src/platform/deps.d.ts +86 -0
- package/dist/src/platform/deps.js +304 -0
- package/dist/src/platform/deps.js.map +1 -0
- package/dist/src/platform/php-facts.d.ts +49 -0
- package/dist/src/platform/php-facts.js +67 -0
- package/dist/src/platform/php-facts.js.map +1 -0
- package/dist/src/platform/runtime-version.d.ts +78 -0
- package/dist/src/platform/runtime-version.js +573 -0
- package/dist/src/platform/runtime-version.js.map +1 -0
- package/dist/src/report/colors.d.ts +50 -0
- package/dist/src/report/colors.js +97 -0
- package/dist/src/report/colors.js.map +1 -0
- package/dist/src/report/coverage-table.d.ts +90 -0
- package/dist/src/report/coverage-table.js +291 -0
- package/dist/src/report/coverage-table.js.map +1 -0
- package/dist/src/report/html-report.d.ts +66 -0
- package/dist/src/report/html-report.js +489 -0
- package/dist/src/report/html-report.js.map +1 -0
- package/dist/src/report/human.d.ts +44 -0
- package/dist/src/report/human.js +575 -0
- package/dist/src/report/human.js.map +1 -0
- package/dist/src/report/json.d.ts +24 -0
- package/dist/src/report/json.js +178 -0
- package/dist/src/report/json.js.map +1 -0
- package/dist/src/report/mark.d.ts +17 -0
- package/dist/src/report/mark.js +39 -0
- package/dist/src/report/mark.js.map +1 -0
- package/dist/src/report/pricing.d.ts +33 -0
- package/dist/src/report/pricing.js +343 -0
- package/dist/src/report/pricing.js.map +1 -0
- package/dist/src/report/sarif.d.ts +39 -0
- package/dist/src/report/sarif.js +219 -0
- package/dist/src/report/sarif.js.map +1 -0
- package/dist/src/report/site.d.ts +87 -0
- package/dist/src/report/site.js +742 -0
- package/dist/src/report/site.js.map +1 -0
- package/dist/src/rules/code-injection.d.ts +47 -0
- package/dist/src/rules/code-injection.js +326 -0
- package/dist/src/rules/code-injection.js.map +1 -0
- package/dist/src/rules/command-injection.d.ts +30 -0
- package/dist/src/rules/command-injection.js +286 -0
- package/dist/src/rules/command-injection.js.map +1 -0
- package/dist/src/rules/contract.d.ts +198 -0
- package/dist/src/rules/contract.js +39 -0
- package/dist/src/rules/contract.js.map +1 -0
- package/dist/src/rules/format-string.d.ts +46 -0
- package/dist/src/rules/format-string.js +146 -0
- package/dist/src/rules/format-string.js.map +1 -0
- package/dist/src/rules/hardcoded-secret.d.ts +38 -0
- package/dist/src/rules/hardcoded-secret.js +440 -0
- package/dist/src/rules/hardcoded-secret.js.map +1 -0
- package/dist/src/rules/lib/guards.d.ts +168 -0
- package/dist/src/rules/lib/guards.js +1179 -0
- package/dist/src/rules/lib/guards.js.map +1 -0
- package/dist/src/rules/lib/html-safety.d.ts +75 -0
- package/dist/src/rules/lib/html-safety.js +755 -0
- package/dist/src/rules/lib/html-safety.js.map +1 -0
- package/dist/src/rules/lib/local-build.d.ts +59 -0
- package/dist/src/rules/lib/local-build.js +320 -0
- package/dist/src/rules/lib/local-build.js.map +1 -0
- package/dist/src/rules/lib/strings.d.ts +208 -0
- package/dist/src/rules/lib/strings.js +1970 -0
- package/dist/src/rules/lib/strings.js.map +1 -0
- package/dist/src/rules/path-traversal.d.ts +33 -0
- package/dist/src/rules/path-traversal.js +96 -0
- package/dist/src/rules/path-traversal.js.map +1 -0
- package/dist/src/rules/registry.d.ts +19 -0
- package/dist/src/rules/registry.js +77 -0
- package/dist/src/rules/registry.js.map +1 -0
- package/dist/src/rules/sql-injection.d.ts +36 -0
- package/dist/src/rules/sql-injection.js +299 -0
- package/dist/src/rules/sql-injection.js.map +1 -0
- package/dist/src/rules/ssrf.d.ts +39 -0
- package/dist/src/rules/ssrf.js +111 -0
- package/dist/src/rules/ssrf.js.map +1 -0
- package/dist/src/rules/unbounded-copy.d.ts +37 -0
- package/dist/src/rules/unbounded-copy.js +147 -0
- package/dist/src/rules/unbounded-copy.js.map +1 -0
- package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
- package/dist/src/rules/unsafe-deserialization.js +276 -0
- package/dist/src/rules/unsafe-deserialization.js.map +1 -0
- package/dist/src/rules/weak-hash.d.ts +37 -0
- package/dist/src/rules/weak-hash.js +197 -0
- package/dist/src/rules/weak-hash.js.map +1 -0
- package/dist/src/rules/xss.d.ts +38 -0
- package/dist/src/rules/xss.js +527 -0
- package/dist/src/rules/xss.js.map +1 -0
- package/dist/src/taint/content-type.d.ts +19 -0
- package/dist/src/taint/content-type.js +20 -0
- package/dist/src/taint/content-type.js.map +1 -0
- package/dist/src/taint/dictionaries.d.ts +23 -0
- package/dist/src/taint/dictionaries.js +1778 -0
- package/dist/src/taint/dictionaries.js.map +1 -0
- package/dist/src/taint/fold.d.ts +59 -0
- package/dist/src/taint/fold.js +1177 -0
- package/dist/src/taint/fold.js.map +1 -0
- package/dist/src/taint/java-scope.d.ts +61 -0
- package/dist/src/taint/java-scope.js +212 -0
- package/dist/src/taint/java-scope.js.map +1 -0
- package/dist/src/taint/local-map.d.ts +79 -0
- package/dist/src/taint/local-map.js +603 -0
- package/dist/src/taint/local-map.js.map +1 -0
- package/dist/src/taint/project.d.ts +172 -0
- package/dist/src/taint/project.js +617 -0
- package/dist/src/taint/project.js.map +1 -0
- package/dist/src/taint/tracer.d.ts +265 -0
- package/dist/src/taint/tracer.js +3817 -0
- package/dist/src/taint/tracer.js.map +1 -0
- package/dist/src/taint/types.d.ts +484 -0
- package/dist/src/taint/types.js +47 -0
- package/dist/src/taint/types.js.map +1 -0
- package/dist/src/taint/visibility.d.ts +25 -0
- package/dist/src/taint/visibility.js +96 -0
- package/dist/src/taint/visibility.js.map +1 -0
- package/docs/examples/defuse.json +20 -0
- package/docs/examples/dvwa-urls.json +14 -0
- package/docs/examples/github-code-scanning.yml +54 -0
- package/docs/examples/tiny.js +4 -0
- package/package.json +73 -4
- package/src/cli.ts +747 -0
- package/src/core/analyze.ts +1072 -0
- package/src/core/coverage.ts +269 -0
- package/src/core/finding.ts +311 -0
- package/src/core/licence.ts +61 -0
- package/src/core/paths.ts +59 -0
- package/src/core/score.ts +353 -0
- package/src/dast/targets.ts +121 -0
- package/src/dast/zap.ts +246 -0
- package/src/engine/scan.ts +200 -0
- package/src/engine/shapes.ts +348 -0
- package/src/engine/walk.ts +196 -0
- package/src/parse/astDump.ts +85 -0
- package/src/parse/languages.ts +232 -0
- package/src/parse/location.ts +44 -0
- package/src/parse/node-grammars.ts +41 -0
- package/src/parse/parser.ts +262 -0
- package/src/parse/tree-store.ts +301 -0
- package/src/platform/dependency-rule.ts +33 -0
- package/src/platform/deps.ts +374 -0
- package/src/platform/php-facts.ts +85 -0
- package/src/platform/runtime-version.ts +710 -0
- package/src/report/colors.ts +107 -0
- package/src/report/coverage-table.ts +359 -0
- package/src/report/html-report.ts +601 -0
- package/src/report/human.ts +811 -0
- package/src/report/json.ts +189 -0
- package/src/report/mark.ts +47 -0
- package/src/report/pricing.ts +374 -0
- package/src/report/sarif.ts +235 -0
- package/src/report/site.ts +760 -0
- package/src/rules/code-injection.ts +346 -0
- package/src/rules/command-injection.ts +296 -0
- package/src/rules/contract.ts +220 -0
- package/src/rules/format-string.ts +158 -0
- package/src/rules/hardcoded-secret.ts +485 -0
- package/src/rules/lib/guards.ts +1235 -0
- package/src/rules/lib/html-safety.ts +793 -0
- package/src/rules/lib/local-build.ts +329 -0
- package/src/rules/lib/strings.ts +2034 -0
- package/src/rules/path-traversal.ts +101 -0
- package/src/rules/registry.ts +90 -0
- package/src/rules/sql-injection.ts +311 -0
- package/src/rules/ssrf.ts +118 -0
- package/src/rules/unbounded-copy.ts +162 -0
- package/src/rules/unsafe-deserialization.ts +291 -0
- package/src/rules/weak-hash.ts +226 -0
- package/src/rules/xss.ts +572 -0
- package/src/taint/content-type.ts +20 -0
- package/src/taint/dictionaries.ts +1855 -0
- package/src/taint/fold.ts +1206 -0
- package/src/taint/java-scope.ts +200 -0
- package/src/taint/local-map.ts +594 -0
- package/src/taint/project.ts +774 -0
- package/src/taint/tracer.ts +4298 -0
- package/src/taint/types.ts +511 -0
- package/src/taint/visibility.ts +100 -0
- package/tsconfig.json +61 -0
|
@@ -0,0 +1,3817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TAINT TRACER
|
|
3
|
+
* ================
|
|
4
|
+
*
|
|
5
|
+
* This is the engine that turns "signature-based" into "flow-verified".
|
|
6
|
+
*
|
|
7
|
+
* HOW IT WORKS, in one paragraph:
|
|
8
|
+
* We read each function body top to bottom, keeping a notebook of which
|
|
9
|
+
* variables currently hold dirty data. When a line assigns something, we work
|
|
10
|
+
* out whether the right-hand side is dirty and write that in the notebook. When
|
|
11
|
+
* a line calls something dangerous, we look up its arguments in the notebook.
|
|
12
|
+
* If a dirty value reaches a sink and no sanitiser washed it on the way, we can
|
|
13
|
+
* finally say the thing a pattern match never could: *this specific value, from
|
|
14
|
+
* this specific source, reaches this specific sink.* And we can show every hop.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT DELIBERATELY DOES NOT DO - each of these is a documented gap, not an
|
|
17
|
+
* oversight, and each is printed in the scan report:
|
|
18
|
+
*
|
|
19
|
+
* 1. BOUNDED REACH. Functions defined in this file are followed, and so are
|
|
20
|
+
* functions in another file when a relative import resolves to it. What
|
|
21
|
+
* still ends a trace: a call into a third-party package, a dynamic or
|
|
22
|
+
* aliased import, a name several modules define (we decline rather than
|
|
23
|
+
* guess), and any chain deeper than four calls. A cut at that depth, or at
|
|
24
|
+
* a call that looks recursive, does NOT end the value: it is carried past
|
|
25
|
+
* the unread call exactly as in 3 below, with the reason for the stop
|
|
26
|
+
* written into the path.
|
|
27
|
+
*
|
|
28
|
+
* 2. STATEMENT ORDER, NOT BRANCH LOGIC. We read lines in order and ignore
|
|
29
|
+
* whether an `if` is true. So a value cleaned only inside `if (isAdmin)`
|
|
30
|
+
* is treated as cleaned afterwards. That direction of error loses findings
|
|
31
|
+
* rather than inventing them, which is the right way to be wrong.
|
|
32
|
+
*
|
|
33
|
+
* 3. UNKNOWN FUNCTIONS CARRY TAINT, AND SAY SO. `foo(dirty)` keeps the taint
|
|
34
|
+
* and records the hop as UNMODELLED, naming `foo` on the finding. Treating
|
|
35
|
+
* unknowns as clean was tried first and produced ZERO verified flows across
|
|
36
|
+
* 2,766 Java files, every one a known vulnerability - so the quiet option
|
|
37
|
+
* was not the honest one. This is the weakest link in any path we print,
|
|
38
|
+
* which is exactly why it is printed rather than smoothed over.
|
|
39
|
+
*
|
|
40
|
+
* 4. CONTAINERS ARE TAINTED WHOLE. `list.add(dirty)` dirties the list, not
|
|
41
|
+
* the slot - so a clean element read back out of that list is treated as
|
|
42
|
+
* dirty too. Following values into objects at all requires this trade; the
|
|
43
|
+
* alternative was the previous behaviour, which was to lose the value
|
|
44
|
+
* entirely the moment it entered a StringBuilder or a List.
|
|
45
|
+
*
|
|
46
|
+
* 5. NO LOOP FIXPOINT. A variable that becomes dirty at the bottom of a loop
|
|
47
|
+
* and is used at the top is missed on the first pass. Real analysers
|
|
48
|
+
* iterate to a fixpoint; we do not, yet.
|
|
49
|
+
*
|
|
50
|
+
* Every one of those makes the tool MISS things (false negatives). None of them
|
|
51
|
+
* makes it invent things. That asymmetry is deliberate: a flow-verified label is
|
|
52
|
+
* a promise, and a promise that is sometimes wrong is worth less than no promise
|
|
53
|
+
* at all.
|
|
54
|
+
*/
|
|
55
|
+
import { describeFact, guardClears, guardFactsAt, guardsApply, normalizeExpression } from '../rules/lib/guards.js';
|
|
56
|
+
import { nodeLocation } from '../parse/location.js';
|
|
57
|
+
import { analyzeStringExpression, looksLikeHtml, looksLikeSql } from '../rules/lib/strings.js';
|
|
58
|
+
import { TAINT_DICTIONARIES } from './dictionaries.js';
|
|
59
|
+
import { ASSIGNMENT_NODES, SWITCH_NODES, assignmentParts, createFolder } from './fold.js';
|
|
60
|
+
import { declaredImmutable, enclosingMethod, enclosingPythonFunction, mentionedBetween, resolveContainerRead, writtenBetween } from './local-map.js';
|
|
61
|
+
import { SINK_KIND_RULE } from './types.js';
|
|
62
|
+
/** A fresh wash for these kinds: nothing has been taken on trust since. */
|
|
63
|
+
function washedNow(prior, kinds) {
|
|
64
|
+
const next = { ...(prior ?? {}) };
|
|
65
|
+
for (const k of kinds)
|
|
66
|
+
next[k] = [];
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
/** An unmodelled function the value just went through, after every wash it carries. */
|
|
70
|
+
function trustedHop(prior, name) {
|
|
71
|
+
if (!prior)
|
|
72
|
+
return undefined;
|
|
73
|
+
const next = {};
|
|
74
|
+
for (const [k, names] of Object.entries(prior)) {
|
|
75
|
+
next[k] = names.includes(name) ? names : [...names, name];
|
|
76
|
+
}
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Values joined into one: for each kind ALL of them are washed for, every
|
|
81
|
+
* function any of them took on trust. A part that did not track it leaves
|
|
82
|
+
* the kind untracked, so the receipt falls back to the cautious list.
|
|
83
|
+
*/
|
|
84
|
+
function joinedAfterWash(parts, kinds) {
|
|
85
|
+
const next = {};
|
|
86
|
+
for (const k of kinds) {
|
|
87
|
+
const lists = parts.map((p) => p.afterWash?.[k]);
|
|
88
|
+
if (lists.some((l) => l === undefined))
|
|
89
|
+
continue;
|
|
90
|
+
next[k] = [...new Set(lists.flatMap((l) => l ?? []))];
|
|
91
|
+
}
|
|
92
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
93
|
+
}
|
|
94
|
+
/** How a clean part of a string is written in a urlPrefix. */
|
|
95
|
+
export const CLEAN_PART = '\u0001';
|
|
96
|
+
/**
|
|
97
|
+
* Does the text before a value pin the destination of the address it is part
|
|
98
|
+
* of? Only when a path, query or fragment has already begun - after a host
|
|
99
|
+
* (`https://api.example.com/`), after a clean base (`${base}/`), or in a
|
|
100
|
+
* relative path (`./`, `../`, `/api/`, `docs/`).
|
|
101
|
+
*
|
|
102
|
+
* NOT pinned, and each is a real SSRF shape: nothing before the value; a
|
|
103
|
+
* scheme with the host still open (`https://`); a host with no slash after it
|
|
104
|
+
* (`https://api.example.com` + `@evil.example`); a clean base with nothing
|
|
105
|
+
* after it, which may itself end without a slash; `https:///`, where URL
|
|
106
|
+
* parsers skip the extra slash and the value becomes the host.
|
|
107
|
+
*
|
|
108
|
+
* One shape it cannot see: a clean base holding only a scheme with no slashes
|
|
109
|
+
* (`https:`) followed by `/` and the value - parsers read `https:/evil` as
|
|
110
|
+
* host `evil`. A configuration value shaped like that is rare enough to
|
|
111
|
+
* accept, and it is written down here rather than discovered later.
|
|
112
|
+
*/
|
|
113
|
+
export function destinationPinned(before) {
|
|
114
|
+
if (before === '')
|
|
115
|
+
return false;
|
|
116
|
+
const scheme = before.indexOf('://');
|
|
117
|
+
if (scheme < 0 && !before.startsWith('//') && /^(\.{1,2}[/\\]|\/(?!\/))/.test(before))
|
|
118
|
+
return true;
|
|
119
|
+
const rest = scheme >= 0 ? before.slice(scheme + 3) : before.startsWith('//') ? before.slice(2) : before;
|
|
120
|
+
return rest.search(/[/?#]/) > 0;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The text a string-building expression contributes, clean parts written as
|
|
124
|
+
* CLEAN_PART. Like literalTextOf, except a variable or call is not nothing -
|
|
125
|
+
* it is SOMETHING, unknown but not the attacker's, and where it sits matters.
|
|
126
|
+
*/
|
|
127
|
+
function shapeOf(node) {
|
|
128
|
+
if (!node)
|
|
129
|
+
return '';
|
|
130
|
+
if (STRING_NODES.has(node.type)) {
|
|
131
|
+
const content = stringContent(node.text);
|
|
132
|
+
const splices = node.namedChildren.filter((c) => c !== null && (INTERPOLATION_NODES.has(c.type) || VARIABLE_NODES.has(c.type) || MEMBER_NODES.has(c.type)));
|
|
133
|
+
if (splices.length === 0)
|
|
134
|
+
return content;
|
|
135
|
+
const last = splices[splices.length - 1];
|
|
136
|
+
return last ? templateShapeBefore(node, last) + CLEAN_PART + afterSplice(node, last) : content;
|
|
137
|
+
}
|
|
138
|
+
if (node.type === 'parenthesized_expression') {
|
|
139
|
+
return shapeOf(node.namedChildren.find((c) => c !== null) ?? null);
|
|
140
|
+
}
|
|
141
|
+
if (node.type === 'binary_expression' || node.type === 'binary_operator') {
|
|
142
|
+
const operator = node.childForFieldName('operator')?.text ?? '';
|
|
143
|
+
if (operator === '+' || operator === '.') {
|
|
144
|
+
return shapeOf(node.childForFieldName('left')) + shapeOf(node.childForFieldName('right'));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return CLEAN_PART;
|
|
148
|
+
}
|
|
149
|
+
/** The literal text of a template after a splice, up to its closing delimiter. */
|
|
150
|
+
function afterSplice(template, splice) {
|
|
151
|
+
const from = splice.endIndex - template.startIndex;
|
|
152
|
+
const tail = template.text.slice(from);
|
|
153
|
+
const close = template.text.match(/["'`]+$/)?.[0]?.length ?? 0;
|
|
154
|
+
// PHP's closing brace of `"{$x}"`, the twin of the one templateShapeBefore drops.
|
|
155
|
+
return tail.slice(0, Math.max(0, tail.length - close)).replace(/^\}/, '');
|
|
156
|
+
}
|
|
157
|
+
/** `a.b(x, y).c(z).d` -> `a.b().c().d`: a callee chain with every argument list emptied. */
|
|
158
|
+
function withoutCallArguments(callee) {
|
|
159
|
+
let out = callee;
|
|
160
|
+
for (let i = 0; i < 8; i++) {
|
|
161
|
+
const next = out.replace(/\((?:[^()]|\(\))*\)/g, '()');
|
|
162
|
+
if (next === out)
|
|
163
|
+
break;
|
|
164
|
+
out = next;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Whether an SSRF flow can send the request where the attacker chooses. See
|
|
170
|
+
* FlowResult.fixedDestination and destinationPinned.
|
|
171
|
+
*/
|
|
172
|
+
function fixedDestinationOf(taint, kind) {
|
|
173
|
+
if (kind !== 'ssrf')
|
|
174
|
+
return undefined;
|
|
175
|
+
if (taint.fromBrowser)
|
|
176
|
+
return { kind: 'browser', before: taint.urlPrefix ?? '' };
|
|
177
|
+
if (taint.urlPrefix !== undefined && destinationPinned(taint.urlPrefix)) {
|
|
178
|
+
return { kind: 'path', before: taint.urlPrefix };
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* What precedes one splice inside a template literal or interpolated string:
|
|
184
|
+
* its literal text, with every earlier splice written as CLEAN_PART (the
|
|
185
|
+
* first dirty splice is the one being placed, so the earlier ones are clean).
|
|
186
|
+
*/
|
|
187
|
+
function templateShapeBefore(template, splice) {
|
|
188
|
+
let out = '';
|
|
189
|
+
let cursor = 0;
|
|
190
|
+
const head = afterOpening(template.text);
|
|
191
|
+
const opening = template.text.length - head.length;
|
|
192
|
+
for (const child of template.namedChildren) {
|
|
193
|
+
if (!child)
|
|
194
|
+
continue;
|
|
195
|
+
if (child.startIndex >= splice.startIndex)
|
|
196
|
+
break;
|
|
197
|
+
if (!(INTERPOLATION_NODES.has(child.type) || VARIABLE_NODES.has(child.type) || MEMBER_NODES.has(child.type)))
|
|
198
|
+
continue;
|
|
199
|
+
const start = child.startIndex - template.startIndex - opening;
|
|
200
|
+
if (start < cursor)
|
|
201
|
+
continue;
|
|
202
|
+
out += head.slice(cursor, start) + CLEAN_PART;
|
|
203
|
+
cursor = child.endIndex - template.startIndex - opening;
|
|
204
|
+
}
|
|
205
|
+
// PHP's `"{$x}"` keeps its brace in the literal text; it is syntax, not part of the address.
|
|
206
|
+
return (out + head.slice(cursor, splice.startIndex - template.startIndex - opening)).replace(/\{$/, '');
|
|
207
|
+
}
|
|
208
|
+
/* -------------------------------------------------------------------------- *
|
|
209
|
+
* Language-specific node vocabulary. Small enough to keep here rather than in
|
|
210
|
+
* the dictionaries, because it describes the GRAMMAR, not a framework.
|
|
211
|
+
* -------------------------------------------------------------------------- */
|
|
212
|
+
const FUNCTION_NODES = {
|
|
213
|
+
javascript: [
|
|
214
|
+
'function_declaration', 'function_expression', 'arrow_function',
|
|
215
|
+
'method_definition', 'generator_function_declaration', 'generator_function',
|
|
216
|
+
],
|
|
217
|
+
typescript: [
|
|
218
|
+
'function_declaration', 'function_expression', 'arrow_function',
|
|
219
|
+
'method_definition', 'generator_function_declaration', 'generator_function',
|
|
220
|
+
],
|
|
221
|
+
python: ['function_definition', 'lambda'],
|
|
222
|
+
/*
|
|
223
|
+
* C nests the name one level down: a function_definition has a `declarator`
|
|
224
|
+
* (the function_declarator) which in turn has the `declarator` that is the
|
|
225
|
+
* identifier. There is no `name` field anywhere on it, which is why
|
|
226
|
+
* functionNameOf() below exists - without it collectFunctions found zero C
|
|
227
|
+
* functions and every cross-function trace in the language stopped dead at
|
|
228
|
+
* the call, silently.
|
|
229
|
+
*/
|
|
230
|
+
c: ['function_definition'],
|
|
231
|
+
cpp: ['function_definition', 'lambda_expression'],
|
|
232
|
+
java: ['method_declaration', 'constructor_declaration', 'lambda_expression'],
|
|
233
|
+
go: ['function_declaration', 'method_declaration', 'func_literal'],
|
|
234
|
+
php: [
|
|
235
|
+
'function_definition',
|
|
236
|
+
'method_declaration',
|
|
237
|
+
'anonymous_function_creation_expression',
|
|
238
|
+
'arrow_function',
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Node types that ARE a reference to a variable by name.
|
|
243
|
+
*
|
|
244
|
+
* This started as a bare `node.type === 'identifier'` check, which is right for
|
|
245
|
+
* four of the five languages and silently wrong for PHP: `$x` parses as
|
|
246
|
+
* `variable_name`, so every PHP variable read fell through to "not a variable",
|
|
247
|
+
* every binding was ignored, and taint died at the first assignment. Direct
|
|
248
|
+
* source-to-sink flows still verified, which is the dangerous kind of bug -
|
|
249
|
+
* it looked like the language worked.
|
|
250
|
+
*/
|
|
251
|
+
const VARIABLE_NODES = new Set(['identifier', 'variable_name']);
|
|
252
|
+
/**
|
|
253
|
+
* Assignment targets that bind SEVERAL names at once.
|
|
254
|
+
*
|
|
255
|
+
* const { syncUrl } = req.body;
|
|
256
|
+
* const [first] = request.args.getlist("x");
|
|
257
|
+
*
|
|
258
|
+
* The tracer read the whole pattern as one name - literally the string
|
|
259
|
+
* "{ syncUrl }" - which matches nothing anyone ever looks up, so the taint was
|
|
260
|
+
* recorded under a key no read could find and every destructured request field
|
|
261
|
+
* came out clean.
|
|
262
|
+
*
|
|
263
|
+
* This is not an exotic shape. It is how modern JavaScript and TypeScript read
|
|
264
|
+
* request fields, and it silently defeated every rule at once: a Gemini-written
|
|
265
|
+
* Express app had `const { syncUrl } = req.body` two lines above an SSRF, and
|
|
266
|
+
* the trace died at the brace.
|
|
267
|
+
*
|
|
268
|
+
* Every name in the pattern is bound to the taint of the whole right-hand side.
|
|
269
|
+
* That is the container rule again - if the object is dirty, everything read
|
|
270
|
+
* out of it is treated as dirty - and it errs toward reporting.
|
|
271
|
+
*/
|
|
272
|
+
const DESTRUCTURING_NODES = new Set([
|
|
273
|
+
'object_pattern', // JS/TS const { a, b } = x
|
|
274
|
+
'array_pattern', // JS/TS const [a, b] = x
|
|
275
|
+
'pattern_list', // Python a, b = x
|
|
276
|
+
'tuple_pattern',
|
|
277
|
+
'list_pattern',
|
|
278
|
+
]);
|
|
279
|
+
/** Every plain name a destructuring pattern introduces, however nested. */
|
|
280
|
+
function namesBoundBy(pattern) {
|
|
281
|
+
const names = [];
|
|
282
|
+
const visit = (node, depth) => {
|
|
283
|
+
if (depth > 6)
|
|
284
|
+
return;
|
|
285
|
+
for (const child of node.namedChildren) {
|
|
286
|
+
if (!child)
|
|
287
|
+
continue;
|
|
288
|
+
// `{ a: renamed }` - the NAME the body will use is on the right.
|
|
289
|
+
if (child.type === 'pair_pattern' || child.type === 'object_assignment_pattern') {
|
|
290
|
+
const value = child.childForFieldName('value') ?? child.namedChildren[1] ?? null;
|
|
291
|
+
if (value)
|
|
292
|
+
visit(value.type === 'identifier' ? child : value, depth + 1);
|
|
293
|
+
if (value && VARIABLE_NODES.has(value.type))
|
|
294
|
+
names.push(text(value));
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (VARIABLE_NODES.has(child.type) || child.type === 'shorthand_property_identifier_pattern') {
|
|
298
|
+
names.push(text(child));
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
visit(child, depth + 1);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
visit(pattern, 0);
|
|
305
|
+
return names;
|
|
306
|
+
}
|
|
307
|
+
const UNWRAP_NODES = new Set([
|
|
308
|
+
'parenthesized_expression',
|
|
309
|
+
'await_expression',
|
|
310
|
+
'as_expression',
|
|
311
|
+
'satisfies_expression',
|
|
312
|
+
'non_null_expression',
|
|
313
|
+
'type_assertion',
|
|
314
|
+
'expression_statement',
|
|
315
|
+
/*
|
|
316
|
+
* `(String) map.get("keyB")` - a Java cast.
|
|
317
|
+
*
|
|
318
|
+
* TypeScript's three ways of saying the same thing were all here from the
|
|
319
|
+
* start; Java's was not, and the omission cost more than every cross-file
|
|
320
|
+
* idea tried this session put together. A cast changes what the compiler
|
|
321
|
+
* calls a value. It does not change the value, so it cannot change whether
|
|
322
|
+
* an attacker chose it.
|
|
323
|
+
*
|
|
324
|
+
* Found by a ten-line fixture that put a tainted value into a HashMap three
|
|
325
|
+
* different ways. Two came out traced; the one with a cast in front did not.
|
|
326
|
+
* Written before touching the engine, on purpose - the hypothesis it killed
|
|
327
|
+
* ("the container machinery is broken") was the third wrong guess in a row
|
|
328
|
+
* that a benchmark run alone would not have corrected.
|
|
329
|
+
*/
|
|
330
|
+
'cast_expression', // Java, C-style casts
|
|
331
|
+
]);
|
|
332
|
+
/**
|
|
333
|
+
* Wrappers whose FIRST named child is the type and whose LAST is the value.
|
|
334
|
+
*
|
|
335
|
+
* Everything else in UNWRAP_NODES puts the value first, which is why grabbing
|
|
336
|
+
* child 0 worked for years before a Java cast walked in. This is the third
|
|
337
|
+
* time the same shape has turned up wearing different clothes:
|
|
338
|
+
*
|
|
339
|
+
* (String) map.get("k") cast_expression [type, value]
|
|
340
|
+
* <string>req.query.path type_assertion [type, value]
|
|
341
|
+
* req.query.path as string as_expression [value, type] <- NOT here
|
|
342
|
+
*
|
|
343
|
+
* The first two look nothing alike and parse identically. The third looks
|
|
344
|
+
* almost the same as the second and parses backwards, so it must stay on the
|
|
345
|
+
* first-child path - which is the whole reason this is a set of node types
|
|
346
|
+
* and not a guess about which spelling means a cast.
|
|
347
|
+
*
|
|
348
|
+
* `type_assertion` was in UNWRAP_NODES from the beginning and still did not
|
|
349
|
+
* work, because being listed as a wrapper only says the node should be seen
|
|
350
|
+
* through; it says nothing about which side the value is on. The tracer
|
|
351
|
+
* solemnly evaluated the word `string` and reported the value clean. Found by
|
|
352
|
+
* ts-only-syntax.ts, which was written to falsify the claim that TypeScript
|
|
353
|
+
* behaves "identically to JavaScript" - and did, on the first run.
|
|
354
|
+
*/
|
|
355
|
+
const TYPE_FIRST_WRAPPERS = new Set(['cast_expression', 'type_assertion']);
|
|
356
|
+
/**
|
|
357
|
+
* Binary operators whose result can still be attacker-chosen.
|
|
358
|
+
*
|
|
359
|
+
* Deliberately a short list. Every operator NOT here - `==`, `<`, `instanceof`,
|
|
360
|
+
* `-`, `*` - produces a value whose shape the attacker no longer controls, and
|
|
361
|
+
* carrying taint across those would manufacture flows that do not exist.
|
|
362
|
+
* Arithmetic is the clearest case: whatever `dirty - 1` is, it is a number, and
|
|
363
|
+
* a number cannot be a shell command.
|
|
364
|
+
*/
|
|
365
|
+
const BINARY_CARRIERS = new Set([
|
|
366
|
+
'+', // concatenation in every language here, and addition
|
|
367
|
+
'%', // Python's old-style format string
|
|
368
|
+
'.', // PHP concatenation
|
|
369
|
+
'??', // JS/TS nullish coalescing
|
|
370
|
+
'||', // returns the left operand when it is truthy
|
|
371
|
+
'&&', // returns the left operand when it is falsy
|
|
372
|
+
]);
|
|
373
|
+
const MEMBER_NODES = new Set([
|
|
374
|
+
'member_expression', // JS/TS a.b
|
|
375
|
+
'attribute', // Python a.b
|
|
376
|
+
'selector_expression', // Go a.b
|
|
377
|
+
'field_access', // Java a.b
|
|
378
|
+
'subscript_expression',
|
|
379
|
+
'subscript',
|
|
380
|
+
'index_expression',
|
|
381
|
+
'array_access', // Java a[i]
|
|
382
|
+
/*
|
|
383
|
+
* C and C++: `obj.field`, `ptr->field`, and `s.c_str`.
|
|
384
|
+
*
|
|
385
|
+
* Its receiver sits in the `argument` field rather than `object`, which is
|
|
386
|
+
* why the lookup below tries several names. Without this entry `s.c_str()`
|
|
387
|
+
* had no receiver at all, so a tainted std::string went clean the moment
|
|
388
|
+
* anybody called the one method that gets the bytes out of it - which is the
|
|
389
|
+
* only way a std::string ever reaches system().
|
|
390
|
+
*/
|
|
391
|
+
'field_expression',
|
|
392
|
+
]);
|
|
393
|
+
/** Assignment targets that write ONE element of a container: `m['k']`, `a[i]`, `$a['k']`. */
|
|
394
|
+
const ELEMENT_TARGETS = new Set(['subscript', 'subscript_expression', 'index_expression', 'array_access']);
|
|
395
|
+
/** Container literals: `{...}`, `[...]`, `array(...)`, Java `{a, b}`. */
|
|
396
|
+
const CONTAINER_LITERALS = new Set(['dictionary', 'list', 'array', 'object', 'array_creation_expression', 'array_initializer', 'composite_literal']);
|
|
397
|
+
/**
|
|
398
|
+
* Calls that COMPILE a query and hand back a statement handle.
|
|
399
|
+
*
|
|
400
|
+
* After `stmt, err := db.Prepare(sql)`, the arguments to `stmt.QueryRow(uid)`
|
|
401
|
+
* are bound parameters - the database keeps them separate from the
|
|
402
|
+
* instruction, which is the whole point of preparing. Reporting them as
|
|
403
|
+
* injection punishes the correct fix, which is the worst thing a SQL rule can
|
|
404
|
+
* do. So we remember which local names hold a prepared statement.
|
|
405
|
+
*
|
|
406
|
+
* Note what this does NOT excuse: `db.Prepare(dynamicSql)` is still a sink,
|
|
407
|
+
* because a query assembled from user input is dangerous whether or not it is
|
|
408
|
+
* prepared afterwards.
|
|
409
|
+
*/
|
|
410
|
+
const PREPARE_CALLS = new Set([
|
|
411
|
+
'Prepare',
|
|
412
|
+
'PrepareContext',
|
|
413
|
+
'Preparex',
|
|
414
|
+
'PrepareNamed',
|
|
415
|
+
'prepareStatement',
|
|
416
|
+
'prepareCall',
|
|
417
|
+
]);
|
|
418
|
+
const CALL_NODES = new Set([
|
|
419
|
+
'call_expression',
|
|
420
|
+
'call',
|
|
421
|
+
'new_expression',
|
|
422
|
+
'method_invocation', // Java
|
|
423
|
+
'object_creation_expression', // Java new Foo(...)
|
|
424
|
+
'function_call_expression', // PHP foo(...)
|
|
425
|
+
'member_call_expression', // PHP $obj->foo(...)
|
|
426
|
+
'scoped_call_expression', // PHP Foo::bar(...)
|
|
427
|
+
]);
|
|
428
|
+
const STRING_NODES = new Set([
|
|
429
|
+
'string',
|
|
430
|
+
'template_string',
|
|
431
|
+
'concatenated_string',
|
|
432
|
+
'string_literal', // Java
|
|
433
|
+
'text_block', // Java
|
|
434
|
+
'interpreted_string_literal', // Go
|
|
435
|
+
'raw_string_literal', // Go
|
|
436
|
+
'encapsed_string', // PHP "text $var"
|
|
437
|
+
'heredoc', // PHP
|
|
438
|
+
]);
|
|
439
|
+
const INTERPOLATION_NODES = new Set(['template_substitution', 'interpolation']);
|
|
440
|
+
/**
|
|
441
|
+
* A string literal's text without its prefix and delimiters: `f"..."`,
|
|
442
|
+
* `'...'`, `"""..."""`, and a PHP heredoc's first and last lines.
|
|
443
|
+
*/
|
|
444
|
+
function stringContent(raw) {
|
|
445
|
+
if (raw.startsWith('<<<')) {
|
|
446
|
+
const lines = raw.split('\n');
|
|
447
|
+
return lines.slice(1, -1).join('\n');
|
|
448
|
+
}
|
|
449
|
+
const open = raw.search(/["'`]/);
|
|
450
|
+
if (open === -1)
|
|
451
|
+
return raw;
|
|
452
|
+
const quote = raw[open];
|
|
453
|
+
const width = raw.startsWith(quote.repeat(3), open) ? 3 : 1;
|
|
454
|
+
const end = raw.length - width >= open + width ? raw.length - width : raw.length;
|
|
455
|
+
return raw.slice(open + width, end);
|
|
456
|
+
}
|
|
457
|
+
/** The text of a string literal from just after its opening delimiter - for a prefix ending mid-string. */
|
|
458
|
+
function afterOpening(raw) {
|
|
459
|
+
if (raw.startsWith('<<<')) {
|
|
460
|
+
const newline = raw.indexOf('\n');
|
|
461
|
+
return newline === -1 ? '' : raw.slice(newline + 1);
|
|
462
|
+
}
|
|
463
|
+
const open = raw.search(/["'`]/);
|
|
464
|
+
if (open === -1)
|
|
465
|
+
return raw;
|
|
466
|
+
const quote = raw[open];
|
|
467
|
+
const width = raw.startsWith(quote.repeat(3), open) ? 3 : 1;
|
|
468
|
+
return raw.slice(open + width);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The literal text a string-building expression contributes, in order. Parts
|
|
472
|
+
* that are not literals - variables, calls - contribute nothing, which is the
|
|
473
|
+
* cautious reading: a quote this engine cannot see is not counted as there.
|
|
474
|
+
*/
|
|
475
|
+
function literalTextOf(node) {
|
|
476
|
+
if (!node)
|
|
477
|
+
return '';
|
|
478
|
+
if (STRING_NODES.has(node.type))
|
|
479
|
+
return stringContent(node.text);
|
|
480
|
+
if (node.type === 'parenthesized_expression') {
|
|
481
|
+
return literalTextOf(node.namedChildren.find((c) => c !== null) ?? null);
|
|
482
|
+
}
|
|
483
|
+
if (node.type === 'binary_expression' || node.type === 'binary_operator') {
|
|
484
|
+
const operator = node.childForFieldName('operator')?.text ?? '';
|
|
485
|
+
if (operator !== '+' && operator !== '.')
|
|
486
|
+
return '';
|
|
487
|
+
return literalTextOf(node.childForFieldName('left')) + literalTextOf(node.childForFieldName('right'));
|
|
488
|
+
}
|
|
489
|
+
return '';
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Does a value spliced in right after this text sit inside a quoted string?
|
|
493
|
+
* An odd number of single or double quotes before it says it does. A backtick
|
|
494
|
+
* is NOT counted: it quotes an identifier, and escaping does not touch it.
|
|
495
|
+
*/
|
|
496
|
+
function opensAQuote(textBefore) {
|
|
497
|
+
const singles = (textBefore.match(/'/g) ?? []).length;
|
|
498
|
+
const doubles = (textBefore.match(/"/g) ?? []).length;
|
|
499
|
+
return singles % 2 === 1 || doubles % 2 === 1;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Nodes that BUILD a container out of listed elements.
|
|
503
|
+
*
|
|
504
|
+
* `Object[] obj = {"a", bar};` then `writer.format(fmt, obj)` is one of the
|
|
505
|
+
* commonest real XSS shapes in Java, and the trace used to die at the brace:
|
|
506
|
+
* `bar` was tainted, `obj` was not, and the sink saw a clean array.
|
|
507
|
+
*
|
|
508
|
+
* A container is tainted WHOLE, exactly as ENGINE_CAPABILITIES already says for
|
|
509
|
+
* `list.add(dirty)`. That is imprecise - element 0 here is the literal "a" and
|
|
510
|
+
* is perfectly clean - but a container holding one dirty element is a dirty
|
|
511
|
+
* container for every purpose a sink cares about, and erring the other way
|
|
512
|
+
* loses the finding entirely.
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* Nodes whose body may or may not run.
|
|
516
|
+
*
|
|
517
|
+
* Used for one narrow decision: whether a CLEAN assignment is allowed to erase
|
|
518
|
+
* a variable's taint. Consider the shape that dominates the OWASP benchmark:
|
|
519
|
+
*
|
|
520
|
+
* if ((500 / 42) + num > 200) bar = param; // param is attacker input
|
|
521
|
+
* else bar = "This should never happen";
|
|
522
|
+
*
|
|
523
|
+
* Reading statements in order, the else branch is simply the last write, so
|
|
524
|
+
* `bar` came out clean and the finding vanished - even though the two branches
|
|
525
|
+
* are mutually exclusive and one of them hands the attacker's value straight
|
|
526
|
+
* to the page.
|
|
527
|
+
*
|
|
528
|
+
* We do not evaluate the condition; that is a different and much larger piece
|
|
529
|
+
* of work. We make the weaker claim that a conditional write cannot PROVE a
|
|
530
|
+
* value clean. So taint set anywhere survives a clean write inside a branch,
|
|
531
|
+
* while an unconditional write still clears it, because that one really does
|
|
532
|
+
* happen every time.
|
|
533
|
+
*
|
|
534
|
+
* The direction matters: this errs toward reporting a value that might be
|
|
535
|
+
* clean, rather than staying silent about one that might be dirty.
|
|
536
|
+
*/
|
|
537
|
+
/**
|
|
538
|
+
* The loop variable and the collection of a for-each, per grammar.
|
|
539
|
+
*
|
|
540
|
+
* Every language spells this differently and two of them hide the parts inside
|
|
541
|
+
* an intermediate node, which is why a single childForFieldName() call would
|
|
542
|
+
* have worked for Java and quietly returned nothing for the rest - the same
|
|
543
|
+
* shape of mistake as the if-statement's `consequence` vs `body`, recorded in
|
|
544
|
+
* rules/lib/guards.ts.
|
|
545
|
+
*/
|
|
546
|
+
const FOREACH_NODES = new Set([
|
|
547
|
+
'enhanced_for_statement', // Java for (String v : values)
|
|
548
|
+
'for_in_statement', // JS/TS for (const v of values)
|
|
549
|
+
'for_statement', // Python for v in values (also Go's plain for)
|
|
550
|
+
'for_range_loop', // Go for _, v := range values
|
|
551
|
+
'foreach_statement', // PHP foreach ($values as $v)
|
|
552
|
+
]);
|
|
553
|
+
function foreachBinding(node) {
|
|
554
|
+
// Java: `name` and `value` fields. JS/TS and Python: `left` and `right`.
|
|
555
|
+
const name = node.childForFieldName('name') ?? node.childForFieldName('left');
|
|
556
|
+
const iterable = node.childForFieldName('value') ?? node.childForFieldName('right');
|
|
557
|
+
if (name && iterable)
|
|
558
|
+
return { name, iterable };
|
|
559
|
+
// Go hides both inside a range_clause child; PHP has no fields at all and
|
|
560
|
+
// spells it `foreach (EXPR as $v)`, so the last variable before the body is
|
|
561
|
+
// the binding and the first expression is the collection.
|
|
562
|
+
const range = node.namedChildren.find((c) => c?.type === 'range_clause');
|
|
563
|
+
if (range) {
|
|
564
|
+
const left = range.childForFieldName('left');
|
|
565
|
+
const right = range.childForFieldName('right');
|
|
566
|
+
if (left && right) {
|
|
567
|
+
// `for _, v := range values` - the VALUE is the second name, not the index.
|
|
568
|
+
const names = left.namedChildren.filter((c) => c !== null);
|
|
569
|
+
const chosen = names.length > 1 ? names[names.length - 1] : names[0];
|
|
570
|
+
if (chosen)
|
|
571
|
+
return { name: chosen, iterable: right };
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
if (node.type === 'foreach_statement') {
|
|
576
|
+
const parts = node.namedChildren.filter((c) => c !== null);
|
|
577
|
+
const body = node.childForFieldName('body');
|
|
578
|
+
const beforeBody = body ? parts.filter((c) => c.startIndex < body.startIndex) : parts;
|
|
579
|
+
const iterablePhp = beforeBody[0];
|
|
580
|
+
const namePhp = beforeBody[beforeBody.length - 1];
|
|
581
|
+
// `foreach ($a as $k => $v)` puts the pair in a `pair` node; take its value.
|
|
582
|
+
const resolved = namePhp?.type === 'pair' || namePhp?.type === 'by_ref'
|
|
583
|
+
? namePhp.namedChildren[namePhp.namedChildCount - 1] ?? namePhp
|
|
584
|
+
: namePhp;
|
|
585
|
+
if (iterablePhp && resolved && iterablePhp !== resolved) {
|
|
586
|
+
return { name: resolved, iterable: iterablePhp };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Calls that APPLY a function to every element of a collection.
|
|
593
|
+
*
|
|
594
|
+
* Deliberately tiny. These are the shapes where naming a sanitiser is the same
|
|
595
|
+
* as calling it on each element; anything looser would let an arbitrary
|
|
596
|
+
* callback argument read as proof of safety.
|
|
597
|
+
*/
|
|
598
|
+
const HIGHER_ORDER_APPLIERS = new Set(['map', 'imap', 'starmap', 'forEach']);
|
|
599
|
+
/** Comprehensions, which apply their element expression to every item. */
|
|
600
|
+
const COMPREHENSION_NODES = new Set([
|
|
601
|
+
'list_comprehension',
|
|
602
|
+
'dictionary_comprehension',
|
|
603
|
+
'set_comprehension',
|
|
604
|
+
'generator_expression',
|
|
605
|
+
]);
|
|
606
|
+
const CONDITIONAL_NODES = new Set([
|
|
607
|
+
'if_statement',
|
|
608
|
+
'else_clause',
|
|
609
|
+
'switch_statement',
|
|
610
|
+
'switch_block',
|
|
611
|
+
'switch_expression',
|
|
612
|
+
/*
|
|
613
|
+
* GO'S SWITCHES WERE MISSING FROM THIS LIST, and it cost real findings.
|
|
614
|
+
*
|
|
615
|
+
* This set is how the tracer knows a write might not happen. Go spells its
|
|
616
|
+
* switch `expression_switch_statement`, not `switch_statement`, so a clean
|
|
617
|
+
* write in ANY case of a Go switch was treated as certain and erased the
|
|
618
|
+
* taint - `bar := input; switch len(bar) { case 1: bar = "safe" }` left `bar`
|
|
619
|
+
* clean. The same logic written as an `if` was reported. switch-constant.go.
|
|
620
|
+
*/
|
|
621
|
+
'expression_switch_statement',
|
|
622
|
+
'type_switch_statement',
|
|
623
|
+
'select_statement',
|
|
624
|
+
'ternary_expression',
|
|
625
|
+
'conditional_expression',
|
|
626
|
+
'case_statement',
|
|
627
|
+
'match_statement',
|
|
628
|
+
'try_statement',
|
|
629
|
+
'catch_clause',
|
|
630
|
+
'for_statement',
|
|
631
|
+
'while_statement',
|
|
632
|
+
'do_statement',
|
|
633
|
+
'enhanced_for_statement',
|
|
634
|
+
'for_in_statement',
|
|
635
|
+
'for_range_loop',
|
|
636
|
+
'foreach_statement',
|
|
637
|
+
]);
|
|
638
|
+
/** Java literal node types: a map value of one of these is a constant. */
|
|
639
|
+
const JAVA_LITERALS = new Set([
|
|
640
|
+
'string_literal', 'character_literal', 'decimal_integer_literal', 'hex_integer_literal',
|
|
641
|
+
'octal_integer_literal', 'binary_integer_literal', 'decimal_floating_point_literal',
|
|
642
|
+
'true', 'false', 'null_literal',
|
|
643
|
+
]);
|
|
644
|
+
const CONTAINER_NODES = new Set([
|
|
645
|
+
'array_initializer', // Java {"a", bar}
|
|
646
|
+
'array_creation_expression', // Java new String[]{...} PHP array(...)
|
|
647
|
+
'array', // JS/TS [a, b]
|
|
648
|
+
'list', // Python [a, b]
|
|
649
|
+
'tuple',
|
|
650
|
+
'set',
|
|
651
|
+
'dictionary',
|
|
652
|
+
'composite_literal', // Go []string{a, b}
|
|
653
|
+
]);
|
|
654
|
+
function text(node) {
|
|
655
|
+
return (node?.text ?? '').replace(/\s+/g, ' ').trim();
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The method name at the end of a callee expression.
|
|
659
|
+
*
|
|
660
|
+
* The first version cut the string at the first "(" and then split on dots.
|
|
661
|
+
* That worked for `db.query` and broke silently on Java's
|
|
662
|
+
* `Runtime.getRuntime().exec` - cutting at the paren left "Runtime.getRuntime",
|
|
663
|
+
* so the name came back as `getRuntime` and the command-injection sink never
|
|
664
|
+
* matched. Taking the trailing identifier instead is both simpler and correct
|
|
665
|
+
* for every chained call.
|
|
666
|
+
*/
|
|
667
|
+
function lastSegment(value) {
|
|
668
|
+
const match = value.match(/([A-Za-z_$][\w$]*)\s*$/);
|
|
669
|
+
return match?.[1] ?? value.trim();
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Strip a C declarator down to the name it declares.
|
|
673
|
+
*
|
|
674
|
+
* `char *user = getenv("X")` gives an assignment whose TARGET is the whole
|
|
675
|
+
* declarator, `*user`, not the identifier. The binding loop below rejects any
|
|
676
|
+
* name that is not a plain identifier - a deliberate guard against binding
|
|
677
|
+
* taint to something it cannot name - so `*user` was silently dropped, `user`
|
|
678
|
+
* read as clean, and every C flow through a pointer variable died on the
|
|
679
|
+
* declaration line.
|
|
680
|
+
*
|
|
681
|
+
* It was invisible because the shapes that DO work are common enough to look
|
|
682
|
+
* like success: `char buf[64]` is an array_declarator whose text is `buf[64]`,
|
|
683
|
+
* whose last segment is a clean name, and a plain `p = argv[1]` never goes
|
|
684
|
+
* through a declarator at all. Only the pointer form broke, which is most of
|
|
685
|
+
* real C.
|
|
686
|
+
*
|
|
687
|
+
* Deliberately conservative: it removes leading `*` and `&` and any array
|
|
688
|
+
* suffix, and returns the input unchanged if what is left is not a plain
|
|
689
|
+
* identifier. A declarator this cannot reduce is still refused rather than
|
|
690
|
+
* guessed at.
|
|
691
|
+
*/
|
|
692
|
+
function stripDeclarator(value) {
|
|
693
|
+
const stripped = value.trim().replace(/^[*&\s]+/, '').replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
|
694
|
+
return /^[A-Za-z_$][\w$]*$/.test(stripped) ? stripped : value;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* "What is being called, and on what?"
|
|
698
|
+
*
|
|
699
|
+
* Every grammar spells this differently. JS and Python put the whole callee in
|
|
700
|
+
* a `function` field. Java's `method_invocation` does NOT - it keeps the
|
|
701
|
+
* receiver and the method name as separate children, so `stmt.executeQuery(x)`
|
|
702
|
+
* has no single node meaning "stmt.executeQuery". We rebuild it here so the
|
|
703
|
+
* rest of the tracer can stay grammar-agnostic.
|
|
704
|
+
*/
|
|
705
|
+
function calleeOf(callNode) {
|
|
706
|
+
const fn = callNode.childForFieldName('function') ?? callNode.childForFieldName('constructor') ?? null;
|
|
707
|
+
if (fn) {
|
|
708
|
+
const receiver = MEMBER_NODES.has(fn.type)
|
|
709
|
+
? fn.childForFieldName('object') ??
|
|
710
|
+
fn.childForFieldName('operand') ??
|
|
711
|
+
fn.childForFieldName('argument') ?? // C/C++ field_expression
|
|
712
|
+
fn.namedChildren[0] ??
|
|
713
|
+
null
|
|
714
|
+
: null;
|
|
715
|
+
return { node: fn, text: text(fn), receiver };
|
|
716
|
+
}
|
|
717
|
+
const name = callNode.childForFieldName('name'); // Java method_invocation
|
|
718
|
+
const object = callNode.childForFieldName('object');
|
|
719
|
+
if (name) {
|
|
720
|
+
return {
|
|
721
|
+
node: name,
|
|
722
|
+
text: object ? `${text(object)}.${text(name)}` : text(name),
|
|
723
|
+
receiver: object,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const type = callNode.childForFieldName('type'); // Java new Foo(...)
|
|
727
|
+
if (type)
|
|
728
|
+
return { node: type, text: text(type), receiver: null };
|
|
729
|
+
return { node: null, text: '', receiver: null };
|
|
730
|
+
}
|
|
731
|
+
function argumentsOf(callNode) {
|
|
732
|
+
const argsNode = callNode.childForFieldName('arguments');
|
|
733
|
+
if (!argsNode)
|
|
734
|
+
return [];
|
|
735
|
+
return argsNode.namedChildren
|
|
736
|
+
.filter((n) => n !== null)
|
|
737
|
+
// PHP wraps each argument in an `argument` node - unwrap it, or every
|
|
738
|
+
// argument the tracer inspects is a container rather than the expression.
|
|
739
|
+
.map((n) => (n.type === 'argument' ? (n.namedChildren.find((c) => c !== null) ?? n) : n));
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The parameter NAMES of a function, in whichever way its grammar spells them.
|
|
743
|
+
* We need names, not types: to follow a value into a function we bind the
|
|
744
|
+
* incoming taint to the name the body will use to refer to it.
|
|
745
|
+
*/
|
|
746
|
+
/**
|
|
747
|
+
* The identifier a function definition binds, across grammars.
|
|
748
|
+
*
|
|
749
|
+
* Every language this engine supported until C put the name in a `name` field.
|
|
750
|
+
* C puts it inside a declarator: function_definition.declarator is a
|
|
751
|
+
* function_declarator, and ITS declarator is the identifier. Pointer-returning
|
|
752
|
+
* functions nest one level deeper again (`char *f(void)` wraps the whole thing
|
|
753
|
+
* in a pointer_declarator), so this walks down rather than assuming a depth.
|
|
754
|
+
*/
|
|
755
|
+
function functionNameOf(fnNode) {
|
|
756
|
+
const direct = fnNode.childForFieldName('name');
|
|
757
|
+
if (direct)
|
|
758
|
+
return direct;
|
|
759
|
+
let node = fnNode.childForFieldName('declarator');
|
|
760
|
+
for (let depth = 0; node && depth < 6; depth++) {
|
|
761
|
+
if (node.type === 'identifier' || node.type === 'field_identifier')
|
|
762
|
+
return node;
|
|
763
|
+
node = node.childForFieldName('declarator');
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
/** The parameter list, which C hangs off the declarator rather than the definition. */
|
|
768
|
+
function parameterListOf(fnNode) {
|
|
769
|
+
const direct = fnNode.childForFieldName('parameters');
|
|
770
|
+
if (direct)
|
|
771
|
+
return direct;
|
|
772
|
+
let node = fnNode.childForFieldName('declarator');
|
|
773
|
+
for (let depth = 0; node && depth < 6; depth++) {
|
|
774
|
+
const params = node.childForFieldName('parameters');
|
|
775
|
+
if (params)
|
|
776
|
+
return params;
|
|
777
|
+
node = node.childForFieldName('declarator');
|
|
778
|
+
}
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
function parameterNames(fnNode) {
|
|
782
|
+
const container = parameterListOf(fnNode);
|
|
783
|
+
if (!container) {
|
|
784
|
+
// Single-parameter arrow function: `x => ...` has no parameter list node.
|
|
785
|
+
const bare = fnNode.childForFieldName('parameter');
|
|
786
|
+
return bare ? [text(bare)] : [];
|
|
787
|
+
}
|
|
788
|
+
const names = [];
|
|
789
|
+
for (const child of container.namedChildren) {
|
|
790
|
+
if (!child)
|
|
791
|
+
continue;
|
|
792
|
+
if (VARIABLE_NODES.has(child.type)) {
|
|
793
|
+
names.push(text(child));
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
// typed_parameter / required_parameter / formal_parameter / parameter_declaration
|
|
797
|
+
const named = child.childForFieldName('name') ??
|
|
798
|
+
child.childForFieldName('pattern') ??
|
|
799
|
+
// C: parameter_declaration's name lives in `declarator`, and arrives with
|
|
800
|
+
// its pointer stars attached (`*dst`). stripDeclarator removes them.
|
|
801
|
+
child.childForFieldName('declarator') ??
|
|
802
|
+
null;
|
|
803
|
+
if (named) {
|
|
804
|
+
names.push(stripDeclarator(text(named)));
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const identifier = child.namedChildren.find((c) => c !== null && VARIABLE_NODES.has(c.type));
|
|
808
|
+
names.push(identifier ? text(identifier) : '');
|
|
809
|
+
}
|
|
810
|
+
return names;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Parameter declarations with their FULL text, annotations included.
|
|
814
|
+
*
|
|
815
|
+
* `parameterNames` above deliberately returns only names, because binding a
|
|
816
|
+
* caller's argument to a callee's name is all it is for. Recognising a Spring
|
|
817
|
+
* binding needs the opposite half - the annotation sitting in front of the
|
|
818
|
+
* name - so this returns both rather than making the other function lie about
|
|
819
|
+
* what it does.
|
|
820
|
+
*/
|
|
821
|
+
function parameterDeclarations(fnNode) {
|
|
822
|
+
const container = fnNode.childForFieldName('parameters');
|
|
823
|
+
if (!container)
|
|
824
|
+
return [];
|
|
825
|
+
const out = [];
|
|
826
|
+
for (const child of container.namedChildren) {
|
|
827
|
+
if (!child)
|
|
828
|
+
continue;
|
|
829
|
+
// A bare `def view(name)` parameter IS the identifier - it has no children.
|
|
830
|
+
const named = VARIABLE_NODES.has(child.type)
|
|
831
|
+
? child
|
|
832
|
+
: child.childForFieldName('name') ??
|
|
833
|
+
child.childForFieldName('pattern') ??
|
|
834
|
+
child.namedChildren.find((c) => c !== null && VARIABLE_NODES.has(c.type)) ??
|
|
835
|
+
null;
|
|
836
|
+
if (!named)
|
|
837
|
+
continue;
|
|
838
|
+
out.push({ name: text(named), text: text(child), node: child });
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* A URL PATH THAT NAMES THIS PARAMETER, read off a Python route decorator.
|
|
844
|
+
*
|
|
845
|
+
* @app.route("/user/<name>") Flask, <converter:name> too
|
|
846
|
+
* @router.get("/items/{item_id}") FastAPI / Starlette
|
|
847
|
+
* def view(name): ...
|
|
848
|
+
*
|
|
849
|
+
* The framework splits the URL and passes the segment as a plain str, so the
|
|
850
|
+
* body holds no source to match - the placeholder in the decorator is the
|
|
851
|
+
* only evidence, which is why parameterSources (one regex per parameter's own
|
|
852
|
+
* text) cannot see it. Only a parameter the path NAMES is bound: a decorator
|
|
853
|
+
* that is not a route method, or a parameter with no placeholder, binds
|
|
854
|
+
* nothing (route-path-params.py). Django binds kwargs in a separate urls.py
|
|
855
|
+
* and is not read here.
|
|
856
|
+
*/
|
|
857
|
+
const ROUTE_DECORATOR = /^@[\w.]+\.(?:route|get|post|put|patch|delete|api_route)\(\s*[rRbBuU]?(["'])(.*?)\1/s;
|
|
858
|
+
function routePathFor(fnNode, name) {
|
|
859
|
+
const parent = fnNode.parent;
|
|
860
|
+
if (!name || parent?.type !== 'decorated_definition')
|
|
861
|
+
return null;
|
|
862
|
+
for (const decorator of parent.namedChildren) {
|
|
863
|
+
if (decorator?.type !== 'decorator')
|
|
864
|
+
continue;
|
|
865
|
+
const path = ROUTE_DECORATOR.exec(text(decorator))?.[2];
|
|
866
|
+
if (!path)
|
|
867
|
+
continue;
|
|
868
|
+
for (const match of path.matchAll(/<(?:[^<>:]+:)?(\w+)>|\{(\w+)(?::[^}]*)?\}/g)) {
|
|
869
|
+
if ((match[1] ?? match[2]) === name)
|
|
870
|
+
return `"${path}"`;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* DECLARED TYPES, so a receiver check can see past the variable's name.
|
|
877
|
+
*
|
|
878
|
+
* `receiverPattern` matches the TEXT in front of the call, which works for
|
|
879
|
+
* `Runtime.getRuntime().exec(cmd)` and fails completely for the form
|
|
880
|
+
* BenchmarkJava actually uses 188 times:
|
|
881
|
+
*
|
|
882
|
+
* Runtime r = Runtime.getRuntime();
|
|
883
|
+
* r.exec(cmd);
|
|
884
|
+
*
|
|
885
|
+
* The receiver is `r`. Scoping `exec` to a runtime-looking receiver removed 16
|
|
886
|
+
* false positives and 13 TRUE ones in the same change - a straight trade, not
|
|
887
|
+
* an improvement, and the benchmark said so within a minute.
|
|
888
|
+
*
|
|
889
|
+
* Java, Go and TypeScript all write the type down at the declaration, so it is
|
|
890
|
+
* there to be read. This is one shallow pass over the file collecting
|
|
891
|
+
* `Type name = ...` and `var name Type`; it is not type inference and does not
|
|
892
|
+
* pretend to be. A name declared twice with different types in one file is
|
|
893
|
+
* recorded once and is a known imprecision - the alternative is threading a
|
|
894
|
+
* type environment through every scope, which is a much larger change than the
|
|
895
|
+
* problem justifies.
|
|
896
|
+
*/
|
|
897
|
+
function declaredTypes(root, language) {
|
|
898
|
+
const types = new Map();
|
|
899
|
+
if (language !== 'java' && language !== 'go' && language !== 'typescript')
|
|
900
|
+
return types;
|
|
901
|
+
const visit = (node, depth) => {
|
|
902
|
+
if (depth > 60)
|
|
903
|
+
return;
|
|
904
|
+
if (node.type === 'local_variable_declaration' ||
|
|
905
|
+
node.type === 'field_declaration' ||
|
|
906
|
+
node.type === 'var_declaration' ||
|
|
907
|
+
node.type === 'var_spec') {
|
|
908
|
+
const typeNode = node.childForFieldName('type');
|
|
909
|
+
const typeText = typeNode ? text(typeNode) : '';
|
|
910
|
+
if (typeText) {
|
|
911
|
+
for (const child of node.namedChildren) {
|
|
912
|
+
if (!child)
|
|
913
|
+
continue;
|
|
914
|
+
const nameNode = child.type === 'variable_declarator'
|
|
915
|
+
? child.childForFieldName('name')
|
|
916
|
+
: VARIABLE_NODES.has(child.type)
|
|
917
|
+
? child
|
|
918
|
+
: null;
|
|
919
|
+
if (nameNode)
|
|
920
|
+
types.set(text(nameNode), typeText);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
for (const child of node.namedChildren)
|
|
925
|
+
if (child)
|
|
926
|
+
visit(child, depth + 1);
|
|
927
|
+
};
|
|
928
|
+
visit(root, 0);
|
|
929
|
+
return types;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* The bare NAME of a receiver, with the language's decoration removed.
|
|
933
|
+
*
|
|
934
|
+
* PHP writes `$conn->query(...)`, and comparing "$conn" against a list holding
|
|
935
|
+
* "conn" fails silently - which is exactly what happened: scoping the PHP SQL
|
|
936
|
+
* sink dropped two asserted flow-verified findings and resurrected a false
|
|
937
|
+
* positive in the safe fixture, because the receiver never matched anything and
|
|
938
|
+
* the tracer stopped proving the sanitiser ran.
|
|
939
|
+
*
|
|
940
|
+
* Go pointers (`*db`), references (`&db`) and PHP's sigil are punctuation about
|
|
941
|
+
* how the value is held, not about what it is. The last segment after a dot is
|
|
942
|
+
* the object being called on.
|
|
943
|
+
*/
|
|
944
|
+
function ownerName(receiverText) {
|
|
945
|
+
const last = receiverText.split(/[.\s(\[]/).filter(Boolean).pop() ?? receiverText;
|
|
946
|
+
return last.replace(/^[$*&]+/, '');
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* LOCAL ALIASES OF A DANGEROUS FUNCTION.
|
|
950
|
+
*
|
|
951
|
+
* import { exec } from "child_process";
|
|
952
|
+
* const execAsync = promisify(exec);
|
|
953
|
+
* await execAsync(command); // <- invisible to a name list
|
|
954
|
+
*
|
|
955
|
+
* That is CVE-2025-53107, twenty-three vulnerable files, and we found zero of
|
|
956
|
+
* them. The sink list holds `exec`; the call says `execAsync`. Nothing about
|
|
957
|
+
* the danger changed - only the label on it.
|
|
958
|
+
*
|
|
959
|
+
* The near-identical CVE-2025-59046 WAS caught, and only because that author
|
|
960
|
+
* happened to write `const exec = promisify(execCb)` - aliasing back to the
|
|
961
|
+
* name we already knew. Two CVEs, one real difference between them, and it was
|
|
962
|
+
* luck.
|
|
963
|
+
*
|
|
964
|
+
* `promisify` is the standard Node idiom for exactly these APIs, so this is not
|
|
965
|
+
* an exotic shape; it is the normal one. This pass reads the file for
|
|
966
|
+
* declarations that bind a known sink name to a new identifier and returns the
|
|
967
|
+
* mapping, so the sink check can recognise both.
|
|
968
|
+
*
|
|
969
|
+
* FILE-LOCAL ONLY. An alias exported from another module is still missed, and
|
|
970
|
+
* that is a documented gap rather than a silent one.
|
|
971
|
+
*/
|
|
972
|
+
function sinkAliases(root, knownSinkNames) {
|
|
973
|
+
const aliases = new Map();
|
|
974
|
+
const visit = (node, depth) => {
|
|
975
|
+
if (depth > 40)
|
|
976
|
+
return;
|
|
977
|
+
if (node.type === 'variable_declarator' || node.type === 'short_var_declaration') {
|
|
978
|
+
const nameNode = node.childForFieldName('name') ?? node.childForFieldName('left');
|
|
979
|
+
const valueNode = node.childForFieldName('value') ?? node.childForFieldName('right');
|
|
980
|
+
if (nameNode && valueNode) {
|
|
981
|
+
const alias = text(nameNode);
|
|
982
|
+
const initialiser = text(valueNode);
|
|
983
|
+
// Only wrappers that PASS THE FUNCTION ALONG: promisify(fn),
|
|
984
|
+
// util.promisify(fn), require('child_process').fn, cp.fn.
|
|
985
|
+
if (/^(?:[\w.]*\bpromisify\s*\(|require\s*\(|[\w$]+\.)/.test(initialiser)) {
|
|
986
|
+
for (const sinkName of knownSinkNames) {
|
|
987
|
+
if (alias === sinkName)
|
|
988
|
+
continue;
|
|
989
|
+
if (new RegExp(`\\b${sinkName}\\b`).test(initialiser)) {
|
|
990
|
+
aliases.set(alias, sinkName);
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
for (const child of node.namedChildren)
|
|
998
|
+
if (child)
|
|
999
|
+
visit(child, depth + 1);
|
|
1000
|
+
};
|
|
1001
|
+
visit(root, 0);
|
|
1002
|
+
return aliases;
|
|
1003
|
+
}
|
|
1004
|
+
/*
|
|
1005
|
+
* A parameter that makes the argument count flexible: a default value, or a
|
|
1006
|
+
* variadic tail. Matched on node type across the grammars, because each spells
|
|
1007
|
+
* both differently - spread_parameter (Java), variadic_parameter_declaration
|
|
1008
|
+
* (Go), variadic_parameter / optional_parameter_declaration (C, C++),
|
|
1009
|
+
* default_parameter / list_splat_pattern (Python), rest_pattern /
|
|
1010
|
+
* assignment_pattern / optional_parameter (JS, TS).
|
|
1011
|
+
*/
|
|
1012
|
+
const VARIADIC_PARAMETER = /spread|variadic|splat|rest_pattern|rest_parameter/;
|
|
1013
|
+
const DEFAULTED_PARAMETER = /default|optional|assignment_pattern/;
|
|
1014
|
+
function argumentRange(fnNode) {
|
|
1015
|
+
const list = parameterListOf(fnNode);
|
|
1016
|
+
if (!list)
|
|
1017
|
+
return { minArgs: 0, maxArgs: Infinity };
|
|
1018
|
+
const params = list.namedChildren.filter((c) => c !== null && c.type !== 'comment');
|
|
1019
|
+
// C's `f(void)` is a list holding one parameter that means "none".
|
|
1020
|
+
if (params.length === 1 && (params[0]?.text ?? '').trim() === 'void')
|
|
1021
|
+
return { minArgs: 0, maxArgs: 0 };
|
|
1022
|
+
let min = 0;
|
|
1023
|
+
let max = 0;
|
|
1024
|
+
for (const p of params) {
|
|
1025
|
+
if (VARIADIC_PARAMETER.test(p.type) || (p.text ?? '').includes('...'))
|
|
1026
|
+
return { minArgs: min, maxArgs: Infinity };
|
|
1027
|
+
max++;
|
|
1028
|
+
if (!DEFAULTED_PARAMETER.test(p.type))
|
|
1029
|
+
min++;
|
|
1030
|
+
}
|
|
1031
|
+
return { minArgs: min, maxArgs: max };
|
|
1032
|
+
}
|
|
1033
|
+
/*
|
|
1034
|
+
* A declaration with no body is not somewhere a value can go.
|
|
1035
|
+
*
|
|
1036
|
+
* An interface method, an abstract method, a signature: the tracer indexed them
|
|
1037
|
+
* like functions, and the index keeps the FIRST definition of a name. So when a
|
|
1038
|
+
* Java interface sat above its implementation - the normal way round - calls
|
|
1039
|
+
* resolved to `String clean(String s);`, found nothing inside, and the value
|
|
1040
|
+
* vanished. Swap the two declarations and the same call was flow-verified.
|
|
1041
|
+
* DeclarationOrder.java is that case.
|
|
1042
|
+
*/
|
|
1043
|
+
const BODYLESS_DECLARATION = /declaration|definition|signature/;
|
|
1044
|
+
const FUNCTION_TYPES_BY_LANGUAGE = FUNCTION_NODES;
|
|
1045
|
+
/** Languages where calling with the wrong number of arguments cannot compile. */
|
|
1046
|
+
const ARITY_ENFORCED = new Set(['java', 'go', 'c', 'cpp']);
|
|
1047
|
+
/** `org.acme.Thing<String>[]` -> `Thing`. */
|
|
1048
|
+
function simpleTypeName(raw) {
|
|
1049
|
+
return raw.replace(/<[^>]*>/g, '').replace(/\[\]/g, '').trim().split('.').pop() ?? '';
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* The declared type of a Java call's receiver, when it is written down nearby:
|
|
1053
|
+
* `new X(...).m()`, or a local variable / parameter / field declared in the
|
|
1054
|
+
* enclosing method or class. Anything else - `this`, a chain, a static class
|
|
1055
|
+
* reference, `var` - returns null, and the call is resolved exactly as before.
|
|
1056
|
+
*/
|
|
1057
|
+
function javaReceiverType(call) {
|
|
1058
|
+
if (call.type !== 'method_invocation')
|
|
1059
|
+
return null;
|
|
1060
|
+
const receiver = call.childForFieldName('object');
|
|
1061
|
+
if (!receiver)
|
|
1062
|
+
return null;
|
|
1063
|
+
if (receiver.type === 'object_creation_expression') {
|
|
1064
|
+
return simpleTypeName(receiver.childForFieldName('type')?.text ?? '') || null;
|
|
1065
|
+
}
|
|
1066
|
+
if (receiver.type !== 'identifier')
|
|
1067
|
+
return null;
|
|
1068
|
+
const name = receiver.text ?? '';
|
|
1069
|
+
const types = new Set();
|
|
1070
|
+
const collect = (scope, stopAtNested) => {
|
|
1071
|
+
if (!scope)
|
|
1072
|
+
return;
|
|
1073
|
+
const visit = (n) => {
|
|
1074
|
+
if (n.type === 'formal_parameter' && n.childForFieldName('name')?.text === name) {
|
|
1075
|
+
types.add(simpleTypeName(n.childForFieldName('type')?.text ?? ''));
|
|
1076
|
+
}
|
|
1077
|
+
if (n.type === 'local_variable_declaration' || n.type === 'field_declaration') {
|
|
1078
|
+
for (const d of n.namedChildren) {
|
|
1079
|
+
if (d?.type === 'variable_declarator' && d.childForFieldName('name')?.text === name) {
|
|
1080
|
+
types.add(simpleTypeName(n.childForFieldName('type')?.text ?? ''));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
for (const child of n.namedChildren) {
|
|
1085
|
+
if (!child)
|
|
1086
|
+
continue;
|
|
1087
|
+
if (stopAtNested && /^(method|constructor)_declaration$|^class_body$/.test(child.type))
|
|
1088
|
+
continue;
|
|
1089
|
+
visit(child);
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
visit(scope);
|
|
1093
|
+
};
|
|
1094
|
+
let method = call.parent;
|
|
1095
|
+
while (method && !/^(method|constructor)_declaration$|^lambda_expression$/.test(method.type)) {
|
|
1096
|
+
method = method.parent;
|
|
1097
|
+
}
|
|
1098
|
+
collect(method, false);
|
|
1099
|
+
if (types.size === 0) {
|
|
1100
|
+
// A field of the enclosing class: search the class body, not other methods.
|
|
1101
|
+
let klass = method?.parent ?? null;
|
|
1102
|
+
while (klass && klass.type !== 'class_body')
|
|
1103
|
+
klass = klass.parent;
|
|
1104
|
+
collect(klass, true);
|
|
1105
|
+
}
|
|
1106
|
+
types.delete('');
|
|
1107
|
+
if (types.size !== 1 || types.has('var'))
|
|
1108
|
+
return null;
|
|
1109
|
+
return [...types][0] ?? null;
|
|
1110
|
+
}
|
|
1111
|
+
/** The class, interface, enum or record a function body is declared in. */
|
|
1112
|
+
function declaringTypeOf(fn) {
|
|
1113
|
+
let n = fn.body.parent;
|
|
1114
|
+
while (n && !/^(class|interface|enum|record)_declaration$/.test(n.type))
|
|
1115
|
+
n = n.parent;
|
|
1116
|
+
return n?.childForFieldName('name')?.text ?? null;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Index every function in a tree by name. Exported because the cross-file
|
|
1120
|
+
* resolver needs exactly the same view of another file that the tracer has of
|
|
1121
|
+
* this one - if the two disagreed about what counts as a function, a value
|
|
1122
|
+
* could be followed into something that is not there.
|
|
1123
|
+
*/
|
|
1124
|
+
export function collectFunctions(root, language) {
|
|
1125
|
+
const functionTypes = new Set(FUNCTION_TYPES_BY_LANGUAGE[language] ?? []);
|
|
1126
|
+
const found = new Map();
|
|
1127
|
+
const register = (name, fnNode) => {
|
|
1128
|
+
if (!name || found.has(name))
|
|
1129
|
+
return;
|
|
1130
|
+
const bodyNode = fnNode.childForFieldName('body');
|
|
1131
|
+
if (!bodyNode && BODYLESS_DECLARATION.test(fnNode.type))
|
|
1132
|
+
return; // see BODYLESS_DECLARATION
|
|
1133
|
+
const body = bodyNode ?? fnNode;
|
|
1134
|
+
found.set(name, { name, params: parameterNames(fnNode), body, ...argumentRange(fnNode) });
|
|
1135
|
+
};
|
|
1136
|
+
const visit = (node) => {
|
|
1137
|
+
if (functionTypes.has(node.type)) {
|
|
1138
|
+
const nameNode = functionNameOf(node);
|
|
1139
|
+
if (nameNode)
|
|
1140
|
+
register(text(nameNode), node);
|
|
1141
|
+
}
|
|
1142
|
+
// `const helper = (x) => {...}` - the name is on the declarator.
|
|
1143
|
+
const value = node.childForFieldName('value') ?? node.childForFieldName('right');
|
|
1144
|
+
const name = node.childForFieldName('name') ?? node.childForFieldName('left');
|
|
1145
|
+
if (value && name && functionTypes.has(value.type))
|
|
1146
|
+
register(text(name), value);
|
|
1147
|
+
for (const child of node.namedChildren)
|
|
1148
|
+
if (child)
|
|
1149
|
+
visit(child);
|
|
1150
|
+
};
|
|
1151
|
+
visit(root);
|
|
1152
|
+
return found;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* A FLASK ROUTE RETURNING A STR IS AN HTML PAGE, TAGS OR NOT.
|
|
1156
|
+
*
|
|
1157
|
+
* The return sink otherwise needs visible markup, because most functions that
|
|
1158
|
+
* return a string are not views. BenchmarkPython's 31 real XSS cases return
|
|
1159
|
+
* `f"bar is '{bar}'"` from `@app.route` - Flask sends a returned str as
|
|
1160
|
+
* text/html, so `<script>` in bar runs - and all 31 were silent.
|
|
1161
|
+
*
|
|
1162
|
+
* Both halves are proven, not guessed:
|
|
1163
|
+
* - the function carries a `@<x>.route(...)` decorator (Flask, Blueprint,
|
|
1164
|
+
* Quart). NOT FastAPI's `@app.get`, which JSON-encodes a returned str;
|
|
1165
|
+
* - the value is a str by construction: a string literal or f-string, a `+`
|
|
1166
|
+
* or `%` with a string literal on the left, or a name whose only writes in
|
|
1167
|
+
* the function are one `name = "<literal>"` and then `name += ...` (str +=
|
|
1168
|
+
* anything that is not a str raises, so it stays a str). A name that may
|
|
1169
|
+
* hold a dict - which Flask turns into JSON - never qualifies.
|
|
1170
|
+
* A header set elsewhere (after_request, a config default) is not seen.
|
|
1171
|
+
*/
|
|
1172
|
+
function flaskRouteReturningStr(ret, expression) {
|
|
1173
|
+
let fn = ret.parent;
|
|
1174
|
+
while (fn && fn.type !== 'function_definition')
|
|
1175
|
+
fn = fn.parent;
|
|
1176
|
+
const decorated = fn?.parent?.type === 'decorated_definition' ? fn.parent : null;
|
|
1177
|
+
if (!fn || !decorated)
|
|
1178
|
+
return false;
|
|
1179
|
+
const isRoute = decorated.namedChildren.some((d) => d?.type === 'decorator' && /^@\s*[\w.]+\.route\s*\(/.test(d.text));
|
|
1180
|
+
if (!isRoute)
|
|
1181
|
+
return false;
|
|
1182
|
+
const strByConstruction = (e) => {
|
|
1183
|
+
if (e.type === 'parenthesized_expression')
|
|
1184
|
+
return !!e.namedChildren[0] && strByConstruction(e.namedChildren[0]);
|
|
1185
|
+
if (e.type === 'string' || e.type === 'concatenated_string')
|
|
1186
|
+
return true;
|
|
1187
|
+
if (e.type === 'binary_operator') {
|
|
1188
|
+
const op = e.childForFieldName('operator')?.text;
|
|
1189
|
+
const left = e.childForFieldName('left');
|
|
1190
|
+
return (op === '+' || op === '%') && !!left && strByConstruction(left);
|
|
1191
|
+
}
|
|
1192
|
+
return false;
|
|
1193
|
+
};
|
|
1194
|
+
if (strByConstruction(expression))
|
|
1195
|
+
return true;
|
|
1196
|
+
if (expression.type !== 'identifier')
|
|
1197
|
+
return false;
|
|
1198
|
+
const name = expression.text;
|
|
1199
|
+
let initial = 0;
|
|
1200
|
+
let other = false;
|
|
1201
|
+
const visit = (n) => {
|
|
1202
|
+
if (other)
|
|
1203
|
+
return;
|
|
1204
|
+
if (n.type === 'assignment' || n.type === 'augmented_assignment') {
|
|
1205
|
+
const left = n.childForFieldName('left');
|
|
1206
|
+
if (left?.text === name) {
|
|
1207
|
+
const op = n.type === 'augmented_assignment' ? n.childForFieldName('operator')?.text : '=';
|
|
1208
|
+
const right = n.childForFieldName('right');
|
|
1209
|
+
if (op === '=' && right && strByConstruction(right))
|
|
1210
|
+
initial++;
|
|
1211
|
+
else if (op !== '+=')
|
|
1212
|
+
other = true;
|
|
1213
|
+
}
|
|
1214
|
+
else if (left && new RegExp(`(^|[^\\w])${name}([^\\w]|$)`).test(left.text))
|
|
1215
|
+
other = true;
|
|
1216
|
+
}
|
|
1217
|
+
else if (n.type === 'identifier' && n.text === name && n.parent && /^(for_statement|with_item|global_statement|nonlocal_statement|named_expression|parameters|typed_parameter|default_parameter)$/.test(n.parent.type)) {
|
|
1218
|
+
other = true;
|
|
1219
|
+
}
|
|
1220
|
+
for (const c of n.namedChildren)
|
|
1221
|
+
if (c)
|
|
1222
|
+
visit(c);
|
|
1223
|
+
};
|
|
1224
|
+
visit(fn);
|
|
1225
|
+
return !other && initial === 1;
|
|
1226
|
+
}
|
|
1227
|
+
export function traceFile(file, language, resolver,
|
|
1228
|
+
/**
|
|
1229
|
+
* Absolute path -> the path a human should see. Resolution needs absolute
|
|
1230
|
+
* paths; reports need short ones. Keeping both straight matters more once a
|
|
1231
|
+
* single finding can name two different files.
|
|
1232
|
+
*/
|
|
1233
|
+
toDisplay = (p) => p) {
|
|
1234
|
+
/**
|
|
1235
|
+
* Blind spots, counted by SITE rather than by visit.
|
|
1236
|
+
*
|
|
1237
|
+
* A note on why, because the obvious reading of this code is wrong. A scan
|
|
1238
|
+
* reported "3 hops through functions we do not model" while the printed flow
|
|
1239
|
+
* paths showed only two, which looks exactly like double-counting: the
|
|
1240
|
+
* tracer can reach one expression from both the expression walk and the
|
|
1241
|
+
* statement walk, so a tally would plausibly count it twice.
|
|
1242
|
+
*
|
|
1243
|
+
* It was measured, and that is NOT what was happening - raw visits equalled
|
|
1244
|
+
* distinct sites on every corpus tried. The third hop was a real, separate
|
|
1245
|
+
* site (`db.Query(query)`, whose tainted RESULT flows on) that simply never
|
|
1246
|
+
* reached a sink, so no finding carried it.
|
|
1247
|
+
*
|
|
1248
|
+
* The Set stays, because counting a site twice would be wrong if the walk
|
|
1249
|
+
* ever changed. But it fixed nothing that was broken, and saying otherwise
|
|
1250
|
+
* here would be the same kind of confident wrong answer this file exists to
|
|
1251
|
+
* avoid. What was actually wrong was the LABEL - see the blind-spot text in
|
|
1252
|
+
* core/score.ts, which now says these are scan-wide rather than per-finding.
|
|
1253
|
+
*/
|
|
1254
|
+
const seenDepth = new Set();
|
|
1255
|
+
const seenRecursion = new Set();
|
|
1256
|
+
const seenUnmodelled = new Set();
|
|
1257
|
+
/**
|
|
1258
|
+
* A call's receiver type, per call site. Working it out means walking the
|
|
1259
|
+
* enclosing method for the receiver's declaration, and the same call is
|
|
1260
|
+
* evaluated once per taint shape that reaches it - done eagerly for every
|
|
1261
|
+
* call, that walk made a BenchmarkJava scan 65% slower (40s to 67s). Keyed by
|
|
1262
|
+
* file and span rather than node id, because a node id is a memory address
|
|
1263
|
+
* and can be reused once a tree is evicted from the budget.
|
|
1264
|
+
*/
|
|
1265
|
+
const receiverTypes = new Map();
|
|
1266
|
+
/** Sites where the syntax tree was deeper than MAX_AST_DEPTH. */
|
|
1267
|
+
const seenDeepAst = new Set();
|
|
1268
|
+
const siteOf = (n, filePath) => `${filePath}#${n.id}`;
|
|
1269
|
+
const dictionary = TAINT_DICTIONARIES[language];
|
|
1270
|
+
// Java and Go: no dictionary yet. Reported in the coverage section, not hidden.
|
|
1271
|
+
const seenSources = new Set();
|
|
1272
|
+
const limits = () => ({
|
|
1273
|
+
astTruncations: seenDeepAst.size,
|
|
1274
|
+
depthTruncations: seenDepth.size,
|
|
1275
|
+
recursionStops: seenRecursion.size,
|
|
1276
|
+
unmodelledHops: seenUnmodelled.size,
|
|
1277
|
+
sourcesFound: seenSources.size,
|
|
1278
|
+
});
|
|
1279
|
+
if (!dictionary)
|
|
1280
|
+
return { flows: [], sanitized: [], unreachable: [], resolvedReads: [], freshStops: [], limits: limits() };
|
|
1281
|
+
const functionTypes = new Set(FUNCTION_NODES[language] ?? []);
|
|
1282
|
+
const fileTypes = declaredTypes(file.root, language);
|
|
1283
|
+
const knownSinkNames = new Set();
|
|
1284
|
+
for (const sink of dictionary.callSinks)
|
|
1285
|
+
for (const m of sink.methods)
|
|
1286
|
+
knownSinkNames.add(m);
|
|
1287
|
+
const fileAliases = sinkAliases(file.root, knownSinkNames);
|
|
1288
|
+
const results = [];
|
|
1289
|
+
const sanitized = [];
|
|
1290
|
+
/*
|
|
1291
|
+
* Every region skipped as unreachable, once each. Before this list existed the
|
|
1292
|
+
* dead arm of an `if` was skipped with no record at all - a flow inside it
|
|
1293
|
+
* simply never existed, and nothing in the report could say why a line that
|
|
1294
|
+
* reads a request parameter went unreported.
|
|
1295
|
+
*/
|
|
1296
|
+
const unreachable = [];
|
|
1297
|
+
const unreachableSeen = new Set();
|
|
1298
|
+
/*
|
|
1299
|
+
* A map read answered by key rather than by "the whole collection is dirty".
|
|
1300
|
+
* Recorded for the same reason as unreachable code: a finding that used to
|
|
1301
|
+
* exist and now does not has to leave the decision behind it on the record.
|
|
1302
|
+
*/
|
|
1303
|
+
const resolvedReads = [];
|
|
1304
|
+
const resolvedSeen = new Set();
|
|
1305
|
+
const noteResolvedRead = (node, filePath, why) => {
|
|
1306
|
+
const key = `${filePath}\0${node.startIndex}:${node.endIndex}`;
|
|
1307
|
+
if (resolvedSeen.has(key))
|
|
1308
|
+
return;
|
|
1309
|
+
resolvedSeen.add(key);
|
|
1310
|
+
resolvedReads.push({ node, filePath: toDisplay(filePath), why });
|
|
1311
|
+
};
|
|
1312
|
+
/*
|
|
1313
|
+
* Traces ended at a read whose result is new data (FreshResultSpec). The
|
|
1314
|
+
* same reason as the two lists around it: a proof that used to be published
|
|
1315
|
+
* and no longer is has to leave the decision on the record.
|
|
1316
|
+
*/
|
|
1317
|
+
const freshStops = [];
|
|
1318
|
+
const freshSeen = new Set();
|
|
1319
|
+
const noteFreshStop = (node, filePath, why) => {
|
|
1320
|
+
const key = `${filePath}\0${node.startIndex}:${node.endIndex}`;
|
|
1321
|
+
if (freshSeen.has(key))
|
|
1322
|
+
return;
|
|
1323
|
+
freshSeen.add(key);
|
|
1324
|
+
freshStops.push({ node, filePath: toDisplay(filePath), why });
|
|
1325
|
+
};
|
|
1326
|
+
const noteUnreachable = (node, filePath, why) => {
|
|
1327
|
+
const key = `${filePath}\0${node.startIndex}:${node.endIndex}`;
|
|
1328
|
+
if (unreachableSeen.has(key))
|
|
1329
|
+
return;
|
|
1330
|
+
unreachableSeen.add(key);
|
|
1331
|
+
unreachable.push({ node, filePath: toDisplay(filePath), why });
|
|
1332
|
+
};
|
|
1333
|
+
/**
|
|
1334
|
+
* The file we are standing in RIGHT NOW - not necessarily the file we were
|
|
1335
|
+
* asked to scan. Saved and restored around every cross-file descent.
|
|
1336
|
+
*
|
|
1337
|
+
* A single mutable is safe here only because the walk is depth-first and
|
|
1338
|
+
* synchronous: steps created during a descent get the callee's path, and the
|
|
1339
|
+
* restore happens before the caller creates any more. If this ever becomes
|
|
1340
|
+
* async, this variable becomes a bug.
|
|
1341
|
+
*/
|
|
1342
|
+
let currentPath = file.path;
|
|
1343
|
+
// Scopes are processed as a queue. The module top level is the first one;
|
|
1344
|
+
// every nested function becomes another, seeded with a SNAPSHOT of the
|
|
1345
|
+
// enclosing notebook so that callbacks can see variables defined outside them.
|
|
1346
|
+
const queue = [
|
|
1347
|
+
{
|
|
1348
|
+
body: file.root,
|
|
1349
|
+
env: new Map(),
|
|
1350
|
+
filePath: file.path,
|
|
1351
|
+
prepared: new Set(),
|
|
1352
|
+
builders: new Set(),
|
|
1353
|
+
},
|
|
1354
|
+
];
|
|
1355
|
+
/* ---------------------------------------------------------------- *
|
|
1356
|
+
* PHASE 3b: the call graph.
|
|
1357
|
+
*
|
|
1358
|
+
* Phase 3a stopped at the function boundary. `helper(req.query.id)` was the
|
|
1359
|
+
* end of the trace, because guessing what `helper` does would have been
|
|
1360
|
+
* exactly the kind of confident wrong answer this project refuses to give.
|
|
1361
|
+
*
|
|
1362
|
+
* Now we do not guess - we go and look. Every function defined in this file
|
|
1363
|
+
* is indexed by name; when a tainted value is passed to one, we bind it to
|
|
1364
|
+
* the parameter name and analyse that body with the taint already in place.
|
|
1365
|
+
* Sinks inside the callee become findings whose path spans both functions.
|
|
1366
|
+
*
|
|
1367
|
+
* Three guards keep this honest and terminating:
|
|
1368
|
+
* MAX_CALL_DEPTH stop after 4 nested calls. Deeper chains are missed and
|
|
1369
|
+
* that is a stated gap, not a silent one.
|
|
1370
|
+
* callStack a function already being analysed is not re-entered, so
|
|
1371
|
+
* recursion cannot loop forever.
|
|
1372
|
+
* descendCache each call site is followed once; both the expression path
|
|
1373
|
+
* and the statement path read the same cached answer.
|
|
1374
|
+
* ---------------------------------------------------------------- */
|
|
1375
|
+
const MAX_CALL_DEPTH = 4;
|
|
1376
|
+
const localFunctions = new Map();
|
|
1377
|
+
const callStack = [];
|
|
1378
|
+
const descendCache = new Map();
|
|
1379
|
+
const localFunctionsFound = collectFunctions(file.root, language);
|
|
1380
|
+
for (const [name, fn] of localFunctionsFound)
|
|
1381
|
+
localFunctions.set(name, fn);
|
|
1382
|
+
// Assigned after `walk` exists; `evaluateCall` reaches it through this box.
|
|
1383
|
+
let descendIntoLocalCall = () => ({
|
|
1384
|
+
handled: false,
|
|
1385
|
+
taint: null,
|
|
1386
|
+
});
|
|
1387
|
+
const step = (kind, node, description, sanitizesKinds) => ({
|
|
1388
|
+
kind,
|
|
1389
|
+
location: nodeLocation(node, toDisplay(currentPath)),
|
|
1390
|
+
description,
|
|
1391
|
+
...(sanitizesKinds ? { sanitizesKinds } : {}),
|
|
1392
|
+
});
|
|
1393
|
+
/**
|
|
1394
|
+
* Is this WHOLE expression one a source names as not the client's - see
|
|
1395
|
+
* SourceSpec.except? Then it is clean, and so is anything read straight out
|
|
1396
|
+
* of it.
|
|
1397
|
+
*/
|
|
1398
|
+
const exceptedSource = (expression) => dictionary.sources.some((s) => !!s.except && s.pattern.test(expression) && s.except.test(expression));
|
|
1399
|
+
/** Does this expression's text look like a known attacker-controlled source? */
|
|
1400
|
+
const sourceTaint = (node, expression) => {
|
|
1401
|
+
for (const source of dictionary.sources) {
|
|
1402
|
+
if (!source.pattern.test(expression))
|
|
1403
|
+
continue;
|
|
1404
|
+
seenSources.add(siteOf(node, currentPath));
|
|
1405
|
+
return {
|
|
1406
|
+
steps: [
|
|
1407
|
+
step('source', node, `\`${expression.slice(0, 60)}\` is ${source.description}`),
|
|
1408
|
+
],
|
|
1409
|
+
sanitizedFor: new Set(),
|
|
1410
|
+
origin: expression.slice(0, 60),
|
|
1411
|
+
unknownHops: [],
|
|
1412
|
+
...(source.browserOnly ? { fromBrowser: true } : {}),
|
|
1413
|
+
...(source.localOnly ? { fromLocal: true } : {}),
|
|
1414
|
+
...(source.fileNameOnly ? { fileNameOnly: true } : {}),
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
return null;
|
|
1418
|
+
};
|
|
1419
|
+
const addStep = (taint, node, description) => ({
|
|
1420
|
+
steps: [...taint.steps, step('propagation', node, description)],
|
|
1421
|
+
sanitizedFor: taint.sanitizedFor,
|
|
1422
|
+
origin: taint.origin,
|
|
1423
|
+
unknownHops: taint.unknownHops,
|
|
1424
|
+
compartment: taint.compartment,
|
|
1425
|
+
branchAssumed: taint.branchAssumed,
|
|
1426
|
+
containerGuess: taint.containerGuess,
|
|
1427
|
+
collectedMany: taint.collectedMany,
|
|
1428
|
+
quotePending: taint.quotePending,
|
|
1429
|
+
urlPrefix: taint.urlPrefix,
|
|
1430
|
+
fromBrowser: taint.fromBrowser,
|
|
1431
|
+
fromLocal: taint.fromLocal,
|
|
1432
|
+
fileNameOnly: taint.fileNameOnly,
|
|
1433
|
+
joined: taint.joined,
|
|
1434
|
+
afterWash: taint.afterWash,
|
|
1435
|
+
});
|
|
1436
|
+
/*
|
|
1437
|
+
* A CHECK IS READ WHERE THE VALUE IS READ, not where it is used.
|
|
1438
|
+
*
|
|
1439
|
+
* The first version of validation guards lived at the sink: it took the
|
|
1440
|
+
* text of the sink's argument, broke it into pieces, and asked whether each
|
|
1441
|
+
* piece had been checked. Text is not a value. `$q = "... '$b'"` had no
|
|
1442
|
+
* pieces at all - string nodes were skipped whole - so a check on `$a`
|
|
1443
|
+
* vouched for `$b`, and DVWA-shaped code printed "checked and cleared" over
|
|
1444
|
+
* a real injection. A call was worse: its arguments were the pieces, so a
|
|
1445
|
+
* function returning request data looked like the checked value it was
|
|
1446
|
+
* handed.
|
|
1447
|
+
*
|
|
1448
|
+
* Here a read of `$_GET['id']` inside `if (ctype_digit($_GET['id']))` comes
|
|
1449
|
+
* back WASHED, like the output of a sanitiser, and everything built from it
|
|
1450
|
+
* inherits that through the ordinary machinery - concatenation keeps only
|
|
1451
|
+
* what both sides washed, so an unchecked value beside it stays dirty. The
|
|
1452
|
+
* check becomes a step on the path, so a sink it does not cover (a URL host,
|
|
1453
|
+
* which digits do not make safe) says so in words.
|
|
1454
|
+
*
|
|
1455
|
+
* VALUES BUILT BEFORE THE CHECK. DVWA's upload/impossible.php builds the
|
|
1456
|
+
* file name from the extension FIRST and checks the extension after:
|
|
1457
|
+
*
|
|
1458
|
+
* $name = bin2hex(random_bytes(16)) . '.' . $ext;
|
|
1459
|
+
* if (strtolower($ext) == 'jpg' || ...) { echo "<a href='$name'>"; }
|
|
1460
|
+
*
|
|
1461
|
+
* The check limits `$ext`, so it limits `$name` - but only while `$ext`
|
|
1462
|
+
* still holds the very value `$name` was built from, and only if `$name`
|
|
1463
|
+
* was built by joining strings, which keeps every character. `throughOf`
|
|
1464
|
+
* records the variables a value was joined through, and `bindingOf` which
|
|
1465
|
+
* assignment a variable's current value came from. Both live in WeakMaps
|
|
1466
|
+
* and are set in exactly two places (a plain assignment, and string
|
|
1467
|
+
* joining), so every other way of making a value - a call, a decode, a
|
|
1468
|
+
* conditional write, a copy with one flag changed - silently has neither,
|
|
1469
|
+
* and is never cleared this way. Absence is the safe default.
|
|
1470
|
+
*/
|
|
1471
|
+
const guardOn = guardsApply(language);
|
|
1472
|
+
const throughOf = new WeakMap();
|
|
1473
|
+
const bindingOf = new WeakMap();
|
|
1474
|
+
const guarded = (taint, node, expression, scope) => {
|
|
1475
|
+
if (!guardOn)
|
|
1476
|
+
return taint;
|
|
1477
|
+
const facts = guardFactsAt(node, language);
|
|
1478
|
+
if (facts.size === 0)
|
|
1479
|
+
return taint;
|
|
1480
|
+
let fact = facts.get(normalizeExpression(expression));
|
|
1481
|
+
let via = null;
|
|
1482
|
+
if (!fact) {
|
|
1483
|
+
const through = throughOf.get(taint);
|
|
1484
|
+
if (through) {
|
|
1485
|
+
for (const [checked, candidate] of facts) {
|
|
1486
|
+
const current = scope.env.get(checked);
|
|
1487
|
+
const binding = current ? bindingOf.get(current) : undefined;
|
|
1488
|
+
if (binding && through.has(binding)) {
|
|
1489
|
+
fact = candidate;
|
|
1490
|
+
via = checked;
|
|
1491
|
+
break;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (!fact)
|
|
1497
|
+
return taint;
|
|
1498
|
+
const { kinds, insideQuotesOnly } = guardClears(fact);
|
|
1499
|
+
const unconditional = kinds.filter((k) => !insideQuotesOnly.includes(k));
|
|
1500
|
+
const before = taint.quotePending ?? [];
|
|
1501
|
+
const added = kinds.filter((k) => !taint.sanitizedFor.has(k) || before.includes(k));
|
|
1502
|
+
if (added.length === 0)
|
|
1503
|
+
return taint;
|
|
1504
|
+
// A kind washed for good leaves the quote question; a word adds SQL to it
|
|
1505
|
+
// unless SQL was already washed for good by something earlier.
|
|
1506
|
+
const pending = [
|
|
1507
|
+
...new Set([
|
|
1508
|
+
...before.filter((k) => !unconditional.includes(k)),
|
|
1509
|
+
...insideQuotesOnly.filter((k) => !taint.sanitizedFor.has(k) || before.includes(k)),
|
|
1510
|
+
]),
|
|
1511
|
+
];
|
|
1512
|
+
const what = describeFact(fact);
|
|
1513
|
+
return {
|
|
1514
|
+
...taint,
|
|
1515
|
+
steps: [
|
|
1516
|
+
...taint.steps,
|
|
1517
|
+
{
|
|
1518
|
+
...step('sanitizer', node, via
|
|
1519
|
+
? `built by joining strings from \`${via}\`, which still holds that same value here and was ${what}`
|
|
1520
|
+
: what, kinds),
|
|
1521
|
+
...(insideQuotesOnly.length > 0 ? { quoteDependent: true, quoteKinds: insideQuotesOnly } : {}),
|
|
1522
|
+
guard: true,
|
|
1523
|
+
},
|
|
1524
|
+
],
|
|
1525
|
+
sanitizedFor: new Set([...taint.sanitizedFor, ...kinds]),
|
|
1526
|
+
quotePending: pending.length > 0 ? pending : undefined,
|
|
1527
|
+
afterWash: washedNow(taint.afterWash, kinds),
|
|
1528
|
+
};
|
|
1529
|
+
};
|
|
1530
|
+
/**
|
|
1531
|
+
* Several dirty values become one: a template, a container literal. The
|
|
1532
|
+
* result is washed only for what EVERY part was washed for, and the path is
|
|
1533
|
+
* told through the least-washed part, so a reader sees the value that is
|
|
1534
|
+
* actually dangerous rather than its cleaned neighbour.
|
|
1535
|
+
*/
|
|
1536
|
+
const joinParts = (parts) => {
|
|
1537
|
+
const first = parts[0];
|
|
1538
|
+
if (parts.length === 1)
|
|
1539
|
+
return first;
|
|
1540
|
+
const narrator = parts.reduce((a, b) => (b.sanitizedFor.size < a.sanitizedFor.size ? b : a));
|
|
1541
|
+
const pending = [...new Set(parts.flatMap((p) => p.quotePending ?? []))];
|
|
1542
|
+
const sanitizedFor = new Set([...first.sanitizedFor].filter((k) => parts.every((p) => p.sanitizedFor.has(k))));
|
|
1543
|
+
return {
|
|
1544
|
+
...narrator,
|
|
1545
|
+
sanitizedFor,
|
|
1546
|
+
unknownHops: [...new Set(parts.flatMap((p) => p.unknownHops))],
|
|
1547
|
+
quotePending: pending.length > 0 ? pending : undefined,
|
|
1548
|
+
branchAssumed: parts.every((p) => p.branchAssumed) ? true : undefined,
|
|
1549
|
+
containerGuess: parts.every((p) => p.containerGuess) ? true : undefined,
|
|
1550
|
+
collectedMany: parts.some((p) => p.collectedMany) ? true : undefined,
|
|
1551
|
+
// Where the FIRST dirty part starts is where the attacker's text starts.
|
|
1552
|
+
urlPrefix: first.urlPrefix,
|
|
1553
|
+
fromBrowser: parts.every((p) => p.fromBrowser) ? true : undefined,
|
|
1554
|
+
fromLocal: parts.every((p) => p.fromLocal) ? true : undefined,
|
|
1555
|
+
fileNameOnly: parts.every((p) => p.fileNameOnly) ? true : undefined,
|
|
1556
|
+
joined: true,
|
|
1557
|
+
afterWash: joinedAfterWash(parts, sanitizedFor),
|
|
1558
|
+
};
|
|
1559
|
+
};
|
|
1560
|
+
/** What both joined values passed through - see `throughOf`. */
|
|
1561
|
+
const joinedThrough = (left, right) => {
|
|
1562
|
+
if (left && right) {
|
|
1563
|
+
const a = throughOf.get(left);
|
|
1564
|
+
const b = throughOf.get(right);
|
|
1565
|
+
if (!a || !b)
|
|
1566
|
+
return undefined;
|
|
1567
|
+
const both = new Set([...a].filter((k) => b.has(k)));
|
|
1568
|
+
return both.size > 0 ? both : undefined;
|
|
1569
|
+
}
|
|
1570
|
+
const only = left ?? right;
|
|
1571
|
+
return only ? throughOf.get(only) : undefined;
|
|
1572
|
+
};
|
|
1573
|
+
/**
|
|
1574
|
+
* WHERE AN ESCAPED VALUE IS SPLICED DECIDES WHETHER THE ESCAPING COUNTS.
|
|
1575
|
+
*
|
|
1576
|
+
* Inside quotes, escape-only cleaning (see SanitizerSpec.onlyInsideQuotes) is
|
|
1577
|
+
* the whole defence, so it stands. Outside them it protects nothing: the
|
|
1578
|
+
* kinds go back out of sanitizedFor, and the path says why, so a reader sees
|
|
1579
|
+
* the escaping AND the reason it did not help.
|
|
1580
|
+
*/
|
|
1581
|
+
const placeInString = (taint, quoted, node) => {
|
|
1582
|
+
const pending = taint.quotePending;
|
|
1583
|
+
if (!pending || pending.length === 0)
|
|
1584
|
+
return taint;
|
|
1585
|
+
if (quoted)
|
|
1586
|
+
return { ...taint, quotePending: undefined };
|
|
1587
|
+
return {
|
|
1588
|
+
...taint,
|
|
1589
|
+
steps: [
|
|
1590
|
+
...taint.steps,
|
|
1591
|
+
step('propagation', node, taint.steps.some((s) => s.quoteDependent && !s.guard)
|
|
1592
|
+
? 'placed OUTSIDE quotes in the string it is built into, where the escaping applied earlier ' +
|
|
1593
|
+
'protects nothing - escaping only works on a value inside quotes'
|
|
1594
|
+
: 'placed OUTSIDE quotes in the string it is built into, where a word the check let through ' +
|
|
1595
|
+
'is read as SQL - a column name or a keyword - rather than as data'),
|
|
1596
|
+
],
|
|
1597
|
+
sanitizedFor: new Set([...taint.sanitizedFor].filter((k) => !pending.includes(k))),
|
|
1598
|
+
quotePending: undefined,
|
|
1599
|
+
};
|
|
1600
|
+
};
|
|
1601
|
+
/**
|
|
1602
|
+
* The heart of it: "is this expression dirty, and how did it get that way?"
|
|
1603
|
+
* Returns null for "clean, or we cannot tell" - deliberately the same answer,
|
|
1604
|
+
* because the engine never claims cleanliness it has not proven.
|
|
1605
|
+
*/
|
|
1606
|
+
const evaluate = (node, scope, depth = 0) => {
|
|
1607
|
+
if (!node || depth > 24)
|
|
1608
|
+
return null;
|
|
1609
|
+
const expression = text(node);
|
|
1610
|
+
if (UNWRAP_NODES.has(node.type)) {
|
|
1611
|
+
/*
|
|
1612
|
+
* Take the VALUE, not the first named child.
|
|
1613
|
+
*
|
|
1614
|
+
* Every wrapper here used to be unwrapped by grabbing child 0, which is
|
|
1615
|
+
* right for `(x)` and `await x` and wrong for a Java cast: the first
|
|
1616
|
+
* named child of `(String) map.get("k")` is the type name `String`, so
|
|
1617
|
+
* the tracer solemnly evaluated the word "String" and reported the value
|
|
1618
|
+
* clean. `value` is the field the Java grammar gives the expression; the
|
|
1619
|
+
* last named child is the fallback for wrappers that name no field.
|
|
1620
|
+
*/
|
|
1621
|
+
const inner = node.childForFieldName('value') ??
|
|
1622
|
+
(TYPE_FIRST_WRAPPERS.has(node.type)
|
|
1623
|
+
? node.namedChildren.filter((c) => c !== null).pop() ?? null
|
|
1624
|
+
: node.namedChildren.find((c) => c !== null) ?? null);
|
|
1625
|
+
return evaluate(inner, scope, depth + 1);
|
|
1626
|
+
}
|
|
1627
|
+
// Go writes `a, b := f(), g()`. A single-valued list is just the value.
|
|
1628
|
+
// A multi-valued one we do not split - documented as a Go imprecision.
|
|
1629
|
+
if (node.type === 'expression_list' && node.namedChildCount === 1) {
|
|
1630
|
+
return evaluate(node.namedChildren[0] ?? null, scope, depth + 1);
|
|
1631
|
+
}
|
|
1632
|
+
if (VARIABLE_NODES.has(node.type)) {
|
|
1633
|
+
const found = scope.env.get(expression) ?? sourceTaint(node, expression);
|
|
1634
|
+
return found ? guarded(found, node, expression, scope) : null;
|
|
1635
|
+
}
|
|
1636
|
+
// `lst[1]` / `d['k']` on a local list or dict whose every write is on the
|
|
1637
|
+
// page - see resolvePythonListRead / resolvePythonDictRead. A literal there
|
|
1638
|
+
// is clean; a name nothing mentions between the write and here is traced
|
|
1639
|
+
// as itself, with no guess attached. Anything else keeps the
|
|
1640
|
+
// whole-container answer below.
|
|
1641
|
+
if (language === 'python' && node.type === 'subscript') {
|
|
1642
|
+
const read = resolveContainerRead(node, language);
|
|
1643
|
+
const value = read?.value;
|
|
1644
|
+
const where = read && `\`${expression.slice(0, 50)}\` returns what was stored at ${read.selector} on line ${read.write.startPosition.row + 1}`;
|
|
1645
|
+
if (read && value &&
|
|
1646
|
+
(['integer', 'float', 'true', 'false', 'none'].includes(value.type) ||
|
|
1647
|
+
(value.type === 'string' && !value.namedChildren.some((c) => c?.type === 'interpolation')))) {
|
|
1648
|
+
noteResolvedRead(node, scope.filePath, `${where}, a literal`);
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
const fn = read && enclosingPythonFunction(node);
|
|
1652
|
+
if (read && value?.type === 'identifier' && fn && !mentionedBetween(fn, value.text, read.write.endIndex, node.startIndex)) {
|
|
1653
|
+
noteResolvedRead(node, scope.filePath, `${where}, \`${value.text}\``);
|
|
1654
|
+
const stored = evaluate(value, scope, depth + 1);
|
|
1655
|
+
return stored ? addStep(stored, node, `read back from \`${read.container}\` at ${read.selector}`) : null;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (MEMBER_NODES.has(node.type)) {
|
|
1659
|
+
if (exceptedSource(expression))
|
|
1660
|
+
return null;
|
|
1661
|
+
// `req.query.id` matches a source pattern as a whole.
|
|
1662
|
+
const direct = sourceTaint(node, expression);
|
|
1663
|
+
if (direct)
|
|
1664
|
+
return guarded(direct, node, expression, scope);
|
|
1665
|
+
// Otherwise dirt flows outward: if `data` is dirty, so is `data.name`.
|
|
1666
|
+
const object = node.childForFieldName('object') ??
|
|
1667
|
+
node.childForFieldName('value') ??
|
|
1668
|
+
node.namedChildren.find((c) => c !== null) ??
|
|
1669
|
+
null;
|
|
1670
|
+
const inner = evaluate(object, scope, depth + 1);
|
|
1671
|
+
if (!inner)
|
|
1672
|
+
return null;
|
|
1673
|
+
const read = addStep(inner, node, `read out of a tainted value as \`${expression.slice(0, 50)}\``);
|
|
1674
|
+
// One element of several: which one is a guess - see collectedMany.
|
|
1675
|
+
const oneOfMany = ELEMENT_TARGETS.has(node.type) && inner.collectedMany ? { ...read, containerGuess: true } : read;
|
|
1676
|
+
return guarded(oneOfMany, node, expression, scope);
|
|
1677
|
+
}
|
|
1678
|
+
if (node.type === 'binary_expression' || node.type === 'binary_operator') {
|
|
1679
|
+
const operator = node.childForFieldName('operator')?.text ?? '';
|
|
1680
|
+
/*
|
|
1681
|
+
* Two different reasons an operator is on this list.
|
|
1682
|
+
*
|
|
1683
|
+
* COMPOSITION - `+`, `%`, `.` - builds a new value that CONTAINS the
|
|
1684
|
+
* operands. `.` is PHP's concatenation operator; every other language
|
|
1685
|
+
* here uses `+`, and `%` is Python's old-style format.
|
|
1686
|
+
*
|
|
1687
|
+
* SELECTION - `??`, `||`, `&&` - builds nothing and RETURNS one of the
|
|
1688
|
+
* operands unchanged. `dirty ?? "fallback"` is `dirty` on every run
|
|
1689
|
+
* where `dirty` exists, which is every run an attacker cares about.
|
|
1690
|
+
*
|
|
1691
|
+
* Selection was missing, and the cost was quiet: the tracer returned
|
|
1692
|
+
* null, the finding fell back to signature-based, and the report said a
|
|
1693
|
+
* pattern had been matched when a path was in fact walkable. Nothing was
|
|
1694
|
+
* hidden - the finding still appeared - but a provable claim was being
|
|
1695
|
+
* reported as a guess, which is the honesty contract failing in the
|
|
1696
|
+
* cautious direction rather than the loud one. Worth fixing for the same
|
|
1697
|
+
* reason the loud direction is: the label is supposed to mean something.
|
|
1698
|
+
*
|
|
1699
|
+
* This is yesterday's Java argument - a null default is not a sanitiser -
|
|
1700
|
+
* arriving in the languages that spell it with an operator instead of an
|
|
1701
|
+
* `if`. It went unnoticed there because JavaScript's version has no
|
|
1702
|
+
* statement to look at.
|
|
1703
|
+
*
|
|
1704
|
+
* The merge below is already right for both kinds. Composition needs the
|
|
1705
|
+
* union of what is dirty and the intersection of what was washed;
|
|
1706
|
+
* selection needs the same answer for a different reason, because either
|
|
1707
|
+
* operand could be the one that comes out.
|
|
1708
|
+
*/
|
|
1709
|
+
if (!BINARY_CARRIERS.has(operator))
|
|
1710
|
+
return null;
|
|
1711
|
+
const leftNode = node.childForFieldName('left');
|
|
1712
|
+
let left = evaluate(leftNode, scope, depth + 1);
|
|
1713
|
+
let right = evaluate(node.childForFieldName('right'), scope, depth + 1);
|
|
1714
|
+
// Joining strings settles any quote-dependent cleaning - see placeInString.
|
|
1715
|
+
// A right operand is quoted when the literal text before it opens a quote;
|
|
1716
|
+
// a left operand still unsettled here starts the string, so nothing quotes it.
|
|
1717
|
+
if (operator === '+' || operator === '.') {
|
|
1718
|
+
if (right?.quotePending?.length)
|
|
1719
|
+
right = placeInString(right, opensAQuote(literalTextOf(leftNode)), node);
|
|
1720
|
+
if (left?.quotePending?.length)
|
|
1721
|
+
left = placeInString(left, false, node);
|
|
1722
|
+
}
|
|
1723
|
+
const taint = left ?? right;
|
|
1724
|
+
if (!taint)
|
|
1725
|
+
return null;
|
|
1726
|
+
// If both sides are dirty, keep the union of what has been washed.
|
|
1727
|
+
const pendingBoth = [...new Set([...(left?.quotePending ?? []), ...(right?.quotePending ?? [])])];
|
|
1728
|
+
// Told through the less-washed side - see the note on template parts.
|
|
1729
|
+
const narrator = left && right && right.sanitizedFor.size < left.sanitizedFor.size ? right : left;
|
|
1730
|
+
const merged = left && right
|
|
1731
|
+
? {
|
|
1732
|
+
steps: narrator.steps,
|
|
1733
|
+
sanitizedFor: new Set([...left.sanitizedFor].filter((k) => right.sanitizedFor.has(k))),
|
|
1734
|
+
origin: narrator.origin,
|
|
1735
|
+
unknownHops: [...left.unknownHops, ...right.unknownHops],
|
|
1736
|
+
quotePending: pendingBoth.length > 0 ? pendingBoth : undefined,
|
|
1737
|
+
// Joined, the string is dirty for certain if EITHER side is - so
|
|
1738
|
+
// an assumption survives only when both sides rest on one.
|
|
1739
|
+
branchAssumed: left.branchAssumed && right.branchAssumed ? true : undefined,
|
|
1740
|
+
containerGuess: left.containerGuess && right.containerGuess ? true : undefined,
|
|
1741
|
+
urlPrefix: left.urlPrefix,
|
|
1742
|
+
// The joined string is browser-only only if both halves are.
|
|
1743
|
+
fromBrowser: left.fromBrowser && right.fromBrowser ? true : undefined,
|
|
1744
|
+
// Local only if both halves are: one request value makes it the attacker's.
|
|
1745
|
+
fromLocal: left.fromLocal && right.fromLocal ? true : undefined,
|
|
1746
|
+
fileNameOnly: left.fileNameOnly && right.fileNameOnly ? true : undefined,
|
|
1747
|
+
joined: true,
|
|
1748
|
+
afterWash: joinedAfterWash([left, right], new Set([...left.sanitizedFor].filter((k) => right.sanitizedFor.has(k)))),
|
|
1749
|
+
}
|
|
1750
|
+
: taint;
|
|
1751
|
+
/*
|
|
1752
|
+
* Where the dirty part now sits. A dirty left side starts the string and
|
|
1753
|
+
* keeps what it had; a dirty right side has everything on the left in
|
|
1754
|
+
* front of it. `%` formats rather than joins, so the position inside the
|
|
1755
|
+
* format string is not known - and unknown means "starts the string".
|
|
1756
|
+
*/
|
|
1757
|
+
const urlPrefix = operator !== '+' && operator !== '.'
|
|
1758
|
+
? undefined
|
|
1759
|
+
: left
|
|
1760
|
+
? left.urlPrefix
|
|
1761
|
+
: shapeOf(leftNode) + (right?.urlPrefix ?? '');
|
|
1762
|
+
const joined = addStep({ ...merged, urlPrefix }, node, operator === '%'
|
|
1763
|
+
? 'formatted into a larger string with %'
|
|
1764
|
+
: `concatenated into a larger string with ${operator}`);
|
|
1765
|
+
const through = guardOn ? joinedThrough(left, right) : undefined;
|
|
1766
|
+
if (through)
|
|
1767
|
+
throughOf.set(joined, through);
|
|
1768
|
+
return joined;
|
|
1769
|
+
}
|
|
1770
|
+
if (CONTAINER_NODES.has(node.type)) {
|
|
1771
|
+
/*
|
|
1772
|
+
* EVERY ELEMENT, for the reason the template note below gives. This also
|
|
1773
|
+
* returned on the first dirty element, so `[shellQuote(a), b]` was the
|
|
1774
|
+
* quoted `a`, and `exec('ls ' + args.join(' '))` was printed as checked
|
|
1775
|
+
* and cleared with a raw `b` in the command line.
|
|
1776
|
+
*/
|
|
1777
|
+
const elements = [];
|
|
1778
|
+
for (const element of node.namedChildren) {
|
|
1779
|
+
if (!element)
|
|
1780
|
+
continue;
|
|
1781
|
+
const taint = evaluate(element, scope, depth + 1);
|
|
1782
|
+
if (taint)
|
|
1783
|
+
elements.push(taint);
|
|
1784
|
+
}
|
|
1785
|
+
if (elements.length === 0)
|
|
1786
|
+
return null;
|
|
1787
|
+
return addStep(joinParts(elements), node, 'placed into a container - we taint the container whole, not per element');
|
|
1788
|
+
}
|
|
1789
|
+
if (STRING_NODES.has(node.type)) {
|
|
1790
|
+
/*
|
|
1791
|
+
* A template literal or f-string: the dirt is inside the ${...} / {...}.
|
|
1792
|
+
*
|
|
1793
|
+
* EVERY PART, NOT THE FIRST DIRTY ONE. This loop used to return on the
|
|
1794
|
+
* first part that carried taint, so the string took that part's washing
|
|
1795
|
+
* as its own:
|
|
1796
|
+
*
|
|
1797
|
+
* $a = intval($_GET['a']);
|
|
1798
|
+
* $q = "INSERT INTO t VALUES ({$a}, '{$ip}')"; // $ip is a header
|
|
1799
|
+
*
|
|
1800
|
+
* `$a` came first, `$a` was washed for SQL, and the query was printed as
|
|
1801
|
+
* "checked and cleared" with the X-Forwarded-For header spliced into it
|
|
1802
|
+
* raw. Concatenation with `.` already kept only what BOTH sides washed;
|
|
1803
|
+
* interpolation is the same operation written differently and now gets
|
|
1804
|
+
* the same answer. Found in DVWA's bac/medium.php, where the first part
|
|
1805
|
+
* became washed once validation checks were read.
|
|
1806
|
+
*/
|
|
1807
|
+
const parts = [];
|
|
1808
|
+
for (const child of node.namedChildren) {
|
|
1809
|
+
if (!child)
|
|
1810
|
+
continue;
|
|
1811
|
+
// PHP splices a variable into a double-quoted string with no wrapper
|
|
1812
|
+
// node at all: "id = $id" holds a `variable_name` child directly, where
|
|
1813
|
+
// JS and Python wrap theirs in a substitution node. So the variable IS
|
|
1814
|
+
// the interpolation, and it is evaluated in place.
|
|
1815
|
+
const inner = INTERPOLATION_NODES.has(child.type)
|
|
1816
|
+
? (child.namedChildren.find((c) => c !== null) ?? null)
|
|
1817
|
+
: VARIABLE_NODES.has(child.type) || MEMBER_NODES.has(child.type)
|
|
1818
|
+
? child
|
|
1819
|
+
: null;
|
|
1820
|
+
if (!inner)
|
|
1821
|
+
continue;
|
|
1822
|
+
const taint = evaluate(inner, scope, depth + 1);
|
|
1823
|
+
if (taint) {
|
|
1824
|
+
// Quote-dependent cleaning is settled by what comes before the splice
|
|
1825
|
+
// in this literal. If the offsets do not line up with the text, the
|
|
1826
|
+
// position is unknown and the value is treated as unquoted.
|
|
1827
|
+
let placed = taint;
|
|
1828
|
+
const offset = child.startIndex - node.startIndex;
|
|
1829
|
+
const lined = node.text.slice(offset, offset + child.text.length) === child.text;
|
|
1830
|
+
if (taint.quotePending?.length) {
|
|
1831
|
+
placed = placeInString(taint, lined && opensAQuote(afterOpening(node.text.slice(0, offset))), node);
|
|
1832
|
+
}
|
|
1833
|
+
// What precedes the value in this literal. If the offsets do not
|
|
1834
|
+
// line up with the text, where it sits is unknown: treated as the
|
|
1835
|
+
// start of the string, which keeps the old answer.
|
|
1836
|
+
const before = lined ? templateShapeBefore(node, child) : undefined;
|
|
1837
|
+
const shaped = { ...placed, urlPrefix: before === undefined ? undefined : before + (placed.urlPrefix ?? '') };
|
|
1838
|
+
if (throughOf.has(placed))
|
|
1839
|
+
throughOf.set(shaped, throughOf.get(placed));
|
|
1840
|
+
parts.push(shaped);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (parts.length === 0)
|
|
1844
|
+
return null;
|
|
1845
|
+
const result = addStep(joinParts(parts), node, 'interpolated into a string template');
|
|
1846
|
+
if (guardOn) {
|
|
1847
|
+
const sets = parts.map((p) => throughOf.get(p));
|
|
1848
|
+
if (sets.every((t) => t !== undefined)) {
|
|
1849
|
+
const shared = new Set([...sets[0]].filter((k) => sets.every((t) => t.has(k))));
|
|
1850
|
+
if (shared.size > 0)
|
|
1851
|
+
throughOf.set(result, shared);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
return result;
|
|
1855
|
+
}
|
|
1856
|
+
/*
|
|
1857
|
+
* A COMPREHENSION THAT ESCAPES EVERY ELEMENT.
|
|
1858
|
+
*
|
|
1859
|
+
* kwargs_safe = {k: conditional_escape(v) for (k, v) in kwargs.items()}
|
|
1860
|
+
*
|
|
1861
|
+
* The line directly below the map() call in Django's format_html. Both
|
|
1862
|
+
* idioms sit in the same six-line function, so covering one and not the
|
|
1863
|
+
* other would have left the very case that started this half-fixed - and
|
|
1864
|
+
* did, until the fixture caught it.
|
|
1865
|
+
*
|
|
1866
|
+
* Same rule as the higher-order applier: if EVERY element passes through a
|
|
1867
|
+
* sanitiser, the collection is washed for that sanitiser's kinds. The
|
|
1868
|
+
* element expression is the whole test - a comprehension whose body is a
|
|
1869
|
+
* bare name, or a call this engine does not know, proves nothing and is
|
|
1870
|
+
* asserted as still-reported in vulnerable/higher-order-escape-fence.py.
|
|
1871
|
+
*/
|
|
1872
|
+
if (COMPREHENSION_NODES.has(node.type)) {
|
|
1873
|
+
const body = node.namedChildren.find((c) => c !== null);
|
|
1874
|
+
// A dict comprehension's body is a `pair`; the VALUE is what gets escaped.
|
|
1875
|
+
const element = body?.type === 'pair'
|
|
1876
|
+
? body.childForFieldName('value') ?? body
|
|
1877
|
+
: body;
|
|
1878
|
+
const applied = element && CALL_NODES.has(element.type)
|
|
1879
|
+
? dictionary.sanitizers.find((san) => san.names.includes(lastSegment(calleeOf(element).text)))
|
|
1880
|
+
: undefined;
|
|
1881
|
+
if (applied && element) {
|
|
1882
|
+
for (const child of node.namedChildren) {
|
|
1883
|
+
if (!child || child === body)
|
|
1884
|
+
continue;
|
|
1885
|
+
const iterated = child.childForFieldName('right') ?? child;
|
|
1886
|
+
const taint = evaluate(iterated, scope, depth + 1);
|
|
1887
|
+
if (!taint)
|
|
1888
|
+
continue;
|
|
1889
|
+
return {
|
|
1890
|
+
steps: [
|
|
1891
|
+
...taint.steps,
|
|
1892
|
+
step('sanitizer', node, `every element passed through \`${lastSegment(calleeOf(element).text)}()\` ` +
|
|
1893
|
+
`by a comprehension - ${applied.description}`, applied.kinds),
|
|
1894
|
+
],
|
|
1895
|
+
sanitizedFor: new Set([...taint.sanitizedFor, ...applied.kinds]),
|
|
1896
|
+
origin: taint.origin,
|
|
1897
|
+
unknownHops: taint.unknownHops,
|
|
1898
|
+
afterWash: washedNow(taint.afterWash, applied.kinds),
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
// `cond ? a : b` and Python's `a if cond else b`. We do not evaluate the
|
|
1904
|
+
// condition - if EITHER branch can be dirty, the result can be dirty.
|
|
1905
|
+
if (node.type === 'ternary_expression' || node.type === 'conditional_expression') {
|
|
1906
|
+
/*
|
|
1907
|
+
* When the condition is decidable, only the branch that runs can carry a
|
|
1908
|
+
* value. `(7*18)+106 > 200 ? "constant" : param` is the constant, every
|
|
1909
|
+
* time, and the taint on the other side is in unreachable code.
|
|
1910
|
+
*
|
|
1911
|
+
* When it is not decidable, fall through to the original behaviour: take
|
|
1912
|
+
* whichever branch is dirty, because either might run.
|
|
1913
|
+
*/
|
|
1914
|
+
const parts = ternaryParts(node);
|
|
1915
|
+
const truth = conditionTruth(parts.condition, scope.body);
|
|
1916
|
+
if (truth !== undefined) {
|
|
1917
|
+
const taken = truth ? parts.consequence : parts.alternative;
|
|
1918
|
+
const taint = taken ? evaluate(taken, scope, depth + 1) : null;
|
|
1919
|
+
return taint ? addStep(taint, node, 'returned from one branch of a conditional') : null;
|
|
1920
|
+
}
|
|
1921
|
+
/*
|
|
1922
|
+
* The VALUE of `c ? a : b` is a or b. This loop used to walk every child,
|
|
1923
|
+
* the condition included, so `isset($_COOKIE['r']) ? $_COOKIE['r'] : 'x'`
|
|
1924
|
+
* was proven through isset() - a true/false answer - and
|
|
1925
|
+
* `strlen($q) > 9 ? 'long' : 'short'` could only ever be the two words.
|
|
1926
|
+
* The one form whose condition IS the value, `$a ?: $b`, comes back from
|
|
1927
|
+
* ternaryParts with the condition as its consequence.
|
|
1928
|
+
*
|
|
1929
|
+
* A shape ternaryParts cannot split keeps the old walk: carrying too much
|
|
1930
|
+
* is the failure this engine prefers to carrying too little.
|
|
1931
|
+
*/
|
|
1932
|
+
const branches = [parts.consequence, parts.alternative].filter((b) => b !== null);
|
|
1933
|
+
for (const child of branches.length > 0 ? branches : node.namedChildren) {
|
|
1934
|
+
if (!child)
|
|
1935
|
+
continue;
|
|
1936
|
+
const taint = evaluate(child, scope, depth + 1);
|
|
1937
|
+
if (taint)
|
|
1938
|
+
return addStep(taint, node, 'returned from one branch of a conditional');
|
|
1939
|
+
}
|
|
1940
|
+
return null;
|
|
1941
|
+
}
|
|
1942
|
+
if (CALL_NODES.has(node.type)) {
|
|
1943
|
+
return evaluateCall(node, scope, depth);
|
|
1944
|
+
}
|
|
1945
|
+
return null;
|
|
1946
|
+
};
|
|
1947
|
+
const evaluateCall = (node, scope, depth) => {
|
|
1948
|
+
const { text: calleeText, receiver } = calleeOf(node);
|
|
1949
|
+
const name = lastSegment(calleeText);
|
|
1950
|
+
const args = argumentsOf(node);
|
|
1951
|
+
// Is the CALL ITSELF a source? e.g. searchParams.get('q'), input().
|
|
1952
|
+
//
|
|
1953
|
+
// Test the callee only, never the whole call. Testing the whole call text
|
|
1954
|
+
// was a real bug: `parseInt(req.query.id)` contains "req.query", so the
|
|
1955
|
+
// sanitiser was skipped and the SANITISED value was reported as a verified
|
|
1956
|
+
// vulnerability. A source is about where a value comes FROM, not about what
|
|
1957
|
+
// characters appear anywhere inside the expression.
|
|
1958
|
+
if (exceptedSource(text(node)))
|
|
1959
|
+
return null;
|
|
1960
|
+
/*
|
|
1961
|
+
* ...AND NOT THE ARGUMENTS OF CALLS IN THE RECEIVER CHAIN EITHER. The callee
|
|
1962
|
+
* of `crypto.createHash('sha256').update(req.query.text).digest('hex')` is
|
|
1963
|
+
* everything before the last `(`, which contains `req.query` - so the digest
|
|
1964
|
+
* call was treated as a SOURCE, and every sanitiser between the request and
|
|
1965
|
+
* the end of the chain was skipped: `escape(req.query.q).trim()` was the
|
|
1966
|
+
* raw request value. Calls in the chain are emptied before the test, so a
|
|
1967
|
+
* source is matched by the names it is reached through, never by what is
|
|
1968
|
+
* passed to something on the way.
|
|
1969
|
+
*/
|
|
1970
|
+
const asSource = sourceTaint(node, `${withoutCallArguments(calleeText)}(`);
|
|
1971
|
+
if (asSource)
|
|
1972
|
+
return asSource;
|
|
1973
|
+
/*
|
|
1974
|
+
* A READ FROM A LOCAL MAP WHOSE EVERY WRITE IS ON THE PAGE.
|
|
1975
|
+
*
|
|
1976
|
+
* The default answer to `map.get(k)` is the whole map's taint, labelled a
|
|
1977
|
+
* guess about which element came out. When local-map.ts can show which put
|
|
1978
|
+
* a read returns, the answer is that put's value instead - a literal is
|
|
1979
|
+
* clean, and a variable is traced as itself, with no guess attached.
|
|
1980
|
+
*
|
|
1981
|
+
* A variable is only read at the `get` if nothing reassigns it between the
|
|
1982
|
+
* put and the read - the map keeps the value it was given, and evaluating a
|
|
1983
|
+
* reassigned name here would describe the new value, not the stored one -
|
|
1984
|
+
* and if its declared type cannot change in place (declaredImmutable): a
|
|
1985
|
+
* StringBuilder or an array in the map changes whenever an alias of it
|
|
1986
|
+
* does. Anything else falls through to the whole-collection answer.
|
|
1987
|
+
*/
|
|
1988
|
+
if (language === 'java' && name === 'get') {
|
|
1989
|
+
const read = resolveContainerRead(node, language);
|
|
1990
|
+
if (read) {
|
|
1991
|
+
const line = read.write.startPosition.row + 1;
|
|
1992
|
+
const where = `\`${read.container}.get(${read.selector.startsWith('index') ? read.selector.slice(6) : read.selector})\` returns what was stored at ${read.selector} on line ${line}`;
|
|
1993
|
+
if (JAVA_LITERALS.has(read.value.type)) {
|
|
1994
|
+
noteResolvedRead(node, scope.filePath, `${where}, a literal`);
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
const method = enclosingMethod(node);
|
|
1998
|
+
if (read.value.type === 'identifier' &&
|
|
1999
|
+
method &&
|
|
2000
|
+
declaredImmutable(method, read.value.text) &&
|
|
2001
|
+
!writtenBetween(method, read.value.text, read.write.endIndex, node.startIndex)) {
|
|
2002
|
+
noteResolvedRead(node, scope.filePath, `${where}, \`${read.value.text}\``);
|
|
2003
|
+
const stored = evaluate(read.value, scope, depth + 1);
|
|
2004
|
+
return stored
|
|
2005
|
+
? addStep(stored, node, `read back from \`${read.container}\` at ${read.selector} - the value stored there on line ${line}`)
|
|
2006
|
+
: null;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
/*
|
|
2011
|
+
* A SANITISER PASSED AS A VALUE, NOT CALLED.
|
|
2012
|
+
*
|
|
2013
|
+
* args_safe = map(conditional_escape, args) django/utils/html.py
|
|
2014
|
+
*
|
|
2015
|
+
* That is Django's format_html - the function its own docstring calls the
|
|
2016
|
+
* one you should use instead of str.format to build HTML - and this scanner
|
|
2017
|
+
* reported it as flow-verified XSS. Eighth time this project has punished a
|
|
2018
|
+
* fix, and the most prominent target yet.
|
|
2019
|
+
*
|
|
2020
|
+
* Everything needed was already present. `mark_safe` was a sink, correctly:
|
|
2021
|
+
* mark_safe(user_input) really is dangerous. `conditional_escape` was in the
|
|
2022
|
+
* sanitiser list. What nothing modelled is that the escaping happened
|
|
2023
|
+
* through a HIGHER-ORDER call: the sanitiser was never written as
|
|
2024
|
+
* `escape(x)` at a place the tracer watches, it was handed to map() as a
|
|
2025
|
+
* value and applied out of sight.
|
|
2026
|
+
*
|
|
2027
|
+
* A sanitiser recognised only in the shape `escape(x)` stops working the
|
|
2028
|
+
* moment somebody writes idiomatic Python - and both idioms sit on adjacent
|
|
2029
|
+
* lines of that one Django function, map() then a dict comprehension.
|
|
2030
|
+
*
|
|
2031
|
+
* The cost of missing it compounds: the second Django finding was a CALLER
|
|
2032
|
+
* of format_html. One unmodelled shape at the bottom of a framework
|
|
2033
|
+
* propagates into every place that uses it.
|
|
2034
|
+
*/
|
|
2035
|
+
if (HIGHER_ORDER_APPLIERS.has(name)) {
|
|
2036
|
+
for (const arg of args) {
|
|
2037
|
+
const applied = dictionary.sanitizers.find((san) => san.names.includes(lastSegment(text(arg))));
|
|
2038
|
+
if (!applied)
|
|
2039
|
+
continue;
|
|
2040
|
+
// The collection being mapped over is the OTHER argument.
|
|
2041
|
+
for (const other of args) {
|
|
2042
|
+
if (other === arg)
|
|
2043
|
+
continue;
|
|
2044
|
+
const taint = evaluate(other, scope, depth + 1);
|
|
2045
|
+
if (!taint)
|
|
2046
|
+
continue;
|
|
2047
|
+
return {
|
|
2048
|
+
steps: [
|
|
2049
|
+
...taint.steps,
|
|
2050
|
+
step('sanitizer', node, `every element passed through \`${lastSegment(text(arg))}()\` by ` +
|
|
2051
|
+
`\`${name}()\` - ${applied.description}`, applied.kinds),
|
|
2052
|
+
],
|
|
2053
|
+
sanitizedFor: new Set([...taint.sanitizedFor, ...applied.kinds]),
|
|
2054
|
+
origin: taint.origin,
|
|
2055
|
+
unknownHops: taint.unknownHops,
|
|
2056
|
+
afterWash: washedNow(taint.afterWash, applied.kinds),
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
// A sanitiser: the value stays traceable, but is marked washed for the
|
|
2062
|
+
// kinds this particular soap actually works on.
|
|
2063
|
+
// An entry with maxArgs does not describe a call that passes more - md5's
|
|
2064
|
+
// raw flag - so that call falls through to the unmodelled-function rule.
|
|
2065
|
+
const sanitizer = dictionary.sanitizers.find((s) => s.names.includes(name) &&
|
|
2066
|
+
(s.maxArgs === undefined || args.length <= s.maxArgs) &&
|
|
2067
|
+
(s.argumentPattern === undefined || (args[0] !== undefined && s.argumentPattern.test(text(args[0])))));
|
|
2068
|
+
if (sanitizer) {
|
|
2069
|
+
/*
|
|
2070
|
+
* A method with NO arguments answers a question about its own object:
|
|
2071
|
+
* `param.length()`, `name.isEmpty()`. The dirt is on the receiver, and
|
|
2072
|
+
* the answer is washed all the same. Only with no arguments - when there
|
|
2073
|
+
* are some, they are what the entry describes, and the receiver is
|
|
2074
|
+
* usually a helper object (`$conn->real_escape_string($x)`).
|
|
2075
|
+
*/
|
|
2076
|
+
const inputs = sanitizer.fromReceiver ? [receiver] : args.length > 0 ? args : [receiver];
|
|
2077
|
+
for (const arg of inputs) {
|
|
2078
|
+
const taint = evaluate(arg, scope, depth + 1);
|
|
2079
|
+
if (!taint)
|
|
2080
|
+
continue;
|
|
2081
|
+
const quoteDependent = sanitizer.onlyInsideQuotes ?? [];
|
|
2082
|
+
const pending = [...new Set([...(taint.quotePending ?? []), ...quoteDependent])];
|
|
2083
|
+
return {
|
|
2084
|
+
steps: [
|
|
2085
|
+
...taint.steps,
|
|
2086
|
+
{
|
|
2087
|
+
...step('sanitizer', node, `passed through \`${name}()\` - ${sanitizer.description}`, sanitizer.kinds),
|
|
2088
|
+
...(quoteDependent.length > 0 ? { quoteDependent: true, quoteKinds: quoteDependent } : {}),
|
|
2089
|
+
},
|
|
2090
|
+
],
|
|
2091
|
+
sanitizedFor: new Set([...taint.sanitizedFor, ...sanitizer.kinds]),
|
|
2092
|
+
origin: taint.origin,
|
|
2093
|
+
unknownHops: taint.unknownHops,
|
|
2094
|
+
afterWash: washedNow(taint.afterWash, sanitizer.kinds),
|
|
2095
|
+
quotePending: pending.length > 0 ? pending : undefined,
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
// A propagator: dirt survives. Check the receiver first (`dirty.trim()`),
|
|
2101
|
+
// then the arguments (`String(dirty)`, `",".join(dirty)`).
|
|
2102
|
+
if (dictionary.propagators.names.includes(name)) {
|
|
2103
|
+
let taint = evaluate(receiver, scope, depth + 1);
|
|
2104
|
+
// A propagator reached through the RECEIVER of a multi-element container
|
|
2105
|
+
// is an element read - `map.get(k)`, `list.get(i)` - and `get` is in the
|
|
2106
|
+
// propagator list, which is why the unmodelled-call path never sees it.
|
|
2107
|
+
// Same rule, second doorway: reading one of several is a guess.
|
|
2108
|
+
const fromContainer = taint?.collectedMany ?? false;
|
|
2109
|
+
if (!taint) {
|
|
2110
|
+
for (const arg of args) {
|
|
2111
|
+
taint = evaluate(arg, scope, depth + 1);
|
|
2112
|
+
if (taint)
|
|
2113
|
+
break;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
if (!taint)
|
|
2117
|
+
return null;
|
|
2118
|
+
const carried = addStep(taint, node, `passed through \`${name}()\``);
|
|
2119
|
+
return fromContainer ? { ...carried, containerGuess: true } : carried;
|
|
2120
|
+
}
|
|
2121
|
+
/* A function we can actually read - in this file or, since cross-file
|
|
2122
|
+
* resolution, in a file this one imports. Ask the descent whether it
|
|
2123
|
+
* HANDLED the call, not merely whether it produced taint: the two are
|
|
2124
|
+
* different answers, and conflating them was a real bug. The gate used to
|
|
2125
|
+
* be `localFunctions.get(name)`, so a sanitiser living in another module
|
|
2126
|
+
* was never consulted and its call fell through to the "unknown function"
|
|
2127
|
+
* branch below, which preserves taint. Result: correctly-escaped code
|
|
2128
|
+
* reported as a vulnerability. */
|
|
2129
|
+
const descended = descendIntoLocalCall(node, scope, depth);
|
|
2130
|
+
if (descended.handled)
|
|
2131
|
+
return descended.taint;
|
|
2132
|
+
const stoppedAt = descended.stopped;
|
|
2133
|
+
/*
|
|
2134
|
+
* A READ: the result is new data, not the argument - see FreshResultSpec.
|
|
2135
|
+
* Checked only here, after the project's own functions have had their
|
|
2136
|
+
* chance, so a project `fetch()` that returns its argument is followed.
|
|
2137
|
+
* The trace ends, and the stop is put on the record when the input was
|
|
2138
|
+
* dirty: a finding that would have existed must leave its reason behind.
|
|
2139
|
+
*/
|
|
2140
|
+
const fresh = stoppedAt ? undefined : dictionary.freshResults?.find((f) => f.names.includes(name));
|
|
2141
|
+
if (fresh) {
|
|
2142
|
+
if (fresh.rowsFollowInjection) {
|
|
2143
|
+
const sqlSink = dictionary.callSinks.find((s) => s.kind === 'sql' && s.methods.includes(name));
|
|
2144
|
+
const watched = sqlSink && sqlSink.argIndexes !== 'all'
|
|
2145
|
+
? sqlSink.argIndexes.map((i) => args[i]).filter((a) => a !== undefined)
|
|
2146
|
+
: args;
|
|
2147
|
+
for (const arg of watched) {
|
|
2148
|
+
const taint = evaluate(arg, scope, depth + 1);
|
|
2149
|
+
if (!taint || retracted(taint, arg, 'sql') !== null)
|
|
2150
|
+
continue;
|
|
2151
|
+
return addStep(taint, node, sqlSink
|
|
2152
|
+
? `the query text can be injected into (the SQL injection on this line), so the rows ` +
|
|
2153
|
+
`\`${name}()\` returns can be chosen by the attacker - for example with UNION SELECT`
|
|
2154
|
+
: `read through \`${name}()\` from a result the attacker can choose the rows of`);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
const input = [...args, receiver].some((n) => evaluate(n, scope, depth + 1) !== null);
|
|
2158
|
+
if (input)
|
|
2159
|
+
noteFreshStop(node, scope.filePath, `\`${name}()\` is ${fresh.description}`);
|
|
2160
|
+
return null;
|
|
2161
|
+
}
|
|
2162
|
+
/*
|
|
2163
|
+
* A KEYED LOOKUP on a clean store. Juice Shop's
|
|
2164
|
+
* `security.authenticatedUsers.get(req.cookies.token)` returns the user
|
|
2165
|
+
* kept under that token, and four routes build a file path from that
|
|
2166
|
+
* user's id: the cookie picks WHICH user, it writes nothing. A dirty
|
|
2167
|
+
* receiver (`new URLSearchParams(dirty).get('x')`) still carries, and so
|
|
2168
|
+
* does a language that lists `get` as a propagator (Java, Go).
|
|
2169
|
+
*/
|
|
2170
|
+
if (name === 'get' && args.length === 1 && receiver && evaluate(receiver, scope, depth + 1) === null) {
|
|
2171
|
+
if (evaluate(args[0] ?? null, scope, depth + 1) !== null) {
|
|
2172
|
+
noteFreshStop(node, scope.filePath, '`get()` is a lookup - it returns what is stored under the key, not the key');
|
|
2173
|
+
}
|
|
2174
|
+
return null;
|
|
2175
|
+
}
|
|
2176
|
+
/* ---- A function we do not model. The hardest judgement call here. ----
|
|
2177
|
+
*
|
|
2178
|
+
* Two wrong answers were available:
|
|
2179
|
+
*
|
|
2180
|
+
* Treat it as CLEAN. Safe-sounding, and it made the tool useless on real
|
|
2181
|
+
* Java: OWASP's own benchmark writes
|
|
2182
|
+
* param = URLDecoder.decode(request.getHeader("x"), "UTF-8");
|
|
2183
|
+
* and dropping the taint at `decode` produced ZERO verified flows across
|
|
2184
|
+
* 2,766 files, every one of which is a known vulnerability.
|
|
2185
|
+
*
|
|
2186
|
+
* Treat it as SANITISING. Never - assuming an unknown function fixes the
|
|
2187
|
+
* problem is how scanners miss real bugs.
|
|
2188
|
+
*
|
|
2189
|
+
* What we do instead: the value keeps its taint, and the hop is recorded as
|
|
2190
|
+
* UNMODELLED. The path shows exactly where our knowledge ran out, and the
|
|
2191
|
+
* finding's limitations names the function. If `decode` had been a custom
|
|
2192
|
+
* sanitiser, the reader can see that in one glance and dismiss it.
|
|
2193
|
+
*
|
|
2194
|
+
* That is the honest version of a guess: make it, then show your work. */
|
|
2195
|
+
let carried = null;
|
|
2196
|
+
for (const arg of args) {
|
|
2197
|
+
carried = evaluate(arg, scope, depth + 1);
|
|
2198
|
+
if (carried)
|
|
2199
|
+
break;
|
|
2200
|
+
}
|
|
2201
|
+
/*
|
|
2202
|
+
* TAINT ARRIVING FROM THE RECEIVER, which is where Jenkins went wrong.
|
|
2203
|
+
*
|
|
2204
|
+
* No argument was dirty, so the only way this call can be dirty is if the
|
|
2205
|
+
* OBJECT is. Usually that is right - `sb.toString()` on a dirtied
|
|
2206
|
+
* StringBuilder is exactly the dirty string. But when the object was
|
|
2207
|
+
* dirtied through a KEYED write, the taint is in a named compartment and
|
|
2208
|
+
* this call may have nothing to do with it:
|
|
2209
|
+
*
|
|
2210
|
+
* req.setAttribute("loginForm", dirty);
|
|
2211
|
+
* req.getContextPath() // <- not the compartment. Not dirty.
|
|
2212
|
+
*
|
|
2213
|
+
* So a compartmented receiver only carries taint out through a reader of
|
|
2214
|
+
* that compartment. Reading it back DOES carry, and clears the compartment,
|
|
2215
|
+
* because the value is now in the caller's hands rather than filed away.
|
|
2216
|
+
*/
|
|
2217
|
+
const fromReceiver = carried === null;
|
|
2218
|
+
carried ??= evaluate(receiver, scope, depth + 1);
|
|
2219
|
+
if (!carried)
|
|
2220
|
+
return null;
|
|
2221
|
+
let compartment = carried.compartment;
|
|
2222
|
+
if (fromReceiver && compartment) {
|
|
2223
|
+
if (!compartment.includes(name))
|
|
2224
|
+
return null;
|
|
2225
|
+
compartment = undefined;
|
|
2226
|
+
}
|
|
2227
|
+
// A stop is already counted where it happened (depth / recursion).
|
|
2228
|
+
if (!stoppedAt)
|
|
2229
|
+
seenUnmodelled.add(siteOf(node, scope.filePath));
|
|
2230
|
+
// A known name called with an option its entry does not cover.
|
|
2231
|
+
const overArgs = dictionary.sanitizers.find((s) => s.names.includes(name) && s.maxArgs !== undefined && args.length > s.maxArgs);
|
|
2232
|
+
const hopText = stoppedAt === 'depth'
|
|
2233
|
+
? `passed through \`${name}()\`, which we did NOT follow: the call chain reached ` +
|
|
2234
|
+
`its depth limit here. We assume it preserves the value; if it actually ` +
|
|
2235
|
+
`sanitises, this trace is wrong`
|
|
2236
|
+
: stoppedAt === 'recursion'
|
|
2237
|
+
? `passed through \`${name}()\`, which resolves by name to a function already ` +
|
|
2238
|
+
`being followed on this path - a recursive call, or a same-named method on ` +
|
|
2239
|
+
`another object. We did NOT follow it again and assume it preserves the value; ` +
|
|
2240
|
+
`if it actually sanitises, this trace is wrong`
|
|
2241
|
+
: overArgs
|
|
2242
|
+
? `passed through \`${name}()\`, which is NOT in our dictionary in this form: it is ` +
|
|
2243
|
+
`called with ${args.length} arguments and our entry covers at most ${overArgs.maxArgs}, ` +
|
|
2244
|
+
`and an extra argument can change what comes back (md5's raw-binary flag does). ` +
|
|
2245
|
+
`We assume it preserves the value; if it actually sanitises, this trace is wrong`
|
|
2246
|
+
: `passed through \`${name}()\`, which is NOT in our dictionary - we assume it ` +
|
|
2247
|
+
`preserves the value; if it actually sanitises, this trace is wrong`;
|
|
2248
|
+
return {
|
|
2249
|
+
steps: [...carried.steps, { ...step('propagation', node, hopText), unmodelled: name }],
|
|
2250
|
+
sanitizedFor: carried.sanitizedFor,
|
|
2251
|
+
origin: carried.origin,
|
|
2252
|
+
unknownHops: [...carried.unknownHops, name],
|
|
2253
|
+
quotePending: carried.quotePending,
|
|
2254
|
+
compartment,
|
|
2255
|
+
collectedMany: carried.collectedMany,
|
|
2256
|
+
/*
|
|
2257
|
+
* Carried, not rebuilt. This return used to list its fields by hand and
|
|
2258
|
+
* left this one out, so a guessed hop ERASED the record that a branch had
|
|
2259
|
+
* been assumed further back - and a path resting on two assumptions was
|
|
2260
|
+
* published as a proof resting on one. Found when stripslashes() and
|
|
2261
|
+
* new ProcessBuilder() became known functions: their paths went through
|
|
2262
|
+
* addStep, kept the flag, and ten BenchmarkJava proofs that had never
|
|
2263
|
+
* been entitled to the label lost it.
|
|
2264
|
+
*/
|
|
2265
|
+
branchAssumed: carried.branchAssumed,
|
|
2266
|
+
/*
|
|
2267
|
+
* THE READ IS WHERE THE AMBIGUITY IS BORN, not the write.
|
|
2268
|
+
*
|
|
2269
|
+
* `argList.add(a); argList.add(b); pb.command(argList)` hands the whole
|
|
2270
|
+
* list over - every element arrives, nothing is guessed, and the trace
|
|
2271
|
+
* stays proven. `map.put(k1,clean); map.put(k2,dirty); map.get(k1)` pulls
|
|
2272
|
+
* ONE element out of several and this engine does not model which. Same
|
|
2273
|
+
* container, same write count; only the read tells them apart.
|
|
2274
|
+
*/
|
|
2275
|
+
containerGuess: carried.containerGuess ?? (fromReceiver && carried.collectedMany ? true : undefined),
|
|
2276
|
+
joined: carried.joined,
|
|
2277
|
+
afterWash: trustedHop(carried.afterWash, name),
|
|
2278
|
+
};
|
|
2279
|
+
};
|
|
2280
|
+
/* ---- sinks ---- */
|
|
2281
|
+
const buildPath = (taint, sinkNode, kind, description) => {
|
|
2282
|
+
// Convert sanitiser steps that do NOT cover this sink kind into ordinary
|
|
2283
|
+
// propagation steps, with the mismatch spelled out. This is where the
|
|
2284
|
+
// kind-scoping becomes visible to the user: "you escaped it for HTML, and
|
|
2285
|
+
// then put it in a SQL query".
|
|
2286
|
+
const steps = taint.steps.map((s) => {
|
|
2287
|
+
if (s.kind !== 'sanitizer')
|
|
2288
|
+
return { kind: s.kind, location: s.location, description: s.description };
|
|
2289
|
+
/*
|
|
2290
|
+
* A SANITISER STEP FROM SOMEBODY ELSE'S VALUE.
|
|
2291
|
+
*
|
|
2292
|
+
* Scanning WordPress on the new page-buffer sink crashed the whole scan
|
|
2293
|
+
* on the engine's own invariant: "path contains a sanitiser - that is a
|
|
2294
|
+
* CLEAN flow, not a verified vulnerability." The invariant was right to
|
|
2295
|
+
* fire and right to crash rather than publish. The path was wrong.
|
|
2296
|
+
*
|
|
2297
|
+
* $html .= '<a href="' . esc_url($u) . '">' . $label . '</a>';
|
|
2298
|
+
*
|
|
2299
|
+
* Two tainted values merge here. `esc_url($u)` is escaped; `$label` is
|
|
2300
|
+
* not. `sanitizedFor` correctly ends up WITHOUT xss - the finding is
|
|
2301
|
+
* real, because $label is raw - but the merged step list still carried
|
|
2302
|
+
* the esc_url sanitiser step from the other contributor. One narrative
|
|
2303
|
+
* assembled out of two different values' histories.
|
|
2304
|
+
*
|
|
2305
|
+
* So a step only stays a SANITISER when the value actually reaching this
|
|
2306
|
+
* sink was sanitised for this kind. Otherwise it is something that
|
|
2307
|
+
* happened to a sibling expression, and the path says so instead of
|
|
2308
|
+
* claiming a cleanliness the traced value never had.
|
|
2309
|
+
*/
|
|
2310
|
+
const coversThisKind = s.sanitizesKinds?.includes(kind) ?? false;
|
|
2311
|
+
if (coversThisKind && taint.sanitizedFor.has(kind)) {
|
|
2312
|
+
return { kind: s.kind, location: s.location, description: s.description };
|
|
2313
|
+
}
|
|
2314
|
+
if (coversThisKind && s.quoteDependent) {
|
|
2315
|
+
return {
|
|
2316
|
+
kind: 'propagation',
|
|
2317
|
+
location: s.location,
|
|
2318
|
+
description: s.guard
|
|
2319
|
+
? `${s.description} - NOTE: this value is not inside quotes where it is used, and ` +
|
|
2320
|
+
`outside quotes a word is still SQL - a column name or a keyword - so the check does not protect it`
|
|
2321
|
+
: `${s.description} - NOTE: this value is not inside quotes where it is used, ` +
|
|
2322
|
+
`so the escaping does not protect it`,
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
if (coversThisKind) {
|
|
2326
|
+
return {
|
|
2327
|
+
kind: 'propagation',
|
|
2328
|
+
location: s.location,
|
|
2329
|
+
description: `${s.description} - NOTE: this covered a DIFFERENT value in the same ` +
|
|
2330
|
+
`expression. The value followed here never went through it.`,
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
return {
|
|
2334
|
+
kind: 'propagation',
|
|
2335
|
+
location: s.location,
|
|
2336
|
+
description: `${s.description} - NOTE: this does not make a value safe for a ${kind} sink, ` +
|
|
2337
|
+
`so the value is still dangerous here`,
|
|
2338
|
+
};
|
|
2339
|
+
});
|
|
2340
|
+
steps.push({
|
|
2341
|
+
kind: 'sink',
|
|
2342
|
+
location: nodeLocation(sinkNode, toDisplay(currentPath)),
|
|
2343
|
+
description,
|
|
2344
|
+
});
|
|
2345
|
+
return steps;
|
|
2346
|
+
};
|
|
2347
|
+
/**
|
|
2348
|
+
* ONE RETRACTION, THREE PLACES THAT CAN REPORT.
|
|
2349
|
+
*
|
|
2350
|
+
* `checkTaintAtSink` says in its own comment that it was factored out "so a
|
|
2351
|
+
* second kind of sink cannot get half of this right". It was, and then two
|
|
2352
|
+
* more emission sites grew their own inline copies of the sanitised check -
|
|
2353
|
+
* so when the validation guard was added to the one factored function, DVWA's
|
|
2354
|
+
* `exec/impossible.php` carried on being reported through a path that had
|
|
2355
|
+
* never heard of it.
|
|
2356
|
+
*
|
|
2357
|
+
* Every reason to NOT report belongs here, and `npm test` fails if a
|
|
2358
|
+
* `results.push` appears in this file without a `retracted()` guarding it.
|
|
2359
|
+
*/
|
|
2360
|
+
const retracted = (taint, _valueNode, kind) => {
|
|
2361
|
+
/*
|
|
2362
|
+
* WE ASSUMED A BRANCH. THAT IS NOT A PROOF.
|
|
2363
|
+
*
|
|
2364
|
+
* The value reached here only because a clean write was skipped for being
|
|
2365
|
+
* conditional - see the note on branchAssumed. Reporting is right; calling
|
|
2366
|
+
* it flow-verified is not, and the difference between those two sentences
|
|
2367
|
+
* is this project's whole product. The signature rules still cover the
|
|
2368
|
+
* line, so the caution survives and only the overclaim is dropped.
|
|
2369
|
+
*
|
|
2370
|
+
* This sits in the shared gate rather than in checkTaintAtSink, because the
|
|
2371
|
+
* first attempt put it in checkTaintAtSink alone and Django carried on being
|
|
2372
|
+
* reported: the finding came through one of the OTHER two emission sites.
|
|
2373
|
+
* That is the fourth-time-lesson recorded on this function - every reason
|
|
2374
|
+
* not to report belongs here, or it only half exists.
|
|
2375
|
+
*/
|
|
2376
|
+
// Escaping whose protection depends on quotes, on a value whose place in
|
|
2377
|
+
// the query was never seen, proves nothing either way - see quoteUnknown.
|
|
2378
|
+
// That includes cleanliness established by the control flow: a value read
|
|
2379
|
+
// inside `if (is_numeric($octet[0]) && ...)` arrives here already washed -
|
|
2380
|
+
// see `guarded`, which replaced a text check that used to sit on this line.
|
|
2381
|
+
if (taint.sanitizedFor.has(kind) && !taint.quotePending?.includes(kind))
|
|
2382
|
+
return 'sanitised';
|
|
2383
|
+
return null;
|
|
2384
|
+
};
|
|
2385
|
+
/*
|
|
2386
|
+
* THE RECEIPT FOR A CLEARED LINE.
|
|
2387
|
+
*
|
|
2388
|
+
* The report's "Checked and cleared" section printed one sentence for every
|
|
2389
|
+
* line the tracer cleared: "A sanitiser for this kind of problem was proven
|
|
2390
|
+
* to run on the path into this line." It was printed for an allow-list
|
|
2391
|
+
* check, which is not a sanitiser; for a hex digest; and for DVWA's ping
|
|
2392
|
+
* output, which was cleared only because the ping's ADDRESS was checked and
|
|
2393
|
+
* `shell_exec()` - a function this engine does not model - was assumed to
|
|
2394
|
+
* hand back what it was given. A claim of safety with no stated ground is
|
|
2395
|
+
* the same overclaim as a finding with no path, pointed the other way.
|
|
2396
|
+
*
|
|
2397
|
+
* The ground is on the path already: the last step that washed the value
|
|
2398
|
+
* for THIS kind of sink. What can undo it is on the path too - a function
|
|
2399
|
+
* we do not model that the value went through afterwards. Hops before the
|
|
2400
|
+
* wash do not matter (the wash happened after them), so they are not
|
|
2401
|
+
* listed; hops on another value joined in, whose order we cannot see, are
|
|
2402
|
+
* listed, because over-disclosing an assumption costs a reader a glance and
|
|
2403
|
+
* hiding one costs them the truth.
|
|
2404
|
+
*/
|
|
2405
|
+
const cleanReason = (taint, kind) => {
|
|
2406
|
+
const steps = taint.steps;
|
|
2407
|
+
let at = -1;
|
|
2408
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
2409
|
+
const s = steps[i];
|
|
2410
|
+
if (s.kind === 'sanitizer' && s.sanitizesKinds?.includes(kind)) {
|
|
2411
|
+
at = i;
|
|
2412
|
+
break;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
const before = new Set(steps.slice(0, Math.max(at, 0)).map((s) => s.unmodelled).filter((n) => !!n));
|
|
2416
|
+
const after = steps.slice(at + 1).map((s) => s.unmodelled).filter((n) => !!n);
|
|
2417
|
+
const elsewhere = taint.unknownHops.filter((n) => !before.has(n) && !after.includes(n) && !steps.some((s) => s.unmodelled === n));
|
|
2418
|
+
// Tracked precisely when every way this value was built kept count - see
|
|
2419
|
+
// `afterWash`. Otherwise the cautious list: anything that MIGHT be after.
|
|
2420
|
+
const tracked = taint.afterWash?.[kind];
|
|
2421
|
+
const assumes = tracked ? [...tracked] : [...new Set([...after, ...elsewhere])];
|
|
2422
|
+
if (at === -1) {
|
|
2423
|
+
return { reason: 'the value was cleaned for this kind of sink on the way here, by a step this engine did not record', assumes };
|
|
2424
|
+
}
|
|
2425
|
+
const wash = steps[at];
|
|
2426
|
+
const line = wash.location.startLine;
|
|
2427
|
+
const where = wash.location.file === toDisplay(currentPath) ? `line ${line}` : `${wash.location.file}:${line}`;
|
|
2428
|
+
let reason = wash.guard ? wash.description : `${wash.description} (${where})`;
|
|
2429
|
+
if (wash.quoteKinds?.includes(kind))
|
|
2430
|
+
reason += ' - and the value sits inside quotes where it is used, which is where that holds';
|
|
2431
|
+
if (taint.joined)
|
|
2432
|
+
reason += ' - one of several values joined into this one, and every one of them was cleaned for this kind of sink';
|
|
2433
|
+
return { reason, assumes };
|
|
2434
|
+
};
|
|
2435
|
+
const recordRetraction = (sinkNode, kind, taint) => {
|
|
2436
|
+
const { reason, assumes } = cleanReason(taint, kind);
|
|
2437
|
+
sanitized.push({
|
|
2438
|
+
ruleId: SINK_KIND_RULE[kind],
|
|
2439
|
+
sinkNode,
|
|
2440
|
+
kind,
|
|
2441
|
+
filePath: toDisplay(currentPath),
|
|
2442
|
+
reason,
|
|
2443
|
+
...(assumes.length > 0 ? { assumes } : {}),
|
|
2444
|
+
});
|
|
2445
|
+
};
|
|
2446
|
+
/**
|
|
2447
|
+
* A tainted value has arrived somewhere dangerous. Decide which of the two
|
|
2448
|
+
* outcomes it is - a finding, or a proof that this line is fine.
|
|
2449
|
+
*
|
|
2450
|
+
* Factored out of checkCallSink so a second kind of sink cannot get half of
|
|
2451
|
+
* this right. Both branches matter equally: forgetting the sanitised branch
|
|
2452
|
+
* would mean correctly-escaped code gets reported, and this project treats
|
|
2453
|
+
* shouting at correct code as the worse failure of the two.
|
|
2454
|
+
*/
|
|
2455
|
+
const checkTaintAtSink = (taint, valueNode, kind,
|
|
2456
|
+
/** A NOUN PHRASE. The message reads "... reaches ${description}". */
|
|
2457
|
+
description,
|
|
2458
|
+
/** The sink step's own wording, which may say more than the headline. */
|
|
2459
|
+
pathDescription = `reaches ${description}`) => {
|
|
2460
|
+
const verdict = retracted(taint, valueNode, kind);
|
|
2461
|
+
if (verdict) {
|
|
2462
|
+
if (verdict === 'sanitised')
|
|
2463
|
+
recordRetraction(valueNode, kind, taint);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
results.push({
|
|
2467
|
+
ruleId: SINK_KIND_RULE[kind],
|
|
2468
|
+
kind,
|
|
2469
|
+
node: valueNode,
|
|
2470
|
+
sinkNode: valueNode,
|
|
2471
|
+
path: buildPath(taint, valueNode, kind, pathDescription),
|
|
2472
|
+
origin: taint.origin,
|
|
2473
|
+
sinkDescription: description,
|
|
2474
|
+
unknownHops: taint.unknownHops,
|
|
2475
|
+
branchAssumed: taint.branchAssumed ?? false,
|
|
2476
|
+
containerGuess: taint.containerGuess ?? false,
|
|
2477
|
+
quoteUnknown: taint.quotePending?.includes(kind) ?? false,
|
|
2478
|
+
fixedDestination: fixedDestinationOf(taint, kind),
|
|
2479
|
+
localSource: kind === 'path' && taint.fromLocal ? true : undefined,
|
|
2480
|
+
filePath: toDisplay(currentPath),
|
|
2481
|
+
});
|
|
2482
|
+
};
|
|
2483
|
+
/**
|
|
2484
|
+
* A tainted argument dirties the object it was handed to.
|
|
2485
|
+
*
|
|
2486
|
+
* sb.append(param); -> `sb` is now tainted
|
|
2487
|
+
* argList.add(param); -> `argList` is now tainted
|
|
2488
|
+
*
|
|
2489
|
+
* The existing propagator model could not express this: it carries taint out
|
|
2490
|
+
* through a RETURN VALUE, and these calls either discard theirs or hand back
|
|
2491
|
+
* a boolean. `append` sat in the Java propagator list the whole time and
|
|
2492
|
+
* caught nothing.
|
|
2493
|
+
*
|
|
2494
|
+
* HONEST ABOUT THE IMPRECISION THIS BUYS. The taint lands on the whole
|
|
2495
|
+
* container, not on the element - so after
|
|
2496
|
+
*
|
|
2497
|
+
* list.add(cleanValue);
|
|
2498
|
+
* list.add(dirtyValue);
|
|
2499
|
+
*
|
|
2500
|
+
* every read out of `list` is treated as dirty, including the clean one. That
|
|
2501
|
+
* is the standard container-insensitive trade and it errs toward reporting.
|
|
2502
|
+
* It is written into the flow path ("collected into ...") and into the
|
|
2503
|
+
* capability block, because a new source of imprecision that nobody announced
|
|
2504
|
+
* is exactly the kind of quiet overclaim this engine is built against.
|
|
2505
|
+
*/
|
|
2506
|
+
/**
|
|
2507
|
+
* THE OUTPUT-PARAMETER MUTATOR - C's version of the same idea, and the one
|
|
2508
|
+
* shape without which C coverage would be nearly zero.
|
|
2509
|
+
*
|
|
2510
|
+
* checkMutation below binds taint to the RECEIVER, because in every language
|
|
2511
|
+
* this engine supported until now the thing being filled sits to the left of
|
|
2512
|
+
* the dot:
|
|
2513
|
+
*
|
|
2514
|
+
* sb.append(dirty) -> `sb`
|
|
2515
|
+
*
|
|
2516
|
+
* C has no receiver. The destination is argument ZERO, and the value goes in
|
|
2517
|
+
* after it:
|
|
2518
|
+
*
|
|
2519
|
+
* sprintf(cmd, "ping %s", argv[1]); -> `cmd`
|
|
2520
|
+
* strcpy(buf, argv[1]); -> `buf`
|
|
2521
|
+
* strcat(path, getenv("X")); -> `path`
|
|
2522
|
+
*
|
|
2523
|
+
* That is not a corner case, it is THE C idiom - the two-line build-then-run
|
|
2524
|
+
* pattern that every command injection in the language is written in. With
|
|
2525
|
+
* only the receiver rule, `sprintf(cmd, ...)` would taint nothing, `cmd`
|
|
2526
|
+
* would read as clean, and `system(cmd)` on the next line would be silent.
|
|
2527
|
+
* Every fixture in vulnerable/cmdi.c is this shape.
|
|
2528
|
+
*
|
|
2529
|
+
* Argument zero is excluded from the tainted-value scan for the same reason
|
|
2530
|
+
* `writerArgPattern` excludes it on the sink side: it is where the output
|
|
2531
|
+
* goes, not what is written.
|
|
2532
|
+
*/
|
|
2533
|
+
const checkOutParamMutation = (node, scope) => {
|
|
2534
|
+
const method = lastSegment(calleeOf(node).text);
|
|
2535
|
+
/*
|
|
2536
|
+
* READER FUNCTIONS: the call IS the source, and the bytes land in an
|
|
2537
|
+
* argument rather than in the return value.
|
|
2538
|
+
*
|
|
2539
|
+
* fgets(line, sizeof(line), stdin);
|
|
2540
|
+
* system(line);
|
|
2541
|
+
*
|
|
2542
|
+
* Nothing here is an expression the source patterns could match - fgets
|
|
2543
|
+
* returns a pointer nobody keeps, and `line` was declared clean on the
|
|
2544
|
+
* line above. Without this the two statements are unrelated and reading a
|
|
2545
|
+
* command from standard input is invisible, which is the oldest shape of
|
|
2546
|
+
* this bug there is.
|
|
2547
|
+
*/
|
|
2548
|
+
for (const reader of dictionary.outParamSources ?? []) {
|
|
2549
|
+
if (!reader.names.includes(method))
|
|
2550
|
+
continue;
|
|
2551
|
+
const destination = argumentsOf(node)[reader.destination];
|
|
2552
|
+
if (!destination)
|
|
2553
|
+
continue;
|
|
2554
|
+
const name = lastSegment(text(destination));
|
|
2555
|
+
if (!name || !/^[A-Za-z_][\w]*$/.test(name))
|
|
2556
|
+
continue;
|
|
2557
|
+
seenSources.add(siteOf(node, currentPath));
|
|
2558
|
+
scope.env.set(name, {
|
|
2559
|
+
steps: [
|
|
2560
|
+
step('source', node, `\`${name}\` is filled by \`${method}()\` from ${reader.description}`),
|
|
2561
|
+
],
|
|
2562
|
+
sanitizedFor: new Set(),
|
|
2563
|
+
origin: `${method}()`,
|
|
2564
|
+
unknownHops: [],
|
|
2565
|
+
});
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
const outParams = dictionary.outParamMutators;
|
|
2569
|
+
if (!outParams || outParams.length === 0)
|
|
2570
|
+
return;
|
|
2571
|
+
if (!outParams.includes(method))
|
|
2572
|
+
return;
|
|
2573
|
+
const args = argumentsOf(node);
|
|
2574
|
+
const destination = args[0];
|
|
2575
|
+
if (!destination)
|
|
2576
|
+
return;
|
|
2577
|
+
// Only something we can name. `sprintf(ptr->buf, ...)` gives us no single
|
|
2578
|
+
// variable to bind, so it is left alone rather than guessed at.
|
|
2579
|
+
const destinationName = lastSegment(text(destination));
|
|
2580
|
+
if (!destinationName || !/^[A-Za-z_][\w]*$/.test(destinationName))
|
|
2581
|
+
return;
|
|
2582
|
+
for (const arg of args.slice(1)) {
|
|
2583
|
+
const taint = evaluate(arg, scope);
|
|
2584
|
+
if (!taint)
|
|
2585
|
+
continue;
|
|
2586
|
+
scope.env.set(destinationName, addStep(taint, node, `written into \`${destinationName}\` by \`${method}()\``));
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
/**
|
|
2591
|
+
* HOW MANY THINGS WENT INTO THE BOX?
|
|
2592
|
+
*
|
|
2593
|
+
* Counts element writes to `receiverName` inside the function the write sits
|
|
2594
|
+
* in. One means the value later read out is the value written, and the trace
|
|
2595
|
+
* is sound. More than one means a read picks one of several and this engine
|
|
2596
|
+
* does not model which - so the finding keeps travelling but loses its claim
|
|
2597
|
+
* to proof.
|
|
2598
|
+
*
|
|
2599
|
+
* DELIBERATELY TEXTUAL, and the limitation is real: it counts writes that
|
|
2600
|
+
* appear anywhere in the enclosing function, including ones AFTER the read.
|
|
2601
|
+
* That over-counts, which costs confidence rather than findings - the error
|
|
2602
|
+
* lands on the side of claiming less. A precise version would need ordering
|
|
2603
|
+
* and reachability, which is the modelling this file has repeatedly decided
|
|
2604
|
+
* not to guess at.
|
|
2605
|
+
*/
|
|
2606
|
+
const countElementWrites = (node, receiverName) => {
|
|
2607
|
+
const names = dictionary.elementMutators;
|
|
2608
|
+
if (!names || names.length === 0)
|
|
2609
|
+
return 0;
|
|
2610
|
+
let scopeNode = node;
|
|
2611
|
+
while (scopeNode && !functionTypes.has(scopeNode.type))
|
|
2612
|
+
scopeNode = scopeNode.parent;
|
|
2613
|
+
const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2614
|
+
const pattern = new RegExp(`\\b${escaped}\\s*\\.\\s*(?:${names.join('|')})\\s*\\(`, 'g');
|
|
2615
|
+
return (text(scopeNode ?? node).match(pattern) ?? []).length;
|
|
2616
|
+
};
|
|
2617
|
+
/**
|
|
2618
|
+
* How many elements `name` can hold in the function around `node`: element
|
|
2619
|
+
* writes `name[...] = v`, plus the entries of any container literal bound
|
|
2620
|
+
* to it. Textual, like countElementWrites, and wrong the same safe way: a
|
|
2621
|
+
* write after the read still counts, which costs a proof, never a finding.
|
|
2622
|
+
*/
|
|
2623
|
+
const elementCount = (node, name) => {
|
|
2624
|
+
let scopeNode = node;
|
|
2625
|
+
while (scopeNode && !functionTypes.has(scopeNode.type))
|
|
2626
|
+
scopeNode = scopeNode.parent;
|
|
2627
|
+
let count = 0;
|
|
2628
|
+
const visit = (n) => {
|
|
2629
|
+
if (ASSIGNMENT_NODES.has(n.type)) {
|
|
2630
|
+
const parts = assignmentParts(n);
|
|
2631
|
+
if (parts && stripDeclarator(text(parts.target)) === name) {
|
|
2632
|
+
if (ELEMENT_TARGETS.has(parts.target.type))
|
|
2633
|
+
count++;
|
|
2634
|
+
else if (CONTAINER_LITERALS.has(parts.value.type))
|
|
2635
|
+
count += parts.value.namedChildCount;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
for (const c of n.namedChildren)
|
|
2639
|
+
if (c)
|
|
2640
|
+
visit(c);
|
|
2641
|
+
};
|
|
2642
|
+
visit(scopeNode ?? node);
|
|
2643
|
+
return count;
|
|
2644
|
+
};
|
|
2645
|
+
const checkMutation = (node, scope) => {
|
|
2646
|
+
const mutators = dictionary.mutators;
|
|
2647
|
+
if (!mutators || mutators.length === 0)
|
|
2648
|
+
return;
|
|
2649
|
+
const callee = calleeOf(node);
|
|
2650
|
+
const method = lastSegment(callee.text);
|
|
2651
|
+
if (!mutators.includes(method))
|
|
2652
|
+
return;
|
|
2653
|
+
// We can only dirty something we can NAME. `a.b().c(dirty)` gives us no
|
|
2654
|
+
// variable to bind, so it is left alone rather than guessed at.
|
|
2655
|
+
const receiverName = lastSegment(text(callee.receiver));
|
|
2656
|
+
if (!receiverName || !/^[A-Za-z_$][\w$]*$/.test(receiverName))
|
|
2657
|
+
return;
|
|
2658
|
+
// A keyed write files the value in a NAMED COMPARTMENT of an object that is
|
|
2659
|
+
// primarily something else. The object is dirty for reads of that store and
|
|
2660
|
+
// clean for everything else it does. See safe/keyed-container.java.
|
|
2661
|
+
const keyed = dictionary.keyedMutators?.includes(method) ?? false;
|
|
2662
|
+
const readers = dictionary.keyedReaders;
|
|
2663
|
+
// An element write into a collection that took several elements makes any
|
|
2664
|
+
// later read of it a guess about WHICH element. The taint is unchanged; only
|
|
2665
|
+
// the confidence label is. Accumulators are not in `elementMutators`, so
|
|
2666
|
+
// sb.append() keeps its proof however many times it runs.
|
|
2667
|
+
const manyElements = (dictionary.elementMutators?.includes(method) ?? false) &&
|
|
2668
|
+
countElementWrites(node, receiverName) > 1;
|
|
2669
|
+
for (const arg of argumentsOf(node)) {
|
|
2670
|
+
const taint = evaluate(arg, scope);
|
|
2671
|
+
if (!taint)
|
|
2672
|
+
continue;
|
|
2673
|
+
const stored = addStep(taint, node, keyed
|
|
2674
|
+
? `filed into \`${receiverName}\` under a key via \`${method}()\` - reads of that ` +
|
|
2675
|
+
`store are tainted, other methods on \`${receiverName}\` are not`
|
|
2676
|
+
: `collected into \`${receiverName}\` via \`${method}()\``);
|
|
2677
|
+
const marked = manyElements ? { ...stored, collectedMany: true } : stored;
|
|
2678
|
+
scope.env.set(receiverName, keyed && readers ? { ...marked, compartment: readers } : marked);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
};
|
|
2682
|
+
/**
|
|
2683
|
+
* The dangerous call took no arguments - the payload is already inside the
|
|
2684
|
+
* object. `pb.command(list); pb.start();` is the canonical shape.
|
|
2685
|
+
*/
|
|
2686
|
+
const checkReceiverSink = (node, scope) => {
|
|
2687
|
+
const sinks = dictionary.receiverSinks;
|
|
2688
|
+
if (!sinks || sinks.length === 0)
|
|
2689
|
+
return;
|
|
2690
|
+
const callee = calleeOf(node);
|
|
2691
|
+
const method = lastSegment(callee.text);
|
|
2692
|
+
for (const sink of sinks) {
|
|
2693
|
+
if (!sink.methods.includes(method))
|
|
2694
|
+
continue;
|
|
2695
|
+
if (!callee.receiver)
|
|
2696
|
+
continue;
|
|
2697
|
+
// `new ProcessBuilder(argList).start()` already fired as an ARGUMENT
|
|
2698
|
+
// sink on the constructor. Firing again on the receiver reports one bug
|
|
2699
|
+
// twice at one line. This shape only adds something when the payload was
|
|
2700
|
+
// loaded on an earlier line, which means the receiver is a plain name.
|
|
2701
|
+
if (CALL_NODES.has(callee.receiver.type))
|
|
2702
|
+
continue;
|
|
2703
|
+
const taint = evaluate(callee.receiver, scope);
|
|
2704
|
+
if (!taint)
|
|
2705
|
+
continue;
|
|
2706
|
+
checkTaintAtSink(taint, node, sink.kind, sink.description, `reaches ${sink.description} via \`${method}()\` - the value was loaded into ` +
|
|
2707
|
+
`\`${lastSegment(text(callee.receiver))}\` on an earlier line, so this call carries no arguments`);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
};
|
|
2711
|
+
const checkCallSink = (node, scope) => {
|
|
2712
|
+
const callee = calleeOf(node);
|
|
2713
|
+
const name = lastSegment(callee.text);
|
|
2714
|
+
const wholeCall = text(node);
|
|
2715
|
+
const args = argumentsOf(node);
|
|
2716
|
+
for (const sink of dictionary.callSinks) {
|
|
2717
|
+
// The name as written, or the sink it was locally aliased to.
|
|
2718
|
+
const aliasedTo = fileAliases.get(name);
|
|
2719
|
+
const effective = sink.methods.includes(name) ? name : aliasedTo;
|
|
2720
|
+
if (!effective || !sink.methods.includes(effective))
|
|
2721
|
+
continue;
|
|
2722
|
+
if (sink.bareOnly && callee.receiver) {
|
|
2723
|
+
const owner = ownerName(text(callee.receiver));
|
|
2724
|
+
if (!(sink.allowedReceivers ?? []).includes(owner))
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
if (sink.requiredReceivers && sink.requiredReceivers.length > 0) {
|
|
2728
|
+
const receiver = callee.receiver ? text(callee.receiver) : '';
|
|
2729
|
+
const last = ownerName(receiver) || receiver;
|
|
2730
|
+
if (!sink.requiredReceivers.some((name) => last === name))
|
|
2731
|
+
continue;
|
|
2732
|
+
}
|
|
2733
|
+
if (sink.receiverPattern) {
|
|
2734
|
+
const receiver = callee.receiver ? text(callee.receiver) : '';
|
|
2735
|
+
// The variable's NAME, or the type it was declared with. `r.exec(cmd)`
|
|
2736
|
+
// says nothing; `Runtime r = ...` two lines up says everything.
|
|
2737
|
+
const declared = fileTypes.get(receiver) ?? fileTypes.get(lastSegment(receiver)) ?? '';
|
|
2738
|
+
if (!sink.receiverPattern.test(receiver) && !sink.receiverPattern.test(declared))
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
if (sink.requiresShellOption && !sink.requiresShellOption.test(wholeCall))
|
|
2742
|
+
continue;
|
|
2743
|
+
// The safe form of a deserialiser is the same call with a restriction
|
|
2744
|
+
// added. Seeing that restriction means this is the FIX, not the bug.
|
|
2745
|
+
if (sink.unlessOption && sink.unlessOption.test(wholeCall))
|
|
2746
|
+
continue;
|
|
2747
|
+
// `append`/`write`/`print` on a StringBuilder is string assembly, not a
|
|
2748
|
+
// write to the page. Only the ones we KNOW are builders are excluded, so
|
|
2749
|
+
// an unknown receiver is still treated as a possible writer.
|
|
2750
|
+
if (sink.kind === 'xss' && scope.builders.has(lastSegment(text(callee.receiver))))
|
|
2751
|
+
continue;
|
|
2752
|
+
// Executing a PREPARED statement: the arguments are bound parameters, not
|
|
2753
|
+
// the instruction. Recorded as a clean flow so the signature-based guess
|
|
2754
|
+
// at this line is withdrawn too - correct code should end up silent.
|
|
2755
|
+
if (sink.kind === 'sql' && scope.prepared.has(lastSegment(text(callee.receiver)))) {
|
|
2756
|
+
sanitized.push({
|
|
2757
|
+
ruleId: SINK_KIND_RULE[sink.kind],
|
|
2758
|
+
sinkNode: node,
|
|
2759
|
+
kind: sink.kind,
|
|
2760
|
+
filePath: toDisplay(currentPath),
|
|
2761
|
+
reason: `\`${lastSegment(text(callee.receiver))}\` is a prepared statement, so what is passed here is bound ` +
|
|
2762
|
+
`as a parameter - data, never part of the query text`,
|
|
2763
|
+
});
|
|
2764
|
+
continue;
|
|
2765
|
+
}
|
|
2766
|
+
const indexes = sink.argIndexes === 'all' ? args.map((_, index) => index) : sink.argIndexes;
|
|
2767
|
+
/*
|
|
2768
|
+
* Sinks whose destination is ARGUMENT ZERO, not the receiver.
|
|
2769
|
+
* `fmt.Fprintf(w, ...)` - see writerArgPattern in types.ts.
|
|
2770
|
+
*/
|
|
2771
|
+
if (sink.writerArgPattern) {
|
|
2772
|
+
const writer = args[0] ? text(args[0]) : '';
|
|
2773
|
+
const declared = fileTypes.get(writer) ?? fileTypes.get(lastSegment(writer)) ?? '';
|
|
2774
|
+
if (!sink.writerArgPattern.test(writer) && !sink.writerArgPattern.test(declared))
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
for (const index of indexes) {
|
|
2778
|
+
// Argument zero is the destination, not the payload.
|
|
2779
|
+
if (sink.writerArgPattern && index === 0)
|
|
2780
|
+
continue;
|
|
2781
|
+
const arg = args[index];
|
|
2782
|
+
if (!arg)
|
|
2783
|
+
continue;
|
|
2784
|
+
// Ambiguous method names need the content to agree - but ONLY when we
|
|
2785
|
+
// can actually see the content. `db.query(sql)` shows us nothing: the
|
|
2786
|
+
// query text was built on an earlier line. There we trust the sink name
|
|
2787
|
+
// plus the traced flow instead. Demanding visible SQL in that case was
|
|
2788
|
+
// a real bug - it silently discarded the most common verified flow of
|
|
2789
|
+
// all, the one that goes through a variable.
|
|
2790
|
+
if (sink.contentCheck) {
|
|
2791
|
+
const built = analyzeStringExpression(arg, language);
|
|
2792
|
+
if (built.literalText.length > 0) {
|
|
2793
|
+
const looksRight = sink.contentCheck === 'sql'
|
|
2794
|
+
? looksLikeSql(built.literalText)
|
|
2795
|
+
: looksLikeHtml(built.literalText);
|
|
2796
|
+
if (!looksRight)
|
|
2797
|
+
continue;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
const taint = evaluate(arg, scope);
|
|
2801
|
+
if (!taint)
|
|
2802
|
+
continue;
|
|
2803
|
+
// Properly washed for THIS kind of sink. Not a finding - and worth
|
|
2804
|
+
// recording, because it lets the scan RETRACT the signature-based
|
|
2805
|
+
// guess at this line instead of nagging about code that is provably
|
|
2806
|
+
// correct. Confirmed-clean is a result too.
|
|
2807
|
+
const verdict = retracted(taint, arg, sink.kind);
|
|
2808
|
+
if (verdict) {
|
|
2809
|
+
if (verdict === 'sanitised')
|
|
2810
|
+
recordRetraction(node, sink.kind, taint);
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
// PHP's file_get_contents("http://" . $host) is a request, never a
|
|
2814
|
+
// file on disk: `../` there climbs a URL. It is the SSRF sink's line.
|
|
2815
|
+
if (sink.kind === 'path' && /^(https?|ftps?):\/\//i.test(taint.urlPrefix ?? ''))
|
|
2816
|
+
continue;
|
|
2817
|
+
// ponytail: a bare `..` upload name still names the parent folder itself;
|
|
2818
|
+
// it cannot reach a file in it. Track literal slashes joined in if that matters.
|
|
2819
|
+
if (sink.kind === 'path' && taint.fileNameOnly)
|
|
2820
|
+
continue;
|
|
2821
|
+
results.push({
|
|
2822
|
+
ruleId: SINK_KIND_RULE[sink.kind],
|
|
2823
|
+
kind: sink.kind,
|
|
2824
|
+
node: arg,
|
|
2825
|
+
sinkNode: node,
|
|
2826
|
+
path: buildPath(taint, node, sink.kind, `reaches ${sink.description} via \`${callee.text}()\``),
|
|
2827
|
+
origin: taint.origin,
|
|
2828
|
+
sinkDescription: sink.description,
|
|
2829
|
+
unknownHops: taint.unknownHops,
|
|
2830
|
+
branchAssumed: taint.branchAssumed ?? false,
|
|
2831
|
+
containerGuess: taint.containerGuess ?? false,
|
|
2832
|
+
quoteUnknown: taint.quotePending?.includes(sink.kind) ?? false,
|
|
2833
|
+
fixedDestination: fixedDestinationOf(taint, sink.kind),
|
|
2834
|
+
localSource: sink.kind === 'path' && taint.fromLocal ? true : undefined,
|
|
2835
|
+
filePath: toDisplay(currentPath),
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
};
|
|
2840
|
+
const checkAssignSink = (assignNode, target, value, taint) => {
|
|
2841
|
+
// The sigil goes the same way it does for receivers: PHP writes $html and
|
|
2842
|
+
// the sink list holds html. See ownerName() for the receiver-side twin.
|
|
2843
|
+
const name = lastSegment(text(target)).replace(/^["']|["']$/g, '').replace(/^[$*&]+/, '');
|
|
2844
|
+
for (const sink of dictionary.assignSinks) {
|
|
2845
|
+
if (!sink.properties.includes(name))
|
|
2846
|
+
continue;
|
|
2847
|
+
// A name-based sink has to see the shape it claims to parse. See the
|
|
2848
|
+
// note on contentCheck in types.ts - `$sql .= $where` is not a page.
|
|
2849
|
+
if (sink.contentCheck === 'html' && !looksLikeHtml(text(value)))
|
|
2850
|
+
continue;
|
|
2851
|
+
if (sink.contentCheck === 'sql' && !looksLikeSql(text(value)))
|
|
2852
|
+
continue;
|
|
2853
|
+
const verdict = retracted(taint, value, sink.kind);
|
|
2854
|
+
if (verdict) {
|
|
2855
|
+
if (verdict === 'sanitised')
|
|
2856
|
+
recordRetraction(assignNode, sink.kind, taint);
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
results.push({
|
|
2860
|
+
ruleId: SINK_KIND_RULE[sink.kind],
|
|
2861
|
+
kind: sink.kind,
|
|
2862
|
+
node: value,
|
|
2863
|
+
sinkNode: assignNode,
|
|
2864
|
+
path: buildPath(taint, assignNode, sink.kind, `assigned to \`${text(target)}\` - ${sink.description}`),
|
|
2865
|
+
origin: taint.origin,
|
|
2866
|
+
sinkDescription: sink.description,
|
|
2867
|
+
unknownHops: taint.unknownHops,
|
|
2868
|
+
branchAssumed: taint.branchAssumed ?? false,
|
|
2869
|
+
containerGuess: taint.containerGuess ?? false,
|
|
2870
|
+
quoteUnknown: taint.quotePending?.includes(sink.kind) ?? false,
|
|
2871
|
+
fixedDestination: fixedDestinationOf(taint, sink.kind),
|
|
2872
|
+
localSource: sink.kind === 'path' && taint.fromLocal ? true : undefined,
|
|
2873
|
+
filePath: toDisplay(currentPath),
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
/* ---- the statement walk ---- */
|
|
2878
|
+
/** Does this node sit inside a branch or loop body within the current scope? */
|
|
2879
|
+
/**
|
|
2880
|
+
* A NULL DEFAULT IS NOT A SANITISER.
|
|
2881
|
+
*
|
|
2882
|
+
* String param = request.getParameter("q"); // tainted
|
|
2883
|
+
* if (param == null) param = ""; // clean write, conditional
|
|
2884
|
+
*
|
|
2885
|
+
* `underCondition` sees a clean write it cannot prove happens and marks the
|
|
2886
|
+
* taint `branchAssumed`, which costs the finding its proof. But the two cases
|
|
2887
|
+
* here are DISJOINT: the write fires only when the value is null, and the
|
|
2888
|
+
* taint exists only when it is not. Nothing was assumed - the tainted path is
|
|
2889
|
+
* the only path there is. And in the case the guard does fire, what reaches
|
|
2890
|
+
* the sink is a constant carrying no payload.
|
|
2891
|
+
*
|
|
2892
|
+
* THE OPERATOR IS THE WHOLE RULE. `if (x != null) x = "safe"` puts the clean
|
|
2893
|
+
* write exactly on the tainted value and really might clean it; that one must
|
|
2894
|
+
* stay a guess. So this matches equality-against-null only, on the same name
|
|
2895
|
+
* being written, and demands that EVERY enclosing conditional qualify - one
|
|
2896
|
+
* ordinary `if` in the chain and we know nothing again.
|
|
2897
|
+
*
|
|
2898
|
+
* Measured before building: 171 of BenchmarkJava's 320 found-but-unproven
|
|
2899
|
+
* real vulnerabilities are downgraded for an unevaluated branch, and this
|
|
2900
|
+
* idiom is the largest single shape among them. It is also ordinary Java
|
|
2901
|
+
* rather than a benchmark artefact, which is why it was worth doing first.
|
|
2902
|
+
*/
|
|
2903
|
+
const NULL_LITERALS = 'null|NULL|None|nil|undefined';
|
|
2904
|
+
const guardCannotCleanTarget = (node, body, targetName) => {
|
|
2905
|
+
const name = targetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2906
|
+
const test = new RegExp(`^\\(?\\s*(?:${name}\\s*={2,3}\\s*(?:${NULL_LITERALS})` +
|
|
2907
|
+
`|(?:${NULL_LITERALS})\\s*={2,3}\\s*${name}` +
|
|
2908
|
+
`|${name}\\s+is\\s+None)\\s*\\)?$`);
|
|
2909
|
+
let current = node.parent;
|
|
2910
|
+
let seen = 0;
|
|
2911
|
+
while (current && current !== body) {
|
|
2912
|
+
if (CONDITIONAL_NODES.has(current.type)) {
|
|
2913
|
+
// An `else` branch of a null test is the NON-null path - the opposite
|
|
2914
|
+
// case - so it never qualifies.
|
|
2915
|
+
if (current.type !== 'if_statement')
|
|
2916
|
+
return false;
|
|
2917
|
+
const condition = current.childForFieldName('condition');
|
|
2918
|
+
if (!condition)
|
|
2919
|
+
return false;
|
|
2920
|
+
if (!test.test(text(condition).trim()))
|
|
2921
|
+
return false;
|
|
2922
|
+
seen++;
|
|
2923
|
+
}
|
|
2924
|
+
current = current.parent;
|
|
2925
|
+
}
|
|
2926
|
+
return seen > 0;
|
|
2927
|
+
};
|
|
2928
|
+
/*
|
|
2929
|
+
* THE CLEAN WRITE AND THE DIRTY ONE ARE IN DIFFERENT ARMS OF ONE IF/ELSE.
|
|
2930
|
+
*
|
|
2931
|
+
* if (values != null && values.length > 0) param = values[0];
|
|
2932
|
+
* else param = "";
|
|
2933
|
+
* ... param ...
|
|
2934
|
+
*
|
|
2935
|
+
* At most one arm runs, so the clean write never lands on top of the dirty
|
|
2936
|
+
* one: after the if/else, on every run that took the first arm, `param` is
|
|
2937
|
+
* the request value. Nothing is assumed there - the path is as proven as any
|
|
2938
|
+
* other path through an `if` this engine does not evaluate. The opposite
|
|
2939
|
+
* order (clean first, dirty in the else) never set the flag at all - see
|
|
2940
|
+
* BranchAndContainer.java - so one decision was labelled two ways depending
|
|
2941
|
+
* on which arm was written first.
|
|
2942
|
+
*
|
|
2943
|
+
* INSIDE the clean arm it is the other way round: after `param = ""` the
|
|
2944
|
+
* value IS clean, and a sink there must not be proven (branch-assumed.py,
|
|
2945
|
+
* from Django). So the flag is set as before, and taken off again only when
|
|
2946
|
+
* the walk leaves the if/else - settleArms - and only if nothing has written
|
|
2947
|
+
* the name since.
|
|
2948
|
+
*
|
|
2949
|
+
* Returns that if/else, or null. The dirty write is found by the last step
|
|
2950
|
+
* of the kept taint, which is where it was stored. Every `if` around the
|
|
2951
|
+
* clean write is tried, so an `else if` chain is covered: the clean write
|
|
2952
|
+
* sits in one arm of the outer `if` even when nested in an inner one.
|
|
2953
|
+
*/
|
|
2954
|
+
/** Per branch: what a name should hold once the walk leaves it, if nothing wrote it since. */
|
|
2955
|
+
const armSettles = new Map();
|
|
2956
|
+
/** Kinds a value is washed for, counting escaping still waiting on its quotes as not washed. */
|
|
2957
|
+
const washedKinds = (t) => [...t.sanitizedFor].filter((k) => !(t.quotePending ?? []).includes(k));
|
|
2958
|
+
const settleArms = (node, scope) => {
|
|
2959
|
+
const key = spanKey(scope.filePath, node);
|
|
2960
|
+
const pending = armSettles.get(key);
|
|
2961
|
+
if (!pending)
|
|
2962
|
+
return;
|
|
2963
|
+
armSettles.delete(key);
|
|
2964
|
+
// Newest first: a second clean arm re-flags the first one's flagged value,
|
|
2965
|
+
// and unwinding in reverse lands on the original. A name written again
|
|
2966
|
+
// since is left alone. That is the cautious side, kept on purpose: every
|
|
2967
|
+
// case worked through gives the same or a more guarded answer with it, so
|
|
2968
|
+
// no fixture can tell it apart - it is the one check here no test pins.
|
|
2969
|
+
// An assumption it keeps costs a proof; an assumption it lifted wrongly
|
|
2970
|
+
// would publish one.
|
|
2971
|
+
for (const { target, inside, after } of [...pending].reverse()) {
|
|
2972
|
+
if (scope.env.get(target) === inside)
|
|
2973
|
+
scope.env.set(target, after);
|
|
2974
|
+
}
|
|
2975
|
+
};
|
|
2976
|
+
/*
|
|
2977
|
+
* The branch that can skip a write on a run where the dirty write it lands
|
|
2978
|
+
* on happened: the first branch or loop around the write that does not also
|
|
2979
|
+
* hold the dirty write - an `if` it sits in alone, the other arm of an
|
|
2980
|
+
* if/else, a loop that may run no times. Null when there is none.
|
|
2981
|
+
*
|
|
2982
|
+
* if (isset($_GET['q'])) {
|
|
2983
|
+
* $q = $_GET['q'];
|
|
2984
|
+
* $q = htmlspecialchars($q); // same block: runs whenever the line above did
|
|
2985
|
+
* echo $q;
|
|
2986
|
+
* }
|
|
2987
|
+
*
|
|
2988
|
+
* That escape is certain, and the last write may win. Without this test the
|
|
2989
|
+
* washed-write rule flagged it and took away a correct "cleared". A dirty
|
|
2990
|
+
* write it cannot place - another file - counts as skippable by the
|
|
2991
|
+
* outermost branch around the write.
|
|
2992
|
+
*/
|
|
2993
|
+
const skippedBy = (write, kept, body, filePath) => {
|
|
2994
|
+
const stored = kept.steps[kept.steps.length - 1]?.location;
|
|
2995
|
+
const at = stored && stored.file === toDisplay(filePath)
|
|
2996
|
+
? { row: stored.startLine - 1, column: stored.startColumn - 1 }
|
|
2997
|
+
: null;
|
|
2998
|
+
let child = write;
|
|
2999
|
+
let current = write.parent;
|
|
3000
|
+
let outermost = null;
|
|
3001
|
+
while (current && current !== body) {
|
|
3002
|
+
if (CONDITIONAL_NODES.has(current.type)) {
|
|
3003
|
+
// The first branch around the write that the dirty write is not in.
|
|
3004
|
+
if (at && !holds(child, at))
|
|
3005
|
+
return current;
|
|
3006
|
+
outermost = current;
|
|
3007
|
+
}
|
|
3008
|
+
child = current;
|
|
3009
|
+
current = current.parent;
|
|
3010
|
+
}
|
|
3011
|
+
// A dirty write in another file: skippable by every branch around this one.
|
|
3012
|
+
return at ? null : outermost;
|
|
3013
|
+
};
|
|
3014
|
+
/** Whether a 0-based position lies inside a node. */
|
|
3015
|
+
const holds = (n, at) => {
|
|
3016
|
+
const s = n.startPosition;
|
|
3017
|
+
const e = n.endPosition;
|
|
3018
|
+
const afterStart = at.row > s.row || (at.row === s.row && at.column >= s.column);
|
|
3019
|
+
const beforeEnd = at.row < e.row || (at.row === e.row && at.column < e.column);
|
|
3020
|
+
return afterStart && beforeEnd;
|
|
3021
|
+
};
|
|
3022
|
+
const inOtherArm = (clean, kept, body, filePath) => {
|
|
3023
|
+
const stored = kept.steps[kept.steps.length - 1]?.location;
|
|
3024
|
+
if (!stored || stored.file !== toDisplay(filePath))
|
|
3025
|
+
return null;
|
|
3026
|
+
const at = { row: stored.startLine - 1, column: stored.startColumn - 1 };
|
|
3027
|
+
const contains = (n) => {
|
|
3028
|
+
const s = n.startPosition;
|
|
3029
|
+
const e = n.endPosition;
|
|
3030
|
+
const afterStart = at.row > s.row || (at.row === s.row && at.column >= s.column);
|
|
3031
|
+
const beforeEnd = at.row < e.row || (at.row === e.row && at.column < e.column);
|
|
3032
|
+
return afterStart && beforeEnd;
|
|
3033
|
+
};
|
|
3034
|
+
let child = clean;
|
|
3035
|
+
let current = clean.parent;
|
|
3036
|
+
while (current && current !== body) {
|
|
3037
|
+
if (current.type === 'if_statement' && contains(current)) {
|
|
3038
|
+
const condition = current.childForFieldName('condition');
|
|
3039
|
+
const sameAsChild = (n) => n.startIndex === child.startIndex && n.endIndex === child.endIndex;
|
|
3040
|
+
const inCondition = condition !== null && sameAsChild(condition);
|
|
3041
|
+
if (!inCondition) {
|
|
3042
|
+
for (const arm of current.namedChildren) {
|
|
3043
|
+
if (!arm || sameAsChild(arm))
|
|
3044
|
+
continue;
|
|
3045
|
+
if (condition && arm.startIndex === condition.startIndex && arm.endIndex === condition.endIndex)
|
|
3046
|
+
continue;
|
|
3047
|
+
if (contains(arm))
|
|
3048
|
+
return current;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
child = current;
|
|
3053
|
+
current = current.parent;
|
|
3054
|
+
}
|
|
3055
|
+
return null;
|
|
3056
|
+
};
|
|
3057
|
+
// The constant folder lives in fold.ts now, shared with the signature rules.
|
|
3058
|
+
const { conditionTruth, ternaryParts, deadBranchOf, switchDecision } = createFolder(language);
|
|
3059
|
+
/*
|
|
3060
|
+
* A switch is decided once per site, not once per statement inside it. Keyed
|
|
3061
|
+
* by file and span: the tracer walks into other files' functions, and a span
|
|
3062
|
+
* alone means nothing across files.
|
|
3063
|
+
*/
|
|
3064
|
+
const switchDecisions = new Map();
|
|
3065
|
+
const decideSwitch = (node, body, filePath) => {
|
|
3066
|
+
const key = `${filePath}\0${node.startIndex}:${node.endIndex}`;
|
|
3067
|
+
if (!switchDecisions.has(key))
|
|
3068
|
+
switchDecisions.set(key, switchDecision(node, body));
|
|
3069
|
+
return switchDecisions.get(key) ?? null;
|
|
3070
|
+
};
|
|
3071
|
+
/** Case groups of decided switches that cannot run. The walk never enters them. */
|
|
3072
|
+
const deadCases = new Set();
|
|
3073
|
+
const spanKey = (filePath, node) => `${filePath}\0${node.startIndex}:${node.endIndex}`;
|
|
3074
|
+
/*
|
|
3075
|
+
* The switch a switch-internal conditional node belongs to, within two levels.
|
|
3076
|
+
* Only a PART of a switch qualifies: an `if` inside a Go case is two levels
|
|
3077
|
+
* below the switch too, and mistaking it for the switch would treat the body
|
|
3078
|
+
* of that `if` as certain.
|
|
3079
|
+
*/
|
|
3080
|
+
const SWITCH_PARTS = new Set([
|
|
3081
|
+
'switch_block', 'switch_body', 'case_statement', 'default_statement',
|
|
3082
|
+
'switch_case', 'switch_default', 'expression_case', 'default_case',
|
|
3083
|
+
]);
|
|
3084
|
+
const owningSwitch = (current) => {
|
|
3085
|
+
if (SWITCH_NODES.has(current.type))
|
|
3086
|
+
return current;
|
|
3087
|
+
if (!SWITCH_PARTS.has(current.type))
|
|
3088
|
+
return null;
|
|
3089
|
+
const up = current.parent;
|
|
3090
|
+
if (up && SWITCH_NODES.has(up.type))
|
|
3091
|
+
return up;
|
|
3092
|
+
const upper = up?.parent ?? null;
|
|
3093
|
+
return upper && SWITCH_NODES.has(upper.type) ? upper : null;
|
|
3094
|
+
};
|
|
3095
|
+
const underCondition = (node, body, filePath) => {
|
|
3096
|
+
let current = node.parent;
|
|
3097
|
+
while (current && current !== body) {
|
|
3098
|
+
if (CONDITIONAL_NODES.has(current.type)) {
|
|
3099
|
+
/*
|
|
3100
|
+
* A case of a decided switch that runs EVERY time the switch does is no
|
|
3101
|
+
* more conditional than the if-true below - see switchDecision in
|
|
3102
|
+
* fold.ts for what "every time" has to survive.
|
|
3103
|
+
*/
|
|
3104
|
+
const sw = owningSwitch(current);
|
|
3105
|
+
if (sw) {
|
|
3106
|
+
const decision = decideSwitch(sw, body, filePath);
|
|
3107
|
+
if (decision?.certain(node)) {
|
|
3108
|
+
current = sw.parent;
|
|
3109
|
+
continue;
|
|
3110
|
+
}
|
|
3111
|
+
return true;
|
|
3112
|
+
}
|
|
3113
|
+
/*
|
|
3114
|
+
* An `if` whose condition is decidably TRUE is not a condition at all -
|
|
3115
|
+
* the block runs every time. Without this the folder would silence the
|
|
3116
|
+
* dead arm and still refuse to call the live arm proven, which would be
|
|
3117
|
+
* a strange half-answer: certain enough to drop one side, not certain
|
|
3118
|
+
* enough to trust the other.
|
|
3119
|
+
*/
|
|
3120
|
+
if (current.type === 'if_statement' && conditionTruth(current.childForFieldName('condition'), body) === true) {
|
|
3121
|
+
current = current.parent;
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
return true;
|
|
3125
|
+
}
|
|
3126
|
+
current = current.parent;
|
|
3127
|
+
}
|
|
3128
|
+
return false;
|
|
3129
|
+
};
|
|
3130
|
+
// assignmentParts and ASSIGNMENT_NODES live in fold.ts, which needs them too.
|
|
3131
|
+
/** PHP statements that write their argument straight to the response body. */
|
|
3132
|
+
const ECHO_NODES = new Set(['echo_statement', 'print_intrinsic']);
|
|
3133
|
+
/**
|
|
3134
|
+
* How deep the statement walk will recurse into a syntax tree.
|
|
3135
|
+
*
|
|
3136
|
+
* `0 + 1 + 2 + ... + 1499` parses as a binary_expression nested 1,499 deep,
|
|
3137
|
+
* and each level is one stack frame here. Node's default stack takes roughly
|
|
3138
|
+
* a thousand of these frames before it gives up and kills the process, which
|
|
3139
|
+
* means one file in TypeScript's own test suite could destroy a 25,809-file
|
|
3140
|
+
* scan and return NOTHING - no findings, no report, no partial result.
|
|
3141
|
+
*
|
|
3142
|
+
* 400 is comfortably under the limit and far past any hand-written code.
|
|
3143
|
+
* Anything deeper is generated, minified or a stress test, and the honest
|
|
3144
|
+
* response is to stop and count it rather than to crash or to pretend the
|
|
3145
|
+
* file was fully examined.
|
|
3146
|
+
*/
|
|
3147
|
+
const MAX_AST_DEPTH = 400;
|
|
3148
|
+
const walk = (node, scope, depth = 0) => {
|
|
3149
|
+
if (depth > MAX_AST_DEPTH) {
|
|
3150
|
+
seenDeepAst.add(siteOf(node, scope.filePath));
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
/*
|
|
3154
|
+
* A case that cannot run is not walked, exactly like the dead arm of an if
|
|
3155
|
+
* below: a write in it never happened, so it is never recorded - neither a
|
|
3156
|
+
* tainted write that would invent a flow, nor a clean one that would cost a
|
|
3157
|
+
* real flow its proof. SwitchOnConstant.java.
|
|
3158
|
+
*/
|
|
3159
|
+
if (deadCases.size > 0 && deadCases.has(spanKey(scope.filePath, node)))
|
|
3160
|
+
return;
|
|
3161
|
+
if (SWITCH_NODES.has(node.type)) {
|
|
3162
|
+
const decision = decideSwitch(node, scope.body, scope.filePath);
|
|
3163
|
+
for (const dead of decision?.dead ?? []) {
|
|
3164
|
+
deadCases.add(spanKey(scope.filePath, dead));
|
|
3165
|
+
noteUnreachable(dead, scope.filePath, decision?.note ?? 'the switch is decided at compile time');
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
/*
|
|
3169
|
+
* Do not walk into a branch that cannot run.
|
|
3170
|
+
*
|
|
3171
|
+
* The ternary case above decides which VALUE comes out of an expression.
|
|
3172
|
+
* This decides which STATEMENTS happen at all, which is what the `if/else`
|
|
3173
|
+
* form of the same decoy needs - a write inside the dead arm should never
|
|
3174
|
+
* be recorded, rather than recorded and then reasoned about.
|
|
3175
|
+
*
|
|
3176
|
+
* Nothing is skipped unless the condition folded to a definite answer, so
|
|
3177
|
+
* an undecidable `if` still has both arms walked exactly as before.
|
|
3178
|
+
*/
|
|
3179
|
+
if (node.type === 'if_statement') {
|
|
3180
|
+
const dead = deadBranchOf(node, scope.body);
|
|
3181
|
+
if (dead) {
|
|
3182
|
+
const truth = conditionTruth(node.childForFieldName('condition'), scope.body);
|
|
3183
|
+
noteUnreachable(dead, scope.filePath, `the condition on line ${node.startPosition.row + 1} is always ${truth ? 'true' : 'false'}`);
|
|
3184
|
+
/*
|
|
3185
|
+
* Compared by source span, not by identity. The tree-sitter binding
|
|
3186
|
+
* hands back a fresh wrapper object each time a child is asked for, so
|
|
3187
|
+
* `child !== dead` was true even for the very node we meant to skip -
|
|
3188
|
+
* the branch folded correctly and then got walked anyway.
|
|
3189
|
+
*/
|
|
3190
|
+
for (const child of node.namedChildren) {
|
|
3191
|
+
if (!child)
|
|
3192
|
+
continue;
|
|
3193
|
+
if (child.startIndex === dead.startIndex && child.endIndex === dead.endIndex)
|
|
3194
|
+
continue;
|
|
3195
|
+
walk(child, scope, depth + 1);
|
|
3196
|
+
}
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
// A nested function is a separate notebook. Queue it, don't descend.
|
|
3201
|
+
if (node !== scope.body && functionTypes.has(node.type)) {
|
|
3202
|
+
const body = node.childForFieldName('body') ?? node;
|
|
3203
|
+
const env = new Map(scope.env);
|
|
3204
|
+
/*
|
|
3205
|
+
* PARAMETERS THAT ARE ALREADY ATTACKER-CONTROLLED WHEN THE BODY STARTS.
|
|
3206
|
+
*
|
|
3207
|
+
* Every other source is an expression the body evaluates. A framework
|
|
3208
|
+
* binding is not: Spring reads the query string, converts it, and hands
|
|
3209
|
+
* the method a plain String. By the time the body runs there is nothing
|
|
3210
|
+
* left to match - the only evidence is the annotation on the declaration,
|
|
3211
|
+
* which is why this has to happen HERE, as the scope is created, rather
|
|
3212
|
+
* than in sourceTaint().
|
|
3213
|
+
*
|
|
3214
|
+
* Zero flow-verified findings on OWASP WebGoat is what this cost. See the
|
|
3215
|
+
* long note on parameterSources in dictionaries.ts.
|
|
3216
|
+
*/
|
|
3217
|
+
for (const source of dictionary.parameterSources ?? []) {
|
|
3218
|
+
for (const parameter of parameterDeclarations(node)) {
|
|
3219
|
+
if (!parameter.name || !source.pattern.test(parameter.text))
|
|
3220
|
+
continue;
|
|
3221
|
+
seenSources.add(siteOf(parameter.node, scope.filePath));
|
|
3222
|
+
env.set(parameter.name, {
|
|
3223
|
+
steps: [
|
|
3224
|
+
step('source', parameter.node, `\`${parameter.name}\` is ${source.description}`),
|
|
3225
|
+
],
|
|
3226
|
+
sanitizedFor: new Set(),
|
|
3227
|
+
origin: parameter.name,
|
|
3228
|
+
unknownHops: [],
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
for (const parameter of parameterDeclarations(node)) {
|
|
3233
|
+
const route = routePathFor(node, parameter.name);
|
|
3234
|
+
if (!route)
|
|
3235
|
+
continue;
|
|
3236
|
+
seenSources.add(siteOf(parameter.node, scope.filePath));
|
|
3237
|
+
env.set(parameter.name, {
|
|
3238
|
+
steps: [
|
|
3239
|
+
step('source', parameter.node, `\`${parameter.name}\` is bound from the URL path ${route}`),
|
|
3240
|
+
],
|
|
3241
|
+
sanitizedFor: new Set(),
|
|
3242
|
+
origin: parameter.name,
|
|
3243
|
+
unknownHops: [],
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
queue.push({
|
|
3247
|
+
body,
|
|
3248
|
+
env,
|
|
3249
|
+
filePath: scope.filePath,
|
|
3250
|
+
prepared: new Set(scope.prepared),
|
|
3251
|
+
builders: new Set(scope.builders),
|
|
3252
|
+
});
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
/*
|
|
3256
|
+
* PHP `echo $x;` and `print $x;`.
|
|
3257
|
+
*
|
|
3258
|
+
* These are statements, not assignments and not calls, so neither of the
|
|
3259
|
+
* two shapes below sees them - and `echo` is the single most common way a
|
|
3260
|
+
* PHP page writes attacker data into a page. The value it prints is the
|
|
3261
|
+
* same question an assign-sink asks, so it is routed there, with the `echo`
|
|
3262
|
+
* keyword itself standing in as the "property" being written.
|
|
3263
|
+
*/
|
|
3264
|
+
if (ECHO_NODES.has(node.type)) {
|
|
3265
|
+
const keyword = node.child(0);
|
|
3266
|
+
for (const printed of node.namedChildren) {
|
|
3267
|
+
if (!printed)
|
|
3268
|
+
continue;
|
|
3269
|
+
const taint = evaluate(printed, scope);
|
|
3270
|
+
if (taint && keyword)
|
|
3271
|
+
checkAssignSink(node, keyword, printed, taint);
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
/*
|
|
3275
|
+
* A FOR-EACH INTRODUCES A NAME, AND NOTHING WAS BINDING IT.
|
|
3276
|
+
*
|
|
3277
|
+
* String[] values = request.getParameterValues("q");
|
|
3278
|
+
* out.write(values[0]); // flow-verified
|
|
3279
|
+
* for (String v : values) out.write(v); // signature-based only
|
|
3280
|
+
*
|
|
3281
|
+
* Subscripting a tainted array carried the taint; iterating the same array
|
|
3282
|
+
* threw it away, because `v` was never entered into the environment and so
|
|
3283
|
+
* evaluated to clean.
|
|
3284
|
+
*
|
|
3285
|
+
* The loop node types were ALREADY listed in CONDITIONAL_NODES - the tracer
|
|
3286
|
+
* knew a for-each was a branch, and used that to decide a clean write inside
|
|
3287
|
+
* one cannot prove a value safe. Knowing a construct exists and modelling
|
|
3288
|
+
* what it does are different things, and the first reads exactly like the
|
|
3289
|
+
* second until something measures it.
|
|
3290
|
+
*
|
|
3291
|
+
* Found by diagnosing BenchmarkJava's remaining 123 misses: every one had no
|
|
3292
|
+
* source recognised, and fourteen of them were `for (Cookie c :
|
|
3293
|
+
* request.getCookies())` - a source this engine has always known, thrown
|
|
3294
|
+
* away by the loop around it. It looked like a cookie bug and was not.
|
|
3295
|
+
*
|
|
3296
|
+
* Container-insensitive, like every other collection in this engine: the
|
|
3297
|
+
* loop variable takes the taint of the whole iterable, so iterating a list
|
|
3298
|
+
* with one dirty element treats every element as dirty. That errs toward
|
|
3299
|
+
* reporting, which is the direction this project takes on purpose.
|
|
3300
|
+
*/
|
|
3301
|
+
if (FOREACH_NODES.has(node.type)) {
|
|
3302
|
+
const binding = foreachBinding(node);
|
|
3303
|
+
if (binding) {
|
|
3304
|
+
const taint = evaluate(binding.iterable, scope);
|
|
3305
|
+
const name = text(binding.name).replace(/^[$*&]+/, '').trim();
|
|
3306
|
+
if (taint && /^[A-Za-z_$][\w$]*$/.test(name)) {
|
|
3307
|
+
scope.env.set(name, addStep(taint, binding.name, `bound to \`${name}\` by a for-each over the collection`));
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
if (ASSIGNMENT_NODES.has(node.type)) {
|
|
3312
|
+
const parts = assignmentParts(node);
|
|
3313
|
+
if (parts) {
|
|
3314
|
+
const taint = evaluate(parts.value, scope);
|
|
3315
|
+
if (taint)
|
|
3316
|
+
checkAssignSink(node, parts.target, parts.value, taint);
|
|
3317
|
+
// Record it in the notebook, but only for plain variable names.
|
|
3318
|
+
// `obj.field = dirty` is not tracked - tracking object fields properly
|
|
3319
|
+
// needs alias analysis, which is well beyond this phase.
|
|
3320
|
+
//
|
|
3321
|
+
// Go's `id, err := strconv.Atoi(x)` puts several names on the left. We
|
|
3322
|
+
// bind every one of them to the same taint. It is imprecise - only one
|
|
3323
|
+
// of the returned values really carries it - but erring this way keeps
|
|
3324
|
+
// the extremely common `value, err :=` idiom traceable, and it is
|
|
3325
|
+
// recorded in the Go coverage note rather than left as a surprise.
|
|
3326
|
+
const targetNames = (parts.target.type === 'expression_list'
|
|
3327
|
+
? parts.target.namedChildren.filter((c) => c !== null).map((c) => text(c))
|
|
3328
|
+
: DESTRUCTURING_NODES.has(parts.target.type)
|
|
3329
|
+
? namesBoundBy(parts.target)
|
|
3330
|
+
: [text(parts.target)]).map(stripDeclarator);
|
|
3331
|
+
// `stmt, err := db.Prepare(sql)` - remember that `stmt` is a handle.
|
|
3332
|
+
// Go wraps the right-hand side in an expression_list, so unwrap first;
|
|
3333
|
+
// without this the check silently never fired.
|
|
3334
|
+
const valueNode = parts.value.type === 'expression_list' && parts.value.namedChildCount >= 1
|
|
3335
|
+
? parts.value.namedChildren[0] ?? parts.value
|
|
3336
|
+
: parts.value;
|
|
3337
|
+
if (CALL_NODES.has(valueNode.type)) {
|
|
3338
|
+
const preparedBy = lastSegment(calleeOf(valueNode).text);
|
|
3339
|
+
if (/^(StringBuilder|StringBuffer)$/.test(preparedBy)) {
|
|
3340
|
+
for (const targetName of targetNames)
|
|
3341
|
+
scope.builders.add(targetName);
|
|
3342
|
+
}
|
|
3343
|
+
if (PREPARE_CALLS.has(preparedBy)) {
|
|
3344
|
+
for (const targetName of targetNames)
|
|
3345
|
+
scope.prepared.add(targetName);
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
/*
|
|
3349
|
+
* `s += x` EXTENDS s. It does not replace it.
|
|
3350
|
+
*
|
|
3351
|
+
* Every node type in ASSIGNMENT_NODES was being applied as "the target
|
|
3352
|
+
* now holds the right-hand side", which is right for `=` and wrong for
|
|
3353
|
+
* `+=`. A clean right-hand side therefore deleted the target's taint,
|
|
3354
|
+
* and this is the shape it broke:
|
|
3355
|
+
*
|
|
3356
|
+
* String sql = "SELECT * FROM t WHERE x = '";
|
|
3357
|
+
* sql += request.getParameter("x"); // sql is dirty
|
|
3358
|
+
* sql += "'"; // sql was wiped clean here
|
|
3359
|
+
* stmt.execute(sql); // reported nothing at all
|
|
3360
|
+
*
|
|
3361
|
+
* That is the canonical injection, written the way people write it.
|
|
3362
|
+
* Found in C++ and assumed to be a C++ problem; rewriting it in four
|
|
3363
|
+
* other languages showed JavaScript, Python and Java all lost the value
|
|
3364
|
+
* identically, and only PHP survived. It was never about C++.
|
|
3365
|
+
*
|
|
3366
|
+
* Only `+=` and `.=` qualify. The other compound operators keep the old
|
|
3367
|
+
* value too, but `s -= x` and `s *= x` produce a number, and a number
|
|
3368
|
+
* cannot be a shell command - the same reason BINARY_CARRIERS holds
|
|
3369
|
+
* only the operators that can still carry an attacker's shape.
|
|
3370
|
+
*
|
|
3371
|
+
* Measured per language rather than assumed: C++, Java and Go spell
|
|
3372
|
+
* this `assignment_expression`/`assignment_statement` with a `+=`
|
|
3373
|
+
* operator, while JS, Python and PHP use a distinct augmented node. All
|
|
3374
|
+
* six expose the same `operator` field, so the operator decides and the
|
|
3375
|
+
* node type does not have to.
|
|
3376
|
+
*/
|
|
3377
|
+
const operator = node.childForFieldName('operator')?.text ?? '';
|
|
3378
|
+
const extendsTarget = operator === '+=' || operator === '.=';
|
|
3379
|
+
/*
|
|
3380
|
+
* `m['k'] = v` WRITES ONE ELEMENT. It does not replace `m`.
|
|
3381
|
+
*
|
|
3382
|
+
* stripDeclarator turns `m['k']` into `m` (it exists for C's `buf[10]`),
|
|
3383
|
+
* so an element write was applied as `m = v`. BenchmarkPython 00189:
|
|
3384
|
+
*
|
|
3385
|
+
* m['keyB'] = param # m dirty
|
|
3386
|
+
* m['keyC'] = 'another' # m wiped clean - the whole dict
|
|
3387
|
+
* bar = m['keyB'] # silent: not even "to review"
|
|
3388
|
+
*
|
|
3389
|
+
* And the mirror image claimed a proof it did not have: one dirty
|
|
3390
|
+
* element, then a read of a CLEAN key, read back the dirt as certain.
|
|
3391
|
+
* Now the container keeps every element: a clean write leaves the dirt
|
|
3392
|
+
* where it is, a dirty one joins it, and a container holding more than
|
|
3393
|
+
* one element is marked collectedMany, so reading ONE back is a guess
|
|
3394
|
+
* unless local-map.ts can show which write it returns.
|
|
3395
|
+
*/
|
|
3396
|
+
const elementWrite = ELEMENT_TARGETS.has(parts.target.type) && targetNames.length === 1 && /^[A-Za-z_$][\w$]*$/.test(targetNames[0]);
|
|
3397
|
+
if (elementWrite) {
|
|
3398
|
+
const name = targetNames[0];
|
|
3399
|
+
const before = scope.env.get(name);
|
|
3400
|
+
const many = !!before || elementCount(node, name) > 1;
|
|
3401
|
+
if (taint) {
|
|
3402
|
+
const stored = addStep(taint, parts.target, `written into an element of \`${name}\``);
|
|
3403
|
+
// joinParts, but not `joined`: that marks a string built from parts.
|
|
3404
|
+
const both = before ? { ...joinParts([before, stored]), joined: before.joined } : stored;
|
|
3405
|
+
scope.env.set(name, many ? { ...both, collectedMany: true } : both);
|
|
3406
|
+
}
|
|
3407
|
+
else if (before) {
|
|
3408
|
+
scope.env.set(name, { ...before, collectedMany: true });
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
for (const targetName of elementWrite ? [] : targetNames) {
|
|
3412
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(targetName))
|
|
3413
|
+
continue;
|
|
3414
|
+
const before = scope.env.get(targetName);
|
|
3415
|
+
if (taint &&
|
|
3416
|
+
before &&
|
|
3417
|
+
!extendsTarget &&
|
|
3418
|
+
washedKinds(taint).some((k) => !washedKinds(before).includes(k)) &&
|
|
3419
|
+
underCondition(node, scope.body, scope.filePath) &&
|
|
3420
|
+
skippedBy(node, before, scope.body, scope.filePath) !== null) {
|
|
3421
|
+
/*
|
|
3422
|
+
* A CONDITIONAL WRITE OF A WASHED VALUE OVER A DIRTY ONE.
|
|
3423
|
+
*
|
|
3424
|
+
* if (raw) p = input; else p = escapeHtml(input);
|
|
3425
|
+
* out.println(p);
|
|
3426
|
+
*
|
|
3427
|
+
* This printed "checked and cleared": the last write wins in this
|
|
3428
|
+
* engine, and the last write was the escaped one. On every run that
|
|
3429
|
+
* takes the first arm the page gets the raw input. A conditional
|
|
3430
|
+
* CLEAN write was already handled below - kept the dirt, flagged the
|
|
3431
|
+
* assumption - but a washed value is not clean, it is a taint, so it
|
|
3432
|
+
* took this branch and replaced the dirt outright.
|
|
3433
|
+
*
|
|
3434
|
+
* Inside the branch, after the write, the value IS the washed one -
|
|
3435
|
+
* DVWA's sqli/source/impossible.php checks is_numeric(), calls
|
|
3436
|
+
* intval() and prints, all in one block, and that stays cleared.
|
|
3437
|
+
* When the walk leaves the branch that could skip the write, the
|
|
3438
|
+
* name holds either value, so the old dirt comes back for every
|
|
3439
|
+
* kind the new value does not wash. That is flagged as resting on a
|
|
3440
|
+
* branch, like a skipped clean write - unless the dirty write sits
|
|
3441
|
+
* in the other arm of one if/else (inOtherArm), where the run that
|
|
3442
|
+
* took that arm certainly delivers it.
|
|
3443
|
+
*/
|
|
3444
|
+
const stored = addStep(taint, parts.target, `stored in \`${targetName}\``);
|
|
3445
|
+
scope.env.set(targetName, stored);
|
|
3446
|
+
const block = skippedBy(node, before, scope.body, scope.filePath);
|
|
3447
|
+
if (block) {
|
|
3448
|
+
const pendingEither = [...new Set([...(before.quotePending ?? []), ...(taint.quotePending ?? [])])];
|
|
3449
|
+
const either = {
|
|
3450
|
+
...before,
|
|
3451
|
+
sanitizedFor: new Set([...before.sanitizedFor].filter((k) => taint.sanitizedFor.has(k))),
|
|
3452
|
+
afterWash: joinedAfterWash([before, taint], new Set([...before.sanitizedFor].filter((k) => taint.sanitizedFor.has(k)))),
|
|
3453
|
+
quotePending: pendingEither.length > 0 ? pendingEither : undefined,
|
|
3454
|
+
branchAssumed: inOtherArm(node, before, scope.body, scope.filePath) ? before.branchAssumed : true,
|
|
3455
|
+
};
|
|
3456
|
+
const key = spanKey(scope.filePath, block);
|
|
3457
|
+
armSettles.set(key, [...(armSettles.get(key) ?? []), { target: targetName, inside: stored, after: either }]);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
else if (taint) {
|
|
3461
|
+
const stored = addStep(taint, parts.target, `stored in \`${targetName}\``);
|
|
3462
|
+
// The value now has a name - see `throughOf`. Not for `.=`/`+=`,
|
|
3463
|
+
// whose result also holds whatever the name held before.
|
|
3464
|
+
if (guardOn && !extendsTarget && targetNames.length === 1) {
|
|
3465
|
+
const binding = `${scope.filePath}#${node.id}#${targetName}`;
|
|
3466
|
+
bindingOf.set(stored, binding);
|
|
3467
|
+
throughOf.set(stored, new Set([...(throughOf.get(taint) ?? []), binding]));
|
|
3468
|
+
}
|
|
3469
|
+
scope.env.set(targetName, stored);
|
|
3470
|
+
}
|
|
3471
|
+
else if (extendsTarget) {
|
|
3472
|
+
// Appending something clean to a dirty value leaves it dirty.
|
|
3473
|
+
continue;
|
|
3474
|
+
}
|
|
3475
|
+
else if (scope.env.has(targetName) && underCondition(node, scope.body, scope.filePath)) {
|
|
3476
|
+
/*
|
|
3477
|
+
* A clean write we cannot prove happens. Keep the dirt - but record
|
|
3478
|
+
* that we ASSUMED rather than followed, so no proof is claimed on
|
|
3479
|
+
* the strength of it. See the note on branchAssumed.
|
|
3480
|
+
*/
|
|
3481
|
+
const kept = scope.env.get(targetName);
|
|
3482
|
+
if (kept) {
|
|
3483
|
+
const certain = guardCannotCleanTarget(node, scope.body, targetName);
|
|
3484
|
+
const assumed = { ...kept, branchAssumed: true };
|
|
3485
|
+
scope.env.set(targetName, certain ? kept : assumed);
|
|
3486
|
+
// Clean and dirty writes in different arms of one if/else: the
|
|
3487
|
+
// assumption holds only until that if/else ends - see inOtherArm.
|
|
3488
|
+
const shared = certain ? null : inOtherArm(node, kept, scope.body, scope.filePath);
|
|
3489
|
+
if (shared) {
|
|
3490
|
+
const key = spanKey(scope.filePath, shared);
|
|
3491
|
+
armSettles.set(key, [...(armSettles.get(key) ?? []), { target: targetName, inside: assumed, after: kept }]);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
else {
|
|
3496
|
+
scope.env.delete(targetName); // reassigned to something clean
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
// Still descend into the value: `const x = db.query(dirty)` has a sink
|
|
3500
|
+
// inside an assignment.
|
|
3501
|
+
//
|
|
3502
|
+
// All THREE dispatches, not just the argument one. `Process p =
|
|
3503
|
+
// pb.start();` is an assignment whose value is a receiver sink, and it
|
|
3504
|
+
// is the standard idiom - the return is captured even though the
|
|
3505
|
+
// dangerous act needs no arguments. Having one dispatch here and three
|
|
3506
|
+
// in the statement branch meant the same call was analysed differently
|
|
3507
|
+
// depending on whether anybody kept its result.
|
|
3508
|
+
for (const child of parts.value.namedChildren)
|
|
3509
|
+
if (child)
|
|
3510
|
+
walk(child, scope, depth + 1);
|
|
3511
|
+
if (CALL_NODES.has(parts.value.type)) {
|
|
3512
|
+
checkMutation(parts.value, scope);
|
|
3513
|
+
checkOutParamMutation(parts.value, scope);
|
|
3514
|
+
checkReceiverSink(parts.value, scope);
|
|
3515
|
+
checkCallSink(parts.value, scope);
|
|
3516
|
+
}
|
|
3517
|
+
return;
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
if (node.type === 'return_statement') {
|
|
3521
|
+
const expression = node.namedChildren.find((c) => c !== null) ?? null;
|
|
3522
|
+
const returned = expression ? evaluate(expression, scope) : null;
|
|
3523
|
+
// Returning a page IS writing a page, in a framework where the view's
|
|
3524
|
+
// return value is the response body. Without this the most ordinary
|
|
3525
|
+
// reflected XSS in Flask - `return f"<h1>{name}</h1>"` - produced nothing
|
|
3526
|
+
// at all, and the correctly-escaped version of the same code looked
|
|
3527
|
+
// "clean" only because no sink existed for the escaping to be credited
|
|
3528
|
+
// against. Two cases passing for the wrong reason is worse than one
|
|
3529
|
+
// failing for the right one.
|
|
3530
|
+
// `return render_template_string("<h1>" + name)` is ALREADY a call sink.
|
|
3531
|
+
// Counting the return as a second sink reported the same bug twice at the
|
|
3532
|
+
// same line, and double-recorded the escaped version as clean. The return
|
|
3533
|
+
// only adds something when the returned expression IS the string.
|
|
3534
|
+
const returnsAString = expression !== null && !CALL_NODES.has(expression.type);
|
|
3535
|
+
const flaskRoute = language === 'python' && expression !== null && flaskRouteReturningStr(node, expression);
|
|
3536
|
+
if (dictionary.htmlReturnIsSink &&
|
|
3537
|
+
returned &&
|
|
3538
|
+
expression &&
|
|
3539
|
+
returnsAString &&
|
|
3540
|
+
(looksLikeHtml(text(expression)) || flaskRoute)) {
|
|
3541
|
+
checkTaintAtSink(returned, expression, 'xss', 'an HTML response body returned by this function', flaskRoute
|
|
3542
|
+
? 'is returned as a str from a Flask route (`@...route`), which Flask sends as the ' +
|
|
3543
|
+
'response body with Content-Type text/html - markup in the value is rendered. A ' +
|
|
3544
|
+
'content type changed elsewhere (after_request, a config default) is not seen.'
|
|
3545
|
+
: 'is returned from this function as an HTML string - in Flask/Django a view\'s ' +
|
|
3546
|
+
'return value IS the response body. We do not verify that this function is a ' +
|
|
3547
|
+
'route handler; if it is not, this string is rendered somewhere else instead.');
|
|
3548
|
+
}
|
|
3549
|
+
// While analysing a called function, remember what it hands back.
|
|
3550
|
+
if (scope.onReturn)
|
|
3551
|
+
scope.onReturn(returned, node);
|
|
3552
|
+
}
|
|
3553
|
+
if (CALL_NODES.has(node.type)) {
|
|
3554
|
+
checkMutation(node, scope);
|
|
3555
|
+
checkOutParamMutation(node, scope);
|
|
3556
|
+
checkReceiverSink(node, scope);
|
|
3557
|
+
checkCallSink(node, scope);
|
|
3558
|
+
// A bare call statement - `helper(req.query.id);` - is never evaluated as
|
|
3559
|
+
// an expression, so without this line the cross-function trace would only
|
|
3560
|
+
// work when the result was assigned to something. Easy gap to miss.
|
|
3561
|
+
descendIntoLocalCall(node, scope, 0);
|
|
3562
|
+
}
|
|
3563
|
+
for (const child of node.namedChildren) {
|
|
3564
|
+
if (child)
|
|
3565
|
+
walk(child, scope, depth + 1);
|
|
3566
|
+
}
|
|
3567
|
+
if (armSettles.size > 0)
|
|
3568
|
+
settleArms(node, scope);
|
|
3569
|
+
};
|
|
3570
|
+
/* ---- following a value into another function ---- */
|
|
3571
|
+
descendIntoLocalCall = (node, scope, depth) => {
|
|
3572
|
+
const { text: calleeText } = calleeOf(node);
|
|
3573
|
+
const calleeName = lastSegment(calleeText);
|
|
3574
|
+
/*
|
|
3575
|
+
* A FULLY QUALIFIED CALL IS NOT A LOCAL FUNCTION, however the names line up.
|
|
3576
|
+
*
|
|
3577
|
+
* java.util.Collections.list(request.getParameterNames())
|
|
3578
|
+
*
|
|
3579
|
+
* resolved to a fixture's own `list(HttpServletRequest, HttpServletResponse)`
|
|
3580
|
+
* three files away, because resolution used the LAST SEGMENT of the callee
|
|
3581
|
+
* and threw the package path away. The descent then "handled" the call and
|
|
3582
|
+
* returned that unrelated method's answer, which was nothing - so a proof
|
|
3583
|
+
* that existed when the file was scanned alone disappeared when it was
|
|
3584
|
+
* scanned alongside its neighbours.
|
|
3585
|
+
*
|
|
3586
|
+
* That is the worst shape a bug can take here: the same code, analysed
|
|
3587
|
+
* twice, giving two answers, with no message either time.
|
|
3588
|
+
*
|
|
3589
|
+
* The engine's limitations already say "whether a function is actually
|
|
3590
|
+
* EXPORTED is not checked - a module-private helper with a matching name
|
|
3591
|
+
* could be entered". This is that, with a fact available to rule it out and
|
|
3592
|
+
* nobody reading it. `java.util.Collections` names one class in one package;
|
|
3593
|
+
* a local method cannot be it.
|
|
3594
|
+
*
|
|
3595
|
+
* The test is the Java package convention - lowercase segments then a
|
|
3596
|
+
* Capitalised type - which is deliberately narrow. `Utils.list(x)` and
|
|
3597
|
+
* `this.list(x)` are unqualified enough to still resolve locally, and are
|
|
3598
|
+
* left alone.
|
|
3599
|
+
*/
|
|
3600
|
+
if (/^[a-z][\w$]*(?:\.[a-z][\w$]*)+\.[A-Z][\w$]*\.[\w$]+$/.test(calleeText.trim())) {
|
|
3601
|
+
return { handled: false, taint: null };
|
|
3602
|
+
}
|
|
3603
|
+
/* ---- PHASE 3c: the function may live in another file. ----
|
|
3604
|
+
* We look locally first, then ask the project index. The index only
|
|
3605
|
+
* answers when the answer is UNAMBIGUOUS - one file, one function. If
|
|
3606
|
+
* three modules export `sanitize`, it says nothing and records that it
|
|
3607
|
+
* declined, which is printed in the report. Picking one at random would be
|
|
3608
|
+
* how a scanner ends up proving a path that does not exist. */
|
|
3609
|
+
/*
|
|
3610
|
+
* A NAME IS NOT ENOUGH WHERE THE LANGUAGE COUNTS ARGUMENTS.
|
|
3611
|
+
*
|
|
3612
|
+
* Calls are resolved by name, and in BenchmarkJava that name was
|
|
3613
|
+
* `doSomething` - which every test file declares. So a one-argument
|
|
3614
|
+
* `thing.doSomething(param)` resolved to the file's own two-parameter
|
|
3615
|
+
* `doSomething(request, param)`, the tainted value was bound to `request`,
|
|
3616
|
+
* `param` arrived empty, and the flow vanished. In Java, Go and the C family
|
|
3617
|
+
* a one-argument call cannot run a two-parameter function; the count rules
|
|
3618
|
+
* it out. Python, JavaScript and PHP accept the wrong count, so there it
|
|
3619
|
+
* proves nothing and is not used.
|
|
3620
|
+
*
|
|
3621
|
+
* A local function the call cannot reach is set aside and the project index
|
|
3622
|
+
* asked instead, exactly as if the name had not been found here.
|
|
3623
|
+
*/
|
|
3624
|
+
const argCount = argumentsOf(node).length;
|
|
3625
|
+
// Only asked for when a same-named target actually exists - most calls
|
|
3626
|
+
// (println, getParameter) have none, and never pay for the lookup.
|
|
3627
|
+
const receiverTypeOfCall = () => {
|
|
3628
|
+
const key = `${scope.filePath}\0${node.startIndex}:${node.endIndex}`;
|
|
3629
|
+
if (!receiverTypes.has(key))
|
|
3630
|
+
receiverTypes.set(key, javaReceiverType(node));
|
|
3631
|
+
return receiverTypes.get(key) ?? null;
|
|
3632
|
+
};
|
|
3633
|
+
const reachable = (fn) => {
|
|
3634
|
+
if (ARITY_ENFORCED.has(language) &&
|
|
3635
|
+
fn.minArgs !== undefined &&
|
|
3636
|
+
(argCount < fn.minArgs || argCount > (fn.maxArgs ?? Infinity))) {
|
|
3637
|
+
return false;
|
|
3638
|
+
}
|
|
3639
|
+
/*
|
|
3640
|
+
* A CALL ON A `Fragment` CAN ONLY RUN A `Fragment` METHOD.
|
|
3641
|
+
*
|
|
3642
|
+
* Across files in one package, the index answers whenever exactly one
|
|
3643
|
+
* file declares the name - so `f.renderFragment(param)`, with `f`
|
|
3644
|
+
* declared as an interface, was followed into an unrelated class that
|
|
3645
|
+
* shared the method name and returned a constant. The flow came back
|
|
3646
|
+
* clean. ReceiverTypeCaller.java is that case; SameNameDelegate.java
|
|
3647
|
+
* found it by accident, passing alone and failing beside a fixture that
|
|
3648
|
+
* declared its own doSomething.
|
|
3649
|
+
*
|
|
3650
|
+
* Java writes the receiver's type down, so a target declared in some
|
|
3651
|
+
* other type is ruled out. An interface-typed receiver therefore reaches
|
|
3652
|
+
* no body at all - which is correct: which implementation runs is a
|
|
3653
|
+
* runtime fact, and the value is carried with that assumption named.
|
|
3654
|
+
*/
|
|
3655
|
+
if (language === 'java') {
|
|
3656
|
+
const declaring = declaringTypeOf(fn);
|
|
3657
|
+
const receiverType = declaring ? receiverTypeOfCall() : null;
|
|
3658
|
+
if (declaring && receiverType && declaring !== receiverType)
|
|
3659
|
+
return false;
|
|
3660
|
+
}
|
|
3661
|
+
return true;
|
|
3662
|
+
};
|
|
3663
|
+
let local = localFunctions.get(calleeName);
|
|
3664
|
+
if (local && !reachable(local))
|
|
3665
|
+
local = undefined;
|
|
3666
|
+
let targetPath = scope.filePath;
|
|
3667
|
+
if (!local && resolver) {
|
|
3668
|
+
const resolved = resolver.resolve(scope.filePath, calleeName);
|
|
3669
|
+
if (resolved && reachable(resolved.fn)) {
|
|
3670
|
+
local = resolved.fn;
|
|
3671
|
+
targetPath = resolved.path;
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
if (!local)
|
|
3675
|
+
return { handled: false, taint: null };
|
|
3676
|
+
/*
|
|
3677
|
+
* THE TWO STOPS, AND WHAT A STOP IS ALLOWED TO CONCLUDE.
|
|
3678
|
+
*
|
|
3679
|
+
* "Both are recorded as misses, never as invented answers" - that is what
|
|
3680
|
+
* this comment used to say, above two lines that returned `taint: null`.
|
|
3681
|
+
* No taint IS an answer: it is "this value is clean", and it is the
|
|
3682
|
+
* dangerous one. Every flow that reached a stop was silently cut, and the
|
|
3683
|
+
* only trace of it was a counter.
|
|
3684
|
+
*
|
|
3685
|
+
* It cost all 38 of BenchmarkJava's missed vulnerabilities (a helper named
|
|
3686
|
+
* doSomething calling thing.doSomething, taken for a call to itself), and it
|
|
3687
|
+
* costs every delegating method - `run` forwarding to `self.engine.run` -
|
|
3688
|
+
* and every call chain deeper than MAX_CALL_DEPTH, in every language.
|
|
3689
|
+
*
|
|
3690
|
+
* A stop now hands the call back as NOT HANDLED, which sends it to the
|
|
3691
|
+
* branch this file already has for a function it cannot read: the value
|
|
3692
|
+
* keeps its taint, and the path says, at that hop, that the trace stopped
|
|
3693
|
+
* following and why. The assumption is made in the open, where a reader can
|
|
3694
|
+
* check it - which is what the old comment promised and the code did not do.
|
|
3695
|
+
*/
|
|
3696
|
+
const stackKey = `${targetPath}::${local.name}`;
|
|
3697
|
+
if (callStack.length >= MAX_CALL_DEPTH) {
|
|
3698
|
+
seenDepth.add(siteOf(node, scope.filePath));
|
|
3699
|
+
return { handled: false, taint: null, stopped: 'depth' };
|
|
3700
|
+
}
|
|
3701
|
+
if (callStack.includes(stackKey)) {
|
|
3702
|
+
seenRecursion.add(siteOf(node, scope.filePath));
|
|
3703
|
+
return { handled: false, taint: null, stopped: 'recursion' };
|
|
3704
|
+
}
|
|
3705
|
+
// Which arguments are dirty, and what is their history so far?
|
|
3706
|
+
const argumentTaints = argumentsOf(node).map((arg) => evaluate(arg, scope, depth + 1));
|
|
3707
|
+
/* ---- the cache key has to include WHICH ARGUMENTS ARE DIRTY ----
|
|
3708
|
+
*
|
|
3709
|
+
* It used to be the call site alone (`file#nodeId`), and that quietly
|
|
3710
|
+
* capped interprocedural tracing at ONE level.
|
|
3711
|
+
*
|
|
3712
|
+
* Why: the main walk visits every function body at top level, where the
|
|
3713
|
+
* parameters are unbound. So `return s2(x)` inside `s1` is evaluated once
|
|
3714
|
+
* with `x` clean, decides "no dirty arguments, nothing to do", and caches
|
|
3715
|
+
* NULL against that call site. Later, when the tracer descends into `s1`
|
|
3716
|
+
* from a real caller with `x` tainted, it reaches the same node id and gets
|
|
3717
|
+
* handed back that stale null. The taint dies one level in.
|
|
3718
|
+
*
|
|
3719
|
+
* The outermost call is never poisoned this way - it is only ever seen with
|
|
3720
|
+
* tainted arguments - which is exactly why `handler -> s1` worked and
|
|
3721
|
+
* `handler -> s1 -> s2` did not, and why it looked like a depth limit doing
|
|
3722
|
+
* its job. MAX_CALL_DEPTH is 4; the real reach was 1, and the capability
|
|
3723
|
+
* text claiming four was wrong by three.
|
|
3724
|
+
*
|
|
3725
|
+
* Keyed by taint shape, the same call with the same dirty arguments still
|
|
3726
|
+
* hits the cache - which is all the cache was ever for. */
|
|
3727
|
+
const taintShape = argumentTaints.map((t) => (t ? '1' : '0')).join('');
|
|
3728
|
+
const cacheKey = `${scope.filePath}#${node.id}#${taintShape}`;
|
|
3729
|
+
if (descendCache.has(cacheKey)) {
|
|
3730
|
+
return { handled: true, taint: descendCache.get(cacheKey) ?? null };
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* A helper can be worth entering even with entirely clean arguments, if it
|
|
3734
|
+
* fetches attacker data ITSELF:
|
|
3735
|
+
*
|
|
3736
|
+
* function readId(req) { return req.query.id; }
|
|
3737
|
+
* const id = readId(req); // <- `req` alone is not a source
|
|
3738
|
+
* db.query("... " + id);
|
|
3739
|
+
*
|
|
3740
|
+
* `req` on its own does not match any source pattern (we only recognise
|
|
3741
|
+
* `req.query`-shaped expressions), so the argument looks clean and the old
|
|
3742
|
+
* check bailed out. Peeking at whether the body mentions a source at all is
|
|
3743
|
+
* a cheap text test that unlocks this very common shape.
|
|
3744
|
+
*/
|
|
3745
|
+
const bodyText = (local.body.text ?? '').slice(0, 4000);
|
|
3746
|
+
const bodyFetchesASource = dictionary.sources.some((source) => source.pattern.test(bodyText));
|
|
3747
|
+
if (!argumentTaints.some((t) => t !== null) && !bodyFetchesASource) {
|
|
3748
|
+
descendCache.set(cacheKey, null);
|
|
3749
|
+
return { handled: true, taint: null };
|
|
3750
|
+
}
|
|
3751
|
+
// Bind each dirty argument to the NAME the callee will use for it. This one
|
|
3752
|
+
// line is the whole idea of interprocedural analysis: the value does not
|
|
3753
|
+
// change, only what it is called.
|
|
3754
|
+
const env = new Map();
|
|
3755
|
+
local.params.forEach((parameter, index) => {
|
|
3756
|
+
const taint = argumentTaints[index];
|
|
3757
|
+
if (!parameter || !taint)
|
|
3758
|
+
return;
|
|
3759
|
+
env.set(parameter, addStep(taint, node, `passed into \`${local.name}()\` as parameter \`${parameter}\``));
|
|
3760
|
+
});
|
|
3761
|
+
if (env.size === 0 && !bodyFetchesASource) {
|
|
3762
|
+
descendCache.set(cacheKey, null);
|
|
3763
|
+
return { handled: true, taint: null };
|
|
3764
|
+
}
|
|
3765
|
+
let returned = null;
|
|
3766
|
+
const calleeScope = {
|
|
3767
|
+
body: local.body,
|
|
3768
|
+
env,
|
|
3769
|
+
filePath: targetPath,
|
|
3770
|
+
prepared: new Set(),
|
|
3771
|
+
builders: new Set(),
|
|
3772
|
+
onReturn: (taint, returnNode) => {
|
|
3773
|
+
if (taint && !returned) {
|
|
3774
|
+
returned = addStep(taint, returnNode, `returned from \`${local.name}()\``);
|
|
3775
|
+
}
|
|
3776
|
+
},
|
|
3777
|
+
};
|
|
3778
|
+
callStack.push(stackKey);
|
|
3779
|
+
// Step into the callee's file for the duration of the descent, so every
|
|
3780
|
+
// hop recorded in there points at the right file and line. Restored below.
|
|
3781
|
+
const callerPath = currentPath;
|
|
3782
|
+
currentPath = targetPath;
|
|
3783
|
+
if (local.body.type === 'statement_block' || local.body.namedChildCount > 0) {
|
|
3784
|
+
for (const child of local.body.namedChildren) {
|
|
3785
|
+
if (child)
|
|
3786
|
+
walk(child, calleeScope);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
// A concise arrow body (`x => f(x)`) is an expression, not a block: its
|
|
3790
|
+
// value IS the return value, so evaluate it directly.
|
|
3791
|
+
if (local.body.type !== 'statement_block' && local.body.type !== 'block') {
|
|
3792
|
+
const direct = evaluate(local.body, calleeScope);
|
|
3793
|
+
if (direct && !returned) {
|
|
3794
|
+
returned = addStep(direct, local.body, `returned from \`${local.name}()\``);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
currentPath = callerPath;
|
|
3798
|
+
callStack.pop();
|
|
3799
|
+
descendCache.set(cacheKey, returned);
|
|
3800
|
+
return { handled: true, taint: returned };
|
|
3801
|
+
};
|
|
3802
|
+
// Process every scope. `queue` grows as nested functions are discovered.
|
|
3803
|
+
let processed = 0;
|
|
3804
|
+
while (processed < queue.length && processed < 500) {
|
|
3805
|
+
const scope = queue[processed];
|
|
3806
|
+
processed++;
|
|
3807
|
+
if (!scope)
|
|
3808
|
+
continue;
|
|
3809
|
+
currentPath = scope.filePath;
|
|
3810
|
+
for (const child of scope.body.namedChildren) {
|
|
3811
|
+
if (child)
|
|
3812
|
+
walk(child, scope);
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
return { flows: results, sanitized, unreachable, resolvedReads, freshStops, limits: limits() };
|
|
3816
|
+
}
|
|
3817
|
+
//# sourceMappingURL=tracer.js.map
|