@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.
- package/README.md +28 -12
- package/bin/cdxgen.js +227 -119
- package/bin/evinse.js +5 -0
- package/bin/sign.js +88 -14
- package/bin/tracebom.js +64 -4
- package/bin/validate.js +29 -7
- package/bin/verify.js +79 -39
- package/data/remediations.json +24 -0
- package/lib/cli/bomAssembly.js +105 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +43 -4
- package/lib/cli/jsBom.js +49 -24
- package/lib/cli/jvmBom.js +46 -12
- package/lib/cli/managedBom.js +112 -41
- package/lib/cli/nativeBom.js +125 -14
- package/lib/core/env.js +5 -6
- package/lib/core/fs.js +174 -6
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/ecosystems/bunutils.js +269 -101
- package/lib/ecosystems/caxa.js +434 -2
- package/lib/ecosystems/cmakeResolver.js +66 -0
- package/lib/ecosystems/cppEvidence.js +64 -31
- package/lib/ecosystems/ecosystems.js +19 -8
- package/lib/ecosystems/gradleutils.js +42 -7
- package/lib/ecosystems/jsEvidence.js +19 -20
- package/lib/ecosystems/kosi.js +10 -3
- package/lib/ecosystems/npmutils.js +110 -9
- package/lib/ecosystems/parsers-dotnet.js +51 -10
- package/lib/ecosystems/parsers-go.js +47 -9
- package/lib/ecosystems/parsers-js.js +171 -50
- package/lib/ecosystems/parsers-misc.js +747 -161
- package/lib/ecosystems/parsers-python.js +26 -14
- package/lib/ecosystems/parsers-rust.js +24 -18
- package/lib/ecosystems/rubyutils.js +1426 -166
- package/lib/ecosystems/rusi.js +32 -1
- package/lib/evinser/evinser.js +436 -128
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/bomSigner.js +652 -221
- package/lib/inventory/atomUtils.js +568 -31
- package/lib/inventory/cdxrs.js +103 -12
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +550 -13
- package/lib/inventory/dynamic.js +39 -1
- package/lib/inventory/evidenceUtils.js +14 -6
- package/lib/inventory/fetchBatch.js +116 -17
- package/lib/inventory/protobom.js +8 -1
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/source.js +37 -19
- package/lib/inventory/traceRunner.js +210 -21
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +440 -20
- package/lib/managers/oci.js +39 -1
- package/lib/parsers/cmakeLists.js +452 -0
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/stages/postgen/postgen.js +68 -15
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +48 -10
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +1 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +20 -20
- package/types/lib/cli/bomAssembly.d.ts +2 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +33 -1
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +55 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.d.ts.map +1 -0
- package/types/lib/core/projectConfig.d.ts +6 -2
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/ecosystems/bunutils.d.ts +12 -5
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/caxa.d.ts +14 -0
- package/types/lib/ecosystems/caxa.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +20 -4
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts +13 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +42 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +10 -0
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +10 -4
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +67 -3
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +143 -2
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts +13 -0
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts +59 -9
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +280 -15
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +106 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +145 -3
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/cdxrs.d.ts +8 -3
- package/types/lib/inventory/cdxrs.d.ts.map +1 -1
- package/types/lib/inventory/depsUtils.d.ts +15 -0
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +64 -1
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/purl.d.ts +71 -2
- package/types/lib/inventory/purl.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +47 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts +30 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +29 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +14 -0
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +76 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/largeJson.d.ts +52 -0
- package/types/lib/parsers/largeJson.d.ts.map +1 -0
- package/types/lib/parsers/npmrc.d.ts +5 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +10 -1
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
package/lib/managers/docker.js
CHANGED
|
@@ -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 {
|
|
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
|
|
1261
|
+
* @returns {string} Container CLI command to invoke
|
|
990
1262
|
*/
|
|
991
|
-
const getContainerCliCmd = () => {
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
2096
|
+
lastLayerConfigFile =
|
|
2097
|
+
resolveContainedArchivePath(
|
|
2098
|
+
tempDir,
|
|
2099
|
+
selectedManifest.Config,
|
|
2100
|
+
"config",
|
|
2101
|
+
) || "";
|
|
1715
2102
|
}
|
|
1716
2103
|
if (!lastLayerConfigFile && lastLayer?.includes("layer.tar")) {
|
|
1717
|
-
|
|
2104
|
+
const lastLayerPath = resolveContainedArchivePath(
|
|
1718
2105
|
tempDir,
|
|
1719
|
-
lastLayer
|
|
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
|
-
|
|
1730
|
-
|
|
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
|
}
|
package/lib/managers/oci.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|