@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,1235 @@
1
+ /**
2
+ * VALIDATION GUARDS - cleanliness that lives in the control flow.
3
+ * ==============================================================
4
+ *
5
+ * Every sanitiser this engine knew about until now was value-in / value-out:
6
+ *
7
+ * $safe = esc_html($dirty); // takes a dirty value, returns a clean one
8
+ *
9
+ * A GUARD is a different shape. It transforms nothing and returns a boolean,
10
+ * and the fact it establishes is true only where the program can stand after
11
+ * that boolean came out a particular way:
12
+ *
13
+ * if (is_numeric($octet[0]) && is_numeric($octet[1])) {
14
+ * $target = $octet[0] . '.' . $octet[1];
15
+ * shell_exec('ping ' . $target); // <- cannot hold a metacharacter
16
+ * }
17
+ *
18
+ * That is DVWA's `exec/impossible.php`, the author's deliberate FIX, and this
19
+ * scanner reported it for weeks. It is the seventh time this project has
20
+ * punished a fix.
21
+ *
22
+ * WHY THE BAR FOR A GUARD IS SO HIGH.
23
+ *
24
+ * A sanitiser rule that fires too often costs precision. A GUARD rule that
25
+ * fires too often costs SILENCE, and silence is the failure this scanner
26
+ * cannot detect in itself - a tool that has gone quiet looks exactly like a
27
+ * tool that has been fixed. This project has over-corrected before: the
28
+ * lowercase-word rejection that dropped the corpus from 48 findings to 32, and
29
+ * the mathjs.eval regression that was caught only because dvna's flow count
30
+ * fell from 3 to 2.
31
+ *
32
+ * So a check earns a place here on exactly one condition: being on the
33
+ * passing side of it must prove what the value can contain. Three strengths:
34
+ *
35
+ * digits only digits (is_numeric, ctype_digit, preg_match('/^\d+$/'))
36
+ * word only letters, digits and underscore (ctype_alnum, /^\w+$/)
37
+ * fixed only values the programmer wrote down ($x === 'png',
38
+ * in_array($x, ['jpg', 'png'], true))
39
+ *
40
+ * and each one clears a different set of sinks - see guardClears(). A word is
41
+ * not enough for a SQL query unless it sits inside quotes (`WHERE id = id` is
42
+ * true for every row), so `word` hands SQL to the same quote check that
43
+ * escaping uses.
44
+ *
45
+ * These do NOT count, and they are the ones that look reassuring:
46
+ *
47
+ * strlen($x) < 100 a hundred characters of `; rm -rf /` fits fine
48
+ * $x !== '' proves only that something is there
49
+ * isset($x) proves only that it exists
50
+ * preg_match('/\d+/', $x) not anchored: "1; rm -rf /" contains a digit
51
+ * preg_match('/^\d+$/m') multi-line: "1\n; rm -rf /" has a line of digits
52
+ * preg_match(...) !== 0 preg_match returns FALSE on an error, and
53
+ * false !== 0 - the error would pass as a match
54
+ *
55
+ * WHERE A FACT HOLDS (guardFactsAt):
56
+ *
57
+ * if (CHECK) { here } the true branch
58
+ * if (!CHECK) { ... } else { here } the else of a negated check
59
+ * if (!CHECK) { exit; } here after a branch that always leaves
60
+ * CHECK ? here : not-here the matching arm of a ternary
61
+ *
62
+ * and only while the checked value is not written again: a write to it
63
+ * anywhere in that region - including the condition - drops the fact, because
64
+ * `if (is_numeric($x)) { $x = $_GET['y']; run($x); }` is a real injection. The
65
+ * walk also stops at a function boundary: a closure defined inside the branch
66
+ * may run long after the value changed.
67
+ *
68
+ * NOT covered, on purpose or not yet - each one only means a finding stays:
69
+ * Java (no checks recognised at all yet); Python's `abort()` and other calls
70
+ * that never return; compiled regex objects (`re.compile(...).fullmatch`,
71
+ * `const re = /.../; re.test(x)`); `switch` on the value; a check whose
72
+ * result was stored in a variable first; a closure or by-reference
73
+ * parameter that rewrites the value from inside a called function.
74
+ *
75
+ * See safe/validated-guard.php and safe/allow-list-guards.* for the shapes this
76
+ * recognises, and vulnerable/weak-guard.php and vulnerable/allow-list-fence.*
77
+ * for the fence around them.
78
+ */
79
+ import type { Node, Tree } from 'web-tree-sitter';
80
+ import type { LanguageId } from '../../parse/languages.js';
81
+ import type { SinkKind } from '../../taint/types.js';
82
+
83
+ export type GuardStrength = 'digits' | 'word' | 'fixed' | 'quoted-literal';
84
+
85
+ export interface GuardFact {
86
+ readonly strength: GuardStrength;
87
+ /** For `fixed`: the values the check lets through. */
88
+ readonly values?: readonly string[];
89
+ /** For `fixed`: the check compared a lower- or upper-cased copy. */
90
+ readonly caseFolded?: boolean;
91
+ /** A regex `$` in PHP or Python also lets one line break through after the value. */
92
+ readonly newlineTail?: boolean;
93
+ /** The check itself, for the path a reader sees. */
94
+ readonly check: Node;
95
+ }
96
+
97
+ type Facts = Map<string, GuardFact>;
98
+
99
+ interface Outcome {
100
+ readonly whenTrue: Facts;
101
+ readonly whenFalse: Facts;
102
+ }
103
+
104
+ const GUARD_LANGUAGES: ReadonlySet<LanguageId> = new Set<LanguageId>(['php', 'python', 'javascript', 'typescript']);
105
+
106
+ /** Does this language have any checks this module recognises? */
107
+ export const guardsApply = (language: LanguageId): boolean => GUARD_LANGUAGES.has(language);
108
+
109
+ /**
110
+ * Predicates whose truth proves what the value can contain. Deliberately
111
+ * short. Adding a name here silences findings, so a candidate has to survive
112
+ * the question: "can a value this accepts contain a quote, a semicolon, an
113
+ * angle bracket or a dot-dot-slash?"
114
+ *
115
+ * is_numeric accepts "1e5", "-1" and " 1" - no metacharacter in any of them.
116
+ * ctype_xdigit accepts "dead" and "beef", which are words, not digits.
117
+ */
118
+ const VALIDATORS: Partial<Record<LanguageId, Readonly<Record<string, GuardStrength>>>> = {
119
+ php: {
120
+ is_numeric: 'digits', is_int: 'digits', is_integer: 'digits', is_long: 'digits',
121
+ is_float: 'digits', is_double: 'digits', ctype_digit: 'digits',
122
+ ctype_alnum: 'word', ctype_alpha: 'word', ctype_xdigit: 'word',
123
+ },
124
+ python: {
125
+ // Method form: `page.isdigit()`. The receiver is the checked value.
126
+ isdigit: 'digits', isnumeric: 'digits', isdecimal: 'digits',
127
+ isalnum: 'word', isalpha: 'word',
128
+ },
129
+ javascript: { isInteger: 'digits', isFinite: 'digits', isSafeInteger: 'digits' },
130
+ typescript: { isInteger: 'digits', isFinite: 'digits', isSafeInteger: 'digits' },
131
+ };
132
+
133
+ /** Calls that hand back the value with its letters' case changed - and nothing else. */
134
+ const CASE_FOLDS = new Set(['strtolower', 'strtoupper', 'mb_strtolower', 'mb_strtoupper', 'toLowerCase', 'toUpperCase', 'lower', 'upper']);
135
+
136
+ const SAFE_KINDS: Record<GuardStrength, { readonly kinds: readonly SinkKind[]; readonly insideQuotesOnly: readonly SinkKind[] }> = {
137
+ // No quote, semicolon, angle bracket, dot-dot-slash or `--`. Not a URL host,
138
+ // though: "2130706433" is 127.0.0.1 to most URL parsers.
139
+ digits: { kinds: ['sql', 'command', 'xss', 'deserialization', 'code', 'format', 'path'], insideQuotesOnly: [] },
140
+ // A bare word is a column name in SQL (`WHERE id = id`) and a name in code,
141
+ // so SQL counts only inside quotes and code not at all.
142
+ word: { kinds: ['sql', 'command', 'xss', 'format', 'path'], insideQuotesOnly: ['sql'] },
143
+ // The attacker only chooses which of the programmer's own values arrives.
144
+ fixed: {
145
+ kinds: ['sql', 'command', 'xss', 'deserialization', 'code', 'ssrf', 'format', 'overflow', 'path'],
146
+ insideQuotesOnly: [],
147
+ },
148
+ // One Python string literal with no quote of its kind inside: eval/exec can
149
+ // only build a str from it. Says nothing for SQL, a shell or a page.
150
+ 'quoted-literal': { kinds: ['code'], insideQuotesOnly: [] },
151
+ };
152
+
153
+ /** Which sinks a fact makes safe, and which of those only when the value lands inside quotes. */
154
+ export const guardClears = (fact: GuardFact): { readonly kinds: readonly SinkKind[]; readonly insideQuotesOnly: readonly SinkKind[] } => {
155
+ const base = SAFE_KINDS[fact.strength];
156
+ // `/^(localhost|internal)$/` also passes "localhost\n": fine in a query or a
157
+ // page, not something to vouch for inside a URL.
158
+ if (fact.newlineTail) return { kinds: base.kinds.filter((k) => k !== 'ssrf' && k !== 'overflow'), insideQuotesOnly: base.insideQuotesOnly };
159
+ return base;
160
+ };
161
+
162
+ /** One sentence for the path: what the check proved. */
163
+ export function describeFact(fact: GuardFact): string {
164
+ const line = fact.check.startPosition.row + 1;
165
+ const snippet = (fact.check.text ?? '').replace(/\s+/g, ' ').trim();
166
+ const shown = snippet.length > 70 ? `${snippet.slice(0, 67)}...` : snippet;
167
+ const lets =
168
+ fact.strength === 'quoted-literal'
169
+ ? 'only one quoted string literal can pass, so evaluating it only builds a string'
170
+ : fact.strength === 'digits'
171
+ ? 'only digits can pass'
172
+ : fact.strength === 'word'
173
+ ? 'only letters, digits and underscores can pass'
174
+ : `only ${fact.values && fact.values.length > 0 ? fact.values.slice(0, 5).map((v) => `'${v}'`).join(', ') : 'fixed values'}` +
175
+ `${fact.values && fact.values.length > 5 ? ' (and others)' : ''} can pass` +
176
+ `${fact.caseFolded ? ', in any letter case' : ''}`;
177
+ const tail = fact.newlineTail ? ' (and one line break after it, which `$` allows)' : '';
178
+ return `checked by \`${shown}\` on line ${line}: ${lets}${tail}`;
179
+ }
180
+
181
+ /* ------------------------------------------------------------------------ */
182
+ /* Text */
183
+ /* ------------------------------------------------------------------------ */
184
+
185
+ /**
186
+ * The same expression written two ways must compare equal. DVWA writes
187
+ * `$_REQUEST[ 'ip' ]` with spaces and reads it back the same way, but a check
188
+ * written `$_GET["id"]` and a read written `$_GET['id']` are one value too.
189
+ */
190
+ export function normalizeExpression(raw: string): string {
191
+ let out = '';
192
+ let quote: string | null = null;
193
+ for (let i = 0; i < raw.length; i++) {
194
+ const c = raw[i]!;
195
+ if (quote) {
196
+ out += c === '"' ? "'" : c;
197
+ if (c === '\\') {
198
+ out += raw[i + 1] ?? '';
199
+ i++;
200
+ } else if (c === quote) {
201
+ quote = null;
202
+ }
203
+ continue;
204
+ }
205
+ if (c === '"' || c === "'") {
206
+ quote = c;
207
+ out += "'";
208
+ continue;
209
+ }
210
+ if (/\s/.test(c)) continue;
211
+ out += c;
212
+ }
213
+ return out;
214
+ }
215
+
216
+ const firstNamed = (node: Node | null | undefined): Node | null =>
217
+ node?.namedChildren.find((c): c is Node => c !== null) ?? null;
218
+
219
+ const namedKids = (node: Node | null | undefined): Node[] =>
220
+ node ? node.namedChildren.filter((c): c is Node => c !== null) : [];
221
+
222
+ const contains = (container: Node, node: Node): boolean =>
223
+ container.startIndex <= node.startIndex && container.endIndex >= node.endIndex;
224
+
225
+ const lastSegment = (text: string): string => {
226
+ const trimmed = text.trim();
227
+ const cut = Math.max(trimmed.lastIndexOf('.'), trimmed.lastIndexOf('->'), trimmed.lastIndexOf('::'));
228
+ if (cut === -1) return trimmed;
229
+ return trimmed.slice(cut + (trimmed.startsWith('->', cut) || trimmed.startsWith('::', cut) ? 2 : 1));
230
+ };
231
+
232
+ /** The arguments of a call, unwrapped from PHP's `argument` nodes. */
233
+ function callArgs(call: Node): Node[] {
234
+ const list = call.childForFieldName('arguments') ?? namedKids(call).find((c) => c.type === 'argument_list' || c.type === 'arguments') ?? null;
235
+ return namedKids(list)
236
+ .filter((a) => a.type !== 'comment')
237
+ .map((a) => (a.type === 'argument' ? firstNamed(a) ?? a : a));
238
+ }
239
+
240
+ const CALL_TYPES = new Set(['function_call_expression', 'member_call_expression', 'scoped_call_expression', 'call_expression', 'call']);
241
+
242
+ /** `strtolower($x)` -> `$x`; anything else -> null. */
243
+ function caseFoldedArgument(node: Node): Node | null {
244
+ if (!CALL_TYPES.has(node.type)) return null;
245
+ const callee = node.childForFieldName('function') ?? node.childForFieldName('name');
246
+ const name = lastSegment(callee?.text ?? '');
247
+ if (!CASE_FOLDS.has(name) && !CASE_FOLDS.has(name.toLowerCase())) return null;
248
+ const args = callArgs(node);
249
+ if (args.length === 1) return args[0] ?? null;
250
+ // Method form: `ext.toLowerCase()`, `ext.lower()`.
251
+ if (args.length === 0 && callee && (callee.type === 'member_expression' || callee.type === 'attribute')) {
252
+ return callee.childForFieldName('object');
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /* ------------------------------------------------------------------------ */
258
+ /* Literals */
259
+ /* ------------------------------------------------------------------------ */
260
+
261
+ interface Literal {
262
+ readonly kind: 'string' | 'number' | 'bool' | 'null';
263
+ readonly value: string;
264
+ }
265
+
266
+ /**
267
+ * The value of a literal, or null when it is not one we can read exactly.
268
+ * Interpolation, escapes we do not decode and heredocs all answer null - and
269
+ * null only ever means a check is NOT counted.
270
+ */
271
+ function literalOf(node: Node | null | undefined, language: LanguageId): Literal | null {
272
+ if (!node) return null;
273
+ const raw = node.text ?? '';
274
+ if (node.type === 'parenthesized_expression') return literalOf(firstNamed(node), language);
275
+ if (node.type === 'integer' || node.type === 'number' || node.type === 'float') return { kind: 'number', value: raw };
276
+ if (node.type === 'boolean' || node.type === 'true' || node.type === 'false') return { kind: 'bool', value: raw.toLowerCase() };
277
+ if (node.type === 'null' || node.type === 'none') return { kind: 'null', value: '' };
278
+
279
+ if (language === 'php') {
280
+ if (node.type === 'string' && raw.startsWith("'") && raw.endsWith("'")) {
281
+ return { kind: 'string', value: raw.slice(1, -1).replace(/\\([\\'])/g, '$1') };
282
+ }
283
+ if (node.type === 'encapsed_string' && raw.startsWith('"') && raw.endsWith('"')) {
284
+ if (namedKids(node).some((c) => c.type !== 'string_content' && c.type !== 'string_value' && c.type !== 'escape_sequence')) return null;
285
+ const body = raw.slice(1, -1);
286
+ if (/\$[A-Za-z_{]/.test(body)) return null;
287
+ if (/\\(?:[nrtvef0-7]|x[0-9A-Fa-f]|u\{)/.test(body)) return null;
288
+ return { kind: 'string', value: body.replace(/\\([\\$"])/g, '$1') };
289
+ }
290
+ return null;
291
+ }
292
+
293
+ if (language === 'javascript' || language === 'typescript') {
294
+ if (node.type === 'string' || node.type === 'template_string') {
295
+ if (namedKids(node).some((c) => c.type !== 'string_fragment')) return null;
296
+ return { kind: 'string', value: raw.slice(1, -1) };
297
+ }
298
+ return null;
299
+ }
300
+
301
+ if (language === 'python' && node.type === 'string') {
302
+ const kids = namedKids(node);
303
+ if (kids.some((c) => c.type === 'interpolation')) return null;
304
+ const start = kids.find((c) => c.type === 'string_start')?.text ?? '';
305
+ const prefix = start.replace(/['"]+$/, '').toLowerCase();
306
+ if (prefix.includes('f')) return null;
307
+ const content = kids.filter((c) => c.type === 'string_content').map((c) => c.text ?? '').join('');
308
+ if (prefix.includes('r')) return { kind: 'string', value: content };
309
+ // Escapes Python decodes are refused; ones it leaves alone (`\d`) are kept.
310
+ if (/\\[\\'"abfnrtv0-7xNuU\n]/.test(content.replace(/\\\\/g, ''))) return null;
311
+ return { kind: 'string', value: content.replace(/\\\\/g, '\\') };
312
+ }
313
+ return null;
314
+ }
315
+
316
+ /** PHP's idea of a numeric string - the ones `==` compares as numbers. */
317
+ const PHP_NUMERIC_STRING = /^\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?\s*$/;
318
+
319
+ /* ------------------------------------------------------------------------ */
320
+ /* Regular expressions */
321
+ /* ------------------------------------------------------------------------ */
322
+
323
+ const precedingBackslashes = (s: string, at: number): number => {
324
+ let n = 0;
325
+ for (let i = at - 1; i >= 0 && s[i] === '\\'; i--) n++;
326
+ return n;
327
+ };
328
+
329
+ const sameRangeGroup = (lo: string, hi: string): boolean =>
330
+ (/[0-9]/.test(lo) && /[0-9]/.test(hi)) || (/[a-z]/.test(lo) && /[a-z]/.test(hi)) || (/[A-Z]/.test(lo) && /[A-Z]/.test(hi));
331
+
332
+ /**
333
+ * What a whole-value regular expression lets through: 'digits', 'word',
334
+ * 'fixed' (a list of literal words, `/^(asc|desc)$/`), or null when anything
335
+ * else could pass - and null is the answer for everything this does not
336
+ * understand.
337
+ *
338
+ * The body may only be digits, letters, underscores, `\d`, `\w`, classes of
339
+ * those, groups, alternation INSIDE a group, and quantifiers. Top-level `|`
340
+ * is refused because it splits the anchors: `/^1|2$/` is "starts with 1" OR
341
+ * "ends with 2", and "1; rm -rf /" starts with 1.
342
+ *
343
+ * `$` lets one trailing newline through in PHP and Python (not in
344
+ * JavaScript, and not with PHP's D flag). A lone newline at the very end
345
+ * cannot open a quote or a tag, and is accepted - but it is recorded, so a
346
+ * fixed list is not vouched for inside a URL (see guardClears).
347
+ */
348
+ export function regexAllowList(
349
+ source: string,
350
+ options: {
351
+ readonly flags?: string;
352
+ readonly implicitStart?: boolean;
353
+ readonly implicitEnd?: boolean;
354
+ readonly language: LanguageId;
355
+ },
356
+ ): { readonly strength: GuardStrength; readonly values?: readonly string[]; readonly caseFolded?: boolean; readonly newlineTail?: boolean } | null {
357
+ const flags = options.flags ?? '';
358
+ // Multi-line makes ^ and $ match at every line; extended mode changes what
359
+ // whitespace and # mean. Either way the anchors no longer pin the value.
360
+ if (/[mx]/.test(flags)) return null;
361
+ const php = options.language === 'php';
362
+ let body = source;
363
+ if (body.startsWith('^')) body = body.slice(1);
364
+ else if (php && body.startsWith('\\A')) body = body.slice(2);
365
+ else if (!options.implicitStart && !(php && flags.includes('A'))) return null;
366
+
367
+ const endsWith = (token: string): boolean =>
368
+ body.endsWith(token) && precedingBackslashes(body, body.length - token.length) % 2 === 0;
369
+ const javascript = options.language === 'javascript' || options.language === 'typescript';
370
+ let newlineTail = false;
371
+ if (endsWith('$')) {
372
+ body = body.slice(0, -1);
373
+ newlineTail = !javascript && !(php && flags.includes('D'));
374
+ } else if (!javascript && endsWith('\\Z')) {
375
+ // PCRE's and Python's \Z both allow the same one line break as `$`.
376
+ body = body.slice(0, -2);
377
+ newlineTail = true;
378
+ } else if (!javascript && endsWith('\\z')) {
379
+ body = body.slice(0, -2);
380
+ } else if (!options.implicitEnd) return null;
381
+ const tail = newlineTail ? { newlineTail: true } : {};
382
+
383
+ // A list of literal words: the value is one of them.
384
+ const list = /^\((?:\?:)?([A-Za-z0-9_]+(?:\|[A-Za-z0-9_]+)*)\)$/.exec(body) ?? /^([A-Za-z0-9_]+)$/.exec(body);
385
+ if (list) {
386
+ const values = list[1]!.split('|');
387
+ return { strength: 'fixed', values, ...(flags.includes('i') ? { caseFolded: true } : {}), ...tail };
388
+ }
389
+
390
+ let letters = false;
391
+ let depth = 0;
392
+ let i = 0;
393
+ while (i < body.length) {
394
+ const c = body[i]!;
395
+ if (c === '\\') {
396
+ const e = body[i + 1];
397
+ if (e === 'd') { i += 2; continue; }
398
+ if (e === 'w') { letters = true; i += 2; continue; }
399
+ return null;
400
+ }
401
+ if (c === '[') {
402
+ let j = i + 1;
403
+ if (body[j] === '^' || body[j] === ']') return null;
404
+ let any = false;
405
+ while (j < body.length && body[j] !== ']') {
406
+ const k = body[j]!;
407
+ if (k === '[' && php && body[j + 1] === ':') {
408
+ const posix = /^\[:(digit|alnum|alpha|upper|lower|word|xdigit):\]/.exec(body.slice(j));
409
+ if (!posix) return null;
410
+ if (posix[1] !== 'digit') letters = true;
411
+ j += posix[0].length;
412
+ any = true;
413
+ continue;
414
+ }
415
+ if (k === '\\') {
416
+ const e = body[j + 1];
417
+ if (e === 'd') { j += 2; any = true; continue; }
418
+ if (e === 'w') { letters = true; j += 2; any = true; continue; }
419
+ return null;
420
+ }
421
+ if (!/[A-Za-z0-9_]/.test(k)) return null;
422
+ if (body[j + 1] === '-' && body[j + 2] !== undefined && body[j + 2] !== ']') {
423
+ const hi = body[j + 2]!;
424
+ if (!sameRangeGroup(k, hi) || k > hi) return null;
425
+ if (!/[0-9]/.test(k)) letters = true;
426
+ j += 3;
427
+ any = true;
428
+ continue;
429
+ }
430
+ if (!/[0-9]/.test(k)) letters = true;
431
+ j++;
432
+ any = true;
433
+ }
434
+ if (body[j] !== ']' || !any) return null;
435
+ i = j + 1;
436
+ continue;
437
+ }
438
+ if (c === '(') {
439
+ if (body[i + 1] === '?') {
440
+ if (body[i + 2] !== ':') return null; // lookarounds, inline flags, named groups
441
+ i += 3;
442
+ } else {
443
+ i++;
444
+ }
445
+ depth++;
446
+ continue;
447
+ }
448
+ if (c === ')') {
449
+ if (depth === 0) return null;
450
+ depth--;
451
+ i++;
452
+ continue;
453
+ }
454
+ if (c === '|') {
455
+ if (depth === 0) return null;
456
+ i++;
457
+ continue;
458
+ }
459
+ if (c === '+' || c === '*' || c === '?') { i++; continue; }
460
+ if (c === '{') {
461
+ const q = /^\{\d+(,\d*)?\}/.exec(body.slice(i));
462
+ if (!q) return null;
463
+ i += q[0].length;
464
+ continue;
465
+ }
466
+ if (/[0-9]/.test(c)) { i++; continue; }
467
+ if (/[A-Za-z_]/.test(c)) { letters = true; i++; continue; }
468
+ return null;
469
+ }
470
+ if (depth !== 0) return null;
471
+ return { strength: letters ? 'word' : 'digits', ...tail };
472
+ }
473
+
474
+ /** `/^\d+$/i` written as a PHP string: delimiters, then flags. */
475
+ function phpPattern(literal: string): { source: string; flags: string } | null {
476
+ const open = literal[0];
477
+ if (!open || /[A-Za-z0-9\\\s]/.test(open)) return null;
478
+ const close = ({ '(': ')', '[': ']', '{': '}', '<': '>' } as Record<string, string>)[open] ?? open;
479
+ const end = literal.lastIndexOf(close);
480
+ if (end <= 0) return null;
481
+ const flags = literal.slice(end + 1);
482
+ if (!/^[A-Za-z]*$/.test(flags)) return null;
483
+ return { source: literal.slice(1, end), flags };
484
+ }
485
+
486
+ /* ------------------------------------------------------------------------ */
487
+ /* Conditions */
488
+ /* ------------------------------------------------------------------------ */
489
+
490
+ const EMPTY: Outcome = { whenTrue: new Map(), whenFalse: new Map() };
491
+ const RANK: Record<GuardStrength, number> = { 'quoted-literal': 0, word: 1, digits: 2, fixed: 3 };
492
+
493
+ const classOfValues = (values: readonly string[]): GuardStrength | null =>
494
+ values.every((v) => /^[0-9]*$/.test(v)) ? 'digits' : values.every((v) => /^[A-Za-z0-9_]*$/.test(v)) ? 'word' : null;
495
+
496
+ /** Both facts hold - keep the stronger. */
497
+ function stronger(a: GuardFact, b: GuardFact): GuardFact {
498
+ return RANK[b.strength] > RANK[a.strength] ? b : a;
499
+ }
500
+
501
+ /** One of the two holds - keep only what both promise. */
502
+ function weaker(a: GuardFact, b: GuardFact, at: Node): GuardFact | null {
503
+ if (a.strength === 'fixed' && b.strength === 'fixed') {
504
+ return {
505
+ strength: 'fixed',
506
+ values: [...new Set([...(a.values ?? []), ...(b.values ?? [])])],
507
+ ...(a.caseFolded || b.caseFolded ? { caseFolded: true } : {}),
508
+ ...(a.newlineTail || b.newlineTail ? { newlineTail: true } : {}),
509
+ check: at,
510
+ };
511
+ }
512
+ // Not a character class: "word OR quoted literal" promises nothing either does.
513
+ if (a.strength === 'quoted-literal' || b.strength === 'quoted-literal') {
514
+ return a.strength === b.strength ? { strength: a.strength, check: at } : null;
515
+ }
516
+ const asCharset = (f: GuardFact): GuardStrength | null => (f.strength === 'fixed' ? classOfValues(f.values ?? ['\u0000']) : f.strength);
517
+ const x = asCharset(a);
518
+ const y = asCharset(b);
519
+ if (!x || !y) return null;
520
+ return { strength: RANK[x] <= RANK[y] ? x : y, ...(a.newlineTail || b.newlineTail ? { newlineTail: true } : {}), check: at };
521
+ }
522
+
523
+ function joinStrong(a: Facts, b: Facts): Facts {
524
+ const out: Facts = new Map(a);
525
+ for (const [k, f] of b) out.set(k, out.has(k) ? stronger(out.get(k)!, f) : f);
526
+ return out;
527
+ }
528
+
529
+ function meetWeak(a: Facts, b: Facts, at: Node): Facts {
530
+ const out: Facts = new Map();
531
+ for (const [k, f] of a) {
532
+ const g = b.get(k);
533
+ if (!g) continue;
534
+ const w = weaker(f, g, at);
535
+ if (w) out.set(k, w);
536
+ }
537
+ return out;
538
+ }
539
+
540
+ const facts1 = (subject: Node | null, fact: Omit<GuardFact, 'check'>, check: Node): Facts => {
541
+ const out: Facts = new Map();
542
+ if (subject?.text) out.set(normalizeExpression(subject.text), { ...fact, check } as GuardFact);
543
+ return out;
544
+ };
545
+
546
+ const operatorOf = (node: Node): string => {
547
+ const field = node.childForFieldName('operator')?.text;
548
+ if (field) return field.trim();
549
+ // Python's comparison operators are anonymous children: `in`, `not in`, `is not`.
550
+ return node.children
551
+ .filter((c): c is Node => c !== null && !c.isNamed)
552
+ .map((c) => c.text)
553
+ .join(' ')
554
+ .trim();
555
+ };
556
+
557
+ /** A check written as a call: validators, regex matches, list membership. */
558
+ function callFacts(call: Node, language: LanguageId): Facts {
559
+ const callee = call.childForFieldName('function') ?? call.childForFieldName('name');
560
+ const calleeText = callee?.text ?? '';
561
+ // PHP lets a builtin be called fully qualified: `\preg_match(...)`.
562
+ const name = lastSegment(calleeText).replace(/^\\+/, '');
563
+ const args = callArgs(call);
564
+ const empty: Facts = new Map();
565
+
566
+ // Validators: is_numeric($x), ctype_digit($x), Number.isInteger(x), page.isdigit()
567
+ const validators = VALIDATORS[language] ?? {};
568
+ const lookup = language === 'php' ? name.toLowerCase() : name;
569
+ if (Object.prototype.hasOwnProperty.call(validators, lookup)) {
570
+ const strength = validators[lookup]!;
571
+ if (args.length >= 1) return facts1(args[0] ?? null, { strength }, call);
572
+ if (callee && (callee.type === 'attribute' || callee.type === 'member_expression')) {
573
+ return facts1(callee.childForFieldName('object'), { strength }, call);
574
+ }
575
+ return empty;
576
+ }
577
+
578
+ if (language === 'php') {
579
+ const lower = name.toLowerCase();
580
+ if (lower === 'preg_match' && args.length >= 2 && args.length <= 4) {
581
+ const pattern = literalOf(args[0], language);
582
+ const parsed = pattern?.kind === 'string' ? phpPattern(pattern.value) : null;
583
+ const allowed = parsed ? regexAllowList(parsed.source, { flags: parsed.flags, language }) : null;
584
+ return allowed ? facts1(args[1] ?? null, allowed, call) : empty;
585
+ }
586
+ if (lower === 'in_array' && (args.length === 2 || args.length === 3)) {
587
+ const strict = args.length === 3 && (args[2]?.text ?? '').toLowerCase() === 'true';
588
+ const values = arrayLiteralValues(args[1] ?? null, language, strict);
589
+ if (!values) return empty;
590
+ return membershipFacts(args[0] ?? null, values, call);
591
+ }
592
+ return empty;
593
+ }
594
+
595
+ if (language === 'javascript' || language === 'typescript') {
596
+ if (!callee || callee.type !== 'member_expression') return empty;
597
+ const object = callee.childForFieldName('object');
598
+ const property = callee.childForFieldName('property')?.text ?? '';
599
+ // /^\d+$/.test(x)
600
+ if (property === 'test' && object?.type === 'regex' && args.length === 1) {
601
+ const source = object.childForFieldName('pattern')?.text ?? '';
602
+ const flags = object.childForFieldName('flags')?.text ?? '';
603
+ const allowed = regexAllowList(source, { flags, language });
604
+ return allowed ? facts1(args[0] ?? null, allowed, call) : empty;
605
+ }
606
+ // x.match(/^\d+$/)
607
+ if (property === 'match' && args.length === 1 && args[0]?.type === 'regex') {
608
+ const source = args[0].childForFieldName('pattern')?.text ?? '';
609
+ const flags = args[0].childForFieldName('flags')?.text ?? '';
610
+ const allowed = regexAllowList(source, { flags, language });
611
+ return allowed ? facts1(object, allowed, call) : empty;
612
+ }
613
+ // ['jpg', 'png'].includes(x) - includes() is strict equality.
614
+ if (property === 'includes' && object?.type === 'array' && args.length === 1) {
615
+ const values = arrayLiteralValues(object, language, true);
616
+ return values ? membershipFacts(args[0] ?? null, values, call) : empty;
617
+ }
618
+ return empty;
619
+ }
620
+
621
+ if (language === 'python') {
622
+ // re.fullmatch(r'\d+', x) / re.match(r'\d+$', x)
623
+ if ((calleeText === 're.fullmatch' || calleeText === 're.match') && (args.length === 2 || args.length === 3)) {
624
+ if (args.length === 3 && !/^re\.(I|IGNORECASE|A|ASCII|U|UNICODE)$/.test((args[2]?.text ?? '').trim())) return empty;
625
+ const pattern = literalOf(args[0], language);
626
+ if (pattern?.kind !== 'string') return empty;
627
+ const allowed = regexAllowList(pattern.value, {
628
+ language,
629
+ implicitStart: true,
630
+ implicitEnd: calleeText === 're.fullmatch',
631
+ // Python spells case-insensitive as an argument, not a suffix.
632
+ ...(args.length === 3 && /^re\.(I|IGNORECASE)$/.test((args[2]?.text ?? '').trim()) ? { flags: 'i' } : {}),
633
+ });
634
+ return allowed ? facts1(args[1] ?? null, allowed, call) : empty;
635
+ }
636
+ return empty;
637
+ }
638
+ return empty;
639
+ }
640
+
641
+ /** The values of an array literal of strings - or null if any element is not one. */
642
+ function arrayLiteralValues(node: Node | null, language: LanguageId, strict: boolean): string[] | null {
643
+ if (!node) return null;
644
+ const ok = new Set(['array_creation_expression', 'array', 'list', 'tuple', 'set']);
645
+ if (!ok.has(node.type)) return null;
646
+ const values: string[] = [];
647
+ for (const element of namedKids(node)) {
648
+ if (element.type === 'comment') continue;
649
+ // PHP wraps each element; a `key => value` pair is refused.
650
+ const inner = element.type === 'array_element_initializer' ? (namedKids(element).length === 1 ? firstNamed(element) : null) : element;
651
+ const lit = literalOf(inner, language);
652
+ if (!lit) return null;
653
+ if (lit.kind === 'string') {
654
+ // Loose comparison turns "1e0" == "1" true; only exact strings are safe there.
655
+ if (!strict && language === 'php' && PHP_NUMERIC_STRING.test(lit.value)) return null;
656
+ values.push(lit.value);
657
+ continue;
658
+ }
659
+ // A number compared loosely is PHP 7's `"abc" == 0`. Strictly, a request
660
+ // string never equals one, so the branch is dead - harmless to count.
661
+ if (strict && lit.kind === 'number') {
662
+ values.push(lit.value);
663
+ continue;
664
+ }
665
+ return null;
666
+ }
667
+ return values.length > 0 ? values : null;
668
+ }
669
+
670
+ function membershipFacts(subject: Node | null, values: readonly string[], check: Node): Facts {
671
+ if (!subject) return new Map();
672
+ const folded = caseFoldedArgument(subject);
673
+ if (folded) return facts1(folded, { strength: 'fixed', values, caseFolded: true }, check);
674
+ return facts1(subject, { strength: 'fixed', values }, check);
675
+ }
676
+
677
+ /**
678
+ * Comparisons. Two families:
679
+ *
680
+ * CHECK == 1, CHECK > 0, CHECK != 0 ... the result of a check, compared
681
+ * $x === 'png', 'png' == $x, $x != 'a' the value against a literal
682
+ *
683
+ * The first family is where preg_match bites: it returns 1, 0, or FALSE on an
684
+ * error (bad UTF-8 under /u, a backtrack limit). `!== 0` and `=== false`
685
+ * treat that error as a pass, so they are not counted at all.
686
+ */
687
+ function comparisonOutcome(node: Node, language: LanguageId, depth: number): Outcome {
688
+ const operator = operatorOf(node);
689
+ const left = node.childForFieldName('left') ?? namedKids(node)[0] ?? null;
690
+ const right = node.childForFieldName('right') ?? namedKids(node)[1] ?? null;
691
+ if (!left || !right || namedKids(node).length > 2) return EMPTY;
692
+
693
+ const leftLit = literalOf(left, language);
694
+ const rightLit = literalOf(right, language);
695
+
696
+ // Python: `m is not None` / `m is None`
697
+ if (language === 'python' && (operator === 'is not' || operator === 'is') && rightLit?.kind === 'null') {
698
+ const inner = outcomeOf(left, language, depth + 1);
699
+ return operator === 'is not' ? inner : { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
700
+ }
701
+
702
+ // The result of a check, compared to a constant.
703
+ const lit = rightLit ?? leftLit;
704
+ const other = rightLit ? left : right;
705
+ if (lit && (lit.kind === 'number' || lit.kind === 'bool')) {
706
+ const v = lit.value;
707
+ const isOne = v === '1' || v === 'true';
708
+ const isZero = v === '0' || v === 'false';
709
+ const flipped = !rightLit; // literal on the left: `0 < x` reads the other way
710
+ const op = flipped ? ({ '>': '<', '<': '>', '>=': '<=', '<=': '>=' } as Record<string, string>)[operator] ?? operator : operator;
711
+ const positive =
712
+ ((op === '==' || op === '===') && isOne) ||
713
+ ((op === '!=' || op === '<>') && isZero) ||
714
+ (op === '>' && v === '0') ||
715
+ (op === '>=' && v === '1');
716
+ const negative =
717
+ (op === '==' && isZero) ||
718
+ ((op === '!=' || op === '<>' || op === '!==') && isOne) ||
719
+ (op === '<' && v === '1');
720
+ if (positive || negative) {
721
+ const inner = outcomeOf(other, language, depth + 1);
722
+ if (inner.whenTrue.size > 0 || inner.whenFalse.size > 0) {
723
+ return positive ? inner : { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
724
+ }
725
+ }
726
+ }
727
+
728
+ // Membership: `x in ('a', 'b')`, `x not in [...]`
729
+ if (language === 'python' && (operator === 'in' || operator === 'not in')) {
730
+ const values = arrayLiteralValues(right, language, true);
731
+ if (!values) return EMPTY;
732
+ const facts = membershipFacts(left, values, node);
733
+ return operator === 'in' ? { whenTrue: facts, whenFalse: new Map() } : { whenTrue: new Map(), whenFalse: facts };
734
+ }
735
+
736
+ // The value against a literal.
737
+ const equal = operator === '===' || operator === '==';
738
+ const notEqual = operator === '!==' || operator === '!=' || operator === '<>';
739
+ if (!equal && !notEqual) return EMPTY;
740
+ if (!lit || lit.kind === 'null' || lit.kind === 'bool') return EMPTY;
741
+ const strict = operator === '===' || operator === '!==' || language === 'python';
742
+ if (!strict) {
743
+ // Loose equality is exact only against a non-numeric string.
744
+ if (lit.kind !== 'string') return EMPTY;
745
+ if (language === 'php' && PHP_NUMERIC_STRING.test(lit.value)) return EMPTY;
746
+ if ((language === 'javascript' || language === 'typescript') && (lit.value === '' || !Number.isNaN(Number(lit.value)))) return EMPTY;
747
+ }
748
+ if (literalOf(other, language)) return EMPTY;
749
+ const facts = membershipFacts(other, [lit.value], node);
750
+ return equal ? { whenTrue: facts, whenFalse: new Map() } : { whenTrue: new Map(), whenFalse: facts };
751
+ }
752
+
753
+ /** `'` or `"` written as a one-character Python literal, in any of its spellings. */
754
+ const QUOTE_LITERALS: Readonly<Record<string, string>> = { [`"'"`]: "'", [`'\\''`]: "'", [`'"'`]: '"', [`"\\""`]: '"' };
755
+
756
+ /**
757
+ * `s.startswith(Q) and s.endswith(Q) and Q not in s[1:-1]`, or its negation
758
+ * `not s.startswith(Q) or not s.endswith(Q) or Q in s[1:-1]` (true side leaves).
759
+ *
760
+ * Where it holds, s is exactly one Q-quoted literal with no Q inside, so
761
+ * eval/exec can only produce a str (or a SyntaxError). BenchmarkPython fences
762
+ * eval and exec with it; the engine proved 7 of those safe cases "vulnerable".
763
+ *
764
+ * Recognised WHOLE or not at all - each part alone proves nothing - and on one
765
+ * subject with one quote character.
766
+ */
767
+ function quotedLiteralFence(node: Node): Outcome | null {
768
+ const operator = operatorOf(node).toLowerCase();
769
+ if (operator !== 'and' && operator !== 'or') return null;
770
+ const operands: Node[] = [];
771
+ const flatten = (n: Node | null): void => {
772
+ if (!n) return;
773
+ if (n.type === 'parenthesized_expression') return flatten(firstNamed(n));
774
+ if (n.type === 'boolean_operator' && operatorOf(n).toLowerCase() === operator) {
775
+ flatten(n.childForFieldName('left'));
776
+ flatten(n.childForFieldName('right'));
777
+ } else operands.push(n);
778
+ };
779
+ flatten(node);
780
+ // `and`: every operand true on the true side. `or`: every operand false on the false side.
781
+ const need = operator === 'and';
782
+ const seen = new Map<string, Set<string>>(); // `${subject}|${quote}` -> parts
783
+ for (let operand of operands) {
784
+ let truth = need;
785
+ while (operand.type === 'not_operator' || operand.type === 'parenthesized_expression') {
786
+ if (operand.type === 'not_operator') truth = !truth;
787
+ const inner = operand.childForFieldName('argument') ?? firstNamed(operand);
788
+ if (!inner) break;
789
+ operand = inner;
790
+ }
791
+ let part: string | null = null;
792
+ let subject = '';
793
+ let quote: string | undefined;
794
+ if (operand.type === 'call' && truth) {
795
+ const callee = operand.childForFieldName('function');
796
+ const method = callee?.type === 'attribute' ? callee.childForFieldName('attribute')?.text : undefined;
797
+ const args = callArgs(operand);
798
+ quote = args.length === 1 ? QUOTE_LITERALS[args[0]?.text ?? ''] : undefined;
799
+ if ((method === 'startswith' || method === 'endswith') && quote) {
800
+ part = method;
801
+ subject = normalizeExpression(callee?.childForFieldName('object')?.text ?? '');
802
+ }
803
+ } else if (operand.type === 'comparison_operator') {
804
+ const op = operatorOf(operand);
805
+ const [left, right] = namedKids(operand);
806
+ quote = QUOTE_LITERALS[left?.text ?? ''];
807
+ // `Q in s[1:-1]` must come out false; `Q not in s[1:-1]` true.
808
+ if (quote && right?.type === 'subscript' && ((op === 'in' && !truth) || (op === 'not in' && truth))) {
809
+ if (normalizeExpression(right.text.slice(right.childForFieldName('value')?.text.length ?? 0)) === '[1:-1]') {
810
+ part = 'inner';
811
+ subject = normalizeExpression(right.childForFieldName('value')?.text ?? '');
812
+ }
813
+ }
814
+ }
815
+ if (!part || !subject || !quote) continue;
816
+ const key = `${subject}|${quote}`;
817
+ if (!seen.has(key)) seen.set(key, new Set());
818
+ seen.get(key)!.add(part);
819
+ }
820
+ for (const [key, parts] of seen) {
821
+ if (parts.size !== 3) continue;
822
+ const facts: Facts = new Map([[key.slice(0, key.lastIndexOf('|')), { strength: 'quoted-literal', check: node }]]);
823
+ return need ? { whenTrue: facts, whenFalse: new Map() } : { whenTrue: new Map(), whenFalse: facts };
824
+ }
825
+ return null;
826
+ }
827
+
828
+ const outcomeCache = new WeakMap<Tree, Map<number, Outcome>>();
829
+
830
+ /** What is known about values when this condition came out true, and when false. */
831
+ function outcomeOf(node: Node | null, language: LanguageId, depth = 0): Outcome {
832
+ if (!node || depth > 40) return EMPTY;
833
+ let perTree = outcomeCache.get(node.tree);
834
+ if (!perTree) {
835
+ perTree = new Map();
836
+ outcomeCache.set(node.tree, perTree);
837
+ }
838
+ const cached = perTree.get(node.id);
839
+ if (cached) return cached;
840
+ const result = computeOutcome(node, language, depth);
841
+ perTree.set(node.id, result);
842
+ return result;
843
+ }
844
+
845
+ function computeOutcome(node: Node, language: LanguageId, depth: number): Outcome {
846
+ switch (node.type) {
847
+ case 'parenthesized_expression':
848
+ return outcomeOf(firstNamed(node), language, depth + 1);
849
+ case 'unary_op_expression': // PHP
850
+ case 'unary_expression': { // JS
851
+ const operator = node.childForFieldName('operator')?.text ?? (node.text ?? '').trim().charAt(0);
852
+ if (operator.trim() !== '!') return EMPTY;
853
+ const inner = outcomeOf(node.childForFieldName('argument') ?? firstNamed(node), language, depth + 1);
854
+ return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
855
+ }
856
+ case 'not_operator': { // Python
857
+ const inner = outcomeOf(node.childForFieldName('argument') ?? firstNamed(node), language, depth + 1);
858
+ return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
859
+ }
860
+ case 'binary_expression':
861
+ case 'boolean_operator':
862
+ case 'comparison_operator': {
863
+ const fence = language === 'python' ? quotedLiteralFence(node) : null;
864
+ if (fence) return fence;
865
+ const operator = operatorOf(node).toLowerCase();
866
+ if (operator === '&&' || operator === 'and') {
867
+ const a = outcomeOf(node.childForFieldName('left'), language, depth + 1);
868
+ const b = outcomeOf(node.childForFieldName('right'), language, depth + 1);
869
+ return { whenTrue: joinStrong(a.whenTrue, b.whenTrue), whenFalse: meetWeak(a.whenFalse, b.whenFalse, node) };
870
+ }
871
+ if (operator === '||' || operator === 'or') {
872
+ const a = outcomeOf(node.childForFieldName('left'), language, depth + 1);
873
+ const b = outcomeOf(node.childForFieldName('right'), language, depth + 1);
874
+ return { whenTrue: meetWeak(a.whenTrue, b.whenTrue, node), whenFalse: joinStrong(a.whenFalse, b.whenFalse) };
875
+ }
876
+ return comparisonOutcome(node, language, depth);
877
+ }
878
+ default:
879
+ if (CALL_TYPES.has(node.type)) return { whenTrue: callFacts(node, language), whenFalse: new Map() };
880
+ return EMPTY;
881
+ }
882
+ }
883
+
884
+ /* ------------------------------------------------------------------------ */
885
+ /* Where a fact holds, and whether the value is written while it does */
886
+ /* ------------------------------------------------------------------------ */
887
+
888
+ const FUNCTION_TYPES = new Set([
889
+ 'function_definition', 'method_declaration', 'anonymous_function_creation_expression', 'anonymous_function',
890
+ 'arrow_function', 'function_declaration', 'function_expression', 'method_definition',
891
+ 'generator_function_declaration', 'generator_function', 'lambda',
892
+ ]);
893
+ const IF_TYPES = new Set(['if_statement']);
894
+ const ALTERNATIVE_TYPES = new Set(['else_if_clause', 'else_clause', 'elif_clause']);
895
+ const TERNARY_TYPES = new Set(['conditional_expression', 'ternary_expression']);
896
+ const BLOCK_TYPES = new Set(['compound_statement', 'statement_block', 'block', 'program', 'module']);
897
+ const EXIT_TYPES = new Set(['exit_statement', 'return_statement', 'throw_statement', 'raise_statement', 'break_statement', 'continue_statement']);
898
+
899
+ const WRITE_TYPES = new Set([
900
+ 'assignment_expression', 'augmented_assignment_expression', 'reference_assignment_expression',
901
+ 'variable_declarator', 'assignment', 'augmented_assignment', 'named_expression', 'update_expression',
902
+ ]);
903
+
904
+ interface Writes {
905
+ readonly targets: readonly string[];
906
+ /** extract(), parse_str() with one argument, `$$name = ...`: any variable may change. */
907
+ readonly anything: boolean;
908
+ }
909
+
910
+ const writesCache = new WeakMap<Tree, Map<number, Writes>>();
911
+
912
+ function writesIn(region: Node): Writes {
913
+ let perTree = writesCache.get(region.tree);
914
+ if (!perTree) {
915
+ perTree = new Map();
916
+ writesCache.set(region.tree, perTree);
917
+ }
918
+ const cached = perTree.get(region.id);
919
+ if (cached) return cached;
920
+ const targets: string[] = [];
921
+ let anything = false;
922
+ const visit = (n: Node): void => {
923
+ if (WRITE_TYPES.has(n.type)) {
924
+ const target = n.childForFieldName('left') ?? n.childForFieldName('name') ?? n.childForFieldName('argument') ?? firstNamed(n);
925
+ if (target?.text) {
926
+ if (target.type === 'dynamic_variable_name' || target.text.startsWith('$$')) anything = true;
927
+ targets.push(normalizeExpression(target.text));
928
+ }
929
+ }
930
+ if (n.type === 'foreach_statement') {
931
+ // Everything bound by `as $k => $v`, which sits between the iterable and the body.
932
+ const kids = namedKids(n);
933
+ for (const k of kids.slice(1)) {
934
+ if (k.type === 'compound_statement' || k.type === 'colon_block') continue;
935
+ targets.push(normalizeExpression(k.text ?? ''));
936
+ }
937
+ }
938
+ if (n.type === 'as_pattern') {
939
+ // Python `with open(p) as f`, `except E as e`: the name after `as`.
940
+ const alias = namedKids(n).pop();
941
+ if (alias?.text) targets.push(normalizeExpression(alias.text));
942
+ }
943
+ if (n.type === 'for_in_statement' || n.type === 'for_statement') {
944
+ const left = n.childForFieldName('left');
945
+ if (left?.text) targets.push(normalizeExpression(left.text));
946
+ }
947
+ if (n.type === 'global_declaration' || n.type === 'static_variable_declaration' || n.type === 'global_statement' || n.type === 'nonlocal_statement') {
948
+ targets.push(normalizeExpression(n.text ?? ''));
949
+ }
950
+ if (n.type === 'function_call_expression') {
951
+ const name = (n.childForFieldName('function')?.text ?? '').toLowerCase();
952
+ const args = callArgs(n);
953
+ if (name === 'extract' || (name === 'parse_str' && args.length < 2)) anything = true;
954
+ // Out-parameters PHP writes through a reference.
955
+ const outIndex: Record<string, number> = { parse_str: 1, preg_match: 2, preg_match_all: 2, settype: 0, sscanf: 2 };
956
+ const at = outIndex[name];
957
+ if (at !== undefined) for (const a of args.slice(at)) targets.push(normalizeExpression(a.text ?? ''));
958
+ }
959
+ for (const c of namedKids(n)) visit(c);
960
+ };
961
+ visit(region);
962
+ const result: Writes = { targets, anything };
963
+ perTree.set(region.id, result);
964
+ return result;
965
+ }
966
+
967
+ const isIdentChar = (c: string | undefined): boolean => c !== undefined && /[\w$]/.test(c);
968
+
969
+ /** Does a write to `target` change the value written `text`? */
970
+ function touches(target: string, text: string): boolean {
971
+ if (target === text) return true;
972
+ for (const sep of ['[', '->', '.']) {
973
+ if (text.startsWith(target + sep) || target.startsWith(text + sep)) return true;
974
+ }
975
+ // `[$a, $b] = ...`, `list($a) = ...`, `global $a;`, `obj.x = ...` - a whole
976
+ // token inside the target counts. Over-cautious by design.
977
+ let from = 0;
978
+ while (true) {
979
+ const at = target.indexOf(text, from);
980
+ if (at === -1) return false;
981
+ if (!isIdentChar(target[at - 1]) || text.startsWith('$')) {
982
+ if (!isIdentChar(target[at + text.length])) return true;
983
+ }
984
+ from = at + 1;
985
+ }
986
+ }
987
+
988
+ function writtenIn(regions: readonly Node[], text: string): boolean {
989
+ for (const region of regions) {
990
+ const w = writesIn(region);
991
+ if (w.anything) return true;
992
+ if (w.targets.some((t) => touches(t, text))) return true;
993
+ }
994
+ return false;
995
+ }
996
+
997
+ /** The then-branch: PHP calls it `body`, JavaScript and Python `consequence`. */
998
+ const thenBranch = (statement: Node): Node | null =>
999
+ statement.childForFieldName('consequence') ?? statement.childForFieldName('body');
1000
+
1001
+ /** Does this branch always leave - its last statement an exit, return, throw, break or continue? */
1002
+ function alwaysLeaves(branch: Node | null): boolean {
1003
+ if (!branch) return false;
1004
+ const last = BLOCK_TYPES.has(branch.type) || branch.type === 'colon_block' ? namedKids(branch).filter((k) => k.type !== 'comment').pop() ?? null : branch;
1005
+ if (!last) return false;
1006
+ if (EXIT_TYPES.has(last.type)) return true;
1007
+ // PHP: `die(...)`, `exit(...)` and `throw` parse inside an expression statement.
1008
+ const inner = last.type === 'expression_statement' ? firstNamed(last) : null;
1009
+ if (inner?.type === 'throw_expression') return true;
1010
+ if (inner?.type === 'exit_statement') return true;
1011
+ if (inner?.type === 'function_call_expression') {
1012
+ const name = (inner.childForFieldName('function')?.text ?? '').toLowerCase();
1013
+ if (name === 'die' || name === 'exit') return true;
1014
+ }
1015
+ return false;
1016
+ }
1017
+
1018
+ function add(into: Facts, facts: Facts, regions: readonly Node[]): void {
1019
+ for (const [text, fact] of facts) {
1020
+ if (writtenIn(regions, text)) continue;
1021
+ const had = into.get(text);
1022
+ into.set(text, had ? stronger(had, fact) : fact);
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * Every value a check proves something about, at this node. Keys are
1028
+ * normalised expression texts (see normalizeExpression).
1029
+ */
1030
+ export function guardFactsAt(node: Node, language: LanguageId): ReadonlyMap<string, GuardFact> {
1031
+ const facts: Facts = new Map();
1032
+ if (!GUARD_LANGUAGES.has(language)) return facts;
1033
+ let child: Node = node;
1034
+ let current: Node | null = node.parent;
1035
+ while (current) {
1036
+ if (FUNCTION_TYPES.has(current.type)) break;
1037
+
1038
+ if (IF_TYPES.has(current.type)) {
1039
+ const condition = current.childForFieldName('condition');
1040
+ const then = thenBranch(current);
1041
+ if (condition) {
1042
+ const outcome = outcomeOf(condition, language);
1043
+ if (then && then.id === child.id) {
1044
+ add(facts, outcome.whenTrue, [current]);
1045
+ } else {
1046
+ const alternatives = namedKids(current).filter((k) => ALTERNATIVE_TYPES.has(k.type));
1047
+ if (alternatives.some((a) => a.id === child.id)) {
1048
+ add(facts, outcome.whenFalse, [current]);
1049
+ for (const alt of alternatives) {
1050
+ const altCondition = alt.childForFieldName('condition');
1051
+ if (alt.id === child.id) {
1052
+ const altBody = thenBranch(alt);
1053
+ if (altCondition && altBody && contains(altBody, node)) {
1054
+ add(facts, outcomeOf(altCondition, language).whenTrue, [current]);
1055
+ }
1056
+ break;
1057
+ }
1058
+ if (altCondition) add(facts, outcomeOf(altCondition, language).whenFalse, [current]);
1059
+ }
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ if (TERNARY_TYPES.has(current.type)) {
1066
+ const condition = current.childForFieldName('condition');
1067
+ const yes = current.childForFieldName('consequence') ?? current.childForFieldName('body');
1068
+ const no = current.childForFieldName('alternative');
1069
+ if (condition) {
1070
+ const outcome = outcomeOf(condition, language);
1071
+ if (yes && yes.id === child.id) add(facts, outcome.whenTrue, [current]);
1072
+ else if (no && no.id === child.id) add(facts, outcome.whenFalse, [current]);
1073
+ }
1074
+ }
1075
+
1076
+ // An earlier `if (!CHECK) { exit; }` in the same block.
1077
+ if (BLOCK_TYPES.has(current.type)) {
1078
+ const siblings = namedKids(current);
1079
+ const index = siblings.findIndex((s) => s.id === child.id);
1080
+ for (let i = 0; i < index; i++) {
1081
+ const s = siblings[i]!;
1082
+ if (!IF_TYPES.has(s.type)) continue;
1083
+ const condition = s.childForFieldName('condition');
1084
+ if (!condition || !alwaysLeaves(thenBranch(s))) continue;
1085
+ add(facts, outcomeOf(condition, language).whenFalse, siblings.slice(i));
1086
+ }
1087
+ }
1088
+
1089
+ child = current;
1090
+ current = current.parent;
1091
+ }
1092
+ return facts;
1093
+ }
1094
+
1095
+ /* ------------------------------------------------------------------------ */
1096
+ /* The signature rules' entry point */
1097
+ /* ------------------------------------------------------------------------ */
1098
+
1099
+ /** A fact a signature rule may use for this sink: word needs quotes for SQL, and a text rule does not know them. */
1100
+ const usableFor = (fact: GuardFact, kind: SinkKind): boolean => {
1101
+ const clears = SAFE_KINDS[fact.strength];
1102
+ return clears.kinds.includes(kind) && !clears.insideQuotesOnly.includes(kind);
1103
+ };
1104
+
1105
+ /**
1106
+ * Are ALL the spliced parts of this expression proven safe by a check?
1107
+ *
1108
+ * Every part must be covered. That is deliberate and it is the lesson from the
1109
+ * Jenkins escaper bug, where a sanitiser covering ONE value in an expression
1110
+ * was allowed to vouch for a DIFFERENT value beside it. Here, one unchecked
1111
+ * part means the whole expression still reports:
1112
+ *
1113
+ * if (is_numeric($port)) { shell_exec('nc ' . $host . ' ' . $port); }
1114
+ * ^^^^^ never checked
1115
+ *
1116
+ * One level of local indirection is followed, because the shape that matters
1117
+ * rebuilds the value inside the branch:
1118
+ *
1119
+ * $target = $octet[0] . '.' . $octet[1]; <- every piece checked here
1120
+ * shell_exec('ping ' . $target); <- and used here
1121
+ *
1122
+ * A name counts when its value here certainly comes from one assignment: the
1123
+ * last plain `name = ...` before this point, sitting directly in a block that
1124
+ * also holds this point, with no other write to the name from that assignment
1125
+ * to the end of the statement this point is in (so a loop cannot bring a
1126
+ * different value round). And every piece of that one value was checked where
1127
+ * it was assigned. DVWA writes `$target` twice - once from the request, once
1128
+ * rebuilt from checked octets - so "assigned once in the function" was too
1129
+ * strict, and "assigned somewhere in the branch" (the first version) never
1130
+ * asked whether a later write replaced it.
1131
+ */
1132
+ export function partsAreGuarded(
1133
+ node: Node,
1134
+ dynamicParts: readonly string[],
1135
+ language: LanguageId,
1136
+ kind: SinkKind,
1137
+ ): boolean {
1138
+ if (dynamicParts.length === 0) return false;
1139
+ if (!GUARD_LANGUAGES.has(language)) return false;
1140
+ const facts = guardFactsAt(node, language);
1141
+ for (const raw of dynamicParts) {
1142
+ const part = normalizeExpression(raw);
1143
+ const fact = facts.get(part);
1144
+ if (fact && usableFor(fact, kind)) continue;
1145
+ if (!definedFromCheckedParts(node, part, language, kind)) return false;
1146
+ }
1147
+ return true;
1148
+ }
1149
+
1150
+ function definedFromCheckedParts(node: Node, name: string, language: LanguageId, kind: SinkKind): boolean {
1151
+ if (!/^\$?[A-Za-z_][\w]*$/.test(name)) return false;
1152
+ let scope: Node | null = node.parent;
1153
+ while (scope && !FUNCTION_TYPES.has(scope.type) && scope.parent) scope = scope.parent;
1154
+ if (!scope) return false;
1155
+
1156
+ // Every write to the name in the function, in source order.
1157
+ const writes: Node[] = [];
1158
+ const visit = (n: Node): void => {
1159
+ if (WRITE_TYPES.has(n.type)) {
1160
+ const target = n.childForFieldName('left') ?? n.childForFieldName('name') ?? n.childForFieldName('argument');
1161
+ if (target?.text && touches(normalizeExpression(target.text), name)) writes.push(n);
1162
+ }
1163
+ for (const c of namedKids(n)) visit(c);
1164
+ };
1165
+ visit(scope);
1166
+ const before = writes.filter((w) => w.endIndex <= node.startIndex);
1167
+ const definition = before[before.length - 1];
1168
+ if (!definition) return false;
1169
+ const target = definition.childForFieldName('left') ?? definition.childForFieldName('name');
1170
+ const value = definition.childForFieldName('right') ?? definition.childForFieldName('value');
1171
+ const plain = definition.type === 'assignment_expression' || definition.type === 'assignment' || definition.type === 'variable_declarator';
1172
+ if (!plain || !value || !target || normalizeExpression(target.text ?? '') !== name) return false;
1173
+
1174
+ // The assignment's statement must sit directly in a block that holds this point.
1175
+ let statement: Node = definition;
1176
+ while (statement.parent && !BLOCK_TYPES.has(statement.parent.type)) statement = statement.parent;
1177
+ const block = statement.parent;
1178
+ if (!block || !contains(block, node) || statement.endIndex > node.startIndex) return false;
1179
+ let pointStatement: Node = node;
1180
+ while (pointStatement.parent && pointStatement.parent.id !== block.id) pointStatement = pointStatement.parent;
1181
+ if (pointStatement.parent?.id !== block.id) return false;
1182
+
1183
+ // Nothing else writes it - by assignment, foreach, global, out-parameter or
1184
+ // extract() - between the assignment and the end of this point's statement.
1185
+ if (writes.some((w) => w.id !== definition.id && w.startIndex > definition.startIndex && w.startIndex < pointStatement.endIndex)) return false;
1186
+ const between = namedKids(block).filter((s) => s.startIndex >= statement.endIndex && s.startIndex <= pointStatement.startIndex);
1187
+ for (const s of between) {
1188
+ const w = writesIn(s);
1189
+ if (w.anything || w.targets.some((t) => touches(t, name))) return false;
1190
+ }
1191
+
1192
+ const pieces = leafExpressions(value);
1193
+ if (pieces.length === 0) return false;
1194
+ const factsThere = guardFactsAt(value, language);
1195
+ return pieces.every((piece) => {
1196
+ const fact = factsThere.get(normalizeExpression(piece));
1197
+ return fact !== undefined && usableFor(fact, kind);
1198
+ });
1199
+ }
1200
+
1201
+ /**
1202
+ * The pieces of an expression that could carry a value. String literal TEXT
1203
+ * is skipped, but what is interpolated INTO a string is not - the first
1204
+ * version of this skipped every node with "string" in its type, so
1205
+ * `"... '$b'"` had no pieces at all and a check on a different value vouched
1206
+ * for it. A call is one opaque piece: what it returns is not something a
1207
+ * check on its arguments describes.
1208
+ */
1209
+ function leafExpressions(node: Node): string[] {
1210
+ const out: string[] = [];
1211
+ const walk = (n: Node | null): void => {
1212
+ if (!n) return;
1213
+ if (n.type === 'integer' || n.type === 'float' || n.type === 'number' || n.type === 'boolean') return;
1214
+ if (n.type === 'string_content' || n.type === 'string_value' || n.type === 'string_fragment' || n.type === 'escape_sequence') return;
1215
+ if (CALL_TYPES.has(n.type)) {
1216
+ out.push(n.text ?? '');
1217
+ return;
1218
+ }
1219
+ if (
1220
+ n.type === 'variable_name' ||
1221
+ n.type === 'identifier' ||
1222
+ n.type === 'subscript_expression' ||
1223
+ n.type === 'member_access_expression' ||
1224
+ n.type === 'member_expression' ||
1225
+ n.type === 'subscript' ||
1226
+ n.type === 'attribute'
1227
+ ) {
1228
+ out.push((n.text ?? '').trim());
1229
+ return; // do not descend - `$octet[0]` is the unit a check names
1230
+ }
1231
+ for (const c of namedKids(n)) walk(c);
1232
+ };
1233
+ walk(node);
1234
+ return out;
1235
+ }