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