@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,348 @@
1
+ /**
2
+ * THE SHAPE LAYER
3
+ * ===============
4
+ *
5
+ * This is the translator between "five different languages" and "one set of
6
+ * rules". It uses tree-sitter's QUERY LANGUAGE, which is worth understanding
7
+ * because it is the only unfamiliar syntax in this project.
8
+ *
9
+ * A tree-sitter query is a pattern written as nested parentheses that describes
10
+ * a shape of tree, with @names marking the parts you want back:
11
+ *
12
+ * (call_expression
13
+ * function: (member_expression
14
+ * object: (_) @receiver
15
+ * property: (property_identifier) @method)
16
+ * arguments: (arguments) @args) @call
17
+ *
18
+ * Read it as: "find a call_expression; its `function` child must be a
19
+ * member_expression (that's the `a.b` shape); give me whatever is on the left
20
+ * of the dot as @receiver, the name after the dot as @method, the argument list
21
+ * as @args, and the whole call as @call."
22
+ *
23
+ * `(_)` means "any node". Field names before the colon (`function:`,
24
+ * `object:`) are the ROLE labels you saw in the AST dump. Matching on roles
25
+ * instead of child positions is what makes these patterns stable.
26
+ *
27
+ * Every language below gets its own query because the grammars use different
28
+ * node names for the same idea - JavaScript says `call_expression`, Python says
29
+ * `call`, Java says `method_invocation`. The OUTPUT is identical for all five,
30
+ * and that output is what rules see.
31
+ *
32
+ * ADDING A LANGUAGE LATER means adding two query strings to this file. No rule
33
+ * changes.
34
+ */
35
+
36
+ import { Query, type Node, type QueryMatch } from 'web-tree-sitter';
37
+ import type { Assignment, CallSite, Shape } from '../rules/contract.js';
38
+ import type { LanguageId } from '../parse/languages.js';
39
+ import { getLanguage, type ParsedFile } from '../parse/parser.js';
40
+
41
+ /* ---------------------------------------------------------------------------
42
+ * CALL SITES: "something is being called with arguments"
43
+ * ------------------------------------------------------------------------ */
44
+
45
+ /** Shared by C and C++, because C++ is a superset of these two shapes. */
46
+ const C_CALL_QUERY = `
47
+ (call_expression
48
+ function: (identifier) @method
49
+ arguments: (argument_list) @args) @call
50
+ (call_expression
51
+ function: (field_expression argument: (_) @receiver field: (field_identifier) @method)
52
+ arguments: (argument_list) @args) @call
53
+ `;
54
+
55
+ const CALL_QUERIES: Record<LanguageId, string> = {
56
+ javascript: `
57
+ (call_expression
58
+ function: (member_expression object: (_) @receiver property: (property_identifier) @method)
59
+ arguments: (arguments) @args) @call
60
+ (call_expression
61
+ function: (identifier) @method
62
+ arguments: (arguments) @args) @call
63
+ (new_expression
64
+ constructor: (_) @method
65
+ arguments: (arguments) @args) @call
66
+ `,
67
+ // TypeScript's grammar is JavaScript's grammar plus types, so the call shapes
68
+ // are byte-for-byte identical. Kept as a separate entry rather than an alias
69
+ // so that a future TS-only pattern has an obvious home.
70
+ typescript: `
71
+ (call_expression
72
+ function: (member_expression object: (_) @receiver property: (property_identifier) @method)
73
+ arguments: (arguments) @args) @call
74
+ (call_expression
75
+ function: (identifier) @method
76
+ arguments: (arguments) @args) @call
77
+ (new_expression
78
+ constructor: (_) @method
79
+ arguments: (arguments) @args) @call
80
+ `,
81
+ python: `
82
+ (call
83
+ function: (attribute object: (_) @receiver attribute: (identifier) @method)
84
+ arguments: (argument_list) @args) @call
85
+ (call
86
+ function: (identifier) @method
87
+ arguments: (argument_list) @args) @call
88
+ `,
89
+ java: `
90
+ (method_invocation
91
+ object: (_) @receiver
92
+ name: (identifier) @method
93
+ arguments: (argument_list) @args) @call
94
+ (method_invocation
95
+ name: (identifier) @method
96
+ arguments: (argument_list) @args) @call
97
+ (object_creation_expression
98
+ type: (_) @method
99
+ arguments: (argument_list) @args) @call
100
+ `,
101
+ go: `
102
+ (call_expression
103
+ function: (selector_expression operand: (_) @receiver field: (field_identifier) @method)
104
+ arguments: (argument_list) @args) @call
105
+ (call_expression
106
+ function: (identifier) @method
107
+ arguments: (argument_list) @args) @call
108
+ `,
109
+ // PHP has three call shapes: a bare function, a method on an object, and a
110
+ // static method on a class. `echo` is deliberately NOT here - it is a
111
+ // statement, not a call, and the XSS rule handles it separately.
112
+ php: `
113
+ (function_call_expression
114
+ function: (name) @method
115
+ arguments: (arguments) @args) @call
116
+ (member_call_expression
117
+ object: (_) @receiver
118
+ name: (name) @method
119
+ arguments: (arguments) @args) @call
120
+ (scoped_call_expression
121
+ scope: (_) @receiver
122
+ name: (name) @method
123
+ arguments: (arguments) @args) @call
124
+ (object_creation_expression
125
+ (name) @method
126
+ (arguments) @args) @call
127
+ `,
128
+
129
+ /*
130
+ * C. Two shapes only, because C has two: a bare call, and a call through a
131
+ * struct member. Every node type and field name below was read off the real
132
+ * grammar with a query probe rather than assumed - the same discipline the
133
+ * PHP entry learned the hard way, when `if_statement` turned out to use
134
+ * `body` where every other grammar uses `consequence`.
135
+ */
136
+ c: C_CALL_QUERY,
137
+
138
+ /*
139
+ * C++ is C plus namespaces. `std::system(cmd)` parses as a
140
+ * qualified_identifier, NOT an identifier, so a query that only matched the
141
+ * C shapes would miss it - and reaching system() through <cstdlib> is the
142
+ * stylistically CORRECT way to do it in C++, which means the well-written
143
+ * code would be the code that slipped through.
144
+ */
145
+ cpp: `
146
+ ${C_CALL_QUERY}
147
+ (call_expression
148
+ function: (qualified_identifier name: (identifier) @method)
149
+ arguments: (argument_list) @args) @call
150
+ `,
151
+ };
152
+
153
+ /* ---------------------------------------------------------------------------
154
+ * ASSIGNMENTS: "a name is being bound to a value"
155
+ *
156
+ * Deliberately broad: it covers `x = v`, `const x = v`, object/dict entries
157
+ * `{ key: v }`, and keyword arguments `f(key=v)`. Hardcoded credentials hide in
158
+ * all of those, and so does `element.innerHTML = ...`.
159
+ * ------------------------------------------------------------------------ */
160
+
161
+ const JS_ASSIGN_QUERY = `
162
+ (assignment_expression left: (_) @target right: (_) @value) @assign
163
+ (variable_declarator name: (_) @target value: (_) @value) @assign
164
+ (pair key: (_) @target value: (_) @value) @assign
165
+ (augmented_assignment_expression left: (_) @target right: (_) @value) @assign
166
+ `;
167
+
168
+ const C_ASSIGN_QUERY = `
169
+ (assignment_expression left: (_) @target right: (_) @value) @assign
170
+ (init_declarator declarator: (_) @target value: (_) @value) @assign
171
+ `;
172
+
173
+ const ASSIGNMENT_QUERIES: Record<LanguageId, string> = {
174
+ php: `
175
+ (assignment_expression left: (_) @target right: (_) @value) @assign
176
+ (property_element (variable_name) @target (_) @value) @assign
177
+ (echo_statement "echo" @target (_) @value) @assign
178
+ `,
179
+ javascript: JS_ASSIGN_QUERY,
180
+ typescript: `
181
+ ${JS_ASSIGN_QUERY}
182
+ (public_field_definition name: (_) @target value: (_) @value) @assign
183
+ `,
184
+ python: `
185
+ (assignment left: (_) @target right: (_) @value) @assign
186
+ (pair key: (_) @target value: (_) @value) @assign
187
+ (keyword_argument name: (identifier) @target value: (_) @value) @assign
188
+ `,
189
+ java: `
190
+ (variable_declarator name: (identifier) @target value: (_) @value) @assign
191
+ (assignment_expression left: (_) @target right: (_) @value) @assign
192
+ `,
193
+ go: `
194
+ (short_var_declaration left: (expression_list) @target right: (expression_list) @value) @assign
195
+ (assignment_statement left: (expression_list) @target right: (expression_list) @value) @assign
196
+ (var_spec name: (identifier) @target value: (expression_list) @value) @assign
197
+ (const_spec name: (identifier) @target value: (expression_list) @value) @assign
198
+ (keyed_element (literal_element (identifier) @target) (literal_element (_) @value)) @assign
199
+ `,
200
+
201
+ /*
202
+ * C and C++ share these. `init_declarator` is the declaration-with-value
203
+ * form (`char *p = getenv(...)`), and its @target captures the DECLARATOR
204
+ * rather than a bare name - so `*name = getenv("USER")` yields a target of
205
+ * `*name`, pointer star included. The tracer strips it; see POINTER_PREFIX
206
+ * in taint/tracer.ts. Recorded here because it is the kind of detail that
207
+ * silently halves a language's coverage if nobody notices it.
208
+ */
209
+ c: C_ASSIGN_QUERY,
210
+ cpp: C_ASSIGN_QUERY,
211
+ };
212
+
213
+ /* ------------------------------------------------------------------------ */
214
+
215
+ /** Compiled queries are expensive to build and cheap to reuse. Cache per grammar. */
216
+ const queryCache = new Map<string, Query>();
217
+
218
+ async function compile(grammar: string, key: string, source: string): Promise<Query> {
219
+ const cacheKey = `${grammar}:${key}`;
220
+ const cached = queryCache.get(cacheKey);
221
+ if (cached) return cached;
222
+ const language = await getLanguage(grammar);
223
+ const query = new Query(language, source);
224
+ queryCache.set(cacheKey, query);
225
+ return query;
226
+ }
227
+
228
+ function collapse(text: string | null | undefined): string {
229
+ return (text ?? '').replace(/\s+/g, ' ').trim();
230
+ }
231
+
232
+ /** Pull one capture out of a match. Returns null when the capture is optional. */
233
+ function captured(match: QueryMatch, name: string): Node | null {
234
+ for (const capture of match.captures) {
235
+ if (capture.name === name) return capture.node;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Argument lists contain punctuation as well as expressions. `namedChildren`
242
+ * gives us only the meaningful ones - see the "named vs anonymous" note in
243
+ * astDump.ts.
244
+ */
245
+ function argumentNodes(argsNode: Node | null): Node[] {
246
+ if (!argsNode) return [];
247
+ return argsNode.namedChildren
248
+ .filter((n): n is Node => n !== null)
249
+ // PHP wraps every argument in an `argument` node, so the expression a rule
250
+ // wants to examine sits one level down. Without this unwrap every PHP
251
+ // argument reads as the node type `argument` and no content check matches.
252
+ .map((n) => (n.type === 'argument' ? (n.namedChildren.find((c) => c !== null) ?? n) : n));
253
+ }
254
+
255
+ /**
256
+ * Go writes `a, b := f(), g()` - both sides are lists. When the lists line up
257
+ * we pair them; when they don't (a multi-return call) we bind every name to the
258
+ * single value node, which is honest enough for signature matching and is
259
+ * flagged as a known imprecision in the Go coverage notes.
260
+ */
261
+ function goPairs(target: Node, value: Node): Array<{ t: Node; v: Node }> {
262
+ const targets = target.type === 'expression_list' ? target.namedChildren : [target];
263
+ const values = value.type === 'expression_list' ? value.namedChildren : [value];
264
+ const pairs: Array<{ t: Node; v: Node }> = [];
265
+ for (let i = 0; i < targets.length; i++) {
266
+ const t = targets[i];
267
+ if (!t) continue;
268
+ const v = values.length === targets.length ? values[i] : values[0];
269
+ if (v) pairs.push({ t, v });
270
+ }
271
+ return pairs;
272
+ }
273
+
274
+ /** Last dotted segment: "config.db.password" -> "password". */
275
+ function lastSegment(text: string): string {
276
+ const cleaned = text.replace(/\[["']?|["']?\]/g, '.').replace(/\.+$/, '');
277
+ const parts = cleaned.split('.');
278
+ return (parts[parts.length - 1] ?? cleaned).trim();
279
+ }
280
+
281
+ /**
282
+ * Lower one parsed file into the flat list of shapes rules will see.
283
+ * Order is source order, which makes reports read top-to-bottom like the file.
284
+ */
285
+ export async function extractShapes(file: ParsedFile, language: LanguageId): Promise<Shape[]> {
286
+ const shapes: Shape[] = [];
287
+ const seen = new Set<number>();
288
+
289
+ const callQuery = await compile(file.grammar, 'calls', CALL_QUERIES[language]);
290
+ for (const match of callQuery.matches(file.root)) {
291
+ const call = captured(match, 'call');
292
+ const method = captured(match, 'method');
293
+ if (!call || !method) continue;
294
+ // Java's two method_invocation patterns overlap (object is optional), so the
295
+ // same node can match twice. Dedupe by node id.
296
+ if (seen.has(call.id)) continue;
297
+ seen.add(call.id);
298
+
299
+ const receiver = captured(match, 'receiver');
300
+ const receiverText = collapse(receiver?.text);
301
+ const calleeName = collapse(method.text);
302
+ // Java's `method_invocation` has no `function` field - the receiver and the
303
+ // name are separate children - so we rebuild "receiver.name" by hand there.
304
+ const functionNode = call.childForFieldName('function') ?? call.childForFieldName('constructor');
305
+ const site: CallSite = {
306
+ kind: 'call',
307
+ node: call,
308
+ calleeName,
309
+ receiverText,
310
+ calleeText: collapse(
311
+ functionNode?.text ?? (receiverText ? `${receiverText}.${calleeName}` : calleeName),
312
+ ),
313
+ args: argumentNodes(captured(match, 'args')),
314
+ };
315
+ shapes.push(site);
316
+ }
317
+
318
+ const assignQuery = await compile(file.grammar, 'assignments', ASSIGNMENT_QUERIES[language]);
319
+ for (const match of assignQuery.matches(file.root)) {
320
+ const node = captured(match, 'assign');
321
+ const target = captured(match, 'target');
322
+ const value = captured(match, 'value');
323
+ if (!node || !target || !value) continue;
324
+ if (seen.has(node.id)) continue;
325
+ seen.add(node.id);
326
+
327
+ const pairs = language === 'go' ? goPairs(target, value) : [{ t: target, v: value }];
328
+ for (const pair of pairs) {
329
+ const targetText = collapse(pair.t.text);
330
+ shapes.push({
331
+ kind: 'assignment',
332
+ node,
333
+ targetText,
334
+ targetName: lastSegment(targetText).replace(/^["']|["']$/g, ''),
335
+ value: pair.v,
336
+ } satisfies Assignment);
337
+ }
338
+ }
339
+
340
+ shapes.sort((a, b) => a.node.startIndex - b.node.startIndex);
341
+ return shapes;
342
+ }
343
+
344
+ /** Exposed so a self-check can compile every query against every grammar. */
345
+ export const SHAPE_QUERY_SOURCES = {
346
+ calls: CALL_QUERIES,
347
+ assignments: ASSIGNMENT_QUERIES,
348
+ } as const;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * FILE DISCOVERY
3
+ *
4
+ * Turning "scan this path" into "here is the list of files". Boring code, but
5
+ * two decisions in it matter:
6
+ *
7
+ * 1. We skip dependency and build directories. Scanning node_modules finds
8
+ * thousands of "vulnerabilities" in other people's code that you cannot fix
9
+ * and did not write. Dependency risk is a real and separate problem
10
+ * (Software Composition Analysis) and it deserves its own tool, not noise
11
+ * inside this one.
12
+ *
13
+ * 2. We record the extensions we skipped. That list feeds the coverage report,
14
+ * so a scan of a repo that is 70% Ruby says "we did not look at 412 .rb
15
+ * files" instead of quietly reporting "no issues found".
16
+ */
17
+
18
+ import { readdir, stat } from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { runtimeFileKind } from '../platform/runtime-version.js';
21
+
22
+ const IGNORED_DIRECTORIES = new Set([
23
+ '.git', '.hg', '.svn',
24
+ 'node_modules', 'bower_components', 'jspm_packages',
25
+ 'dist', 'build', 'out', 'target', 'bin', 'obj',
26
+ 'vendor', 'third_party',
27
+ '.venv', 'venv', 'env', '__pycache__', '.tox', 'site-packages', '.mypy_cache',
28
+ '.next', '.nuxt', '.svelte-kit', '.turbo', '.parcel-cache',
29
+ 'coverage', '.nyc_output', '.gradle', '.m2',
30
+ '.idea', '.vscode', '.cache', '.pytest_cache',
31
+ ]);
32
+
33
+ /** 2 MB. Anything larger is almost always generated, minified or a data blob. */
34
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
35
+
36
+ export interface WalkResult {
37
+ readonly files: readonly string[];
38
+ /** Extension -> how many files with it we passed over. Feeds the coverage report. */
39
+ readonly skippedByExtension: ReadonlyMap<string, number>;
40
+ readonly oversizedFiles: readonly string[];
41
+ /** How many files --exclude removed. Printed, never silent. */
42
+ readonly excludedCount: number;
43
+ /**
44
+ * Directories --exclude pruned. Counted separately because we stop walking
45
+ * them, so we honestly do not know how many files were inside. Saying
46
+ * "0 files excluded" after skipping a whole tree would be its own small lie.
47
+ */
48
+ readonly excludedDirectories: readonly string[];
49
+ /**
50
+ * Files that declare which PHP the project runs on - Dockerfiles, compose
51
+ * files, composer.json, `.php-version`. Not source code, so not in `files`;
52
+ * the runtime version check reads them (platform/runtime-version.ts). The
53
+ * same directories are skipped as for code, so a dependency's composer.json
54
+ * under vendor/ is never mistaken for the project's own.
55
+ */
56
+ readonly runtimeFiles: readonly string[];
57
+ }
58
+
59
+ export interface WalkOptions {
60
+ /** Extensions we are able to parse. Everything else is counted and skipped. */
61
+ readonly scannable: readonly string[];
62
+ readonly maxBytes?: number;
63
+ /** Glob-ish patterns from --exclude. */
64
+ readonly exclude?: readonly string[];
65
+ }
66
+
67
+ /**
68
+ * Turn a glob into a regular expression.
69
+ *
70
+ * Supported, deliberately a small subset:
71
+ * ** any number of path segments
72
+ * * anything except a path separator
73
+ * ? one character
74
+ * A pattern with no wildcards and no separator (e.g. `vendor`) matches any path
75
+ * SEGMENT with that name, which is what people mean by `--exclude=vendor`.
76
+ *
77
+ * Everything is matched against the path with forward slashes, so the same
78
+ * pattern works on Windows and Unix.
79
+ */
80
+ export function globToRegExp(pattern: string): RegExp {
81
+ const cleaned = pattern.trim().replace(/\\/g, '/').replace(/\/+$/, '');
82
+ const bare = !/[*?/]/.test(cleaned);
83
+
84
+ const escaped = cleaned
85
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
86
+ .replace(/\*\*/g, '\u0000')
87
+ .replace(/\*/g, '[^/]*')
88
+ .replace(/\?/g, '[^/]')
89
+ .replace(/\u0000/g, '.*');
90
+
91
+ // `vendor` -> matches the segment anywhere in the path.
92
+ if (bare) return new RegExp(`(^|/)${escaped}(/|$)`);
93
+ // `src/**/test.js` or `*.spec.ts` -> match anywhere the shape fits.
94
+ return new RegExp(`(^|/)${escaped}$|^${escaped}$`);
95
+ }
96
+
97
+ export async function collectFiles(target: string, options: WalkOptions): Promise<WalkResult> {
98
+ const scannable = new Set(options.scannable.map((e) => e.toLowerCase()));
99
+ const maxBytes = options.maxBytes ?? MAX_FILE_BYTES;
100
+ const excludePatterns = (options.exclude ?? []).map(globToRegExp);
101
+ let excludedCount = 0;
102
+ const excludedDirectories: string[] = [];
103
+
104
+ /** Matched against the path relative to the scan target, slashes normalised. */
105
+ const isExcluded = (fullPath: string): boolean => {
106
+ if (excludePatterns.length === 0) return false;
107
+ const relative = path.relative(target, fullPath).split(path.sep).join('/');
108
+ return excludePatterns.some((pattern) => pattern.test(relative));
109
+ };
110
+
111
+ const files: string[] = [];
112
+ const skipped = new Map<string, number>();
113
+ const oversized: string[] = [];
114
+ const runtimeFiles: string[] = [];
115
+
116
+ const info = await stat(target);
117
+ if (info.isFile()) {
118
+ // An explicitly named file is always scanned - if the user pointed at it,
119
+ // they meant it, even if it is huge or in node_modules.
120
+ files.push(target);
121
+ return {
122
+ files,
123
+ skippedByExtension: skipped,
124
+ oversizedFiles: oversized,
125
+ excludedCount,
126
+ excludedDirectories,
127
+ runtimeFiles: runtimeFileKind(path.basename(target)) ? [target] : [],
128
+ };
129
+ }
130
+
131
+ const queue: string[] = [target];
132
+ while (queue.length > 0) {
133
+ const dir = queue.pop();
134
+ if (!dir) break;
135
+
136
+ let entries;
137
+ try {
138
+ entries = await readdir(dir, { withFileTypes: true });
139
+ } catch {
140
+ continue; // unreadable directory - permissions, broken symlink, race
141
+ }
142
+
143
+ for (const entry of entries) {
144
+ const full = path.join(dir, entry.name);
145
+
146
+ if (entry.isDirectory()) {
147
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue;
148
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
149
+ // Pruning an excluded directory here saves walking its whole subtree.
150
+ if (isExcluded(full)) {
151
+ excludedDirectories.push(path.relative(target, full).split(path.sep).join('/'));
152
+ continue;
153
+ }
154
+ queue.push(full);
155
+ continue;
156
+ }
157
+ if (!entry.isFile()) continue;
158
+
159
+ if (isExcluded(full)) {
160
+ excludedCount++;
161
+ continue;
162
+ }
163
+
164
+ if (runtimeFileKind(entry.name)) runtimeFiles.push(full);
165
+
166
+ const ext = path.extname(entry.name).toLowerCase();
167
+ if (!scannable.has(ext)) {
168
+ if (ext) skipped.set(ext, (skipped.get(ext) ?? 0) + 1);
169
+ continue;
170
+ }
171
+
172
+ try {
173
+ const fileInfo = await stat(full);
174
+ if (fileInfo.size > maxBytes) {
175
+ oversized.push(full);
176
+ continue;
177
+ }
178
+ } catch {
179
+ continue;
180
+ }
181
+
182
+ files.push(full);
183
+ }
184
+ }
185
+
186
+ files.sort();
187
+ excludedDirectories.sort();
188
+ return {
189
+ files,
190
+ skippedByExtension: skipped,
191
+ oversizedFiles: oversized,
192
+ excludedCount,
193
+ excludedDirectories,
194
+ runtimeFiles,
195
+ };
196
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A human-readable printer for syntax trees.
3
+ *
4
+ * This exists purely so you can SEE what the parser sees. Run:
5
+ * npm run ast -- somefile.js
6
+ * and compare the output to the source. Once the shape of the tree stops
7
+ * looking mysterious, writing detection rules becomes ordinary programming.
8
+ *
9
+ * Two concepts show up in the output:
10
+ *
11
+ * NAMED vs ANONYMOUS nodes. `db.query(x)` contains the parentheses and the
12
+ * dot as nodes too, but they carry no meaning of their own - tree-sitter calls
13
+ * those "anonymous". By default we hide them, because rules almost never care.
14
+ *
15
+ * FIELDS. Some children have a role, not just a position. In
16
+ * `binary_expression`, the children are labelled `left`, `operator`, `right`.
17
+ * Fields are how a rule says "the thing being called" instead of "child #0",
18
+ * which is what makes rules survive grammar updates.
19
+ */
20
+
21
+ import type { Node } from 'web-tree-sitter';
22
+
23
+ export interface AstDumpOptions {
24
+ /** Show punctuation/keyword nodes too. Default false. */
25
+ readonly includeAnonymous?: boolean;
26
+ /** Stop after this depth. Default 12. */
27
+ readonly maxDepth?: number;
28
+ /** Stop after this many lines, so a huge file doesn't flood the terminal. */
29
+ readonly maxLines?: number;
30
+ /** Show the source text of leaf nodes. Default true. */
31
+ readonly showText?: boolean;
32
+ }
33
+
34
+ export function dumpAst(root: Node, options: AstDumpOptions = {}): string {
35
+ const includeAnonymous = options.includeAnonymous ?? false;
36
+ const maxDepth = options.maxDepth ?? 12;
37
+ const maxLines = options.maxLines ?? 400;
38
+ const showText = options.showText ?? true;
39
+
40
+ const lines: string[] = [];
41
+
42
+ const walk = (node: Node, depth: number, fieldName: string | null, prefix: string, isLast: boolean): void => {
43
+ if (lines.length >= maxLines) return;
44
+
45
+ const connector = depth === 0 ? '' : isLast ? '└─ ' : '├─ ';
46
+ const label = fieldName ? `${fieldName}: ${node.type}` : node.type;
47
+ const position = `[${node.startPosition.row + 1}:${node.startPosition.column + 1}]`;
48
+
49
+ // Show source text only for leaves - printing it for a whole function is noise.
50
+ let text = '';
51
+ if (showText && node.namedChildCount === 0) {
52
+ const raw = (node.text ?? '').replace(/\s+/g, ' ').trim();
53
+ if (raw.length > 0) text = ` "${raw.length > 48 ? `${raw.slice(0, 47)}…` : raw}"`;
54
+ }
55
+
56
+ lines.push(`${prefix}${connector}${label} ${position}${text}`);
57
+
58
+ if (depth >= maxDepth) {
59
+ if (node.namedChildCount > 0) {
60
+ lines.push(`${prefix}${isLast ? ' ' : '│ '}└─ … (${node.namedChildCount} more, depth limit)`);
61
+ }
62
+ return;
63
+ }
64
+
65
+ const children: Array<{ node: Node; field: string | null }> = [];
66
+ for (let i = 0; i < node.childCount; i++) {
67
+ const child = node.child(i);
68
+ if (!child) continue;
69
+ if (!includeAnonymous && !child.isNamed) continue;
70
+ children.push({ node: child, field: node.fieldNameForChild(i) });
71
+ }
72
+
73
+ const childPrefix = depth === 0 ? '' : prefix + (isLast ? ' ' : '│ ');
74
+ children.forEach((child, index) => {
75
+ walk(child.node, depth + 1, child.field, childPrefix, index === children.length - 1);
76
+ });
77
+ };
78
+
79
+ walk(root, 0, null, '', true);
80
+
81
+ if (lines.length >= maxLines) {
82
+ lines.push(`… output truncated at ${maxLines} lines (use --max-lines to raise)`);
83
+ }
84
+ return lines.join('\n');
85
+ }