@docker-doctor/cli 0.5.2 → 0.6.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.
@@ -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.6.0";
37
37
 
38
38
  //#endregion
39
39
  //#region ../core/src/project-info/ignore.ts
@@ -89,20 +89,66 @@ const createIgnoreMatcher = (patterns) => {
89
89
 
90
90
  //#endregion
91
91
  //#region ../core/src/project-info/discover.ts
92
- const walk = async (dir, fileList = []) => {
93
- const files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
92
+ const PRUNED_DIRECTORIES = [
93
+ ".git",
94
+ ".hg",
95
+ ".svn",
96
+ "node_modules",
97
+ "bower_components",
98
+ ".pnpm-store",
99
+ ".yarn",
100
+ ".venv",
101
+ "venv",
102
+ "__pycache__",
103
+ ".tox",
104
+ ".mypy_cache",
105
+ ".pytest_cache",
106
+ ".ruff_cache",
107
+ ".gradle",
108
+ ".turbo",
109
+ ".next",
110
+ ".nuxt",
111
+ ".svelte-kit",
112
+ ".parcel-cache",
113
+ ".cache",
114
+ "coverage",
115
+ "dist"
116
+ ];
117
+ const PRUNED = new Set(PRUNED_DIRECTORIES);
118
+ const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
119
+ const SUBTREE_SUFFIX = "/**";
120
+ const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
121
+ const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
122
+ const walk = async (dir, options, fileList = []) => {
123
+ let files;
124
+ try {
125
+ files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
126
+ } catch (error) {
127
+ if (dir !== options.rootDir && isPermissionError(error)) {
128
+ options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
129
+ return fileList;
130
+ }
131
+ throw error;
132
+ }
94
133
  await Promise.all(files.map(async (file) => {
95
134
  const filePath = node_path.default.join(dir, file.name);
135
+ if (file.isSymbolicLink()) {
136
+ fileList.push(filePath);
137
+ return;
138
+ }
96
139
  if (file.isDirectory()) {
97
- if (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
98
- await walk(filePath, fileList);
140
+ if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
141
+ await walk(filePath, options, fileList);
99
142
  } else fileList.push(filePath);
100
143
  }));
101
144
  return fileList;
102
145
  };
103
- const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
104
146
  const discoverProject = async (rootDir, options) => {
105
- const allFiles = await walk(rootDir);
147
+ const allFiles = await walk(rootDir, {
148
+ isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
149
+ onSkippedDirectory: options?.onSkippedDirectory,
150
+ rootDir
151
+ });
106
152
  const dockerfiles = [];
107
153
  const composeFiles = [];
108
154
  const dockerignores = [];
@@ -287,7 +333,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
287
333
  const lineNum = i + 1;
288
334
  const insideHeredoc = state.heredocQueue.length > 0;
289
335
  if (!insideHeredoc && trimmed.startsWith("#")) continue;
290
- if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
336
+ if (!insideHeredoc && trimmed === "") continue;
291
337
  state.rawAccumulator.push(rawLine);
292
338
  if (insideHeredoc) processHeredocLine(state, trimmed);
293
339
  else processInstructionLine(state, trimmed, lineNum);
@@ -458,6 +504,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
458
504
  severity
459
505
  });
460
506
 
507
+ //#endregion
508
+ //#region ../core/src/rules/package-managers.ts
509
+ const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
510
+ const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
511
+ const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
512
+ const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
513
+
461
514
  //#endregion
462
515
  //#region ../core/src/rules/best-practices.ts
463
516
  const requireHealthcheck = {
@@ -473,6 +526,16 @@ const requireHealthcheck = {
473
526
  key: "docker-doctor/require-healthcheck",
474
527
  message: "Add a HEALTHCHECK instruction"
475
528
  };
529
+ const AUTO_EXTRACT_SUFFIXES = [
530
+ ".tar",
531
+ ".tar.gz",
532
+ ".tgz",
533
+ ".tar.bz2",
534
+ ".tbz2",
535
+ ".tbz",
536
+ ".tar.xz",
537
+ ".txz"
538
+ ];
476
539
  const preferCopyOverAdd = {
477
540
  category: "Best Practices",
478
541
  check(instructions, file) {
@@ -481,7 +544,7 @@ const preferCopyOverAdd = {
481
544
  const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
482
545
  if (!src) continue;
483
546
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
484
- const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
547
+ const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
485
548
  if (!isRemote && !isArchive) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
486
549
  }
487
550
  return diagnostics;
@@ -519,8 +582,8 @@ const combineAptUpdateInstall = {
519
582
  check(instructions, file) {
520
583
  const diagnostics = [];
521
584
  for (const inst of instructions) if (inst.instruction === "RUN") {
522
- const hasUpdate = inst.args.includes("apt-get update");
523
- const hasInstall = inst.args.includes("apt-get install");
585
+ const hasUpdate = hasAptGetUpdate(inst.args);
586
+ const hasInstall = hasAptGetInstall(inst.args);
524
587
  if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get update used without apt-get install in the same instruction. This can cause caching issues and build failures.", this.help, inst.line));
525
588
  else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get install used without apt-get update in the same instruction. Always combine them to ensure up-to-date package installation.", this.help, inst.line));
526
589
  }
@@ -600,18 +663,32 @@ const avoidRunCd = {
600
663
  key: "docker-doctor/avoid-run-cd",
601
664
  message: "Avoid changing directories with cd in RUN"
602
665
  };
666
+ const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
667
+ const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
668
+ const WHITESPACE_RE$1 = /\s+/u;
669
+ const collectPackageList = (raw) => {
670
+ const start = PACKAGE_LIST_START_RE.exec(raw);
671
+ if (!start) return [];
672
+ const packages = [];
673
+ const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
674
+ for (const token of tokens) {
675
+ if (SHELL_SEPARATOR_RE$1.test(token)) break;
676
+ if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
677
+ }
678
+ return packages;
679
+ };
603
680
  const sortMultilineArgs = {
604
681
  category: "Best Practices",
605
682
  check(instructions, file) {
606
683
  const diagnostics = [];
607
684
  for (const inst of instructions) if (inst.instruction === "RUN") {
608
685
  const { raw } = inst;
609
- const isPackageInstall = raw.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
686
+ const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
610
687
  const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
611
688
  if (isPackageInstall && hasContinuation) {
612
- const packages = raw.split(/\r?\n/u).slice(1).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("&&") && !line.startsWith("-") && !line.includes("rm -rf")).map((line) => line.endsWith("\\") ? line.slice(0, -1).trim() : line).filter(Boolean);
689
+ const packages = collectPackageList(raw);
613
690
  if (packages.length > 1) {
614
- const sorted = packages.toSorted((a, b) => a.localeCompare(b));
691
+ const sorted = packages.toSorted();
615
692
  if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Multi-line package arguments are not sorted alphanumerically. Keeping them sorted makes maintenance easier and prevents duplicates.", this.help, inst.line));
616
693
  }
617
694
  }
@@ -863,48 +940,21 @@ const pinModelVersion = {
863
940
  const composeModelRules = [undefinedModelReference, pinModelVersion];
864
941
 
865
942
  //#endregion
866
- //#region ../core/src/rules/secret-keywords.ts
867
- const SECRET_KEY_PATTERNS = [
868
- /password(?:[_-]|$)/iu,
869
- /(?:^|[_-])secret(?:[_-]|$)/iu,
870
- /(?:^|[_-])token(?:[_-]|$)/iu,
871
- /(?:^|[_-])api_key(?:[_-]|$)/iu,
872
- /(?:^|[_-])apikey(?:[_-]|$)/iu,
873
- /(?:^|[_-])private_key(?:[_-]|$)/iu,
874
- /(?:^|[_-])auth(?:[_-]|$)/iu,
875
- /(?:^|[_-])pat(?:[_-]|$)/iu
876
- ];
877
- const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
878
- const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
879
- const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
880
-
881
- //#endregion
882
- //#region ../core/src/rules/compose-security.ts
883
- const noPrivilegedService = {
884
- category: "Compose",
885
- check(composeContent, file, context) {
886
- const diagnostics = [];
887
- for (const [name, config] of composeServices(composeContent)) if (config.privileged === true) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' runs in privileged mode. A privileged container has full access to the host's devices and kernel, so compromising this service compromises the host.`, this.help, context?.locate?.([
888
- "services",
889
- name,
890
- "privileged"
891
- ])));
892
- return diagnostics;
893
- },
894
- defaultSeverity: "error",
895
- help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
896
- key: "docker-doctor/no-privileged-service",
897
- message: "Do not run services in privileged mode"
898
- };
899
- const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
943
+ //#region ../core/src/rules/compose-mounts.ts
944
+ /**
945
+ * Shared parsing for Compose `volumes:` entries, used by every rule that
946
+ * reasons about bind mounts. A Compose volume entry is either the short
947
+ * `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
948
+ * to a `VolumeMount`.
949
+ */
900
950
  const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
901
951
  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, "");
952
+ const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
953
+ const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
954
+ const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
955
+ const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
956
+ const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
903
957
  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
958
  const splitShortSyntax = (spec) => {
909
959
  const parts = [];
910
960
  let depth = 0;
@@ -922,28 +972,137 @@ const splitShortSyntax = (spec) => {
922
972
  parts.push(current);
923
973
  return parts;
924
974
  };
975
+ const DRIVE_LETTER = /^[A-Za-z]$/u;
976
+ const joinDriveLetter = (parts) => {
977
+ const [first, second, ...rest] = parts;
978
+ if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
979
+ return parts;
980
+ };
981
+ const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
925
982
  const volumeMount = (volume) => {
926
983
  if (typeof volume === "string") {
927
- const [source, target] = splitShortSyntax(volume);
928
- return target === void 0 ? void 0 : {
984
+ const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
985
+ if (target === void 0) return;
986
+ return {
987
+ readOnly: shortSyntaxReadOnly(mode),
929
988
  source,
930
- target
989
+ target,
990
+ type: void 0
931
991
  };
932
992
  }
933
993
  if (volume && typeof volume === "object") {
934
- const { source, target } = volume;
994
+ const { read_only: readOnly, source, target, type } = volume;
935
995
  return {
996
+ readOnly: readOnly === true,
936
997
  source: typeof source === "string" ? source : void 0,
937
- target: typeof target === "string" ? target : void 0
998
+ target: typeof target === "string" ? target : void 0,
999
+ type: typeof type === "string" ? type : void 0
938
1000
  };
939
1001
  }
940
1002
  };
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;
1003
+ /**
1004
+ * The host path a bind mount reads from, with interpolation defaults
1005
+ * applied and Windows separators folded to `/`. Undefined when the entry is
1006
+ * not a bind mount (named volume, tmpfs, anonymous volume) or when the
1007
+ * source starts with an interpolation that has no default. A source such as
1008
+ * `${DATA_DIR}/pg` names no known host location, so no rule can say whether
1009
+ * it is inside the project.
1010
+ */
1011
+ const bindMountSource = (mount) => {
1012
+ if (mount.type !== void 0 && mount.type !== "bind") return;
1013
+ if (!mount.source) return;
1014
+ const withDefaults = applyFoldsAndDefaults(mount.source);
1015
+ if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
1016
+ const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
1017
+ if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
1018
+ return resolved;
1019
+ };
1020
+ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
1021
+ const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
1022
+ /**
1023
+ * Whether the entry bind-mounts the Docker socket, under any of its host
1024
+ * spellings. A source that starts with `${VAR}` names no known host path,
1025
+ * but a `/docker.sock` suffix or a socket target still identifies it.
1026
+ */
1027
+ const mountsDockerSocket = (mount) => {
1028
+ const source = bindMountSource(mount);
1029
+ if (source !== void 0) return isDockerSocketPath(source);
1030
+ if (mount.type !== void 0 && mount.type !== "bind") return false;
1031
+ if (!mount.source) return false;
1032
+ const remainder = resolveInterpolationDefaults(mount.source);
1033
+ return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
1034
+ };
1035
+ const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
1036
+ const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
1037
+ const SYSTEM_DIRECTORIES = [
1038
+ "/etc",
1039
+ "/root",
1040
+ "/home",
1041
+ "/Users",
1042
+ "/proc",
1043
+ "/sys",
1044
+ "/boot",
1045
+ "/run",
1046
+ "/var/run"
1047
+ ];
1048
+ const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
1049
+ /**
1050
+ * Classifies where on the host a bind-mount source points. `project` is a
1051
+ * relative path that stays inside the Compose project directory; everything
1052
+ * else reaches outside it.
1053
+ */
1054
+ const classifyHostPath = (hostPath) => {
1055
+ const collapsed = node_path.default.posix.normalize(hostPath);
1056
+ const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
1057
+ if (normalized === "~") return "home";
1058
+ if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
1059
+ if (normalized === "." || normalized.startsWith("./")) return "project";
1060
+ if (normalized === ".." || normalized.startsWith("../")) return "parent";
1061
+ if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
1062
+ if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
1063
+ if (normalized === "/") return "root";
1064
+ if (!normalized.startsWith("/")) return "project";
1065
+ for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
1066
+ return "host";
1067
+ };
1068
+
1069
+ //#endregion
1070
+ //#region ../core/src/rules/secret-keywords.ts
1071
+ const SECRET_KEY_PATTERNS = [
1072
+ /password(?:[_-]|$)/iu,
1073
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
1074
+ /(?:^|[_-])token(?:[_-]|$)/iu,
1075
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
1076
+ /(?:^|[_-])apikey(?:[_-]|$)/iu,
1077
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
1078
+ /(?:^|[_-])auth(?:[_-]|$)/iu,
1079
+ /(?:^|[_-])pat(?:[_-]|$)/iu
1080
+ ];
1081
+ const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
1082
+ const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
1083
+ const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
1084
+ const isLiteralSecretValue = (value) => {
1085
+ const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
1086
+ return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
1087
+ };
1088
+
1089
+ //#endregion
1090
+ //#region ../core/src/rules/compose-security.ts
1091
+ const noPrivilegedService = {
1092
+ category: "Compose",
1093
+ check(composeContent, file, context) {
1094
+ const diagnostics = [];
1095
+ for (const [name, config] of composeServices(composeContent)) if (config.privileged === true) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' runs in privileged mode. A privileged container has full access to the host's devices and kernel, so compromising this service compromises the host.`, this.help, context?.locate?.([
1096
+ "services",
1097
+ name,
1098
+ "privileged"
1099
+ ])));
1100
+ return diagnostics;
1101
+ },
1102
+ defaultSeverity: "error",
1103
+ help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
1104
+ key: "docker-doctor/no-privileged-service",
1105
+ message: "Do not run services in privileged mode"
947
1106
  };
948
1107
  const noDockerSocketMount = {
949
1108
  category: "Compose",
@@ -952,12 +1111,15 @@ const noDockerSocketMount = {
952
1111
  for (const [name, config] of composeServices(composeContent)) {
953
1112
  const { volumes } = config;
954
1113
  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
- ])));
1114
+ for (const [index, volume] of volumes.entries()) {
1115
+ const mount = volumeMount(volume);
1116
+ 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?.([
1117
+ "services",
1118
+ name,
1119
+ "volumes",
1120
+ index
1121
+ ])));
1122
+ }
961
1123
  }
962
1124
  return diagnostics;
963
1125
  },
@@ -966,6 +1128,73 @@ const noDockerSocketMount = {
966
1128
  key: "docker-doctor/no-docker-socket-mount",
967
1129
  message: "Do not bind-mount the Docker socket into services"
968
1130
  };
1131
+ const BROAD_SCOPE_REASONS = {
1132
+ home: "the whole home directory",
1133
+ "home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
1134
+ parent: "a directory above the Compose project",
1135
+ root: "the host's root filesystem",
1136
+ system: "a host system directory"
1137
+ };
1138
+ const noBroadBindMount = {
1139
+ category: "Compose",
1140
+ check(composeContent, file, context) {
1141
+ const diagnostics = [];
1142
+ for (const [name, config] of composeServices(composeContent)) {
1143
+ const { volumes } = config;
1144
+ if (!Array.isArray(volumes)) continue;
1145
+ for (const [index, volume] of volumes.entries()) {
1146
+ const mount = volumeMount(volume);
1147
+ if (!mount || mountsDockerSocket(mount)) continue;
1148
+ const source = bindMountSource(mount);
1149
+ if (source === void 0) continue;
1150
+ const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
1151
+ if (reason === void 0) continue;
1152
+ 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?.([
1153
+ "services",
1154
+ name,
1155
+ "volumes",
1156
+ index
1157
+ ])));
1158
+ }
1159
+ }
1160
+ return diagnostics;
1161
+ },
1162
+ defaultSeverity: "warning",
1163
+ 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.",
1164
+ key: "docker-doctor/no-broad-bind-mount",
1165
+ message: "Do not bind-mount broad host directories into services"
1166
+ };
1167
+ const preferReadOnlyBindMount = {
1168
+ category: "Compose",
1169
+ check(composeContent, file, context) {
1170
+ const diagnostics = [];
1171
+ for (const [name, config] of composeServices(composeContent)) {
1172
+ const { volumes } = config;
1173
+ if (!Array.isArray(volumes)) continue;
1174
+ for (const [index, volume] of volumes.entries()) {
1175
+ const mount = volumeMount(volume);
1176
+ if (!mount || mount.readOnly || mountsDockerSocket(mount)) continue;
1177
+ const source = bindMountSource(mount);
1178
+ if (source === void 0 || classifyHostPath(source) === "project") continue;
1179
+ 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?.([
1180
+ "services",
1181
+ name,
1182
+ "volumes",
1183
+ index
1184
+ ])));
1185
+ }
1186
+ }
1187
+ return diagnostics;
1188
+ },
1189
+ defaultSeverity: "info",
1190
+ 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.",
1191
+ key: "docker-doctor/prefer-read-only-bind-mount",
1192
+ message: "Bind mounts outside the project should be read-only"
1193
+ };
1194
+ const literalValue = (value) => {
1195
+ if (typeof value === "string") return value;
1196
+ if (typeof value === "number") return String(value);
1197
+ };
969
1198
  const noPlaintextSecrets = {
970
1199
  category: "Compose",
971
1200
  check(composeContent, file, context) {
@@ -981,15 +1210,16 @@ const noPlaintextSecrets = {
981
1210
  if (eqIndex <= 0) continue;
982
1211
  const key = entry.slice(0, eqIndex);
983
1212
  const value = entry.slice(eqIndex + 1);
984
- if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1213
+ if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
985
1214
  "services",
986
1215
  name,
987
1216
  "environment",
988
1217
  index
989
1218
  ]));
990
1219
  }
991
- else if (environment && typeof environment === "object") {
992
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1220
+ else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
1221
+ const literal = literalValue(value);
1222
+ if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
993
1223
  "services",
994
1224
  name,
995
1225
  "environment",
@@ -1007,6 +1237,8 @@ const noPlaintextSecrets = {
1007
1237
  const composeSecurityRules = [
1008
1238
  noPrivilegedService,
1009
1239
  noDockerSocketMount,
1240
+ noBroadBindMount,
1241
+ preferReadOnlyBindMount,
1010
1242
  noPlaintextSecrets
1011
1243
  ];
1012
1244
 
@@ -1047,7 +1279,7 @@ const cleanPackageCache = {
1047
1279
  const diagnostics = [];
1048
1280
  for (const inst of instructions) if (inst.instruction === "RUN") {
1049
1281
  const { args } = inst;
1050
- if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
1282
+ if (hasAptGetInstall(args) && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
1051
1283
  if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk") && !hasCacheMountFor(args, APK_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
1052
1284
  }
1053
1285
  return diagnostics;
@@ -1057,7 +1289,40 @@ const cleanPackageCache = {
1057
1289
  key: "docker-doctor/clean-package-cache",
1058
1290
  message: "Clean up package manager cache in the same RUN layer"
1059
1291
  };
1060
- const installsDevDependencies = (args) => (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune");
1292
+ const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
1293
+ const WHITESPACE_RE = /\s+/u;
1294
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
1295
+ const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
1296
+ "npm",
1297
+ "yarn",
1298
+ "pnpm",
1299
+ "bun"
1300
+ ]);
1301
+ const INSTALL_VERBS = /* @__PURE__ */ new Set([
1302
+ "install",
1303
+ "ci",
1304
+ "i"
1305
+ ]);
1306
+ const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
1307
+ "-g",
1308
+ "--global",
1309
+ "--production",
1310
+ "--omit=dev",
1311
+ "--only=prod",
1312
+ "--only=production",
1313
+ "--prod",
1314
+ "-P",
1315
+ "-p"
1316
+ ]);
1317
+ const segmentInstallsDevDependencies = (segment) => {
1318
+ const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
1319
+ const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
1320
+ if (commandStart === -1) return false;
1321
+ const [manager, verb, ...options] = tokens.slice(commandStart);
1322
+ if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
1323
+ return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
1324
+ };
1325
+ const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
1061
1326
  const avoidDevDependencies = {
1062
1327
  category: "Image Size",
1063
1328
  check(instructions, file) {
@@ -1103,11 +1368,12 @@ const imageSizeRules = [
1103
1368
 
1104
1369
  //#endregion
1105
1370
  //#region ../core/src/rules/performance.ts
1371
+ const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
1106
1372
  const useMultiStage = {
1107
1373
  category: "Performance",
1108
1374
  check(instructions, file) {
1109
1375
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
1110
- if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || inst.args.includes("make")))) return [createDiagnostic(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
1376
+ if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || BUILD_TOOL_COMMAND_RE.test(maskQuotedText(inst.args))))) return [createDiagnostic(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
1111
1377
  }
1112
1378
  return [];
1113
1379
  },
@@ -1166,7 +1432,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
1166
1432
  const lastSlash = dockerfilePath.lastIndexOf("/");
1167
1433
  const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
1168
1434
  const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
1169
- return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
1435
+ const perDockerfile = `${dockerfilePath}.dockerignore`;
1436
+ return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
1170
1437
  };
1171
1438
  const useDockerignore = {
1172
1439
  category: "Performance",
@@ -1578,13 +1845,13 @@ const calculateScore = (diagnostics) => {
1578
1845
 
1579
1846
  //#endregion
1580
1847
  //#region ../core/src/report.ts
1581
- const REPORT_SCHEMA_VERSION = 3;
1848
+ const REPORT_SCHEMA_VERSION = 4;
1582
1849
  const resolveCategory = (diagnostic) => {
1583
1850
  const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1584
1851
  if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1585
1852
  return category;
1586
1853
  };
1587
- const toJsonReport = (diagnostics, score, label, project) => ({
1854
+ const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
1588
1855
  diagnostics: diagnostics.map((d) => ({
1589
1856
  category: resolveCategory(d),
1590
1857
  column: d.column,
@@ -1595,9 +1862,13 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1595
1862
  rule: d.rule,
1596
1863
  severity: d.severity
1597
1864
  })),
1865
+ failures: failures.map(({ file, message }) => ({
1866
+ file,
1867
+ message
1868
+ })),
1598
1869
  label,
1599
1870
  project,
1600
- schemaVersion: 3,
1871
+ schemaVersion: 4,
1601
1872
  score,
1602
1873
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1603
1874
  });
@@ -1681,4 +1952,4 @@ Object.defineProperty(exports, 'version', {
1681
1952
  return version;
1682
1953
  }
1683
1954
  });
1684
- //# sourceMappingURL=src-D3jzc3GX.cjs.map
1955
+ //# sourceMappingURL=src-BpAv-H0Z.cjs.map