@cdxgen/cdxgen 13.4.0 → 13.6.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 (305) hide show
  1. package/README.md +9 -9
  2. package/bin/cdxgen-secure.js +15 -0
  3. package/bin/cdxgen.js +29 -5
  4. package/bin/evinse.js +8 -0
  5. package/bin/hbom.js +15 -7
  6. package/bin/repl.js +29 -0
  7. package/bin/validate.js +6 -0
  8. package/data/cbomosdb-queries.json +13 -1
  9. package/data/component-tags.json +8 -0
  10. package/data/license-aliases.json +24 -2
  11. package/data/queries-darwin.json +25 -1
  12. package/data/queries-win.json +19 -1
  13. package/data/queries.json +41 -0
  14. package/data/remediations.json +407 -1
  15. package/data/rules/ai-bom.yaml +1 -1
  16. package/data/rules/brotli-decoder.yaml +107 -0
  17. package/data/rules/cbom-security.yaml +235 -4
  18. package/data/rules/go-binaries.yaml +46 -0
  19. package/data/rules/golem-go.yaml +80 -0
  20. package/data/rules/hbom-compliance.yaml +40 -22
  21. package/data/rules/hbom-performance.yaml +7 -1
  22. package/data/rules/host-topology.yaml +1 -1
  23. package/data/rules/obom-runtime.yaml +31 -2
  24. package/data/rules/php-runtime.yaml +143 -0
  25. package/data/rules/vscode-extensions.yaml +75 -0
  26. package/data/spdx-license-list.json +121 -7
  27. package/data/spdx-licenses.json +69 -0
  28. package/data/spdx.schema.json +845 -2
  29. package/lib/audit/index.js +14 -0
  30. package/lib/cli/bomAssembly.js +120 -6
  31. package/lib/cli/cliOptions.js +110 -3
  32. package/lib/cli/index.js +182 -6
  33. package/lib/cli/jsBom.js +153 -64
  34. package/lib/cli/jvmBom.js +363 -66
  35. package/lib/cli/managedBom.js +853 -87
  36. package/lib/cli/nativeBom.js +267 -33
  37. package/lib/core/boundedFile.js +344 -0
  38. package/lib/core/brotliProbe.js +182 -0
  39. package/lib/core/env.js +38 -8
  40. package/lib/core/fs.js +40 -19
  41. package/lib/core/httpClient.js +172 -46
  42. package/lib/core/lockPaths.js +38 -0
  43. package/lib/core/paths.js +24 -1
  44. package/lib/core/processTree.js +5 -0
  45. package/lib/core/projectConfig.js +1 -0
  46. package/lib/core/projectTooling.js +97 -0
  47. package/lib/core/propertySanitizer.js +72 -0
  48. package/lib/core/toolRegistry.js +170 -0
  49. package/lib/ecosystems/cppEvidence.js +263 -106
  50. package/lib/ecosystems/cppIncludeResolver.js +16 -5
  51. package/lib/ecosystems/ecosystems.js +25 -10
  52. package/lib/ecosystems/gemHostProbe.js +301 -0
  53. package/lib/ecosystems/golem.js +550 -6
  54. package/lib/ecosystems/gradleutils.js +858 -244
  55. package/lib/ecosystems/parsers-bazel.js +107 -6
  56. package/lib/ecosystems/parsers-dotnet.js +117 -18
  57. package/lib/ecosystems/parsers-go.js +1090 -367
  58. package/lib/ecosystems/parsers-js.js +505 -22
  59. package/lib/ecosystems/parsers-jvm.js +259 -17
  60. package/lib/ecosystems/parsers-misc.js +438 -64
  61. package/lib/ecosystems/parsers-python.js +100 -12
  62. package/lib/ecosystems/pythonutils.js +128 -2
  63. package/lib/ecosystems/rubyutils.js +544 -34
  64. package/lib/ecosystems/rusi.js +10 -0
  65. package/lib/ecosystems/scalacliutils.js +4 -1
  66. package/lib/ecosystems/scalasem.js +798 -151
  67. package/lib/ecosystems/vendoredCode.js +237 -3
  68. package/lib/ecosystems/venvProbe.js +360 -0
  69. package/lib/evinser/evinser.js +472 -61
  70. package/lib/evinser/swiftsem.js +68 -5
  71. package/lib/helpers/agenticToolutils.js +413 -93
  72. package/lib/helpers/vsixutils.js +1574 -315
  73. package/lib/inventory/agentSkillSpec.js +17 -0
  74. package/lib/inventory/apkInstalledDb.js +243 -0
  75. package/lib/inventory/atomUtils.js +110 -0
  76. package/lib/inventory/auditCategories.js +3 -3
  77. package/lib/inventory/brotliDecoder.js +142 -0
  78. package/lib/inventory/buildToolRateLimit.js +1 -1
  79. package/lib/inventory/cacheDir.js +52 -9
  80. package/lib/inventory/cbomutils.js +56 -7
  81. package/lib/inventory/certificates.js +2371 -0
  82. package/lib/inventory/communityAiConfigParser.js +4 -9
  83. package/lib/inventory/deps.js +41 -4
  84. package/lib/inventory/display.js +200 -3
  85. package/lib/inventory/dosai.js +639 -63
  86. package/lib/inventory/dotnetRuntime.js +217 -0
  87. package/lib/inventory/dynamic.js +45 -3
  88. package/lib/inventory/envcontext.js +475 -18
  89. package/lib/inventory/evidenceUtils.js +447 -23
  90. package/lib/inventory/fetchBatch.js +1 -1
  91. package/lib/inventory/formulationParsers.js +32 -3
  92. package/lib/inventory/goBuildInfo.js +140 -0
  93. package/lib/inventory/gtfobins.js +13 -0
  94. package/lib/inventory/hbom.js +67 -7
  95. package/lib/inventory/hbomAnalysis.js +16 -0
  96. package/lib/inventory/hostRuntimeProbe.js +686 -0
  97. package/lib/inventory/hostTopology.js +21 -0
  98. package/lib/inventory/jvmLocalRepos.js +9 -2
  99. package/lib/inventory/jvmToolEnv.js +29 -0
  100. package/lib/inventory/licenseEnhancer.js +12 -0
  101. package/lib/inventory/mavenCentralMirror.js +534 -0
  102. package/lib/inventory/nodeBuildInfo.js +218 -0
  103. package/lib/inventory/nodeHostProbe.js +202 -0
  104. package/lib/inventory/osPackagePurl.js +244 -0
  105. package/lib/inventory/osPackageResolver.js +271 -87
  106. package/lib/inventory/osinfo.js +79 -31
  107. package/lib/inventory/osqueryTransform.js +114 -5
  108. package/lib/inventory/phpBuildInfo.js +438 -0
  109. package/lib/inventory/phpHostProbe.js +185 -0
  110. package/lib/inventory/rootfsCerts.js +426 -0
  111. package/lib/inventory/rubyBuildInfo.js +972 -0
  112. package/lib/inventory/rubyHostProbe.js +218 -0
  113. package/lib/inventory/scalaCoords.js +4 -3
  114. package/lib/inventory/scalaDedupe.js +440 -0
  115. package/lib/inventory/source.js +256 -5
  116. package/lib/inventory/spdx.js +144 -0
  117. package/lib/inventory/traceRunner.js +5 -1
  118. package/lib/inventory/tracedCrypto.js +129 -0
  119. package/lib/managers/binary.js +866 -186
  120. package/lib/managers/docker.js +245 -12
  121. package/lib/parsers/email.js +277 -0
  122. package/lib/parsers/largeJson.js +62 -13
  123. package/lib/parsers/vscodeExtensionId.js +183 -0
  124. package/lib/server/server.js +198 -2
  125. package/lib/stages/postgen/annotator.js +17 -1
  126. package/lib/stages/postgen/introspection/reflect.js +4 -1
  127. package/lib/stages/postgen/postgen.js +5 -5
  128. package/lib/stages/postgen/ruleEngine.js +71 -6
  129. package/lib/stages/postgen/specVersionCompat.js +30 -0
  130. package/lib/stages/pregen/envAudit.js +1 -0
  131. package/lib/stages/pregen/pregen.js +231 -62
  132. package/lib/validator/bomValidator.js +32 -6
  133. package/lib/validator/generated/runtime.mjs +13 -0
  134. package/lib/validator/generated/validate-1.6.mjs +27 -8
  135. package/lib/validator/generated/validate-1.7.mjs +32 -13
  136. package/lib/validator/generated/validate-2.0.mjs +31 -12
  137. package/lib/validator/index.js +1 -1
  138. package/package.json +19 -18
  139. package/types/bin/cdxgen-secure.d.ts +3 -0
  140. package/types/bin/cdxgen-secure.d.ts.map +1 -0
  141. package/types/bin/repl.d.ts.map +1 -1
  142. package/types/lib/audit/index.d.ts.map +1 -1
  143. package/types/lib/cli/bomAssembly.d.ts +22 -3
  144. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  145. package/types/lib/cli/cliOptions.d.ts +43 -0
  146. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  147. package/types/lib/cli/index.d.ts.map +1 -1
  148. package/types/lib/cli/jsBom.d.ts +1 -17
  149. package/types/lib/cli/jsBom.d.ts.map +1 -1
  150. package/types/lib/cli/jvmBom.d.ts +20 -0
  151. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  152. package/types/lib/cli/managedBom.d.ts.map +1 -1
  153. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  154. package/types/lib/core/boundedFile.d.ts +70 -0
  155. package/types/lib/core/boundedFile.d.ts.map +1 -0
  156. package/types/lib/core/brotliProbe.d.ts +37 -0
  157. package/types/lib/core/brotliProbe.d.ts.map +1 -0
  158. package/types/lib/core/env.d.ts +22 -2
  159. package/types/lib/core/env.d.ts.map +1 -1
  160. package/types/lib/core/fs.d.ts.map +1 -1
  161. package/types/lib/core/httpClient.d.ts +24 -0
  162. package/types/lib/core/httpClient.d.ts.map +1 -1
  163. package/types/lib/core/lockPaths.d.ts +19 -0
  164. package/types/lib/core/lockPaths.d.ts.map +1 -0
  165. package/types/lib/core/processTree.d.ts +2 -0
  166. package/types/lib/core/processTree.d.ts.map +1 -1
  167. package/types/lib/core/projectConfig.d.ts.map +1 -1
  168. package/types/lib/core/projectTooling.d.ts +41 -0
  169. package/types/lib/core/projectTooling.d.ts.map +1 -0
  170. package/types/lib/core/propertySanitizer.d.ts +26 -0
  171. package/types/lib/core/propertySanitizer.d.ts.map +1 -1
  172. package/types/lib/core/toolRegistry.d.ts +36 -0
  173. package/types/lib/core/toolRegistry.d.ts.map +1 -0
  174. package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
  175. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  176. package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
  177. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
  178. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  179. package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
  180. package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
  181. package/types/lib/ecosystems/golem.d.ts +81 -1
  182. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  183. package/types/lib/ecosystems/gradleutils.d.ts +57 -21
  184. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-dotnet.d.ts +40 -4
  187. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  188. package/types/lib/ecosystems/parsers-go.d.ts +85 -13
  189. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  190. package/types/lib/ecosystems/parsers-js.d.ts +17 -20
  191. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +28 -2
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +37 -58
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +14 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/pythonutils.d.ts +19 -1
  199. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  200. package/types/lib/ecosystems/rubyutils.d.ts +144 -12
  201. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  202. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  203. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  204. package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
  205. package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
  206. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
  207. package/types/lib/ecosystems/venvProbe.d.ts +40 -0
  208. package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
  209. package/types/lib/evinser/evinser.d.ts +88 -8
  210. package/types/lib/evinser/evinser.d.ts.map +1 -1
  211. package/types/lib/evinser/swiftsem.d.ts +10 -0
  212. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  213. package/types/lib/inventory/agentSkillSpec.d.ts +13 -0
  214. package/types/lib/inventory/agentSkillSpec.d.ts.map +1 -0
  215. package/types/lib/inventory/apkInstalledDb.d.ts +42 -0
  216. package/types/lib/inventory/apkInstalledDb.d.ts.map +1 -0
  217. package/types/lib/inventory/atomUtils.d.ts +33 -0
  218. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  219. package/types/lib/inventory/brotliDecoder.d.ts +58 -0
  220. package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
  221. package/types/lib/inventory/cacheDir.d.ts +12 -4
  222. package/types/lib/inventory/cacheDir.d.ts.map +1 -1
  223. package/types/lib/inventory/cbomutils.d.ts +17 -0
  224. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  225. package/types/lib/inventory/certificates.d.ts +205 -0
  226. package/types/lib/inventory/certificates.d.ts.map +1 -0
  227. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  228. package/types/lib/inventory/deps.d.ts +2 -1
  229. package/types/lib/inventory/deps.d.ts.map +1 -1
  230. package/types/lib/inventory/display.d.ts +18 -0
  231. package/types/lib/inventory/display.d.ts.map +1 -1
  232. package/types/lib/inventory/dosai.d.ts +162 -7
  233. package/types/lib/inventory/dosai.d.ts.map +1 -1
  234. package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
  235. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
  236. package/types/lib/inventory/dynamic.d.ts +6 -1
  237. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  238. package/types/lib/inventory/envcontext.d.ts +92 -8
  239. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  240. package/types/lib/inventory/evidenceUtils.d.ts +29 -10
  241. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  242. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  243. package/types/lib/inventory/goBuildInfo.d.ts +10 -0
  244. package/types/lib/inventory/goBuildInfo.d.ts.map +1 -0
  245. package/types/lib/inventory/gtfobins.d.ts.map +1 -1
  246. package/types/lib/inventory/hbom.d.ts.map +1 -1
  247. package/types/lib/inventory/hbomAnalysis.d.ts +10 -0
  248. package/types/lib/inventory/hbomAnalysis.d.ts.map +1 -1
  249. package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
  250. package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
  251. package/types/lib/inventory/hostTopology.d.ts.map +1 -1
  252. package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
  253. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  254. package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
  255. package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
  256. package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
  257. package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
  258. package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
  259. package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
  260. package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
  261. package/types/lib/inventory/osPackagePurl.d.ts +99 -0
  262. package/types/lib/inventory/osPackagePurl.d.ts.map +1 -0
  263. package/types/lib/inventory/osPackageResolver.d.ts +44 -2
  264. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  265. package/types/lib/inventory/osinfo.d.ts +31 -6
  266. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  267. package/types/lib/inventory/osqueryTransform.d.ts +51 -0
  268. package/types/lib/inventory/osqueryTransform.d.ts.map +1 -1
  269. package/types/lib/inventory/phpBuildInfo.d.ts +142 -0
  270. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -0
  271. package/types/lib/inventory/phpHostProbe.d.ts +35 -0
  272. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -0
  273. package/types/lib/inventory/rootfsCerts.d.ts +27 -0
  274. package/types/lib/inventory/rootfsCerts.d.ts.map +1 -0
  275. package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
  276. package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
  277. package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
  278. package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
  279. package/types/lib/inventory/source.d.ts +38 -0
  280. package/types/lib/inventory/source.d.ts.map +1 -1
  281. package/types/lib/inventory/spdx.d.ts +15 -0
  282. package/types/lib/inventory/spdx.d.ts.map +1 -1
  283. package/types/lib/inventory/traceRunner.d.ts +4 -1
  284. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  285. package/types/lib/inventory/tracedCrypto.d.ts +22 -0
  286. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -1
  287. package/types/lib/managers/binary.d.ts +10 -0
  288. package/types/lib/managers/binary.d.ts.map +1 -1
  289. package/types/lib/managers/docker.d.ts +24 -0
  290. package/types/lib/managers/docker.d.ts.map +1 -1
  291. package/types/lib/parsers/email.d.ts +19 -0
  292. package/types/lib/parsers/email.d.ts.map +1 -0
  293. package/types/lib/parsers/largeJson.d.ts +17 -3
  294. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  295. package/types/lib/server/server.d.ts +8 -0
  296. package/types/lib/server/server.d.ts.map +1 -1
  297. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  298. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -1
  299. package/types/lib/stages/postgen/ruleEngine.d.ts +7 -4
  300. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  301. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  302. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  303. package/types/lib/stages/pregen/pregen.d.ts +26 -9
  304. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -1,11 +1,17 @@
1
- import { readdirSync, readFileSync } from "node:fs";
1
+ import { lstatSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, join, resolve } from "node:path";
3
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
 
5
5
  import { build, Purl } from "@cdxgen/cdx-purl";
6
6
  import StreamZip from "node-stream-zip";
7
7
 
8
8
  import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
9
+ import {
10
+ hashRegularFile,
11
+ isRunningAsRoot,
12
+ readRegularTextFile,
13
+ readUserFileText,
14
+ } from "../core/boundedFile.js";
9
15
  import {
10
16
  getTmpDir,
11
17
  safeExistsSync,
@@ -13,7 +19,13 @@ import {
13
19
  safeMkdtempSync,
14
20
  safeRmSync,
15
21
  } from "../core/fs.js";
16
- import { isMac, isWin } from "../core/paths.js";
22
+ import { thoughtLog } from "../core/logger.js";
23
+ import { isMac, isWin, redactHomePath } from "../core/paths.js";
24
+ import { sanitizeBomUrl } from "../core/propertySanitizer.js";
25
+ import {
26
+ normalizeVscodeTargetPlatform,
27
+ parseVscodeExtensionDirName,
28
+ } from "../parsers/vscodeExtensionId.js";
17
29
  import { xml2js } from "../parsers/xml.js";
18
30
  import { toVersRange } from "./versutils.js";
19
31
 
@@ -28,225 +40,672 @@ export const VSCODE_EXTENSION_PURL_TYPE = "vscode-extension";
28
40
  const MANIFEST_ANALYSIS_CONFIDENCE = 0.6;
29
41
 
30
42
  /**
31
- * IDE configuration entries describing where each IDE stores its extensions.
32
- * Each entry contains the IDE name and an array of candidate extension
33
- * directory paths for Windows, macOS, and Linux (including remote/server
34
- * environments).
43
+ * Namespace of the `pkg:generic` purl given to an installed editor, which has
44
+ * no package type of its own.
45
+ */
46
+ export const IDE_PURL_NAMESPACE = "cdxgen-ide";
47
+
48
+ /**
49
+ * VS Code-family editors and remote servers whose installed extensions cdxgen
50
+ * can inventory.
51
+ *
52
+ * Every editor built from VS Code installs extensions into
53
+ * `<home>/<dataFolderName>/extensions` on Windows, macOS and Linux alike, and
54
+ * its remote server installs them into `<home>/<serverDataFolderName>/extensions`
55
+ * on the remote host. Settings and global storage live elsewhere (for example
56
+ * `~/Library/Application Support/Cursor/User`), and that folder never holds
57
+ * extensions.
35
58
  *
36
- * The paths use platform-specific logic via `homedir()` and common
37
- * environment variables.
59
+ * `apps` lists where the editor itself is installed, relative to the
60
+ * platform's application folders, so its version can be read from
61
+ * `resources/app/product.json` and `package.json`.
38
62
  */
39
- export function getIdeExtensionDirs() {
40
- const home = homedir();
41
- const appData =
42
- readEnvironmentVariable("APPDATA") || join(home, "AppData", "Roaming");
43
- const localAppData =
44
- readEnvironmentVariable("LOCALAPPDATA") || join(home, "AppData", "Local");
45
- const xdgDataHome =
46
- readEnvironmentVariable("XDG_DATA_HOME") || join(home, ".local", "share");
47
-
48
- // Each entry: { name, dirs: string[] }
49
- // Only include directories that are relevant for the current platform,
50
- // plus well-known remote/server paths that are always Linux.
51
- const ides = [
52
- {
53
- name: "VS Code",
54
- dirs: isWin
55
- ? [join(appData, "Code", "User", "extensions")]
56
- : isMac
57
- ? [
58
- join(
59
- home,
60
- "Library",
61
- "Application Support",
62
- "Code",
63
- "User",
64
- "extensions",
65
- ),
66
- ]
67
- : [join(home, ".vscode", "extensions")],
63
+ export const IDE_PRODUCTS = [
64
+ {
65
+ id: "vscode",
66
+ name: "VS Code",
67
+ dataFolderName: ".vscode",
68
+ serverDataFolderName: ".vscode-server",
69
+ apps: {
70
+ darwin: ["Visual Studio Code.app"],
71
+ linux: [
72
+ "/usr/share/code",
73
+ "/opt/visual-studio-code",
74
+ "/snap/code/current/usr/share/code",
75
+ ],
76
+ win32: ["Microsoft VS Code"],
68
77
  },
69
- {
70
- name: "VS Code Insiders",
71
- dirs: isWin
72
- ? [join(appData, "Code - Insiders", "User", "extensions")]
73
- : isMac
74
- ? [
75
- join(
76
- home,
77
- "Library",
78
- "Application Support",
79
- "Code - Insiders",
80
- "User",
81
- "extensions",
82
- ),
83
- ]
84
- : [join(home, ".vscode-insiders", "extensions")],
78
+ },
79
+ {
80
+ id: "vscode-insiders",
81
+ name: "VS Code Insiders",
82
+ dataFolderName: ".vscode-insiders",
83
+ serverDataFolderName: ".vscode-server-insiders",
84
+ apps: {
85
+ darwin: ["Visual Studio Code - Insiders.app"],
86
+ linux: [
87
+ "/usr/share/code-insiders",
88
+ "/snap/code-insiders/current/usr/share/code-insiders",
89
+ ],
90
+ win32: ["Microsoft VS Code Insiders"],
85
91
  },
86
- {
87
- name: "VSCodium",
88
- dirs: isWin
89
- ? [join(appData, "VSCodium", "User", "extensions")]
90
- : isMac
91
- ? [
92
- join(
93
- home,
94
- "Library",
95
- "Application Support",
96
- "VSCodium",
97
- "User",
98
- "extensions",
99
- ),
100
- ]
101
- : [
102
- join(home, ".vscode-oss", "extensions"),
103
- join(home, ".config", "VSCodium", "User", "extensions"),
104
- ],
92
+ },
93
+ {
94
+ // VSCodium keeps the Code - OSS data folder, so a self-built Code - OSS
95
+ // shares it.
96
+ id: "vscodium",
97
+ name: "VSCodium",
98
+ dataFolderName: ".vscode-oss",
99
+ serverDataFolderName: ".vscodium-server",
100
+ apps: {
101
+ darwin: ["VSCodium.app"],
102
+ linux: [
103
+ "/usr/share/codium",
104
+ "/opt/vscodium-bin",
105
+ "/snap/codium/current/usr/share/codium",
106
+ ],
107
+ win32: ["VSCodium"],
105
108
  },
106
- {
107
- name: "Cursor",
108
- dirs: isWin
109
- ? [
110
- join(appData, "Cursor", "User", "extensions"),
111
- join(localAppData, "cursor", "extensions"),
112
- ]
113
- : isMac
114
- ? [
115
- join(
116
- home,
117
- "Library",
118
- "Application Support",
119
- "Cursor",
120
- "User",
121
- "extensions",
122
- ),
123
- ]
124
- : [join(home, ".cursor", "extensions")],
109
+ },
110
+ {
111
+ id: "vscodium-insiders",
112
+ name: "VSCodium Insiders",
113
+ dataFolderName: ".vscodium-insiders",
114
+ serverDataFolderName: ".vscodium-server-insiders",
115
+ apps: {
116
+ darwin: ["VSCodium - Insiders.app"],
117
+ linux: ["/usr/share/codium-insiders"],
118
+ win32: ["VSCodium Insiders"],
125
119
  },
126
- {
127
- name: "Windsurf",
128
- dirs: isWin
129
- ? [join(appData, "Windsurf", "User", "extensions")]
130
- : isMac
131
- ? [
132
- join(
133
- home,
134
- "Library",
135
- "Application Support",
136
- "Windsurf",
137
- "User",
138
- "extensions",
139
- ),
140
- ]
141
- : [join(home, ".windsurf", "extensions")],
120
+ },
121
+ {
122
+ id: "cursor",
123
+ name: "Cursor",
124
+ dataFolderName: ".cursor",
125
+ serverDataFolderName: ".cursor-server",
126
+ apps: {
127
+ darwin: ["Cursor.app"],
128
+ linux: ["/usr/share/cursor", "/opt/Cursor", "/opt/cursor"],
129
+ win32: ["cursor"],
142
130
  },
143
- {
144
- name: "Positron",
145
- dirs: isWin
146
- ? [join(appData, "Positron", "User", "extensions")]
147
- : isMac
148
- ? [
149
- join(
150
- home,
151
- "Library",
152
- "Application Support",
153
- "Positron",
154
- "User",
155
- "extensions",
156
- ),
157
- ]
158
- : [join(home, ".positron", "extensions")],
131
+ },
132
+ {
133
+ id: "windsurf",
134
+ name: "Windsurf",
135
+ dataFolderName: ".windsurf",
136
+ serverDataFolderName: ".windsurf-server",
137
+ apps: {
138
+ darwin: ["Windsurf.app"],
139
+ linux: ["/usr/share/windsurf", "/opt/Windsurf", "/opt/windsurf"],
140
+ win32: ["Windsurf"],
159
141
  },
160
- {
161
- name: "Theia",
162
- dirs: isWin
163
- ? [join(appData, "Theia", "extensions")]
164
- : isMac
165
- ? [
166
- join(
167
- home,
168
- "Library",
169
- "Application Support",
170
- "Theia",
171
- "extensions",
172
- ),
173
- ]
174
- : [
175
- join(home, ".theia", "extensions"),
176
- join(xdgDataHome, "theia", "extensions"),
177
- ],
142
+ },
143
+ {
144
+ id: "windsurf-next",
145
+ name: "Windsurf Next",
146
+ dataFolderName: ".windsurf-next",
147
+ serverDataFolderName: ".windsurf-next-server",
148
+ apps: {
149
+ darwin: ["Windsurf - Next.app"],
150
+ linux: ["/usr/share/windsurf-next"],
151
+ win32: ["Windsurf Next"],
178
152
  },
179
- // Remote / server environments (Linux only)
180
- {
181
- name: "code-server",
182
- dirs: [join(xdgDataHome, "code-server", "extensions")],
153
+ },
154
+ {
155
+ // Windsurf was renamed Devin Desktop, and new installs use these folders.
156
+ id: "devin-desktop",
157
+ name: "Devin Desktop",
158
+ dataFolderName: ".devin",
159
+ serverDataFolderName: ".devin-server",
160
+ apps: {
161
+ darwin: ["Devin.app", "Devin Desktop.app"],
162
+ linux: ["/usr/share/devin"],
163
+ win32: ["Devin"],
183
164
  },
184
- {
185
- name: "VS Code Remote",
186
- dirs: [join(home, ".vscode-remote", "extensions")],
165
+ },
166
+ {
167
+ id: "positron",
168
+ name: "Positron",
169
+ dataFolderName: ".positron",
170
+ serverDataFolderName: ".positron-server",
171
+ apps: {
172
+ darwin: ["Positron.app"],
173
+ linux: ["/usr/share/positron", "/usr/lib/positron"],
174
+ win32: ["Positron"],
187
175
  },
188
- {
189
- name: "OpenVSCode Server",
190
- dirs: [join(xdgDataHome, "openvscode-server", "extensions")],
176
+ },
177
+ {
178
+ id: "kiro",
179
+ name: "Kiro",
180
+ dataFolderName: ".kiro",
181
+ serverDataFolderName: ".kiro-server",
182
+ apps: {
183
+ darwin: ["Kiro.app"],
184
+ linux: ["/usr/share/kiro", "/opt/Kiro"],
185
+ win32: ["Kiro"],
191
186
  },
192
- {
193
- name: "Trae",
194
- dirs: isWin
195
- ? [join(appData, "Trae", "User", "extensions")]
196
- : isMac
197
- ? [
198
- join(
199
- home,
200
- "Library",
201
- "Application Support",
202
- "Trae",
203
- "User",
204
- "extensions",
205
- ),
206
- ]
207
- : [join(home, ".trae", "extensions")],
187
+ },
188
+ {
189
+ id: "trae",
190
+ name: "Trae",
191
+ dataFolderName: ".trae",
192
+ serverDataFolderName: ".trae-server",
193
+ apps: {
194
+ darwin: ["Trae.app"],
195
+ linux: ["/usr/share/trae"],
196
+ win32: ["Trae"],
208
197
  },
209
- {
210
- name: "Augment Code",
211
- dirs: isWin
212
- ? [join(appData, "Augment Code", "User", "extensions")]
213
- : isMac
214
- ? [
215
- join(
216
- home,
217
- "Library",
218
- "Application Support",
219
- "Augment Code",
220
- "User",
221
- "extensions",
222
- ),
223
- ]
224
- : [join(home, ".augment-code", "extensions")],
198
+ },
199
+ {
200
+ id: "antigravity",
201
+ name: "Antigravity",
202
+ dataFolderName: ".antigravity",
203
+ serverDataFolderName: ".antigravity-server",
204
+ apps: {
205
+ darwin: ["Antigravity.app"],
206
+ linux: ["/usr/share/antigravity"],
207
+ win32: ["Antigravity"],
225
208
  },
226
- ];
209
+ },
210
+ {
211
+ id: "antigravity-ide",
212
+ name: "Antigravity IDE",
213
+ dataFolderName: ".antigravity-ide",
214
+ serverDataFolderName: ".antigravity-ide-server",
215
+ apps: {
216
+ darwin: ["Antigravity IDE.app"],
217
+ linux: ["/usr/share/antigravity-ide"],
218
+ win32: ["Antigravity IDE"],
219
+ },
220
+ },
221
+ {
222
+ id: "qoder",
223
+ name: "Qoder",
224
+ dataFolderName: ".qoder",
225
+ serverDataFolderName: ".qoder-server",
226
+ apps: { darwin: ["Qoder.app"], linux: [], win32: ["Qoder"] },
227
+ },
228
+ {
229
+ id: "void",
230
+ name: "Void",
231
+ dataFolderName: ".void-editor",
232
+ serverDataFolderName: ".void-server",
233
+ apps: { darwin: ["Void.app"], linux: ["/usr/share/void"], win32: ["Void"] },
234
+ },
235
+ {
236
+ id: "pearai",
237
+ name: "PearAI",
238
+ dataFolderName: ".pearai",
239
+ serverDataFolderName: ".pearai-server",
240
+ apps: { darwin: ["PearAI.app"], linux: [], win32: ["PearAI"] },
241
+ },
242
+ {
243
+ id: "theia",
244
+ name: "Theia IDE",
245
+ dataFolderName: ".theia-ide",
246
+ apps: {},
247
+ },
248
+ {
249
+ id: "theia-legacy",
250
+ name: "Theia",
251
+ dataFolderName: ".theia",
252
+ apps: {},
253
+ },
254
+ ];
227
255
 
228
- return ides;
256
+ /**
257
+ * Remote and hosted servers that are not tied to one desktop editor.
258
+ * `appDirs` lists where the server itself is installed, so its version can be
259
+ * read.
260
+ */
261
+ const REMOTE_EXTENSION_HOSTS = [
262
+ {
263
+ id: "code-server",
264
+ name: "code-server",
265
+ dirs: (_home, xdgDataHome) => [
266
+ join(xdgDataHome, "code-server", "extensions"),
267
+ ],
268
+ // The deb and rpm packages install into /usr/lib/code-server, and the
269
+ // standalone installer into ~/.local/lib/code-server-<version>. Its
270
+ // product.json, which carries codeServerVersion, is in lib/vscode.
271
+ appDirs: (home) => [
272
+ join("/usr", "lib", "code-server", "lib", "vscode"),
273
+ ...listChildDirs(join(home, ".local", "lib"))
274
+ .filter((name) => name.startsWith("code-server-"))
275
+ .map((name) => join(home, ".local", "lib", name, "lib", "vscode")),
276
+ ],
277
+ },
278
+ {
279
+ id: "openvscode-server",
280
+ name: "OpenVSCode Server",
281
+ dirs: (home) => [join(home, ".openvscode-server", "extensions")],
282
+ },
283
+ {
284
+ id: "vscode-remote",
285
+ name: "VS Code Remote",
286
+ dirs: (home) => [join(home, ".vscode-remote", "extensions")],
287
+ },
288
+ ];
289
+
290
+ // Largest editor metadata file read: product.json, package.json and the
291
+ // .vsixmanifest of one extension, and .obsolete.
292
+ const MAX_METADATA_FILE_BYTES = 8 * 1024 * 1024;
293
+ // extensions.json lists every installed extension and grows with them.
294
+ const MAX_REGISTRY_FILE_BYTES = 32 * 1024 * 1024;
295
+ // Most remote server builds a host keeps, and most extension folders, read
296
+ // from one directory.
297
+ const MAX_SERVER_BUILDS = 64;
298
+ const MAX_EXTENSION_FOLDERS = 10000;
299
+
300
+ /**
301
+ * List the names of the folders in a directory, or nothing when it cannot be
302
+ * read. A symlink to a folder counts as a folder except when the scan runs as
303
+ * root.
304
+ *
305
+ * @param {string} dir Directory
306
+ * @param {number} [limit] Most names returned
307
+ * @returns {string[]} Folder names
308
+ */
309
+ function listChildDirs(dir, limit = MAX_EXTENSION_FOLDERS) {
310
+ if (!safeExistsSync(dir)) {
311
+ return [];
312
+ }
313
+ let entries;
314
+ try {
315
+ entries = readdirSync(dir, { withFileTypes: true });
316
+ } catch {
317
+ return [];
318
+ }
319
+ const names = [];
320
+ for (const entry of entries) {
321
+ if (names.length >= limit) {
322
+ break;
323
+ }
324
+ if (entry.isDirectory()) {
325
+ names.push(entry.name);
326
+ } else if (entry.isSymbolicLink() && !isRunningAsRoot()) {
327
+ try {
328
+ if (statSync(join(dir, entry.name)).isDirectory()) {
329
+ names.push(entry.name);
330
+ }
331
+ } catch {
332
+ // dangling link
333
+ }
334
+ }
335
+ }
336
+ return names;
229
337
  }
230
338
 
231
339
  /**
232
- * Discover all existing IDE extension directories on the current system.
340
+ * The XDG data folder for a home. The environment only describes the current
341
+ * user, so another home, such as a fixture or another user's home in a root
342
+ * scan, uses the default.
233
343
  *
234
- * @returns {Array<{name: string, dir: string}>} Array of objects with IDE name
235
- * and the existing directory path.
344
+ * @param {string} home Home directory
345
+ * @returns {string} Data folder
236
346
  */
237
- export function discoverIdeExtensionDirs() {
238
- const ides = getIdeExtensionDirs();
347
+ function xdgDataHomeFor(home) {
348
+ const defaultDir = join(home, ".local", "share");
349
+ if (home !== homedir()) {
350
+ return defaultDir;
351
+ }
352
+ return readEnvironmentVariable("XDG_DATA_HOME") || defaultDir;
353
+ }
354
+
355
+ /**
356
+ * Describe where each known editor and remote server installs extensions for
357
+ * the given home directory.
358
+ *
359
+ * Each desktop editor contributes one entry for its own extensions folder and,
360
+ * when it has a remote server, a second entry for the server's folder. For the
361
+ * current user's home, the `VSCODE_EXTENSIONS` environment variable, which VS
362
+ * Code reads in place of the default folder, adds one more entry.
363
+ *
364
+ * @param {string} [home] Home directory to resolve the folders against
365
+ * @returns {Array<{id: string, name: string, kind: string, dirs: string[]}>}
366
+ * One entry per editor or server, with `kind` set to `desktop` or `server`
367
+ */
368
+ export function getIdeExtensionDirs(home = homedir()) {
369
+ const xdgDataHome = xdgDataHomeFor(home);
370
+ const entries = [];
371
+ for (const product of IDE_PRODUCTS) {
372
+ entries.push({
373
+ id: product.id,
374
+ name: product.name,
375
+ kind: "desktop",
376
+ dirs: [join(home, product.dataFolderName, "extensions")],
377
+ });
378
+ if (product.serverDataFolderName) {
379
+ entries.push({
380
+ id: `${product.id}-server`,
381
+ name: `${product.name} Server`,
382
+ kind: "server",
383
+ dirs: [join(home, product.serverDataFolderName, "extensions")],
384
+ });
385
+ }
386
+ }
387
+ for (const host of REMOTE_EXTENSION_HOSTS) {
388
+ entries.push({
389
+ id: host.id,
390
+ name: host.name,
391
+ kind: "server",
392
+ dirs: host.dirs(home, xdgDataHome),
393
+ });
394
+ }
395
+ const overrideDir =
396
+ home === homedir() ? readEnvironmentVariable("VSCODE_EXTENSIONS") : "";
397
+ if (overrideDir) {
398
+ entries.push({
399
+ id: "vscode",
400
+ name: "VS Code",
401
+ kind: "desktop",
402
+ dirs: [resolve(overrideDir)],
403
+ });
404
+ }
405
+ return entries;
406
+ }
407
+
408
+ /**
409
+ * True when a path, or the folder holding it, is a symlink. A root scan does
410
+ * not follow these, because a user could point them at files only root reads.
411
+ *
412
+ * @param {string} dir Extensions folder
413
+ * @returns {boolean} true when either is a symlink
414
+ */
415
+ function isLinkedFolder(dir) {
416
+ for (const candidate of [dir, dirname(dir)]) {
417
+ try {
418
+ if (lstatSync(candidate).isSymbolicLink()) {
419
+ return true;
420
+ }
421
+ } catch {
422
+ // missing
423
+ }
424
+ }
425
+ return false;
426
+ }
427
+
428
+ /**
429
+ * Discover the extension folders that exist for the given home directory.
430
+ *
431
+ * @param {string} [home] Home directory to resolve the folders against
432
+ * @returns {Array<{id: string, name: string, kind: string, dir: string, home: string}>}
433
+ * One entry per existing folder
434
+ */
435
+ export function discoverIdeExtensionDirs(home = homedir()) {
239
436
  const found = [];
240
- for (const ide of ides) {
437
+ const seenDirs = new Set();
438
+ for (const ide of getIdeExtensionDirs(home)) {
241
439
  for (const dir of ide.dirs) {
242
- if (safeExistsSync(dir)) {
243
- found.push({ name: ide.name, dir });
440
+ if (seenDirs.has(dir) || !safeExistsSync(dir)) {
441
+ continue;
244
442
  }
443
+ if (isRunningAsRoot() && isLinkedFolder(dir)) {
444
+ thoughtLog(
445
+ `Skipping ${dir}: a root scan does not follow a symlinked extensions folder.`,
446
+ );
447
+ continue;
448
+ }
449
+ seenDirs.add(dir);
450
+ found.push({ id: ide.id, name: ide.name, kind: ide.kind, dir, home });
245
451
  }
246
452
  }
247
453
  return found;
248
454
  }
249
455
 
456
+ /**
457
+ * Find the editor or server an extensions folder belongs to from its path,
458
+ * such as `~/.cursor/extensions` or `/home/dev/.cursor-server/extensions`.
459
+ *
460
+ * @param {string} extensionsDir Path to an extensions folder
461
+ * @returns {{id: string, name: string, kind: string, home: string}|undefined}
462
+ * The match, with the home folder it sits in, or undefined for a folder no
463
+ * known editor uses
464
+ */
465
+ export function identifyIdeFromExtensionsDir(extensionsDir) {
466
+ const resolved = resolve(extensionsDir);
467
+ const segments = resolved.split(/[/\\]/u).filter((segment) => segment.length);
468
+ if (segments[segments.length - 1] !== "extensions" || segments.length < 2) {
469
+ return undefined;
470
+ }
471
+ const parent = segments[segments.length - 2];
472
+ const dataHome = dirname(dirname(resolved));
473
+ for (const product of IDE_PRODUCTS) {
474
+ if (parent === product.dataFolderName) {
475
+ return {
476
+ id: product.id,
477
+ name: product.name,
478
+ kind: "desktop",
479
+ home: dataHome,
480
+ };
481
+ }
482
+ if (
483
+ product.serverDataFolderName &&
484
+ parent === product.serverDataFolderName
485
+ ) {
486
+ return {
487
+ id: `${product.id}-server`,
488
+ name: `${product.name} Server`,
489
+ kind: "server",
490
+ home: dataHome,
491
+ };
492
+ }
493
+ }
494
+ if (parent === "code-server") {
495
+ // ~/.local/share/code-server/extensions
496
+ return {
497
+ id: "code-server",
498
+ name: "code-server",
499
+ kind: "server",
500
+ home: dirname(dirname(dataHome)),
501
+ };
502
+ }
503
+ if (parent === ".openvscode-server") {
504
+ return {
505
+ id: "openvscode-server",
506
+ name: "OpenVSCode Server",
507
+ kind: "server",
508
+ home: dataHome,
509
+ };
510
+ }
511
+ if (parent === ".vscode-remote") {
512
+ return {
513
+ id: "vscode-remote",
514
+ name: "VS Code Remote",
515
+ kind: "server",
516
+ home: dataHome,
517
+ };
518
+ }
519
+ return undefined;
520
+ }
521
+
522
+ /**
523
+ * Candidate `resources/app` folders for an installed desktop editor on the
524
+ * current platform.
525
+ *
526
+ * @param {Object} product Entry from IDE_PRODUCTS
527
+ * @param {string} home Home directory
528
+ * @returns {string[]} Candidate folders, which may not exist
529
+ */
530
+ function ideAppResourceDirs(product, home) {
531
+ const apps = product.apps || {};
532
+ if (isMac) {
533
+ return (apps.darwin || []).flatMap((bundle) => [
534
+ join("/Applications", bundle, "Contents", "Resources", "app"),
535
+ join(home, "Applications", bundle, "Contents", "Resources", "app"),
536
+ ]);
537
+ }
538
+ if (isWin) {
539
+ const ownHome = home === homedir();
540
+ const localAppData =
541
+ (ownHome && readEnvironmentVariable("LOCALAPPDATA")) ||
542
+ join(home, "AppData", "Local");
543
+ const programFiles =
544
+ readEnvironmentVariable("ProgramFiles") || "C:\\Program Files";
545
+ return (apps.win32 || []).flatMap((dirName) => [
546
+ join(localAppData, "Programs", dirName, "resources", "app"),
547
+ join(programFiles, dirName, "resources", "app"),
548
+ ]);
549
+ }
550
+ return (apps.linux || []).map((dir) => join(dir, "resources", "app"));
551
+ }
552
+
553
+ /**
554
+ * Candidate `product.json` folders for the remote server an editor installs on
555
+ * a remote host. Newer servers live under `cli/servers/<quality>-<commit>/server`
556
+ * and older ones under `bin/<commit>`.
557
+ *
558
+ * @param {Object} product Entry from IDE_PRODUCTS
559
+ * @param {string} home Home directory
560
+ * @returns {string[]} Existing server folders
561
+ */
562
+ function ideServerResourceDirs(product, home) {
563
+ if (!product.serverDataFolderName) {
564
+ return [];
565
+ }
566
+ const root = join(home, product.serverDataFolderName);
567
+ return [
568
+ ...listChildDirs(join(root, "cli", "servers"), MAX_SERVER_BUILDS).map(
569
+ (name) => join(root, "cli", "servers", name, "server"),
570
+ ),
571
+ ...listChildDirs(join(root, "bin"), MAX_SERVER_BUILDS).map((name) =>
572
+ join(root, "bin", name),
573
+ ),
574
+ ];
575
+ }
576
+
577
+ function readJsonFile(filePath, maxBytes = MAX_METADATA_FILE_BYTES) {
578
+ try {
579
+ const text = readUserFileText(filePath, maxBytes);
580
+ return text === undefined ? undefined : JSON.parse(text);
581
+ } catch {
582
+ return undefined;
583
+ }
584
+ }
585
+
586
+ function boundedString(value, maxLength = 256) {
587
+ return typeof value === "string" && value.trim() && value.length <= maxLength
588
+ ? value.trim()
589
+ : undefined;
590
+ }
591
+
592
+ /**
593
+ * Read an editor's identity from the `product.json` and `package.json` in its
594
+ * `resources/app` folder (or the root of a remote server).
595
+ *
596
+ * Forks report their own release in different fields of `product.json`:
597
+ * `version` (Cursor, Kiro), `windsurfVersion` (Windsurf and Devin Desktop),
598
+ * `positronVersion`, `appVersion` (Trae) and `codeServerVersion`. VS Code
599
+ * itself has none of these and keeps its release in `package.json`, as every
600
+ * fork does for the VS Code release it is based on.
601
+ *
602
+ * @param {string} appDir Folder holding `product.json`
603
+ * @returns {Object|undefined} `{ version, vscodeVersion, commit, quality,
604
+ * nameLong, dataFolderName, serverDataFolderName, appDir }`, or undefined
605
+ * when there is no product.json
606
+ */
607
+ export function readIdeProductInfo(appDir) {
608
+ const product = readJsonFile(join(appDir, "product.json"));
609
+ if (!product || typeof product !== "object" || Array.isArray(product)) {
610
+ return undefined;
611
+ }
612
+ const pkg = readJsonFile(join(appDir, "package.json")) || {};
613
+ const ownVersion = [
614
+ product.windsurfVersion,
615
+ product.positronVersion,
616
+ product.codeServerVersion,
617
+ product.appVersion,
618
+ product.version,
619
+ ]
620
+ .map((value) => boundedString(value, 64))
621
+ .find(Boolean);
622
+ const vscodeVersion = [product.vscodeVersion, pkg.version]
623
+ .map((value) => boundedString(value, 64))
624
+ .find(Boolean);
625
+ return {
626
+ version: ownVersion || boundedString(pkg.version, 64),
627
+ vscodeVersion,
628
+ commit: boundedString(product.commit, 64),
629
+ quality: boundedString(product.quality, 32),
630
+ nameLong: boundedString(product.nameLong, 128),
631
+ applicationName: boundedString(product.applicationName, 64),
632
+ dataFolderName: boundedString(product.dataFolderName, 64),
633
+ serverDataFolderName: boundedString(product.serverDataFolderName, 64),
634
+ appDir,
635
+ };
636
+ }
637
+
638
+ const productInfoCache = new Map();
639
+
640
+ /**
641
+ * Find the installed editor (or remote server) for an extensions folder and
642
+ * read its identity.
643
+ *
644
+ * A remote server is looked up in the home the folder is in. A desktop editor
645
+ * and code-server are looked up in the application folders of the machine
646
+ * running cdxgen, so that lookup happens only when `probeApps` is true: for a
647
+ * scan of this host, not for a folder copied from another machine.
648
+ *
649
+ * @param {{id: string, kind: string}} ide Entry from discoverIdeExtensionDirs
650
+ * @param {string} [home] Home directory
651
+ * @param {Object} [options] Lookup options
652
+ * @param {boolean} [options.probeApps] Look in this machine's application
653
+ * folders; defaults to true only for the current user's home
654
+ * @param {string[]} [options.appDirs] Folders to read in place of the
655
+ * application folders
656
+ * @returns {Object|undefined} Result of readIdeProductInfo for the newest
657
+ * installation whose data folder matches, or undefined
658
+ */
659
+ export function findIdeProductInfo(ide, home = homedir(), options = {}) {
660
+ const probeApps = options.probeApps ?? home === homedir();
661
+ const cacheKey = `${ide.kind}\u0000${ide.id}\u0000${home}\u0000${probeApps}\u0000${(options.appDirs || []).join("\u0000")}`;
662
+ if (productInfoCache.has(cacheKey)) {
663
+ return productInfoCache.get(cacheKey);
664
+ }
665
+ const productId =
666
+ ide.kind === "server" ? ide.id.replace(/-server$/u, "") : ide.id;
667
+ const product = IDE_PRODUCTS.find((p) => p.id === productId);
668
+ const host = REMOTE_EXTENSION_HOSTS.find((h) => h.id === ide.id);
669
+ let candidates = [];
670
+ if (options.appDirs) {
671
+ candidates = options.appDirs;
672
+ } else if (product && ide.kind === "server") {
673
+ candidates = ideServerResourceDirs(product, home);
674
+ } else if (product && probeApps) {
675
+ candidates = ideAppResourceDirs(product, home);
676
+ } else if (host?.appDirs && probeApps) {
677
+ candidates = host.appDirs(home);
678
+ }
679
+ const infos = [];
680
+ for (const candidate of candidates) {
681
+ const info = readIdeProductInfo(candidate);
682
+ if (!info) {
683
+ continue;
684
+ }
685
+ if (product) {
686
+ const expectedFolder =
687
+ ide.kind === "server"
688
+ ? product.serverDataFolderName
689
+ : product.dataFolderName;
690
+ const actualFolder =
691
+ ide.kind === "server" ? info.serverDataFolderName : info.dataFolderName;
692
+ if (actualFolder && actualFolder !== expectedFolder) {
693
+ continue;
694
+ }
695
+ }
696
+ infos.push(info);
697
+ }
698
+ // A remote host keeps one server per editor release it has connected from.
699
+ // The newest by version is the one in use.
700
+ infos.sort((a, b) =>
701
+ `${b.version || ""}`.localeCompare(`${a.version || ""}`, undefined, {
702
+ numeric: true,
703
+ }),
704
+ );
705
+ productInfoCache.set(cacheKey, infos[0]);
706
+ return infos[0];
707
+ }
708
+
250
709
  /**
251
710
  * Parse a `.vsixmanifest` XML string and extract extension metadata.
252
711
  *
@@ -466,8 +925,13 @@ export function parseVsixPackageJson(packageJsonData, srcPath) {
466
925
  return undefined;
467
926
  }
468
927
  const externalReferences = [];
469
- if (pkg.repository?.url) {
470
- externalReferences.push({ type: "vcs", url: pkg.repository.url });
928
+ const repositoryUrl = boundedString(
929
+ typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url,
930
+ 2048,
931
+ );
932
+ const vcsUrl = repositoryUrl ? sanitizeBomUrl(repositoryUrl) : "";
933
+ if (vcsUrl) {
934
+ externalReferences.push({ type: "vcs", url: vcsUrl });
471
935
  }
472
936
  return {
473
937
  publisher: pkg.publisher || "",
@@ -494,6 +958,25 @@ export function parseVsixPackageJson(packageJsonData, srcPath) {
494
958
  }
495
959
  }
496
960
 
961
+ /**
962
+ * Keep the string members of a manifest list, each bounded in length, so a
963
+ * crafted package.json cannot put objects or very long text into a property.
964
+ *
965
+ * @param {unknown} value List from package.json
966
+ * @param {number} [maxItems=200] Most members kept
967
+ * @param {number} [maxLength=256] Longest member kept
968
+ * @returns {string[]} The members
969
+ */
970
+ function stringList(value, maxItems = 200, maxLength = 256) {
971
+ if (!Array.isArray(value)) {
972
+ return typeof value === "string" ? stringList([value]) : [];
973
+ }
974
+ return value
975
+ .map((item) => boundedString(item, maxLength))
976
+ .filter(Boolean)
977
+ .slice(0, maxItems);
978
+ }
979
+
497
980
  /**
498
981
  * Extract deep capability and permission information from a VS Code
499
982
  * extension package.json.
@@ -517,23 +1000,27 @@ export function extractExtensionCapabilities(pkg) {
517
1000
  const capabilities = {};
518
1001
 
519
1002
  // Activation events - security relevant: "*" means the extension activates for every workspace
520
- if (pkg.activationEvents?.length) {
521
- capabilities.activationEvents = pkg.activationEvents;
1003
+ const activationEvents = stringList(pkg.activationEvents);
1004
+ if (activationEvents.length) {
1005
+ capabilities.activationEvents = activationEvents;
522
1006
  }
523
1007
 
524
1008
  // Extension kind - where the extension runs (ui=local, workspace=remote, both)
525
- if (pkg.extensionKind?.length) {
526
- capabilities.extensionKind = pkg.extensionKind;
1009
+ const extensionKind = stringList(pkg.extensionKind);
1010
+ if (extensionKind.length) {
1011
+ capabilities.extensionKind = extensionKind;
527
1012
  }
528
1013
 
529
1014
  // Extension dependencies - other extensions this requires
530
- if (pkg.extensionDependencies?.length) {
531
- capabilities.extensionDependencies = pkg.extensionDependencies;
1015
+ const extensionDependencies = stringList(pkg.extensionDependencies);
1016
+ if (extensionDependencies.length) {
1017
+ capabilities.extensionDependencies = extensionDependencies;
532
1018
  }
533
1019
 
534
1020
  // Extension pack - bundled extensions
535
- if (pkg.extensionPack?.length) {
536
- capabilities.extensionPack = pkg.extensionPack;
1021
+ const extensionPack = stringList(pkg.extensionPack);
1022
+ if (extensionPack.length) {
1023
+ capabilities.extensionPack = extensionPack;
537
1024
  }
538
1025
 
539
1026
  // Workspace trust configuration
@@ -593,11 +1080,13 @@ export function extractExtensionCapabilities(pkg) {
593
1080
  if (contributedFeatures.length) {
594
1081
  capabilities.contributes = contributedFeatures;
595
1082
  }
596
- if (pkg.main) {
597
- capabilities.main = pkg.main;
1083
+ const main = boundedString(pkg.main, 512);
1084
+ if (main) {
1085
+ capabilities.main = main;
598
1086
  }
599
- if (pkg.browser) {
600
- capabilities.browser = pkg.browser;
1087
+ const browser = boundedString(pkg.browser, 512);
1088
+ if (browser) {
1089
+ capabilities.browser = browser;
601
1090
  }
602
1091
  const scripts = pkg.scripts || {};
603
1092
  const lifecycleScripts = [];
@@ -621,6 +1110,22 @@ export function extractExtensionCapabilities(pkg) {
621
1110
  return capabilities;
622
1111
  }
623
1112
 
1113
+ /**
1114
+ * Read a workspace support declaration, which package.json gives as a boolean,
1115
+ * a string such as `limited`, or an object with a `supported` field.
1116
+ *
1117
+ * @param {unknown} value Declaration from package.json capabilities
1118
+ * @returns {string|undefined} `true`, `false`, `limited`, or undefined
1119
+ */
1120
+ function workspaceSupportValue(value) {
1121
+ const supported =
1122
+ value && typeof value === "object" ? value.supported : value;
1123
+ if (typeof supported === "boolean") {
1124
+ return String(supported);
1125
+ }
1126
+ return boundedString(supported, 32);
1127
+ }
1128
+
624
1129
  /**
625
1130
  * Convert parsed extension metadata into a CycloneDX component object.
626
1131
  *
@@ -701,24 +1206,18 @@ export function toComponent(extInfo, ideName) {
701
1206
  value: extensionPack.join(", "),
702
1207
  });
703
1208
  }
704
- if (caps.untrustedWorkspaces !== undefined) {
705
- const uws = caps.untrustedWorkspaces;
1209
+ const uwsValue = workspaceSupportValue(caps.untrustedWorkspaces);
1210
+ if (uwsValue) {
706
1211
  props.push({
707
1212
  name: "cdx:vscode-extension:untrustedWorkspaces",
708
- value:
709
- typeof uws === "object" && uws.supported !== undefined
710
- ? String(uws.supported)
711
- : String(uws),
1213
+ value: uwsValue,
712
1214
  });
713
1215
  }
714
- if (caps.virtualWorkspaces !== undefined) {
715
- const vws = caps.virtualWorkspaces;
1216
+ const vwsValue = workspaceSupportValue(caps.virtualWorkspaces);
1217
+ if (vwsValue) {
716
1218
  props.push({
717
1219
  name: "cdx:vscode-extension:virtualWorkspaces",
718
- value:
719
- typeof vws === "object" && vws.supported !== undefined
720
- ? String(vws.supported)
721
- : String(vws),
1220
+ value: vwsValue,
722
1221
  });
723
1222
  }
724
1223
  if (caps.contributes?.length) {
@@ -761,20 +1260,23 @@ export function toComponent(extInfo, ideName) {
761
1260
  externalRefs.push(...extInfo.externalReferences);
762
1261
  }
763
1262
  if (extInfo.links) {
764
- if (extInfo.links.Source || extInfo.links.GitHub) {
765
- const vcsUrl = extInfo.links.Source || extInfo.links.GitHub;
766
- if (!externalRefs.some((r) => r.type === "vcs")) {
767
- externalRefs.push({ type: "vcs", url: vcsUrl });
768
- }
769
- }
770
- if (extInfo.links.Support) {
771
- externalRefs.push({ type: "issue-tracker", url: extInfo.links.Support });
772
- }
773
- if (extInfo.links.Learn) {
774
- externalRefs.push({ type: "documentation", url: extInfo.links.Learn });
1263
+ const link = (value) => {
1264
+ const url = boundedString(value, 2048);
1265
+ return url ? sanitizeBomUrl(url) : "";
1266
+ };
1267
+ const vcsUrl = link(extInfo.links.Source) || link(extInfo.links.GitHub);
1268
+ if (vcsUrl && !externalRefs.some((r) => r.type === "vcs")) {
1269
+ externalRefs.push({ type: "vcs", url: vcsUrl });
775
1270
  }
776
- if (extInfo.links.Getstarted) {
777
- externalRefs.push({ type: "website", url: extInfo.links.Getstarted });
1271
+ for (const [key, type] of [
1272
+ ["Support", "issue-tracker"],
1273
+ ["Learn", "documentation"],
1274
+ ["Getstarted", "website"],
1275
+ ]) {
1276
+ const url = link(extInfo.links[key]);
1277
+ if (url) {
1278
+ externalRefs.push({ type, url });
1279
+ }
778
1280
  }
779
1281
  }
780
1282
  if (externalRefs.length) {
@@ -796,6 +1298,36 @@ export function toComponent(extInfo, ideName) {
796
1298
  return component;
797
1299
  }
798
1300
 
1301
+ /** Largest .vsix whose SHA-256 is computed. */
1302
+ const MAX_VSIX_BYTES = 2 * 1024 * 1024 * 1024;
1303
+
1304
+ function realpathOrSelf(filePath) {
1305
+ try {
1306
+ return realpathSync(filePath);
1307
+ } catch {
1308
+ return filePath;
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * True when a .vsix path is a regular file. A FIFO or a device named `*.vsix`
1314
+ * would block or never end, and a root scan does not follow a symlink.
1315
+ *
1316
+ * @param {string} vsixFile Path to the .vsix
1317
+ * @returns {boolean} true when the archive can be opened
1318
+ */
1319
+ export function isReadableVsix(vsixFile) {
1320
+ try {
1321
+ const link = lstatSync(vsixFile);
1322
+ if (link.isSymbolicLink()) {
1323
+ return !isRunningAsRoot() && statSync(vsixFile).isFile();
1324
+ }
1325
+ return link.isFile();
1326
+ } catch {
1327
+ return false;
1328
+ }
1329
+ }
1330
+
799
1331
  /**
800
1332
  * Extract a `.vsix` file (ZIP archive) to a temporary directory for deep
801
1333
  * analysis. The caller is responsible for cleaning up the temp directory.
@@ -804,6 +1336,9 @@ export function toComponent(extInfo, ideName) {
804
1336
  * @returns {Promise<string|undefined>} Path to the extracted temp directory, or undefined on failure
805
1337
  */
806
1338
  export async function extractVsixToTempDir(vsixFile) {
1339
+ if (!isReadableVsix(vsixFile)) {
1340
+ return undefined;
1341
+ }
807
1342
  let tempDir;
808
1343
  let zip;
809
1344
  try {
@@ -876,6 +1411,9 @@ export function cleanupTempDir(tempDir) {
876
1411
  * @returns {Promise<Object|undefined>} CycloneDX component object or undefined
877
1412
  */
878
1413
  export async function parseVsixFile(vsixFile) {
1414
+ if (!isReadableVsix(vsixFile)) {
1415
+ return undefined;
1416
+ }
879
1417
  let zip;
880
1418
  try {
881
1419
  zip = new StreamZip.async({ file: vsixFile });
@@ -922,7 +1460,17 @@ export async function parseVsixFile(vsixFile) {
922
1460
  }
923
1461
 
924
1462
  if (extInfo) {
925
- return toComponent(extInfo);
1463
+ const component = toComponent(extInfo);
1464
+ // Marketplaces and security notices publish the SHA-256 of the .vsix
1465
+ // itself, so that is the hash that identifies this build.
1466
+ const digest = hashRegularFile(
1467
+ isRunningAsRoot() ? vsixFile : realpathOrSelf(vsixFile),
1468
+ MAX_VSIX_BYTES,
1469
+ );
1470
+ if (digest) {
1471
+ component.hashes = [{ alg: "SHA-256", content: digest }];
1472
+ }
1473
+ return component;
926
1474
  }
927
1475
  return undefined;
928
1476
  } catch (e) {
@@ -942,94 +1490,695 @@ export async function parseVsixFile(vsixFile) {
942
1490
  }
943
1491
 
944
1492
  /**
945
- * Parse a single installed extension directory (already extracted).
946
- * Looks for `package.json` (preferred) and `.vsixmanifest`.
1493
+ * Read the install registry an editor keeps beside its extensions.
947
1494
  *
948
- * @param {string} extDir Absolute path to the extension directory (e.g. `~/.vscode/extensions/ms-python.python-2023.1.0`)
949
- * @param {string} [ideName] Optional IDE name
950
- * @returns {Object|undefined} CycloneDX component object or undefined
1495
+ * `extensions.json` lists every installed extension with where it came from
1496
+ * and when it was installed. `.obsolete` names the folders of extensions that
1497
+ * were uninstalled or replaced by an update but are still on disk until the
1498
+ * editor next starts. Folder names from either file are only compared with the
1499
+ * names in the folder, never turned into paths.
1500
+ *
1501
+ * @param {string} extensionsDir Extensions folder
1502
+ * @returns {{records: Map<string, Object>, obsolete: Set<string>, hasRegistry: boolean}}
1503
+ * Install records keyed by folder name, the obsolete folder names, and
1504
+ * whether an extensions.json was read
951
1505
  */
952
- export function parseInstalledExtensionDir(extDir, ideName) {
953
- // First try package.json at the root of the extension directory
954
- const packageJsonPath = join(extDir, "package.json");
955
- if (safeExistsSync(packageJsonPath)) {
956
- try {
957
- const data = readFileSync(packageJsonPath, { encoding: "utf-8" });
958
- const extInfo = parseVsixPackageJson(data, extDir);
959
- if (extInfo?.name) {
960
- return toComponent(extInfo, ideName);
1506
+ export function readInstalledExtensionsRegistry(extensionsDir) {
1507
+ const records = new Map();
1508
+ const obsolete = new Set();
1509
+ const registry = readJsonFile(
1510
+ join(extensionsDir, "extensions.json"),
1511
+ MAX_REGISTRY_FILE_BYTES,
1512
+ );
1513
+ if (Array.isArray(registry)) {
1514
+ for (const record of registry) {
1515
+ if (!record || typeof record !== "object") {
1516
+ continue;
1517
+ }
1518
+ const location = record.location || {};
1519
+ const folder =
1520
+ boundedString(record.relativeLocation, 512) ||
1521
+ basename(
1522
+ `${boundedString(location.path || location.fsPath, 4096) || ""}`,
1523
+ );
1524
+ if (folder) {
1525
+ records.set(folder, record);
1526
+ }
1527
+ }
1528
+ }
1529
+ const obsoleteMap = readJsonFile(join(extensionsDir, ".obsolete"));
1530
+ if (
1531
+ obsoleteMap &&
1532
+ typeof obsoleteMap === "object" &&
1533
+ !Array.isArray(obsoleteMap)
1534
+ ) {
1535
+ for (const [folder, flag] of Object.entries(obsoleteMap)) {
1536
+ if (flag === true) {
1537
+ obsolete.add(folder);
961
1538
  }
962
- } catch (_e) {
963
- // Fall through to vsixmanifest
964
1539
  }
965
1540
  }
1541
+ return { records, obsolete, hasRegistry: Array.isArray(registry) };
1542
+ }
966
1543
 
967
- // Try .vsixmanifest at the root
968
- const manifestPath = join(extDir, ".vsixmanifest");
969
- if (safeExistsSync(manifestPath)) {
970
- try {
971
- const data = readFileSync(manifestPath, { encoding: "utf-8" });
972
- const extInfo = parseVsixManifest(data);
973
- if (extInfo) {
974
- extInfo.srcPath = extDir;
975
- return toComponent(extInfo, ideName);
1544
+ /** Largest extension entry file whose SHA-256 is computed. */
1545
+ const MAX_HASHED_ENTRY_FILE_BYTES = 64 * 1024 * 1024;
1546
+
1547
+ /**
1548
+ * Hash the `main` and `browser` entry files of an installed extension the way
1549
+ * the extension host resolves them: the path as written, with a `.js` suffix,
1550
+ * or as a folder holding `index.js`. Only regular files that stay inside the
1551
+ * extension folder are read; a symlink, a FIFO or a path that leaves the
1552
+ * folder is not.
1553
+ *
1554
+ * @param {string} extDir Installed extension folder
1555
+ * @param {{main?: string, browser?: string}} entries Entry points from
1556
+ * package.json
1557
+ * @returns {Array<{role: string, name: string, digest: string, path: string}>}
1558
+ * One entry per hashed file, with its path relative to the folder
1559
+ */
1560
+ export function hashExtensionEntryFiles(extDir, entries) {
1561
+ const files = [];
1562
+ const seen = new Set();
1563
+ const root = resolve(extDir);
1564
+ for (const role of ["main", "browser"]) {
1565
+ const entry = boundedString(entries?.[role], 512);
1566
+ if (!entry) {
1567
+ continue;
1568
+ }
1569
+ for (const candidate of [entry, `${entry}.js`, join(entry, "index.js")]) {
1570
+ const filePath = resolve(root, candidate);
1571
+ if (!filePath.startsWith(`${root}${sep}`)) {
1572
+ break;
976
1573
  }
977
- } catch (_e) {
978
- // Ignore
1574
+ const digest = hashRegularFile(
1575
+ filePath,
1576
+ MAX_HASHED_ENTRY_FILE_BYTES,
1577
+ root,
1578
+ );
1579
+ if (!digest) {
1580
+ continue;
1581
+ }
1582
+ if (!seen.has(filePath)) {
1583
+ seen.add(filePath);
1584
+ files.push({
1585
+ role,
1586
+ name: relative(root, filePath).replaceAll("\\", "/"),
1587
+ digest,
1588
+ path: filePath,
1589
+ });
1590
+ }
1591
+ break;
1592
+ }
1593
+ }
1594
+ return files;
1595
+ }
1596
+
1597
+ /**
1598
+ * Describe the code an installed extension runs as `file` components with
1599
+ * their SHA-256, so the extension can be matched against a published hash of
1600
+ * a known good or known bad build.
1601
+ *
1602
+ * @param {string} extDir Installed extension folder
1603
+ * @param {Object} capabilities Capabilities from extractExtensionCapabilities
1604
+ * @param {string} parentRef bom-ref of the extension component
1605
+ * @returns {Object[]} File components, empty when no entry file is found
1606
+ */
1607
+ export function describeExtensionEntryFiles(extDir, capabilities, parentRef) {
1608
+ return hashExtensionEntryFiles(extDir, capabilities).map((file) =>
1609
+ toEntryFileComponent(parentRef, file, false),
1610
+ );
1611
+ }
1612
+
1613
+ function toEntryFileComponent(parentRef, file, qualifyRef) {
1614
+ return {
1615
+ type: "file",
1616
+ name: file.name,
1617
+ "bom-ref": qualifyRef
1618
+ ? `${parentRef}#${file.name}@sha256:${file.digest}`
1619
+ : `${parentRef}#${file.name}`,
1620
+ hashes: [{ alg: "SHA-256", content: file.digest }],
1621
+ properties: [
1622
+ { name: "cdx:vscode-extension:entryPoint", value: file.role },
1623
+ { name: "internal:SrcFile", value: file.path },
1624
+ ],
1625
+ };
1626
+ }
1627
+
1628
+ const INSTALL_SOURCES = new Set(["gallery", "vsix", "resource"]);
1629
+ // The largest millisecond value a JavaScript Date holds.
1630
+ const MAX_DATE_MS = 8.64e15;
1631
+
1632
+ function isUuid(value) {
1633
+ if (typeof value !== "string") {
1634
+ return false;
1635
+ }
1636
+ const groups = value.split("-");
1637
+ const lengths = [8, 4, 4, 4, 12];
1638
+ return (
1639
+ groups.length === lengths.length &&
1640
+ groups.every(
1641
+ (group, i) =>
1642
+ group.length === lengths[i] &&
1643
+ [...group].every((c) => "0123456789abcdefABCDEF".includes(c)),
1644
+ )
1645
+ );
1646
+ }
1647
+
1648
+ /**
1649
+ * Read what an editor's install registry says about one folder, keeping only
1650
+ * enumerated values, a UUID and a valid timestamp.
1651
+ *
1652
+ * @param {Object} [record] Entry from extensions.json
1653
+ * @returns {Object} Install state
1654
+ */
1655
+ function installStateFromRecord(record) {
1656
+ const metadata =
1657
+ record?.metadata && typeof record.metadata === "object"
1658
+ ? record.metadata
1659
+ : {};
1660
+ const state = {};
1661
+ if (INSTALL_SOURCES.has(metadata.source)) {
1662
+ state.source = metadata.source;
1663
+ }
1664
+ const installedAt = Number(metadata.installedTimestamp);
1665
+ if (
1666
+ Number.isFinite(installedAt) &&
1667
+ installedAt > 0 &&
1668
+ installedAt <= MAX_DATE_MS
1669
+ ) {
1670
+ state.installedAt = installedAt;
1671
+ }
1672
+ state.preRelease =
1673
+ metadata.isPreReleaseVersion === true || metadata.preRelease === true;
1674
+ state.pinned = metadata.pinned === true;
1675
+ state.builtin = metadata.isBuiltin === true;
1676
+ state.updated = metadata.updated === true;
1677
+ if (isUuid(metadata.id)) {
1678
+ state.galleryId = metadata.id.toLowerCase();
1679
+ } else if (isUuid(record?.identifier?.uuid)) {
1680
+ state.galleryId = record.identifier.uuid.toLowerCase();
1681
+ }
1682
+ return state;
1683
+ }
1684
+
1685
+ /**
1686
+ * Record on an extension component what the editors' install registries say
1687
+ * about every folder it was found in.
1688
+ *
1689
+ * Each value describes the whole host: the install sources are all listed, the
1690
+ * install time is the earliest, a flag such as `pinned` is set when any copy
1691
+ * has it, an extension is pending removal only when every copy is, and it is
1692
+ * unregistered when any copy is missing from a registry that exists.
1693
+ *
1694
+ * @param {Object} component Extension component
1695
+ * @param {Object[]} states Install state of each folder
1696
+ */
1697
+ function applyInstallStates(component, states) {
1698
+ const props = component.properties || [];
1699
+ const addProp = (name, value) => {
1700
+ if (value === undefined || value === null || value === "") {
1701
+ return;
979
1702
  }
1703
+ if (!props.some((p) => p.name === name)) {
1704
+ props.push({ name, value: String(value) });
1705
+ }
1706
+ };
1707
+ const sources = [...new Set(states.map((s) => s.source).filter(Boolean))];
1708
+ addProp("cdx:vscode-extension:installSource", sources.sort().join(", "));
1709
+ const times = states.map((s) => s.installedAt).filter(Boolean);
1710
+ if (times.length) {
1711
+ addProp(
1712
+ "cdx:vscode-extension:installedAt",
1713
+ new Date(Math.min(...times)).toISOString(),
1714
+ );
1715
+ }
1716
+ for (const [key, name] of [
1717
+ ["preRelease", "cdx:vscode-extension:preRelease"],
1718
+ ["pinned", "cdx:vscode-extension:pinned"],
1719
+ ["builtin", "cdx:vscode-extension:builtin"],
1720
+ ["updated", "cdx:vscode-extension:updated"],
1721
+ ["symlink", "cdx:vscode-extension:symlink"],
1722
+ ]) {
1723
+ if (states.some((s) => s[key])) {
1724
+ addProp(name, "true");
1725
+ }
1726
+ }
1727
+ addProp(
1728
+ "cdx:vscode-extension:galleryId",
1729
+ states.map((s) => s.galleryId).find(Boolean),
1730
+ );
1731
+ if (states.length && states.every((s) => s.obsolete)) {
1732
+ addProp("cdx:vscode-extension:pendingRemoval", "true");
1733
+ }
1734
+ if (states.some((s) => s.unregistered)) {
1735
+ addProp("cdx:vscode-extension:registered", "false");
980
1736
  }
1737
+ if (props.length) {
1738
+ component.properties = props;
1739
+ }
1740
+ }
981
1741
 
982
- // Try to infer from directory name (publisher.name-version pattern)
983
- return parseExtensionDirName(extDir, ideName);
1742
+ /**
1743
+ * Read one installed extension folder as JSON text, only from a regular file
1744
+ * inside that folder.
1745
+ *
1746
+ * @param {string} extDir Extension folder
1747
+ * @param {string} fileName File in the folder
1748
+ * @returns {string|undefined} File text
1749
+ */
1750
+ function readExtensionFileText(extDir, fileName) {
1751
+ return readRegularTextFile(
1752
+ join(extDir, fileName),
1753
+ MAX_METADATA_FILE_BYTES,
1754
+ extDir,
1755
+ );
1756
+ }
1757
+
1758
+ /**
1759
+ * Parse a single installed extension folder.
1760
+ *
1761
+ * `package.json` gives the identity and capabilities. The `.vsixmanifest` the
1762
+ * editor keeps beside it adds the target platform, links and manifest
1763
+ * properties, and the folder name supplies the platform when neither does.
1764
+ *
1765
+ * @param {string} extDir Absolute path to the extension folder (e.g. `~/.cursor/extensions/anthropic.claude-code-2.1.313-darwin-arm64`)
1766
+ * @param {string} [ideName] Name of the editor the folder belongs to
1767
+ * @param {Object} [installRecord] Entry for the folder from extensions.json
1768
+ * @param {Object} [options] Parse options
1769
+ * @param {boolean} [options.nameOnly] Read only the folder name, as a root
1770
+ * scan does for a symlinked folder
1771
+ * @returns {Object|undefined} CycloneDX component object or undefined
1772
+ */
1773
+ export function parseInstalledExtensionDir(
1774
+ extDir,
1775
+ ideName,
1776
+ installRecord,
1777
+ options = {},
1778
+ ) {
1779
+ const dirParts = parseVscodeExtensionDirName(basename(extDir));
1780
+ let extInfo;
1781
+ let manifestInfo;
1782
+ if (!options.nameOnly) {
1783
+ const packageJson = readExtensionFileText(extDir, "package.json");
1784
+ if (packageJson !== undefined) {
1785
+ const parsed = parseVsixPackageJson(packageJson, extDir);
1786
+ if (parsed?.name) {
1787
+ extInfo = parsed;
1788
+ }
1789
+ }
1790
+ const manifest = readExtensionFileText(extDir, ".vsixmanifest");
1791
+ if (manifest !== undefined) {
1792
+ manifestInfo = parseVsixManifest(manifest);
1793
+ }
1794
+ }
1795
+ if (!extInfo && manifestInfo) {
1796
+ extInfo = { ...manifestInfo, srcPath: extDir };
1797
+ } else if (extInfo && manifestInfo) {
1798
+ for (const key of [
1799
+ "vscodeEngine",
1800
+ "executesCode",
1801
+ "links",
1802
+ "extensionKind",
1803
+ "extensionDependencies",
1804
+ "extensionPack",
1805
+ ]) {
1806
+ if (extInfo[key] === undefined && manifestInfo[key] !== undefined) {
1807
+ extInfo[key] = manifestInfo[key];
1808
+ }
1809
+ }
1810
+ extInfo.platform = manifestInfo.platform || "";
1811
+ }
1812
+ if (!extInfo && dirParts) {
1813
+ extInfo = {
1814
+ publisher: dirParts.publisher,
1815
+ name: dirParts.name,
1816
+ version: dirParts.version,
1817
+ displayName: "",
1818
+ description: "",
1819
+ platform: "",
1820
+ srcPath: extDir,
1821
+ };
1822
+ }
1823
+ if (!extInfo) {
1824
+ return undefined;
1825
+ }
1826
+ extInfo.platform =
1827
+ normalizeVscodeTargetPlatform(extInfo.platform) ||
1828
+ normalizeVscodeTargetPlatform(installRecord?.metadata?.targetPlatform) ||
1829
+ dirParts?.platform ||
1830
+ "";
1831
+ return toComponent(extInfo, ideName);
984
1832
  }
985
1833
 
986
1834
  /**
987
1835
  * Attempt to extract extension metadata from a directory name following the
988
- * pattern `publisher.name-version`.
1836
+ * pattern `publisher.name-version` or `publisher.name-version-platform`.
989
1837
  *
990
1838
  * @param {string} extDir Absolute path to extension directory
991
1839
  * @param {string} [ideName] IDE name
992
1840
  * @returns {Object|undefined} CycloneDX component or undefined
993
1841
  */
994
1842
  export function parseExtensionDirName(extDir, ideName) {
995
- const dirName = extDir.split(/[/\\]/).pop();
996
- if (!dirName) {
1843
+ const dirName = extDir
1844
+ .split(/[/\\]/)
1845
+ .filter((segment) => segment.length)
1846
+ .pop();
1847
+ const parts = parseVscodeExtensionDirName(dirName);
1848
+ if (!parts) {
997
1849
  return undefined;
998
1850
  }
999
- // Pattern: publisher.name-version (e.g., ms-python.python-2023.25.0)
1000
- // Use a non-backtracking approach: split on the last hyphen followed by a digit
1001
- const dotIdx = dirName.indexOf(".");
1002
- if (dotIdx < 1) {
1003
- return undefined;
1851
+ return toComponent(
1852
+ {
1853
+ publisher: parts.publisher,
1854
+ name: parts.name,
1855
+ version: parts.version,
1856
+ displayName: "",
1857
+ description: "",
1858
+ platform: parts.platform,
1859
+ srcPath: extDir,
1860
+ },
1861
+ ideName,
1862
+ );
1863
+ }
1864
+
1865
+ /**
1866
+ * Decide whether a folder is an editor's extensions folder: one at a path a
1867
+ * known editor uses, or one holding the `extensions.json` or `.obsolete` an
1868
+ * editor writes. A source repository whose subfolders happen to include an
1869
+ * extension is not one.
1870
+ *
1871
+ * @param {string} dir Folder to check
1872
+ * @returns {boolean} True when the folder is an extensions folder
1873
+ */
1874
+ export function isInstalledExtensionsDir(dir) {
1875
+ if (identifyIdeFromExtensionsDir(dir)) {
1876
+ return safeExistsSync(dir);
1877
+ }
1878
+ return (
1879
+ safeExistsSync(join(dir, "extensions.json")) ||
1880
+ safeExistsSync(join(dir, ".obsolete"))
1881
+ );
1882
+ }
1883
+
1884
+ /**
1885
+ * Decide whether a folder is one installed extension: a `.vsixmanifest`, or a
1886
+ * package.json with a publisher and a `vscode` engine.
1887
+ *
1888
+ * @param {string} dir Folder to check
1889
+ * @returns {boolean} True for an extension folder
1890
+ */
1891
+ export function isInstalledExtensionDir(dir) {
1892
+ if (readExtensionFileText(dir, ".vsixmanifest") !== undefined) {
1893
+ return true;
1894
+ }
1895
+ let pkg;
1896
+ try {
1897
+ pkg = JSON.parse(readExtensionFileText(dir, "package.json") ?? "");
1898
+ } catch {
1899
+ return false;
1900
+ }
1901
+ return Boolean(
1902
+ typeof pkg?.publisher === "string" && pkg?.engines?.vscode !== undefined,
1903
+ );
1904
+ }
1905
+
1906
+ /**
1907
+ * Build a CycloneDX component for an installed editor or remote server.
1908
+ *
1909
+ * @param {{id: string, name: string, kind: string, dirs?: string[], dir?: string, home?: string}} ide
1910
+ * Discovered extensions folder, or folders, of one editor
1911
+ * @param {Object} [productInfo] Result of findIdeProductInfo
1912
+ * @param {number} extensionCount Number of extension builds found
1913
+ * @returns {Object} CycloneDX application component
1914
+ */
1915
+ export function toIdeComponent(ide, productInfo, extensionCount) {
1916
+ const version = productInfo?.version;
1917
+ let purl;
1918
+ try {
1919
+ purl = build({
1920
+ type: "generic",
1921
+ namespace: IDE_PURL_NAMESPACE,
1922
+ name: ide.id,
1923
+ version: version || null,
1924
+ });
1925
+ } catch {
1926
+ purl = undefined;
1004
1927
  }
1005
- const publisher = dirName.substring(0, dotIdx);
1006
- const rest = dirName.substring(dotIdx + 1);
1007
- // Find the last hyphen followed by a digit to separate name from version
1008
- let versionStart = -1;
1009
- for (let i = rest.length - 1; i >= 0; i--) {
1010
- if (rest[i] === "-" && i + 1 < rest.length && /\d/.test(rest[i + 1])) {
1011
- versionStart = i;
1928
+ const dirs = ide.dirs || [ide.dir];
1929
+ const properties = [
1930
+ { name: "cdx:ide:id", value: ide.id },
1931
+ { name: "cdx:ide:kind", value: ide.kind },
1932
+ {
1933
+ name: "cdx:ide:extensionsDir",
1934
+ value: [...new Set(dirs.map((d) => redactHomePath(d, ide.home)))].join(
1935
+ ", ",
1936
+ ),
1937
+ },
1938
+ { name: "cdx:ide:extensionCount", value: String(extensionCount) },
1939
+ ];
1940
+ if (productInfo?.vscodeVersion && productInfo.vscodeVersion !== version) {
1941
+ properties.push({
1942
+ name: "cdx:ide:vscodeVersion",
1943
+ value: productInfo.vscodeVersion,
1944
+ });
1945
+ }
1946
+ if (productInfo?.commit) {
1947
+ properties.push({ name: "cdx:ide:commit", value: productInfo.commit });
1948
+ }
1949
+ if (productInfo?.quality) {
1950
+ properties.push({ name: "cdx:ide:quality", value: productInfo.quality });
1951
+ }
1952
+ if (productInfo?.appDir) {
1953
+ properties.push({
1954
+ name: "cdx:ide:installDir",
1955
+ value: redactHomePath(productInfo.appDir, ide.home),
1956
+ });
1957
+ }
1958
+ const component = {
1959
+ type: "application",
1960
+ group: IDE_PURL_NAMESPACE,
1961
+ name: ide.name,
1962
+ version: version || "",
1963
+ description: productInfo?.nameLong || ide.name,
1964
+ "bom-ref": purl ? decodeURIComponent(purl) : `urn:ide:${ide.id}`,
1965
+ properties,
1966
+ tags: ["ide", ide.kind === "server" ? "ide-server" : "ide-desktop"],
1967
+ };
1968
+ if (purl) {
1969
+ component.purl = purl;
1970
+ }
1971
+ return component;
1972
+ }
1973
+
1974
+ /**
1975
+ * Collect the installed extensions of each editor folder, plus a component for
1976
+ * each editor and the dependency edges from an editor to its extensions.
1977
+ *
1978
+ * One extension installed in two editors is one component: the editors are
1979
+ * listed in `cdx:vscode-extension:ide`, every folder it was found in is an
1980
+ * occurrence, and every copy's entry files are hashed. When two copies of the
1981
+ * same build have different entry-file bytes, each digest becomes its own file
1982
+ * component and the extension is marked `cdx:vscode-extension:entryHashMismatch`.
1983
+ *
1984
+ * @param {Array<{id?: string, name?: string, kind?: string, dir: string, home?: string, only?: string, probeApps?: boolean}>} ideDirs
1985
+ * Extensions folders, as returned by discoverIdeExtensionDirs. `only` limits
1986
+ * a folder to one extension; `home` and `probeApps` say where the editor's
1987
+ * own version is looked up (see findIdeProductInfo)
1988
+ * @param {Object} [options] Collection options
1989
+ * @param {boolean} [options.includeIdeComponents=true] Emit a component for
1990
+ * each editor and link it to its extensions
1991
+ * @param {string} [options.home] Home directory used when a folder names none
1992
+ * @param {boolean} [options.probeApps] Default for folders that do not say
1993
+ * @returns {{components: Object[], dependencies: Object[]}} Inventory
1994
+ */
1995
+ export function collectIdeInventory(ideDirs, options = {}) {
1996
+ const includeIdeComponents = options.includeIdeComponents !== false;
1997
+ const extensions = new Map();
1998
+ const ideGroups = new Map();
1999
+ for (const ide of ideDirs) {
2000
+ try {
2001
+ collectIdeFolder(ide, options, extensions, ideGroups);
2002
+ } catch (err) {
2003
+ thoughtLog(`Unable to read the extensions in ${ide.dir}: ${err.message}`);
2004
+ }
2005
+ }
2006
+ const components = [];
2007
+ const dependencies = [];
2008
+ if (includeIdeComponents) {
2009
+ for (const group of ideGroups.values()) {
2010
+ const home = group.home || options.home || homedir();
2011
+ const probeApps = group.probeApps ?? options.probeApps;
2012
+ const ideComponent = toIdeComponent(
2013
+ group,
2014
+ findIdeProductInfo(group, home, { probeApps }),
2015
+ group.refs.size,
2016
+ );
2017
+ components.push(ideComponent);
2018
+ if (group.refs.size) {
2019
+ dependencies.push({
2020
+ ref: ideComponent["bom-ref"],
2021
+ dependsOn: [...group.refs].sort(),
2022
+ });
2023
+ }
2024
+ }
2025
+ }
2026
+ for (const entry of extensions.values()) {
2027
+ components.push(finishExtension(entry));
2028
+ }
2029
+ return { components, dependencies };
2030
+ }
2031
+
2032
+ function collectIdeFolder(ide, options, extensions, ideGroups) {
2033
+ const { name: ideName, dir } = ide;
2034
+ const { records, obsolete, hasRegistry } =
2035
+ readInstalledExtensionsRegistry(dir);
2036
+ let group;
2037
+ if (ide.id) {
2038
+ const home = ide.home || options.home;
2039
+ const key = `${ide.kind || "desktop"}\u0000${ide.id}\u0000${home || ""}`;
2040
+ group = ideGroups.get(key);
2041
+ if (!group) {
2042
+ group = {
2043
+ id: ide.id,
2044
+ name: ide.name,
2045
+ kind: ide.kind || "desktop",
2046
+ home,
2047
+ probeApps: ide.probeApps,
2048
+ dirs: [],
2049
+ refs: new Set(),
2050
+ };
2051
+ ideGroups.set(key, group);
2052
+ }
2053
+ group.dirs.push(dir);
2054
+ }
2055
+ let entries;
2056
+ try {
2057
+ entries = readdirSync(dir, { withFileTypes: true });
2058
+ } catch {
2059
+ return;
2060
+ }
2061
+ let seenFolders = 0;
2062
+ for (const entry of entries) {
2063
+ if (seenFolders >= MAX_EXTENSION_FOLDERS) {
2064
+ thoughtLog(
2065
+ `Stopped reading ${dir} after ${MAX_EXTENSION_FOLDERS} extension folders.`,
2066
+ );
1012
2067
  break;
1013
2068
  }
2069
+ if (entry.name.startsWith(".") || (ide.only && entry.name !== ide.only)) {
2070
+ continue;
2071
+ }
2072
+ const extDir = join(dir, entry.name);
2073
+ let isLink = false;
2074
+ if (entry.isSymbolicLink()) {
2075
+ isLink = true;
2076
+ try {
2077
+ if (!statSync(extDir).isDirectory()) {
2078
+ continue;
2079
+ }
2080
+ } catch {
2081
+ continue;
2082
+ }
2083
+ } else if (!entry.isDirectory()) {
2084
+ continue;
2085
+ }
2086
+ // A root scan reads only the name of a symlinked folder, which a user
2087
+ // could point anywhere.
2088
+ const nameOnly = isLink && isRunningAsRoot();
2089
+ if (
2090
+ !parseVscodeExtensionDirName(entry.name) &&
2091
+ (nameOnly || !isInstalledExtensionDir(extDir))
2092
+ ) {
2093
+ continue;
2094
+ }
2095
+ seenFolders++;
2096
+ const record = records.get(entry.name);
2097
+ const component = parseInstalledExtensionDir(extDir, ideName, record, {
2098
+ nameOnly,
2099
+ });
2100
+ if (!component) {
2101
+ continue;
2102
+ }
2103
+ const ref = component["bom-ref"];
2104
+ group?.refs.add(ref);
2105
+ let target = extensions.get(ref);
2106
+ if (!target) {
2107
+ target = {
2108
+ component,
2109
+ ideNames: [],
2110
+ occurrences: [],
2111
+ states: [],
2112
+ files: new Map(),
2113
+ };
2114
+ extensions.set(ref, target);
2115
+ }
2116
+ if (ideName && !target.ideNames.includes(ideName)) {
2117
+ target.ideNames.push(ideName);
2118
+ }
2119
+ target.occurrences.push(extDir);
2120
+ const state = installStateFromRecord(record);
2121
+ state.obsolete = obsolete.has(entry.name);
2122
+ state.unregistered = hasRegistry && !record && !state.obsolete;
2123
+ state.symlink = isLink;
2124
+ target.states.push(state);
2125
+ if (!nameOnly) {
2126
+ const props = component.properties || [];
2127
+ for (const file of hashExtensionEntryFiles(extDir, {
2128
+ main: props.find((p) => p.name === "cdx:vscode-extension:main")?.value,
2129
+ browser: props.find((p) => p.name === "cdx:vscode-extension:browser")
2130
+ ?.value,
2131
+ })) {
2132
+ const digests = target.files.get(file.name) || new Map();
2133
+ if (!digests.has(file.digest)) {
2134
+ digests.set(file.digest, file);
2135
+ }
2136
+ target.files.set(file.name, digests);
2137
+ }
2138
+ }
1014
2139
  }
1015
- if (versionStart < 1) {
1016
- return undefined;
2140
+ }
2141
+
2142
+ function finishExtension(entry) {
2143
+ const { component } = entry;
2144
+ const ref = component["bom-ref"];
2145
+ component.properties = (component.properties || []).filter(
2146
+ (p) => p.name !== "cdx:vscode-extension:ide",
2147
+ );
2148
+ if (entry.ideNames.length) {
2149
+ component.properties.unshift({
2150
+ name: "cdx:vscode-extension:ide",
2151
+ value: entry.ideNames.join(", "),
2152
+ });
1017
2153
  }
1018
- const name = rest.substring(0, versionStart);
1019
- const version = rest.substring(versionStart + 1);
1020
- if (name && version) {
1021
- const extInfo = {
1022
- publisher: publisher,
1023
- name: name,
1024
- version,
1025
- displayName: "",
1026
- description: "",
1027
- platform: "",
1028
- srcPath: extDir,
1029
- };
1030
- return toComponent(extInfo, ideName);
2154
+ applyInstallStates(component, entry.states);
2155
+ const files = [];
2156
+ let mismatch = false;
2157
+ for (const digests of entry.files.values()) {
2158
+ if (digests.size > 1) {
2159
+ mismatch = true;
2160
+ }
2161
+ for (const file of digests.values()) {
2162
+ files.push(toEntryFileComponent(ref, file, digests.size > 1));
2163
+ }
1031
2164
  }
1032
- return undefined;
2165
+ if (mismatch) {
2166
+ component.properties.push({
2167
+ name: "cdx:vscode-extension:entryHashMismatch",
2168
+ value: "true",
2169
+ });
2170
+ }
2171
+ if (files.length) {
2172
+ component.components = files;
2173
+ }
2174
+ component.evidence = {
2175
+ ...(component.evidence || {}),
2176
+ occurrences: entry.occurrences.map((location) => ({ location })),
2177
+ };
2178
+ if (!component.properties.length) {
2179
+ delete component.properties;
2180
+ }
2181
+ return component;
1033
2182
  }
1034
2183
 
1035
2184
  /**
@@ -1039,31 +2188,141 @@ export function parseExtensionDirName(extDir, ideName) {
1039
2188
  * @returns {Object[]} Array of CycloneDX component objects
1040
2189
  */
1041
2190
  export function collectInstalledExtensions(ideDirs) {
1042
- const pkgList = [];
1043
- const seen = new Set();
2191
+ return collectIdeInventory(ideDirs, { includeIdeComponents: false })
2192
+ .components;
2193
+ }
1044
2194
 
1045
- for (const { name: ideName, dir } of ideDirs) {
1046
- let entries;
1047
- try {
1048
- entries = readdirSync(dir, { withFileTypes: true });
1049
- } catch (_e) {
2195
+ /**
2196
+ * Merge two descriptions of the same extension build, such as a `.vsix` file
2197
+ * and the folder it was installed into, so neither loses its hashes,
2198
+ * occurrences, entry files or properties.
2199
+ *
2200
+ * @param {Object[]} components Components, some of which share a bom-ref
2201
+ * @returns {Object[]} Components with one entry per extension bom-ref
2202
+ */
2203
+ export function mergeExtensionComponents(components) {
2204
+ const merged = [];
2205
+ const byRef = new Map();
2206
+ for (const component of components) {
2207
+ const ref = component?.["bom-ref"];
2208
+ if (!ref || !component.purl?.startsWith("pkg:vscode-extension/")) {
2209
+ merged.push(component);
1050
2210
  continue;
1051
2211
  }
1052
- for (const entry of entries) {
1053
- if (!entry.isDirectory()) {
1054
- continue;
1055
- }
1056
- // Skip hidden directories and special directories
1057
- if (entry.name.startsWith(".")) {
1058
- continue;
1059
- }
1060
- const extDir = join(dir, entry.name);
1061
- const component = parseInstalledExtensionDir(extDir, ideName);
1062
- if (component && !seen.has(component.purl)) {
1063
- seen.add(component.purl);
1064
- pkgList.push(component);
2212
+ const known = byRef.get(ref);
2213
+ if (!known) {
2214
+ byRef.set(ref, component);
2215
+ merged.push(component);
2216
+ continue;
2217
+ }
2218
+ mergeInto(known, component);
2219
+ }
2220
+ return merged;
2221
+ }
2222
+
2223
+ function mergeInto(known, component) {
2224
+ for (const hash of component.hashes || []) {
2225
+ known.hashes = known.hashes || [];
2226
+ if (
2227
+ !known.hashes.some(
2228
+ (h) => h.alg === hash.alg && h.content === hash.content,
2229
+ )
2230
+ ) {
2231
+ known.hashes.push(hash);
2232
+ }
2233
+ }
2234
+ known.properties = known.properties || [];
2235
+ for (const prop of component.properties || []) {
2236
+ const existing = known.properties.find((p) => p.name === prop.name);
2237
+ if (!existing) {
2238
+ known.properties.push(prop);
2239
+ } else if (prop.name === "cdx:vscode-extension:ide") {
2240
+ const names = existing.value.split(", ");
2241
+ for (const name of prop.value.split(", ")) {
2242
+ if (!names.includes(name)) {
2243
+ names.push(name);
2244
+ }
1065
2245
  }
2246
+ existing.value = names.join(", ");
1066
2247
  }
1067
2248
  }
1068
- return pkgList;
2249
+ const occurrences = known.evidence?.occurrences || [];
2250
+ for (const occurrence of component.evidence?.occurrences || []) {
2251
+ if (!occurrences.some((o) => o.location === occurrence.location)) {
2252
+ occurrences.push(occurrence);
2253
+ }
2254
+ }
2255
+ if (occurrences.length) {
2256
+ known.evidence = { ...(known.evidence || {}), occurrences };
2257
+ }
2258
+ for (const child of component.components || []) {
2259
+ known.components = known.components || [];
2260
+ if (!known.components.some((c) => c["bom-ref"] === child["bom-ref"])) {
2261
+ known.components.push(child);
2262
+ }
2263
+ }
2264
+ }
2265
+
2266
+ /**
2267
+ * Add the host's editors and the extensions osquery could not see to an OBOM
2268
+ * component list.
2269
+ *
2270
+ * osquery's `vscode_extensions` table reads `extensions.json` for a fixed set
2271
+ * of editors, so extensions of other editors, and folders an editor never
2272
+ * registered, are missing from its rows. The editor folders of each home are
2273
+ * read here: an extension osquery already reported keeps its row and gains the
2274
+ * editor names, the occurrences and the hashed entry files, and the rest are
2275
+ * added. Every editor found also becomes a component. No dependency edges are
2276
+ * added, because an OBOM has no dependency graph.
2277
+ *
2278
+ * @param {Object[]} pkgList OBOM components, which are updated in place
2279
+ * @param {Object} [options] Collection options
2280
+ * @param {string[]} [options.homes] Home directories to read
2281
+ * @param {string} [options.home] One home directory to read, used when
2282
+ * `homes` is not given; defaults to the current user's
2283
+ * @param {Array<Object>} [options.ideDirs] Extensions folders to read in place
2284
+ * of discovering them
2285
+ * @returns {Object[]} Components to append to the OBOM
2286
+ */
2287
+ export function collectObomIdeInventory(pkgList, options = {}) {
2288
+ const homes = options.homes?.length
2289
+ ? options.homes
2290
+ : [options.home || homedir()];
2291
+ const ideDirs =
2292
+ options.ideDirs ||
2293
+ homes.flatMap((home) =>
2294
+ discoverIdeExtensionDirs(home).map((ide) => ({
2295
+ ...ide,
2296
+ probeApps: true,
2297
+ })),
2298
+ );
2299
+ if (!ideDirs.length) {
2300
+ return [];
2301
+ }
2302
+ const inventory = collectIdeInventory(ideDirs, {
2303
+ home: homes[0],
2304
+ probeApps: true,
2305
+ });
2306
+ const existing = new Map(
2307
+ pkgList
2308
+ .filter((c) => c?.["bom-ref"])
2309
+ .map((component) => [component["bom-ref"], component]),
2310
+ );
2311
+ const additions = [];
2312
+ for (const component of inventory.components) {
2313
+ const known = existing.get(component["bom-ref"]);
2314
+ if (!known) {
2315
+ additions.push(component);
2316
+ continue;
2317
+ }
2318
+ const scanned = {
2319
+ ...component,
2320
+ properties: (component.properties || []).filter((p) =>
2321
+ p.name.startsWith("cdx:vscode-extension:"),
2322
+ ),
2323
+ hashes: undefined,
2324
+ };
2325
+ mergeInto(known, scanned);
2326
+ }
2327
+ return additions;
1069
2328
  }