@docker-doctor/cli 0.5.2 → 0.5.3
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/dist/cli.cjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-BOGzkp17.mjs → src-CklEIXTX.mjs} +195 -57
- package/dist/src-CklEIXTX.mjs.map +1 -0
- package/dist/{src-D3jzc3GX.cjs → src-D4rVJg36.cjs} +195 -57
- package/dist/src-D4rVJg36.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/src-BOGzkp17.mjs.map +0 -1
- package/dist/src-D3jzc3GX.cjs.map +0 -1
|
@@ -33,7 +33,7 @@ node_path = __toESM(node_path, 1);
|
|
|
33
33
|
let yaml = require("yaml");
|
|
34
34
|
|
|
35
35
|
//#region package.json
|
|
36
|
-
var version = "0.5.
|
|
36
|
+
var version = "0.5.3";
|
|
37
37
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region ../core/src/project-info/ignore.ts
|
|
@@ -862,6 +862,125 @@ const pinModelVersion = {
|
|
|
862
862
|
};
|
|
863
863
|
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
864
864
|
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region ../core/src/rules/compose-mounts.ts
|
|
867
|
+
/**
|
|
868
|
+
* Shared parsing for Compose `volumes:` entries, used by every rule that
|
|
869
|
+
* reasons about bind mounts. A Compose volume entry is either the short
|
|
870
|
+
* `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
|
|
871
|
+
* to a `VolumeMount`.
|
|
872
|
+
*/
|
|
873
|
+
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
874
|
+
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
875
|
+
const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
|
|
876
|
+
const resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
877
|
+
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
878
|
+
const splitShortSyntax = (spec) => {
|
|
879
|
+
const parts = [];
|
|
880
|
+
let depth = 0;
|
|
881
|
+
let current = "";
|
|
882
|
+
for (const char of spec) {
|
|
883
|
+
if (char === "{") depth += 1;
|
|
884
|
+
else if (char === "}") depth = Math.max(0, depth - 1);
|
|
885
|
+
if (char === ":" && depth === 0) {
|
|
886
|
+
parts.push(current);
|
|
887
|
+
current = "";
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
current += char;
|
|
891
|
+
}
|
|
892
|
+
parts.push(current);
|
|
893
|
+
return parts;
|
|
894
|
+
};
|
|
895
|
+
const DRIVE_LETTER = /^[A-Za-z]$/u;
|
|
896
|
+
const joinDriveLetter = (parts) => {
|
|
897
|
+
const [first, second, ...rest] = parts;
|
|
898
|
+
if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
|
|
899
|
+
return parts;
|
|
900
|
+
};
|
|
901
|
+
const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
|
|
902
|
+
const volumeMount = (volume) => {
|
|
903
|
+
if (typeof volume === "string") {
|
|
904
|
+
const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
|
|
905
|
+
if (target === void 0) return;
|
|
906
|
+
return {
|
|
907
|
+
readOnly: shortSyntaxReadOnly(mode),
|
|
908
|
+
source,
|
|
909
|
+
target,
|
|
910
|
+
type: void 0
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
if (volume && typeof volume === "object") {
|
|
914
|
+
const { read_only: readOnly, source, target, type } = volume;
|
|
915
|
+
return {
|
|
916
|
+
readOnly: readOnly === true,
|
|
917
|
+
source: typeof source === "string" ? source : void 0,
|
|
918
|
+
target: typeof target === "string" ? target : void 0,
|
|
919
|
+
type: typeof type === "string" ? type : void 0
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
/**
|
|
924
|
+
* The host path a bind mount reads from, with interpolation defaults
|
|
925
|
+
* applied and Windows separators folded to `/`. Undefined when the entry is
|
|
926
|
+
* not a bind mount (named volume, tmpfs, anonymous volume) or when the
|
|
927
|
+
* source is an interpolation with no default, which names no host path.
|
|
928
|
+
*/
|
|
929
|
+
const bindMountSource = (mount) => {
|
|
930
|
+
if (mount.type !== void 0 && mount.type !== "bind") return;
|
|
931
|
+
if (!mount.source) return;
|
|
932
|
+
const resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
|
|
933
|
+
if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
|
|
934
|
+
return resolved;
|
|
935
|
+
};
|
|
936
|
+
const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
|
|
937
|
+
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
938
|
+
/**
|
|
939
|
+
* Whether the entry bind-mounts the Docker socket, under any of its host
|
|
940
|
+
* spellings. A bare `${VAR}` source names no host path, but a socket target
|
|
941
|
+
* still tells the story.
|
|
942
|
+
*/
|
|
943
|
+
const mountsDockerSocket = (mount) => {
|
|
944
|
+
const source = bindMountSource(mount);
|
|
945
|
+
if (source !== void 0) return isDockerSocketPath(source);
|
|
946
|
+
if (mount.type !== void 0 && mount.type !== "bind") return false;
|
|
947
|
+
if (!mount.source) return false;
|
|
948
|
+
return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
949
|
+
};
|
|
950
|
+
const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
|
|
951
|
+
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
|
|
952
|
+
const SYSTEM_DIRECTORIES = [
|
|
953
|
+
"/etc",
|
|
954
|
+
"/root",
|
|
955
|
+
"/home",
|
|
956
|
+
"/Users",
|
|
957
|
+
"/proc",
|
|
958
|
+
"/sys",
|
|
959
|
+
"/boot",
|
|
960
|
+
"/run",
|
|
961
|
+
"/var/run"
|
|
962
|
+
];
|
|
963
|
+
const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
|
|
964
|
+
/**
|
|
965
|
+
* Classifies where on the host a bind-mount source points. `project` is a
|
|
966
|
+
* relative path that stays inside the Compose project directory; everything
|
|
967
|
+
* else reaches outside it.
|
|
968
|
+
*/
|
|
969
|
+
const classifyHostPath = (hostPath) => {
|
|
970
|
+
const collapsed = node_path.default.posix.normalize(hostPath);
|
|
971
|
+
const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
|
|
972
|
+
if (normalized === "~") return "home";
|
|
973
|
+
if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
|
|
974
|
+
if (normalized === "." || normalized.startsWith("./")) return "project";
|
|
975
|
+
if (normalized === ".." || normalized.startsWith("../")) return "parent";
|
|
976
|
+
if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
|
|
977
|
+
if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
|
|
978
|
+
if (normalized === "/") return "root";
|
|
979
|
+
if (!normalized.startsWith("/")) return "project";
|
|
980
|
+
for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
|
|
981
|
+
return "host";
|
|
982
|
+
};
|
|
983
|
+
|
|
865
984
|
//#endregion
|
|
866
985
|
//#region ../core/src/rules/secret-keywords.ts
|
|
867
986
|
const SECRET_KEY_PATTERNS = [
|
|
@@ -896,55 +1015,6 @@ const noPrivilegedService = {
|
|
|
896
1015
|
key: "docker-doctor/no-privileged-service",
|
|
897
1016
|
message: "Do not run services in privileged mode"
|
|
898
1017
|
};
|
|
899
|
-
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
900
|
-
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
901
|
-
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
902
|
-
const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
903
|
-
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
904
|
-
const isDockerSocketPath = (rawPath) => {
|
|
905
|
-
const normalized = rawPath.replaceAll("\\", "/");
|
|
906
|
-
return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
|
|
907
|
-
};
|
|
908
|
-
const splitShortSyntax = (spec) => {
|
|
909
|
-
const parts = [];
|
|
910
|
-
let depth = 0;
|
|
911
|
-
let current = "";
|
|
912
|
-
for (const char of spec) {
|
|
913
|
-
if (char === "{") depth += 1;
|
|
914
|
-
else if (char === "}") depth = Math.max(0, depth - 1);
|
|
915
|
-
if (char === ":" && depth === 0) {
|
|
916
|
-
parts.push(current);
|
|
917
|
-
current = "";
|
|
918
|
-
continue;
|
|
919
|
-
}
|
|
920
|
-
current += char;
|
|
921
|
-
}
|
|
922
|
-
parts.push(current);
|
|
923
|
-
return parts;
|
|
924
|
-
};
|
|
925
|
-
const volumeMount = (volume) => {
|
|
926
|
-
if (typeof volume === "string") {
|
|
927
|
-
const [source, target] = splitShortSyntax(volume);
|
|
928
|
-
return target === void 0 ? void 0 : {
|
|
929
|
-
source,
|
|
930
|
-
target
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
if (volume && typeof volume === "object") {
|
|
934
|
-
const { source, target } = volume;
|
|
935
|
-
return {
|
|
936
|
-
source: typeof source === "string" ? source : void 0,
|
|
937
|
-
target: typeof target === "string" ? target : void 0
|
|
938
|
-
};
|
|
939
|
-
}
|
|
940
|
-
};
|
|
941
|
-
const mountsDockerSocket = (volume) => {
|
|
942
|
-
const mount = volumeMount(volume);
|
|
943
|
-
if (!mount?.source) return false;
|
|
944
|
-
const resolvedSource = resolveInterpolationDefaults(mount.source);
|
|
945
|
-
if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
|
|
946
|
-
return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
947
|
-
};
|
|
948
1018
|
const noDockerSocketMount = {
|
|
949
1019
|
category: "Compose",
|
|
950
1020
|
check(composeContent, file, context) {
|
|
@@ -952,12 +1022,15 @@ const noDockerSocketMount = {
|
|
|
952
1022
|
for (const [name, config] of composeServices(composeContent)) {
|
|
953
1023
|
const { volumes } = config;
|
|
954
1024
|
if (!Array.isArray(volumes)) continue;
|
|
955
|
-
for (const [index, volume] of volumes.entries())
|
|
956
|
-
|
|
957
|
-
name,
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1025
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1026
|
+
const mount = volumeMount(volume);
|
|
1027
|
+
if (mount && mountsDockerSocket(mount)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' bind-mounts the Docker socket. Anything running in this container can control the Docker daemon: start privileged containers, read every volume, and escape to the host.`, this.help, context?.locate?.([
|
|
1028
|
+
"services",
|
|
1029
|
+
name,
|
|
1030
|
+
"volumes",
|
|
1031
|
+
index
|
|
1032
|
+
])));
|
|
1033
|
+
}
|
|
961
1034
|
}
|
|
962
1035
|
return diagnostics;
|
|
963
1036
|
},
|
|
@@ -966,6 +1039,69 @@ const noDockerSocketMount = {
|
|
|
966
1039
|
key: "docker-doctor/no-docker-socket-mount",
|
|
967
1040
|
message: "Do not bind-mount the Docker socket into services"
|
|
968
1041
|
};
|
|
1042
|
+
const BROAD_SCOPE_REASONS = {
|
|
1043
|
+
home: "the whole home directory",
|
|
1044
|
+
"home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
|
|
1045
|
+
parent: "a directory above the Compose project",
|
|
1046
|
+
root: "the host's root filesystem",
|
|
1047
|
+
system: "a host system directory"
|
|
1048
|
+
};
|
|
1049
|
+
const noBroadBindMount = {
|
|
1050
|
+
category: "Compose",
|
|
1051
|
+
check(composeContent, file, context) {
|
|
1052
|
+
const diagnostics = [];
|
|
1053
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
1054
|
+
const { volumes } = config;
|
|
1055
|
+
if (!Array.isArray(volumes)) continue;
|
|
1056
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1057
|
+
const mount = volumeMount(volume);
|
|
1058
|
+
if (!mount || mountsDockerSocket(mount)) continue;
|
|
1059
|
+
const source = bindMountSource(mount);
|
|
1060
|
+
if (source === void 0) continue;
|
|
1061
|
+
const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
|
|
1062
|
+
if (reason === void 0) continue;
|
|
1063
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' bind-mounts '${source}', ${reason}. Everything under it is readable by the container, and a writable mount is the foothold for VM-escape bugs like CVE-2026-77179.`, this.help, context?.locate?.([
|
|
1064
|
+
"services",
|
|
1065
|
+
name,
|
|
1066
|
+
"volumes",
|
|
1067
|
+
index
|
|
1068
|
+
])));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return diagnostics;
|
|
1072
|
+
},
|
|
1073
|
+
defaultSeverity: "warning",
|
|
1074
|
+
help: "Mount the narrowest directory the service actually reads (`./data`, not `~` or `/`), and add `:ro` unless the service must write to it. Host paths the service only needs at build time belong in the image instead.",
|
|
1075
|
+
key: "docker-doctor/no-broad-bind-mount",
|
|
1076
|
+
message: "Do not bind-mount broad host directories into services"
|
|
1077
|
+
};
|
|
1078
|
+
const preferReadOnlyBindMount = {
|
|
1079
|
+
category: "Compose",
|
|
1080
|
+
check(composeContent, file, context) {
|
|
1081
|
+
const diagnostics = [];
|
|
1082
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
1083
|
+
const { volumes } = config;
|
|
1084
|
+
if (!Array.isArray(volumes)) continue;
|
|
1085
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1086
|
+
const mount = volumeMount(volume);
|
|
1087
|
+
if (!mount || mount.readOnly || mountsDockerSocket(mount)) continue;
|
|
1088
|
+
const source = bindMountSource(mount);
|
|
1089
|
+
if (source === void 0 || classifyHostPath(source) === "project") continue;
|
|
1090
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' bind-mounts host path '${source}' read-write. A writable host mount lets the container change files outside the project, and is the foothold for VM-escape bugs like CVE-2026-77179.`, this.help, context?.locate?.([
|
|
1091
|
+
"services",
|
|
1092
|
+
name,
|
|
1093
|
+
"volumes",
|
|
1094
|
+
index
|
|
1095
|
+
])));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return diagnostics;
|
|
1099
|
+
},
|
|
1100
|
+
defaultSeverity: "info",
|
|
1101
|
+
help: "Append `:ro` to the short syntax, or set `read_only: true` in the long syntax, unless the service must write to the host path.",
|
|
1102
|
+
key: "docker-doctor/prefer-read-only-bind-mount",
|
|
1103
|
+
message: "Bind mounts outside the project should be read-only"
|
|
1104
|
+
};
|
|
969
1105
|
const noPlaintextSecrets = {
|
|
970
1106
|
category: "Compose",
|
|
971
1107
|
check(composeContent, file, context) {
|
|
@@ -1007,6 +1143,8 @@ const noPlaintextSecrets = {
|
|
|
1007
1143
|
const composeSecurityRules = [
|
|
1008
1144
|
noPrivilegedService,
|
|
1009
1145
|
noDockerSocketMount,
|
|
1146
|
+
noBroadBindMount,
|
|
1147
|
+
preferReadOnlyBindMount,
|
|
1010
1148
|
noPlaintextSecrets
|
|
1011
1149
|
];
|
|
1012
1150
|
|
|
@@ -1681,4 +1819,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1681
1819
|
return version;
|
|
1682
1820
|
}
|
|
1683
1821
|
});
|
|
1684
|
-
//# sourceMappingURL=src-
|
|
1822
|
+
//# sourceMappingURL=src-D4rVJg36.cjs.map
|