@trailofbits/vsix-audit 0.1.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 (197) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +281 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +703 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +4 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/scanner/batch.d.ts +12 -0
  12. package/dist/scanner/batch.d.ts.map +1 -0
  13. package/dist/scanner/batch.js +104 -0
  14. package/dist/scanner/batch.js.map +1 -0
  15. package/dist/scanner/bundler.d.ts +35 -0
  16. package/dist/scanner/bundler.d.ts.map +1 -0
  17. package/dist/scanner/bundler.js +120 -0
  18. package/dist/scanner/bundler.js.map +1 -0
  19. package/dist/scanner/cache.d.ts +45 -0
  20. package/dist/scanner/cache.d.ts.map +1 -0
  21. package/dist/scanner/cache.js +153 -0
  22. package/dist/scanner/cache.js.map +1 -0
  23. package/dist/scanner/cache.test.d.ts +2 -0
  24. package/dist/scanner/cache.test.d.ts.map +1 -0
  25. package/dist/scanner/cache.test.js +149 -0
  26. package/dist/scanner/cache.test.js.map +1 -0
  27. package/dist/scanner/capabilities.d.ts +29 -0
  28. package/dist/scanner/capabilities.d.ts.map +1 -0
  29. package/dist/scanner/capabilities.js +217 -0
  30. package/dist/scanner/capabilities.js.map +1 -0
  31. package/dist/scanner/checks/ast.d.ts +3 -0
  32. package/dist/scanner/checks/ast.d.ts.map +1 -0
  33. package/dist/scanner/checks/ast.js +469 -0
  34. package/dist/scanner/checks/ast.js.map +1 -0
  35. package/dist/scanner/checks/ast.test.d.ts +2 -0
  36. package/dist/scanner/checks/ast.test.d.ts.map +1 -0
  37. package/dist/scanner/checks/ast.test.js +389 -0
  38. package/dist/scanner/checks/ast.test.js.map +1 -0
  39. package/dist/scanner/checks/behavioral.d.ts +3 -0
  40. package/dist/scanner/checks/behavioral.d.ts.map +1 -0
  41. package/dist/scanner/checks/behavioral.js +367 -0
  42. package/dist/scanner/checks/behavioral.js.map +1 -0
  43. package/dist/scanner/checks/blocklist.d.ts +3 -0
  44. package/dist/scanner/checks/blocklist.d.ts.map +1 -0
  45. package/dist/scanner/checks/blocklist.js +32 -0
  46. package/dist/scanner/checks/blocklist.js.map +1 -0
  47. package/dist/scanner/checks/blocklist.test.d.ts +2 -0
  48. package/dist/scanner/checks/blocklist.test.d.ts.map +1 -0
  49. package/dist/scanner/checks/blocklist.test.js +74 -0
  50. package/dist/scanner/checks/blocklist.test.js.map +1 -0
  51. package/dist/scanner/checks/chains.d.ts +35 -0
  52. package/dist/scanner/checks/chains.d.ts.map +1 -0
  53. package/dist/scanner/checks/chains.js +505 -0
  54. package/dist/scanner/checks/chains.js.map +1 -0
  55. package/dist/scanner/checks/chains.test.d.ts +2 -0
  56. package/dist/scanner/checks/chains.test.d.ts.map +1 -0
  57. package/dist/scanner/checks/chains.test.js +250 -0
  58. package/dist/scanner/checks/chains.test.js.map +1 -0
  59. package/dist/scanner/checks/dataflow.d.ts +3 -0
  60. package/dist/scanner/checks/dataflow.d.ts.map +1 -0
  61. package/dist/scanner/checks/dataflow.js +316 -0
  62. package/dist/scanner/checks/dataflow.js.map +1 -0
  63. package/dist/scanner/checks/dependencies.d.ts +13 -0
  64. package/dist/scanner/checks/dependencies.d.ts.map +1 -0
  65. package/dist/scanner/checks/dependencies.js +225 -0
  66. package/dist/scanner/checks/dependencies.js.map +1 -0
  67. package/dist/scanner/checks/dependencies.test.d.ts +2 -0
  68. package/dist/scanner/checks/dependencies.test.d.ts.map +1 -0
  69. package/dist/scanner/checks/dependencies.test.js +248 -0
  70. package/dist/scanner/checks/dependencies.test.js.map +1 -0
  71. package/dist/scanner/checks/finding-quality.test.d.ts +8 -0
  72. package/dist/scanner/checks/finding-quality.test.d.ts.map +1 -0
  73. package/dist/scanner/checks/finding-quality.test.js +164 -0
  74. package/dist/scanner/checks/finding-quality.test.js.map +1 -0
  75. package/dist/scanner/checks/ioc.d.ts +20 -0
  76. package/dist/scanner/checks/ioc.d.ts.map +1 -0
  77. package/dist/scanner/checks/ioc.js +234 -0
  78. package/dist/scanner/checks/ioc.js.map +1 -0
  79. package/dist/scanner/checks/ioc.test.d.ts +2 -0
  80. package/dist/scanner/checks/ioc.test.d.ts.map +1 -0
  81. package/dist/scanner/checks/ioc.test.js +298 -0
  82. package/dist/scanner/checks/ioc.test.js.map +1 -0
  83. package/dist/scanner/checks/manifest.d.ts +6 -0
  84. package/dist/scanner/checks/manifest.d.ts.map +1 -0
  85. package/dist/scanner/checks/manifest.js +123 -0
  86. package/dist/scanner/checks/manifest.js.map +1 -0
  87. package/dist/scanner/checks/manifest.test.d.ts +2 -0
  88. package/dist/scanner/checks/manifest.test.d.ts.map +1 -0
  89. package/dist/scanner/checks/manifest.test.js +108 -0
  90. package/dist/scanner/checks/manifest.test.js.map +1 -0
  91. package/dist/scanner/checks/obfuscation.d.ts +3 -0
  92. package/dist/scanner/checks/obfuscation.d.ts.map +1 -0
  93. package/dist/scanner/checks/obfuscation.js +432 -0
  94. package/dist/scanner/checks/obfuscation.js.map +1 -0
  95. package/dist/scanner/checks/obfuscation.test.d.ts +2 -0
  96. package/dist/scanner/checks/obfuscation.test.d.ts.map +1 -0
  97. package/dist/scanner/checks/obfuscation.test.js +399 -0
  98. package/dist/scanner/checks/obfuscation.test.js.map +1 -0
  99. package/dist/scanner/checks/package.d.ts +17 -0
  100. package/dist/scanner/checks/package.d.ts.map +1 -0
  101. package/dist/scanner/checks/package.js +422 -0
  102. package/dist/scanner/checks/package.js.map +1 -0
  103. package/dist/scanner/checks/package.test.d.ts +2 -0
  104. package/dist/scanner/checks/package.test.d.ts.map +1 -0
  105. package/dist/scanner/checks/package.test.js +518 -0
  106. package/dist/scanner/checks/package.test.js.map +1 -0
  107. package/dist/scanner/checks/patterns.d.ts +5 -0
  108. package/dist/scanner/checks/patterns.d.ts.map +1 -0
  109. package/dist/scanner/checks/patterns.js +251 -0
  110. package/dist/scanner/checks/patterns.js.map +1 -0
  111. package/dist/scanner/checks/patterns.test.d.ts +2 -0
  112. package/dist/scanner/checks/patterns.test.d.ts.map +1 -0
  113. package/dist/scanner/checks/patterns.test.js +147 -0
  114. package/dist/scanner/checks/patterns.test.js.map +1 -0
  115. package/dist/scanner/checks/unicode.d.ts +3 -0
  116. package/dist/scanner/checks/unicode.d.ts.map +1 -0
  117. package/dist/scanner/checks/unicode.js +247 -0
  118. package/dist/scanner/checks/unicode.js.map +1 -0
  119. package/dist/scanner/checks/unicode.test.d.ts +2 -0
  120. package/dist/scanner/checks/unicode.test.d.ts.map +1 -0
  121. package/dist/scanner/checks/unicode.test.js +202 -0
  122. package/dist/scanner/checks/unicode.test.js.map +1 -0
  123. package/dist/scanner/checks/yara.d.ts +23 -0
  124. package/dist/scanner/checks/yara.d.ts.map +1 -0
  125. package/dist/scanner/checks/yara.js +349 -0
  126. package/dist/scanner/checks/yara.js.map +1 -0
  127. package/dist/scanner/checks/yara.test.d.ts +2 -0
  128. package/dist/scanner/checks/yara.test.d.ts.map +1 -0
  129. package/dist/scanner/checks/yara.test.js +126 -0
  130. package/dist/scanner/checks/yara.test.js.map +1 -0
  131. package/dist/scanner/constants.d.ts +18 -0
  132. package/dist/scanner/constants.d.ts.map +1 -0
  133. package/dist/scanner/constants.js +37 -0
  134. package/dist/scanner/constants.js.map +1 -0
  135. package/dist/scanner/detection-coverage.test.d.ts +2 -0
  136. package/dist/scanner/detection-coverage.test.d.ts.map +1 -0
  137. package/dist/scanner/detection-coverage.test.js +216 -0
  138. package/dist/scanner/detection-coverage.test.js.map +1 -0
  139. package/dist/scanner/download.d.ts +76 -0
  140. package/dist/scanner/download.d.ts.map +1 -0
  141. package/dist/scanner/download.js +339 -0
  142. package/dist/scanner/download.js.map +1 -0
  143. package/dist/scanner/download.test.d.ts +2 -0
  144. package/dist/scanner/download.test.d.ts.map +1 -0
  145. package/dist/scanner/download.test.js +149 -0
  146. package/dist/scanner/download.test.js.map +1 -0
  147. package/dist/scanner/index.d.ts +8 -0
  148. package/dist/scanner/index.d.ts.map +1 -0
  149. package/dist/scanner/index.js +167 -0
  150. package/dist/scanner/index.js.map +1 -0
  151. package/dist/scanner/index.test.d.ts +2 -0
  152. package/dist/scanner/index.test.d.ts.map +1 -0
  153. package/dist/scanner/index.test.js +71 -0
  154. package/dist/scanner/index.test.js.map +1 -0
  155. package/dist/scanner/loaders/zoo.d.ts +3 -0
  156. package/dist/scanner/loaders/zoo.d.ts.map +1 -0
  157. package/dist/scanner/loaders/zoo.js +112 -0
  158. package/dist/scanner/loaders/zoo.js.map +1 -0
  159. package/dist/scanner/types.d.ts +118 -0
  160. package/dist/scanner/types.d.ts.map +1 -0
  161. package/dist/scanner/types.js +2 -0
  162. package/dist/scanner/types.js.map +1 -0
  163. package/dist/scanner/utils.d.ts +14 -0
  164. package/dist/scanner/utils.d.ts.map +1 -0
  165. package/dist/scanner/utils.js +25 -0
  166. package/dist/scanner/utils.js.map +1 -0
  167. package/dist/scanner/vsix.d.ts +6 -0
  168. package/dist/scanner/vsix.d.ts.map +1 -0
  169. package/dist/scanner/vsix.js +213 -0
  170. package/dist/scanner/vsix.js.map +1 -0
  171. package/dist/scanner/vsix.test.d.ts +2 -0
  172. package/dist/scanner/vsix.test.d.ts.map +1 -0
  173. package/dist/scanner/vsix.test.js +355 -0
  174. package/dist/scanner/vsix.test.js.map +1 -0
  175. package/package.json +60 -0
  176. package/zoo/blocklist/extensions.json +201 -0
  177. package/zoo/iocs/blockchain-extensions.txt +21 -0
  178. package/zoo/iocs/c2-domains.txt +50 -0
  179. package/zoo/iocs/c2-ips.txt +24 -0
  180. package/zoo/iocs/hashes.txt +47 -0
  181. package/zoo/iocs/malicious-npm.txt +85 -0
  182. package/zoo/iocs/wallets.txt +18 -0
  183. package/zoo/signatures/yara/README.md +46 -0
  184. package/zoo/signatures/yara/blockchain_c2.yar +48 -0
  185. package/zoo/signatures/yara/code_execution.yar +165 -0
  186. package/zoo/signatures/yara/credential_harvesting.yar +116 -0
  187. package/zoo/signatures/yara/crypto_wallet_targeting.yar +92 -0
  188. package/zoo/signatures/yara/data_exfiltration.yar +207 -0
  189. package/zoo/signatures/yara/google_calendar_c2.yar +187 -0
  190. package/zoo/signatures/yara/messaging_c2.yar +103 -0
  191. package/zoo/signatures/yara/multi_stage_attacks.yar +331 -0
  192. package/zoo/signatures/yara/obfuscation_patterns.yar +208 -0
  193. package/zoo/signatures/yara/powershell_attacks.yar +116 -0
  194. package/zoo/signatures/yara/rat_capabilities.yar +243 -0
  195. package/zoo/signatures/yara/self_propagation.yar +239 -0
  196. package/zoo/signatures/yara/unicode_stealth.yar +48 -0
  197. package/zoo/signatures/yara/websocket_c2.yar +83 -0
package/dist/cli.js ADDED
@@ -0,0 +1,703 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { Command } from "commander";
4
+ import pc from "picocolors";
5
+ import { clearCache, getCacheDir, getCachedVersions, listCached } from "./scanner/cache.js";
6
+ import { extractCapabilities } from "./scanner/capabilities.js";
7
+ import { downloadExtension, parseExtensionId } from "./scanner/download.js";
8
+ import { MODULE_NAMES, scanDirectory, scanExtension } from "./scanner/index.js";
9
+ import { loadExtension } from "./scanner/vsix.js";
10
+ const REGISTRIES = ["marketplace", "openvsx", "cursor"];
11
+ /**
12
+ * Strip registry prefix from an extension ID for path-checking purposes
13
+ */
14
+ function stripRegistryPrefix(target) {
15
+ if (target.startsWith("openvsx:"))
16
+ return target.slice(8);
17
+ if (target.startsWith("marketplace:"))
18
+ return target.slice(12);
19
+ if (target.startsWith("cursor:"))
20
+ return target.slice(7);
21
+ return target;
22
+ }
23
+ /**
24
+ * Check if a target looks like an extension ID vs a local path
25
+ */
26
+ function isExtensionId(target) {
27
+ // Strip registry prefix for path validation
28
+ const id = stripRegistryPrefix(target);
29
+ // Local paths: start with /, ./, ~, or contain path separators
30
+ if (id.startsWith("/") || id.startsWith("./") || id.startsWith("~")) {
31
+ return false;
32
+ }
33
+ if (id.includes("/") || id.includes("\\")) {
34
+ return false;
35
+ }
36
+ // Extension IDs: publisher.name or publisher.name@version
37
+ try {
38
+ parseExtensionId(target);
39
+ return true;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ export const cli = new Command()
46
+ .name("vsix-audit")
47
+ .description("Security scanner for VS Code extensions")
48
+ .version("0.1.0");
49
+ cli
50
+ .command("scan")
51
+ .description("Scan a VS Code extension for security issues")
52
+ .argument("<target>", "Path to .vsix file or extension ID (e.g., publisher.extension)")
53
+ .option("-o, --output <format>", "Output format (text, json, sarif)", "text")
54
+ .option("-s, --severity <level>", "Minimum severity to report (low, medium, high, critical)", "low")
55
+ .option("--no-network", "Disable network-based checks")
56
+ .option("--all-registries", "Scan from all registries (Marketplace + OpenVSX + Cursor)")
57
+ .option("--no-cache", "Bypass cache, download fresh")
58
+ .option("--force", "Re-download even if cached")
59
+ .option("-r, --recursive", "Recursively scan all .vsix files in a directory")
60
+ .option("-j, --jobs <n>", "Number of parallel scans (default: 4)", "4")
61
+ .option("-m, --module <names>", "Run only specific modules (comma-separated: package,obfuscation,ast,ioc,yara)")
62
+ .option("--profile", "Show detailed timing breakdown for each module")
63
+ .action(async (target, options) => {
64
+ try {
65
+ const useCache = options.noCache !== true;
66
+ const forceDownload = options.force === true;
67
+ // Parse and validate module filter
68
+ const modules = options.module?.split(",").map((m) => m.trim());
69
+ if (modules) {
70
+ const invalid = modules.filter((m) => !MODULE_NAMES.includes(m));
71
+ if (invalid.length > 0) {
72
+ console.error(pc.red("Error:"), `Unknown module(s): ${invalid.join(", ")}. Valid modules: ${MODULE_NAMES.join(", ")}`);
73
+ process.exit(2);
74
+ }
75
+ }
76
+ const scanOptions = {
77
+ output: options.output,
78
+ severity: options.severity,
79
+ network: options.network,
80
+ ...(options.profile ? { profile: true } : {}),
81
+ ...(modules ? { modules } : {}),
82
+ };
83
+ // Handle --all-registries mode for extension IDs
84
+ if (options.allRegistries && isExtensionId(target)) {
85
+ const results = [];
86
+ const baseId = stripRegistryPrefix(target);
87
+ for (const registry of REGISTRIES) {
88
+ const prefixedId = `${registry}:${baseId}`;
89
+ try {
90
+ console.log(pc.cyan(`Downloading from ${registry}:`), baseId);
91
+ const downloaded = await downloadExtension(prefixedId, {
92
+ useCache,
93
+ forceDownload,
94
+ });
95
+ if (downloaded.fromCache) {
96
+ console.log(pc.green("✓ Using cached"), pc.dim(downloaded.path));
97
+ }
98
+ else {
99
+ console.log(pc.green("✓ Downloaded"), pc.dim(downloaded.path));
100
+ }
101
+ const result = await scanExtension(downloaded.path, scanOptions);
102
+ result.metadata = { ...result.metadata, registry };
103
+ results.push(result);
104
+ }
105
+ catch (error) {
106
+ // Extension may not exist in this registry - continue
107
+ const msg = error instanceof Error ? error.message : String(error);
108
+ console.log(pc.dim(` Not found in ${registry}: ${msg}`));
109
+ }
110
+ }
111
+ console.log();
112
+ if (results.length === 0) {
113
+ console.error(pc.red("Error:"), `Extension not found in any registry: ${baseId}`);
114
+ process.exit(2);
115
+ }
116
+ // Output results
117
+ if (options.output === "json") {
118
+ console.log(JSON.stringify(results, null, 2));
119
+ }
120
+ else if (options.output === "sarif") {
121
+ // Combine SARIF results
122
+ const combined = {
123
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
124
+ version: "2.1.0",
125
+ runs: results.map((r) => toSarif(r).runs[0]),
126
+ };
127
+ console.log(JSON.stringify(combined, null, 2));
128
+ }
129
+ else {
130
+ for (const result of results) {
131
+ const registry = result.metadata.registry ?? "unknown";
132
+ console.log(pc.bold(`Registry: ${registry}`));
133
+ printTextReport(result);
134
+ console.log();
135
+ }
136
+ }
137
+ const hasFindings = results.some((r) => r.findings.length > 0);
138
+ process.exit(hasFindings ? 1 : 0);
139
+ return;
140
+ }
141
+ // Warn if -j used without -r
142
+ if (options.jobs && options.jobs !== "4" && !options.recursive) {
143
+ console.log(pc.yellow("Warning:"), "--jobs is only used with --recursive, ignoring");
144
+ }
145
+ // Handle --recursive mode for directories
146
+ if (options.recursive) {
147
+ const targetStat = await stat(target).catch(() => null);
148
+ if (!targetStat?.isDirectory()) {
149
+ console.error(pc.red("Error:"), "--recursive requires a directory path");
150
+ process.exit(2);
151
+ }
152
+ // Validate --jobs
153
+ const concurrency = parseInt(options.jobs ?? "4", 10);
154
+ if (isNaN(concurrency) || concurrency < 1) {
155
+ console.error(pc.red("Error:"), "--jobs must be a positive integer");
156
+ process.exit(2);
157
+ }
158
+ const isParallel = concurrency > 1;
159
+ console.log(pc.cyan("Scanning directory:"), target);
160
+ if (isParallel) {
161
+ console.log(pc.dim(`Parallel mode: ${concurrency} concurrent scans`));
162
+ }
163
+ console.log();
164
+ const batchResult = await scanDirectory(target, scanOptions, {
165
+ onProgress: (completed, total, _path) => {
166
+ if (isParallel) {
167
+ // No line clearing in parallel mode - just update progress
168
+ process.stderr.write(`\r[${completed}/${total}] Scanning...`);
169
+ }
170
+ // Sequential mode: progress shown via onResult
171
+ },
172
+ onResult: (_path, result) => {
173
+ if (isParallel) {
174
+ // Clear progress line before printing result
175
+ process.stderr.write("\r\x1b[K");
176
+ }
177
+ else {
178
+ process.stdout.write("\r\x1b[K");
179
+ }
180
+ const count = result.findings.length;
181
+ if (count === 0) {
182
+ console.log(`[${pc.green("OK")}] ${result.extension.name} v${result.extension.version}`);
183
+ }
184
+ else {
185
+ const summary = formatFindingSummary(result.findings);
186
+ console.log(`[${pc.yellow("WARN")}] ${result.extension.name} v${result.extension.version} - ${count} issue(s) (${summary})`);
187
+ }
188
+ },
189
+ onError: (path, error) => {
190
+ if (isParallel) {
191
+ process.stderr.write("\r\x1b[K");
192
+ }
193
+ else {
194
+ process.stdout.write("\r\x1b[K");
195
+ }
196
+ console.log(`[${pc.red("ERROR")}] ${basename(path)} - Error: ${error}`);
197
+ },
198
+ }, { concurrency });
199
+ // Output results
200
+ if (options.output === "json") {
201
+ console.log(JSON.stringify(batchResult, null, 2));
202
+ }
203
+ else if (options.output === "sarif") {
204
+ const combined = {
205
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
206
+ version: "2.1.0",
207
+ runs: batchResult.results.map((r) => toSarif(r).runs[0]),
208
+ };
209
+ console.log(JSON.stringify(combined, null, 2));
210
+ }
211
+ else {
212
+ printBatchSummary(batchResult);
213
+ }
214
+ // Exit codes: 0=clean, 1=findings, 2=errors only
215
+ if (batchResult.summary.totalFindings > 0) {
216
+ process.exit(1);
217
+ }
218
+ else if (batchResult.summary.failedFiles > 0 && batchResult.summary.scannedFiles === 0) {
219
+ process.exit(2);
220
+ }
221
+ process.exit(0);
222
+ return;
223
+ }
224
+ // Standard single-registry scan
225
+ let scanTarget = target;
226
+ if (isExtensionId(target)) {
227
+ console.log(pc.cyan("Downloading:"), target);
228
+ const result = await downloadExtension(target, {
229
+ useCache,
230
+ forceDownload,
231
+ });
232
+ scanTarget = result.path;
233
+ if (result.fromCache) {
234
+ console.log(pc.green("✓ Using cached"), pc.dim(result.path));
235
+ }
236
+ else {
237
+ console.log(pc.green("✓ Downloaded"), pc.dim(result.path));
238
+ }
239
+ console.log();
240
+ }
241
+ const result = await scanExtension(scanTarget, scanOptions);
242
+ if (scanOptions.output === "json") {
243
+ console.log(JSON.stringify(result, null, 2));
244
+ }
245
+ else if (scanOptions.output === "sarif") {
246
+ console.log(JSON.stringify(toSarif(result), null, 2));
247
+ }
248
+ else {
249
+ printTextReport(result);
250
+ }
251
+ process.exit(result.findings.length > 0 ? 1 : 0);
252
+ }
253
+ catch (error) {
254
+ console.error(pc.red("Error:"), error instanceof Error ? error.message : error);
255
+ process.exit(2);
256
+ }
257
+ });
258
+ cli
259
+ .command("download")
260
+ .description("Download a VS Code extension from the marketplace")
261
+ .argument("<extension-id>", "Extension ID (e.g., ms-python.python or ms-python.python@2024.1.0)")
262
+ .option("-o, --output <dir>", "Also copy to this directory (in addition to cache)")
263
+ .option("--no-cache", "Bypass cache, download fresh")
264
+ .option("--force", "Re-download even if cached")
265
+ .action(async (extensionId, options) => {
266
+ try {
267
+ const useCache = options.noCache !== true;
268
+ const forceDownload = options.force === true;
269
+ console.log(pc.cyan("Downloading:"), extensionId);
270
+ const downloadOptions = {
271
+ useCache,
272
+ forceDownload,
273
+ ...(options.output ? { destDir: options.output } : {}),
274
+ };
275
+ const result = await downloadExtension(extensionId, downloadOptions);
276
+ console.log();
277
+ if (result.fromCache) {
278
+ console.log(pc.green("✓ Using cached version"));
279
+ }
280
+ else {
281
+ console.log(pc.green("✓ Downloaded successfully"));
282
+ }
283
+ console.log(pc.dim("─".repeat(50)));
284
+ console.log(`${pc.cyan("Name:")} ${result.metadata.displayName ?? result.metadata.name}`);
285
+ console.log(`${pc.cyan("Publisher:")} ${result.metadata.publisher}`);
286
+ console.log(`${pc.cyan("Version:")} ${result.metadata.version}`);
287
+ if (result.metadata.installCount) {
288
+ console.log(`${pc.cyan("Installs:")} ${result.metadata.installCount.toLocaleString()}`);
289
+ }
290
+ console.log(`${pc.cyan("Path:")} ${result.path}`);
291
+ }
292
+ catch (error) {
293
+ console.error(pc.red("Error:"), error instanceof Error ? error.message : error);
294
+ process.exit(2);
295
+ }
296
+ });
297
+ cli
298
+ .command("info")
299
+ .description("Display metadata and capabilities of a VS Code extension")
300
+ .argument("<target>", "Path to .vsix file, directory, or extension ID (e.g., publisher.extension)")
301
+ .option("-v, --verbose", "Show detailed evidence for each capability")
302
+ .action(async (target, options) => {
303
+ try {
304
+ let infoTarget = target;
305
+ if (isExtensionId(target)) {
306
+ console.log(pc.cyan("Downloading:"), target);
307
+ const result = await downloadExtension(target);
308
+ infoTarget = result.path;
309
+ if (result.fromCache) {
310
+ console.log(pc.green("✓ Using cached"), pc.dim(result.path));
311
+ }
312
+ else {
313
+ console.log(pc.green("✓ Downloaded"), pc.dim(result.path));
314
+ }
315
+ }
316
+ const contents = await loadExtension(infoTarget);
317
+ const manifest = contents.manifest;
318
+ // Run scanner to get findings for capability extraction
319
+ const scanResult = await scanExtension(infoTarget, {
320
+ output: "text",
321
+ severity: "low",
322
+ network: true,
323
+ });
324
+ const capabilities = extractCapabilities(scanResult.findings);
325
+ console.log();
326
+ console.log(pc.bold(`Extension: ${manifest.displayName ?? manifest.name} v${manifest.version}`));
327
+ console.log(`${pc.cyan("Publisher:")} ${manifest.publisher}`);
328
+ if (manifest.description) {
329
+ console.log(`${pc.dim(manifest.description)}`);
330
+ }
331
+ // Capabilities section
332
+ console.log();
333
+ console.log(pc.dim("── Capabilities ") + pc.dim("─".repeat(34)));
334
+ printCapabilities(capabilities, options.verbose ?? false);
335
+ // Manifest section
336
+ console.log();
337
+ console.log(pc.dim("── Manifest ") + pc.dim("─".repeat(38)));
338
+ // Activation events
339
+ const events = manifest.activationEvents ?? [];
340
+ console.log(`${pc.cyan("Activation:".padEnd(16))}${events.length > 0 ? events.join(", ") : pc.dim("(none)")}`);
341
+ // Entry points
342
+ const entryPoints = [];
343
+ if (manifest.main)
344
+ entryPoints.push(`main: ${manifest.main}`);
345
+ if (manifest.browser)
346
+ entryPoints.push(`browser: ${manifest.browser}`);
347
+ if (entryPoints.length > 0) {
348
+ console.log(`${pc.cyan("Entry Points:".padEnd(16))}${entryPoints.join(", ")}`);
349
+ }
350
+ // Contributions summary
351
+ const contributes = manifest.contributes ?? {};
352
+ const contributionSummary = Object.entries(contributes)
353
+ .filter(([, val]) => (Array.isArray(val) ? val.length > 0 : val !== undefined))
354
+ .map(([key, val]) => {
355
+ const count = Array.isArray(val) ? val.length : 1;
356
+ return `${key} (${count})`;
357
+ });
358
+ if (contributionSummary.length > 0) {
359
+ console.log(`${pc.cyan("Contributes:".padEnd(16))}${contributionSummary.join(", ")}`);
360
+ }
361
+ // Dependencies
362
+ const deps = manifest["extensionDependencies"];
363
+ if (deps && deps.length > 0) {
364
+ console.log(`${pc.cyan("Dependencies:".padEnd(16))}${deps.join(", ")}`);
365
+ }
366
+ // Stats section
367
+ console.log();
368
+ console.log(pc.dim("── Stats ") + pc.dim("─".repeat(41)));
369
+ const codeExtensions = [".js", ".ts", ".mjs", ".cjs"];
370
+ const codeFileCount = [...contents.files.keys()].filter((f) => codeExtensions.some((ext) => f.endsWith(ext))).length;
371
+ console.log(`${pc.cyan("Files:".padEnd(16))}${contents.files.size} (${codeFileCount} code)`);
372
+ const totalSize = [...contents.files.values()].reduce((sum, buf) => sum + buf.length, 0);
373
+ console.log(`${pc.cyan("Size:".padEnd(16))}${formatBytes(totalSize)}`);
374
+ console.log();
375
+ }
376
+ catch (error) {
377
+ console.error(pc.red("Error:"), error instanceof Error ? error.message : error);
378
+ process.exit(2);
379
+ }
380
+ });
381
+ function printCapabilities(capabilities, verbose) {
382
+ const capEntries = [
383
+ ["Network", "network"],
384
+ ["Execution", "execution"],
385
+ ["File Access", "fileAccess"],
386
+ ["Credentials", "credentials"],
387
+ ["Obfuscation", "obfuscation"],
388
+ ];
389
+ for (const [label, key] of capEntries) {
390
+ const cap = capabilities[key];
391
+ const icon = cap.detected ? pc.green("✓") : pc.dim("✗");
392
+ const summary = cap.detected ? cap.summary.join(", ") : pc.dim("None detected");
393
+ console.log(`${label.padEnd(16)}${icon} ${summary}`);
394
+ if (verbose && cap.detected && cap.evidence.length > 0) {
395
+ for (const ev of cap.evidence.slice(0, 5)) {
396
+ const loc = ev.line ? `${ev.file}:${ev.line}` : ev.file;
397
+ const matched = ev.matched ? pc.dim(` (${ev.matched.slice(0, 40)})`) : "";
398
+ console.log(` ${pc.dim("└")} ${pc.dim(loc)}${matched}`);
399
+ }
400
+ if (cap.evidence.length > 5) {
401
+ console.log(` ${pc.dim(`... and ${cap.evidence.length - 5} more`)}`);
402
+ }
403
+ }
404
+ }
405
+ }
406
+ // Cache subcommand
407
+ const cacheCommand = cli.command("cache").description("Manage the extension cache");
408
+ cacheCommand
409
+ .command("path")
410
+ .description("Print the cache directory path")
411
+ .action(() => {
412
+ console.log(getCacheDir());
413
+ });
414
+ cacheCommand
415
+ .command("list")
416
+ .description("List cached extensions")
417
+ .option("--json", "Output as JSON")
418
+ .action(async (options) => {
419
+ try {
420
+ const extensions = await listCached();
421
+ if (extensions.length === 0) {
422
+ if (options.json) {
423
+ console.log("[]");
424
+ }
425
+ else {
426
+ console.log(pc.dim("Cache is empty"));
427
+ }
428
+ return;
429
+ }
430
+ if (options.json) {
431
+ console.log(JSON.stringify(extensions, null, 2));
432
+ return;
433
+ }
434
+ console.log();
435
+ console.log(pc.bold("Cached Extensions"));
436
+ console.log(pc.dim("─".repeat(70)));
437
+ for (const ext of extensions) {
438
+ const extensionId = `${ext.publisher}.${ext.name}`;
439
+ const cachedDate = ext.cachedAt.toLocaleDateString();
440
+ console.log(` ${pc.cyan(extensionId.padEnd(40))} ` +
441
+ `${pc.dim(ext.version.padEnd(12))} ` +
442
+ `${pc.dim(formatBytes(ext.size).padEnd(10))} ` +
443
+ `${pc.dim(ext.registry.padEnd(12))} ` +
444
+ `${pc.dim(cachedDate)}`);
445
+ }
446
+ console.log();
447
+ const totalSize = extensions.reduce((sum, ext) => sum + ext.size, 0);
448
+ console.log(`${pc.cyan("Total:")} ${extensions.length} extensions, ${formatBytes(totalSize)}`);
449
+ }
450
+ catch (error) {
451
+ console.error(pc.red("Error:"), error instanceof Error ? error.message : error);
452
+ process.exit(2);
453
+ }
454
+ });
455
+ cacheCommand
456
+ .command("clear")
457
+ .description("Clear cached extensions")
458
+ .argument("[pattern]", "Optional glob pattern (e.g., ms-python.* or *.python)")
459
+ .action(async (pattern) => {
460
+ try {
461
+ const deleted = await clearCache(pattern);
462
+ if (deleted === 0) {
463
+ if (pattern) {
464
+ console.log(pc.dim(`No extensions matching "${pattern}" found in cache`));
465
+ }
466
+ else {
467
+ console.log(pc.dim("Cache is already empty"));
468
+ }
469
+ }
470
+ else {
471
+ console.log(pc.green(`✓ Cleared ${deleted} extension(s) from cache`));
472
+ }
473
+ }
474
+ catch (error) {
475
+ console.error(pc.red("Error:"), error instanceof Error ? error.message : error);
476
+ process.exit(2);
477
+ }
478
+ });
479
+ cacheCommand
480
+ .command("info")
481
+ .description("Show cached versions of an extension")
482
+ .argument("<extension-id>", "Extension ID (e.g., ms-python.python)")
483
+ .action(async (extensionId) => {
484
+ try {
485
+ const { publisher, name } = parseExtensionId(extensionId);
486
+ const versions = await getCachedVersions(publisher, name);
487
+ if (versions.length === 0) {
488
+ console.log(pc.dim(`No cached versions of ${extensionId}`));
489
+ return;
490
+ }
491
+ console.log();
492
+ console.log(pc.bold(`Cached versions of ${extensionId}`));
493
+ console.log(pc.dim("─".repeat(50)));
494
+ for (const ext of versions) {
495
+ const cachedDate = ext.cachedAt.toLocaleDateString();
496
+ console.log(` ${pc.cyan(ext.version.padEnd(15))} ` +
497
+ `${pc.dim(ext.registry.padEnd(12))} ` +
498
+ `${pc.dim(formatBytes(ext.size).padEnd(10))} ` +
499
+ `${pc.dim(cachedDate)}`);
500
+ }
501
+ console.log();
502
+ }
503
+ catch (error) {
504
+ console.error(pc.red("Error:"), error instanceof Error ? error.message : error);
505
+ process.exit(2);
506
+ }
507
+ });
508
+ function formatBytes(bytes) {
509
+ if (bytes < 1024)
510
+ return `${bytes} B`;
511
+ if (bytes < 1024 * 1024)
512
+ return `${(bytes / 1024).toFixed(1)} KB`;
513
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
514
+ }
515
+ function formatMs(ms) {
516
+ if (ms < 1000)
517
+ return `${ms.toFixed(0)}ms`;
518
+ return `${(ms / 1000).toFixed(2)}s`;
519
+ }
520
+ function printTimings(timings) {
521
+ console.log(pc.cyan("Module timings:"));
522
+ const entries = [
523
+ ["Load", timings.load],
524
+ ...(timings.package !== undefined ? [["Package", timings.package]] : []),
525
+ ...(timings.obfuscation !== undefined
526
+ ? [["Obfuscation", timings.obfuscation]]
527
+ : []),
528
+ ...(timings.ast !== undefined ? [["AST", timings.ast]] : []),
529
+ ...(timings.ioc !== undefined ? [["IOC", timings.ioc]] : []),
530
+ ...(timings.yara !== undefined ? [["YARA", timings.yara]] : []),
531
+ ];
532
+ // Find longest module name for alignment
533
+ const maxNameLen = Math.max(...entries.map(([name]) => name.length));
534
+ // Calculate percentages and find max for bar scaling
535
+ const moduleTotal = entries.reduce((sum, [, ms]) => sum + ms, 0);
536
+ for (const [name, ms] of entries) {
537
+ const pct = moduleTotal > 0 ? (ms / moduleTotal) * 100 : 0;
538
+ const barLen = Math.round(pct / 2); // Scale to ~50 char max
539
+ const bar = "█".repeat(barLen);
540
+ const padded = name.padEnd(maxNameLen);
541
+ const timeStr = formatMs(ms).padStart(8);
542
+ const pctStr = `${pct.toFixed(1)}%`.padStart(6);
543
+ console.log(` ${pc.bold(padded)} ${timeStr} ${pctStr} ${pc.dim(bar)}`);
544
+ }
545
+ console.log(` ${"─".repeat(maxNameLen + 20)}`);
546
+ console.log(` ${pc.bold("Total".padEnd(maxNameLen))} ${formatMs(timings.total).padStart(8)}`);
547
+ console.log();
548
+ }
549
+ function printTextReport(result) {
550
+ console.log();
551
+ console.log(pc.bold("vsix-audit scan results"));
552
+ console.log(pc.dim("─".repeat(50)));
553
+ console.log();
554
+ console.log(`${pc.cyan("Extension:")} ${result.extension.name} v${result.extension.version}`);
555
+ console.log(`${pc.cyan("Publisher:")} ${result.extension.publisher}`);
556
+ console.log(`${pc.cyan("Scanned:")} ${result.metadata.scannedAt}`);
557
+ console.log();
558
+ // Print inventory of checks performed
559
+ if (result.inventory && result.inventory.length > 0) {
560
+ console.log(pc.cyan("Checks performed:"));
561
+ for (const check of result.inventory) {
562
+ if (check.enabled) {
563
+ console.log(` ${pc.green("✓")} ${pc.bold(check.name.padEnd(14))}${check.description}`);
564
+ }
565
+ else {
566
+ console.log(` ${pc.yellow("⚠")} ${pc.bold(check.name.padEnd(14))}${pc.dim(`Skipped (${check.skipReason})`)}`);
567
+ }
568
+ }
569
+ console.log();
570
+ }
571
+ // Print timings if profiling enabled
572
+ if (result.metadata.timings) {
573
+ printTimings(result.metadata.timings);
574
+ }
575
+ if (result.findings.length === 0) {
576
+ console.log(pc.green("✓ No security issues found"));
577
+ return;
578
+ }
579
+ console.log(pc.yellow(`Found ${result.findings.length} issue(s):`));
580
+ console.log();
581
+ for (const finding of result.findings) {
582
+ const severityColor = {
583
+ critical: pc.red,
584
+ high: pc.red,
585
+ medium: pc.yellow,
586
+ low: pc.blue,
587
+ }[finding.severity];
588
+ console.log(` ${severityColor(`[${finding.severity.toUpperCase()}]`)} ${finding.title}`);
589
+ console.log(` ${pc.dim(finding.description)}`);
590
+ if (finding.location) {
591
+ console.log(` ${pc.dim(`at ${finding.location.file}${finding.location.line ? `:${finding.location.line}` : ""}`)}`);
592
+ }
593
+ console.log();
594
+ }
595
+ }
596
+ function toSarif(result) {
597
+ return {
598
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
599
+ version: "2.1.0",
600
+ runs: [
601
+ {
602
+ tool: {
603
+ driver: {
604
+ name: "vsix-audit",
605
+ version: "0.1.0",
606
+ informationUri: "https://github.com/trailofbits/vsix-audit",
607
+ },
608
+ },
609
+ results: result.findings.map((f) => ({
610
+ ruleId: f.id,
611
+ level: f.severity === "critical" || f.severity === "high" ? "error" : "warning",
612
+ message: { text: f.description },
613
+ locations: f.location
614
+ ? [
615
+ {
616
+ physicalLocation: {
617
+ artifactLocation: { uri: f.location.file },
618
+ region: f.location.line ? { startLine: f.location.line } : undefined,
619
+ },
620
+ },
621
+ ]
622
+ : [],
623
+ })),
624
+ },
625
+ ],
626
+ };
627
+ }
628
+ function formatFindingSummary(findings) {
629
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
630
+ for (const f of findings) {
631
+ counts[f.severity]++;
632
+ }
633
+ const parts = [];
634
+ if (counts.critical > 0)
635
+ parts.push(`${counts.critical} critical`);
636
+ if (counts.high > 0)
637
+ parts.push(`${counts.high} high`);
638
+ if (counts.medium > 0)
639
+ parts.push(`${counts.medium} medium`);
640
+ if (counts.low > 0)
641
+ parts.push(`${counts.low} low`);
642
+ return parts.join(", ");
643
+ }
644
+ function printBatchSummary(batch) {
645
+ const { summary, results, errors } = batch;
646
+ console.log();
647
+ console.log(pc.bold("Batch Scan Summary"));
648
+ console.log(pc.dim("─".repeat(50)));
649
+ console.log();
650
+ console.log(`Files scanned: ${summary.scannedFiles}/${summary.totalFiles}`);
651
+ if (summary.failedFiles > 0) {
652
+ console.log(`Failed: ${pc.red(String(summary.failedFiles))}`);
653
+ }
654
+ console.log(`Duration: ${(summary.scanDuration / 1000).toFixed(1)}s`);
655
+ console.log();
656
+ if (summary.totalFindings === 0) {
657
+ console.log(pc.green("No issues found across all extensions"));
658
+ return;
659
+ }
660
+ console.log(pc.yellow(`Found ${summary.totalFindings} issue(s) across all extensions:`));
661
+ const sev = summary.findingsBySeverity;
662
+ if (sev.critical > 0)
663
+ console.log(` ${pc.red("Critical:")} ${sev.critical}`);
664
+ if (sev.high > 0)
665
+ console.log(` ${pc.red("High:")} ${sev.high}`);
666
+ if (sev.medium > 0)
667
+ console.log(` ${pc.yellow("Medium:")} ${sev.medium}`);
668
+ if (sev.low > 0)
669
+ console.log(` ${pc.blue("Low:")} ${sev.low}`);
670
+ console.log();
671
+ const withFindings = results.filter((r) => r.findings.length > 0);
672
+ if (withFindings.length > 0) {
673
+ console.log(pc.cyan("Extensions with findings:"));
674
+ console.log();
675
+ for (const r of withFindings) {
676
+ console.log(pc.bold(`${r.extension.name} v${r.extension.version}`));
677
+ console.log(pc.dim("─".repeat(40)));
678
+ for (const finding of r.findings) {
679
+ const severityColor = {
680
+ critical: pc.red,
681
+ high: pc.red,
682
+ medium: pc.yellow,
683
+ low: pc.blue,
684
+ }[finding.severity];
685
+ console.log(` ${severityColor(`[${finding.severity.toUpperCase()}]`)} ${finding.title}`);
686
+ console.log(` ${pc.dim(finding.description)}`);
687
+ if (finding.location) {
688
+ console.log(` ${pc.dim(`at ${finding.location.file}${finding.location.line ? `:${finding.location.line}` : ""}`)}`);
689
+ }
690
+ console.log();
691
+ }
692
+ }
693
+ }
694
+ if (errors.length > 0) {
695
+ console.log();
696
+ console.log(pc.cyan("Failed files:"));
697
+ for (const e of errors) {
698
+ console.log(` - ${e.path}`);
699
+ console.log(` ${pc.dim(e.error)}`);
700
+ }
701
+ }
702
+ }
703
+ //# sourceMappingURL=cli.js.map