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