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