@cdxgen/cdxgen 13.2.0 → 13.3.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 (158) hide show
  1. package/README.md +28 -12
  2. package/bin/cdxgen.js +227 -119
  3. package/bin/evinse.js +5 -0
  4. package/bin/sign.js +88 -14
  5. package/bin/tracebom.js +64 -4
  6. package/bin/validate.js +29 -7
  7. package/bin/verify.js +79 -39
  8. package/data/remediations.json +24 -0
  9. package/lib/cli/bomAssembly.js +105 -15
  10. package/lib/cli/cliOptions.js +93 -1
  11. package/lib/cli/index.js +43 -4
  12. package/lib/cli/jsBom.js +49 -24
  13. package/lib/cli/jvmBom.js +46 -12
  14. package/lib/cli/managedBom.js +112 -41
  15. package/lib/cli/nativeBom.js +125 -14
  16. package/lib/core/env.js +5 -6
  17. package/lib/core/fs.js +174 -6
  18. package/lib/core/metadataProperties.js +97 -0
  19. package/lib/core/projectConfig.js +31 -4
  20. package/lib/ecosystems/bunutils.js +269 -101
  21. package/lib/ecosystems/caxa.js +434 -2
  22. package/lib/ecosystems/cmakeResolver.js +66 -0
  23. package/lib/ecosystems/cppEvidence.js +64 -31
  24. package/lib/ecosystems/ecosystems.js +19 -8
  25. package/lib/ecosystems/gradleutils.js +42 -7
  26. package/lib/ecosystems/jsEvidence.js +19 -20
  27. package/lib/ecosystems/kosi.js +10 -3
  28. package/lib/ecosystems/npmutils.js +110 -9
  29. package/lib/ecosystems/parsers-dotnet.js +51 -10
  30. package/lib/ecosystems/parsers-go.js +47 -9
  31. package/lib/ecosystems/parsers-js.js +171 -50
  32. package/lib/ecosystems/parsers-misc.js +747 -161
  33. package/lib/ecosystems/parsers-python.js +26 -14
  34. package/lib/ecosystems/parsers-rust.js +24 -18
  35. package/lib/ecosystems/rubyutils.js +1426 -166
  36. package/lib/ecosystems/rusi.js +32 -1
  37. package/lib/evinser/evinser.js +436 -128
  38. package/lib/evinser/swiftsem.js +1914 -302
  39. package/lib/helpers/bomSigner.js +652 -221
  40. package/lib/inventory/atomUtils.js +568 -31
  41. package/lib/inventory/cdxrs.js +103 -12
  42. package/lib/inventory/depsUtils.js +183 -11
  43. package/lib/inventory/display.js +4 -5
  44. package/lib/inventory/dosai.js +550 -13
  45. package/lib/inventory/dynamic.js +39 -1
  46. package/lib/inventory/evidenceUtils.js +14 -6
  47. package/lib/inventory/fetchBatch.js +116 -17
  48. package/lib/inventory/protobom.js +8 -1
  49. package/lib/inventory/purl.js +152 -3
  50. package/lib/inventory/sbomDocument.js +2 -70
  51. package/lib/inventory/source.js +37 -19
  52. package/lib/inventory/traceRunner.js +210 -21
  53. package/lib/managers/binary.js +115 -24
  54. package/lib/managers/docker.js +440 -20
  55. package/lib/managers/oci.js +39 -1
  56. package/lib/parsers/cmakeLists.js +452 -0
  57. package/lib/parsers/largeJson.js +454 -0
  58. package/lib/parsers/npmrc.js +19 -1
  59. package/lib/stages/postgen/postgen.js +68 -15
  60. package/lib/validator/bomValidator.js +6 -16
  61. package/lib/validator/index.js +48 -10
  62. package/lib/validator/reporters/console.js +3 -0
  63. package/lib/validator/reporters/json.js +1 -0
  64. package/lib/validator/reporters/sarif.js +7 -0
  65. package/package.json +20 -20
  66. package/types/lib/cli/bomAssembly.d.ts +2 -1
  67. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  68. package/types/lib/cli/cliOptions.d.ts +33 -1
  69. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  70. package/types/lib/cli/index.d.ts.map +1 -1
  71. package/types/lib/cli/jsBom.d.ts.map +1 -1
  72. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  73. package/types/lib/cli/managedBom.d.ts.map +1 -1
  74. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  75. package/types/lib/core/env.d.ts.map +1 -1
  76. package/types/lib/core/fs.d.ts +55 -0
  77. package/types/lib/core/fs.d.ts.map +1 -1
  78. package/types/lib/core/metadataProperties.d.ts +39 -0
  79. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  80. package/types/lib/core/projectConfig.d.ts +6 -2
  81. package/types/lib/core/projectConfig.d.ts.map +1 -1
  82. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  83. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  84. package/types/lib/ecosystems/caxa.d.ts +14 -0
  85. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  86. package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
  87. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  88. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  89. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  90. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  91. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  92. package/types/lib/ecosystems/gradleutils.d.ts +20 -4
  93. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  94. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  95. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  96. package/types/lib/ecosystems/kosi.d.ts +13 -0
  97. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  98. package/types/lib/ecosystems/npmutils.d.ts +42 -0
  99. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  100. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  101. package/types/lib/ecosystems/parsers-go.d.ts +10 -0
  102. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  103. package/types/lib/ecosystems/parsers-js.d.ts +10 -4
  104. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  105. package/types/lib/ecosystems/parsers-misc.d.ts +67 -3
  106. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  107. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  108. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  109. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  110. package/types/lib/ecosystems/rubyutils.d.ts +143 -2
  111. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  112. package/types/lib/ecosystems/rusi.d.ts +13 -0
  113. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  114. package/types/lib/evinser/evinser.d.ts +59 -9
  115. package/types/lib/evinser/evinser.d.ts.map +1 -1
  116. package/types/lib/evinser/swiftsem.d.ts +280 -15
  117. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  118. package/types/lib/helpers/bomSigner.d.ts +106 -13
  119. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  120. package/types/lib/inventory/atomUtils.d.ts +145 -3
  121. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  122. package/types/lib/inventory/cdxrs.d.ts +8 -3
  123. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  124. package/types/lib/inventory/depsUtils.d.ts +15 -0
  125. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  126. package/types/lib/inventory/display.d.ts.map +1 -1
  127. package/types/lib/inventory/dosai.d.ts +64 -1
  128. package/types/lib/inventory/dosai.d.ts.map +1 -1
  129. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  130. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  131. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  132. package/types/lib/inventory/plugins.d.ts.map +1 -1
  133. package/types/lib/inventory/protobom.d.ts.map +1 -1
  134. package/types/lib/inventory/purl.d.ts +71 -2
  135. package/types/lib/inventory/purl.d.ts.map +1 -1
  136. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  137. package/types/lib/inventory/source.d.ts.map +1 -1
  138. package/types/lib/inventory/traceRunner.d.ts +47 -1
  139. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  140. package/types/lib/managers/binary.d.ts +30 -1
  141. package/types/lib/managers/binary.d.ts.map +1 -1
  142. package/types/lib/managers/docker.d.ts +29 -0
  143. package/types/lib/managers/docker.d.ts.map +1 -1
  144. package/types/lib/managers/oci.d.ts +14 -0
  145. package/types/lib/managers/oci.d.ts.map +1 -1
  146. package/types/lib/parsers/cmakeLists.d.ts +76 -0
  147. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  148. package/types/lib/parsers/largeJson.d.ts +52 -0
  149. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  150. package/types/lib/parsers/npmrc.d.ts +5 -0
  151. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  152. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  153. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  154. package/types/lib/validator/index.d.ts +10 -1
  155. package/types/lib/validator/index.d.ts.map +1 -1
  156. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  157. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  158. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
@@ -6,7 +6,14 @@ import {
6
6
  readFileSync,
7
7
  } from "node:fs";
8
8
  import { platform as _platform, userInfo as _userInfo, homedir } from "node:os";
9
- import { basename, join, resolve, win32 } from "node:path";
9
+ import {
10
+ basename,
11
+ isAbsolute,
12
+ join,
13
+ relative,
14
+ resolve,
15
+ win32,
16
+ } from "node:path";
10
17
  import process from "node:process";
11
18
  import stream from "node:stream/promises";
12
19
  import { URL } from "node:url";
@@ -293,6 +300,34 @@ let isWinLocalTLS = false;
293
300
  let isNerdctl;
294
301
  let isColima;
295
302
 
303
+ /**
304
+ * WSL Containers CLI (`wslc`), generally available with WSL 3.x. wslc accepts
305
+ * the exact docker CLI invocations cdxgen makes on Windows (`images
306
+ * --format=json`, `pull`, `inspect`, `save -o`), so it serves as a drop-in
307
+ * container CLI on Windows hosts that have WSL but no Docker installation.
308
+ *
309
+ * @see https://learn.microsoft.com/en-us/windows/wsl/wsl-container
310
+ */
311
+ const WSLC_CLI = "wslc";
312
+ let dockerCliInstalled;
313
+ let wslcInstalled;
314
+ // wslc reports container session startup failures (missing Virtual Machine
315
+ // Platform feature, no nested virtualization when Windows itself is a VM)
316
+ // with these markers on stderr.
317
+ const WSLC_SESSION_FAILURE_RE =
318
+ /HCS_E_HYPERV_NOT_INSTALLED|Virtual Machine Platform|WSL2 is unable to start/i;
319
+ const WSLC_SESSION_FAILURE_HINT =
320
+ "The wslc container session could not start. Ensure the 'Virtual Machine Platform' optional component is enabled by running 'wsl --install --no-distribution' with a subsequent reboot, and that virtualization is enabled in the firmware settings. Nested virtualization is required when Windows itself runs inside a VM.";
321
+ const WSLC_ALTERNATIVE_HINT =
322
+ "wslc (WSL Containers) is installed on this host. Set the environment variable DOCKER_CMD=wslc to pull and export Linux images through WSL, for example when Docker Desktop is not running or is switched to Windows containers.";
323
+ // Container spec files (compose, Kubernetes manifests) make cdxgen export one
324
+ // image after another in the same process, and every image fails the same way
325
+ // on a host with a broken container setup. Print each hint and log the CLI
326
+ // selection only once per process.
327
+ let wslcSessionFailureHintShown = false;
328
+ let wslcAlternativeHintShown = false;
329
+ let wslcSelectionRecorded = false;
330
+
296
331
  if (
297
332
  !readEnvironmentVariable("DOCKER_HOST") &&
298
333
  (readEnvironmentVariable("CONTAINERD_ADDRESS") ||
@@ -898,6 +933,154 @@ export const makeRequest = async (path, method, forRegistry) => {
898
933
  return await client.request(path, opts);
899
934
  };
900
935
 
936
+ const IMAGE_TAG_RE = /^[\w][\w.-]{0,127}$/;
937
+ const IMAGE_DIGEST_ALGO_RE = /^[a-z0-9]+(?:[.+_-][a-z0-9]+)*$/;
938
+ const IMAGE_DIGEST_HEX_RE = /^[a-f0-9]{32,}$/;
939
+ const IMAGE_COMPONENT_RE = /^[a-z0-9]+(?:[._-]+[a-z0-9]+)*$/;
940
+ const IMAGE_HOST_PORT_RE = /^\d{1,5}$/;
941
+ // A single label with a bounded quantifier and no alternation: checking hosts
942
+ // label by label keeps the cost linear on hostile inputs, which the equivalent
943
+ // whole-host pattern cannot promise (its nested quantifiers backtrack
944
+ // exponentially on strings such as "0.0" followed by many repetitions of "0").
945
+ const IMAGE_HOST_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
946
+
947
+ /**
948
+ * Validate the registry host of an image reference: a dotted name, `localhost`,
949
+ * or a bare name with a numeric port, each label 1-63 characters of letters,
950
+ * digits and inner hyphens.
951
+ *
952
+ * @param {string} host Host component without the trailing slash.
953
+ * @returns {boolean} true when the host is well-formed.
954
+ */
955
+ const isValidImageHost = (host) => {
956
+ let name = host;
957
+ let hasPort = false;
958
+ const colonIndex = name.lastIndexOf(":");
959
+ if (colonIndex !== -1) {
960
+ const port = name.slice(colonIndex + 1);
961
+ if (!IMAGE_HOST_PORT_RE.test(port)) {
962
+ return false;
963
+ }
964
+ name = name.slice(0, colonIndex);
965
+ hasPort = true;
966
+ }
967
+ if (name === "localhost") {
968
+ return true;
969
+ }
970
+ if (!name.length || name.length > 253) {
971
+ return false;
972
+ }
973
+ const labels = name.split(".");
974
+ if (labels.length < 2 && !hasPort) {
975
+ return false;
976
+ }
977
+ return labels.every((label) => IMAGE_HOST_LABEL_RE.test(label));
978
+ };
979
+
980
+ /**
981
+ * Decide whether a CLI path argument is a container image reference in the
982
+ * styles the container pipeline documents: name, name:tag, namespace/name,
983
+ * registry/namespace/name:tag, registry:port/..., and any of those with a
984
+ * @digest. Everything that smells like a filesystem path, a URL or a CLI
985
+ * option never matches, so strings such as "c:\Users", "c:/tmp" or
986
+ * "file:///etc" can never reach the container clients: backslashes and
987
+ * schemes are rejected outright, and a drive-letter prefix fails because a
988
+ * lone letter is neither a dotted host nor a host:port, and a tag may not
989
+ * begin with the "/" that follows the colon in "c:/tmp".
990
+ *
991
+ * The check mirrors how the distribution reference grammar detects a registry
992
+ * host: the first component counts as a host only when it carries a dot, a
993
+ * numeric port, or is exactly localhost. Each piece is validated by a linear
994
+ * regex or plain string splitting, and the input is length-capped, so hostile
995
+ * inputs cannot backtrack expensively.
996
+ *
997
+ * @param {string} fullImageName Candidate image reference
998
+ * @returns {boolean} true when the string is a plausible image reference
999
+ */
1000
+ export const isImageReference = (fullImageName) => {
1001
+ if (typeof fullImageName !== "string") {
1002
+ return false;
1003
+ }
1004
+ const ref = fullImageName.trim().toLowerCase();
1005
+ if (!ref.length || ref.length > 1024) {
1006
+ return false;
1007
+ }
1008
+ if (
1009
+ ref.includes("\\") ||
1010
+ ref.includes("://") ||
1011
+ ref.startsWith("/") ||
1012
+ ref.startsWith("./") ||
1013
+ ref.startsWith("../") ||
1014
+ ref.startsWith("-") ||
1015
+ ref.includes(" ") ||
1016
+ /^[a-z]:/.test(ref)
1017
+ ) {
1018
+ return false;
1019
+ }
1020
+ let remainder = ref;
1021
+ const atIndex = remainder.lastIndexOf("@");
1022
+ if (atIndex !== -1) {
1023
+ const digest = remainder.slice(atIndex + 1);
1024
+ remainder = remainder.slice(0, atIndex);
1025
+ if (remainder.includes("@") || !remainder.length) {
1026
+ return false;
1027
+ }
1028
+ const digestColonIndex = digest.indexOf(":");
1029
+ if (
1030
+ digestColonIndex <= 0 ||
1031
+ digestColonIndex === digest.length - 1 ||
1032
+ !IMAGE_DIGEST_ALGO_RE.test(digest.slice(0, digestColonIndex)) ||
1033
+ !IMAGE_DIGEST_HEX_RE.test(digest.slice(digestColonIndex + 1))
1034
+ ) {
1035
+ return false;
1036
+ }
1037
+ }
1038
+ const slashIndex = remainder.lastIndexOf("/");
1039
+ const colonIndex = remainder.lastIndexOf(":");
1040
+ if (colonIndex > slashIndex) {
1041
+ const tag = remainder.slice(colonIndex + 1);
1042
+ remainder = remainder.slice(0, colonIndex);
1043
+ if (!IMAGE_TAG_RE.test(tag)) {
1044
+ return false;
1045
+ }
1046
+ }
1047
+ if (!remainder.length) {
1048
+ return false;
1049
+ }
1050
+ const components = remainder.split("/");
1051
+ if (components.length > 1) {
1052
+ const host = components[0];
1053
+ const looksLikeHost =
1054
+ host.includes(".") || host.includes(":") || host === "localhost";
1055
+ if (looksLikeHost) {
1056
+ if (!isValidImageHost(host)) {
1057
+ return false;
1058
+ }
1059
+ components.shift();
1060
+ }
1061
+ }
1062
+ return components.every(
1063
+ (component) =>
1064
+ component.length > 0 &&
1065
+ component.length <= 255 &&
1066
+ IMAGE_COMPONENT_RE.test(component),
1067
+ );
1068
+ };
1069
+
1070
+ /**
1071
+ * Whether a string can be handed to the container CLI or the daemon API as an
1072
+ * image reference. The exact string is checked, not a trimmed copy, so what
1073
+ * reaches `pull`, `save` or `inspect` is a well-formed reference and never an
1074
+ * option (such as `-o file`), a path or a URL.
1075
+ *
1076
+ * @param {string} fullImageName Image reference as received
1077
+ * @returns {boolean} true when the string is safe to use as an image argument
1078
+ */
1079
+ const isSafeImageReference = (fullImageName) =>
1080
+ typeof fullImageName === "string" &&
1081
+ fullImageName === fullImageName.trim() &&
1082
+ isImageReference(fullImageName);
1083
+
901
1084
  /**
902
1085
  * Parse image name
903
1086
  *
@@ -983,19 +1166,129 @@ export const parseImageName = (fullImageName) => {
983
1166
  return nameObj;
984
1167
  };
985
1168
 
1169
+ /**
1170
+ * Detect whether the docker CLI binary is installed. `--version` runs without
1171
+ * a daemon, so the probe is safe on hosts where Docker is installed but not
1172
+ * running. The result is cached for the lifetime of the process.
1173
+ *
1174
+ * @returns {boolean} true when the docker CLI can be invoked
1175
+ */
1176
+ const isDockerCliInstalled = () => {
1177
+ if (dockerCliInstalled === undefined) {
1178
+ const result = safeSpawnSync("docker", ["--version"]);
1179
+ dockerCliInstalled = !result.error && result.status === 0;
1180
+ }
1181
+ return dockerCliInstalled;
1182
+ };
1183
+
1184
+ /**
1185
+ * Detect whether the WSL Containers CLI (`wslc`) is installed. `wslc version`
1186
+ * is answered by the CLI itself without starting a WSL container session, so
1187
+ * it succeeds even on hosts where nested virtualization is unavailable.
1188
+ * The result is cached for the lifetime of the process.
1189
+ *
1190
+ * @returns {boolean} true when the wslc CLI can be invoked
1191
+ */
1192
+ const isWslcInstalled = () => {
1193
+ if (wslcInstalled === undefined) {
1194
+ const result = safeSpawnSync(WSLC_CLI, ["version"]);
1195
+ wslcInstalled = !result.error && result.status === 0;
1196
+ }
1197
+ return wslcInstalled;
1198
+ };
1199
+
1200
+ /**
1201
+ * Recognise wslc failures caused by the WSL container session being unable to
1202
+ * start, as opposed to image-specific errors such as an unknown repository.
1203
+ *
1204
+ * @param {string|Buffer} [stderr] wslc command output to inspect
1205
+ * @returns {boolean} true when the output points to a session startup failure
1206
+ */
1207
+ const isWslcSessionFailure = (stderr) =>
1208
+ !!stderr && WSLC_SESSION_FAILURE_RE.test(Buffer.from(stderr).toString());
1209
+
1210
+ /**
1211
+ * Recognise the wslc CLI however it is named, since `DOCKER_CMD` may hold
1212
+ * `wslc`, `wslc.exe`, or a full path such as `C:\Program Files\WSL\wslc.exe`.
1213
+ *
1214
+ * @param {string} [cmd] Container CLI command
1215
+ * @returns {boolean} true when the command invokes wslc
1216
+ */
1217
+ const isWslcCmd = (cmd) => {
1218
+ const cmdName = win32.basename(`${cmd || ""}`).toLowerCase();
1219
+ return cmdName === WSLC_CLI || cmdName === `${WSLC_CLI}.exe`;
1220
+ };
1221
+
1222
+ /**
1223
+ * Print the wslc session-failure hint once per process. Every wslc invocation
1224
+ * fails the same way when the WSL container session cannot start, so repeating
1225
+ * the hint for each image would only add noise.
1226
+ */
1227
+ const printWslcSessionFailureHint = () => {
1228
+ if (!wslcSessionFailureHintShown) {
1229
+ wslcSessionFailureHintShown = true;
1230
+ console.log(WSLC_SESSION_FAILURE_HINT);
1231
+ }
1232
+ };
1233
+
1234
+ /**
1235
+ * Point at wslc when the auto-selected docker CLI could not provide an image on
1236
+ * a Windows host that also has wslc. cdxgen never switches to wslc on its own
1237
+ * here: wslc keeps its own image store, so a silent switch could scan a
1238
+ * registry image instead of a locally built image with the same name.
1239
+ *
1240
+ * @param {string} dockerCmd Container CLI that failed
1241
+ */
1242
+ const suggestWslcAlternative = (dockerCmd) => {
1243
+ if (
1244
+ wslcAlternativeHintShown ||
1245
+ !isWin ||
1246
+ dockerCmd !== "docker" ||
1247
+ readEnvironmentVariable("DOCKER_CMD")?.trim() ||
1248
+ !isWslcInstalled()
1249
+ ) {
1250
+ return;
1251
+ }
1252
+ wslcAlternativeHintShown = true;
1253
+ console.log(WSLC_ALTERNATIVE_HINT);
1254
+ };
1255
+
986
1256
  /**
987
1257
  * Prefer cli on windows, nerdctl on mac, or when using tcp/ssh based host.
1258
+ * On Windows, fall back to wslc (WSL Containers) when the docker CLI is not
1259
+ * installed, so OCI image scans work on hosts that only have WSL.
988
1260
  *
989
- * @returns boolean true if we should use the cli. false otherwise
1261
+ * @returns {string} Container CLI command to invoke
990
1262
  */
991
- const getContainerCliCmd = () => {
992
- if (readEnvironmentVariable("DOCKER_CMD")?.trim()) {
993
- return readEnvironmentVariable("DOCKER_CMD").trim();
1263
+ export const getContainerCliCmd = () => {
1264
+ const configuredCli = readEnvironmentVariable("DOCKER_CMD")?.trim();
1265
+ if (configuredCli) {
1266
+ return configuredCli;
994
1267
  }
995
1268
  detectRancherDesktop() || detectColima();
996
1269
  if (isNerdctl) {
997
1270
  return "nerdctl";
998
1271
  }
1272
+ if (isWin && !isDockerCliInstalled() && isWslcInstalled()) {
1273
+ if (!wslcSelectionRecorded) {
1274
+ wslcSelectionRecorded = true;
1275
+ recordDecisionActivity(`container-cli:${WSLC_CLI}`, {
1276
+ metadata: {
1277
+ decisionType: "container-cli-selection",
1278
+ evaluatedClis: ["docker:not-installed", `${WSLC_CLI}:installed`],
1279
+ selectedCli: WSLC_CLI,
1280
+ },
1281
+ reason:
1282
+ "Selected wslc (WSL Containers) for image operations because the docker CLI is not installed.",
1283
+ });
1284
+ if (DEBUG_MODE) {
1285
+ console.log(
1286
+ "Using wslc (WSL Containers) as the container CLI since docker was not found.",
1287
+ );
1288
+ }
1289
+ }
1290
+ return WSLC_CLI;
1291
+ }
999
1292
  return "docker";
1000
1293
  };
1001
1294
 
@@ -1019,6 +1312,10 @@ const needsCliFallback = () => {
1019
1312
  * Method to get image to the local registry by pulling from the remote if required
1020
1313
  */
1021
1314
  export const getImage = async (fullImageName) => {
1315
+ if (!isSafeImageReference(fullImageName)) {
1316
+ console.log(`${fullImageName} is not a valid container image reference.`);
1317
+ return undefined;
1318
+ }
1022
1319
  let localData;
1023
1320
  let pullData;
1024
1321
  const { registry, repo, tag, digest } = parseImageName(fullImageName);
@@ -1036,8 +1333,9 @@ export const getImage = async (fullImageName) => {
1036
1333
  );
1037
1334
  return undefined;
1038
1335
  }
1336
+ let dockerCmd;
1039
1337
  if (needsCliFallback()) {
1040
- const dockerCmd = getContainerCliCmd();
1338
+ dockerCmd = getContainerCliCmd();
1041
1339
  let needsPull = true;
1042
1340
  // Let's check the local cache first
1043
1341
  let result = safeSpawnSync(dockerCmd, ["images", "--format=json"]);
@@ -1057,7 +1355,16 @@ export const getImage = async (fullImageName) => {
1057
1355
  if (needsPull) {
1058
1356
  result = safeSpawnSync(dockerCmd, ["pull", fullImageName]);
1059
1357
  if (result.status !== 0 || result.error) {
1060
- if (result.stderr?.includes("docker daemon is not running")) {
1358
+ if (isWslcCmd(dockerCmd)) {
1359
+ // wslc runs Docker Engine inside its own VM, so its errors may
1360
+ // mention a docker daemon; the Docker Desktop and DOCKER_CMD hints
1361
+ // below do not apply. Show wslc's own error instead.
1362
+ if (isWslcSessionFailure(result.stderr)) {
1363
+ printWslcSessionFailureHint();
1364
+ } else if (result.stderr) {
1365
+ console.log(result.stderr);
1366
+ }
1367
+ } else if (result.stderr?.includes("docker daemon is not running")) {
1061
1368
  console.log(
1062
1369
  "Ensure Docker for Desktop is running as an administrator with 'Exposing daemon on TCP without TLS' setting turned on.",
1063
1370
  );
@@ -1075,6 +1382,7 @@ export const getImage = async (fullImageName) => {
1075
1382
  if (result.stderr) {
1076
1383
  console.log(result.stderr);
1077
1384
  }
1385
+ suggestWslcAlternative(dockerCmd);
1078
1386
  // Continue with the daemon client when the CLI fallback is unavailable
1079
1387
  // or unable to inspect the image.
1080
1388
  } else {
@@ -1091,6 +1399,13 @@ export const getImage = async (fullImageName) => {
1091
1399
  // continue regardless of error
1092
1400
  }
1093
1401
  }
1402
+ // wslc has no daemon API, and exportImage saves the image through wslc
1403
+ // again. A Docker daemon could only offer an image that wslc cannot
1404
+ // export, and probing it warns about Docker Desktop on hosts without it.
1405
+ if (isWslcCmd(dockerCmd)) {
1406
+ reportImagePullFailure(fullImageName, dockerCmd);
1407
+ return undefined;
1408
+ }
1094
1409
  }
1095
1410
  try {
1096
1411
  localData = await makeRequest(
@@ -1197,16 +1512,26 @@ export const getImage = async (fullImageName) => {
1197
1512
  }
1198
1513
  }
1199
1514
  if (!localData) {
1200
- console.log(
1201
- `Unable to pull ${fullImageName}. Check if the name is valid. Perform any authentication prior to invoking cdxgen.`,
1202
- );
1203
- console.log(
1204
- `Try manually pulling this image using docker pull ${fullImageName}`,
1205
- );
1515
+ reportImagePullFailure(fullImageName, dockerCmd);
1206
1516
  }
1207
1517
  return localData;
1208
1518
  };
1209
1519
 
1520
+ /**
1521
+ * Explain that an image could not be pulled and how to pull it manually.
1522
+ *
1523
+ * @param {string} fullImageName Image that could not be pulled
1524
+ * @param {string} [dockerCmd] Container CLI used for the attempt, if any
1525
+ */
1526
+ const reportImagePullFailure = (fullImageName, dockerCmd) => {
1527
+ console.log(
1528
+ `Unable to pull ${fullImageName}. Check if the name is valid. Perform any authentication prior to invoking cdxgen.`,
1529
+ );
1530
+ console.log(
1531
+ `Try manually pulling this image using ${dockerCmd || (isPodman ? "podman" : "docker")} pull ${fullImageName}`,
1532
+ );
1533
+ };
1534
+
1210
1535
  /**
1211
1536
  * @typedef {{ path: string }} TarReadEntryLike
1212
1537
  */
@@ -1619,6 +1944,55 @@ export const exportArchive = async (fullImageName, options = {}) => {
1619
1944
  return undefined;
1620
1945
  };
1621
1946
 
1947
+ /**
1948
+ * Check that a path taken from an untrusted image archive (manifest layer or
1949
+ * config reference, or a config WorkingDir) stays inside the directory it is
1950
+ * resolved against. Values straight out of manifest.json otherwise traverse
1951
+ * out of the extraction temp dir and read host files into the SBOM.
1952
+ *
1953
+ * @param {string} baseDir Directory the value must resolve within
1954
+ * @param {string} candidatePath Raw value from the archive
1955
+ * @returns {boolean} true when the resolved path is inside (or equal to) baseDir
1956
+ */
1957
+ const isPathWithinDir = (baseDir, candidatePath) => {
1958
+ if (typeof candidatePath !== "string" || !candidatePath) {
1959
+ return false;
1960
+ }
1961
+ const relativePath = relative(
1962
+ resolve(baseDir),
1963
+ resolve(baseDir, candidatePath),
1964
+ ).replaceAll("\\", "/");
1965
+ return (
1966
+ relativePath === "" ||
1967
+ (!relativePath.startsWith("..") && !relativePath.split("/").includes(".."))
1968
+ );
1969
+ };
1970
+
1971
+ /**
1972
+ * Resolve an untrusted archive reference (manifest Layers/Config entry) against
1973
+ * the extraction directory, rejecting anything that escapes it.
1974
+ *
1975
+ * @param {string} tempDir Extraction directory holding the archive contents
1976
+ * @param {string} archiveRef Raw reference string from the archive manifest
1977
+ * @param {string} kind Label used in the skip message
1978
+ * @returns {string|undefined} Contained joined path, or undefined when the
1979
+ * reference escapes tempDir
1980
+ */
1981
+ const resolveContainedArchivePath = (tempDir, archiveRef, kind) => {
1982
+ if (
1983
+ typeof archiveRef !== "string" ||
1984
+ !archiveRef ||
1985
+ isAbsolute(archiveRef) ||
1986
+ !isPathWithinDir(tempDir, archiveRef)
1987
+ ) {
1988
+ console.warn(
1989
+ `Skipping ${kind} '${archiveRef}' from the image manifest: it resolves outside the extracted archive.`,
1990
+ );
1991
+ return undefined;
1992
+ }
1993
+ return join(tempDir, archiveRef);
1994
+ };
1995
+
1622
1996
  /**
1623
1997
  * Parse a Docker/containerd manifest file and extract all image layers into a
1624
1998
  * single merged directory. Resolves the last layer's config to determine the
@@ -1676,8 +2050,16 @@ export const extractFromManifest = async (
1676
2050
  layerPhase.progress(extractedLayers, layers.length);
1677
2051
  layerPhase.detail(basename(layer));
1678
2052
  extractedLayers += 1;
2053
+ const containedLayerPath = resolveContainedArchivePath(
2054
+ tempDir,
2055
+ layer,
2056
+ "layer",
2057
+ );
2058
+ if (!containedLayerPath) {
2059
+ continue;
2060
+ }
1679
2061
  try {
1680
- if (!lstatSync(join(tempDir, layer)).isFile()) {
2062
+ if (!lstatSync(containedLayerPath).isFile()) {
1681
2063
  console.log(
1682
2064
  `Skipping layer ${layer} since it is not a readable file.`,
1683
2065
  );
@@ -1691,7 +2073,7 @@ export const extractFromManifest = async (
1691
2073
  console.log(`Extracting layer ${layer} to ${allLayersExplodedDir}`);
1692
2074
  }
1693
2075
  try {
1694
- await extractTar(join(tempDir, layer), allLayersExplodedDir, options);
2076
+ await extractTar(containedLayerPath, allLayersExplodedDir, options);
1695
2077
  } catch (err) {
1696
2078
  if (err.code === "TAR_BAD_ARCHIVE") {
1697
2079
  if (DEBUG_MODE) {
@@ -1711,13 +2093,22 @@ export const extractFromManifest = async (
1711
2093
  `${layers.length} layer${layers.length === 1 ? "" : "s"}`,
1712
2094
  );
1713
2095
  if (selectedManifest?.Config) {
1714
- lastLayerConfigFile = join(tempDir, selectedManifest.Config);
2096
+ lastLayerConfigFile =
2097
+ resolveContainedArchivePath(
2098
+ tempDir,
2099
+ selectedManifest.Config,
2100
+ "config",
2101
+ ) || "";
1715
2102
  }
1716
2103
  if (!lastLayerConfigFile && lastLayer?.includes("layer.tar")) {
1717
- lastLayerConfigFile = join(
2104
+ const lastLayerPath = resolveContainedArchivePath(
1718
2105
  tempDir,
1719
- lastLayer.replace("layer.tar", "json"),
2106
+ lastLayer,
2107
+ "layer",
1720
2108
  );
2109
+ if (lastLayerPath) {
2110
+ lastLayerConfigFile = lastLayerPath.replace("layer.tar", "json");
2111
+ }
1721
2112
  }
1722
2113
  if (lastLayerConfigFile && safeExistsSync(lastLayerConfigFile)) {
1723
2114
  try {
@@ -1726,9 +2117,31 @@ export const extractFromManifest = async (
1726
2117
  encoding: "utf-8",
1727
2118
  }),
1728
2119
  );
1729
- lastWorkingDir = lastLayerConfig.config?.WorkingDir
1730
- ? join(allLayersExplodedDir, lastLayerConfig.config.WorkingDir)
2120
+ const workingDir = lastLayerConfig.config?.WorkingDir;
2121
+ // WorkingDir is an in-image path (usually absolute such as /app), so
2122
+ // join first to mirror the extraction layout, then keep it only when
2123
+ // the result stays inside the merged layers directory.
2124
+ const candidateWorkingDir = workingDir
2125
+ ? join(allLayersExplodedDir, workingDir)
1731
2126
  : "";
2127
+ const workingDirRelative = candidateWorkingDir
2128
+ ? relative(
2129
+ resolve(allLayersExplodedDir),
2130
+ resolve(candidateWorkingDir),
2131
+ ).replaceAll("\\", "/")
2132
+ : "..";
2133
+ lastWorkingDir =
2134
+ candidateWorkingDir &&
2135
+ (workingDirRelative === "" ||
2136
+ (!workingDirRelative.startsWith("..") &&
2137
+ !workingDirRelative.split("/").includes("..")))
2138
+ ? candidateWorkingDir
2139
+ : "";
2140
+ if (candidateWorkingDir && !lastWorkingDir) {
2141
+ console.warn(
2142
+ `Ignoring image WorkingDir '${workingDir}': it resolves outside the extracted layers.`,
2143
+ );
2144
+ }
1732
2145
  } catch (_err) {
1733
2146
  deferFailOnError(options, {
1734
2147
  ecosystem: "container",
@@ -1773,6 +2186,10 @@ export const exportImage = async (fullImageName, options) => {
1773
2186
  ) {
1774
2187
  return undefined;
1775
2188
  }
2189
+ if (!isSafeImageReference(fullImageName)) {
2190
+ console.log(`${fullImageName} is not a valid container image reference.`);
2191
+ return undefined;
2192
+ }
1776
2193
  if (isDryRun) {
1777
2194
  const imageDetails = parseImageName(fullImageName);
1778
2195
  const requestedRegistryRef = imageDetails.registry
@@ -1819,6 +2236,9 @@ export const exportImage = async (fullImageName, options) => {
1819
2236
  fullImageName,
1820
2237
  ]);
1821
2238
  if (result.status !== 0 || result.error) {
2239
+ if (isWslcCmd(dockerCmd) && isWslcSessionFailure(result.stderr)) {
2240
+ printWslcSessionFailureHint();
2241
+ }
1822
2242
  if (result.stdout || result.stderr) {
1823
2243
  console.log(result.stdout, result.stderr);
1824
2244
  }
@@ -229,6 +229,35 @@ function getDockerCreds(registry) {
229
229
  return readEnvironmentVariable("DOCKER_AUTH");
230
230
  }
231
231
 
232
+ /**
233
+ * Check whether a token realm URL belongs to the registry itself.
234
+ *
235
+ * A registry's 401 response names the realm, so a hostile registry picks the
236
+ * URL cdxgen will call. Credentials picked up for the registry must therefore
237
+ * only be attached when the realm resolves to the same host and port; public
238
+ * delegating realms (docker.io to auth.docker.io) are still usable, just
239
+ * anonymously.
240
+ *
241
+ * @param {string} registry Registry host, optionally with a port
242
+ * @param {URL} realmUrl Parsed realm URL from the WWW-Authenticate header
243
+ * @returns {boolean} true when host and port match the registry
244
+ */
245
+ export function isSameRegistryHost(registry, realmUrl) {
246
+ try {
247
+ const registryUrl = new URL(
248
+ registry.startsWith("localhost")
249
+ ? `http://${registry}`
250
+ : `https://${registry}`,
251
+ );
252
+ return (
253
+ registryUrl.hostname === realmUrl.hostname &&
254
+ registryUrl.port === realmUrl.port
255
+ );
256
+ } catch (_e) {
257
+ return false;
258
+ }
259
+ }
260
+
232
261
  async function getOciToken(registry, repository, scope) {
233
262
  const creds = getDockerCreds(registry);
234
263
  const scheme = registry.startsWith("localhost") ? "http" : "https";
@@ -258,8 +287,17 @@ async function getOciToken(registry, repository, scope) {
258
287
  responseType: "json",
259
288
  timeout: getOciTimeoutMs(),
260
289
  };
261
- if (creds) {
290
+ // The realm comes from the registry's own 401 response, so a
291
+ // hostile registry chooses this URL. Never forward the stored
292
+ // registry credentials to a different host; an anonymous cross-host
293
+ // token request (how public registries such as docker.io delegate
294
+ // to auth.docker.io) is still allowed.
295
+ if (creds && isSameRegistryHost(registry, reqUrl)) {
262
296
  options.headers = { Authorization: `Basic ${creds}` };
297
+ } else if (creds) {
298
+ console.warn(
299
+ `Skipping credential forwarding to the token realm ${reqUrl.host} for registry ${registry}: the host differs.`,
300
+ );
263
301
  }
264
302
  const tokenRes = await cdxgenAgent.get(reqUrl.toString(), options);
265
303
  if (