@cdxgen/cdxgen 13.5.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 (182) hide show
  1. package/README.md +6 -6
  2. package/bin/cdxgen.js +4 -0
  3. package/bin/evinse.js +3 -0
  4. package/bin/validate.js +6 -0
  5. package/data/license-aliases.json +24 -2
  6. package/data/remediations.json +124 -0
  7. package/data/rules/ai-bom.yaml +1 -1
  8. package/data/rules/brotli-decoder.yaml +107 -0
  9. package/data/rules/vscode-extensions.yaml +75 -0
  10. package/data/spdx-license-list.json +121 -7
  11. package/data/spdx-licenses.json +69 -0
  12. package/data/spdx.schema.json +845 -2
  13. package/lib/cli/bomAssembly.js +92 -3
  14. package/lib/cli/index.js +108 -1
  15. package/lib/cli/jsBom.js +153 -64
  16. package/lib/cli/jvmBom.js +237 -46
  17. package/lib/cli/managedBom.js +142 -16
  18. package/lib/cli/nativeBom.js +42 -5
  19. package/lib/core/boundedFile.js +344 -0
  20. package/lib/core/brotliProbe.js +182 -0
  21. package/lib/core/env.js +8 -0
  22. package/lib/core/fs.js +12 -0
  23. package/lib/core/httpClient.js +98 -1
  24. package/lib/core/lockPaths.js +38 -0
  25. package/lib/core/paths.js +24 -1
  26. package/lib/core/processTree.js +5 -0
  27. package/lib/core/toolRegistry.js +170 -0
  28. package/lib/ecosystems/cppEvidence.js +263 -106
  29. package/lib/ecosystems/cppIncludeResolver.js +16 -5
  30. package/lib/ecosystems/ecosystems.js +25 -10
  31. package/lib/ecosystems/gemHostProbe.js +301 -0
  32. package/lib/ecosystems/gradleutils.js +828 -224
  33. package/lib/ecosystems/parsers-bazel.js +107 -6
  34. package/lib/ecosystems/parsers-js.js +505 -22
  35. package/lib/ecosystems/parsers-misc.js +209 -20
  36. package/lib/ecosystems/parsers-python.js +89 -2
  37. package/lib/ecosystems/pythonutils.js +128 -2
  38. package/lib/ecosystems/rubyutils.js +538 -31
  39. package/lib/ecosystems/scalacliutils.js +2 -0
  40. package/lib/ecosystems/scalasem.js +796 -151
  41. package/lib/ecosystems/vendoredCode.js +237 -3
  42. package/lib/ecosystems/venvProbe.js +360 -0
  43. package/lib/evinser/evinser.js +346 -43
  44. package/lib/helpers/agenticToolutils.js +413 -93
  45. package/lib/helpers/vsixutils.js +1574 -315
  46. package/lib/inventory/atomUtils.js +77 -0
  47. package/lib/inventory/brotliDecoder.js +142 -0
  48. package/lib/inventory/buildToolRateLimit.js +1 -1
  49. package/lib/inventory/cbomutils.js +56 -7
  50. package/lib/inventory/deps.js +35 -0
  51. package/lib/inventory/dosai.js +139 -5
  52. package/lib/inventory/dotnetRuntime.js +217 -0
  53. package/lib/inventory/envcontext.js +307 -13
  54. package/lib/inventory/evidenceUtils.js +11 -0
  55. package/lib/inventory/fetchBatch.js +1 -1
  56. package/lib/inventory/formulationParsers.js +31 -3
  57. package/lib/inventory/gtfobins.js +13 -0
  58. package/lib/inventory/hostRuntimeProbe.js +686 -0
  59. package/lib/inventory/jvmLocalRepos.js +9 -2
  60. package/lib/inventory/jvmToolEnv.js +29 -0
  61. package/lib/inventory/licenseEnhancer.js +12 -0
  62. package/lib/inventory/mavenCentralMirror.js +534 -0
  63. package/lib/inventory/nodeBuildInfo.js +218 -0
  64. package/lib/inventory/nodeHostProbe.js +202 -0
  65. package/lib/inventory/osPackageResolver.js +182 -0
  66. package/lib/inventory/phpBuildInfo.js +10 -59
  67. package/lib/inventory/phpHostProbe.js +32 -488
  68. package/lib/inventory/rubyBuildInfo.js +972 -0
  69. package/lib/inventory/rubyHostProbe.js +218 -0
  70. package/lib/inventory/scalaCoords.js +4 -3
  71. package/lib/inventory/scalaDedupe.js +440 -0
  72. package/lib/inventory/source.js +49 -2
  73. package/lib/managers/binary.js +337 -3
  74. package/lib/managers/docker.js +79 -0
  75. package/lib/parsers/largeJson.js +28 -11
  76. package/lib/parsers/vscodeExtensionId.js +183 -0
  77. package/lib/server/server.js +12 -1
  78. package/lib/stages/postgen/ruleEngine.js +13 -5
  79. package/lib/stages/pregen/envAudit.js +1 -0
  80. package/lib/stages/pregen/pregen.js +114 -57
  81. package/lib/validator/bomValidator.js +6 -1
  82. package/lib/validator/generated/validate-1.6.mjs +1 -1
  83. package/lib/validator/generated/validate-1.7.mjs +1 -1
  84. package/lib/validator/generated/validate-2.0.mjs +1 -1
  85. package/lib/validator/index.js +1 -1
  86. package/package.json +3 -2
  87. package/types/lib/cli/bomAssembly.d.ts +13 -1
  88. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  89. package/types/lib/cli/index.d.ts.map +1 -1
  90. package/types/lib/cli/jsBom.d.ts +1 -17
  91. package/types/lib/cli/jsBom.d.ts.map +1 -1
  92. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  93. package/types/lib/cli/managedBom.d.ts.map +1 -1
  94. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  95. package/types/lib/core/boundedFile.d.ts +70 -0
  96. package/types/lib/core/boundedFile.d.ts.map +1 -0
  97. package/types/lib/core/brotliProbe.d.ts +37 -0
  98. package/types/lib/core/brotliProbe.d.ts.map +1 -0
  99. package/types/lib/core/env.d.ts +4 -0
  100. package/types/lib/core/env.d.ts.map +1 -1
  101. package/types/lib/core/fs.d.ts.map +1 -1
  102. package/types/lib/core/httpClient.d.ts +24 -0
  103. package/types/lib/core/httpClient.d.ts.map +1 -1
  104. package/types/lib/core/lockPaths.d.ts +19 -0
  105. package/types/lib/core/lockPaths.d.ts.map +1 -0
  106. package/types/lib/core/processTree.d.ts +2 -0
  107. package/types/lib/core/processTree.d.ts.map +1 -1
  108. package/types/lib/core/toolRegistry.d.ts +36 -0
  109. package/types/lib/core/toolRegistry.d.ts.map +1 -0
  110. package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
  111. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  112. package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
  113. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
  114. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  115. package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
  116. package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
  117. package/types/lib/ecosystems/gradleutils.d.ts +47 -13
  118. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  119. package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
  120. package/types/lib/ecosystems/parsers-js.d.ts +17 -20
  121. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  122. package/types/lib/ecosystems/parsers-misc.d.ts +19 -0
  123. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  124. package/types/lib/ecosystems/parsers-python.d.ts +10 -0
  125. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  126. package/types/lib/ecosystems/pythonutils.d.ts +20 -2
  127. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  128. package/types/lib/ecosystems/rubyutils.d.ts +144 -12
  129. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  130. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  131. package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
  132. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
  133. package/types/lib/ecosystems/venvProbe.d.ts +40 -0
  134. package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
  135. package/types/lib/evinser/evinser.d.ts +63 -8
  136. package/types/lib/evinser/evinser.d.ts.map +1 -1
  137. package/types/lib/inventory/atomUtils.d.ts +19 -0
  138. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  139. package/types/lib/inventory/brotliDecoder.d.ts +58 -0
  140. package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
  141. package/types/lib/inventory/cbomutils.d.ts +17 -0
  142. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  143. package/types/lib/inventory/deps.d.ts.map +1 -1
  144. package/types/lib/inventory/dosai.d.ts +17 -1
  145. package/types/lib/inventory/dosai.d.ts.map +1 -1
  146. package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
  147. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
  148. package/types/lib/inventory/envcontext.d.ts +52 -6
  149. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  150. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  151. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  152. package/types/lib/inventory/gtfobins.d.ts.map +1 -1
  153. package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
  154. package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
  155. package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
  156. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  157. package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
  158. package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
  159. package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
  160. package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
  161. package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
  162. package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
  163. package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
  164. package/types/lib/inventory/osPackageResolver.d.ts +38 -0
  165. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  166. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -1
  167. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -1
  168. package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
  169. package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
  170. package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
  171. package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
  172. package/types/lib/inventory/source.d.ts +10 -0
  173. package/types/lib/inventory/source.d.ts.map +1 -1
  174. package/types/lib/managers/binary.d.ts.map +1 -1
  175. package/types/lib/managers/docker.d.ts +12 -0
  176. package/types/lib/managers/docker.d.ts.map +1 -1
  177. package/types/lib/parsers/largeJson.d.ts +4 -3
  178. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  179. package/types/lib/server/server.d.ts.map +1 -1
  180. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  181. package/types/lib/stages/pregen/pregen.d.ts +12 -8
  182. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
@@ -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
@@ -301,6 +301,13 @@ 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
+
304
311
  /** Resolved PHP executable command (PHP_CMD env var, or "php"). */
305
312
  export const PHP_CMD = readEnvironmentVariable("PHP_CMD") || "php";
306
313
 
@@ -520,6 +527,7 @@ export const PROJECT_TYPE_ALIASES = {
520
527
  "vscode",
521
528
  "openvsx",
522
529
  "vscode-extensions",
530
+ "ide",
523
531
  "ide-extension",
524
532
  "ide-extensions",
525
533
  ],
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)) {
@@ -991,6 +992,8 @@ export function safeSpawnSync(command, args, options) {
991
992
  }
992
993
  traceLog("spawn", { command, args, ...options });
993
994
  commandsExecuted.add(command);
995
+ // The tool as invoked, before a shell invocation folds the arguments in.
996
+ const executedCommand = command;
994
997
  // Fix for DEP0190 warning
995
998
  if (options?.shell === true) {
996
999
  const joined = joinShellCommand(command, args, rawShellArgs);
@@ -1000,6 +1003,15 @@ export function safeSpawnSync(command, args, options) {
1000
1003
  }
1001
1004
  }
1002
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
+ }
1003
1015
  recordActivity({
1004
1016
  kind: activityDescriptor.kind,
1005
1017
  ...activityDescriptor.metadata,
@@ -5,6 +5,7 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
5
5
 
6
6
  import { Agent, EnvHttpProxyAgent, request as undiciRequest } from "undici";
7
7
 
8
+ import { readBrotliDecoder } from "./brotliProbe.js";
8
9
  import { registerRunReset } from "./runState.js";
9
10
 
10
11
  // Environment variables here are read with raw `process.env` rather than
@@ -208,6 +209,75 @@ export function resolveTimeout(timeout) {
208
209
  return undefined;
209
210
  }
210
211
 
212
+ let runtimeBrotliVerdict;
213
+
214
+ /**
215
+ * Set or clear the cached runtime Brotli verdict. Exported for unit tests only.
216
+ *
217
+ * @param {boolean|undefined} verdict verdict to use, or undefined to re-check
218
+ */
219
+ export function _setRuntimeBrotliVerdict(verdict) {
220
+ runtimeBrotliVerdict = verdict;
221
+ }
222
+
223
+ /**
224
+ * Whether the Brotli decoder of the runtime cdxgen runs on carries the
225
+ * CVE-2026-111497 fix, read once from the decoder itself: the node binary, or
226
+ * the shared libbrotlidec a `--shared-brotli` build (Homebrew, distro
227
+ * packages) loads; under Bun, the bun binary, which carries its own copy.
228
+ * process.versions.brotli cannot answer this, since Node.js backports the fix
229
+ * without changing it. Deno decodes Brotli in Rust, which was never affected.
230
+ *
231
+ * On Node.js cdxgen never asks for `br`, so a Brotli response comes only from
232
+ * a server that sends it unasked. When the decoder cannot be shown to be
233
+ * patched, such a body is left undecoded rather than handed to a decoder an
234
+ * attacker can write past. Bun decodes in its transport, so there the client
235
+ * stops asking for `br` instead.
236
+ *
237
+ * @param {Object} [runtime] the runtime to judge, for tests
238
+ * @returns {boolean} true when Brotli bodies may be decoded
239
+ */
240
+ export function runtimeBrotliDecoderIsPatched(runtime = undefined) {
241
+ if (!runtime && runtimeBrotliVerdict !== undefined) {
242
+ return runtimeBrotliVerdict;
243
+ }
244
+ const subject = runtime || {
245
+ isDeno: globalThis.Deno?.version?.deno !== undefined,
246
+ execPath: process.execPath,
247
+ sharedBrotli: [true, "true"].includes(
248
+ process.config?.variables?.node_shared_brotli,
249
+ ),
250
+ sharedObjects: () => process.report?.getReport?.()?.sharedObjects || [],
251
+ };
252
+ let verdict = false;
253
+ if (subject.isDeno) {
254
+ verdict = true;
255
+ } else {
256
+ const decoderFile = subject.sharedBrotli
257
+ ? subject
258
+ .sharedObjects()
259
+ .find((path) =>
260
+ `${path}`.split(/[\\/]/).pop().startsWith("libbrotlidec"),
261
+ )
262
+ : subject.execPath;
263
+ verdict = Boolean(
264
+ decoderFile &&
265
+ readBrotliDecoder(decoderFile)?.c?.blockSwitchFix === "true",
266
+ );
267
+ }
268
+ if (!runtime) {
269
+ runtimeBrotliVerdict = verdict;
270
+ if (!verdict) {
271
+ console.warn(
272
+ isBun
273
+ ? "The Brotli decoder in this Bun is not patched for CVE-2026-111497, or could not be checked, so cdxgen no longer asks servers for Brotli-encoded responses. Bun still decodes one a server sends unasked; upgrade Bun to a release with a patched Brotli."
274
+ : "The Brotli decoder of this runtime is not patched for CVE-2026-111497, or could not be checked, so Brotli-encoded responses are left undecoded. Upgrade Node.js to 22.23.4, 24.22.1 or 26.12.1 or later (and the shared brotli library on Homebrew or distro builds).",
275
+ );
276
+ }
277
+ }
278
+ return verdict;
279
+ }
280
+
211
281
  /**
212
282
  * Decompress a response body buffer according to its `Content-Encoding`. undici
213
283
  * performs no automatic decompression, so we replicate the behaviour `got` gave
@@ -235,6 +305,9 @@ function decompressBody(rawBody, contentEncoding) {
235
305
  } else if (encoding === "deflate") {
236
306
  buffer = inflateSync(buffer);
237
307
  } else if (encoding === "br") {
308
+ if (!runtimeBrotliDecoderIsPatched()) {
309
+ return buffer;
310
+ }
238
311
  buffer = brotliDecompressSync(buffer);
239
312
  } else if (encoding === "identity") {
240
313
  // no-op
@@ -296,6 +369,30 @@ async function readBody(undiciResponse, responseType) {
296
369
  return { body, rawBody };
297
370
  }
298
371
 
372
+ /**
373
+ * The headers to put on the wire. Bun's bundled undici asks for br by default
374
+ * and decodes it with Bun's own decoder before this client sees the body, so
375
+ * the check in decompressBody never runs there. Where that decoder is not
376
+ * patched, ask only for the encodings this client decodes itself. A server
377
+ * that sends br unasked is still decoded by Bun; only upgrading Bun closes
378
+ * that. The cache key is built from the caller's headers, not these.
379
+ *
380
+ * @param {Object} headers Request headers.
381
+ * @returns {Object} Headers to send.
382
+ */
383
+ function transportHeaders(headers) {
384
+ if (
385
+ !isBun ||
386
+ Object.keys(headers).some(
387
+ (key) => key.toLowerCase() === "accept-encoding",
388
+ ) ||
389
+ runtimeBrotliDecoderIsPatched()
390
+ ) {
391
+ return headers;
392
+ }
393
+ return { ...headers, "accept-encoding": "gzip, deflate" };
394
+ }
395
+
299
396
  /**
300
397
  * Convert a body into the `[body, headers]` pair to hand to undici. When the
301
398
  * `json` option is present it is serialized and a JSON `content-type` is added
@@ -752,7 +849,7 @@ async function doRequest(mergedOptions) {
752
849
  const insecure = options.https?.rejectUnauthorized === false;
753
850
  const requestOptions = {
754
851
  method,
755
- headers,
852
+ headers: transportHeaders(headers),
756
853
  body: method === "GET" || method === "HEAD" ? undefined : body,
757
854
  dispatcher: selectDispatcher(insecure),
758
855
  signal,
@@ -0,0 +1,38 @@
1
+ import { isAbsolute, relative, resolve, win32 } from "node:path";
2
+
3
+ /**
4
+ * Containment check for a path taken from a lockfile (issue #3816). A crafted
5
+ * npm lockfile can name `file:`/`link:` specifiers or link targets built from
6
+ * `..` chains or absolute paths, so a parser that is about to list such an
7
+ * entry checks the resolved path against the project root first.
8
+ *
9
+ * The candidate is resolved against `projectRoot`, so both relative values
10
+ * and the absolute paths arborist already normalised are accepted. The root
11
+ * itself counts as inside: a lockfile may legitimately link the project to
12
+ * its own directory.
13
+ *
14
+ * @param {string} candidate Path to check, absolute or relative to projectRoot
15
+ * @param {string} projectRoot Project root the path must stay inside; a falsy
16
+ * root means the current working directory, mirroring `node:path`
17
+ *
18
+ * @returns {Boolean} True when the path is inside projectRoot
19
+ */
20
+ export function isPathInsideProject(candidate, projectRoot) {
21
+ if (typeof candidate !== "string" || !candidate) {
22
+ return false;
23
+ }
24
+ if (candidate.includes("\0")) {
25
+ return false;
26
+ }
27
+ const rootAbs = resolve(projectRoot || process.cwd());
28
+ const rel = relative(rootAbs, resolve(rootAbs, candidate));
29
+ if (!rel) {
30
+ return true;
31
+ }
32
+ // relative() returns an absolute path when the two sit on different
33
+ // Windows drives.
34
+ if (isAbsolute(rel) || win32.isAbsolute(rel)) {
35
+ return false;
36
+ }
37
+ return rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\");
38
+ }
package/lib/core/paths.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * `https://` URL, and `fileURLToPath` throws `ERR_INVALID_URL_SCHEME` on those.
15
15
  */
16
16
 
17
- import { platform } from "node:os";
17
+ import { homedir, platform } from "node:os";
18
18
  import { dirname } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
@@ -68,3 +68,26 @@ export function isValidDriveRoot(root) {
68
68
  export function toCamel(str) {
69
69
  return str.replace(/-([a-z])/g, (_, g) => g.toUpperCase());
70
70
  }
71
+
72
+ /**
73
+ * Rewrite an absolute path under the user's home directory to a `~`-relative
74
+ * form so the emitted BOM does not carry the local username.
75
+ *
76
+ * @param {string} filePath Absolute path
77
+ * @param {string} [home] Home directory, defaulting to the current user's
78
+ * @returns {string} Home-relative path when applicable, else the input
79
+ */
80
+ export function redactHomePath(filePath, home = homedir()) {
81
+ const value = String(filePath || "");
82
+ if (!home || !value.startsWith(home)) {
83
+ return value;
84
+ }
85
+ // Require a path boundary so a sibling home such as /Users/alice-backup is
86
+ // not rewritten as if it sat under /Users/alice.
87
+ const remainder = value.slice(home.length);
88
+ if (remainder && !/^[/\\]/u.test(remainder)) {
89
+ return value;
90
+ }
91
+ const rest = remainder.replace(/^[/\\]/u, "");
92
+ return rest ? `~/${rest.replaceAll("\\", "/")}` : "~";
93
+ }
@@ -37,6 +37,8 @@ export function formatDuration(ms) {
37
37
  * its end: once the root is gone its pid can be handed to an unrelated
38
38
  * process, whose children would otherwise look like the run's. Elsewhere this
39
39
  * does nothing: an orphan is re-parented and can no longer be told apart.
40
+ * It returns once the stopped processes have exited, or after ten seconds,
41
+ * so the caller can remove files the run left half-written.
40
42
  *
41
43
  * @param {number|undefined} rootPid pid of the process cdxgen spawned
42
44
  * @param {number} startedAt Epoch milliseconds at which the run started
@@ -77,6 +79,9 @@ export function reapProcessTree(
77
79
  " }",
78
80
  "}",
79
81
  "foreach ($child in $found) { Stop-Process -Id $child -Force -ErrorAction SilentlyContinue }",
82
+ // Stop-Process returns before the process exits, and a process that is
83
+ // still exiting keeps its files open, so wait for the exits.
84
+ "if ($found.Count) { Wait-Process -Id $found.ToArray() -Timeout 10 -ErrorAction SilentlyContinue }",
80
85
  "$found -join ','",
81
86
  ].join("\n");
82
87
  const result = safeSpawnSync(
@@ -0,0 +1,170 @@
1
+ import { basename } from "node:path";
2
+
3
+ import { registerRunReset } from "./runState.js";
4
+
5
+ /**
6
+ * Run-scoped registry of the external tools cdxgen actually executed, feeding
7
+ * the `metadata.tools` section (issue #598). `safeSpawnSync` records a command
8
+ * once the process has started, so a binary that is missing or a spawn that
9
+ * was blocked never appears, and the stages that already know a tool's
10
+ * version - such as the JVM tool resolution in pregen - enrich the entry; no
11
+ * process is ever spawned just to learn a version.
12
+ *
13
+ * The registry is filled in as the run progresses and read once per BOM when
14
+ * the metadata section is assembled. A run that spawns nothing external keeps
15
+ * it empty, which is what keeps such BOMs byte-identical to the previous
16
+ * behaviour. It is per-run state: a long-lived process such as the server
17
+ * empties it between scans through {@link resetRunState}.
18
+ *
19
+ * @type {Map<string, {name: string, version: (string|undefined)}>}
20
+ */
21
+ const EXECUTED_TOOLS = new Map();
22
+
23
+ /**
24
+ * Versions stages already resolved for a tool, keyed like EXECUTED_TOOLS. They
25
+ * are applied only to tools that actually started, so provisioning a tool the
26
+ * run then never invokes adds nothing to `metadata.tools`.
27
+ *
28
+ * @type {Map<string, string>}
29
+ */
30
+ const KNOWN_VERSIONS = new Map();
31
+
32
+ /**
33
+ * Interpreter and shell binaries that wrap the real work. They appear on
34
+ * nearly every spawn, so recording them would drown the tools that actually
35
+ * generated the BOM.
36
+ *
37
+ * @type {Set<string>}
38
+ */
39
+ const WRAPPER_BASENAMES = new Set([
40
+ "bash",
41
+ "cmd",
42
+ "cmd.exe",
43
+ "node",
44
+ "powershell",
45
+ "powershell.exe",
46
+ "pwsh",
47
+ "sh",
48
+ "zsh",
49
+ ]);
50
+
51
+ /**
52
+ * Strip a trailing Windows executable suffix from a binary name.
53
+ *
54
+ * @param {string} name Binary name
55
+ * @returns {string} Name without a trailing `.exe`, `.bat`, or `.cmd`
56
+ */
57
+ function trimWindowsSuffix(name) {
58
+ for (const suffix of [".exe", ".bat", ".cmd"]) {
59
+ if (name.endsWith(suffix)) {
60
+ return name.slice(0, -suffix.length);
61
+ }
62
+ }
63
+ return name;
64
+ }
65
+
66
+ /**
67
+ * Name a tool the way the registry keys it: the basename without a Windows
68
+ * executable suffix, and without the `-cdxgen-<os>-<arch>` suffix of a
69
+ * bundled cdxgen-plugins-bin helper, so `trivy-cdxgen-linux-amd64` is `trivy`
70
+ * on every platform and matches its plugin manifest entry.
71
+ *
72
+ * @param {string} name Tool name as invoked (a bare name or a path)
73
+ * @returns {string} Tool name
74
+ */
75
+ function toolNameOf(name) {
76
+ const toolName = trimWindowsSuffix(basename(`${name}`.split("\\").join("/")));
77
+ const pluginSuffixAt = toolName.lastIndexOf("-cdxgen-");
78
+ if (
79
+ pluginSuffixAt > 0 &&
80
+ toolName.slice(pluginSuffixAt + "-cdxgen-".length).split("-").length === 2
81
+ ) {
82
+ return toolName.slice(0, pluginSuffixAt);
83
+ }
84
+ return toolName;
85
+ }
86
+
87
+ /**
88
+ * Record an external tool cdxgen executed. The first version seen wins unless
89
+ * it was unknown, so an enriched probe never overwrites a concrete version
90
+ * with `undefined` and a later concrete version fills an earlier gap.
91
+ *
92
+ * @param {string} name Tool name as invoked (a bare name or a path)
93
+ * @param {string|undefined} version Version when cheaply known
94
+ */
95
+ export function recordExecutedTool(name, version) {
96
+ if (typeof name !== "string" || !name) {
97
+ return;
98
+ }
99
+ const toolName = toolNameOf(name);
100
+ if (!toolName || WRAPPER_BASENAMES.has(toolName)) {
101
+ return;
102
+ }
103
+ const key = toolName.toLowerCase();
104
+ const existing = EXECUTED_TOOLS.get(key);
105
+ if (!existing) {
106
+ EXECUTED_TOOLS.set(key, {
107
+ name: toolName,
108
+ version: typeof version === "string" && version ? version : undefined,
109
+ });
110
+ return;
111
+ }
112
+ if (typeof version === "string" && version && !existing.version) {
113
+ existing.version = version;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Remember the version a stage already resolved for a tool, such as the JVM
119
+ * build tool pregen provisioned. The version shows in `metadata.tools` only if
120
+ * the tool is executed; the first version recorded wins.
121
+ *
122
+ * @param {string} name Tool name as it will be invoked (a bare name or a path)
123
+ * @param {string} version Resolved version
124
+ */
125
+ export function recordToolVersion(name, version) {
126
+ if (typeof name !== "string" || !name) {
127
+ return;
128
+ }
129
+ if (typeof version !== "string" || !version) {
130
+ return;
131
+ }
132
+ const key = toolNameOf(name).toLowerCase();
133
+ if (key && !KNOWN_VERSIONS.has(key)) {
134
+ KNOWN_VERSIONS.set(key, version);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Snapshot of the tools recorded so far, sorted by name so the assembled
140
+ * `metadata.tools` diff stays stable between runs.
141
+ *
142
+ * @returns {Array<{name: string, version: (string|undefined)}>} Sorted tool records
143
+ */
144
+ export function getExecutedTools() {
145
+ return [...EXECUTED_TOOLS.entries()]
146
+ .map(([key, tool]) => ({
147
+ name: tool.name,
148
+ version: tool.version || KNOWN_VERSIONS.get(key),
149
+ }))
150
+ .sort((a, b) =>
151
+ a.name === b.name
152
+ ? 0
153
+ : a.name.toLowerCase() < b.name.toLowerCase()
154
+ ? -1
155
+ : 1,
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Clear the registry. The server calls it between scans through the run-state
161
+ * reset; tests call it directly.
162
+ *
163
+ * @returns {void}
164
+ */
165
+ export function resetExecutedTools() {
166
+ EXECUTED_TOOLS.clear();
167
+ KNOWN_VERSIONS.clear();
168
+ }
169
+
170
+ registerRunReset("toolRegistry.executedTools", resetExecutedTools);