@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,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS EXPRESSION SAFE TO HAND TO AN HTML PARSER?
|
|
3
|
+
* ==================================================
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS FILE EXISTS
|
|
6
|
+
*
|
|
7
|
+
* The XSS rule used to fire on the SHAPE of the line alone:
|
|
8
|
+
*
|
|
9
|
+
* element.innerHTML = <anything not a fixed string>
|
|
10
|
+
*
|
|
11
|
+
* and then print "No escaping or sanitiser call is visible at this line."
|
|
12
|
+
*
|
|
13
|
+
* Pointing the scanner at this project's own browser UI produced seven of
|
|
14
|
+
* these. Every one was wrong, and one of them was wrong in a way that matters:
|
|
15
|
+
*
|
|
16
|
+
* $('progressLabel').innerHTML = `<span class="err">${escapeHtml(msg)}</span>`
|
|
17
|
+
*
|
|
18
|
+
* The escaper is RIGHT THERE, in the same expression, four characters from the
|
|
19
|
+
* value it protects. The finding said we could not see it. That is not a
|
|
20
|
+
* conservative guess, it is a false statement about code we had already parsed,
|
|
21
|
+
* and a tool whose entire pitch is "we never overclaim" cannot afford to make
|
|
22
|
+
* it.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS DOES
|
|
25
|
+
*
|
|
26
|
+
* It answers one question about one expression: can every value spliced into
|
|
27
|
+
* this string be PROVEN incapable of carrying markup? A value qualifies when
|
|
28
|
+
* it is
|
|
29
|
+
*
|
|
30
|
+
* - a fixed literal (nobody injects into a constant),
|
|
31
|
+
* - a number, or an expression that can only produce one (`x.length`,
|
|
32
|
+
* `a - b`, `n.toFixed(2)`) - digits cannot open a tag,
|
|
33
|
+
* - the result of a known escaping function (`escapeHtml`, `encodeURIComponent`,
|
|
34
|
+
* `DOMPurify.sanitize`),
|
|
35
|
+
* - a local variable or local function whose every possible value is,
|
|
36
|
+
* recursively, one of the above.
|
|
37
|
+
*
|
|
38
|
+
* The last case is what makes it useful rather than cosmetic. Real UI code is
|
|
39
|
+
* built out of small render helpers - `list.map(renderRow).join('')` - and a
|
|
40
|
+
* check that stops at the first function call proves nothing about any of them.
|
|
41
|
+
*
|
|
42
|
+
* WHAT IT DELIBERATELY DOES NOT DO
|
|
43
|
+
*
|
|
44
|
+
* It PROVES SAFE or it says NOTHING. There is no "probably fine". Anything it
|
|
45
|
+
* does not recognise - a bare property access, an imported function, a value
|
|
46
|
+
* from another file - comes back unproven, and the finding still fires. That
|
|
47
|
+
* asymmetry is the whole design: a wrong "safe" hides a real cross-site
|
|
48
|
+
* scripting bug, while a wrong "unproven" only costs the reader a second look.
|
|
49
|
+
*
|
|
50
|
+
* It also does not decide whether the value is attacker-controlled. That is the
|
|
51
|
+
* tracer's job, one pass later. This is the narrower question of whether the
|
|
52
|
+
* value could carry markup at all, and it is worth asking first because a value
|
|
53
|
+
* that provably cannot needs no trace.
|
|
54
|
+
*
|
|
55
|
+
* SCOPE: JavaScript and TypeScript only. Those are the languages whose HTML
|
|
56
|
+
* sinks take an expression built in the same file. The Python, Java and Go
|
|
57
|
+
* branches of the XSS rule are untouched and still report on shape alone -
|
|
58
|
+
* that gap is stated in the rule's `support` notes rather than papered over.
|
|
59
|
+
*/
|
|
60
|
+
/* ---------------------------------------------------------------------------
|
|
61
|
+
* The escaper list.
|
|
62
|
+
*
|
|
63
|
+
* A name gets in here only if its ONE job is to make text safe for HTML. That
|
|
64
|
+
* excludes `escape()` (deprecated URL escaping - leaves `<` alone), `String()`
|
|
65
|
+
* (converts, does not clean) and `JSON.stringify` (its output can contain a
|
|
66
|
+
* literal `</script>`, which closes the surrounding tag and ends the game).
|
|
67
|
+
* ------------------------------------------------------------------------ */
|
|
68
|
+
const ESCAPER_NAMES = new Set([
|
|
69
|
+
'escapeHtml',
|
|
70
|
+
'escapeHTML',
|
|
71
|
+
'escapehtml',
|
|
72
|
+
'htmlEscape',
|
|
73
|
+
'html_escape',
|
|
74
|
+
'escapeHtmlAttribute',
|
|
75
|
+
'escapeAttribute',
|
|
76
|
+
'encodeURIComponent',
|
|
77
|
+
'encodeURI',
|
|
78
|
+
'sanitize',
|
|
79
|
+
'sanitizeHtml',
|
|
80
|
+
'sanitizeHTML',
|
|
81
|
+
'purify',
|
|
82
|
+
]);
|
|
83
|
+
/** Member-call escapers, matched on the property name of `X.sanitize(...)`. */
|
|
84
|
+
const ESCAPER_METHODS = new Set(['sanitize', 'escape', 'clean']);
|
|
85
|
+
/** Objects whose `.escape`/`.sanitize` we trust. Prevents `router.escape()` counting. */
|
|
86
|
+
const ESCAPER_OBJECTS = new Set([
|
|
87
|
+
'DOMPurify',
|
|
88
|
+
'dompurify',
|
|
89
|
+
'sanitizeHtml',
|
|
90
|
+
'xss',
|
|
91
|
+
'he',
|
|
92
|
+
'validator',
|
|
93
|
+
'escapeHtml',
|
|
94
|
+
]);
|
|
95
|
+
/** Properties that can only ever hold a number. Digits cannot open a tag. */
|
|
96
|
+
const NUMERIC_PROPERTIES = new Set(['length', 'size', 'byteLength']);
|
|
97
|
+
/** Methods whose return value is a number or a boolean, whatever the receiver. */
|
|
98
|
+
const NUMERIC_METHODS = new Set([
|
|
99
|
+
'toFixed',
|
|
100
|
+
'indexOf',
|
|
101
|
+
'lastIndexOf',
|
|
102
|
+
'charCodeAt',
|
|
103
|
+
'codePointAt',
|
|
104
|
+
'includes',
|
|
105
|
+
'startsWith',
|
|
106
|
+
'endsWith',
|
|
107
|
+
'has',
|
|
108
|
+
'valueOf',
|
|
109
|
+
]);
|
|
110
|
+
/** Operators whose result is a number or a boolean regardless of the operands. */
|
|
111
|
+
const NUMERIC_OPERATORS = new Set([
|
|
112
|
+
'-',
|
|
113
|
+
'*',
|
|
114
|
+
'/',
|
|
115
|
+
'%',
|
|
116
|
+
'**',
|
|
117
|
+
'<',
|
|
118
|
+
'>',
|
|
119
|
+
'<=',
|
|
120
|
+
'>=',
|
|
121
|
+
'==',
|
|
122
|
+
'!=',
|
|
123
|
+
'===',
|
|
124
|
+
'!==',
|
|
125
|
+
'instanceof',
|
|
126
|
+
'in',
|
|
127
|
+
'&',
|
|
128
|
+
'|',
|
|
129
|
+
'^',
|
|
130
|
+
'<<',
|
|
131
|
+
'>>',
|
|
132
|
+
'>>>',
|
|
133
|
+
]);
|
|
134
|
+
/** Array methods that pass their safety straight through from the receiver. */
|
|
135
|
+
const PASSTHROUGH_METHODS = new Set([
|
|
136
|
+
'filter',
|
|
137
|
+
'slice',
|
|
138
|
+
'sort',
|
|
139
|
+
'reverse',
|
|
140
|
+
'flat',
|
|
141
|
+
'concat',
|
|
142
|
+
'toSorted',
|
|
143
|
+
'toReversed',
|
|
144
|
+
]);
|
|
145
|
+
/** Node types that open a new function scope. We never resolve names across one. */
|
|
146
|
+
const FUNCTION_SCOPES = new Set([
|
|
147
|
+
'function_declaration',
|
|
148
|
+
'function_expression',
|
|
149
|
+
'function',
|
|
150
|
+
'arrow_function',
|
|
151
|
+
'method_definition',
|
|
152
|
+
'generator_function',
|
|
153
|
+
'generator_function_declaration',
|
|
154
|
+
]);
|
|
155
|
+
/** How far the recursion may go before giving up and reporting unproven. */
|
|
156
|
+
const MAX_DEPTH = 30;
|
|
157
|
+
/** How many nested calls we will follow with their arguments bound. */
|
|
158
|
+
const MAX_FRAMES = 6;
|
|
159
|
+
const NOT_APPLICABLE = { proven: false, unproven: [], escapers: [] };
|
|
160
|
+
/**
|
|
161
|
+
* A binding is normally the expression a caller passed. `NUMERIC` is the one
|
|
162
|
+
* exception: a name the LANGUAGE guarantees holds a number, with no expression
|
|
163
|
+
* anywhere to point at. See `judgeIteratorCallback`.
|
|
164
|
+
*/
|
|
165
|
+
const NUMERIC = Symbol('numeric-by-language');
|
|
166
|
+
export function supportsHtmlSafety(language) {
|
|
167
|
+
return language === 'javascript' || language === 'typescript';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Judge one expression. `root` is the file's root node, used to look up
|
|
171
|
+
* function declarations; pass `ctx.file.root`.
|
|
172
|
+
*/
|
|
173
|
+
export function analyzeHtmlSafety(node, root, language) {
|
|
174
|
+
if (!supportsHtmlSafety(language))
|
|
175
|
+
return NOT_APPLICABLE;
|
|
176
|
+
const unproven = [];
|
|
177
|
+
const escapers = new Set();
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
/* ---------------------------------------------------------------------
|
|
180
|
+
* ARGUMENT BINDING
|
|
181
|
+
*
|
|
182
|
+
* A render helper judged on its own is almost never provably safe:
|
|
183
|
+
*
|
|
184
|
+
* function tile(count, label, note, className) {
|
|
185
|
+
* return `<div class="${className}"><b>${count}</b>${label}</div>`;
|
|
186
|
+
* }
|
|
187
|
+
*
|
|
188
|
+
* `className` is a parameter, so on its own it could be anything, and the
|
|
189
|
+
* helper reads as unsafe at every call site. But the call sites say:
|
|
190
|
+
*
|
|
191
|
+
* tile(flow, 'flow-verified', '...', 'tile-verified')
|
|
192
|
+
*
|
|
193
|
+
* - all four arguments are literals or numbers. The helper is safe HERE,
|
|
194
|
+
* which is the only question being asked.
|
|
195
|
+
*
|
|
196
|
+
* So each call pushes a frame binding parameter names to the argument
|
|
197
|
+
* EXPRESSIONS, and a parameter resolves to whatever the caller passed. This
|
|
198
|
+
* is the same interprocedural move the taint tracer makes one pass later,
|
|
199
|
+
* for the same reason: a function boundary is not an analysis boundary.
|
|
200
|
+
*
|
|
201
|
+
* The frame is popped while the argument itself is judged, so an argument
|
|
202
|
+
* that happens to reuse a parameter's name resolves in the CALLER's scope,
|
|
203
|
+
* where it belongs.
|
|
204
|
+
* ------------------------------------------------------------------- */
|
|
205
|
+
const frames = [];
|
|
206
|
+
const collapse = (n) => (n.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
207
|
+
/**
|
|
208
|
+
* Returns true when `n` is proven safe. Records the reason for every
|
|
209
|
+
* failure so the finding can point at the actual risky sub-expression
|
|
210
|
+
* instead of the whole statement.
|
|
211
|
+
*/
|
|
212
|
+
const judge = (n, depth) => {
|
|
213
|
+
if (!n)
|
|
214
|
+
return false;
|
|
215
|
+
// Every `false` this function returns MUST leave a reason behind. A verdict
|
|
216
|
+
// of "not proven" with nothing to point at produces a finding that says
|
|
217
|
+
// "something here is unsafe" and names nothing - which is the vague,
|
|
218
|
+
// unfalsifiable output this whole project exists to avoid.
|
|
219
|
+
if (depth > MAX_DEPTH) {
|
|
220
|
+
unproven.push(`${collapse(n)} (nested deeper than this analysis follows)`);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
// A name that refers to itself (a recursive render helper) must not spin.
|
|
224
|
+
// The back-edge stops rather than re-judging: every sub-expression of the
|
|
225
|
+
// function body is judged exactly once on the way in, so the second visit
|
|
226
|
+
// could not discover anything the first did not. The key includes the frame
|
|
227
|
+
// depth, because the same expression judged with different arguments bound
|
|
228
|
+
// is a different question.
|
|
229
|
+
const key = `${n.id}#${frames.length}`;
|
|
230
|
+
if (seen.has(key))
|
|
231
|
+
return true;
|
|
232
|
+
seen.add(key);
|
|
233
|
+
switch (n.type) {
|
|
234
|
+
/* ---- Fixed values ---- */
|
|
235
|
+
case 'string':
|
|
236
|
+
case 'number':
|
|
237
|
+
case 'true':
|
|
238
|
+
case 'false':
|
|
239
|
+
case 'null':
|
|
240
|
+
case 'undefined':
|
|
241
|
+
case 'regex':
|
|
242
|
+
return true;
|
|
243
|
+
/* ---- A comment produces no value, so it cannot carry markup ---- */
|
|
244
|
+
case 'comment':
|
|
245
|
+
return true;
|
|
246
|
+
/* ---- `a ${b} c` ---- */
|
|
247
|
+
case 'template_string': {
|
|
248
|
+
let ok = true;
|
|
249
|
+
for (const child of n.namedChildren) {
|
|
250
|
+
if (!child)
|
|
251
|
+
continue;
|
|
252
|
+
if (child.type !== 'template_substitution')
|
|
253
|
+
continue;
|
|
254
|
+
/*
|
|
255
|
+
* A `${ }` hole can contain a comment BEFORE the expression, and this
|
|
256
|
+
* used to take the substitution's first named child - which in that
|
|
257
|
+
* case is the comment, not the code:
|
|
258
|
+
*
|
|
259
|
+
* ${
|
|
260
|
+
* // A traced path is only as strong as its weakest hop.
|
|
261
|
+
* verified && finding.unmodelledHops?.length ? ... : ''
|
|
262
|
+
* }
|
|
263
|
+
*
|
|
264
|
+
* Scanning this project's own UI found it: the checker reported the
|
|
265
|
+
* COMMENT as the thing it could not prove safe, and never looked at
|
|
266
|
+
* the ternary underneath. Both directions of that bug are bad - here
|
|
267
|
+
* it invented a false alarm, but a hole whose comment came first and
|
|
268
|
+
* whose expression was genuinely dangerous would have been waved
|
|
269
|
+
* through the moment `comment` counted as safe.
|
|
270
|
+
*
|
|
271
|
+
* So: drop comments, then judge EVERY remaining part rather than the
|
|
272
|
+
* first one. A hole holds one expression, but "judge all of them" is
|
|
273
|
+
* the reading that cannot silently skip anything.
|
|
274
|
+
*/
|
|
275
|
+
const parts = child.namedChildren.filter((c) => c !== null && c.type !== 'comment');
|
|
276
|
+
for (const part of parts)
|
|
277
|
+
if (!judge(part, depth + 1))
|
|
278
|
+
ok = false;
|
|
279
|
+
}
|
|
280
|
+
return ok;
|
|
281
|
+
}
|
|
282
|
+
/* ---- a + b, a - b, a || b ---- */
|
|
283
|
+
case 'binary_expression': {
|
|
284
|
+
const operator = n.childForFieldName('operator')?.text ?? '';
|
|
285
|
+
// `a - b` is a number whatever a and b are; no need to look inside.
|
|
286
|
+
if (NUMERIC_OPERATORS.has(operator))
|
|
287
|
+
return true;
|
|
288
|
+
const left = judge(n.childForFieldName('left'), depth + 1);
|
|
289
|
+
const right = judge(n.childForFieldName('right'), depth + 1);
|
|
290
|
+
return left && right;
|
|
291
|
+
}
|
|
292
|
+
case 'unary_expression':
|
|
293
|
+
return true; // !x, -x, ~x, +x - all number or boolean
|
|
294
|
+
case 'parenthesized_expression':
|
|
295
|
+
return judge(n.namedChildren.find((c) => c !== null) ?? null, depth + 1);
|
|
296
|
+
/* ---- cond ? a : b - the condition never reaches the page ---- */
|
|
297
|
+
case 'ternary_expression': {
|
|
298
|
+
const consequence = judge(n.childForFieldName('consequence'), depth + 1);
|
|
299
|
+
const alternative = judge(n.childForFieldName('alternative'), depth + 1);
|
|
300
|
+
return consequence && alternative;
|
|
301
|
+
}
|
|
302
|
+
case 'array': {
|
|
303
|
+
let ok = true;
|
|
304
|
+
for (const element of n.namedChildren) {
|
|
305
|
+
if (!element)
|
|
306
|
+
continue;
|
|
307
|
+
if (!judge(element, depth + 1))
|
|
308
|
+
ok = false;
|
|
309
|
+
}
|
|
310
|
+
return ok;
|
|
311
|
+
}
|
|
312
|
+
case 'arrow_function':
|
|
313
|
+
case 'function_expression':
|
|
314
|
+
case 'function':
|
|
315
|
+
case 'function_declaration': {
|
|
316
|
+
// Reached as a VALUE (`rows.map(renderRow)`), so nobody told us what
|
|
317
|
+
// the arguments will be. Push an empty frame so an enclosing call's
|
|
318
|
+
// bindings cannot leak in and answer for a parameter they never saw.
|
|
319
|
+
frames.push(new Map());
|
|
320
|
+
const verdict = judgeFunctionResult(n, depth + 1);
|
|
321
|
+
frames.pop();
|
|
322
|
+
return verdict;
|
|
323
|
+
}
|
|
324
|
+
case 'member_expression': {
|
|
325
|
+
const property = n.childForFieldName('property')?.text ?? '';
|
|
326
|
+
if (NUMERIC_PROPERTIES.has(property))
|
|
327
|
+
return true;
|
|
328
|
+
unproven.push(collapse(n));
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
case 'call_expression':
|
|
332
|
+
return judgeCall(n, depth);
|
|
333
|
+
case 'identifier':
|
|
334
|
+
case 'shorthand_property_identifier': {
|
|
335
|
+
// A bound parameter resolves to what the caller actually passed.
|
|
336
|
+
const frame = frames[frames.length - 1];
|
|
337
|
+
const bound = frame?.get(n.text ?? '');
|
|
338
|
+
if (bound === NUMERIC)
|
|
339
|
+
return true;
|
|
340
|
+
if (bound) {
|
|
341
|
+
// Judge the argument in the CALLER's scope, not this one.
|
|
342
|
+
const saved = frames.pop();
|
|
343
|
+
const verdict = judge(bound, depth + 1);
|
|
344
|
+
frames.push(saved);
|
|
345
|
+
return verdict;
|
|
346
|
+
}
|
|
347
|
+
const bindings = resolveBinding(n);
|
|
348
|
+
if (bindings.length > 0) {
|
|
349
|
+
let ok = true;
|
|
350
|
+
for (const binding of bindings)
|
|
351
|
+
if (!judge(binding, depth + 1))
|
|
352
|
+
ok = false;
|
|
353
|
+
return ok;
|
|
354
|
+
}
|
|
355
|
+
/*
|
|
356
|
+
* An escaper passed BY REFERENCE:
|
|
357
|
+
*
|
|
358
|
+
* finding.unmodelledHops.map(escapeHtml).join(', ')
|
|
359
|
+
*
|
|
360
|
+
* This is the same promise as calling it, and it is one of the most
|
|
361
|
+
* common safe idioms in real UI code. Without this branch the name
|
|
362
|
+
* resolves to the function DECLARATION and we judge its body, which
|
|
363
|
+
* ends at `String(x).replace(...)` - a `.replace()` call we cannot
|
|
364
|
+
* prove anything about. The checker then reported the innards of its
|
|
365
|
+
* own escaper as the unsafe part, which is a confusing way to be wrong.
|
|
366
|
+
*
|
|
367
|
+
* The `bindings.length === 0` test above is what keeps this honest, and
|
|
368
|
+
* makes it STRICTER than the call form: a local `const sanitize = x =>
|
|
369
|
+
* x` shadows the trusted name, resolves first, and gets judged on its
|
|
370
|
+
* actual body instead of being waved through on its name.
|
|
371
|
+
*/
|
|
372
|
+
if (ESCAPER_NAMES.has(n.text ?? '')) {
|
|
373
|
+
escapers.add(`${n.text}()`);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
// A bare name can also be a function passed by reference -
|
|
377
|
+
// `rows.map(renderRow)`. Without this the commonest shape in real UI
|
|
378
|
+
// code resolves to nothing and every render helper reads as unproven.
|
|
379
|
+
const fn = findFunctionNamed(n.text ?? '', root);
|
|
380
|
+
if (fn)
|
|
381
|
+
return judgeFunctionResult(fn, depth + 1);
|
|
382
|
+
unproven.push(collapse(n));
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
default:
|
|
386
|
+
unproven.push(collapse(n));
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
/* ---- Calls ------------------------------------------------------------ */
|
|
391
|
+
const judgeCall = (n, depth) => {
|
|
392
|
+
const callee = n.childForFieldName('function');
|
|
393
|
+
const args = n.childForFieldName('arguments');
|
|
394
|
+
const argList = args ? args.namedChildren.filter((c) => c !== null) : [];
|
|
395
|
+
if (callee && callee.type === 'identifier') {
|
|
396
|
+
const name = callee.text ?? '';
|
|
397
|
+
if (ESCAPER_NAMES.has(name)) {
|
|
398
|
+
/*
|
|
399
|
+
* Trusting a name is a CONVENTION, not a proof, and a file is free to
|
|
400
|
+
* break it. `sanitize` is on the trusted list; a codebase that writes
|
|
401
|
+
*
|
|
402
|
+
* const sanitize = (x) => x;
|
|
403
|
+
*
|
|
404
|
+
* has a function with the right name that cleans nothing, and taking
|
|
405
|
+
* the name at face value silences the rule for the whole file.
|
|
406
|
+
*
|
|
407
|
+
* We do not try to decide whether an arbitrary local escaper is any
|
|
408
|
+
* good - that is a research problem. We check the one case that needs
|
|
409
|
+
* no judgement: a function that hands back its own argument untouched
|
|
410
|
+
* escapes nothing, whatever it is called. Anything more involved keeps
|
|
411
|
+
* the benefit of the doubt, which is what `limitations` already says.
|
|
412
|
+
*/
|
|
413
|
+
const local = findFunctionNamed(name, root);
|
|
414
|
+
if (local && isIdentityFunction(local)) {
|
|
415
|
+
unproven.push(`${collapse(n)} (\`${name}\` is defined in this file and returns its argument unchanged)`);
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
escapers.add(`${name}()`);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
// Number(x) is a number. String(x) is NOT safe - it converts, it does
|
|
422
|
+
// not clean - and neither is JSON.stringify, whose output can carry a
|
|
423
|
+
// literal `</script>` that closes the tag it sits inside.
|
|
424
|
+
if (name === 'Number' || name === 'parseInt' || name === 'parseFloat' || name === 'BigInt') {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
const fn = findFunctionNamed(name, root);
|
|
428
|
+
if (fn)
|
|
429
|
+
return judgeCallWithArguments(fn, argList, n, depth + 1);
|
|
430
|
+
unproven.push(collapse(n));
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (callee && callee.type === 'member_expression') {
|
|
434
|
+
const object = callee.childForFieldName('object');
|
|
435
|
+
const property = callee.childForFieldName('property')?.text ?? '';
|
|
436
|
+
const objectName = object?.type === 'identifier' ? (object.text ?? '') : '';
|
|
437
|
+
// DOMPurify.sanitize(x) and friends.
|
|
438
|
+
if (ESCAPER_METHODS.has(property) && ESCAPER_OBJECTS.has(objectName)) {
|
|
439
|
+
escapers.add(`${objectName}.${property}()`);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
if (ESCAPER_NAMES.has(property)) {
|
|
443
|
+
escapers.add(`.${property}()`);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
if (NUMERIC_METHODS.has(property))
|
|
447
|
+
return true;
|
|
448
|
+
if (objectName === 'Math')
|
|
449
|
+
return true;
|
|
450
|
+
// `parts.join('')` - safe when the thing being joined is safe.
|
|
451
|
+
if (property === 'join')
|
|
452
|
+
return judge(object, depth + 1);
|
|
453
|
+
// `rows.map(renderRow)` - the array's safety is the CALLBACK's safety,
|
|
454
|
+
// never the receiver's. This is the case that makes the whole analysis
|
|
455
|
+
// worth having: UI code is render helpers all the way down.
|
|
456
|
+
if (property === 'map' || property === 'flatMap') {
|
|
457
|
+
const callback = argList[0];
|
|
458
|
+
if (!callback) {
|
|
459
|
+
unproven.push(collapse(n));
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
return judgeIteratorCallback(callback, depth);
|
|
463
|
+
}
|
|
464
|
+
if (PASSTHROUGH_METHODS.has(property))
|
|
465
|
+
return judge(object, depth + 1);
|
|
466
|
+
// `"x".repeat(n)`, `s.padStart(...)` on a literal receiver stay safe.
|
|
467
|
+
if ((property === 'repeat' || property === 'padStart' || property === 'padEnd') &&
|
|
468
|
+
object &&
|
|
469
|
+
object.type === 'string') {
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
unproven.push(collapse(n));
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
unproven.push(collapse(n));
|
|
476
|
+
return false;
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* Does this function return one of its own parameters, unchanged?
|
|
480
|
+
*
|
|
481
|
+
* `(x) => x` and `function f(x) { return x; }` both qualify. A single
|
|
482
|
+
* transformation anywhere - a `.replace()`, a template, a call - and the
|
|
483
|
+
* answer is no, because then we genuinely cannot tell what it does and the
|
|
484
|
+
* name is the only evidence available.
|
|
485
|
+
*/
|
|
486
|
+
const isIdentityFunction = (fn) => {
|
|
487
|
+
const params = new Set(parameterNames(fn)
|
|
488
|
+
.map((p) => p.name)
|
|
489
|
+
.filter((name) => name.length > 0));
|
|
490
|
+
if (params.size === 0)
|
|
491
|
+
return false;
|
|
492
|
+
const body = fn.childForFieldName('body');
|
|
493
|
+
if (!body)
|
|
494
|
+
return false;
|
|
495
|
+
// Concise arrow body: `(x) => x`
|
|
496
|
+
if (body.type !== 'statement_block') {
|
|
497
|
+
return body.type === 'identifier' && params.has(body.text ?? '');
|
|
498
|
+
}
|
|
499
|
+
// Block body: every return must hand back a parameter, and there must be
|
|
500
|
+
// at least one - a function that returns nothing is not an identity.
|
|
501
|
+
let returns = 0;
|
|
502
|
+
let allBareParameters = true;
|
|
503
|
+
const collect = (n, isRoot) => {
|
|
504
|
+
if (!isRoot && FUNCTION_SCOPES.has(n.type))
|
|
505
|
+
return;
|
|
506
|
+
if (n.type === 'return_statement') {
|
|
507
|
+
const value = n.namedChildren.find((c) => c !== null && c.type !== 'comment') ?? null;
|
|
508
|
+
if (value) {
|
|
509
|
+
returns++;
|
|
510
|
+
if (value.type !== 'identifier' || !params.has(value.text ?? '')) {
|
|
511
|
+
allBareParameters = false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const child of n.namedChildren)
|
|
516
|
+
if (child)
|
|
517
|
+
collect(child, false);
|
|
518
|
+
};
|
|
519
|
+
collect(body, true);
|
|
520
|
+
return returns > 0 && allBareParameters;
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Judge the callback of `rows.map((row, i) => ...)`.
|
|
524
|
+
*
|
|
525
|
+
* The first parameter is an element, and nobody here knows what is in the
|
|
526
|
+
* array, so it stays unbound and reads as unproven. The SECOND parameter is
|
|
527
|
+
* the index, and that one is not a guess: `Array.prototype.map` is specified
|
|
528
|
+
* to pass an integer, and no caller can make it anything else. Digits cannot
|
|
529
|
+
* open a tag, so the index is provably safe.
|
|
530
|
+
*
|
|
531
|
+
* This earns its place because loop counters are spliced into rendered markup
|
|
532
|
+
* constantly - `<span class="ln">${i + 1}</span>` is in this project's own
|
|
533
|
+
* code viewer - and without it every such line reads as unproven forever, for
|
|
534
|
+
* a value that is arithmetic by construction.
|
|
535
|
+
*
|
|
536
|
+
* Only `map`/`flatMap` get this: they are the iterators whose RESULT becomes
|
|
537
|
+
* markup. `forEach` returns undefined and never reaches the page.
|
|
538
|
+
*/
|
|
539
|
+
const judgeIteratorCallback = (callback, depth) => {
|
|
540
|
+
if (!FUNCTION_SCOPES.has(callback.type))
|
|
541
|
+
return judge(callback, depth + 1);
|
|
542
|
+
const names = parameterNames(callback);
|
|
543
|
+
const frame = new Map();
|
|
544
|
+
// names[1] is the index. names[0] (the element) and names[2] (the whole
|
|
545
|
+
// array) are deliberately left unbound - we know nothing about either.
|
|
546
|
+
const indexName = names[1]?.name;
|
|
547
|
+
if (indexName)
|
|
548
|
+
frame.set(indexName, NUMERIC);
|
|
549
|
+
frames.push(frame);
|
|
550
|
+
const verdict = judgeFunctionResult(callback, depth + 1);
|
|
551
|
+
frames.pop();
|
|
552
|
+
return verdict;
|
|
553
|
+
};
|
|
554
|
+
/** Judge `fn(...args)` with each parameter bound to the argument passed. */
|
|
555
|
+
const judgeCallWithArguments = (fn, argList, callSite, depth) => {
|
|
556
|
+
if (frames.length >= MAX_FRAMES) {
|
|
557
|
+
unproven.push(`${collapse(callSite)} (call chain longer than this analysis follows)`);
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
const names = parameterNames(fn);
|
|
561
|
+
const frame = new Map();
|
|
562
|
+
names.forEach((param, index) => {
|
|
563
|
+
if (!param.name)
|
|
564
|
+
return;
|
|
565
|
+
// The caller's argument wins; a missing argument falls back to the
|
|
566
|
+
// parameter's own default (`className = ''`), which is just as knowable.
|
|
567
|
+
const arg = argList[index] ?? param.fallback;
|
|
568
|
+
if (arg)
|
|
569
|
+
frame.set(param.name, arg);
|
|
570
|
+
});
|
|
571
|
+
frames.push(frame);
|
|
572
|
+
const verdict = judgeFunctionResult(fn, depth);
|
|
573
|
+
frames.pop();
|
|
574
|
+
return verdict;
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* Parameter names, in order. Destructuring and rest parameters are skipped
|
|
578
|
+
* deliberately: we cannot bind them positionally, so the name stays
|
|
579
|
+
* unresolved and the value reads as unproven, which is the safe direction.
|
|
580
|
+
*/
|
|
581
|
+
const parameterNames = (fn) => {
|
|
582
|
+
const params = fn.childForFieldName('parameters') ?? fn.childForFieldName('parameter');
|
|
583
|
+
if (!params)
|
|
584
|
+
return [];
|
|
585
|
+
if (params.type === 'identifier')
|
|
586
|
+
return [{ name: params.text ?? '', fallback: null }];
|
|
587
|
+
const names = [];
|
|
588
|
+
for (const child of params.namedChildren) {
|
|
589
|
+
if (!child)
|
|
590
|
+
continue;
|
|
591
|
+
if (child.type === 'identifier') {
|
|
592
|
+
names.push({ name: child.text ?? '', fallback: null });
|
|
593
|
+
}
|
|
594
|
+
else if (child.type === 'assignment_pattern') {
|
|
595
|
+
// `className = ''` - a name AND a known value when the caller omits it.
|
|
596
|
+
const left = child.childForFieldName('left');
|
|
597
|
+
names.push({
|
|
598
|
+
name: left?.type === 'identifier' ? (left.text ?? '') : '',
|
|
599
|
+
fallback: child.childForFieldName('right'),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
else if (child.type === 'required_parameter' || child.type === 'optional_parameter') {
|
|
603
|
+
// TypeScript wraps the name in a parameter node, sometimes with a default.
|
|
604
|
+
const pattern = child.childForFieldName('pattern');
|
|
605
|
+
names.push({
|
|
606
|
+
name: pattern?.type === 'identifier' ? (pattern.text ?? '') : '',
|
|
607
|
+
fallback: child.childForFieldName('value'),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
// Destructuring or a rest parameter: we cannot bind it positionally,
|
|
612
|
+
// so the name stays unresolved and its uses read as unproven. Holding
|
|
613
|
+
// the position matters - dropping it would shift every later argument.
|
|
614
|
+
names.push({ name: '', fallback: null });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return names;
|
|
618
|
+
};
|
|
619
|
+
/* ---- What can this function return? ----------------------------------- */
|
|
620
|
+
const judgeFunctionResult = (fn, depth) => {
|
|
621
|
+
if (depth > MAX_DEPTH) {
|
|
622
|
+
unproven.push(`${collapse(fn)} (nested deeper than this analysis follows)`);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
const body = fn.childForFieldName('body');
|
|
626
|
+
if (!body) {
|
|
627
|
+
unproven.push(`${collapse(fn)} (no body to read)`);
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
// Concise arrow body: `x => \`<b>${escapeHtml(x)}</b>\``
|
|
631
|
+
if (body.type !== 'statement_block')
|
|
632
|
+
return judge(body, depth + 1);
|
|
633
|
+
const returns = [];
|
|
634
|
+
const collect = (n, isRoot) => {
|
|
635
|
+
if (!isRoot && FUNCTION_SCOPES.has(n.type))
|
|
636
|
+
return; // a nested helper is its own problem
|
|
637
|
+
if (n.type === 'return_statement') {
|
|
638
|
+
const value = n.namedChildren.find((c) => c !== null) ?? null;
|
|
639
|
+
if (value)
|
|
640
|
+
returns.push(value);
|
|
641
|
+
else
|
|
642
|
+
return; // `return;` yields undefined - harmless
|
|
643
|
+
}
|
|
644
|
+
for (const child of n.namedChildren)
|
|
645
|
+
if (child)
|
|
646
|
+
collect(child, false);
|
|
647
|
+
};
|
|
648
|
+
collect(body, true);
|
|
649
|
+
// A function with no return statement yields undefined, which prints as
|
|
650
|
+
// "undefined" - ugly, but not markup.
|
|
651
|
+
if (returns.length === 0)
|
|
652
|
+
return true;
|
|
653
|
+
let ok = true;
|
|
654
|
+
for (const value of returns)
|
|
655
|
+
if (!judge(value, depth + 1))
|
|
656
|
+
ok = false;
|
|
657
|
+
return ok;
|
|
658
|
+
};
|
|
659
|
+
/* ---- Name resolution --------------------------------------------------
|
|
660
|
+
* Two hard rules, both learned the expensive way on this project:
|
|
661
|
+
*
|
|
662
|
+
* 1. Never descend into a nested function scope when searching. An earlier
|
|
663
|
+
* helper did, walked into sibling functions, and matched a same-named
|
|
664
|
+
* variable that had nothing to do with the one being asked about.
|
|
665
|
+
* 2. A name we cannot find is UNPROVEN, never safe. Function parameters,
|
|
666
|
+
* imports and globals all land here, which is correct - we have no idea
|
|
667
|
+
* what a caller passes in.
|
|
668
|
+
* -------------------------------------------------------------------- */
|
|
669
|
+
const resolveBinding = (identifier) => {
|
|
670
|
+
const name = identifier.text ?? '';
|
|
671
|
+
if (!name)
|
|
672
|
+
return [];
|
|
673
|
+
// Walk outward through enclosing scopes, nearest first.
|
|
674
|
+
let scope = identifier.parent;
|
|
675
|
+
while (scope) {
|
|
676
|
+
if (FUNCTION_SCOPES.has(scope.type) || scope.type === 'program') {
|
|
677
|
+
const found = bindingsIn(scope, name);
|
|
678
|
+
if (found.length > 0)
|
|
679
|
+
return found;
|
|
680
|
+
}
|
|
681
|
+
scope = scope.parent;
|
|
682
|
+
}
|
|
683
|
+
return [];
|
|
684
|
+
};
|
|
685
|
+
const bindingsIn = (scope, name) => {
|
|
686
|
+
const values = [];
|
|
687
|
+
const visit = (n) => {
|
|
688
|
+
// Rule 1: stop at the boundary of any scope that is not the one we were
|
|
689
|
+
// asked about. Sibling functions are a different world.
|
|
690
|
+
if (n !== scope && FUNCTION_SCOPES.has(n.type))
|
|
691
|
+
return;
|
|
692
|
+
if (n.type === 'variable_declarator') {
|
|
693
|
+
const target = n.childForFieldName('name');
|
|
694
|
+
if (target && target.type === 'identifier' && target.text === name) {
|
|
695
|
+
const value = n.childForFieldName('value');
|
|
696
|
+
// `let x;` with no initialiser tells us nothing.
|
|
697
|
+
if (value)
|
|
698
|
+
values.push(value);
|
|
699
|
+
else
|
|
700
|
+
values.push(n); // deliberately unresolvable -> judged unproven
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (n.type === 'assignment_expression') {
|
|
704
|
+
const target = n.childForFieldName('left');
|
|
705
|
+
if (target && target.type === 'identifier' && target.text === name) {
|
|
706
|
+
const value = n.childForFieldName('right');
|
|
707
|
+
if (value)
|
|
708
|
+
values.push(value);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const child of n.namedChildren)
|
|
712
|
+
if (child)
|
|
713
|
+
visit(child);
|
|
714
|
+
};
|
|
715
|
+
visit(scope);
|
|
716
|
+
return values;
|
|
717
|
+
};
|
|
718
|
+
const findFunctionNamed = (name, from) => {
|
|
719
|
+
let found = null;
|
|
720
|
+
const visit = (n) => {
|
|
721
|
+
if (found)
|
|
722
|
+
return;
|
|
723
|
+
if (n.type === 'function_declaration') {
|
|
724
|
+
if (n.childForFieldName('name')?.text === name) {
|
|
725
|
+
found = n;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (n.type === 'variable_declarator') {
|
|
730
|
+
const target = n.childForFieldName('name');
|
|
731
|
+
const value = n.childForFieldName('value');
|
|
732
|
+
if (target?.text === name &&
|
|
733
|
+
value &&
|
|
734
|
+
(value.type === 'arrow_function' ||
|
|
735
|
+
value.type === 'function_expression' ||
|
|
736
|
+
value.type === 'function')) {
|
|
737
|
+
found = value;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
for (const child of n.namedChildren)
|
|
742
|
+
if (child)
|
|
743
|
+
visit(child);
|
|
744
|
+
};
|
|
745
|
+
visit(from);
|
|
746
|
+
return found;
|
|
747
|
+
};
|
|
748
|
+
const proven = judge(node, 0);
|
|
749
|
+
return {
|
|
750
|
+
proven: proven && unproven.length === 0,
|
|
751
|
+
unproven: [...new Set(unproven)],
|
|
752
|
+
escapers: [...escapers],
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
//# sourceMappingURL=html-safety.js.map
|