@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
package/src/cli.ts ADDED
@@ -0,0 +1,747 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE COMMAND-LINE INTERFACE
4
+ *
5
+ * Hand-rolled argument parsing, no library. It is about 60 lines, you can read
6
+ * all of them, and it means the tool has exactly two runtime dependencies -
7
+ * both of them the parser.
8
+ *
9
+ * Commands:
10
+ * defuse scan <path> scan a file or directory
11
+ * defuse ast <file> print the syntax tree (learning / debugging)
12
+ * defuse rules list the rules and what they do NOT cover
13
+ * defuse doctor check every grammar loads and every query compiles
14
+ *
15
+ * EXIT CODES matter for CI:
16
+ * 0 nothing at or above the --fail-on threshold
17
+ * 1 findings at or above the threshold
18
+ * 2 the tool itself failed (bad arguments, unreadable path, internal error)
19
+ */
20
+
21
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
22
+ import { stat } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { SEVERITY_ORDER, type Severity } from './core/finding.js';
25
+ import { buildCoverageReport } from './core/coverage.js';
26
+ import { LICENCE_LINE, LICENCE_SPDX } from './core/licence.js';
27
+ import { scan, type ScanResult } from './engine/scan.js';
28
+ import { SHAPE_QUERY_SOURCES } from './engine/shapes.js';
29
+ import { dumpAst } from './parse/astDump.js';
30
+ import { detectLanguage, LANGUAGES } from './parse/languages.js';
31
+ import { getLanguage, initEngine } from './parse/parser.js';
32
+ import { parseFile, useNodeGrammars } from './parse/node-grammars.js';
33
+ import { checkDependencies, findLockfiles, queryOsv } from './platform/deps.js';
34
+ import { RUNTIME_VERSION_RULE } from './platform/runtime-version.js';
35
+ import { ALL_RULES, validateRegistry } from './rules/registry.js';
36
+ import { color, g, setAsciiMode, wrapText } from './report/colors.js';
37
+ import { BANNER_WIDTH, bannerLines } from './report/mark.js';
38
+ import { renderHuman } from './report/human.js';
39
+ import { renderJson } from './report/json.js';
40
+ import { renderSarif } from './report/sarif.js';
41
+ import { renderHtmlReport } from './report/html-report.js';
42
+
43
+ interface Args {
44
+ readonly command: string;
45
+ readonly positional: readonly string[];
46
+ readonly flags: ReadonlyMap<string, string | true>;
47
+ }
48
+
49
+ /**
50
+ * Flags that TAKE A VALUE, so `--explain xss` works as well as `--explain=xss`.
51
+ *
52
+ * Without this list the parser cannot tell `--explain xss` (one flag with a
53
+ * value) from `--json somefile.js` (a boolean flag followed by a path) - both
54
+ * are "a flag then a word". Every real CLI parser solves it the same way: by
55
+ * being told in advance which flags expect something after them.
56
+ */
57
+ const VALUE_FLAGS = new Set([
58
+ 'explain',
59
+ 'only',
60
+ 'exclude',
61
+ 'min-severity',
62
+ 'fail-on',
63
+ 'depth',
64
+ 'max-lines',
65
+ 'report',
66
+ 'report-name',
67
+ 'report-by',
68
+ 'report-note',
69
+ ]);
70
+
71
+ function parseArgs(argv: readonly string[]): Args {
72
+ const positional: string[] = [];
73
+ const flags = new Map<string, string | true>();
74
+
75
+ for (let i = 0; i < argv.length; i++) {
76
+ const token = argv[i];
77
+ if (token === undefined) continue;
78
+
79
+ if (token.startsWith('--')) {
80
+ const body = token.slice(2);
81
+ const eq = body.indexOf('=');
82
+ if (eq !== -1) {
83
+ flags.set(body.slice(0, eq), body.slice(eq + 1));
84
+ continue;
85
+ }
86
+ // `--explain xss`: consume the next token as this flag's value, but only
87
+ // if it exists and is not itself a flag.
88
+ const next = argv[i + 1];
89
+ if (VALUE_FLAGS.has(body) && next !== undefined && !next.startsWith('-')) {
90
+ flags.set(body, next);
91
+ i++;
92
+ continue;
93
+ }
94
+ flags.set(body, true);
95
+ } else if (token.startsWith('-') && token.length > 1) {
96
+ flags.set(token.slice(1), true);
97
+ } else {
98
+ positional.push(token);
99
+ }
100
+ }
101
+
102
+ return { command: positional[0] ?? '', positional: positional.slice(1), flags };
103
+ }
104
+
105
+ function flagString(args: Args, name: string): string | undefined {
106
+ const value = args.flags.get(name);
107
+ return typeof value === 'string' ? value : undefined;
108
+ }
109
+
110
+ const SEVERITIES = new Set(Object.keys(SEVERITY_ORDER));
111
+
112
+ function parseSeverity(value: string | undefined, fallback: Severity): Severity {
113
+ if (!value) return fallback;
114
+ if (!SEVERITIES.has(value)) {
115
+ throw new Error(`unknown severity '${value}'. Use one of: ${[...SEVERITIES].join(', ')}`);
116
+ }
117
+ return value as Severity;
118
+ }
119
+
120
+
121
+ /* --------------------------------------------------------------- config -- */
122
+
123
+ /**
124
+ * Per-project settings, so a repository can carry its own scan policy.
125
+ *
126
+ * Without this, every option lives in whichever command line last ran it -
127
+ * which means a CI job and a developer's terminal can silently disagree about
128
+ * what counts as a failure. A file in the repo is the only place both of them
129
+ * can read the same answer from.
130
+ *
131
+ * DELIBERATELY SMALL. Four keys, all of which already exist as flags, and no
132
+ * ability to enable, disable or reconfigure a RULE. A config that can turn
133
+ * rules off invites a repo to quietly switch off the ones it fails, and the
134
+ * finding disappears with no record. Suppression already has an honest form -
135
+ * `defuse:ignore <rule> - <reason>` on the line, which the report counts
136
+ * and lists.
137
+ *
138
+ * The command line always wins, so a person can override the file without
139
+ * editing it, and `--no-config` ignores it entirely.
140
+ */
141
+ interface FileConfig {
142
+ readonly exclude?: readonly string[];
143
+ readonly only?: readonly string[];
144
+ readonly failOn?: string;
145
+ readonly minSeverity?: string;
146
+ }
147
+
148
+ const CONFIG_NAME = '.defuse.json';
149
+
150
+ function loadConfig(startDir: string): { config: FileConfig; path: string | null } {
151
+ for (const dir of [startDir, process.cwd()]) {
152
+ const candidate = path.join(dir, CONFIG_NAME);
153
+ if (!existsSync(candidate)) continue;
154
+ try {
155
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as FileConfig;
156
+ return { config: parsed, path: candidate };
157
+ } catch (error) {
158
+ // A broken config is reported, never ignored. Silently falling back to
159
+ // defaults would mean a repo believing it has a policy that never ran.
160
+ console.error(
161
+ color.red(`error: ${candidate} is not valid JSON — ${(error as Error).message}`),
162
+ );
163
+ process.exit(2);
164
+ }
165
+ }
166
+ return { config: {}, path: null };
167
+ }
168
+
169
+ /* ------------------------------------------------------------------ help -- */
170
+
171
+ function printHelp(): void {
172
+ const b = color.bold;
173
+ console.log(`
174
+ ${b('Defuse')} — honesty-first static application security testing
175
+ ${color.dim(`Signature matching plus data-flow verification, in all ${LANGUAGES.length} languages.`)}
176
+ ${color.dim('Flows are followed across functions and, where imports resolve, across files.')}
177
+
178
+ ${b('USAGE')}
179
+ defuse scan <path> [options]
180
+ defuse ast <file> [options]
181
+ defuse rules [--explain <rule-id>]
182
+ defuse doctor
183
+ defuse deps [path] [--json] known advisories for pinned library versions.
184
+ The one command that uses the network: it sends
185
+ package names and versions (no code) to osv.dev.
186
+
187
+ ${b('SCAN OPTIONS')}
188
+ --full the long terminal report: reasoning and limits on every
189
+ finding, the gauge arithmetic, OWASP table and every
190
+ unchecked rule by name. Default is a short report.
191
+ --json machine-readable output (superset of the terminal report)
192
+ --sarif SARIF 2.1.0, for GitHub/GitLab code scanning. A
193
+ flow-verified finding carries its traced path as a
194
+ SARIF codeFlow; a signature-based one carries none,
195
+ and every message opens with which it is.
196
+ --report=<file.html> also write a report for the project owner: one
197
+ self-contained HTML file (nothing loaded from the
198
+ web) that prints to PDF - proven problems with their
199
+ paths, what to review, what was cleared, and what
200
+ was not checked
201
+ --report-name=<name> the project name shown on that report
202
+ --report-by=<name> "Prepared by" on that report
203
+ --report-note=<text> one line under the report's header, e.g. that it is a sample or a draft
204
+ --compact with --json or --sarif, emit a single line
205
+ --min-severity=<level> hide findings below this level in the terminal report
206
+ (they remain in --json). critical|high|medium|low|info
207
+ --fail-on=<level> exit code 1 at or above this level. default: high
208
+ use --fail-on=none to always exit 0
209
+ --only=<ids> comma-separated rule ids, e.g. --only=sql-injection,xss
210
+ --exclude=<globs> comma-separated paths to skip. A bare word matches any
211
+ directory of that name; * and ** work as usual.
212
+ e.g. --exclude=tests,vendor,**/*.min.js
213
+ --no-config ignore .defuse.json
214
+ --deps also check pinned library versions against osv.dev.
215
+ Off by default: it sends package names and versions
216
+ (no code) off the machine. Same as defuse deps.
217
+ --show-suppressed list findings silenced by defuse:ignore comments
218
+ --coverage-matrix print the full rule x language support table
219
+ --no-cross-file do not resolve imports; analyse each file alone.
220
+ Faster, at the cost of every finding that crosses a
221
+ module. It does NOT save memory - that was measured
222
+ and was not true. Set DEFUSE_TREE_BUDGET_MB to trade
223
+ memory for speed instead.
224
+ --ascii plain ASCII output - no box-drawing characters.
225
+ Chosen automatically when redirecting on Windows.
226
+ --quiet suppress the banner and the per-file progress line
227
+
228
+ ${b('AST OPTIONS')}
229
+ --depth=<n> how deep to print (default 12)
230
+ --anonymous include punctuation/keyword nodes
231
+ --max-lines=<n> truncate output (default 400)
232
+
233
+ ${b('ENVIRONMENT')}
234
+ DEFUSE_TREE_BUDGET_MB how much memory syntax trees may hold before the
235
+ least recently used one is freed and the file parsed
236
+ again on demand. Default 1024. Lower it to finish on
237
+ a small machine; raise it to go faster. The findings
238
+ are the same either way - a test fails if they are
239
+ not - and the report says when files were re-parsed.
240
+
241
+ ${b('EXIT CODES')}
242
+ 0 nothing at or above the --fail-on threshold
243
+ 1 findings at or above it
244
+ 2 the tool failed
245
+
246
+ ${b('THE POINT OF THIS TOOL')}
247
+ Every finding is labelled ${color.yellow('signature-based')} or ${color.green('flow-verified')}.
248
+ ${color.yellow('signature-based')} = we matched a shape. Unverified, may be a false positive.
249
+ ${color.green('flow-verified')} = we followed attacker data from its source into the sink
250
+ and print every hop, so you can check the claim yourself.
251
+ The report also lists what was NOT checked. That is the product.
252
+
253
+ ${b('LICENCE')}
254
+ ${LICENCE_LINE}
255
+ `);
256
+ }
257
+
258
+ /* ------------------------------------------------------------------ scan -- */
259
+
260
+ async function commandScan(args: Args): Promise<number> {
261
+ const target = args.positional[0];
262
+ if (!target) {
263
+ console.error(color.red('error: scan needs a path. usage: defuse scan <path>'));
264
+ return 2;
265
+ }
266
+ /* Extra paths used to be dropped in silence. `scan src ui docs` printed a
267
+ * clean, confident report for `src` and said nothing about the two
268
+ * directories it never opened - a scanner reporting on less than it was
269
+ * asked about, without saying so, which is the one failure mode this whole
270
+ * project is built to avoid. Refuse instead of quietly narrowing. */
271
+ if (args.positional.length > 1) {
272
+ console.error(
273
+ color.red(
274
+ `error: scan takes ONE path, but ${args.positional.length} were given ` +
275
+ `(${args.positional.join(', ')}).`,
276
+ ),
277
+ );
278
+ console.error(
279
+ ' Scan the common parent and narrow with --exclude=<globs>, ' +
280
+ 'or run the scans separately.',
281
+ );
282
+ return 2;
283
+ }
284
+
285
+ let resolved: string;
286
+ try {
287
+ resolved = path.resolve(target);
288
+ await stat(resolved);
289
+ } catch {
290
+ console.error(color.red(`error: cannot read '${target}'`));
291
+ return 2;
292
+ }
293
+
294
+ const asJson = args.flags.has('json');
295
+ const asSarif = args.flags.has('sarif');
296
+ const quiet = args.flags.has('quiet') || asJson || asSarif;
297
+ // Decoration for a person at a terminal only: never in piped output, CI logs,
298
+ // or a window too narrow to hold it.
299
+ if (!quiet && process.stdout.isTTY && (process.stdout.columns ?? 0) >= BANNER_WIDTH) {
300
+ const purple = (s: string): string => (color.enabled ? `\x1b[38;2;121;44;247m${s}\x1b[39m` : s);
301
+ console.log(`\n${bannerLines().map(purple).join('\n')}`);
302
+ }
303
+ const configForOnly = args.flags.has('no-config')
304
+ ? ({} as FileConfig)
305
+ : loadConfig(statSync(resolved).isDirectory() ? resolved : path.dirname(resolved)).config;
306
+ const only =
307
+ flagString(args, 'only')?.split(',').map((s) => s.trim()).filter(Boolean) ??
308
+ configForOnly.only ??
309
+ [];
310
+
311
+ for (const id of only) {
312
+ if (!ALL_RULES.some((r) => r.id === id) && id !== RUNTIME_VERSION_RULE.id) {
313
+ console.error(
314
+ color.red(
315
+ `error: unknown rule '${id}'. Known: ${[...ALL_RULES.map((r) => r.id), RUNTIME_VERSION_RULE.id].join(', ')}`,
316
+ ),
317
+ );
318
+ return 2;
319
+ }
320
+ }
321
+
322
+ // The command line wins over the file; the file wins over the defaults.
323
+ const { config, path: configPath } = args.flags.has('no-config')
324
+ ? { config: {} as FileConfig, path: null }
325
+ : loadConfig(statSync(resolved).isDirectory() ? resolved : path.dirname(resolved));
326
+ if (configPath && !quiet) {
327
+ console.error(color.dim(`using ${path.relative(process.cwd(), configPath) || CONFIG_NAME}`));
328
+ }
329
+
330
+ const minSeverity = parseSeverity(
331
+ flagString(args, 'min-severity') ?? config.minSeverity,
332
+ 'info',
333
+ );
334
+ const failOnRaw = flagString(args, 'fail-on') ?? config.failOn ?? 'high';
335
+ const failOn = failOnRaw === 'none' ? null : parseSeverity(failOnRaw, 'high');
336
+
337
+ const exclude =
338
+ flagString(args, 'exclude')?.split(',').map((s) => s.trim()).filter(Boolean) ??
339
+ config.exclude ??
340
+ [];
341
+
342
+ let result: ScanResult = await scan(resolved, {
343
+ only,
344
+ exclude,
345
+ noCrossFile: args.flags.has('no-cross-file'),
346
+ ...(quiet
347
+ ? {}
348
+ : {
349
+ // analyze() reports 2N steps for N files: every file is read once,
350
+ // then checked once. "scanning 3/4" for a two-file folder looked like
351
+ // a counting bug, so the two passes are named and counted per file.
352
+ onProgress: (done, total, file) => {
353
+ const files = total / 2;
354
+ const step = done > files ? `checking ${done - files}` : `reading ${done}`;
355
+ process.stderr.write(
356
+ `\r${color.dim(`${step}/${files} ${path.basename(file).slice(0, 40)}`.padEnd(70))}`,
357
+ );
358
+ },
359
+ }),
360
+ });
361
+
362
+ if (!quiet) process.stderr.write(`\r${' '.repeat(70)}\r`);
363
+
364
+ /*
365
+ * --deps: the one network call, added here rather than inside scan() so the
366
+ * browser page (which runs scan's engine) can never make it. The findings
367
+ * join the same list, so every report and SARIF carries them unchanged.
368
+ */
369
+ if (args.flags.has('deps')) {
370
+ if (!quiet) process.stderr.write(color.dim('sending package names and versions (no code) to api.osv.dev...\n'));
371
+ try {
372
+ const deps = await checkDependencies(
373
+ statSync(resolved).isDirectory() ? resolved : path.dirname(resolved),
374
+ (f) => path.relative(process.cwd(), f) || f,
375
+ exclude,
376
+ );
377
+ result = { ...result, findings: [...result.findings, ...deps.findings], dependencies: deps.report };
378
+ } catch (error) {
379
+ console.error(color.red(`error: --deps lookup failed, so the scan was not reported - ${(error as Error).message}`));
380
+ return 2;
381
+ }
382
+ }
383
+
384
+ /*
385
+ * The client report is WRITTEN BY THE TOOL, never piped. On Windows,
386
+ * PowerShell's `>` writes UTF-16, and a report redirected that way opens as
387
+ * noise in a browser - so the file is written here, as UTF-8, and the
388
+ * terminal says where it went.
389
+ */
390
+ const reportPath = flagString(args, 'report');
391
+ if (args.flags.has('report') && !reportPath) {
392
+ console.error(color.red('error: --report needs a file name, e.g. --report=report.html'));
393
+ return 2;
394
+ }
395
+ if (reportPath) {
396
+ const html = renderHtmlReport(result, {
397
+ generatedAt: new Date(),
398
+ root: path.relative(process.cwd(), resolved),
399
+ ...(flagString(args, 'report-name') ? { projectName: flagString(args, 'report-name')! } : {}),
400
+ ...(flagString(args, 'report-by') ? { preparedBy: flagString(args, 'report-by')! } : {}),
401
+ ...(flagString(args, 'report-note') ? { note: flagString(args, 'report-note')! } : {}),
402
+ });
403
+ writeFileSync(path.resolve(reportPath), html, 'utf8');
404
+ console.error(color.dim(`client report written to ${path.resolve(reportPath)}`));
405
+ }
406
+
407
+ if (asSarif) {
408
+ console.log(renderSarif(result, !args.flags.has('compact')));
409
+ } else if (asJson) {
410
+ console.log(renderJson(result, !args.flags.has('compact')));
411
+ } else {
412
+ console.log(
413
+ renderHuman(result, {
414
+ showSuppressed: args.flags.has('show-suppressed'),
415
+ showCoverageMatrix: args.flags.has('coverage-matrix'),
416
+ full: args.flags.has('full'),
417
+ minSeverity,
418
+ target: path.relative(process.cwd(), resolved) || '.',
419
+ }),
420
+ );
421
+ }
422
+
423
+ if (!failOn) return 0;
424
+ const triggering = result.findings.filter(
425
+ (f) => SEVERITY_ORDER[f.severity] >= SEVERITY_ORDER[failOn],
426
+ );
427
+ return triggering.length > 0 ? 1 : 0;
428
+ }
429
+
430
+ /* ------------------------------------------------------------------- ast -- */
431
+
432
+ async function commandAst(args: Args): Promise<number> {
433
+ const target = args.positional[0];
434
+ if (!target) {
435
+ console.error(color.red('error: ast needs a file. usage: defuse ast <file>'));
436
+ return 2;
437
+ }
438
+
439
+ useNodeGrammars();
440
+ const outcome = await parseFile(path.resolve(target));
441
+ if (outcome.status === 'skipped') {
442
+ console.error(color.red(`error: ${outcome.reason}`));
443
+ return 2;
444
+ }
445
+
446
+ const file = outcome.file;
447
+ console.log(
448
+ color.dim(
449
+ `${file.path}\n${file.language.displayName} (grammar: ${file.grammar}, detected by ${file.detectedBy})\n`,
450
+ ),
451
+ );
452
+ console.log(
453
+ dumpAst(file.root, {
454
+ includeAnonymous: args.flags.has('anonymous'),
455
+ maxDepth: Number(flagString(args, 'depth') ?? 12),
456
+ maxLines: Number(flagString(args, 'max-lines') ?? 400),
457
+ }),
458
+ );
459
+
460
+ if (file.parseIssues.length > 0) {
461
+ console.log(color.yellow(`\n${file.parseIssues.length} parse issue(s):`));
462
+ for (const issue of file.parseIssues) {
463
+ console.log(color.dim(` ${issue.kind} at ${issue.line}:${issue.column} ${issue.text}`));
464
+ }
465
+ }
466
+ return 0;
467
+ }
468
+
469
+ /* ----------------------------------------------------------------- rules -- */
470
+
471
+ function commandRules(args: Args): number {
472
+ const explain = flagString(args, 'explain');
473
+
474
+ if (explain) {
475
+ const rule = ALL_RULES.find((r) => r.id === explain);
476
+ if (!rule) {
477
+ console.error(color.red(`error: no rule '${explain}'`));
478
+ return 2;
479
+ }
480
+ console.log(`\n${color.bold(rule.name)} ${color.dim(`(${rule.id})`)}`);
481
+ console.log(color.dim(`${rule.cwe} · ${rule.owasp} · severity ${rule.severity}\n`));
482
+ console.log(`${color.cyan('In one line')}\n ${wrapText(rule.summary, 86, ' ')}\n`);
483
+ console.log(`${color.cyan('The vulnerability')}\n ${wrapText(rule.explanation, 86, ' ')}\n`);
484
+ console.log(`${color.green('How to fix it')}\n ${wrapText(rule.fix, 86, ' ')}\n`);
485
+ console.log(`${color.yellow('What this rule cannot tell you')}\n ${wrapText(rule.limitations, 86, ' ')}\n`);
486
+ console.log(color.cyan('Per-language support'));
487
+ for (const language of LANGUAGES) {
488
+ const support = rule.support[language.id];
489
+ const status = support?.status ?? 'not-implemented';
490
+ const mark = status === 'implemented' ? color.green(g('dotFull')) : status === 'partial' ? color.yellow(g('dotHalf')) : color.gray(g('dotEmpty'));
491
+ console.log(` ${mark} ${language.displayName.padEnd(12)} ${color.dim(status)}`);
492
+ console.log(` ${color.dim(wrapText(support?.note ?? 'no pattern for this language', 82, ' '))}`);
493
+ }
494
+ console.log('');
495
+ return 0;
496
+ }
497
+
498
+ const coverage = buildCoverageReport();
499
+ console.log(`\n${color.bold(`${ALL_RULES.length} rules`)} ${color.dim('· run with --explain <id> for the full write-up')}\n`);
500
+ for (const rule of ALL_RULES) {
501
+ const cells = coverage.matrix.filter((c) => c.ruleId === rule.id);
502
+ const marks = cells
503
+ .map((c) =>
504
+ c.status === 'implemented' ? color.green(g('dotFull')) : c.status === 'partial' ? color.yellow(g('dotHalf')) : color.gray('○'),
505
+ )
506
+ .join('');
507
+ console.log(` ${marks} ${color.bold(rule.id.padEnd(20))} ${color.dim(`${rule.cwe} · ${rule.severity}`)}`);
508
+ console.log(` ${color.dim(rule.name)}`);
509
+ }
510
+ console.log(
511
+ color.dim(
512
+ `\n legend: ${color.green(g('dotFull'))} implemented ${color.yellow(g('dotHalf'))} partial ${color.gray(g('dotEmpty'))} not implemented` +
513
+ `\n columns: ${LANGUAGES.map((l) => l.id).join(' ')}\n`,
514
+ ),
515
+ );
516
+ return 0;
517
+ }
518
+
519
+ /* ------------------------------------------------------------------ deps -- */
520
+
521
+ async function commandDeps(args: Args): Promise<number> {
522
+ const target = args.positional[0] ?? '.';
523
+ try {
524
+ await stat(target);
525
+ } catch {
526
+ console.error(color.red(`error: cannot read '${target}'`));
527
+ return 2;
528
+ }
529
+ const asJson = args.flags.has('json');
530
+ const { read, unread } = findLockfiles(path.resolve(target));
531
+ const rel = (f: string): string => path.relative(process.cwd(), f) || f;
532
+ const pinned = read.flatMap((r) => r.packages);
533
+
534
+ // Said BEFORE anything is sent, on stderr so --json stays clean.
535
+ console.error(
536
+ color.dim(
537
+ pinned.length
538
+ ? `Sending ${pinned.length} package names and versions (no code) to api.osv.dev...`
539
+ : 'No pinned packages found - nothing sent anywhere.',
540
+ ),
541
+ );
542
+ let result: Awaited<ReturnType<typeof queryOsv>>;
543
+ try {
544
+ result = pinned.length ? await queryOsv(pinned) : { hits: [], checked: 0, incomplete: [] };
545
+ } catch (error) {
546
+ console.error(color.red(`error: the advisory lookup failed, so nothing was checked - ${(error as Error).message}`));
547
+ return 2;
548
+ }
549
+
550
+ if (asJson) {
551
+ console.log(
552
+ JSON.stringify(
553
+ {
554
+ source: 'api.osv.dev',
555
+ checkedAt: new Date().toISOString(),
556
+ confidence: 'signature-based',
557
+ read: read.map((r) => ({ file: rel(r.file), packages: r.packages.length, skipped: r.skipped })),
558
+ notRead: unread.map(rel),
559
+ checked: result.checked,
560
+ incomplete: result.incomplete,
561
+ hits: result.hits.map((h) => ({ ...h, pkg: { ...h.pkg, file: rel(h.pkg.file) } })),
562
+ },
563
+ null,
564
+ args.flags.has('compact') ? 0 : 2,
565
+ ),
566
+ );
567
+ return result.hits.length ? 1 : 0;
568
+ }
569
+
570
+ const sev = (s: string): string =>
571
+ (s === 'critical' || s === 'high' ? color.red : s === 'medium' ? color.yellow : color.dim)(s.toUpperCase().padEnd(8));
572
+ console.log(`\n${color.bold('Dependency check')} ${color.dim('- known advisories for pinned library versions, from osv.dev')}\n`);
573
+ for (const r of read) console.log(` read ${rel(r.file)} ${color.dim(`(${r.packages.length} packages)`)}`);
574
+ for (const f of unread) console.log(` ${color.yellow('not read')} ${rel(f)} ${color.dim('- this lockfile format is not supported yet')}`);
575
+ if (!read.length && !unread.length) console.log(color.dim(' no lockfiles found (package-lock.json, composer.lock, requirements.txt, go.mod)'));
576
+
577
+ for (const h of result.hits) {
578
+ console.log(`\n ${color.bold(`${h.pkg.name} ${h.pkg.version}`)} ${color.dim(`${h.pkg.ecosystem} · ${rel(h.pkg.file)}`)}`);
579
+ for (const a of h.advisories) {
580
+ const ids = [a.id, ...a.aliases.filter((x) => x.startsWith('CVE-'))].join(' ');
581
+ const fix = a.fixed.length ? color.green(`fixed in ${a.fixed.join(', ')}`) : color.dim('no fixed version published');
582
+ console.log(` ${sev(a.severity)} ${ids}\n ${a.summary}\n ${fix}`);
583
+ }
584
+ }
585
+
586
+ const skipped = read.flatMap((r) => r.skipped.map((s) => `${rel(r.file)}: ${s}`));
587
+ if (skipped.length || result.incomplete.length || unread.length) {
588
+ console.log(`\n${color.bold(' What this check did NOT read')}`);
589
+ for (const s of [...skipped, ...result.incomplete]) console.log(` ${g('cross')} ${s}`);
590
+ for (const f of unread) console.log(` ${g('cross')} ${rel(f)}: format not supported yet`);
591
+ }
592
+ console.log(
593
+ `\n ${result.checked} distinct package versions checked, ${color.bold(String(result.hits.length))} with known advisories.\n` +
594
+ color.dim(
595
+ ' signature-based: an advisory matching a pinned version means the vulnerable code is\n' +
596
+ ' installed - not that this project ever calls it.\n',
597
+ ),
598
+ );
599
+ return result.hits.length ? 1 : 0;
600
+ }
601
+
602
+ /* ---------------------------------------------------------------- doctor -- */
603
+
604
+ /**
605
+ * A self-check. The nastiest failure a scanner can have is a query with a
606
+ * typo'd node name: it compiles conceptually, matches nothing, and reports a
607
+ * clean scan forever. `doctor` compiles every query against every grammar so
608
+ * that failure is loud instead of silent.
609
+ */
610
+ async function commandDoctor(): Promise<number> {
611
+ useNodeGrammars();
612
+ console.log(`\n${color.bold('Defuse self-check')}\n`);
613
+ let failures = 0;
614
+
615
+ try {
616
+ validateRegistry();
617
+ console.log(` ${color.green(g('tick'))} rule registry satisfies the honesty contract`);
618
+ } catch (error) {
619
+ failures++;
620
+ console.log(` ${color.red(g('cross'))} ${(error as Error).message}`);
621
+ }
622
+
623
+ await initEngine();
624
+ const { Query } = await import('web-tree-sitter');
625
+
626
+ for (const language of LANGUAGES) {
627
+ const grammars = [language.grammar, ...(language.dialects ?? []).map((d) => d.grammar)];
628
+ for (const grammar of grammars) {
629
+ try {
630
+ const loaded = await getLanguage(grammar);
631
+ for (const kind of ['calls', 'assignments'] as const) {
632
+ new Query(loaded, SHAPE_QUERY_SOURCES[kind][language.id]);
633
+ }
634
+ console.log(
635
+ ` ${color.green(g('tick'))} ${language.displayName.padEnd(12)} grammar '${grammar}' loads; shape queries compile`,
636
+ );
637
+ } catch (error) {
638
+ failures++;
639
+ console.log(
640
+ ` ${color.red(g('cross'))} ${language.displayName.padEnd(12)} grammar '${grammar}': ${(error as Error).message}`,
641
+ );
642
+ }
643
+ }
644
+ }
645
+
646
+ // Language detection sanity: every declared extension must map back correctly.
647
+ for (const language of LANGUAGES) {
648
+ const all = [...language.extensions, ...(language.dialects ?? []).flatMap((d) => d.extensions)];
649
+ for (const ext of all) {
650
+ const match = detectLanguage(`example${ext}`);
651
+ if (match?.spec.id !== language.id) {
652
+ failures++;
653
+ console.log(` ${color.red(g('cross'))} extension ${ext} does not resolve to ${language.id}`);
654
+ }
655
+ }
656
+ }
657
+ if (failures === 0) console.log(` ${color.green(g('tick'))} all declared extensions resolve to a language`);
658
+
659
+ console.log(
660
+ failures === 0
661
+ ? `\n ${color.green('All checks passed.')}\n`
662
+ : `\n ${color.red(`${failures} check(s) failed.`)}\n`,
663
+ );
664
+ return failures === 0 ? 0 : 1;
665
+ }
666
+
667
+ /* ------------------------------------------------------------------ main -- */
668
+
669
+ async function main(): Promise<number> {
670
+ const args = parseArgs(process.argv.slice(2));
671
+ if (args.flags.has('ascii')) setAsciiMode(true);
672
+
673
+ if (args.flags.has('version') || args.flags.has('v')) {
674
+ console.log(buildCoverageReport().engine.version);
675
+ console.log(LICENCE_SPDX);
676
+ return 0;
677
+ }
678
+ if (args.command === '' || args.flags.has('help') || args.flags.has('h')) {
679
+ printHelp();
680
+ return args.command === '' ? 2 : 0;
681
+ }
682
+
683
+ switch (args.command) {
684
+ case 'scan':
685
+ return commandScan(args);
686
+ case 'ast':
687
+ return commandAst(args);
688
+ case 'rules':
689
+ return commandRules(args);
690
+ case 'doctor':
691
+ return commandDoctor();
692
+ case 'deps':
693
+ return commandDeps(args);
694
+ default:
695
+ console.error(color.red(`error: unknown command '${args.command}'`));
696
+ printHelp();
697
+ return 2;
698
+ }
699
+ }
700
+
701
+ main()
702
+ .then((code) => {
703
+ process.exitCode = code;
704
+ })
705
+ .catch((error: unknown) => {
706
+ const message = (error as Error).message ?? String(error);
707
+
708
+ /*
709
+ * `Aborted()` is the WebAssembly heap giving up, and on its own it tells
710
+ * the reader nothing at all.
711
+ *
712
+ * Every syntax tree tree-sitter builds lives in a WASM heap that JavaScript
713
+ * garbage collection cannot reach, and nothing frees them during a scan
714
+ * because cross-file tracing has to be able to resolve into any file at any
715
+ * time. Memory therefore grows with total SOURCE BYTES - measured at 60-70x
716
+ * - and past roughly 120MB on an 8GB machine the heap aborts or the
717
+ * operating system kills the process.
718
+ *
719
+ * The failure is total: no findings, no partial report, nothing. Somebody
720
+ * hitting this deserves to know what happened and what to do instead of a
721
+ * bare `Aborted()`, which is what they got the first time it happened to a
722
+ * real user scanning VS Code.
723
+ */
724
+ if (/Aborted|Maximum call stack|heap out of memory|allocation failed/i.test(message)) {
725
+ console.error(color.red('\nout of memory - the scan did not finish and produced no report.'));
726
+ console.error(
727
+ color.dim(
728
+ '\n Memory grows with the total SIZE of the source, not the file count: about\n' +
729
+ ' 60-70x the bytes on disk, because every parsed syntax tree is held for the\n' +
730
+ ' whole scan so cross-file tracing can resolve into it. On an 8GB machine that\n' +
731
+ ' is a ceiling of roughly 120MB of source.\n' +
732
+ '\n Scan one package at a time instead:\n' +
733
+ ' scan src/vs/base scan src/vs/platform scan src/vs/editor\n' +
734
+ '\n --exclude=tests,node_modules also helps, and node --max-old-space-size=8192\n' +
735
+ ' buys some headroom. This is a known limitation, recorded in the report\'s\n' +
736
+ ' "not implemented" list, and it needs a redesign rather than a flag.',
737
+ ),
738
+ );
739
+ if (process.env['NS1_DEBUG']) console.error((error as Error).stack);
740
+ process.exitCode = 2;
741
+ return;
742
+ }
743
+
744
+ console.error(color.red(`\ninternal error: ${message}`));
745
+ if (process.env['NS1_DEBUG']) console.error((error as Error).stack);
746
+ process.exitCode = 2;
747
+ });