@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,232 @@
1
+ /**
2
+ * LANGUAGE REGISTRY
3
+ * =================
4
+ *
5
+ * A scanner has to answer "what language is this file?" before it can answer
6
+ * "is this file dangerous?". This file is the single place that knows.
7
+ *
8
+ * ARCHITECTURAL DECISION (matters in Phase 5, language expansion):
9
+ * languages are DATA, not code. Adding Ruby later means adding one object to
10
+ * the array below plus one query file per rule - it does not mean editing the
11
+ * parser, the engine, the CLI, or any existing rule. If adding a language ever
12
+ * requires touching the engine, we designed it wrong.
13
+ *
14
+ * WHY WASM GRAMMARS INSTEAD OF NATIVE ONES:
15
+ * Tree-sitter ships two ways to run a grammar - a native C module you compile
16
+ * on your machine, or a WebAssembly (.wasm) file that runs anywhere. We chose
17
+ * WASM for three reasons:
18
+ * 1. No compiler toolchain needed. `npm install` just works, on any OS.
19
+ * 2. The SAME grammar files run in a browser. Phase 2 is a browser UI - it
20
+ * will run this identical engine instead of a reimplementation of it.
21
+ * 3. The tree-sitter-wasms package already ships 36 grammars, so Phase 5's
22
+ * language expansion is mostly unlocking files we already have on disk.
23
+ */
24
+
25
+ /** The languages Defuse supports. */
26
+ export type LanguageId =
27
+ | 'javascript'
28
+ | 'typescript'
29
+ | 'python'
30
+ | 'java'
31
+ | 'go'
32
+ | 'php'
33
+ | 'c'
34
+ | 'cpp';
35
+
36
+ export interface Dialect {
37
+ /** File extensions handled by this dialect. */
38
+ readonly extensions: readonly string[];
39
+ /** Grammar name inside tree-sitter-wasms, i.e. tree-sitter-<name>.wasm */
40
+ readonly grammar: string;
41
+ }
42
+
43
+ export interface LanguageSpec {
44
+ readonly id: LanguageId;
45
+ readonly displayName: string;
46
+ /** Primary extensions -> primary grammar. */
47
+ readonly extensions: readonly string[];
48
+ readonly grammar: string;
49
+ /**
50
+ * Variants of the same language that need a DIFFERENT grammar file but share
51
+ * rules. e.g. .tsx needs the tsx grammar, but a SQL-injection rule written for
52
+ * TypeScript matches .tsx code perfectly well.
53
+ */
54
+ readonly dialects?: readonly Dialect[];
55
+ /** Interpreter names seen in a `#!` shebang line, for extensionless scripts. */
56
+ readonly shebangs?: readonly string[];
57
+ /** Honest notes about what this language's support does and does not include. */
58
+ readonly notes: string;
59
+ }
60
+
61
+ export const LANGUAGES: readonly LanguageSpec[] = [
62
+ {
63
+ id: 'javascript',
64
+ displayName: 'JavaScript',
65
+ extensions: ['.js', '.mjs', '.cjs', '.jsx'],
66
+ grammar: 'javascript',
67
+ shebangs: ['node'],
68
+ notes:
69
+ 'The javascript grammar parses JSX natively, so .jsx needs no separate grammar. ' +
70
+ 'Vue/Svelte single-file components are NOT supported - their <script> blocks are ' +
71
+ 'not extracted. Documented gap, not a silent one.',
72
+ },
73
+ {
74
+ id: 'typescript',
75
+ displayName: 'TypeScript',
76
+ extensions: ['.ts', '.mts', '.cts'],
77
+ grammar: 'typescript',
78
+ dialects: [{ extensions: ['.tsx'], grammar: 'tsx' }],
79
+ notes:
80
+ 'Type annotations are parsed but NOT used for analysis. Knowing that ' +
81
+ 'a parameter is typed `string` tells us nothing about whether it is attacker-' +
82
+ 'controlled, so we deliberately do not pretend it does. .d.ts files are skipped.',
83
+ },
84
+ {
85
+ id: 'python',
86
+ displayName: 'Python',
87
+ extensions: ['.py', '.pyi'],
88
+ grammar: 'python',
89
+ shebangs: ['python', 'python2', 'python3'],
90
+ notes:
91
+ 'Python 3 grammar. Python 2 print-statements will produce parse errors, which we ' +
92
+ 'report rather than swallow.',
93
+ },
94
+ {
95
+ id: 'java',
96
+ displayName: 'Java',
97
+ extensions: ['.java'],
98
+ grammar: 'java',
99
+ notes:
100
+ 'Plain Java only. .jsp, .kt (Kotlin) and .scala are separate languages and are not ' +
101
+ 'covered by these rules even though a Java-ish pattern might appear in them.',
102
+ },
103
+ {
104
+ id: 'php',
105
+ displayName: 'PHP',
106
+ extensions: ['.php', '.phtml'],
107
+ grammar: 'php',
108
+ notes:
109
+ 'PHP source only. A .php file is parsed as a whole document, so HTML outside ' +
110
+ '<?php ?> is text rather than markup - an XSS sink written in raw HTML with a ' +
111
+ 'short echo tag (<?= $x ?>) IS matched, but HTML attributes around it are not ' +
112
+ 'analysed. Blade (.blade.php) and Twig (.twig) templates are NOT parsed.',
113
+ },
114
+ {
115
+ id: 'go',
116
+ displayName: 'Go',
117
+ extensions: ['.go'],
118
+ grammar: 'go',
119
+ notes:
120
+ 'Go source only; go.mod, go.sum and generated protobuf files are skipped as noise.',
121
+ },
122
+ {
123
+ id: 'c',
124
+ displayName: 'C',
125
+ extensions: ['.c'],
126
+ grammar: 'c',
127
+ notes:
128
+ 'MEMORY SAFETY IS NOT ANALYSED. C is famous for buffer overflows, use-after-free, ' +
129
+ 'double-free and integer overflow, and this engine models none of them - it tracks ' +
130
+ 'values, not sizes, allocations or pointer aliasing. What IS analysed is the same ' +
131
+ 'thing analysed in every other language: attacker-controlled data reaching a ' +
132
+ 'dangerous call. In C that means shells (system, popen, exec*), format strings ' +
133
+ '(printf-family, where a tainted FORMAT argument is CWE-134 and a taint engine ' +
134
+ 'catches it exactly), and copies with no length bound (strcpy, strcat, sprintf, ' +
135
+ 'gets). A finding that data reaches an unbounded copy is NOT a claim that the ' +
136
+ 'destination is too small - that needs size analysis we do not have - so the ' +
137
+ 'wording says "reaches a copy with no length bound" and means only that. ' +
138
+ 'THE PREPROCESSOR IS NOT RUN: #include is not followed, #ifdef branches are all ' +
139
+ 'parsed as though taken, and a macro that hides a sink (#define RUN(x) system(x)) ' +
140
+ 'is invisible.',
141
+ },
142
+ {
143
+ id: 'cpp',
144
+ displayName: 'C++',
145
+ extensions: ['.cpp', '.cc', '.cxx', '.c++', '.hpp', '.hh', '.hxx', '.h'],
146
+ grammar: 'cpp',
147
+ notes:
148
+ 'Every C limitation applies, plus: templates are parsed but not instantiated, so a ' +
149
+ 'sink reached only through a template specialisation is missed, and operator ' +
150
+ 'overloading is not resolved - a custom operator<< that executes a command reads ' +
151
+ 'as an ordinary stream write. ' +
152
+ 'THE .h EXTENSION IS TREATED AS C++, deliberately and against appearances. It is ' +
153
+ 'ambiguous - most .h files in the world are C - but the two grammars are not ' +
154
+ 'symmetric: measured on this project\'s own fixtures, the C++ grammar parses C ' +
155
+ 'with zero error nodes while the C grammar produces eleven on C++. Choosing the ' +
156
+ 'superset costs a C project a cosmetic mislabel in the per-language counts; ' +
157
+ 'choosing C would cost a C++ header every std:: sink in it, silently. A wrong ' +
158
+ 'label is better than a missing finding.',
159
+ },
160
+ ];
161
+
162
+ /** Extension -> { language, grammar-to-use }. Built once at module load. */
163
+ const BY_EXTENSION = new Map<string, { spec: LanguageSpec; grammar: string }>();
164
+ for (const spec of LANGUAGES) {
165
+ for (const ext of spec.extensions) {
166
+ BY_EXTENSION.set(ext, { spec, grammar: spec.grammar });
167
+ }
168
+ for (const dialect of spec.dialects ?? []) {
169
+ for (const ext of dialect.extensions) {
170
+ BY_EXTENSION.set(ext, { spec, grammar: dialect.grammar });
171
+ }
172
+ }
173
+ }
174
+
175
+ export interface LanguageMatch {
176
+ readonly spec: LanguageSpec;
177
+ /** Which .wasm grammar to actually load (may differ from spec.grammar for dialects). */
178
+ readonly grammar: string;
179
+ /** How we decided. Reported in --json so detection is auditable, never magic. */
180
+ readonly detectedBy: 'extension' | 'shebang';
181
+ }
182
+
183
+ /** Files we never scan, because scanning them is noise, not security. */
184
+ const SKIP_FILE_PATTERNS: readonly RegExp[] = [
185
+ /\.d\.ts$/,
186
+ /\.min\.js$/,
187
+ /\.bundle\.js$/,
188
+ /\.pb\.go$/,
189
+ /_pb2\.py$/,
190
+ /\.generated\./,
191
+ ];
192
+
193
+ /**
194
+ * Decide which language a file is.
195
+ *
196
+ * Step 1: file extension. Boring, fast, right ~99% of the time.
197
+ * Step 2: if there is no useful extension, read the first line and look for a
198
+ * "shebang" - the `#!/usr/bin/env python3` line that tells Unix which
199
+ * interpreter to run. This is how we catch a CLI script named `deploy`
200
+ * with no extension at all.
201
+ *
202
+ * Returns null for "we don't handle this", which is a normal answer, not an error.
203
+ */
204
+ export function detectLanguage(filePath: string, firstLine?: string): LanguageMatch | null {
205
+ const lower = filePath.toLowerCase();
206
+
207
+ for (const pattern of SKIP_FILE_PATTERNS) {
208
+ if (pattern.test(lower)) return null;
209
+ }
210
+
211
+ const dot = lower.lastIndexOf('.');
212
+ const slash = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
213
+ if (dot > slash) {
214
+ const hit = BY_EXTENSION.get(lower.slice(dot));
215
+ if (hit) return { spec: hit.spec, grammar: hit.grammar, detectedBy: 'extension' };
216
+ }
217
+
218
+ if (firstLine?.startsWith('#!')) {
219
+ // "#!/usr/bin/env python3" -> we want the word "python3"
220
+ const words = firstLine.slice(2).trim().split(/[\s/]+/);
221
+ for (const spec of LANGUAGES) {
222
+ if (spec.shebangs?.some((s) => words.includes(s))) {
223
+ return { spec, grammar: spec.grammar, detectedBy: 'shebang' };
224
+ }
225
+ }
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ /** Every extension we will attempt to open. Used by the file walker. */
232
+ export const SCANNABLE_EXTENSIONS: readonly string[] = [...BY_EXTENSION.keys()];
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Turning a tree-sitter node into a human-readable, reproducible location.
3
+ *
4
+ * Tree-sitter counts rows and columns from 0. Editors, terminals and humans
5
+ * count from 1. Every off-by-one bug in a scanner's output lives right here, so
6
+ * the conversion happens in exactly ONE place: this file.
7
+ */
8
+
9
+ import type { Node } from 'web-tree-sitter';
10
+ import type { CodeLocation } from '../core/finding.js';
11
+
12
+ const MAX_SNIPPET = 160;
13
+
14
+ /** Walk up the tree collecting node types: "program > call_expression > ...". */
15
+ export function astPath(node: Node, maxDepth = 5): string {
16
+ const parts: string[] = [];
17
+ let current: Node | null = node;
18
+ while (current && parts.length < maxDepth) {
19
+ parts.unshift(current.type);
20
+ current = current.parent;
21
+ }
22
+ return parts.join(' > ');
23
+ }
24
+
25
+ /**
26
+ * Build the location record that goes into a finding.
27
+ * `file` is passed in because a node does not know which file it came from -
28
+ * tree-sitter only ever sees a string of text.
29
+ */
30
+ export function nodeLocation(node: Node, file: string): CodeLocation {
31
+ const raw = node.text ?? '';
32
+ const collapsed = raw.replace(/\s+/g, ' ').trim();
33
+ return {
34
+ file,
35
+ startLine: node.startPosition.row + 1,
36
+ startColumn: node.startPosition.column + 1,
37
+ endLine: node.endPosition.row + 1,
38
+ endColumn: node.endPosition.column + 1,
39
+ astNodeType: node.type,
40
+ astNodePath: astPath(node),
41
+ snippet:
42
+ collapsed.length > MAX_SNIPPET ? `${collapsed.slice(0, MAX_SNIPPET - 1)}…` : collapsed,
43
+ };
44
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * THE NODE SHELL for grammar loading and file reading.
3
+ *
4
+ * Everything in here is the half of the tool that only makes sense on a
5
+ * machine with a filesystem. The browser has its own equivalent in
6
+ * ui/worker.js, and the analysis code in between never learns which one it got.
7
+ */
8
+
9
+ import { readFile } from 'node:fs/promises';
10
+ import { createRequire } from 'node:module';
11
+ import path from 'node:path';
12
+ import { configureParsing, parseSourceFile, type ParseOutcome } from './parser.js';
13
+
14
+ const require = createRequire(import.meta.url);
15
+
16
+ /** Folder inside node_modules that holds the prebuilt .wasm grammar files. */
17
+ const GRAMMAR_DIR = path.join(
18
+ path.dirname(require.resolve('tree-sitter-wasms/package.json')),
19
+ 'out',
20
+ );
21
+
22
+ /** Point the parser at node_modules. Called once, before any scan. */
23
+ export function useNodeGrammars(): void {
24
+ configureParsing({
25
+ loadGrammar: (grammar) => path.join(GRAMMAR_DIR, `tree-sitter-${grammar}.wasm`),
26
+ });
27
+ }
28
+
29
+ /** Read a file off disk, then hand the text to the platform-agnostic parser. */
30
+ export async function parseFile(filePath: string): Promise<ParseOutcome> {
31
+ try {
32
+ const source = await readFile(filePath, 'utf8');
33
+ return parseSourceFile(filePath, source);
34
+ } catch (error) {
35
+ return {
36
+ status: 'skipped',
37
+ path: filePath,
38
+ reason: `unreadable: ${(error as Error).message}`,
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * WHAT IS AN AST? (read this once, then the rest of the project makes sense)
3
+ * =========================================================================
4
+ *
5
+ * When you write:
6
+ *
7
+ * const total = price + tax;
8
+ *
9
+ * your computer does not see a sentence. It sees a TREE. "AST" stands for
10
+ * Abstract Syntax Tree: "abstract" because it throws away things that don't
11
+ * change meaning (spaces, most punctuation), "syntax" because it captures the
12
+ * grammar of the language, and "tree" because code nests inside code.
13
+ *
14
+ * That line becomes something like:
15
+ *
16
+ * lexical_declaration
17
+ * +- variable_declarator
18
+ * +- name: identifier "total"
19
+ * +- value: binary_expression
20
+ * +- left: identifier "price"
21
+ * +- operator: "+"
22
+ * +- right: identifier "tax"
23
+ *
24
+ * Why this matters enormously for a security scanner:
25
+ *
26
+ * The old way (what most cheap scanners do, and what earlier versions of this
27
+ * project did) is to search the raw TEXT with regular expressions. Text search
28
+ * cannot tell the difference between:
29
+ *
30
+ * db.query("SELECT * FROM users WHERE id = " + userId) <- real problem
31
+ * // db.query("SELECT * FROM users WHERE id = " + userId) <- a comment
32
+ * log("db.query is unsafe when you do \" + userId\"") <- a string
33
+ *
34
+ * All three contain the same characters. Only one is a vulnerability. An AST
35
+ * knows which is which, because the parser already decided "this is a comment",
36
+ * "this is a string literal", "this is a real function call". We get that
37
+ * accuracy for free, and it is the entire reason we use tree-sitter.
38
+ *
39
+ * Tree-sitter is Github's parser toolkit. It is fast, it is error-tolerant
40
+ * (it produces a usable tree even for code that doesn't compile - important,
41
+ * because real repositories contain broken files), and it has mature grammars
42
+ * for dozens of languages that all behave the same way.
43
+ */
44
+
45
+ import { Language, Parser, type Node, type Tree } from 'web-tree-sitter';
46
+ import { detectLanguage, type LanguageSpec } from './languages.js';
47
+
48
+ /**
49
+ * WHERE DO THE .wasm GRAMMARS COME FROM?
50
+ *
51
+ * On Node they are files in node_modules. In a browser they are URLs fetched
52
+ * over HTTP. That is the ONLY difference between the two platforms in the whole
53
+ * analysis path, so it is the only thing abstracted: everything else - parsing,
54
+ * rules, taint - is identical code running in both places.
55
+ *
56
+ * A loader returns whatever `Language.load` accepts: a path, a URL, or bytes.
57
+ */
58
+ export type GrammarLoader = (grammar: string) => string | Uint8Array | Promise<string | Uint8Array>;
59
+
60
+ let grammarLoader: GrammarLoader | null = null;
61
+ let engineInit: (() => Promise<void>) | null = null;
62
+
63
+ /** Called once by whichever shell is in charge (CLI or browser worker). */
64
+ export function configureParsing(options: {
65
+ loadGrammar: GrammarLoader;
66
+ init?: () => Promise<void>;
67
+ }): void {
68
+ grammarLoader = options.loadGrammar;
69
+ engineInit = options.init ?? null;
70
+ engineReady = null;
71
+ parserCache.clear();
72
+ languageCache.clear();
73
+ }
74
+
75
+ let engineReady: Promise<void> | null = null;
76
+
77
+ /**
78
+ * Boot the WebAssembly runtime. Must happen once before any parsing.
79
+ * We memoise the promise so calling this from ten places still only boots once.
80
+ */
81
+ export async function initEngine(): Promise<void> {
82
+ engineReady ??= engineInit ? engineInit() : Parser.init();
83
+ await engineReady;
84
+ }
85
+
86
+ /** Grammar name -> loaded Parser. Loading a grammar costs ~10-40ms, so we cache. */
87
+ const parserCache = new Map<string, Parser>();
88
+
89
+ async function getParser(grammar: string): Promise<Parser> {
90
+ const cached = parserCache.get(grammar);
91
+ if (cached) return cached;
92
+
93
+ await initEngine();
94
+ if (!grammarLoader) {
95
+ throw new Error(
96
+ 'No grammar loader configured. Call configureParsing() first - ' +
97
+ 'src/parse/node-grammars.ts for the CLI, ui/worker.js for the browser.',
98
+ );
99
+ }
100
+ const language = await Language.load(await grammarLoader(grammar));
101
+ const parser = new Parser();
102
+ parser.setLanguage(language);
103
+ parserCache.set(grammar, parser);
104
+ return parser;
105
+ }
106
+
107
+ /** The tree-sitter Language object, needed to compile queries. */
108
+ const languageCache = new Map<string, Language>();
109
+
110
+ export async function getLanguage(grammar: string): Promise<Language> {
111
+ const cached = languageCache.get(grammar);
112
+ if (cached) return cached;
113
+ const parser = await getParser(grammar);
114
+ const language = parser.language;
115
+ if (!language) throw new Error(`Grammar ${grammar} loaded but exposed no language`);
116
+ languageCache.set(grammar, language);
117
+ return language;
118
+ }
119
+
120
+ /** A place where tree-sitter could not make sense of the source. */
121
+ export interface ParseIssue {
122
+ readonly kind: 'ERROR' | 'MISSING';
123
+ readonly line: number;
124
+ readonly column: number;
125
+ readonly text: string;
126
+ }
127
+
128
+ /** Everything downstream code needs about one parsed file. */
129
+ export interface ParsedFile {
130
+ readonly path: string;
131
+ readonly language: LanguageSpec;
132
+ /** May differ from language.grammar for dialects (.tsx uses the tsx grammar). */
133
+ readonly grammar: string;
134
+ readonly detectedBy: 'extension' | 'shebang';
135
+ readonly source: string;
136
+ readonly tree: Tree;
137
+ readonly root: Node;
138
+ /**
139
+ * Parse problems, reported not hidden. A file with issues is still scanned -
140
+ * tree-sitter recovers - but the report says so, because findings from a
141
+ * partially-understood file deserve less trust.
142
+ */
143
+ readonly parseIssues: readonly ParseIssue[];
144
+ }
145
+
146
+ export type ParseOutcome =
147
+ | { readonly status: 'parsed'; readonly file: ParsedFile }
148
+ | { readonly status: 'skipped'; readonly path: string; readonly reason: string };
149
+
150
+ const MAX_REPORTED_ISSUES = 25;
151
+
152
+ /**
153
+ * Walk the whole tree looking for the two ways tree-sitter admits defeat:
154
+ * ERROR - "I found characters here I cannot fit into the grammar"
155
+ * MISSING - "the grammar says something must be here; I inserted a fake one"
156
+ *
157
+ * We use an explicit stack rather than recursion because deeply nested files
158
+ * (minified code, generated code) can blow the JavaScript call stack.
159
+ */
160
+ function collectParseIssues(root: Node): ParseIssue[] {
161
+ if (!root.hasError) return [];
162
+ const issues: ParseIssue[] = [];
163
+ const stack: Node[] = [root];
164
+
165
+ while (stack.length > 0 && issues.length < MAX_REPORTED_ISSUES) {
166
+ const node = stack.pop();
167
+ if (!node) break;
168
+
169
+ if (node.type === 'ERROR' || node.isMissing) {
170
+ issues.push({
171
+ kind: node.isMissing ? 'MISSING' : 'ERROR',
172
+ line: node.startPosition.row + 1,
173
+ column: node.startPosition.column + 1,
174
+ text: (node.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60),
175
+ });
176
+ // Don't descend into an error subtree; its children are noise.
177
+ continue;
178
+ }
179
+
180
+ // Only descend where an error actually lives - a huge speed win.
181
+ if (node.hasError) {
182
+ for (let i = node.childCount - 1; i >= 0; i--) {
183
+ const child = node.child(i);
184
+ if (child) stack.push(child);
185
+ }
186
+ }
187
+ }
188
+ return issues;
189
+ }
190
+
191
+ /** Parse a string of code you already have in memory. Used by tests and by `ast`. */
192
+ export async function parseSource(source: string, grammar: string): Promise<Tree> {
193
+ const parser = await getParser(grammar);
194
+ const tree = parser.parse(source);
195
+ if (!tree) throw new Error(`Parser returned no tree for grammar ${grammar}`);
196
+ return tree;
197
+ }
198
+
199
+ /**
200
+ * Parse WITHOUT awaiting, for a grammar that has already been loaded.
201
+ *
202
+ * WHY THIS EXISTS. A syntax tree lives in WebAssembly memory, which the
203
+ * JavaScript garbage collector cannot see into and will never reclaim; the only
204
+ * way to free one is to call tree.delete() by hand. Holding every tree in a
205
+ * repository at once therefore costs about sixty times the source - measured,
206
+ * not guessed - and that is the ceiling a large scan dies against.
207
+ *
208
+ * The fix is to free trees and parse a file again if something needs it later.
209
+ * "Later" is inside the tracer, which is synchronous all the way down, so the
210
+ * re-parse has to be synchronous too.
211
+ *
212
+ * It is safe to demand that here because loading is what is actually async, and
213
+ * by the time anything re-parses, every grammar in the scan has already been
214
+ * loaded during the first pass. If that assumption ever breaks, this throws
215
+ * rather than silently returning nothing - a missing tree would look exactly
216
+ * like a file with no vulnerabilities in it.
217
+ */
218
+ export function parseSourceSync(source: string, grammar: string): Tree {
219
+ const parser = parserCache.get(grammar);
220
+ if (!parser) {
221
+ throw new Error(
222
+ `Grammar ${grammar} is not loaded, so it cannot be parsed synchronously. ` +
223
+ 'Every grammar must be loaded by the first (async) pass before anything re-parses.',
224
+ );
225
+ }
226
+ const tree = parser.parse(source);
227
+ if (!tree) throw new Error(`Parser returned no tree for grammar ${grammar}`);
228
+ return tree;
229
+ }
230
+
231
+ /**
232
+ * Work out a file's language and parse it, from text already in memory.
233
+ * No filesystem, no platform assumptions - this is what the browser calls too.
234
+ */
235
+ export async function parseSourceFile(filePath: string, source: string): Promise<ParseOutcome> {
236
+ // A binary file that happens to end in .js would waste a parse and produce
237
+ // garbage findings. NUL bytes are the cheap, reliable binary tell.
238
+ if (source.includes('\u0000')) {
239
+ return { status: 'skipped', path: filePath, reason: 'binary file' };
240
+ }
241
+
242
+ const firstLine = source.slice(0, source.indexOf('\n') + 1 || 200);
243
+ const match = detectLanguage(filePath, firstLine);
244
+ if (!match) {
245
+ return { status: 'skipped', path: filePath, reason: 'unsupported file type' };
246
+ }
247
+
248
+ const tree = await parseSource(source, match.grammar);
249
+ return {
250
+ status: 'parsed',
251
+ file: {
252
+ path: filePath,
253
+ language: match.spec,
254
+ grammar: match.grammar,
255
+ detectedBy: match.detectedBy,
256
+ source,
257
+ tree,
258
+ root: tree.rootNode,
259
+ parseIssues: collectParseIssues(tree.rootNode),
260
+ },
261
+ };
262
+ }