@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
@@ -0,0 +1,344 @@
1
+ // Bounded, read-only access to files inside an extracted image or a live host.
2
+ //
3
+ // Every file a scan opens may be hostile: only regular files that stay inside
4
+ // the extracted root are opened, a symlink is never followed, a FIFO is never
5
+ // opened, and reads are capped. A missing or odd file yields no result rather
6
+ // than throwing.
7
+
8
+ import { createHash } from "node:crypto";
9
+ import {
10
+ closeSync,
11
+ constants,
12
+ fstatSync,
13
+ lstatSync,
14
+ openSync,
15
+ readSync,
16
+ realpathSync,
17
+ } from "node:fs";
18
+ import { resolve, sep } from "node:path";
19
+ import process from "node:process";
20
+
21
+ const READ_CHUNK_BYTES = 1024 * 1024;
22
+
23
+ // O_NOFOLLOW refuses a symlink swapped in after lstat, and O_NONBLOCK keeps a
24
+ // FIFO swapped in the same way from blocking the open. Neither exists on
25
+ // Windows, where neither race applies.
26
+ const OPEN_FLAGS =
27
+ constants.O_RDONLY |
28
+ (constants.O_NOFOLLOW || 0) |
29
+ (constants.O_NONBLOCK || 0);
30
+
31
+ /**
32
+ * The largest file scanFileMarkers reads. Larger files are skipped whole
33
+ * rather than read in part, so a marker past the cap is never mistaken for an
34
+ * absent one; the result says the file was skipped.
35
+ *
36
+ * @type {number}
37
+ */
38
+ export const MAX_MARKER_SCAN_BYTES = 2 * 1024 * 1024 * 1024;
39
+
40
+ /**
41
+ * Open a path only when it is a regular file that does not resolve outside
42
+ * rootDir. lstat runs first so a FIFO is rejected without opening it (an open
43
+ * on a FIFO blocks until a writer appears) and a symlink is never followed.
44
+ *
45
+ * @param {string} absPath absolute path
46
+ * @param {string|undefined} rootDir extracted root the path must stay under
47
+ * @returns {{fd: number, size: number}|undefined} an open descriptor the
48
+ * caller closes, or undefined
49
+ */
50
+ export function openRegularFile(absPath, rootDir) {
51
+ let fd;
52
+ try {
53
+ if (typeof absPath !== "string" || !absPath) {
54
+ return undefined;
55
+ }
56
+ const lst = lstatSync(absPath);
57
+ if (!lst.isFile()) {
58
+ return undefined;
59
+ }
60
+ if (rootDir) {
61
+ const root = realpathSync(resolve(rootDir));
62
+ const real = realpathSync(absPath);
63
+ const rootPrefix = root.endsWith(sep) ? root : root + sep;
64
+ if (real !== root && !real.startsWith(rootPrefix)) {
65
+ return undefined;
66
+ }
67
+ }
68
+ fd = openSync(absPath, OPEN_FLAGS);
69
+ const stats = fstatSync(fd);
70
+ if (!stats.isFile()) {
71
+ closeSync(fd);
72
+ return undefined;
73
+ }
74
+ return { fd, size: stats.size };
75
+ } catch {
76
+ if (fd !== undefined) {
77
+ try {
78
+ closeSync(fd);
79
+ } catch {
80
+ // already closed
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read a small text file in full, only when it is a regular file no larger
89
+ * than maxBytes. A FIFO, a directory, a symlink or an oversized file reads as
90
+ * undefined.
91
+ *
92
+ * @param {string} absPath absolute path
93
+ * @param {number} maxBytes largest size read
94
+ * @param {string|undefined} [rootDir] root the path must stay under
95
+ * @returns {string|undefined} the text, or undefined
96
+ */
97
+ export function readRegularTextFile(absPath, maxBytes, rootDir) {
98
+ const opened = openRegularFile(absPath, rootDir);
99
+ if (!opened) {
100
+ return undefined;
101
+ }
102
+ try {
103
+ if (opened.size > maxBytes) {
104
+ return undefined;
105
+ }
106
+ const buffer = Buffer.alloc(opened.size);
107
+ let total = 0;
108
+ while (total < opened.size) {
109
+ const read = readSync(
110
+ opened.fd,
111
+ buffer,
112
+ total,
113
+ opened.size - total,
114
+ total,
115
+ );
116
+ if (read <= 0) {
117
+ break;
118
+ }
119
+ total += read;
120
+ }
121
+ return buffer.toString("utf8", 0, total);
122
+ } catch {
123
+ return undefined;
124
+ } finally {
125
+ try {
126
+ closeSync(opened.fd);
127
+ } catch {
128
+ // already closed
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Find byte markers in a file with bounded chunked reads.
135
+ *
136
+ * Each hit records up to `beforeBytes` bytes before the marker and up to
137
+ * `contextBytes` bytes after it, so a caller can read a version that follows a
138
+ * marker or check what a string is part of. Consecutive reads overlap by the
139
+ * longest marker plus the context length, so a marker split across two reads
140
+ * is seen whole once. Only bytes that were read are searched.
141
+ *
142
+ * @param {string} absPath absolute path of the file
143
+ * @param {string|undefined} rootDir extracted root the file must stay under
144
+ * @param {Buffer[]} markers byte markers to look for
145
+ * @param {Object} [options] options
146
+ * @param {number} [options.maxBytes] files larger than this are not read
147
+ * @param {number} [options.contextBytes=64] bytes kept after each hit
148
+ * @param {number} [options.beforeBytes=1] bytes kept before each hit
149
+ * @param {number} [options.maxHits=16] hits kept per marker
150
+ * @returns {{hits: {before: Buffer, after: Buffer, offset: number}[][], size: number, skipped?: string}|undefined}
151
+ * hits per marker, in marker order, and the file size; `skipped` is
152
+ * `too-large` when the file is over the cap and was not read. Undefined when
153
+ * the file cannot be opened.
154
+ */
155
+ export function scanFileMarkers(
156
+ absPath,
157
+ rootDir,
158
+ markers,
159
+ {
160
+ maxBytes = MAX_MARKER_SCAN_BYTES,
161
+ contextBytes = 64,
162
+ beforeBytes = 1,
163
+ maxHits = 16,
164
+ } = {},
165
+ ) {
166
+ if (!Array.isArray(markers) || !markers.length) {
167
+ return undefined;
168
+ }
169
+ const opened = openRegularFile(absPath, rootDir);
170
+ if (!opened) {
171
+ return undefined;
172
+ }
173
+ const hits = markers.map(() => []);
174
+ try {
175
+ const { fd, size } = opened;
176
+ if (size > maxBytes) {
177
+ return { hits, size, skipped: "too-large" };
178
+ }
179
+ if (size <= 0) {
180
+ return { hits, size };
181
+ }
182
+ const longest = Math.max(...markers.map((marker) => marker.length));
183
+ const overlap = longest + contextBytes;
184
+ const bufferSize = Math.min(size, READ_CHUNK_BYTES) + overlap;
185
+ const buffer = Buffer.allocUnsafe(bufferSize);
186
+ // The bytes just before the current window, for a hit near its start.
187
+ let previousTail = Buffer.alloc(0);
188
+ for (let position = 0; position < size; position += READ_CHUNK_BYTES) {
189
+ const want = Math.min(bufferSize, size - position);
190
+ const read = readSync(fd, buffer, 0, want, position);
191
+ if (read <= 0) {
192
+ break;
193
+ }
194
+ // What a previous, longer read left in the buffer past `read` is never
195
+ // searched.
196
+ const window = buffer.subarray(0, read);
197
+ // Only hits starting inside this window are new; the overlap lets the
198
+ // next window see a marker and its context that cross the edge. The
199
+ // last window has no next one, so everything it read is searched.
200
+ const isLast = position + read >= size;
201
+ const searchLimit = isLast ? read : Math.min(READ_CHUNK_BYTES, read);
202
+ for (let index = 0; index < markers.length; index++) {
203
+ const marker = markers[index];
204
+ const found = hits[index];
205
+ let offset = window.indexOf(marker);
206
+ while (
207
+ offset !== -1 &&
208
+ offset < searchLimit &&
209
+ found.length < maxHits
210
+ ) {
211
+ const afterStart = offset + marker.length;
212
+ const before =
213
+ offset >= beforeBytes
214
+ ? window.subarray(offset - beforeBytes, offset)
215
+ : Buffer.concat([
216
+ previousTail,
217
+ window.subarray(0, offset),
218
+ ]).subarray(-beforeBytes);
219
+ found.push({
220
+ offset: position + offset,
221
+ before: Buffer.from(before),
222
+ after: Buffer.from(
223
+ window.subarray(
224
+ Math.min(afterStart, read),
225
+ Math.min(read, afterStart + contextBytes),
226
+ ),
227
+ ),
228
+ });
229
+ offset = window.indexOf(marker, offset + 1);
230
+ }
231
+ }
232
+ if (read < bufferSize) {
233
+ break;
234
+ }
235
+ previousTail = Buffer.from(
236
+ window.subarray(
237
+ Math.max(0, READ_CHUNK_BYTES - beforeBytes),
238
+ READ_CHUNK_BYTES,
239
+ ),
240
+ );
241
+ }
242
+ return { hits, size };
243
+ } catch {
244
+ return undefined;
245
+ } finally {
246
+ try {
247
+ closeSync(opened.fd);
248
+ } catch {
249
+ // already closed
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Compute the digest of a regular file with bounded chunked reads, under the
256
+ * same rules as readRegularTextFile: a FIFO, a directory, a symlink, a file
257
+ * that resolves outside rootDir or one larger than maxBytes yields undefined.
258
+ *
259
+ * @param {string} absPath absolute path
260
+ * @param {number} maxBytes largest size hashed
261
+ * @param {string|undefined} [rootDir] root the path must stay under
262
+ * @param {string} [algorithm="sha256"] node:crypto hash algorithm
263
+ * @returns {string|undefined} the hex digest, or undefined
264
+ */
265
+ export function hashRegularFile(
266
+ absPath,
267
+ maxBytes,
268
+ rootDir,
269
+ algorithm = "sha256",
270
+ ) {
271
+ const opened = openRegularFile(absPath, rootDir);
272
+ if (!opened) {
273
+ return undefined;
274
+ }
275
+ try {
276
+ if (opened.size > maxBytes) {
277
+ return undefined;
278
+ }
279
+ const hash = createHash(algorithm);
280
+ const buffer = Buffer.alloc(Math.min(READ_CHUNK_BYTES, opened.size || 1));
281
+ let total = 0;
282
+ while (total < opened.size) {
283
+ const read = readSync(
284
+ opened.fd,
285
+ buffer,
286
+ 0,
287
+ Math.min(buffer.length, opened.size - total),
288
+ total,
289
+ );
290
+ if (read <= 0) {
291
+ break;
292
+ }
293
+ hash.update(buffer.subarray(0, read));
294
+ total += read;
295
+ }
296
+ return hash.digest("hex");
297
+ } catch {
298
+ return undefined;
299
+ } finally {
300
+ try {
301
+ closeSync(opened.fd);
302
+ } catch {
303
+ // already closed
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * True when the process runs as root, where a symlink planted in a user's
310
+ * home could point a scan at files only root can read.
311
+ *
312
+ * @returns {boolean} true for uid 0
313
+ */
314
+ export function isRunningAsRoot() {
315
+ return typeof process.getuid === "function" && process.getuid() === 0;
316
+ }
317
+
318
+ /**
319
+ * Read a small text file a user keeps in their home, such as an editor's or an
320
+ * agent's configuration. Dotfile managers often make these symlinks, so a
321
+ * symlink to a regular file is followed, except when the process runs as root.
322
+ * The target must still be a regular file no larger than maxBytes.
323
+ *
324
+ * @param {string} absPath absolute path
325
+ * @param {number} maxBytes largest size read
326
+ * @returns {string|undefined} the text, or undefined
327
+ */
328
+ export function readUserFileText(absPath, maxBytes) {
329
+ if (typeof absPath !== "string" || !absPath) {
330
+ return undefined;
331
+ }
332
+ let target = absPath;
333
+ try {
334
+ if (lstatSync(absPath).isSymbolicLink()) {
335
+ if (isRunningAsRoot()) {
336
+ return undefined;
337
+ }
338
+ target = realpathSync(absPath);
339
+ }
340
+ } catch {
341
+ return undefined;
342
+ }
343
+ return readRegularTextFile(target, maxBytes);
344
+ }
@@ -0,0 +1,182 @@
1
+ // Bounded probe for a Brotli decoder compiled into a native file, and for
2
+ // whether that copy carries the CVE-2026-111497 fix.
3
+ //
4
+ // Node.js, the Python Brotli and brotlicffi wheels, the PHP and Ruby
5
+ // extensions, the .NET runtime and many static binaries carry a private copy of
6
+ // Google's C decoder. No package manager names that copy, and its version is
7
+ // not stored as a string anywhere in the file, so a BOM has to read the file
8
+ // to say whether a vulnerable decoder ships in it.
9
+ //
10
+ // Three kinds of evidence are read, each classified by the bytes around it:
11
+ //
12
+ // - The names of the decoder's error codes, which BrotliDecoderErrorString()
13
+ // returns (EXUBERANT_NIBBLE is one). Upstream 1.1+ stores them prefixed,
14
+ // `_ERROR_FORMAT_EXUBERANT_NIBBLE`; 1.0.x and Debian's backport store the
15
+ // bare name between NULs. The fix, google/brotli 0ed4268, added the name
16
+ // BLOCK_SWITCH, so a decoder whose table has it is patched.
17
+ // https://github.com/google/brotli/blob/v1.2.0/c/include/brotli/decode.h#L72
18
+ // https://github.com/google/brotli/commit/0ed4268a4401abcb4472096340477cf4af7d1a2b
19
+ // - Two decoder tables, kCodeLengthCodeOrder and kCodeLengthPrefixValue
20
+ // (c/dec/decode.c). They stay when a linker drops the error-name table
21
+ // because nothing calls BrotliDecoderErrorString (--gc-sections, LTO,
22
+ // -dead_strip). The encoder keeps the first table but not the second. With
23
+ // the tables alone the decoder is present but its fix state is unknown.
24
+ // - Node.js's zlib.constants table, BROTLI_DECODER_ERROR_FORMAT_*. It mirrors
25
+ // the brotli headers Node.js was built with, and a linker that merges string
26
+ // tails (lld -O2, lld-link /OPT:ICF) can leave only it in place of the
27
+ // decoder's own names. Next to the decoder tables it stands for them; alone,
28
+ // as in a Node.js linked to a shared libbrotlidec, it is no decoder.
29
+ //
30
+ // The ports are told apart. The Go port (andybalholm/brotli, fixed in v1.2.7)
31
+ // keeps the same names in Go's undelimited string table, so a Go binary can
32
+ // hold both a cgo-linked C decoder and the Go port, and each gets its own
33
+ // fix state. The Rust port (brotli-decompressor) keeps `ERROR_FORMAT_...`
34
+ // strings and the same tables but is not affected; its crate name in symbols
35
+ // or panic paths marks it, and its evidence is ignored.
36
+ //
37
+ // Nothing is executed. A file over the read cap is reported as skipped, never
38
+ // as free of a decoder.
39
+ //
40
+ // The component helpers that put the answer into a BOM are in
41
+ // lib/inventory/brotliDecoder.js; this half lives in the core layer so the HTTP
42
+ // client can ask it about its own runtime.
43
+
44
+ import { scanFileMarkers } from "./boundedFile.js";
45
+
46
+ const NAME = Buffer.from("EXUBERANT_NIBBLE");
47
+ const FIX_NAME = Buffer.from("BLOCK_SWITCH");
48
+ // The Go linker writes this magic at the start of the buildinfo section.
49
+ // https://github.com/golang/go/blob/go1.25.0/src/debug/buildinfo/buildinfo.go#L58
50
+ const GO_BUILDINFO_MAGIC = Buffer.from("\xff Go buildinf:", "latin1");
51
+ // static const uint8_t kCodeLengthCodeOrder[18] and
52
+ // kCodeLengthPrefixValue[16], c/dec/decode.c.
53
+ const CODE_LENGTH_CODE_ORDER = Buffer.from([
54
+ 1, 2, 3, 4, 0, 5, 17, 6, 16, 7, 8, 9, 10, 11, 12, 13, 14, 15,
55
+ ]);
56
+ const CODE_LENGTH_PREFIX_VALUE = Buffer.from([
57
+ 0, 4, 3, 2, 0, 4, 3, 1, 0, 4, 3, 2, 0, 4, 3, 5,
58
+ ]);
59
+ // Legacy and v0 Rust mangling length-prefix the crate name; panic messages
60
+ // name the crate's source path.
61
+ const RUST_CRATE_SYMBOL = Buffer.from("19brotli_decompressor");
62
+ const RUST_CRATE_PATH = Buffer.from("brotli-decompressor-");
63
+
64
+ const CONSTANT_PREFIX = Buffer.from("BROTLI_DECODER_ERROR_FORMAT_");
65
+ const C_PREFIX = Buffer.from("_ERROR_FORMAT_");
66
+ const RUST_PREFIX = Buffer.from("ERROR_FORMAT_");
67
+
68
+ /**
69
+ * The most files of one kind (executables and libraries, or extension
70
+ * modules) one scan opens in search of a Brotli decoder.
71
+ *
72
+ * @type {number}
73
+ */
74
+ export const MAX_BROTLI_PROBES = 4096;
75
+
76
+ /**
77
+ * The most bytes one scan reads in search of Brotli decoders.
78
+ *
79
+ * @type {number}
80
+ */
81
+ export const MAX_BROTLI_PROBE_BYTES = 16 * 1024 * 1024 * 1024;
82
+
83
+ function endsWith(buffer, suffix) {
84
+ return (
85
+ buffer.length >= suffix.length &&
86
+ buffer.subarray(buffer.length - suffix.length).equals(suffix)
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Classify one hit of an error name by what it is part of.
92
+ *
93
+ * @param {{before: Buffer, after: Buffer}} hit marker hit
94
+ * @returns {string} `constant`, `c`, `rust` or `other`
95
+ */
96
+ function classify(hit) {
97
+ if (endsWith(hit.before, CONSTANT_PREFIX)) {
98
+ return "constant";
99
+ }
100
+ if (endsWith(hit.before, C_PREFIX)) {
101
+ return "c";
102
+ }
103
+ if (endsWith(hit.before, RUST_PREFIX)) {
104
+ return "rust";
105
+ }
106
+ const previous = hit.before.length ? hit.before[hit.before.length - 1] : 0;
107
+ if (previous === 0 && hit.after.length && hit.after[0] === 0) {
108
+ return "c";
109
+ }
110
+ return "other";
111
+ }
112
+
113
+ /**
114
+ * Read which Brotli decoders a native file embeds and whether each carries
115
+ * the CVE-2026-111497 fix.
116
+ *
117
+ * @param {string} absPath absolute path of the file
118
+ * @param {string|undefined} rootDir extracted root the file must stay under
119
+ * @returns {{c?: {blockSwitchFix: string, evidence: string}, go?: {blockSwitchFix: string}, skipped?: string, size: number}|undefined}
120
+ * undefined when the file cannot be read or holds no decoder. `c` is
121
+ * Google's C decoder, with blockSwitchFix `true`, `false` or `unknown` and
122
+ * the evidence it rests on (`error-names`, `constant-names` or `tables`);
123
+ * `go` is the Go port. `skipped` is `too-large` for a file over the read cap.
124
+ */
125
+ export function readBrotliDecoder(absPath, rootDir) {
126
+ const scan = scanFileMarkers(
127
+ absPath,
128
+ rootDir,
129
+ [
130
+ NAME,
131
+ FIX_NAME,
132
+ GO_BUILDINFO_MAGIC,
133
+ CODE_LENGTH_CODE_ORDER,
134
+ CODE_LENGTH_PREFIX_VALUE,
135
+ RUST_CRATE_SYMBOL,
136
+ RUST_CRATE_PATH,
137
+ ],
138
+ { contextBytes: 1, beforeBytes: CONSTANT_PREFIX.length, maxHits: 256 },
139
+ );
140
+ if (!scan) {
141
+ return undefined;
142
+ }
143
+ if (scan.skipped) {
144
+ return { skipped: scan.skipped, size: scan.size };
145
+ }
146
+ const [
147
+ names,
148
+ fixNames,
149
+ goMagic,
150
+ codeOrder,
151
+ prefixValue,
152
+ rustSymbol,
153
+ rustPath,
154
+ ] = scan.hits;
155
+ const kinds = (hits) => new Set(hits.map(classify));
156
+ const nameKinds = kinds(names);
157
+ const fixKinds = kinds(fixNames);
158
+ const isGo = goMagic.length > 0;
159
+ const isRust = rustSymbol.length > 0 || rustPath.length > 0;
160
+ const hasTables = codeOrder.length > 0 && prefixValue.length > 0;
161
+ const result = { size: scan.size };
162
+ // Go strings are not delimited, so a name that is neither C-shaped nor a
163
+ // constant is the Go port's, in a Go binary.
164
+ const hasGoPort = isGo && nameKinds.has("other");
165
+ if (hasGoPort) {
166
+ result.go = { blockSwitchFix: fixKinds.has("other") ? "true" : "false" };
167
+ }
168
+ if (nameKinds.has("c")) {
169
+ result.c = {
170
+ blockSwitchFix: fixKinds.has("c") ? "true" : "false",
171
+ evidence: "error-names",
172
+ };
173
+ } else if (hasTables && !hasGoPort && !isRust) {
174
+ result.c = nameKinds.has("constant")
175
+ ? {
176
+ blockSwitchFix: fixKinds.has("constant") ? "true" : "false",
177
+ evidence: "constant-names",
178
+ }
179
+ : { blockSwitchFix: "unknown", evidence: "tables" };
180
+ }
181
+ return result.c || result.go ? result : undefined;
182
+ }
package/lib/core/env.js CHANGED
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, win32 } from "node:path";
2
2
 
3
3
  import {
4
4
  buildReadCountSuffix,
@@ -301,6 +301,20 @@ export const SWIFT_CMD = readEnvironmentVariable("SWIFT_CMD") || "swift";
301
301
  /** Resolved Ruby executable command (RUBY_CMD env var, or "ruby"). */
302
302
  export const RUBY_CMD = readEnvironmentVariable("RUBY_CMD") || "ruby";
303
303
 
304
+ /** Resolved RubyGems executable command (CDXGEN_GEM_CMD env var, or "gem"). */
305
+ export const GEM_CMD = readEnvironmentVariable("CDXGEN_GEM_CMD") || "gem";
306
+
307
+ /** Resolved Bundler executable command (CDXGEN_BUNDLE_CMD env var, or "bundle"). */
308
+ export const BUNDLE_CMD =
309
+ readEnvironmentVariable("CDXGEN_BUNDLE_CMD") || "bundle";
310
+
311
+ /** Resolved PHP executable command (PHP_CMD env var, or "php"). */
312
+ export const PHP_CMD = readEnvironmentVariable("PHP_CMD") || "php";
313
+
314
+ /** Resolved Composer executable command (COMPOSER_CMD env var, or "composer"). */
315
+ export const COMPOSER_CMD =
316
+ readEnvironmentVariable("COMPOSER_CMD") || "composer";
317
+
304
318
  /** Python package components that can be excluded from the generated BOM. */
305
319
  export const PYTHON_EXCLUDED_COMPONENTS = [
306
320
  "pip",
@@ -513,6 +527,7 @@ export const PROJECT_TYPE_ALIASES = {
513
527
  "vscode",
514
528
  "openvsx",
515
529
  "vscode-extensions",
530
+ "ide",
516
531
  "ide-extension",
517
532
  "ide-extensions",
518
533
  ],
@@ -707,23 +722,38 @@ export function hasAnyProjectType(projectTypes, options, defaultStatus = true) {
707
722
  return shouldInclude;
708
723
  }
709
724
 
725
+ /**
726
+ * Extract the lowercase command name from an invoked command path, without
727
+ * directories or a trailing `.js`, `.cjs`, `.mjs` or `.exe` extension. Both
728
+ * `/` and `\\` separators are handled, so a Windows path is inspected the
729
+ * same way on any host.
730
+ *
731
+ * @param {string} [commandPath] Invoked command path or name
732
+ * @returns {string} Lowercase command name
733
+ */
734
+ export function getCommandName(commandPath) {
735
+ return win32
736
+ .basename(`${commandPath || ""}`)
737
+ .toLowerCase()
738
+ .replace(/\.(?:[cm]?js|exe)$/u, "");
739
+ }
740
+
710
741
  /**
711
742
  * Determine whether the predictive dependency audit should run for the current
712
743
  * CLI invocation.
713
744
  *
714
- * OBOM-focused runs (`obom` or explicit `-t os` / OS aliases only) should keep
715
- * the direct BOM audit findings but skip the predictive dependency audit.
745
+ * OBOM and HBOM runs (commands named `obom` or `hbom`, including `hbom-slim`)
746
+ * and explicit `-t os` / OS / hardware project types alone keep the direct BOM
747
+ * audit findings but skip the predictive dependency audit. Only the command
748
+ * name is tested, never the directories leading to it.
716
749
  *
717
750
  * @param {object} options CLI options
718
751
  * @param {string} [commandPath] Invoked command path or name
719
752
  * @returns {boolean} True when predictive dependency audit should run
720
753
  */
721
754
  export function shouldRunPredictiveBomAudit(options, commandPath) {
722
- const normalizedCommandPath = `${commandPath || ""}`.toLowerCase();
723
- if (normalizedCommandPath.includes("obom")) {
724
- return false;
725
- }
726
- if (normalizedCommandPath.includes("hbom")) {
755
+ const commandName = getCommandName(commandPath);
756
+ if (commandName.startsWith("obom") || commandName.startsWith("hbom")) {
727
757
  return false;
728
758
  }
729
759
  const projectTypes = Array.isArray(options?.projectType)
package/lib/core/fs.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  } from "./activity.js";
44
44
  import { thoughtLog, traceLog } from "./logger.js";
45
45
  import { isWin } from "./paths.js";
46
+ import { recordExecutedTool } from "./toolRegistry.js";
46
47
 
47
48
  function getArchiveSourceByteSize(sourcePath) {
48
49
  if (!sourcePath || !safeExistsSync(sourcePath)) {
@@ -522,8 +523,13 @@ function isWindowsShellHijackRisk(command, options) {
522
523
  const absCwd = resolve(cwd);
523
524
  for (const candidate of candidates) {
524
525
  const candidatePath = path.join(absCwd, candidate);
525
- if (existsSync(candidatePath)) {
526
- return true;
526
+ // cmd.exe only runs files, so a directory named like the tool is harmless.
527
+ try {
528
+ if (statSync(candidatePath).isFile()) {
529
+ return true;
530
+ }
531
+ } catch (_err) {
532
+ // Not present.
527
533
  }
528
534
  }
529
535
  return false;
@@ -831,24 +837,28 @@ export function safeSpawnSync(command, args, options) {
831
837
  error: new Error("No execute permission"),
832
838
  };
833
839
  }
840
+ // cmd.exe looks in the working directory before PATH, so a scanned project
841
+ // could otherwise replace the tool cdxgen means to run. This holds outside
842
+ // secure mode too, since --no-install-deps scans still run version probes
843
+ // and read-only tools with the project as the working directory.
844
+ if (isWindowsShellHijackRisk(command, options)) {
845
+ const blockedReason = `${command} matches local file in cwd (Windows shell hijack risk)`;
846
+ console.warn(`\x1b[1;31mSecurity Alert: ${blockedReason}\x1b[0m`);
847
+ recordActivity({
848
+ kind: activityDescriptor.kind,
849
+ ...activityDescriptor.metadata,
850
+ reason: blockedReason,
851
+ status: "blocked",
852
+ target: activityDescriptor.target,
853
+ });
854
+ return {
855
+ status: 1,
856
+ stdout: undefined,
857
+ stderr: undefined,
858
+ error: new Error(blockedReason),
859
+ };
860
+ }
834
861
  if (isSecureMode) {
835
- if (isWindowsShellHijackRisk(command, options)) {
836
- const blockedReason = `${command} matches local file in cwd (Windows shell hijack risk)`;
837
- console.warn(`\x1b[1;31mSecurity Alert: ${blockedReason}\x1b[0m`);
838
- recordActivity({
839
- kind: activityDescriptor.kind,
840
- ...activityDescriptor.metadata,
841
- reason: blockedReason,
842
- status: "blocked",
843
- target: activityDescriptor.target,
844
- });
845
- return {
846
- status: 1,
847
- stdout: undefined,
848
- stderr: undefined,
849
- error: new Error(blockedReason),
850
- };
851
- }
852
862
  if (options?.cwd && options.cwd !== resolve(options.cwd)) {
853
863
  if (DEBUG_MODE) {
854
864
  console.log(
@@ -982,6 +992,8 @@ export function safeSpawnSync(command, args, options) {
982
992
  }
983
993
  traceLog("spawn", { command, args, ...options });
984
994
  commandsExecuted.add(command);
995
+ // The tool as invoked, before a shell invocation folds the arguments in.
996
+ const executedCommand = command;
985
997
  // Fix for DEP0190 warning
986
998
  if (options?.shell === true) {
987
999
  const joined = joinShellCommand(command, args, rawShellArgs);
@@ -991,6 +1003,15 @@ export function safeSpawnSync(command, args, options) {
991
1003
  }
992
1004
  }
993
1005
  const result = spawnSync(command, args, options);
1006
+ // The run-scoped tool registry feeds metadata.tools (issue #598); every
1007
+ // external execution funnels through this wrapper. A spawn that failed to
1008
+ // start, such as a missing binary, ran nothing and is not recorded; through
1009
+ // a shell, a missing command shows as exit code 127, or 9009 on cmd.exe.
1010
+ const shellCommandNotFound =
1011
+ options?.shell === true && [127, 9009].includes(result.status);
1012
+ if (!result.error && !shellCommandNotFound) {
1013
+ recordExecutedTool(executedCommand);
1014
+ }
994
1015
  recordActivity({
995
1016
  kind: activityDescriptor.kind,
996
1017
  ...activityDescriptor.metadata,