@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_src = require('./src-D3jzc3GX.cjs');
2
+ const require_src = require('./src-D4rVJg36.cjs');
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  node_fs_promises = require_src.__toESM(node_fs_promises, 1);
5
5
  let node_path = require("node:path");
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, c as createComposeLocator, d as discoverProject, f as version, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-BOGzkp17.mjs";
2
+ import { a as runDockerfileRules, c as createComposeLocator, d as discoverProject, f as version, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CklEIXTX.mjs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
3
- const require_src = require('./src-D3jzc3GX.cjs');
3
+ const require_src = require('./src-D4rVJg36.cjs');
4
4
 
5
5
  //#region ../core/src/config/define-config.ts
6
6
  const defineConfig = (config) => config;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-BOGzkp17.mjs";
2
+ import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CklEIXTX.mjs";
3
3
 
4
4
  //#region ../core/src/config/define-config.ts
5
5
  const defineConfig = (config) => config;
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.5.2";
7
+ var version = "0.5.3";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/ignore.ts
@@ -833,6 +833,125 @@ const pinModelVersion = {
833
833
  };
834
834
  const composeModelRules = [undefinedModelReference, pinModelVersion];
835
835
 
836
+ //#endregion
837
+ //#region ../core/src/rules/compose-mounts.ts
838
+ /**
839
+ * Shared parsing for Compose `volumes:` entries, used by every rule that
840
+ * reasons about bind mounts. A Compose volume entry is either the short
841
+ * `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
842
+ * to a `VolumeMount`.
843
+ */
844
+ const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
845
+ const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
846
+ const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
847
+ const resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
848
+ const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
849
+ const splitShortSyntax = (spec) => {
850
+ const parts = [];
851
+ let depth = 0;
852
+ let current = "";
853
+ for (const char of spec) {
854
+ if (char === "{") depth += 1;
855
+ else if (char === "}") depth = Math.max(0, depth - 1);
856
+ if (char === ":" && depth === 0) {
857
+ parts.push(current);
858
+ current = "";
859
+ continue;
860
+ }
861
+ current += char;
862
+ }
863
+ parts.push(current);
864
+ return parts;
865
+ };
866
+ const DRIVE_LETTER = /^[A-Za-z]$/u;
867
+ const joinDriveLetter = (parts) => {
868
+ const [first, second, ...rest] = parts;
869
+ if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
870
+ return parts;
871
+ };
872
+ const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
873
+ const volumeMount = (volume) => {
874
+ if (typeof volume === "string") {
875
+ const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
876
+ if (target === void 0) return;
877
+ return {
878
+ readOnly: shortSyntaxReadOnly(mode),
879
+ source,
880
+ target,
881
+ type: void 0
882
+ };
883
+ }
884
+ if (volume && typeof volume === "object") {
885
+ const { read_only: readOnly, source, target, type } = volume;
886
+ return {
887
+ readOnly: readOnly === true,
888
+ source: typeof source === "string" ? source : void 0,
889
+ target: typeof target === "string" ? target : void 0,
890
+ type: typeof type === "string" ? type : void 0
891
+ };
892
+ }
893
+ };
894
+ /**
895
+ * The host path a bind mount reads from, with interpolation defaults
896
+ * applied and Windows separators folded to `/`. Undefined when the entry is
897
+ * not a bind mount (named volume, tmpfs, anonymous volume) or when the
898
+ * source is an interpolation with no default, which names no host path.
899
+ */
900
+ const bindMountSource = (mount) => {
901
+ if (mount.type !== void 0 && mount.type !== "bind") return;
902
+ if (!mount.source) return;
903
+ const resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
904
+ if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
905
+ return resolved;
906
+ };
907
+ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
908
+ const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
909
+ /**
910
+ * Whether the entry bind-mounts the Docker socket, under any of its host
911
+ * spellings. A bare `${VAR}` source names no host path, but a socket target
912
+ * still tells the story.
913
+ */
914
+ const mountsDockerSocket = (mount) => {
915
+ const source = bindMountSource(mount);
916
+ if (source !== void 0) return isDockerSocketPath(source);
917
+ if (mount.type !== void 0 && mount.type !== "bind") return false;
918
+ if (!mount.source) return false;
919
+ return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
920
+ };
921
+ const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
922
+ const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
923
+ const SYSTEM_DIRECTORIES = [
924
+ "/etc",
925
+ "/root",
926
+ "/home",
927
+ "/Users",
928
+ "/proc",
929
+ "/sys",
930
+ "/boot",
931
+ "/run",
932
+ "/var/run"
933
+ ];
934
+ const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
935
+ /**
936
+ * Classifies where on the host a bind-mount source points. `project` is a
937
+ * relative path that stays inside the Compose project directory; everything
938
+ * else reaches outside it.
939
+ */
940
+ const classifyHostPath = (hostPath) => {
941
+ const collapsed = path.posix.normalize(hostPath);
942
+ const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
943
+ if (normalized === "~") return "home";
944
+ if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
945
+ if (normalized === "." || normalized.startsWith("./")) return "project";
946
+ if (normalized === ".." || normalized.startsWith("../")) return "parent";
947
+ if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
948
+ if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
949
+ if (normalized === "/") return "root";
950
+ if (!normalized.startsWith("/")) return "project";
951
+ for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
952
+ return "host";
953
+ };
954
+
836
955
  //#endregion
837
956
  //#region ../core/src/rules/secret-keywords.ts
838
957
  const SECRET_KEY_PATTERNS = [
@@ -867,55 +986,6 @@ const noPrivilegedService = {
867
986
  key: "docker-doctor/no-privileged-service",
868
987
  message: "Do not run services in privileged mode"
869
988
  };
870
- const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
871
- const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
872
- const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
873
- const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
874
- const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
875
- const isDockerSocketPath = (rawPath) => {
876
- const normalized = rawPath.replaceAll("\\", "/");
877
- return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
878
- };
879
- const splitShortSyntax = (spec) => {
880
- const parts = [];
881
- let depth = 0;
882
- let current = "";
883
- for (const char of spec) {
884
- if (char === "{") depth += 1;
885
- else if (char === "}") depth = Math.max(0, depth - 1);
886
- if (char === ":" && depth === 0) {
887
- parts.push(current);
888
- current = "";
889
- continue;
890
- }
891
- current += char;
892
- }
893
- parts.push(current);
894
- return parts;
895
- };
896
- const volumeMount = (volume) => {
897
- if (typeof volume === "string") {
898
- const [source, target] = splitShortSyntax(volume);
899
- return target === void 0 ? void 0 : {
900
- source,
901
- target
902
- };
903
- }
904
- if (volume && typeof volume === "object") {
905
- const { source, target } = volume;
906
- return {
907
- source: typeof source === "string" ? source : void 0,
908
- target: typeof target === "string" ? target : void 0
909
- };
910
- }
911
- };
912
- const mountsDockerSocket = (volume) => {
913
- const mount = volumeMount(volume);
914
- if (!mount?.source) return false;
915
- const resolvedSource = resolveInterpolationDefaults(mount.source);
916
- if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
917
- return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
918
- };
919
989
  const noDockerSocketMount = {
920
990
  category: "Compose",
921
991
  check(composeContent, file, context) {
@@ -923,12 +993,15 @@ const noDockerSocketMount = {
923
993
  for (const [name, config] of composeServices(composeContent)) {
924
994
  const { volumes } = config;
925
995
  if (!Array.isArray(volumes)) continue;
926
- 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?.([
927
- "services",
928
- name,
929
- "volumes",
930
- index
931
- ])));
996
+ for (const [index, volume] of volumes.entries()) {
997
+ const mount = volumeMount(volume);
998
+ 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?.([
999
+ "services",
1000
+ name,
1001
+ "volumes",
1002
+ index
1003
+ ])));
1004
+ }
932
1005
  }
933
1006
  return diagnostics;
934
1007
  },
@@ -937,6 +1010,69 @@ const noDockerSocketMount = {
937
1010
  key: "docker-doctor/no-docker-socket-mount",
938
1011
  message: "Do not bind-mount the Docker socket into services"
939
1012
  };
1013
+ const BROAD_SCOPE_REASONS = {
1014
+ home: "the whole home directory",
1015
+ "home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
1016
+ parent: "a directory above the Compose project",
1017
+ root: "the host's root filesystem",
1018
+ system: "a host system directory"
1019
+ };
1020
+ const noBroadBindMount = {
1021
+ category: "Compose",
1022
+ check(composeContent, file, context) {
1023
+ const diagnostics = [];
1024
+ for (const [name, config] of composeServices(composeContent)) {
1025
+ const { volumes } = config;
1026
+ if (!Array.isArray(volumes)) continue;
1027
+ for (const [index, volume] of volumes.entries()) {
1028
+ const mount = volumeMount(volume);
1029
+ if (!mount || mountsDockerSocket(mount)) continue;
1030
+ const source = bindMountSource(mount);
1031
+ if (source === void 0) continue;
1032
+ const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
1033
+ if (reason === void 0) continue;
1034
+ 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?.([
1035
+ "services",
1036
+ name,
1037
+ "volumes",
1038
+ index
1039
+ ])));
1040
+ }
1041
+ }
1042
+ return diagnostics;
1043
+ },
1044
+ defaultSeverity: "warning",
1045
+ 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.",
1046
+ key: "docker-doctor/no-broad-bind-mount",
1047
+ message: "Do not bind-mount broad host directories into services"
1048
+ };
1049
+ const preferReadOnlyBindMount = {
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 || mount.readOnly || mountsDockerSocket(mount)) continue;
1059
+ const source = bindMountSource(mount);
1060
+ if (source === void 0 || classifyHostPath(source) === "project") continue;
1061
+ 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?.([
1062
+ "services",
1063
+ name,
1064
+ "volumes",
1065
+ index
1066
+ ])));
1067
+ }
1068
+ }
1069
+ return diagnostics;
1070
+ },
1071
+ defaultSeverity: "info",
1072
+ 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.",
1073
+ key: "docker-doctor/prefer-read-only-bind-mount",
1074
+ message: "Bind mounts outside the project should be read-only"
1075
+ };
940
1076
  const noPlaintextSecrets = {
941
1077
  category: "Compose",
942
1078
  check(composeContent, file, context) {
@@ -978,6 +1114,8 @@ const noPlaintextSecrets = {
978
1114
  const composeSecurityRules = [
979
1115
  noPrivilegedService,
980
1116
  noDockerSocketMount,
1117
+ noBroadBindMount,
1118
+ preferReadOnlyBindMount,
981
1119
  noPlaintextSecrets
982
1120
  ];
983
1121
 
@@ -1575,4 +1713,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1575
1713
 
1576
1714
  //#endregion
1577
1715
  export { runDockerfileRules as a, createComposeLocator as c, discoverProject as d, version as f, runComposeRules as i, parseCompose as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, parseDockerfile as u };
1578
- //# sourceMappingURL=src-BOGzkp17.mjs.map
1716
+ //# sourceMappingURL=src-CklEIXTX.mjs.map