@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,2034 @@
1
+ /**
2
+ * STRING BUILDING ANALYSIS
3
+ * ========================
4
+ *
5
+ * Almost every injection bug in this tool's catalogue has the same skeleton:
6
+ * a string was ASSEMBLED at runtime and then handed to something that treats
7
+ * strings as instructions (a database, a shell, a browser).
8
+ *
9
+ * So the one question we ask over and over is: "was this expression a fixed
10
+ * piece of text, or was it stitched together from parts?" This file answers it
11
+ * for all five languages, and it is the piece of shared machinery that lets one
12
+ * rule cover JavaScript, Python, Java and Go at once.
13
+ *
14
+ * VOCABULARY YOU'LL NEED:
15
+ *
16
+ * Sanitisation - cleaning or neutralising untrusted input before use. For SQL
17
+ * the real fix is not cleaning at all but PARAMETERISATION: you hand the
18
+ * database the query and the values separately, so the values can never be
19
+ * read as commands. For HTML it means escaping < > & " so text is displayed
20
+ * rather than executed.
21
+ *
22
+ * Interpolation - languages' built-in "put this value inside this string"
23
+ * syntax: `${x}` in JS, f"{x}" in Python, fmt.Sprintf("%s", x) in Go.
24
+ * To a database or a shell it is identical to concatenation. Feeling
25
+ * modern does not make it safe.
26
+ *
27
+ * PHASE 1 HONESTY NOTE: knowing a string was built dynamically does NOT mean
28
+ * the parts came from an attacker. `"SELECT * FROM " + TABLE_NAME` with a
29
+ * hard-coded constant is fine. We cannot tell the difference without data-flow
30
+ * analysis, which is Phase 3. That is exactly why these findings are labelled
31
+ * signature-based, and why every one of them says so in its `limitations`.
32
+ */
33
+
34
+ import type { Node } from 'web-tree-sitter';
35
+ import type { LanguageId } from '../../parse/languages.js';
36
+ import { SWITCH_NODES, createFolder, type Folder } from '../../taint/fold.js';
37
+ import type { StaticMethodLookup } from '../contract.js';
38
+ import { declaredImmutable, enclosingMethod, resolveContainerRead } from '../../taint/local-map.js';
39
+ import {
40
+ boundElsewhere,
41
+ enclosingClassLikes,
42
+ hasModifier,
43
+ isClassLike,
44
+ supertypesOf,
45
+ usedOnlyAsQualifier,
46
+ } from '../../taint/java-scope.js';
47
+ import { declarationVisibility, hidesCodeFromParser } from '../../taint/visibility.js';
48
+
49
+ /** Node types that ARE a string literal, per language. */
50
+ const STRING_LITERAL_TYPES: Record<LanguageId, readonly string[]> = {
51
+ javascript: ['string', 'template_string'],
52
+ typescript: ['string', 'template_string'],
53
+ python: ['string', 'concatenated_string'],
54
+ java: ['string_literal', 'text_block'],
55
+ go: ['interpreted_string_literal', 'raw_string_literal'],
56
+ // C has one string node type and no interpolation at all, which is why every
57
+ // dangerous C string is BUILT - by sprintf, strcat or std::string's operator+
58
+ // - rather than interpolated in place. `concatenated_string` is adjacent
59
+ // literals ("a" "b"), which C joins at compile time.
60
+ c: ['string_literal', 'concatenated_string', 'char_literal'],
61
+ cpp: ['string_literal', 'concatenated_string', 'char_literal', 'raw_string_literal'],
62
+ // PHP's `string` is single-quoted (never interpolates). `encapsed_string` is
63
+ // double-quoted and CAN interpolate, which is why it appears in both this
64
+ // list and INTERPOLATION_TYPES - isStringLiteral checks for a spliced-in
65
+ // value before calling it a literal.
66
+ php: ['string', 'encapsed_string', 'heredoc'],
67
+ };
68
+
69
+ /** Node types that mean "a value was spliced into this string". */
70
+ const INTERPOLATION_TYPES = new Set([
71
+ 'template_substitution', // JS/TS `... ${x} ...`
72
+ 'interpolation', // Python f"...{x}..."
73
+ // PHP splices a variable straight into a double-quoted string with no wrapper
74
+ // node: "id = $id" holds a `variable_name` child directly. The name exists
75
+ // only in the PHP grammar, so listing it here cannot affect another language.
76
+ 'variable_name', // PHP "... $x ..." and "... {$x} ..."
77
+ ]);
78
+
79
+ /** Node types holding the plain-text chunks inside a string literal. */
80
+ const STRING_CONTENT_TYPES = new Set([
81
+ 'string_fragment',
82
+ 'string_content',
83
+ 'interpreted_string_literal_content',
84
+ 'raw_string_literal_content',
85
+ ]);
86
+
87
+ /** Function names that build a string from a template plus values. */
88
+ const FORMAT_FUNCTIONS = new Set([
89
+ 'format', // Python "..."​.format(), Java String.format
90
+ 'sprintf',
91
+ 'Sprintf', // Go fmt.Sprintf
92
+ 'Sprintln',
93
+ 'Fprintf',
94
+ 'printf',
95
+ 'Printf',
96
+ 'join', // "".join([...]) and String.join
97
+ 'concat',
98
+ 'append', // Java StringBuilder.append chains
99
+ 'valueOf',
100
+ ]);
101
+
102
+ export type BuildMechanism =
103
+ | 'literal' // a fixed string, nothing spliced in
104
+ | 'concatenation' // built with + (or % in Python)
105
+ | 'interpolation' // built with `${}` / f-strings
106
+ | 'format-call' // built with Sprintf / .format() / String.format
107
+ | 'opaque'; // a variable or call - we cannot see the text at all
108
+
109
+ export interface StringExpression {
110
+ /** True when the text is not fully known at write time. */
111
+ readonly isDynamic: boolean;
112
+ readonly mechanism: BuildMechanism;
113
+ /** The fixed parts we could read, joined. Empty when the whole thing is opaque. */
114
+ readonly literalText: string;
115
+ /** The expressions spliced in. Reported so a human can judge them. */
116
+ readonly dynamicParts: readonly string[];
117
+ }
118
+
119
+ /**
120
+ * Node types that can be the TOP of a string-building expression.
121
+ *
122
+ * This gate exists because of a false positive found by pointing the scanner at
123
+ * its own source code. `analyzeStringExpression` walks a whole subtree and
124
+ * concatenates every string literal it finds. Handed an object literal like
125
+ *
126
+ * export const sqlInjectionRule: Rule = {
127
+ * name: 'SQL query assembled from a built string',
128
+ * explanation: '... the query and the values ... update ... table ...',
129
+ * ...
130
+ * };
131
+ *
132
+ * it happily merged prose from a dozen unrelated fields into one blob, and
133
+ * `looksLikeSql()` found "select", "from" and "where" scattered through it.
134
+ * The scanner reported its own rule definitions as SQL injection.
135
+ *
136
+ * The lesson generalises: an analysis is only as good as the SCOPE it runs on.
137
+ * Before asking "does this text look like SQL?", make sure the thing you are
138
+ * looking at is a single string expression - not a container full of unrelated
139
+ * strings that happen to sit next to each other.
140
+ */
141
+ const STRING_EXPRESSION_TYPES: Record<LanguageId, readonly string[]> = {
142
+ javascript: ['string', 'template_string', 'binary_expression', 'call_expression'],
143
+ typescript: ['string', 'template_string', 'binary_expression', 'call_expression'],
144
+ python: ['string', 'concatenated_string', 'binary_operator', 'call'],
145
+ java: ['string_literal', 'text_block', 'binary_expression', 'method_invocation'],
146
+ go: [
147
+ 'interpreted_string_literal',
148
+ 'raw_string_literal',
149
+ 'binary_expression',
150
+ 'call_expression',
151
+ ],
152
+ php: [
153
+ 'string',
154
+ 'encapsed_string',
155
+ 'heredoc',
156
+ 'binary_expression', // PHP concatenates with `.`
157
+ 'function_call_expression',
158
+ 'member_call_expression',
159
+ ],
160
+ // In C a "string expression" is nearly always a CALL - sprintf or strcat -
161
+ // rather than an operator, because char arrays do not concatenate with `+`.
162
+ c: ['string_literal', 'concatenated_string', 'binary_expression', 'call_expression'],
163
+ // C++ gets the operator back via std::string, so binary_expression matters
164
+ // here in a way it does not in C.
165
+ cpp: [
166
+ 'string_literal',
167
+ 'concatenated_string',
168
+ 'raw_string_literal',
169
+ 'binary_expression',
170
+ 'call_expression',
171
+ ],
172
+ };
173
+
174
+ /**
175
+ * True when this node could plausibly evaluate to a string that was assembled.
176
+ * Object literals, arrays, functions and class bodies return false - they are
177
+ * containers, not strings, even though they contain strings.
178
+ */
179
+ export function isStringBuildingExpression(node: Node, language: LanguageId): boolean {
180
+ return STRING_EXPRESSION_TYPES[language].includes(node.type);
181
+ }
182
+
183
+ export function isStringLiteral(node: Node, language: LanguageId): boolean {
184
+ const types = STRING_LITERAL_TYPES[language];
185
+ if (!types.includes(node.type)) return false;
186
+ // An f-string or template literal WITH a substitution is not a literal.
187
+ return !containsType(node, INTERPOLATION_TYPES, 6);
188
+ }
189
+
190
+ function containsType(node: Node, types: Set<string>, maxDepth: number): boolean {
191
+ if (maxDepth <= 0) return false;
192
+ for (const child of node.namedChildren) {
193
+ if (!child) continue;
194
+ if (types.has(child.type)) return true;
195
+ if (containsType(child, types, maxDepth - 1)) return true;
196
+ }
197
+ return false;
198
+ }
199
+
200
+ /** Remove surrounding quotes, backticks and prefixes (f"", b'', @"", `` ). */
201
+ export function unquote(raw: string): string {
202
+ return raw
203
+ .replace(/^[a-zA-Z]{0,2}(["'`])((?:.|\n)*)\1$/, '$2')
204
+ .replace(/^"""|"""$/g, '')
205
+ .replace(/^'''|'''$/g, '');
206
+ }
207
+
208
+ /**
209
+ * Read an expression and describe how its text was assembled.
210
+ *
211
+ * The walk is deliberately shallow (depth-limited) and deliberately simple: it
212
+ * collects three things - fixed text chunks, evidence of splicing, and the
213
+ * spliced expressions - and infers the mechanism from what it found. It does
214
+ * NOT try to evaluate the expression. A scanner that guesses at runtime values
215
+ * is a scanner that lies.
216
+ */
217
+ export function analyzeStringExpression(
218
+ node: Node,
219
+ language: LanguageId,
220
+ maxDepth = 10,
221
+ ): StringExpression {
222
+ const literalChunks: string[] = [];
223
+ const dynamicParts: string[] = [];
224
+ let sawInterpolation = false;
225
+ let sawConcatenation = false;
226
+ let sawFormatCall = false;
227
+ let sawOpaqueRef = false;
228
+
229
+ const visit = (current: Node, depth: number): void => {
230
+ if (depth > maxDepth) return;
231
+
232
+ // A string literal: harvest its fixed text and stop (unless it interpolates).
233
+ if (STRING_LITERAL_TYPES[language].includes(current.type)) {
234
+ const contents = current.namedChildren.filter(
235
+ (c): c is Node => c !== null && STRING_CONTENT_TYPES.has(c.type),
236
+ );
237
+ if (contents.length > 0) {
238
+ for (const chunk of contents) literalChunks.push(chunk.text ?? '');
239
+ } else if (!containsType(current, INTERPOLATION_TYPES, 4)) {
240
+ literalChunks.push(unquote(current.text ?? ''));
241
+ }
242
+ // Still descend, because a template string can hold substitutions.
243
+ for (const child of current.namedChildren) {
244
+ if (child && INTERPOLATION_TYPES.has(child.type)) visit(child, depth + 1);
245
+ }
246
+ return;
247
+ }
248
+
249
+ if (INTERPOLATION_TYPES.has(current.type)) {
250
+ sawInterpolation = true;
251
+ const inner = (current.text ?? '').replace(/^\$?\{|\}$/g, '').trim();
252
+ if (inner) dynamicParts.push(inner);
253
+ return;
254
+ }
255
+
256
+ // Concatenation. JS/Java/Go call it binary_expression, Python binary_operator.
257
+ if (current.type === 'binary_expression' || current.type === 'binary_operator') {
258
+ const operator = current.childForFieldName('operator')?.text ?? '';
259
+ if (operator === '+' || operator === '%') {
260
+ sawConcatenation = true;
261
+ for (const child of current.namedChildren) if (child) visit(child, depth + 1);
262
+ return;
263
+ }
264
+ }
265
+
266
+ // A call: is it a formatter, or just an opaque value?
267
+ if (
268
+ current.type === 'call' ||
269
+ current.type === 'call_expression' ||
270
+ current.type === 'method_invocation' ||
271
+ /*
272
+ * PHP'S CALL NODES WERE MISSING, AND THAT COST 2,547 FINDINGS.
273
+ *
274
+ * Without these three types the walker fell through to "recurse into
275
+ * every child" and descended INSIDE the call, so
276
+ *
277
+ * echo esc_html( $user->user_login );
278
+ *
279
+ * was described as "interpolation splicing in `$user`". The escaper was
280
+ * walked straight past and its argument reported as if it had been
281
+ * concatenated in raw - which is both a wrong description and the reason
282
+ * every WordPress escaping call looked like an unescaped one.
283
+ *
284
+ * A call is opaque: its RESULT is what gets spliced, and the arguments
285
+ * are not that result. Naming the node types makes PHP behave like the
286
+ * other five languages, which had them all along.
287
+ */
288
+ current.type === 'function_call_expression' ||
289
+ current.type === 'member_call_expression' ||
290
+ current.type === 'scoped_call_expression'
291
+ ) {
292
+ const nameNode =
293
+ current.childForFieldName('name') ??
294
+ current.childForFieldName('function') ??
295
+ current.childForFieldName('constructor');
296
+ const nameText = nameNode?.text ?? '';
297
+ const lastSegment = nameText.split(/[.:]/).pop() ?? nameText;
298
+ if (FORMAT_FUNCTIONS.has(lastSegment)) {
299
+ sawFormatCall = true;
300
+ /*
301
+ * SKIP THE FUNCTION'S OWN NAME.
302
+ *
303
+ * `fmt.Sprintf` is a selector_expression, so recursing into every child
304
+ * walked into the callee and recorded it as a spliced-in value. Every
305
+ * format-call finding therefore opened with "It splices in
306
+ * `fmt.Sprintf`, ..." - a sentence about the mechanism presented as a
307
+ * sentence about the data, and simply untrue.
308
+ *
309
+ * It also broke a real proof: a Go escape hatch whose arguments were
310
+ * ALL escaped still had `fmt.Sprintf` sitting in its parts list, so
311
+ * "is every part escaped?" answered no about the function's own name.
312
+ */
313
+ // Compared by POSITION, not identity: web-tree-sitter hands back a new
314
+ // wrapper object on every access, so `child !== nameNode` is always true.
315
+ for (const child of current.namedChildren) {
316
+ if (!child) continue;
317
+ if (nameNode && child.startIndex === nameNode.startIndex && child.endIndex === nameNode.endIndex) {
318
+ continue;
319
+ }
320
+ visit(child, depth + 1);
321
+ }
322
+ /*
323
+ * BUT NOT THE THING IT IS CALLED ON. In JavaScript and Python the
324
+ * callee is `dir.concat` or `template.format` as one node, so skipping
325
+ * "the function's name" skipped `dir` and `template` with it - and they
326
+ * are the very strings being built on (tests/probes/s09b, p01). Their
327
+ * receiver is visited like any other part. Go's receiver here is a
328
+ * package (`fmt`), and Java's call keeps its receiver in a field of its
329
+ * own, which the loop above already visited.
330
+ */
331
+ if (nameNode && (nameNode.type === 'member_expression' || nameNode.type === 'attribute')) {
332
+ const receiver = nameNode.childForFieldName('object');
333
+ if (receiver) visit(receiver, depth + 1);
334
+ }
335
+ return;
336
+ }
337
+ sawOpaqueRef = true;
338
+ dynamicParts.push((current.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60));
339
+ return;
340
+ }
341
+
342
+ /*
343
+ * VALUES THAT ARE NOT WHAT THEY SPELL. `(yield prompt)` is whatever the
344
+ * caller sends back in, not `prompt` (tests/probes/s08). And `"ls " + obj`
345
+ * in Java or JavaScript calls obj's toString(), which can return anything:
346
+ * `new Arg(name)` is not `name` (j08), and `this` is not nothing (j14).
347
+ * Each is one opaque part - never descended into.
348
+ */
349
+ if (
350
+ current.type === 'yield_expression' ||
351
+ current.type === 'yield' ||
352
+ ((language === 'java' || language === 'javascript' || language === 'typescript') && current.type === 'this') ||
353
+ (language === 'java' && current.type === 'object_creation_expression') ||
354
+ ((language === 'javascript' || language === 'typescript') && current.type === 'new_expression')
355
+ ) {
356
+ sawOpaqueRef = true;
357
+ dynamicParts.push((current.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60));
358
+ return;
359
+ }
360
+
361
+ if (
362
+ current.type === 'identifier' ||
363
+ current.type === 'member_expression' ||
364
+ current.type === 'attribute' ||
365
+ current.type === 'selector_expression' ||
366
+ current.type === 'field_access' ||
367
+ current.type === 'subscript' ||
368
+ current.type === 'subscript_expression' ||
369
+ current.type === 'index_expression'
370
+ ) {
371
+ sawOpaqueRef = true;
372
+ dynamicParts.push((current.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60));
373
+ return;
374
+ }
375
+
376
+ for (const child of current.namedChildren) if (child) visit(child, depth + 1);
377
+ };
378
+
379
+ visit(node, 0);
380
+
381
+ const literalText = literalChunks.join('');
382
+ let mechanism: BuildMechanism = 'literal';
383
+ if (sawInterpolation) mechanism = 'interpolation';
384
+ else if (sawConcatenation && dynamicParts.length > 0) mechanism = 'concatenation';
385
+ else if (sawFormatCall) mechanism = 'format-call';
386
+ else if (sawOpaqueRef && literalChunks.length === 0) mechanism = 'opaque';
387
+ else if (sawOpaqueRef) mechanism = 'concatenation';
388
+
389
+ return {
390
+ isDynamic: mechanism !== 'literal',
391
+ mechanism,
392
+ literalText,
393
+ dynamicParts,
394
+ };
395
+ }
396
+
397
+ /* ---------------------------------------------------------------------------
398
+ * "Does this text look like X?" helpers.
399
+ *
400
+ * These are intentionally conservative. Their job is to stop the scanner
401
+ * shouting about `"Hello " + name`, which is not a SQL query and never was.
402
+ * ------------------------------------------------------------------------ */
403
+
404
+ /* ---------------------------------------------------------------------------
405
+ * DOES THIS TEXT LOOK LIKE SQL?
406
+ *
407
+ * The first version of this asked three independent questions of the text:
408
+ * is there a SQL verb ANYWHERE, a clause keyword ANYWHERE, and a general SQL
409
+ * keyword ANYWHERE. Three yesses meant "this is a query".
410
+ *
411
+ * Pointing the scanner at its own source found the flaw. This paragraph -
412
+ * the honesty disclaimer printed under every flow-verified finding - is built
413
+ * from eight string chunks joined with `+`. `analyzeStringExpression` merges
414
+ * them into one 638-character blob, and the blob contains:
415
+ *
416
+ * verb "call" from ... a CALL into a third-party package ...
417
+ * clause "into" from ... a call INTO a third-party package ...
418
+ * clause "where" from ... - WHERE imports resolve - ...
419
+ *
420
+ * Three matches, drawn from two unrelated sentences, and the scanner reported
421
+ * its own disclaimer as a SQL injection. Proximity alone would not have saved
422
+ * it either: `call ... into` are adjacent words.
423
+ *
424
+ * The fix is to stop asking "are these words present?" and start asking
425
+ * "is this the SHAPE of a statement?". Real SQL is not a bag of keywords, it
426
+ * is a grammar: SELECT is followed by FROM, INSERT is followed by INTO,
427
+ * UPDATE is followed by SET. English prose containing the same words almost
428
+ * never puts them in that order, in that span, without a full stop between.
429
+ *
430
+ * This is the same lesson as the STRING_EXPRESSION_TYPES gate above, one level
431
+ * down: there the scope was wrong, here the test was too weak for the scope.
432
+ * ------------------------------------------------------------------------ */
433
+
434
+ /**
435
+ * The canonical statement shapes. Each one pairs a verb with the clause the
436
+ * SQL grammar REQUIRES to follow it, within a bounded window - so the two
437
+ * halves have to belong to the same statement, not the same paragraph.
438
+ */
439
+ const SQL_STATEMENT_SHAPES: readonly RegExp[] = [
440
+ /\bselect\b[\s\S]{0,300}?\bfrom\b/i,
441
+ /\binsert\s+(?:ignore\s+|or\s+\w+\s+)?into\b/i,
442
+ /\bupdate\b[\s\S]{0,200}?\bset\b/i,
443
+ /\bdelete\s+from\b/i,
444
+ /\bmerge\s+into\b/i,
445
+ /\breplace\s+into\b/i,
446
+ /\bunion(?:\s+all)?\s+select\b/i,
447
+ /\bwith\b[\s\S]{0,200}?\bas\s*\(/i, // common table expression
448
+ /\bcall\s+[\w.]+\s*\(/i, // stored procedure
449
+ /\bexec(?:ute)?\s+(?:sp_|dbo\.|[\w.]+\s*[('@])/i,
450
+ /\b(?:drop|alter|create|truncate)\s+(?:table|index|view|database|schema)\b/i,
451
+ // Query FRAGMENTS: a lot of real injection is ` WHERE id = ` + x, appended
452
+ // to a query built elsewhere. A clause keyword sitting at the very start of
453
+ // the fragment is the tell, and prose does not open that way.
454
+ /^\s*(?:where|from|order\s+by|group\s+by|having|limit|offset)\b/i,
455
+ /*
456
+ * VALUES AND AND ARE ALSO ENGLISH, so these two need the rest of the clause.
457
+ *
458
+ * An f-string's literal parts ARE fragments in the sense above - the text
459
+ * between two interpolations is exactly a string appended to something else -
460
+ * which is why the fragment rule reads them. Scanning pandas turned up four
461
+ * assertion messages that way:
462
+ *
463
+ * f"{obj} values are different ({pct} %)" -> " values are different ("
464
+ * f"{a} and {b} were approximately equal" -> " and "
465
+ *
466
+ * Both open with a clause keyword and neither continues into anything a
467
+ * database could parse. SQL's VALUES is always followed by a parenthesis, and
468
+ * a WHERE clause continued by AND or OR always reaches a comparison. Asking
469
+ * for the second half of the clause keeps ` AND id = ` and ` VALUES (` - the
470
+ * shapes the rule was written for - and drops the English.
471
+ */
472
+ /^\s*values\s*\(/i,
473
+ /^\s*(?:and|or)\b[\s\S]{0,80}?(?:[=<>]|\b(?:like|ilike|in|is|between)\b)/i,
474
+ ];
475
+
476
+ /**
477
+ * A full stop followed by a capital letter, or a comma followed by a
478
+ * conjunction, means the window crossed a sentence boundary - so the verb and
479
+ * the clause came from different thoughts, not from one statement.
480
+ * SQL has no sentences; prose has little else.
481
+ */
482
+ const SENTENCE_BREAK = /[.!?]\s+[A-Z(]|[,;:]\s+(?:and|or|but|so|which|because|then)\b/;
483
+
484
+ /**
485
+ * SQL needs the SHAPE of a statement, not a scattering of keywords.
486
+ * Conservative on purpose: a fragment we cannot recognise is reported as
487
+ * "not SQL", which loses a finding rather than inventing one.
488
+ */
489
+ export function looksLikeSql(text: string): boolean {
490
+ if (text.length < 8) return false;
491
+ for (const shape of SQL_STATEMENT_SHAPES) {
492
+ const match = shape.exec(text);
493
+ if (!match) continue;
494
+ if (SENTENCE_BREAK.test(match[0])) continue;
495
+ return true;
496
+ }
497
+ return false;
498
+ }
499
+
500
+ export function looksLikeHtml(text: string): boolean {
501
+ return /<\s*[a-zA-Z][a-zA-Z0-9-]*(\s|>|\/)/.test(text) || /&(lt|gt|amp|quot);/.test(text);
502
+ }
503
+
504
+ /**
505
+ * Shannon entropy: a number for "how random does this look?".
506
+ * Roughly - English prose scores ~2.5-3.5 bits/char, a base64 API key ~4.5-6.
507
+ * It is a hint, never a verdict, and we say so in the finding.
508
+ */
509
+ export function shannonEntropy(text: string): number {
510
+ if (text.length === 0) return 0;
511
+ const counts = new Map<string, number>();
512
+ for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1);
513
+ let entropy = 0;
514
+ for (const count of counts.values()) {
515
+ const p = count / text.length;
516
+ entropy -= p * Math.log2(p);
517
+ }
518
+ return entropy;
519
+ }
520
+
521
+ /** Strings that are obviously not real credentials. Keeps the noise down. */
522
+ const PLACEHOLDER_PATTERNS: readonly RegExp[] = [
523
+ /^$/,
524
+ /^(x{3,}|\*{3,}|\.{3,}|-{3,}|_{3,})$/i,
525
+ /^(none|null|nil|undefined|true|false|test|todo|fixme|example|sample|dummy|placeholder|changeme|change_me|your[-_ ]?\w*|my[-_ ]?\w*|insert[-_ ]?\w*|redacted|secret|password|passwd|pwd|key|token|value|string|foo|bar|baz|abc123?|123456?)$/i,
526
+ /^\$\{?[\w.]+\}?$/, // ${ENV_VAR} or $VAR - a reference, not a value
527
+ /^%[\ws]$/, // printf placeholder
528
+ /^\{\{?[\w.]+\}?\}$/, // {{ template }}
529
+ /^<[^>]+>$/, // <your-key-here>
530
+ /^(process\.env|os\.environ|System\.getenv|os\.Getenv)/,
531
+ // Values that announce themselves as not-real anywhere inside the string.
532
+ // Added after scanning real repositories, where `'faketoken'`, `'Bearer ...'`
533
+ // and `'s00pers3cret'`-style fixtures dominated the results.
534
+ /(fake|dummy|sample|example|placeholder|redacted|changeme|notreal|xxxx|\.\.\.)/i,
535
+ /*
536
+ * THE VALUE NAMES THE KIND OF CREDENTIAL IT IS STANDING IN FOR.
537
+ *
538
+ * accessToken: 'exact-token' 'wrong-token' 'notion-token'
539
+ * clientSecret: 'notion-client-secret'
540
+ * apiKey: 'new-api-key' connectionToken: 'test-token'
541
+ *
542
+ * Ninety-six of the 191 hardcoded-secret findings on a VS Code scan were this
543
+ * shape, all from test files. A real credential does not contain the word
544
+ * "secret" - the whole point of one is that it carries no information about
545
+ * itself. A lowercase hyphenated phrase with `token`, `secret` or `key` as one
546
+ * of its words is a label a person wrote so a failing assertion would be
547
+ * readable.
548
+ *
549
+ * DELIBERATELY NARROW. An earlier attempt rejected short lowercase words
550
+ * outright and had to be reverted: it took `password = 'configpass'` and
551
+ * `SECRET_KEY = 'config'` with it, and those are weak credentials but they
552
+ * are credentials. Neither has a separator or names itself, so neither
553
+ * matches this. A passphrase like `correct-horse-battery-staple` does not
554
+ * match either - it has the separators but names nothing.
555
+ */
556
+ /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*[-_](token|secret|key|password|passwd|pwd|auth|credential|creds?|apikey|pat|jwt|salt|nonce|otp)$/i,
557
+ /^(token|secret|key|password|passwd|pwd|auth|credential|creds?|apikey|pat|jwt|salt|nonce|otp)[-_][a-z0-9]+(?:[-_][a-z0-9]+)*$/i,
558
+ ];
559
+
560
+ /*
561
+ * Values that are SETTINGS, not secrets.
562
+ *
563
+ * `credentials` matches the credential naming pattern, so this line
564
+ *
565
+ * fetch(url, { credentials: "same-origin" })
566
+ *
567
+ * read as a hardcoded credential - twice, inside the vendored tree-sitter
568
+ * runtime, on a self-scan. It is a fixed enum from the fetch specification and
569
+ * there is no possible value of it that is a secret.
570
+ *
571
+ * The whole set is listed rather than just the three `credentials` values,
572
+ * because the neighbouring RequestInit fields (`mode`, `cache`, `redirect`)
573
+ * are one rename away from the same problem. These are exact, whole-string
574
+ * matches against a closed vocabulary defined by the web platform - not a
575
+ * guess about what looks secret-ish.
576
+ */
577
+ const WEB_PLATFORM_ENUMS: ReadonlySet<string> = new Set([
578
+ // RequestInit.credentials
579
+ 'same-origin',
580
+ 'include',
581
+ 'omit',
582
+ // RequestInit.mode
583
+ 'cors',
584
+ 'no-cors',
585
+ 'navigate',
586
+ // RequestInit.cache
587
+ 'default',
588
+ 'no-store',
589
+ 'reload',
590
+ 'no-cache',
591
+ 'force-cache',
592
+ 'only-if-cached',
593
+ // RequestInit.redirect
594
+ 'follow',
595
+ 'manual',
596
+ 'error',
597
+ ]);
598
+
599
+ /**
600
+ * Is this value STRUCTURALLY incapable of being a credential, whatever the
601
+ * variable holding it is called?
602
+ *
603
+ * The name-based signal ("the variable is called `token`") never asked this,
604
+ * and VS Code's editor package showed why: 151 of its 163 high-severity secret
605
+ * findings were Monaco's syntax-highlighting tokens.
606
+ *
607
+ * token: 'delimiter.curly'
608
+ * token: 'variable.predefined'
609
+ * token: 'comment'
610
+ *
611
+ * The variable really is called `token`. The value is a theme scope name. No
612
+ * amount of suspicion about the NAME should survive looking at the VALUE.
613
+ *
614
+ * Two shapes, both decidable:
615
+ * - a dotted namespace (`delimiter.curly`, `com.example.thing`) - no key
616
+ * format contains dots, except a JWT, which starts `eyJ`;
617
+ * - a single lowercase dictionary-shaped word (`comment`, `invalid`) - real
618
+ * credentials mix character classes, because they are generated.
619
+ */
620
+ export function isStructuralIdentifier(value: string): boolean {
621
+ const trimmed = value.trim();
622
+ if (trimmed.length === 0) return false;
623
+ // The trailing dot is not a typo. A dotted name used as a STORAGE PREFIX is
624
+ // written with the separator still attached, because the caller appends to it:
625
+ // SECRET_KEY_PREFIX = 'chat.lm.secret.'
626
+ // STORAGE_PREFIX = 'chat.modelFeedbackSurvey.'
627
+ // Both were reported as credentials on a VS Code scan, the first at HIGH
628
+ // because its name contains SECRET and KEY. It is a namespace, and the value
629
+ // in it is whatever gets appended - which is not in the file.
630
+ if (/^[\w$-]+(?:\.[\w$-]+)+\.?$/.test(trimmed) && !/^eyJ/.test(trimmed)) return true;
631
+ /*
632
+ * A value carrying a URI SCHEME is an address or a standard identifier, not
633
+ * a key. VS Code's OAuth constants are the clean example:
634
+ *
635
+ * TOKEN_TYPE_ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token'
636
+ * token_endpoint = 'http://localhost:8080/token'
637
+ *
638
+ * Every one of those variable names screams credential, and every value is
639
+ * published in an RFC. The name cannot be trusted over the shape.
640
+ */
641
+ if (/^(urn|https?|ftp|file|ws{1,2}|mailto|data|vscode[\w-]*):/i.test(trimmed)) return true;
642
+ /*
643
+ * A COLON-NAMESPACED PERMISSION IS A SCOPE, NOT A CREDENTIAL.
644
+ *
645
+ * AccessTokenScopeReadAdmin AccessTokenScope = "read:admin"
646
+ * AccessTokenScopeWriteRepoHook = "write:repo_hook"
647
+ *
648
+ * 106 of gitea's 359 findings were high-severity "hardcoded secret" and this
649
+ * block is most of them. The constant NAME contains "Token", which is exactly
650
+ * what the name signal is for - but the value is an OAuth scope printed in
651
+ * gitea's own public API documentation.
652
+ *
653
+ * LETTERS ONLY, deliberately. `admin:password123` keeps its digits and still
654
+ * reports, and so does anything with mixed case; generated credentials
655
+ * essentially always carry one or the other. What is left - a lowercase
656
+ * colon-namespaced phrase - is how every permission system in the world
657
+ * spells a scope. A bare `user:pass` is lost to this, which is a placeholder
658
+ * anyway, and a real one inside a URL is caught earlier by the
659
+ * connection-string format.
660
+ */
661
+ if (/^[a-z]+(?:[_-][a-z]+)*(?::[a-z]+(?:[_-][a-z]+)*)+$/.test(trimmed)) return true;
662
+ /*
663
+ * A Subresource Integrity hash is published in the HTML that loads the
664
+ * script. It is the opposite of a secret - its whole job is to be public so
665
+ * a browser can check the file was not tampered with.
666
+ *
667
+ * webWorkerExtensionHostIframeScriptSHA = 'sha256-daEgfo2VIXpx2Np71Kq...'
668
+ */
669
+ if (/^sha(256|384|512)-/.test(trimmed)) return true;
670
+ /*
671
+ * A SCREAMING_SNAKE value is a constant or an environment VARIABLE NAME, not
672
+ * its value. VS Code names the variable after what it holds and gets caught
673
+ * by both halves:
674
+ *
675
+ * agentHostBridgeConnectionTokenEnvironmentVariable =
676
+ * 'VSCODE_AGENT_HOST_BRIDGE_CONNECTION_TOKEN'
677
+ */
678
+ if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(trimmed)) return true;
679
+ /*
680
+ * A single lowercase word is NOT on this list, and the first version of it
681
+ * was. `token: 'comment'` and `token: 'invalid'` in Monaco are exactly that
682
+ * shape - but so are
683
+ *
684
+ * password: 'configpass'
685
+ * password: 'exfil'
686
+ * SECRET_KEY = 'config'
687
+ *
688
+ * and rejecting the shape took the library corpus from 48 findings to 32.
689
+ * Weak human-chosen passwords ARE lowercase words; that is what makes them
690
+ * weak. Trading sixteen real hardcoded credentials for a dozen theme scope
691
+ * names is the wrong direction, so the dotted check stands alone and the
692
+ * remaining Monaco tokens are accepted noise.
693
+ */
694
+ return false;
695
+ }
696
+
697
+ /**
698
+ * Does this spliced-in expression produce nothing but SQL PLACEHOLDERS?
699
+ *
700
+ * VS Code builds a bulk insert the correct way:
701
+ *
702
+ * `INSERT INTO ItemTable VALUES ${new Array(n).fill('(?,?)').join(',')} ...`
703
+ * stmt.run(keysValuesChunk)
704
+ *
705
+ * The interpolation expands to `(?,?),(?,?),(?,?)`. Not one byte of data is in
706
+ * that string - the values go to the driver separately, which is exactly what
707
+ * parameterisation means. And the rule reported it as SQL injection.
708
+ *
709
+ * That is the worst mistake an injection rule can make. Flagging the CORRECT
710
+ * form teaches people that doing it properly does not help, and this is the
711
+ * third time today the same failure has turned up in a different rule -
712
+ * unserialize with allowed_classes, yaml with a SafeLoader, and now this.
713
+ *
714
+ * The test is narrow on purpose. Every string literal inside the expression
715
+ * must be placeholder punctuation, AND at least one must contain an actual
716
+ * placeholder marker (`?`, `$1`, `%s`). That second half is what stops
717
+ * `rows.map(r => `'${r}'`).join(',')` - which builds real quoted DATA and is a
718
+ * genuine injection - from slipping through on its commas.
719
+ */
720
+ export function expandsOnlyPlaceholders(expressionText: string): boolean {
721
+ const literals = [...expressionText.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)].map(
722
+ (m) => m[2] ?? '',
723
+ );
724
+ if (literals.length === 0) return false;
725
+ const hasMarker = literals.some((lit) => /\?|\$\d|%s/.test(lit));
726
+ if (!hasMarker) return false;
727
+ return literals.every((lit) => /^[\s?,()$%sd\d]*$/.test(lit));
728
+ }
729
+
730
+ export function isLikelyPlaceholder(text: string): boolean {
731
+ const trimmed = text.trim();
732
+ if (WEB_PLATFORM_ENUMS.has(trimmed.toLowerCase())) return true;
733
+ return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(trimmed));
734
+ }
735
+
736
+ /**
737
+ * "Could this string plausibly BE a credential?" - a structural gate we apply
738
+ * before the entropy heuristic.
739
+ *
740
+ * This function exists because of measurement, not theory. Running the entropy
741
+ * check alone against five real open-source repositories produced 80 findings,
742
+ * and the top offenders were things like:
743
+ *
744
+ * 'application/x-www-form-urlencoded' a MIME type
745
+ * 'abcdefghijklmnopqrstuvwxyz' a literal alphabet
746
+ * 'https://images.opencollective.com/…' a URL
747
+ *
748
+ * All three score high on randomness and none of them is a secret. Real tokens
749
+ * share a shape: no spaces, no slashes, and a MIX of character classes. Testing
750
+ * for that shape first removes the whole category of noise above without
751
+ * weakening detection of anything that actually looks like a key.
752
+ */
753
+ export function looksLikeToken(value: string): boolean {
754
+ if (/[\s/\\@]/.test(value)) return false; // URLs, paths, MIME types, sentences
755
+ /*
756
+ * STRUCTURAL PUNCTUATION IS NOT IN ANY CREDENTIAL ALPHABET.
757
+ *
758
+ * Every real key format draws from a narrow set of characters: base64 uses
759
+ * A-Za-z0-9+/=, hex uses 0-9a-f, and vendor keys (AKIA..., ghp_..., sk_live_)
760
+ * use A-Za-z0-9_-. None of them contains a brace, a bracket, a quote, a
761
+ * colon or a semicolon.
762
+ *
763
+ * Auditing this tool's findings across five real libraries found NINE
764
+ * entropy-only false positives and every one was killed by this single line:
765
+ *
766
+ * json = "{field1:'abc',field2:'def'}" a JSON test fixture
767
+ * systemProperty = "gson.allowCapturing..." a property name
768
+ * dataURI = "data:;charset=UTF-8,hello" a data URI
769
+ *
770
+ * Entropy alone cannot tell a key from a small structured document - both
771
+ * score above 4 bits per character. Shape can, and shape is not a guess.
772
+ */
773
+ if (/[{}[\]<>(),;:'"`|!?*&^%$#~]/.test(value)) return false;
774
+ if (/^[a-z.\-_]+$/.test(value)) return false; // lowercase words / kebab / dotted names
775
+ if (/^[A-Z.\-_]+$/.test(value)) return false; // SCREAMING_CONSTANT names
776
+ if (/^\d+$/.test(value)) return false; // pure numbers, timestamps, ids
777
+ /*
778
+ * NAMESPACED IDENTIFIERS ARE NOT KEYS.
779
+ *
780
+ * workbench.action.focusCommentsPanel
781
+ * workbench.contrib.viewsExtensionHandler
782
+ * editor.action.formatDocument
783
+ *
784
+ * Scanning VS Code produced 1,369 hardcoded-secret findings and 1,246 were
785
+ * entropy-only hits on strings exactly like these - command ids, context
786
+ * keys, extension ids. They score above 4 bits per character because they mix
787
+ * case and use many distinct letters, which is precisely what the entropy
788
+ * heuristic was built to notice, and precisely why entropy alone is not
789
+ * evidence of anything.
790
+ *
791
+ * A dotted name is the single most common long string in a large application,
792
+ * and no credential format is shaped like one. The exception is a JWT, which
793
+ * is also dot-separated - so a value that opens with the base64 of `{"` is
794
+ * kept. That is the standard JWT header prefix and nothing else starts that
795
+ * way by accident.
796
+ */
797
+ if (/^[\w$-]+(?:\.[\w$-]+)+$/.test(value) && !/^eyJ/.test(value)) return false;
798
+ // The alphabet itself, and any run of consecutive letters. `letterBytes =
799
+ // "abcdefghijklmnopqrstuvwxyzABCDEF..."` in gin scores 4.7 bits/char and is
800
+ // the least random string it is possible to write.
801
+ if (/abcdefghij/i.test(value)) return false;
802
+ /*
803
+ * A CAMELCASE RUN OF PURE LETTERS IS AN IDENTIFIER, NOT A KEY.
804
+ *
805
+ * enablePreviewFromQuickOpen a settings key
806
+ * ChatToolsEligibleForAutoApproval an experiment name
807
+ * didNotEnableEditSessionsWhenPrompted a telemetry outcome
808
+ * historyItemChangeViewModel a view-model type tag
809
+ *
810
+ * The dotted-name rule above catches `workbench.action.focus...`; these are
811
+ * the same thing with the dots removed, and they were the next 35 findings on
812
+ * a VS Code scan once the dotted ones were gone. They score above 4 bits per
813
+ * character for the same reason - many distinct letters, mixed case - which
814
+ * is again a fact about English identifiers rather than about randomness.
815
+ *
816
+ * The discriminator is what is ABSENT. Every generated credential draws on
817
+ * digits or punctuation somewhere: base64 has digits and +/=, hex is digits
818
+ * and a-f, vendor keys carry a prefix and an underscore. Twenty-nine letters
819
+ * and nothing else is a name a person typed.
820
+ *
821
+ * This gate is on the ENTROPY path only, which matters: `password =
822
+ * 'MySecretPassword'` has this exact shape and is a genuine bad credential,
823
+ * but it is caught by the variable NAME (Signal 2), which never consults this
824
+ * function. Weak alphabetic passwords therefore still report.
825
+ */
826
+ if (value.length >= 12 && /^[A-Za-z]+$/.test(value)) {
827
+ const humps = value.match(/[a-z][A-Z]/g)?.length ?? 0;
828
+ if (humps >= 2) return false;
829
+ }
830
+
831
+ const classes =
832
+ (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0);
833
+ return classes >= 2;
834
+ }
835
+
836
+ /**
837
+ * Paths that mean "this is a fixture, not production configuration".
838
+ * A credential in a test file is still a credential in git history, so we do
839
+ * not drop these findings - we lower their severity and say why.
840
+ */
841
+ export function isTestPath(filePath: string): boolean {
842
+ return TEST_DIRECTORY.test(filePath) || TEST_FILENAME.test(filePath);
843
+ }
844
+
845
+ /*
846
+ * TEST-PATH CONVENTIONS ARE PER-ECOSYSTEM, and this list was built from the
847
+ * repositories I had happened to be shown.
848
+ *
849
+ * Scanning cal.com - 3,941 TypeScript files - produced 196 findings, 138 of
850
+ * them hardcoded-secret. The ones still ranked HIGH were led by
851
+ *
852
+ * apps/web/playwright/payment-apps.e2e.ts access_token: "sk_test_randomString"
853
+ * packages/testing/src/lib/bookingScenario/bookingScenario.ts
854
+ *
855
+ * Both are unambiguously test code, and this function said neither was. The
856
+ * reasons were boring and specific: `e2e` was recognised only as a whole
857
+ * DIRECTORY segment, never as the `.e2e.ts` filename suffix that Playwright
858
+ * actually uses; and `tests?` matches `test` and `tests` but not `testing`.
859
+ *
860
+ * That is the third time this exact class has cost real precision. Scanning
861
+ * elasticsearch proved SOURCES are per-framework rather than per-language;
862
+ * scanning Jenkins proved ESCAPERS are too. The lesson generalises past both:
863
+ * ANY list of conventions in this engine is a list of the conventions I have
864
+ * been shown, and it looks complete right up until a codebase with different
865
+ * habits arrives.
866
+ *
867
+ * WHY THIS IS TWO NARROW PATTERNS AND NOT ONE WIDE ONE. Downranking is a
868
+ * silencing mechanism, so the failure mode is a real credential in application
869
+ * source ranked as test noise. `latest.ts`, `contest/`, `testimonials.tsx`,
870
+ * `protester.js` and `attestation/` all contain the letters "test". Every one
871
+ * of them is asserted as NOT a test path in the suite, which is what stops a
872
+ * future widening from quietly eating them.
873
+ */
874
+
875
+ /** A whole path SEGMENT that names a test area. Anchored both sides. */
876
+ const TEST_DIRECTORY =
877
+ /(^|[/\\])(tests?|testing|spec|specs|__tests__|__mocks__|fixtures?|examples?|e2e|benchmarks?|mocks?|playwright|cypress|testdata)([/\\]|$)/i;
878
+
879
+ /**
880
+ * A FILENAME that names itself a test. Kept separate from the directory rule
881
+ * because these are suffixes, not segments - `login.e2e.ts` has no `e2e`
882
+ * segment anywhere in it, which is exactly what cal.com exposed.
883
+ */
884
+ const TEST_FILENAME =
885
+ /\.(test|spec|e2e|cy|e2e-spec|int-spec)\.[a-z]+$|[._-]test\.[a-z]+$|Tests?\.(java|cs|kt|scala)$/i;
886
+
887
+
888
+ /**
889
+ * Is this value just a restatement of the NAME it is stored under?
890
+ *
891
+ * PRIVATE_KEY_KEY = "privateKey" Keycloak, 4,000 Java files
892
+ * KEYSTORE_PASSWORD_KEY = "keystorePassword"
893
+ * MANAGE_OAUTH = "manage_oauth" Mattermost, TypeScript
894
+ * TOKEN_CREATING = "creating"
895
+ * SECTION_TOKENS = "tokens"
896
+ *
897
+ * These are the strings a program uses to LOOK UP a secret in a config map, and
898
+ * they are dense in exactly the codebases that handle credentials for a living
899
+ * - which is why an identity server produced 152 findings and most were this.
900
+ *
901
+ * The test is a subset check on words, which is what makes it safe: a real
902
+ * credential shares no vocabulary with its own variable name. `password =
903
+ * "Tr0ub4dor3xK"`, `SECRET_KEY = "config"` and `password = "configpass"` all
904
+ * survive it, and all three are asserted elsewhere in the suite.
905
+ *
906
+ * Requires at least one word on each side, so an empty or punctuation-only
907
+ * value cannot vacuously qualify.
908
+ */
909
+ export function echoesItsOwnName(name: string, value: string): boolean {
910
+ const words = (text: string): string[] =>
911
+ text
912
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
913
+ .split(/[^A-Za-z0-9]+/)
914
+ .map((w) => w.toLowerCase())
915
+ .filter((w) => w.length > 1);
916
+ const nameWords = new Set(words(name));
917
+ const valueWords = words(value);
918
+ if (nameWords.size === 0 || valueWords.length === 0) return false;
919
+ return valueWords.every((w) => nameWords.has(w));
920
+ }
921
+
922
+ /**
923
+ * Does this value read as an English SENTENCE rather than as a credential?
924
+ *
925
+ * encryption_key_missing: "CALENDSO_ENCRYPTION_KEY is not set"
926
+ *
927
+ * From cal.com's OAuthService. The NAME matches the credential pattern - it
928
+ * contains `key` - and a name-based guess is allowed to be wrong. What it is
929
+ * not allowed to do is ignore a value that could not possibly be a credential.
930
+ *
931
+ * THE TRAP THIS IS SHAPED AROUND: passphrases contain spaces and are real
932
+ * secrets. `password = "correct horse battery staple"` must still report.
933
+ *
934
+ * THE FIRST VERSION OF THIS FUNCTION WAS NOT SAFE, and the corpus caught it -
935
+ * for the third time in this project's life, after the lowercase-word rejection
936
+ * that dropped the corpus 48 to 32 and the mathjs.eval regression that showed
937
+ * up only as dvna's flow count falling 3 to 2.
938
+ *
939
+ * Requiring "several words plus an English function word" looked airtight
940
+ * against a DICEWARE passphrase, whose wordlist is concrete nouns and verbs.
941
+ * It is not airtight against a HUMAN-CHOSEN one. Express ships
942
+ *
943
+ * secret: 'manny is cool' examples/cookie-sessions/index.js
944
+ *
945
+ * a genuine session secret, silenced by the word `is`. One good suppression
946
+ * (dvna's `'A2: Broken Authentication'` label) and one real miss.
947
+ *
948
+ * So the value's shape is no longer the whole test. The NAME has to say the
949
+ * slot holds a message - `encryption_key_missing`, `api_key_invalid` - which is
950
+ * what cal.com's error table actually looks like and what `secret` never does.
951
+ * Both halves are asserted: the express string is now a fixture that must fire.
952
+ */
953
+ const PROSE_WORDS = new Set([
954
+ 'is', 'are', 'was', 'were', 'be', 'been', 'not', 'no', 'the', 'a', 'an',
955
+ 'this', 'that', 'these', 'those', 'your', 'you', 'must', 'should',
956
+ 'cannot', 'could', 'would', 'please', 'try', 'again', 'invalid', 'missing',
957
+ 'expired', 'required', 'failed', 'error', 'unable', 'least', 'provided',
958
+ 'for', 'to', 'of', 'in', 'at', 'with', 'and', 'or', 'but', 'if', 'set',
959
+ ]);
960
+
961
+ export function looksLikeProse(name: string, value: string): boolean {
962
+ // The NAME has to say this slot holds a message. Prose-ness alone is not
963
+ // enough - see the express regression in the note above.
964
+ if (!MESSAGE_NAME.test(name)) return false;
965
+ const words = value.trim().split(/\s+/);
966
+ if (words.length < 3) return false;
967
+ const lowered = words.map((w) => w.replace(/[^A-Za-z]/g, '').toLowerCase());
968
+ return lowered.some((w) => PROSE_WORDS.has(w));
969
+ }
970
+
971
+ /**
972
+ * Names that describe a STATE or a MESSAGE rather than a stored credential.
973
+ * `encryption_key_missing` is not a key; it is what you print when the key is
974
+ * absent. The word that matters is the suffix.
975
+ */
976
+ const MESSAGE_NAME =
977
+ /(_|\b)(missing|invalid|expired|required|failed|error|errors|message|messages|msg|description|hint|warning|notice|reason|too_short|too_long|not_found|unauthorized|denied)$/i;
978
+
979
+ /** Well-known credential formats. A hit here is far stronger than entropy alone. */
980
+ export interface KnownSecretFormat {
981
+ readonly label: string;
982
+ readonly pattern: RegExp;
983
+ }
984
+
985
+ export const KNOWN_SECRET_FORMATS: readonly KnownSecretFormat[] = [
986
+ { label: 'AWS access key id', pattern: /\b(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/ },
987
+ { label: 'GitHub token', pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}\b/ },
988
+ { label: 'Slack token', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
989
+ { label: 'Stripe secret key', pattern: /\b(sk|rk)_(live|test)_[A-Za-z0-9]{16,}\b/ },
990
+ { label: 'Google API key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
991
+ { label: 'OpenAI API key', pattern: /\bsk-[A-Za-z0-9]{20,}\b/ },
992
+ /*
993
+ * OpenAI's CURRENT key format, which the line above cannot match.
994
+ *
995
+ * The legacy shape was `sk-` followed by one unbroken run of alphanumerics.
996
+ * Project and service-account keys put named segments in between -
997
+ * `sk-proj-...`, `sk-svcacct-...` - and the hyphen ends the `[A-Za-z0-9]{20,}`
998
+ * run, so a live project key scored only "high-entropy literal, possible
999
+ * secret" at medium severity instead of being identified outright.
1000
+ *
1001
+ * Found by checking that a placeholder this rule had just started ignoring
1002
+ * (`apiKey: 'sk-secret-internal-key'`) had not taken a real format with it.
1003
+ * It had not - it had exposed one that was already missing. The named segment
1004
+ * is required precisely so that the placeholder stays ignored: "secret" is
1005
+ * not "proj".
1006
+ */
1007
+ { label: 'OpenAI project key', pattern: /\bsk-(proj|svcacct|admin)-[A-Za-z0-9_-]{20,}\b/ },
1008
+ { label: 'JSON Web Token', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
1009
+ {
1010
+ label: 'private key block',
1011
+ /*
1012
+ * The delimiter has to be followed by KEY MATERIAL. Keycloak defines
1013
+ * `BEGIN_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----"` as a constant so it
1014
+ * can assemble and parse PEM files; the string IS the delimiter and there
1015
+ * is no key in it. Requiring base64 after the header keeps every real
1016
+ * embedded key and drops the label on its own.
1017
+ */
1018
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?[A-Za-z0-9+/]{40}/,
1019
+ },
1020
+ { label: 'Twilio account SID', pattern: /\bAC[0-9a-fA-F]{32}\b/ },
1021
+ { label: 'SendGrid API key', pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/ },
1022
+ { label: 'connection string with password', pattern: /:\/\/[^\s:@/]+:[^\s:@/]{3,}@/ },
1023
+ ];
1024
+
1025
+ /* ---------------------------------------------------------------------------
1026
+ * VENDOR DOCUMENTATION EXAMPLES
1027
+ *
1028
+ * `AKIAIOSFODNN7EXAMPLE` is not an AWS access key. It is the string AWS
1029
+ * itself prints in its own documentation, chosen so that it matches the key
1030
+ * FORMAT while being guaranteed never to authenticate anything. It appears in
1031
+ * README files, tutorials and UI demos everywhere, and the scanner found one
1032
+ * in this project's own demo panel and called it a hardcoded credential.
1033
+ *
1034
+ * That finding was not merely noisy, it was FALSE: nothing was hardcoded and
1035
+ * nothing needs rotating. So this is not a severity downgrade like the
1036
+ * test-path rule - the claim itself does not hold, and the finding is dropped.
1037
+ *
1038
+ * The allowlist is deliberately narrow. Vendors mark their examples by ending
1039
+ * them in EXAMPLE (AWS's convention, used consistently across its docs) or by
1040
+ * filling the random portion with an obvious run of digits. A real key landing
1041
+ * on one of these by chance is a 1-in-10^12 event; a real key that a developer
1042
+ * has deliberately named EXAMPLE is not a case this tool can help with.
1043
+ *
1044
+ * Anything dropped here is listed in the rule's `limitations` text, so the gap
1045
+ * is documented rather than silent.
1046
+ * ------------------------------------------------------------------------ */
1047
+ const VENDOR_DOC_EXAMPLES: readonly RegExp[] = [
1048
+ /EXAMPLE(KEY|SECRET)?$/, // AWS: AKIAIOSFODNN7EXAMPLE, ...bPxRfiCYEXAMPLEKEY
1049
+ /\b(AKIA|ASIA|ABIA|ACCA)(?:0{6,}|1234567890|X{6,})/i, // filler-digit stand-ins
1050
+ /^(?:sk|rk|pk)_(?:live|test)_(?:example|1234567890|abcdef|0000)/i,
1051
+ /\bAIza(?:SyExample|0{10,})/i,
1052
+ /\bxox[abprs]-(?:example|0000|1234567890)/i,
1053
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----\s*(?:\.\.\.|EXAMPLE|YOUR)/i,
1054
+ ];
1055
+
1056
+ /**
1057
+ * True when a value matches a vendor key format but is a published,
1058
+ * deliberately non-functional documentation example rather than a credential.
1059
+ *
1060
+ * Pass the MATCHED KEY, not the string it was found in. The first version of
1061
+ * this tested the whole enclosing string, so a demo code sample containing
1062
+ * `AKIAIOSFODNN7EXAMPLE` in the middle of two hundred other characters never
1063
+ * matched the `...EXAMPLE$` anchor and was still reported as a live key. The
1064
+ * question is about the credential, so it has to be asked of the credential.
1065
+ */
1066
+ export function isVendorDocExample(matchedKey: string): boolean {
1067
+ return VENDOR_DOC_EXAMPLES.some((pattern) => pattern.test(matchedKey));
1068
+ }
1069
+
1070
+ export function matchKnownSecret(text: string): KnownSecretFormat | null {
1071
+ for (const format of KNOWN_SECRET_FORMATS) {
1072
+ const match = format.pattern.exec(text);
1073
+ if (!match) continue;
1074
+ if (isVendorDocExample(match[0])) continue;
1075
+ return format;
1076
+ }
1077
+ return null;
1078
+ }
1079
+
1080
+ /* -------------------------------------------------------------------------- *
1081
+ * PROVABLY-CONSTANT INTERPOLATION
1082
+ *
1083
+ * A tester handed the scanner this, expecting silence:
1084
+ *
1085
+ * def get_admin_table():
1086
+ * table_name = "admin_users" # hardcoded, no external input
1087
+ * query = f"SELECT * FROM {table_name}"
1088
+ * return conn.execute(query)
1089
+ *
1090
+ * It flagged the f-string. The label was correct - `signature-based`, data flow
1091
+ * NOT traced - but the finding was still noise: the only spliced-in value is a
1092
+ * string literal written on the line above. A scanner that reports that teaches
1093
+ * people to skim past it, and this project's own test suite treats a false
1094
+ * positive as a failure for exactly that reason.
1095
+ *
1096
+ * So: if EVERY spliced-in part is a plain name bound only to literal constants
1097
+ * in the enclosing scope, the string is not dynamic in any sense a reader cares
1098
+ * about, and the rule stays quiet.
1099
+ *
1100
+ * Deliberately conservative in three ways, because losing a real finding is a
1101
+ * worse trade than printing a weak one:
1102
+ * - a part that is not a bare identifier (a call, an index, an attribute)
1103
+ * is never treated as constant;
1104
+ * - a name with NO visible binding - a parameter, an import, a global from
1105
+ * another file - is never treated as constant;
1106
+ * - a name bound more than once is constant only if EVERY binding is a
1107
+ * literal, so `t = "x"` followed later by `t = request.args.get(...)`
1108
+ * still flags.
1109
+ * -------------------------------------------------------------------------- */
1110
+
1111
+ /** Nodes that introduce a scope worth searching for bindings. */
1112
+ const SCOPE_NODES = new Set([
1113
+ 'function_declaration', 'function_expression', 'arrow_function', 'method_definition',
1114
+ 'generator_function_declaration', 'generator_function', 'class_static_block',
1115
+ 'function_definition', 'lambda',
1116
+ 'method_declaration', 'constructor_declaration',
1117
+ 'func_literal', 'program', 'module', 'source_file', 'compilation_unit',
1118
+ ]);
1119
+
1120
+ /** Nodes that bind a name to a value, and the fields that hold each side. */
1121
+ const BINDING_NODES: Record<string, { name: string; value: string }> = {
1122
+ assignment: { name: 'left', value: 'right' }, // Python
1123
+ assignment_expression: { name: 'left', value: 'right' }, // JS/TS/Java
1124
+ variable_declarator: { name: 'name', value: 'value' }, // JS/TS/Java
1125
+ short_var_declaration: { name: 'left', value: 'right' }, // Go
1126
+ assignment_statement: { name: 'left', value: 'right' }, // Go
1127
+ };
1128
+
1129
+ /** A literal with no spliced-in parts: a genuinely fixed value. */
1130
+ function isPlainLiteral(node: Node | null, language: LanguageId): boolean {
1131
+ if (!node) return false;
1132
+ if (node.type === 'number' || node.type === 'integer' || node.type === 'float') return true;
1133
+ // Java's `null` is a fixed value too - `String command = null;` before the
1134
+ // branches that set it is how BenchmarkJava's command helper begins.
1135
+ if (language === 'java' && node.type === 'null_literal') return true;
1136
+ if (!STRING_LITERAL_TYPES[language].includes(node.type)) return false;
1137
+ return !containsType(node, INTERPOLATION_TYPES, 4);
1138
+ }
1139
+
1140
+ function enclosingScopes(node: Node): Node[] {
1141
+ const scopes: Node[] = [];
1142
+ let current: Node | null = node.parent;
1143
+ while (current) {
1144
+ if (SCOPE_NODES.has(current.type)) scopes.push(current);
1145
+ current = current.parent;
1146
+ }
1147
+ return scopes;
1148
+ }
1149
+
1150
+ /** Names bound by the parameter list of a function. Never constant. */
1151
+ function parameterNames(scope: Node): Set<string> {
1152
+ const names = new Set<string>();
1153
+ const walk = (n: Node | null): void => {
1154
+ if (!n) return;
1155
+ if (n.type === 'identifier') names.add(n.text ?? '');
1156
+ for (const child of n.namedChildren) walk(child ?? null);
1157
+ };
1158
+ /*
1159
+ * Not only `parameters`: a JavaScript arrow with one bare parameter keeps it
1160
+ * in `parameter` (tests/probes/s07), and a Go method's receiver and named
1161
+ * results are input too (g02).
1162
+ */
1163
+ for (const field of ['parameters', 'parameter', 'receiver', 'result']) walk(scope.childForFieldName(field));
1164
+ return names;
1165
+ }
1166
+
1167
+ /** Why a string built from pieces is still provably fixed - or null when it is not. */
1168
+ export interface ConstantProof {
1169
+ /**
1170
+ * True when the proof needed control-flow reasoning - a branch that cannot
1171
+ * run, a condition decided at compile time, a ternary whose every arm is a
1172
+ * literal - rather than "every binding is a plain literal". Only reasoned
1173
+ * proofs leave a receipt, because only reasoning can be wrong.
1174
+ */
1175
+ readonly reasoned: boolean;
1176
+ /** One sentence per name, written so a reader can check it against the code. */
1177
+ readonly reason: string;
1178
+ }
1179
+
1180
+ const TERNARY_NODES = new Set(['ternary_expression', 'conditional_expression']);
1181
+ /*
1182
+ * Go binds a NEW variable in two places the write survey does not collect:
1183
+ * `switch t := v.(type)` and `case t := <-ch`. Inside them, `t` is not the
1184
+ * one declared outside (tests/probes/g03, g06).
1185
+ */
1186
+ function goBindsOtherwise(root: Node, name: string): boolean {
1187
+ const word = new RegExp(`(^|[^\\w])${name}(?![\\w])`);
1188
+ for (const n of root.descendantsOfType(['type_switch_statement', 'receive_statement'])) {
1189
+ if (!n) continue;
1190
+ const bound = n.type === 'type_switch_statement' ? n.childForFieldName('alias') : n.childForFieldName('left');
1191
+ if (bound && word.test(bound.text ?? '')) return true;
1192
+ }
1193
+ return false;
1194
+ }
1195
+
1196
+ function rootOf(n: Node): Node {
1197
+ let root = n;
1198
+ while (root.parent) root = root.parent;
1199
+ return root;
1200
+ }
1201
+
1202
+ /** Nodes that declare a variable: Java and JavaScript declarators, Go's `var` and `:=`. */
1203
+ const DECLARATION_NODES = new Set(['variable_declarator', 'var_spec', 'short_var_declaration']);
1204
+
1205
+ /** Languages whose `+` joins two strings, and the node that holds it. */
1206
+ const CONCATENATING: ReadonlySet<LanguageId> = new Set(['java', 'javascript', 'typescript', 'python', 'go']);
1207
+ const CONCAT_NODES = new Set(['binary_expression', 'binary_operator']);
1208
+
1209
+
1210
+ /*
1211
+ * PROVABLY CONSTANT, NOW WITH THE FOLDER.
1212
+ *
1213
+ * This used to accept a name only if EVERY binding of it was a plain literal.
1214
+ * That is sound, and it missed the single most common false positive left in
1215
+ * BenchmarkJava - 65 of them:
1216
+ *
1217
+ * String bar;
1218
+ * int num = 86;
1219
+ * if ((7 * 42) - num > 200) bar = "This_should_always_happen";
1220
+ * else bar = param;
1221
+ * String sql = "SELECT ... PASSWORD='" + bar + "'";
1222
+ *
1223
+ * Three bindings. `String bar;` binds no value at all. `bar = param` sits in an
1224
+ * arm that cannot run, because 294 - 86 is 208. The only write that can reach
1225
+ * the string is a literal - and the rule could not see any of that, because the
1226
+ * only code that knew how to decide a condition was sealed inside the tracer.
1227
+ * src/taint/fold.ts is that same code, moved out and shared.
1228
+ *
1229
+ * What changed, exactly:
1230
+ * - a declaration that binds no value is not a write, and is skipped;
1231
+ * - a write inside an arm the folder proves cannot run is set aside;
1232
+ * - a ternary contributes the side that runs when its condition is decided,
1233
+ * and BOTH sides when it is not - so `flag ? "asc" : "desc"` is constant;
1234
+ * - there must still be at least one live write, and every live value must
1235
+ * be a plain literal. A parameter is still disqualifying, full stop.
1236
+ *
1237
+ * Every doubt resolves toward reporting. An undecidable condition keeps both
1238
+ * arms live; a value the folder cannot see into is not a literal; a name with
1239
+ * no live write is not provable. ConstantProofFence.java is the list of shapes
1240
+ * that look withdrawable and must not be withdrawn.
1241
+ */
1242
+ export interface ConstantProofOptions {
1243
+ /** RuleContext.staticMethod. Absent: no helper in another file is read. */
1244
+ readonly staticMethod?: StaticMethodLookup | undefined;
1245
+ /** RuleContext.javaTypeInScan. Absent: every supertype is assumed to be in the scan. */
1246
+ readonly javaTypeInScan?: ((simpleName: string) => boolean) | undefined;
1247
+ }
1248
+
1249
+ export function constantProof(
1250
+ node: Node,
1251
+ dynamicParts: readonly string[],
1252
+ language: LanguageId,
1253
+ options: ConstantProofOptions = {},
1254
+ ): ConstantProof | null {
1255
+ if (dynamicParts.length === 0) return null;
1256
+ // A comment the compiler ends where the parser does not - see
1257
+ // taint/visibility.ts. Such a file is one this proof cannot read.
1258
+ if (hidesCodeFromParser(node, language)) return null;
1259
+
1260
+ /*
1261
+ * A fresh folder per question AND PER FILE: a folder's caches are keyed by
1262
+ * source span, and a span is only meaningful inside one tree. Once a helper
1263
+ * in another file can be read, one folder for both would answer a question
1264
+ * about bytes 400-980 of Utils.java with what it learned about bytes
1265
+ * 400-980 of the caller.
1266
+ */
1267
+ const folders = new Map<unknown, Folder>();
1268
+ const folderOf = (n: Node): Folder => {
1269
+ let folder = folders.get(n.tree);
1270
+ if (!folder) {
1271
+ folder = createFolder(language);
1272
+ folders.set(n.tree, folder);
1273
+ }
1274
+ return folder;
1275
+ };
1276
+ const treeIds = new Map<unknown, number>();
1277
+ /** A key for a node that is unique across files, not just within one. */
1278
+ const spanKey = (n: Node): string => {
1279
+ let id = treeIds.get(n.tree);
1280
+ if (id === undefined) {
1281
+ id = treeIds.size;
1282
+ treeIds.set(n.tree, id);
1283
+ }
1284
+ return `${id}:${n.startIndex}:${n.endIndex}`;
1285
+ };
1286
+
1287
+ const lineOf = (n: Node): number => n.startPosition.row + 1;
1288
+ const contains = (outer: Node, inner: Node): boolean =>
1289
+ inner.startIndex >= outer.startIndex && inner.endIndex <= outer.endIndex;
1290
+ const sameSpan = (a: Node, b: Node): boolean =>
1291
+ a.startIndex === b.startIndex && a.endIndex === b.endIndex;
1292
+
1293
+ /*
1294
+ * The arm of an enclosing `if`, or the case of an enclosing switch, that
1295
+ * cannot run - if `n` sits in one. Returned with the sentence the receipt
1296
+ * uses, so the reader is told which decision made the write irrelevant.
1297
+ */
1298
+ const deadArmHolding = (n: Node, scope: Node, body: Node): { at: Node; why: string } | null => {
1299
+ let current: Node | null = n.parent;
1300
+ while (current && contains(scope, current)) {
1301
+ if (current.type === 'if_statement') {
1302
+ const dead = folderOf(current).deadBranchOf(current, body);
1303
+ if (dead && contains(dead, n)) {
1304
+ return { at: current, why: `is in a branch that cannot run (line ${lineOf(current)})` };
1305
+ }
1306
+ }
1307
+ if (SWITCH_NODES.has(current.type)) {
1308
+ const decision = folderOf(current).switchDecision(current, body);
1309
+ if (decision?.dead.some((group) => contains(group, n))) {
1310
+ return { at: current, why: `is in a case that cannot run: ${decision.note}` };
1311
+ }
1312
+ }
1313
+ if (sameSpan(current, scope)) break;
1314
+ current = current.parent;
1315
+ }
1316
+ return null;
1317
+ };
1318
+
1319
+ /** Every value an expression can take, as nodes - or null if that cannot be bounded. */
1320
+ const liveValues = (value: Node, body: Node, depth: number): { nodes: Node[]; note: string | null } | null => {
1321
+ let v: Node = value;
1322
+ /*
1323
+ * Go wraps EVERY assigned value in an expression_list, even a single one,
1324
+ * because `a, b := 1, 2` is legal. So `q := "x"` never looked like a literal
1325
+ * to this check - not after the folder arrived, and not before it either:
1326
+ * Go had no constant proof at all until constant-branch.go asked for one.
1327
+ * A one-element list is unwrapped like parentheses; a real multi-value
1328
+ * assignment is left alone, and reads as "not a literal", which is safe.
1329
+ */
1330
+ while (
1331
+ (v.type === 'parenthesized_expression' || v.type === 'expression_list') &&
1332
+ v.namedChildren.length === 1
1333
+ ) {
1334
+ v = v.namedChildren[0] ?? v;
1335
+ }
1336
+ if (!TERNARY_NODES.has(v.type) || depth > 4) return { nodes: [v], note: null };
1337
+ const parts = folderOf(v).ternaryParts(v);
1338
+ const truth = folderOf(v).conditionTruth(parts.condition, body);
1339
+ if (truth !== undefined) {
1340
+ const live = truth ? parts.consequence : parts.alternative;
1341
+ if (!live) return null;
1342
+ const inner = liveValues(live, body, depth + 1);
1343
+ return inner && {
1344
+ nodes: inner.nodes,
1345
+ note: `the condition on line ${lineOf(v)} is fixed at compile time, and the side that runs is a literal`,
1346
+ };
1347
+ }
1348
+ if (!parts.consequence || !parts.alternative) return null;
1349
+ const a = liveValues(parts.consequence, body, depth + 1);
1350
+ const b = liveValues(parts.alternative, body, depth + 1);
1351
+ if (!a || !b) return null;
1352
+ return { nodes: [...a.nodes, ...b.nodes], note: `both sides of the condition on line ${lineOf(v)} are literals` };
1353
+ };
1354
+
1355
+ /*
1356
+ * ONE LEVEL INTO A HELPER. See HelperConstantProof.java and
1357
+ * StaticHelperCommand.java for the fences.
1358
+ *
1359
+ * 46 of BenchmarkJava's surviving SQL false positives had their decoy inside
1360
+ * a helper: `String bar = new Test().doSomething(request, param)`. The tracer
1361
+ * follows that call; this proof stopped at it. It follows three shapes, each
1362
+ * chosen because the method that runs is not in doubt:
1363
+ *
1364
+ * `new X(...).m(...)`, X declared in this file, X declaring ONE method m;
1365
+ * `m(...)` in this class, when m is private, static or final;
1366
+ * `Utils.m(...)` or `a.b.Utils.m(...)` - a static method, which may live
1367
+ * in ANOTHER file; taint/project.ts, staticMethod(), says when that file
1368
+ * and that method are the only candidates.
1369
+ *
1370
+ * Every `return` in m must be provable by the same rules as everything else
1371
+ * here, with one addition: a parameter of m is as fixed as the argument this
1372
+ * call passes for it - when that argument is provably fixed itself, the
1373
+ * counts line up with no varargs, and m never writes to the parameter. That
1374
+ * is what makes `getOSCommandString("echo")` fixed: it wraps literals around
1375
+ * `append`, and `append` is "echo".
1376
+ *
1377
+ * Java only. A receiver that is a variable could be any subclass; an
1378
+ * overload depends on argument types this proof does not read; a helper
1379
+ * returning another helper is a second level, and there is no second level.
1380
+ * Other languages' helpers are not followed yet, and the SQL rule's
1381
+ * limitations say so rather than letting "constant" imply they are.
1382
+ */
1383
+ const bindings = new Map<string, Map<string, () => string[] | null>>();
1384
+
1385
+ /*
1386
+ * THE `new` A LOCAL CERTAINLY HOLDS, if it holds one.
1387
+ *
1388
+ * SeparateClassRequest scr = new SeparateClassRequest(request);
1389
+ * String param = scr.getTheValue("BenchmarkTest00930");
1390
+ *
1391
+ * A variable could hold any subclass - unless it is a local declared once,
1392
+ * in view of the call, with `= new X(...)`, and never written again anywhere
1393
+ * in the method (closures included). Then it holds that X and nothing else.
1394
+ * Not a parameter, not a field, not a name a lambda or a pattern also binds,
1395
+ * and not read from inside a class that could inherit a field of that name
1396
+ * (LocalObjectHelper.java has each).
1397
+ *
1398
+ * And the object must never leave the method's hands: every use of the name
1399
+ * is as the receiver of a call, `h.m(...)`. Passed as an argument, it can be
1400
+ * changed by whatever receives it - reflection rewrites a private field
1401
+ * (tests/probes/r3-b07) - and `h.f = ...` writes a field directly.
1402
+ */
1403
+ const heldCreation = (name: string, call: Node): Node | null => {
1404
+ if (language !== 'java' || !name) return null;
1405
+ const fn = enclosingScopes(call)[0];
1406
+ if (!fn || parameterNames(fn).has(name) || boundElsewhere(rootOf(call), name)) return null;
1407
+ const declarators = fn
1408
+ .descendantsOfType('variable_declarator')
1409
+ .filter((d): d is Node => !!d && d.childForFieldName('name')?.text === name);
1410
+ const declarator = declarators.length === 1 ? declarators[0] : undefined;
1411
+ const value = declarator?.childForFieldName('value');
1412
+ if (!declarator || value?.type !== 'object_creation_expression') return null;
1413
+ if (declarator.parent?.type !== 'local_variable_declaration') return null;
1414
+ const visible = declarationVisibility(declarator, language);
1415
+ if (!visible || !contains(visible.region, call) || call.startIndex < visible.from) return null;
1416
+ const f = folderOf(fn);
1417
+ if (f.writtenOtherwise(name, fn) || f.plainWriteCount(name, fn) !== 1) return null;
1418
+ if (enclosingClassLikes(call).some((c) => contains(visible.region, c) && supertypesOf(c).length > 0)) return null;
1419
+ const nameNode = declarator.childForFieldName('name');
1420
+ const onlyReceiver = fn.descendantsOfType('identifier').every((id) => {
1421
+ if (!id || id.text !== name || (nameNode && sameSpan(id, nameNode))) return true;
1422
+ const p = id.parent;
1423
+ return p?.type === 'method_invocation' && !!p.childForFieldName('object') && sameSpan(p.childForFieldName('object')!, id);
1424
+ });
1425
+ if (!onlyReceiver) return null;
1426
+ return value;
1427
+ };
1428
+
1429
+ const helperReturnsConstant = (call: Node, depth: number): string[] | null => {
1430
+ if (depth > 0 || language !== 'java' || call.type !== 'method_invocation') return null;
1431
+ const receiver = call.childForFieldName('object');
1432
+ const methodName = call.childForFieldName('name')?.text ?? '';
1433
+ if (!methodName) return null;
1434
+
1435
+ const methodsNamed = (klass: Node): Node[] =>
1436
+ (klass.childForFieldName('body')?.namedChildren ?? []).filter(
1437
+ (m): m is Node => m !== null && m.type === 'method_declaration' && m.childForFieldName('name')?.text === methodName,
1438
+ );
1439
+ let root: Node = call;
1440
+ while (root.parent) root = root.parent;
1441
+
1442
+ let method: Node | null = null;
1443
+ let typeName = '';
1444
+ let where = '';
1445
+ /** Could a supertype declared in the scan supply a member we did not look at? */
1446
+ const inherits = (classLike: Node): boolean =>
1447
+ supertypesOf(classLike).some((t) =>
1448
+ t.split('.').some((part) => (options.javaTypeInScan ? options.javaTypeInScan(part) : true)),
1449
+ );
1450
+ /*
1451
+ * `h.m(...)` where `h` is a local that certainly holds a `new X(...)` - see
1452
+ * heldCreation below. The object is exactly an X, so it is read like
1453
+ * `new X(...).m(...)`.
1454
+ */
1455
+ const created =
1456
+ receiver?.type === 'object_creation_expression'
1457
+ ? receiver
1458
+ : receiver?.type === 'identifier'
1459
+ ? heldCreation(receiver.text ?? '', call)
1460
+ : null;
1461
+ if (created) {
1462
+ /*
1463
+ * `new X(...).m(...)`: the type is exact, so X's own m is the one that
1464
+ * runs - when X is the class in scope here. Not with a body attached
1465
+ * (`new X() { ... }` is an anonymous subclass that can override m, p25),
1466
+ * not when X is a class nested somewhere the call cannot see (p26), and
1467
+ * not when X inherits from anything - a superclass or an interface's
1468
+ * default method - since an inherited overload can be the one chosen
1469
+ * (p34, j03). X may live in another file, found the way a static
1470
+ * helper's class is (staticMethod), with an instance method allowed.
1471
+ */
1472
+ if (isClassLike(created)) return null;
1473
+ // `o.new X()` creates o's member class X - not the X this name finds
1474
+ // from here (tests/probes/r3-a06, r3-a08, r3-d01).
1475
+ if (created.child(0)?.type !== 'new') return null;
1476
+ typeName = (created.childForFieldName('type')?.text ?? '').replace(/\s+/g, '');
1477
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(typeName)) return null; // generic: not read
1478
+ // A class around the call can inherit a member class named X, and then
1479
+ // that is the X created here (tests/probes/j04).
1480
+ if (enclosingClassLikes(call).some(inherits)) return null;
1481
+ const declared: Node[] = [];
1482
+ const findTypes = (n: Node): void => {
1483
+ if (n.type !== 'object_creation_expression' && isClassLike(n) && n.childForFieldName('name')?.text === typeName) {
1484
+ declared.push(n);
1485
+ }
1486
+ for (const child of n.namedChildren) if (child) findTypes(child);
1487
+ };
1488
+ if (!typeName.includes('.')) findTypes(root);
1489
+ if (declared.length > 0) {
1490
+ const klass = declared.length === 1 ? declared[0] : undefined;
1491
+ if (!klass || klass.type !== 'class_declaration' || supertypesOf(klass).length > 0) return null;
1492
+ const owner = klass.parent?.parent ?? null;
1493
+ const inScope =
1494
+ klass.parent?.type === 'program' ||
1495
+ (klass.parent?.type === 'class_body' && !!owner && contains(owner, call) && isClassLike(owner));
1496
+ if (!inScope) return null;
1497
+ const candidates = methodsNamed(klass);
1498
+ if (candidates.length !== 1) return null;
1499
+ method = candidates[0]!;
1500
+ } else {
1501
+ const first = typeName.split('.')[0] ?? '';
1502
+ if (!options.staticMethod || !first || !usedOnlyAsQualifier(root, first)) return null;
1503
+ const supertypes = enclosingClassLikes(call).flatMap((c) => supertypesOf(c));
1504
+ const found = options.staticMethod(typeName, methodName, supertypes, true);
1505
+ if (!found || hidesCodeFromParser(found.method, 'java')) return null;
1506
+ method = found.method;
1507
+ where = ` in ${found.path}`;
1508
+ }
1509
+ typeName = typeName.split('.').pop() ?? typeName;
1510
+ } else if (!receiver) {
1511
+ /*
1512
+ * A bare call, `doSomething(request, param)`. It binds to this class's
1513
+ * method only if no subclass can replace it - so only when that method
1514
+ * is private, static or final, read from the modifier tokens (p29).
1515
+ * BenchmarkJava declares its helpers `private static`, and 27 of the
1516
+ * survivors were exactly this shape. The
1517
+ * class is the INNERMOST class-like around the call: inside an anonymous
1518
+ * class or an enum, `m()` means theirs (p27, p28), and those are not
1519
+ * read. A superclass declared in the scan can supply a more specific
1520
+ * overload (p30); one from outside it - BenchmarkJava's HttpServlet - is
1521
+ * not read, and the SQL rule's limitations say so.
1522
+ */
1523
+ const klass = enclosingClassLikes(call)[0];
1524
+ if (!klass || klass.type !== 'class_declaration' || inherits(klass)) return null;
1525
+ typeName = klass.childForFieldName('name')?.text ?? '';
1526
+ const candidates = methodsNamed(klass);
1527
+ if (candidates.length !== 1) return null;
1528
+ const candidate = candidates[0]!;
1529
+ if (!['private', 'static', 'final'].some((k) => hasModifier(candidate, k))) return null;
1530
+ method = candidate;
1531
+ } else if ((receiver.type === 'identifier' || receiver.type === 'field_access') && options.staticMethod) {
1532
+ /*
1533
+ * `Utils.m(...)`. In Java a variable named `Utils` - or `a` for
1534
+ * `a.b.Utils` - hides the class or package of that name, and so does a
1535
+ * type, a pattern variable, an enum constant, a lambda parameter... So
1536
+ * EVERY appearance of that name in this file must be as a qualifier,
1537
+ * like the one here (p10, p11, p12). Names that arrive without appearing
1538
+ * - static imports, inherited members - are checked by the lookup.
1539
+ */
1540
+ const dotted = (receiver.text ?? '').replace(/\s+/g, '');
1541
+ const first = dotted.split('.')[0] ?? '';
1542
+ if (!first || !usedOnlyAsQualifier(root, first)) return null;
1543
+ const supertypes = enclosingClassLikes(call).flatMap((c) => supertypesOf(c));
1544
+ const found = options.staticMethod(dotted, methodName, supertypes);
1545
+ if (!found || hidesCodeFromParser(found.method, 'java')) return null;
1546
+ method = found.method;
1547
+ typeName = dotted.split('.').pop() ?? dotted;
1548
+ where = ` in ${found.path}`;
1549
+ }
1550
+ if (!method) return null; // a receiver that is a variable: it could be any subclass
1551
+ /*
1552
+ * A method calling itself. Its parameters are bound to what this call
1553
+ * passes - and the arguments are the CALLER's parameters, the same names in
1554
+ * the same method, which would then read their own binding: `m(b, "x")`
1555
+ * inside m makes the caller's `b` look like "x" (tests/probes/r3-a07,
1556
+ * r3-z-scope-recursive-static). Not read.
1557
+ */
1558
+ if (spanKey(rootOf(method)) === spanKey(rootOf(call)) && contains(method, call)) return null;
1559
+
1560
+ /* ---- the parameters, bound to what this call passes ---- */
1561
+ const formal = (method.childForFieldName('parameters')?.namedChildren ?? []).filter(
1562
+ (p): p is Node => p !== null && (p.type === 'formal_parameter' || p.type === 'spread_parameter'),
1563
+ );
1564
+ const args = (call.childForFieldName('arguments')?.namedChildren ?? []).filter(
1565
+ (a): a is Node => a !== null && a.type !== 'line_comment' && a.type !== 'block_comment',
1566
+ );
1567
+ // The count must match with no varargs, whatever is read: a call that
1568
+ // passes more or fewer arguments than this method takes is calling
1569
+ // another one (p02).
1570
+ if (formal.length !== args.length || formal.some((p) => p.type !== 'formal_parameter')) return null;
1571
+ const callerBody = enclosingScopes(call)[0];
1572
+ const bound = new Map<string, () => string[] | null>();
1573
+ const used: string[] = [];
1574
+ if (callerBody) {
1575
+ formal.forEach((p, i) => {
1576
+ const name = p.childForFieldName('name')?.text ?? '';
1577
+ const arg = args[i];
1578
+ if (!name || !arg) return;
1579
+ let memo: string[] | null | undefined;
1580
+ bound.set(name, () => {
1581
+ if (memo === undefined) {
1582
+ const why = valueIsConstant(arg, callerBody, depth);
1583
+ memo = why && [
1584
+ ...why,
1585
+ `\`${name}\` is \`${arg.text.replace(/\s+/g, ' ')}\`, passed on line ${lineOf(arg)}`,
1586
+ ];
1587
+ if (memo) used.push(name);
1588
+ }
1589
+ return memo;
1590
+ });
1591
+ });
1592
+ }
1593
+
1594
+ const returns: Node[] = [];
1595
+ const findReturns = (n: Node): void => {
1596
+ if (n.type === 'return_statement') returns.push(n);
1597
+ // A return inside a nested class or lambda belongs to that, not to m.
1598
+ if (n.type === 'class_body' || n.type === 'lambda_expression') return;
1599
+ for (const child of n.namedChildren) if (child) findReturns(child);
1600
+ };
1601
+ const methodBody = method.childForFieldName('body');
1602
+ if (!methodBody) return null;
1603
+ findReturns(methodBody);
1604
+ if (returns.length === 0) return null;
1605
+
1606
+ const key = spanKey(method);
1607
+ bindings.set(key, bound);
1608
+ const notes: string[] = [];
1609
+ try {
1610
+ for (const ret of returns) {
1611
+ const expr = ret.namedChildren.find((c): c is Node => c !== null) ?? null;
1612
+ if (!expr) return null;
1613
+ const why = valueIsConstant(expr, method, depth + 1);
1614
+ if (!why) return null;
1615
+ notes.push(...why);
1616
+ }
1617
+ } finally {
1618
+ bindings.delete(key);
1619
+ }
1620
+ if (used.length === 0) {
1621
+ return [`the helper ${typeName}.${methodName}()${where} on line ${lineOf(method)} returns a fixed literal on every path`];
1622
+ }
1623
+ return [
1624
+ `the helper ${typeName}.${methodName}()${where} on line ${lineOf(method)} returns only literals and ` +
1625
+ `its parameter${used.length === 1 ? '' : 's'} ${used.map((u) => `\`${u}\``).join(', ')}, never written inside it`,
1626
+ ...notes,
1627
+ ];
1628
+ };
1629
+
1630
+ /** Is this one live value fixed? Returns the notes that justify it, or null. */
1631
+ const valueIsConstant = (v: Node, body: Node, depth: number): string[] | null => {
1632
+ const values = liveValues(v, body, 0);
1633
+ if (!values) return null;
1634
+ const notes: string[] = values.note ? [values.note] : [];
1635
+ for (const raw of values.nodes) {
1636
+ if (isPlainLiteral(raw, language)) continue;
1637
+ /*
1638
+ * `(String) map.get("keyA")` or `list.get(1)` - a read from a local map or
1639
+ * list whose every write is on the page (taint/local-map.ts) is the value
1640
+ * stored there. The cast is looked through first; it changes the type,
1641
+ * not the value.
1642
+ */
1643
+ let each = raw;
1644
+ while (each.type === 'cast_expression' || each.type === 'parenthesized_expression') {
1645
+ each = each.childForFieldName('value') ?? each.namedChildren[each.namedChildren.length - 1] ?? each;
1646
+ if (each === raw) break;
1647
+ }
1648
+ const read = resolveContainerRead(each, language);
1649
+ if (read) {
1650
+ const where =
1651
+ `\`${each.text.replace(/\s+/g, ' ')}\` on line ${lineOf(each)} returns what was stored at ` +
1652
+ `${read.selector} on line ${lineOf(read.write)} - the collection is local, handed to nothing, ` +
1653
+ `and written only by straight-line statements`;
1654
+ if (isPlainLiteral(read.value, language)) {
1655
+ notes.push(where.replace('returns what was stored', 'returns the literal stored'));
1656
+ continue;
1657
+ }
1658
+ /*
1659
+ * A NAME was stored - `map.put("keyB", param)`. Its value is judged
1660
+ * where it was stored, not where it is read: the map holds what the
1661
+ * name held then (ContainerHeldValue.java). Only for a type that cannot
1662
+ * change in place, or an alias could change the stored object later.
1663
+ */
1664
+ const fn = enclosingMethod(each);
1665
+ const stored = read.value.text ?? '';
1666
+ if (read.value.type !== 'identifier' || !fn || !declaredImmutable(fn, stored)) return null;
1667
+ const inner = nameIsConstant(stored, read.value, depth);
1668
+ if (!inner) return null;
1669
+ notes.push(...inner, `${where}, and \`${stored}\` was fixed when stored`);
1670
+ continue;
1671
+ }
1672
+ /*
1673
+ * `"cmd.exe /c " + append + " "` - pieces joined with `+` are as fixed as
1674
+ * the pieces. Only `+`, only where it joins strings (C's is pointer
1675
+ * arithmetic, PHP's is `.`), and every piece judged by these same rules.
1676
+ */
1677
+ if (CONCATENATING.has(language) && CONCAT_NODES.has(each.type) && each.childForFieldName('operator')?.text === '+') {
1678
+ const left = each.childForFieldName('left');
1679
+ const right = each.childForFieldName('right');
1680
+ const l = left ? valueIsConstant(left, body, depth) : null;
1681
+ const r = l && right ? valueIsConstant(right, body, depth) : null;
1682
+ if (!l || !r) return null;
1683
+ notes.push(...l, ...r);
1684
+ continue;
1685
+ }
1686
+ if (/^[A-Za-z_$][\w$]*$/.test((each.text ?? '').trim()) && each.namedChildren.length === 0) {
1687
+ const inner = nameIsConstant((each.text ?? '').trim(), each, depth);
1688
+ if (!inner) return null;
1689
+ notes.push(...inner);
1690
+ continue;
1691
+ }
1692
+ const helper = helperReturnsConstant(each, depth);
1693
+ if (!helper) return null;
1694
+ notes.push(...helper);
1695
+ }
1696
+ return notes;
1697
+ };
1698
+
1699
+ /*
1700
+ * THE CYCLE GUARD, BECAUSE THIS PROJECT HAS ALREADY PAID FOR NOT HAVING ONE.
1701
+ *
1702
+ * A value can be another name - `bar = baz` - so resolving a name can land on
1703
+ * a name, and `a = b; b = a` is a loop. The tracer's folder had exactly this
1704
+ * shape with no guard, and DVWA's packed sha256 turned it into an
1705
+ * out-of-memory on a 10KB file. A name already being resolved answers "not
1706
+ * provable", which is the direction that keeps the finding.
1707
+ */
1708
+ const resolving = new Set<string>();
1709
+
1710
+ /** Is every live write to `part`, as seen from `anchor`, a fixed value? */
1711
+ const nameIsConstant = (part: string, anchor: Node, depth: number): string[] | null => {
1712
+ let scopes = enclosingScopes(anchor);
1713
+ const body = scopes[0];
1714
+ if (!body) return null;
1715
+ /*
1716
+ * A Python name assigned in a function, with no global/nonlocal for it, is
1717
+ * that function's local: no module-level write - and no module-level exec()
1718
+ * - can reach it. Walking the module anyway let an eval() in ANY function
1719
+ * of the file sink every proof (BenchmarkPython's 22 constant-branch evals).
1720
+ */
1721
+ if (
1722
+ language === 'python' && scopes.length > 1 && body.type === 'function_definition' &&
1723
+ folderOf(body).plainWriteCount(part, body) > 0 && !folderOf(body).writtenOtherwise(part, body)
1724
+ ) {
1725
+ scopes = [body];
1726
+ }
1727
+ const key = `${spanKey(body)}#${part}`;
1728
+ if (resolving.has(key) || resolving.size > 24) return null;
1729
+ resolving.add(key);
1730
+ try {
1731
+ return resolveName(part, scopes, body, depth, anchor);
1732
+ } finally {
1733
+ resolving.delete(key);
1734
+ }
1735
+ };
1736
+
1737
+ /** Node types that hold a plain sequence of statements, each run once, in order. */
1738
+ const STATEMENT_LISTS = new Set([
1739
+ 'block', 'statement_block', 'compound_statement', 'constructor_body', 'statement_list',
1740
+ 'program', 'module', 'source_file',
1741
+ ]);
1742
+ /** Wrappers between a write and the statement that holds it. */
1743
+ const STATEMENT_WRAPPERS = new Set([
1744
+ 'expression_statement', 'local_variable_declaration', 'lexical_declaration', 'variable_declaration',
1745
+ ]);
1746
+
1747
+ /** The write that certainly decides the value at `anchor`, if one does. See resolveName. */
1748
+ const certainLastWrite = (
1749
+ writes: ReadonlyArray<{ node: Node; value: Node; scope: Node }>,
1750
+ anchor: Node,
1751
+ ): { node: Node; value: Node } | null => {
1752
+ if (language === 'c' || language === 'cpp' || writes.length === 0) return null;
1753
+ const innermost = enclosingScopes(anchor)[0];
1754
+ if (!innermost || writes.some((w) => !sameSpan(w.scope, innermost))) return null;
1755
+ const last = [...writes].sort((a, b) => a.node.startIndex - b.node.startIndex)[writes.length - 1];
1756
+ if (!last || last.node.endIndex > anchor.startIndex) return null;
1757
+ let statement: Node = last.node;
1758
+ while (statement.parent && STATEMENT_WRAPPERS.has(statement.parent.type)) statement = statement.parent;
1759
+ const holder = statement.parent;
1760
+ if (!holder || !STATEMENT_LISTS.has(holder.type) || !contains(holder, anchor)) return null;
1761
+ return last;
1762
+ };
1763
+
1764
+ const resolveName = (
1765
+ part: string,
1766
+ scopes: Node[],
1767
+ body: Node,
1768
+ depth: number,
1769
+ anchor: Node,
1770
+ ): string[] | null => {
1771
+
1772
+ let live = 0;
1773
+ let fixed = true;
1774
+ const notes: string[] = [];
1775
+
1776
+ /* Every plain write, in every scope, before any value is judged. */
1777
+ const writes: Array<{ node: Node; value: Node; scope: Node }> = [];
1778
+ /* Every declaration of the name the same walk passes - with a value or without. */
1779
+ const declarations: Array<{ node: Node; scope: Node }> = [];
1780
+ for (const scope of scopes) {
1781
+ if (parameterNames(scope).has(part)) {
1782
+ /*
1783
+ * A parameter is input - unless this is a helper being read for one
1784
+ * particular call (helperReturnsConstant), and then it is whatever that
1785
+ * call passes. Only if the helper never writes to it: `a = b;` would
1786
+ * make the literal passed for `a` irrelevant.
1787
+ */
1788
+ const bound = bindings.get(spanKey(scope))?.get(part);
1789
+ if (!bound) return null;
1790
+ const f = folderOf(scope);
1791
+ if (f.writtenOtherwise(part, scope) || f.plainWriteCount(part, scope) > 0) return null;
1792
+ return bound();
1793
+ }
1794
+ /*
1795
+ * A write this walk does not recognise - `bar, err := f()`, `[bar] = x`,
1796
+ * `bar += param` in Python, `for bar in ...` - would leave only the literal
1797
+ * writes visible, and the proof would call a tainted value constant. The
1798
+ * folder's survey sees every form of write; any irregular one ends this.
1799
+ */
1800
+ if (folderOf(scope).writtenOtherwise(part, scope)) return null;
1801
+ const collect = (n: Node | null): void => {
1802
+ if (!n) return;
1803
+ if (n !== scope && SCOPE_NODES.has(n.type)) return;
1804
+ const fields = BINDING_NODES[n.type];
1805
+ if (fields && (n.childForFieldName(fields.name)?.text ?? '').trim() === part) {
1806
+ const value = n.childForFieldName(fields.value);
1807
+ if (value) writes.push({ node: n, value, scope });
1808
+ }
1809
+ if (DECLARATION_NODES.has(n.type) && (n.childForFieldName(n.type === 'short_var_declaration' ? 'left' : 'name')?.text ?? '').trim() === part) {
1810
+ declarations.push({ node: n, scope });
1811
+ }
1812
+ for (const child of n.namedChildren) collect(child ?? null);
1813
+ };
1814
+ collect(scope);
1815
+ }
1816
+ /*
1817
+ * A WRITE INSIDE A CLOSURE is a write this walk does not see - it stops at
1818
+ * nested functions on purpose - and `let bar = "x"; const set = () => {
1819
+ * bar = req.query.q; }; set();` would read as constant. The folder's survey
1820
+ * walks nested functions too; if it counts more writes here than this walk
1821
+ * did, one of them is hidden in a closure, and nothing is proved.
1822
+ */
1823
+ const innermost = scopes[0];
1824
+ if (innermost && folderOf(innermost).plainWriteCount(part, innermost) > writes.filter((w) => w.scope === innermost).length) {
1825
+ return null;
1826
+ }
1827
+
1828
+ let outerBound = writes.some((w) => innermost && !sameSpan(w.scope, innermost));
1829
+
1830
+ /*
1831
+ * JAVA SCOPING. The walk above collects every declaration of the name in
1832
+ * the scopes around the read - and Java's scopes are smaller than that.
1833
+ * Each probe in tests/probes/p15-p24 was a constant declaration the read
1834
+ * could not see, standing in for one it could:
1835
+ *
1836
+ * - a local is visible from its declaration to the end of its block, so
1837
+ * one in another block (p17), one declared after the read (p18), one
1838
+ * inside a lambda (p22) and a field of a nested or local class (p19,
1839
+ * p23, p24) are somebody else's - and any of those ends the proof;
1840
+ * - a lambda parameter or a pattern variable of the same name can shadow
1841
+ * the declaration found, and the walk does not collect them (p15, p16);
1842
+ * - a class between the declaration and the read that inherits from
1843
+ * anything can inherit a field of the same name, which then wins (p20,
1844
+ * p21);
1845
+ * - a field counts only if it is private or final, read from the
1846
+ * modifier TOKENS - the word in `@SuppressWarnings("private")`, or a
1847
+ * `final` inside a comment, is not a modifier (p13, p14) - since any
1848
+ * other class can assign one that is neither;
1849
+ * - no declaration in view at all - an inherited field, a static import -
1850
+ * is nothing this proof can read.
1851
+ */
1852
+ if (language === 'java') {
1853
+ if (boundElsewhere(rootOf(anchor), part)) return null;
1854
+ if (declarations.length === 0) return null;
1855
+ let local = false;
1856
+ for (const { node: declarator } of declarations) {
1857
+ const visible = declarationVisibility(declarator, language);
1858
+ if (!visible || !contains(visible.region, anchor) || anchor.startIndex < visible.from) return null;
1859
+ if (enclosingClassLikes(anchor).some((c) => contains(visible.region, c) && supertypesOf(c).length > 0)) {
1860
+ return null;
1861
+ }
1862
+ const holder = declarator.parent;
1863
+ if (holder?.type === 'local_variable_declaration') local = true;
1864
+ else if (holder?.type === 'field_declaration' && !hasModifier(holder, 'private') && !hasModifier(holder, 'final')) {
1865
+ return null;
1866
+ }
1867
+ }
1868
+ /*
1869
+ * A write inside a class nested in this code - a local or anonymous
1870
+ * class, a record's constructor, an initializer block - runs as THAT
1871
+ * class's code, where every name around it may mean something else:
1872
+ * `if (dbg) cmd = ...` inside a record reads the record's own `dbg`
1873
+ * (tests/probes/j09, j10). Its branches cannot be decided from here, so
1874
+ * such a write ends the proof.
1875
+ */
1876
+ if (writes.some((w) => enclosingClassLikes(w.node).some((c) => !contains(c, anchor)))) return null;
1877
+ if (!local) outerBound = true;
1878
+ } else if (language === 'javascript' || language === 'typescript' || language === 'go') {
1879
+ /*
1880
+ * The same visibility rule as Java's, by these languages' own scoping:
1881
+ * every declaration found must be one the read can see (a block's
1882
+ * `let`, a `for` header's, a Go block's `:=` - s02, s03, s05, s06, g05),
1883
+ * and there must be one: a name declared nowhere in view is an import,
1884
+ * another script's global or another file's package variable (s04, g04).
1885
+ */
1886
+ if (language === 'go' && goBindsOtherwise(rootOf(anchor), part)) return null;
1887
+ if (declarations.length === 0) return null;
1888
+ for (const { node: declarator } of declarations) {
1889
+ const visible = declarationVisibility(declarator, language);
1890
+ if (!visible || !contains(visible.region, anchor) || anchor.startIndex < visible.from) return null;
1891
+ }
1892
+ // Not declared in the reading function: it belongs to a scope outside,
1893
+ // where any other function can assign it - with or without a write of
1894
+ // its own in view.
1895
+ if (!declarations.some((d) => innermost && sameSpan(d.scope, innermost))) outerBound = true;
1896
+ }
1897
+
1898
+ /*
1899
+ * A NAME BOUND OUTSIDE THIS FUNCTION CAN BE WRITTEN BY ANY OTHER ONE.
1900
+ *
1901
+ * static String table = "users";
1902
+ * void setTable(HttpServletRequest r) { table = r.getParameter("t"); }
1903
+ * void list(Statement s) { s.execute("SELECT * FROM " + table); }
1904
+ *
1905
+ * The walk above does not enter other functions - on purpose, because a
1906
+ * sibling's OWN local of the same name is irrelevant (const-interp.py). But
1907
+ * `table` here is not anybody's local: it is a field, and setTable writes
1908
+ * it. The walk saw only "users" and called the query constant. So for a
1909
+ * name bound outside the reading function, every function inside the scope
1910
+ * that holds it is searched too, and any write not already judged ends the
1911
+ * proof - a sibling's same-named local included, which costs a little
1912
+ * precision and never a finding. OuterWriteFence.* has each shape.
1913
+ *
1914
+ * Python and PHP are left out on purpose: a plain assignment inside a
1915
+ * function makes a NEW local there, and reaching the outer name takes a
1916
+ * `global` (or `nonlocal`) statement - which the survey above already
1917
+ * treats as an irregular write.
1918
+ */
1919
+ if (outerBound && language !== 'python' && language !== 'php') {
1920
+ const holding = [...declarations.map((d) => d.scope), ...writes.map((w) => w.scope)];
1921
+ const declaring =
1922
+ (holding.length > 0 && innermost && holding.every((h) => sameSpan(h, innermost))
1923
+ ? scopes[scopes.length - 1]
1924
+ : [...scopes].reverse().find((s) => holding.some((h) => sameSpan(h, s)))) ?? scopes[scopes.length - 1];
1925
+ if (!declaring) return null;
1926
+ const folder = folderOf(declaring);
1927
+ if (folder.writtenOtherwise(part, declaring) || folder.plainWriteCount(part, declaring) > writes.length) return null;
1928
+ }
1929
+
1930
+ /*
1931
+ * THE LAST CERTAIN WRITE.
1932
+ *
1933
+ * bar = (String) map.get("keyB"); // the request parameter
1934
+ * bar = (String) map.get("keyA"); // a literal
1935
+ * sql = "... " + bar;
1936
+ *
1937
+ * Every write to `bar` used to have to be a literal, so the dirty one above
1938
+ * - overwritten one line later - sank the proof. When one write is certain
1939
+ * to run after all the others and before the read, it is the only one that
1940
+ * matters. Certain means: it is a statement directly in a block that holds
1941
+ * the read, it comes before the read, and NO write to the name comes after
1942
+ * it anywhere in the function - so no loop can bring an earlier or later
1943
+ * value round again. Not attempted in C or C++, where a declaration in an
1944
+ * inner block can shadow the name without this walk seeing a write.
1945
+ */
1946
+ // Only for a name local to the reading function: a call between the last
1947
+ // write and the read can change a field, or a variable another function
1948
+ // shares.
1949
+ // A `goto` can jump over the last write (tests/probes/g01).
1950
+ const jumps = !!innermost && innermost.descendantsOfType('goto_statement').length > 0;
1951
+ const last = outerBound || jumps ? null : certainLastWrite(writes, anchor);
1952
+ if (last) {
1953
+ const why = valueIsConstant(last.value, body, depth);
1954
+ if (!why) return null;
1955
+ return [
1956
+ ...why,
1957
+ `the value at line ${lineOf(anchor)} is the one written on line ${lineOf(last.node)}, which ` +
1958
+ `overwrites every earlier write and is never overwritten itself`,
1959
+ ];
1960
+ }
1961
+
1962
+ /*
1963
+ * A FIELD OUTLIVES THE CALL. Inside a helper read for one call, a parameter
1964
+ * means what THAT call passes - but a field written from a parameter, `f =
1965
+ * v`, holds what whichever call ran LAST passed: `swap(tainted);
1966
+ * swap("x")` returns the tainted value, and a lazy cache keeps the first
1967
+ * call's (tests/probes/r3-a04, r3-a05, r3-a09, r3-z-scope-static-swap). So
1968
+ * a name bound outside the reading function is judged with no parameter
1969
+ * bound, and a parameter written into it is input.
1970
+ */
1971
+ const suspended = outerBound && bindings.size > 0 ? new Map(bindings) : null;
1972
+ if (suspended) bindings.clear();
1973
+ try {
1974
+ for (const scope of scopes) {
1975
+ const walk = (n: Node | null): void => {
1976
+ if (!n || !fixed) return;
1977
+ // Do NOT descend into a nested scope. Searching the module body walked
1978
+ // straight into sibling functions, so an unrelated `t = request.args...`
1979
+ // three functions away made a genuinely constant `t` here look tainted.
1980
+ if (n !== scope && SCOPE_NODES.has(n.type)) return;
1981
+ const fields = BINDING_NODES[n.type];
1982
+ if (fields) {
1983
+ const target = n.childForFieldName(fields.name);
1984
+ if ((target?.text ?? '').trim() === part) {
1985
+ const value = n.childForFieldName(fields.value);
1986
+ if (value) {
1987
+ const deadIf = deadArmHolding(n, scope, body);
1988
+ if (deadIf) {
1989
+ notes.push(`the write on line ${lineOf(n)} ${deadIf.why}`);
1990
+ } else {
1991
+ live++;
1992
+ const why = valueIsConstant(value, body, depth);
1993
+ if (!why) fixed = false;
1994
+ else notes.push(...why);
1995
+ }
1996
+ }
1997
+ }
1998
+ }
1999
+ for (const child of n.namedChildren) walk(child ?? null);
2000
+ };
2001
+ walk(scope);
2002
+ }
2003
+ } finally {
2004
+ if (suspended) for (const [k, v] of suspended) bindings.set(k, v);
2005
+ }
2006
+
2007
+ // No live write means we cannot see where it comes from - so we do not get
2008
+ // to call it constant.
2009
+ if (live === 0 || !fixed) return null;
2010
+ return notes;
2011
+ };
2012
+
2013
+ let reasoned = false;
2014
+ const reasons: string[] = [];
2015
+ for (const raw of dynamicParts) {
2016
+ const part = raw.trim();
2017
+ // Only a bare name can be resolved this cheaply. Anything with a call, an
2018
+ // index or an attribute access is left alone.
2019
+ if (!/^[A-Za-z_$][\w$]*$/.test(part)) return null;
2020
+ const notes = nameIsConstant(part, node, 0);
2021
+ if (!notes) return null;
2022
+ if (notes.length > 0) {
2023
+ reasoned = true;
2024
+ reasons.push(`\`${part}\`: ${[...new Set(notes)].join('; ')}`);
2025
+ }
2026
+ }
2027
+
2028
+ return {
2029
+ reasoned,
2030
+ reason: reasoned
2031
+ ? `Every value that can be spliced in is a fixed literal on every path that runs. ${reasons.join('. ')}.`
2032
+ : 'Every value spliced in is bound only to plain literals.',
2033
+ };
2034
+ }