@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.
@@ -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.2";
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()) if (mountsDockerSocket(volume)) 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?.([
956
- "services",
957
- name,
958
- "volumes",
959
- index
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-D3jzc3GX.cjs.map
1822
+ //# sourceMappingURL=src-D4rVJg36.cjs.map