@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,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