@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.
@@ -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.6.0";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/ignore.ts
@@ -60,20 +60,66 @@ const createIgnoreMatcher = (patterns) => {
60
60
 
61
61
  //#endregion
62
62
  //#region ../core/src/project-info/discover.ts
63
- const walk = async (dir, fileList = []) => {
64
- const files = await fs.readdir(dir, { withFileTypes: true });
63
+ const PRUNED_DIRECTORIES = [
64
+ ".git",
65
+ ".hg",
66
+ ".svn",
67
+ "node_modules",
68
+ "bower_components",
69
+ ".pnpm-store",
70
+ ".yarn",
71
+ ".venv",
72
+ "venv",
73
+ "__pycache__",
74
+ ".tox",
75
+ ".mypy_cache",
76
+ ".pytest_cache",
77
+ ".ruff_cache",
78
+ ".gradle",
79
+ ".turbo",
80
+ ".next",
81
+ ".nuxt",
82
+ ".svelte-kit",
83
+ ".parcel-cache",
84
+ ".cache",
85
+ "coverage",
86
+ "dist"
87
+ ];
88
+ const PRUNED = new Set(PRUNED_DIRECTORIES);
89
+ const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
90
+ const SUBTREE_SUFFIX = "/**";
91
+ const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
92
+ const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
93
+ const walk = async (dir, options, fileList = []) => {
94
+ let files;
95
+ try {
96
+ files = await fs.readdir(dir, { withFileTypes: true });
97
+ } catch (error) {
98
+ if (dir !== options.rootDir && isPermissionError(error)) {
99
+ options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
100
+ return fileList;
101
+ }
102
+ throw error;
103
+ }
65
104
  await Promise.all(files.map(async (file) => {
66
105
  const filePath = path.join(dir, file.name);
106
+ if (file.isSymbolicLink()) {
107
+ fileList.push(filePath);
108
+ return;
109
+ }
67
110
  if (file.isDirectory()) {
68
- if (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
69
- await walk(filePath, fileList);
111
+ if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
112
+ await walk(filePath, options, fileList);
70
113
  } else fileList.push(filePath);
71
114
  }));
72
115
  return fileList;
73
116
  };
74
- const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
75
117
  const discoverProject = async (rootDir, options) => {
76
- const allFiles = await walk(rootDir);
118
+ const allFiles = await walk(rootDir, {
119
+ isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
120
+ onSkippedDirectory: options?.onSkippedDirectory,
121
+ rootDir
122
+ });
77
123
  const dockerfiles = [];
78
124
  const composeFiles = [];
79
125
  const dockerignores = [];
@@ -258,7 +304,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
258
304
  const lineNum = i + 1;
259
305
  const insideHeredoc = state.heredocQueue.length > 0;
260
306
  if (!insideHeredoc && trimmed.startsWith("#")) continue;
261
- if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
307
+ if (!insideHeredoc && trimmed === "") continue;
262
308
  state.rawAccumulator.push(rawLine);
263
309
  if (insideHeredoc) processHeredocLine(state, trimmed);
264
310
  else processInstructionLine(state, trimmed, lineNum);
@@ -429,6 +475,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
429
475
  severity
430
476
  });
431
477
 
478
+ //#endregion
479
+ //#region ../core/src/rules/package-managers.ts
480
+ const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
481
+ const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
482
+ const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
483
+ const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
484
+
432
485
  //#endregion
433
486
  //#region ../core/src/rules/best-practices.ts
434
487
  const requireHealthcheck = {
@@ -444,6 +497,16 @@ const requireHealthcheck = {
444
497
  key: "docker-doctor/require-healthcheck",
445
498
  message: "Add a HEALTHCHECK instruction"
446
499
  };
500
+ const AUTO_EXTRACT_SUFFIXES = [
501
+ ".tar",
502
+ ".tar.gz",
503
+ ".tgz",
504
+ ".tar.bz2",
505
+ ".tbz2",
506
+ ".tbz",
507
+ ".tar.xz",
508
+ ".txz"
509
+ ];
447
510
  const preferCopyOverAdd = {
448
511
  category: "Best Practices",
449
512
  check(instructions, file) {
@@ -452,7 +515,7 @@ const preferCopyOverAdd = {
452
515
  const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
453
516
  if (!src) continue;
454
517
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
455
- const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
518
+ const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
456
519
  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));
457
520
  }
458
521
  return diagnostics;
@@ -490,8 +553,8 @@ const combineAptUpdateInstall = {
490
553
  check(instructions, file) {
491
554
  const diagnostics = [];
492
555
  for (const inst of instructions) if (inst.instruction === "RUN") {
493
- const hasUpdate = inst.args.includes("apt-get update");
494
- const hasInstall = inst.args.includes("apt-get install");
556
+ const hasUpdate = hasAptGetUpdate(inst.args);
557
+ const hasInstall = hasAptGetInstall(inst.args);
495
558
  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));
496
559
  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));
497
560
  }
@@ -571,18 +634,32 @@ const avoidRunCd = {
571
634
  key: "docker-doctor/avoid-run-cd",
572
635
  message: "Avoid changing directories with cd in RUN"
573
636
  };
637
+ const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
638
+ const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
639
+ const WHITESPACE_RE$1 = /\s+/u;
640
+ const collectPackageList = (raw) => {
641
+ const start = PACKAGE_LIST_START_RE.exec(raw);
642
+ if (!start) return [];
643
+ const packages = [];
644
+ const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
645
+ for (const token of tokens) {
646
+ if (SHELL_SEPARATOR_RE$1.test(token)) break;
647
+ if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
648
+ }
649
+ return packages;
650
+ };
574
651
  const sortMultilineArgs = {
575
652
  category: "Best Practices",
576
653
  check(instructions, file) {
577
654
  const diagnostics = [];
578
655
  for (const inst of instructions) if (inst.instruction === "RUN") {
579
656
  const { raw } = inst;
580
- const isPackageInstall = raw.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
657
+ const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
581
658
  const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
582
659
  if (isPackageInstall && hasContinuation) {
583
- 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);
660
+ const packages = collectPackageList(raw);
584
661
  if (packages.length > 1) {
585
- const sorted = packages.toSorted((a, b) => a.localeCompare(b));
662
+ const sorted = packages.toSorted();
586
663
  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));
587
664
  }
588
665
  }
@@ -834,48 +911,21 @@ const pinModelVersion = {
834
911
  const composeModelRules = [undefinedModelReference, pinModelVersion];
835
912
 
836
913
  //#endregion
837
- //#region ../core/src/rules/secret-keywords.ts
838
- const SECRET_KEY_PATTERNS = [
839
- /password(?:[_-]|$)/iu,
840
- /(?:^|[_-])secret(?:[_-]|$)/iu,
841
- /(?:^|[_-])token(?:[_-]|$)/iu,
842
- /(?:^|[_-])api_key(?:[_-]|$)/iu,
843
- /(?:^|[_-])apikey(?:[_-]|$)/iu,
844
- /(?:^|[_-])private_key(?:[_-]|$)/iu,
845
- /(?:^|[_-])auth(?:[_-]|$)/iu,
846
- /(?:^|[_-])pat(?:[_-]|$)/iu
847
- ];
848
- const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
849
- const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
850
- const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
851
-
852
- //#endregion
853
- //#region ../core/src/rules/compose-security.ts
854
- const noPrivilegedService = {
855
- category: "Compose",
856
- check(composeContent, file, context) {
857
- const diagnostics = [];
858
- 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?.([
859
- "services",
860
- name,
861
- "privileged"
862
- ])));
863
- return diagnostics;
864
- },
865
- defaultSeverity: "error",
866
- help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
867
- key: "docker-doctor/no-privileged-service",
868
- message: "Do not run services in privileged mode"
869
- };
870
- const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
914
+ //#region ../core/src/rules/compose-mounts.ts
915
+ /**
916
+ * Shared parsing for Compose `volumes:` entries, used by every rule that
917
+ * reasons about bind mounts. A Compose volume entry is either the short
918
+ * `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
919
+ * to a `VolumeMount`.
920
+ */
871
921
  const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
872
922
  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, "");
923
+ const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
924
+ const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
925
+ const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
926
+ const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
927
+ const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
874
928
  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
929
  const splitShortSyntax = (spec) => {
880
930
  const parts = [];
881
931
  let depth = 0;
@@ -893,28 +943,137 @@ const splitShortSyntax = (spec) => {
893
943
  parts.push(current);
894
944
  return parts;
895
945
  };
946
+ const DRIVE_LETTER = /^[A-Za-z]$/u;
947
+ const joinDriveLetter = (parts) => {
948
+ const [first, second, ...rest] = parts;
949
+ if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
950
+ return parts;
951
+ };
952
+ const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
896
953
  const volumeMount = (volume) => {
897
954
  if (typeof volume === "string") {
898
- const [source, target] = splitShortSyntax(volume);
899
- return target === void 0 ? void 0 : {
955
+ const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
956
+ if (target === void 0) return;
957
+ return {
958
+ readOnly: shortSyntaxReadOnly(mode),
900
959
  source,
901
- target
960
+ target,
961
+ type: void 0
902
962
  };
903
963
  }
904
964
  if (volume && typeof volume === "object") {
905
- const { source, target } = volume;
965
+ const { read_only: readOnly, source, target, type } = volume;
906
966
  return {
967
+ readOnly: readOnly === true,
907
968
  source: typeof source === "string" ? source : void 0,
908
- target: typeof target === "string" ? target : void 0
969
+ target: typeof target === "string" ? target : void 0,
970
+ type: typeof type === "string" ? type : void 0
909
971
  };
910
972
  }
911
973
  };
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;
974
+ /**
975
+ * The host path a bind mount reads from, with interpolation defaults
976
+ * applied and Windows separators folded to `/`. Undefined when the entry is
977
+ * not a bind mount (named volume, tmpfs, anonymous volume) or when the
978
+ * source starts with an interpolation that has no default. A source such as
979
+ * `${DATA_DIR}/pg` names no known host location, so no rule can say whether
980
+ * it is inside the project.
981
+ */
982
+ const bindMountSource = (mount) => {
983
+ if (mount.type !== void 0 && mount.type !== "bind") return;
984
+ if (!mount.source) return;
985
+ const withDefaults = applyFoldsAndDefaults(mount.source);
986
+ if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
987
+ const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
988
+ if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
989
+ return resolved;
990
+ };
991
+ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
992
+ const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
993
+ /**
994
+ * Whether the entry bind-mounts the Docker socket, under any of its host
995
+ * spellings. A source that starts with `${VAR}` names no known host path,
996
+ * but a `/docker.sock` suffix or a socket target still identifies it.
997
+ */
998
+ const mountsDockerSocket = (mount) => {
999
+ const source = bindMountSource(mount);
1000
+ if (source !== void 0) return isDockerSocketPath(source);
1001
+ if (mount.type !== void 0 && mount.type !== "bind") return false;
1002
+ if (!mount.source) return false;
1003
+ const remainder = resolveInterpolationDefaults(mount.source);
1004
+ return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
1005
+ };
1006
+ const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
1007
+ const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
1008
+ const SYSTEM_DIRECTORIES = [
1009
+ "/etc",
1010
+ "/root",
1011
+ "/home",
1012
+ "/Users",
1013
+ "/proc",
1014
+ "/sys",
1015
+ "/boot",
1016
+ "/run",
1017
+ "/var/run"
1018
+ ];
1019
+ const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
1020
+ /**
1021
+ * Classifies where on the host a bind-mount source points. `project` is a
1022
+ * relative path that stays inside the Compose project directory; everything
1023
+ * else reaches outside it.
1024
+ */
1025
+ const classifyHostPath = (hostPath) => {
1026
+ const collapsed = path.posix.normalize(hostPath);
1027
+ const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
1028
+ if (normalized === "~") return "home";
1029
+ if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
1030
+ if (normalized === "." || normalized.startsWith("./")) return "project";
1031
+ if (normalized === ".." || normalized.startsWith("../")) return "parent";
1032
+ if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
1033
+ if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
1034
+ if (normalized === "/") return "root";
1035
+ if (!normalized.startsWith("/")) return "project";
1036
+ for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
1037
+ return "host";
1038
+ };
1039
+
1040
+ //#endregion
1041
+ //#region ../core/src/rules/secret-keywords.ts
1042
+ const SECRET_KEY_PATTERNS = [
1043
+ /password(?:[_-]|$)/iu,
1044
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
1045
+ /(?:^|[_-])token(?:[_-]|$)/iu,
1046
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
1047
+ /(?:^|[_-])apikey(?:[_-]|$)/iu,
1048
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
1049
+ /(?:^|[_-])auth(?:[_-]|$)/iu,
1050
+ /(?:^|[_-])pat(?:[_-]|$)/iu
1051
+ ];
1052
+ const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
1053
+ const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
1054
+ const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
1055
+ const isLiteralSecretValue = (value) => {
1056
+ const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
1057
+ return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
1058
+ };
1059
+
1060
+ //#endregion
1061
+ //#region ../core/src/rules/compose-security.ts
1062
+ const noPrivilegedService = {
1063
+ category: "Compose",
1064
+ check(composeContent, file, context) {
1065
+ const diagnostics = [];
1066
+ 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?.([
1067
+ "services",
1068
+ name,
1069
+ "privileged"
1070
+ ])));
1071
+ return diagnostics;
1072
+ },
1073
+ defaultSeverity: "error",
1074
+ help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
1075
+ key: "docker-doctor/no-privileged-service",
1076
+ message: "Do not run services in privileged mode"
918
1077
  };
919
1078
  const noDockerSocketMount = {
920
1079
  category: "Compose",
@@ -923,12 +1082,15 @@ const noDockerSocketMount = {
923
1082
  for (const [name, config] of composeServices(composeContent)) {
924
1083
  const { volumes } = config;
925
1084
  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
- ])));
1085
+ for (const [index, volume] of volumes.entries()) {
1086
+ const mount = volumeMount(volume);
1087
+ 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?.([
1088
+ "services",
1089
+ name,
1090
+ "volumes",
1091
+ index
1092
+ ])));
1093
+ }
932
1094
  }
933
1095
  return diagnostics;
934
1096
  },
@@ -937,6 +1099,73 @@ const noDockerSocketMount = {
937
1099
  key: "docker-doctor/no-docker-socket-mount",
938
1100
  message: "Do not bind-mount the Docker socket into services"
939
1101
  };
1102
+ const BROAD_SCOPE_REASONS = {
1103
+ home: "the whole home directory",
1104
+ "home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
1105
+ parent: "a directory above the Compose project",
1106
+ root: "the host's root filesystem",
1107
+ system: "a host system directory"
1108
+ };
1109
+ const noBroadBindMount = {
1110
+ category: "Compose",
1111
+ check(composeContent, file, context) {
1112
+ const diagnostics = [];
1113
+ for (const [name, config] of composeServices(composeContent)) {
1114
+ const { volumes } = config;
1115
+ if (!Array.isArray(volumes)) continue;
1116
+ for (const [index, volume] of volumes.entries()) {
1117
+ const mount = volumeMount(volume);
1118
+ if (!mount || mountsDockerSocket(mount)) continue;
1119
+ const source = bindMountSource(mount);
1120
+ if (source === void 0) continue;
1121
+ const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
1122
+ if (reason === void 0) continue;
1123
+ 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?.([
1124
+ "services",
1125
+ name,
1126
+ "volumes",
1127
+ index
1128
+ ])));
1129
+ }
1130
+ }
1131
+ return diagnostics;
1132
+ },
1133
+ defaultSeverity: "warning",
1134
+ 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.",
1135
+ key: "docker-doctor/no-broad-bind-mount",
1136
+ message: "Do not bind-mount broad host directories into services"
1137
+ };
1138
+ const preferReadOnlyBindMount = {
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 || mount.readOnly || mountsDockerSocket(mount)) continue;
1148
+ const source = bindMountSource(mount);
1149
+ if (source === void 0 || classifyHostPath(source) === "project") continue;
1150
+ 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?.([
1151
+ "services",
1152
+ name,
1153
+ "volumes",
1154
+ index
1155
+ ])));
1156
+ }
1157
+ }
1158
+ return diagnostics;
1159
+ },
1160
+ defaultSeverity: "info",
1161
+ 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.",
1162
+ key: "docker-doctor/prefer-read-only-bind-mount",
1163
+ message: "Bind mounts outside the project should be read-only"
1164
+ };
1165
+ const literalValue = (value) => {
1166
+ if (typeof value === "string") return value;
1167
+ if (typeof value === "number") return String(value);
1168
+ };
940
1169
  const noPlaintextSecrets = {
941
1170
  category: "Compose",
942
1171
  check(composeContent, file, context) {
@@ -952,15 +1181,16 @@ const noPlaintextSecrets = {
952
1181
  if (eqIndex <= 0) continue;
953
1182
  const key = entry.slice(0, eqIndex);
954
1183
  const value = entry.slice(eqIndex + 1);
955
- if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1184
+ if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
956
1185
  "services",
957
1186
  name,
958
1187
  "environment",
959
1188
  index
960
1189
  ]));
961
1190
  }
962
- else if (environment && typeof environment === "object") {
963
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1191
+ else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
1192
+ const literal = literalValue(value);
1193
+ if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
964
1194
  "services",
965
1195
  name,
966
1196
  "environment",
@@ -978,6 +1208,8 @@ const noPlaintextSecrets = {
978
1208
  const composeSecurityRules = [
979
1209
  noPrivilegedService,
980
1210
  noDockerSocketMount,
1211
+ noBroadBindMount,
1212
+ preferReadOnlyBindMount,
981
1213
  noPlaintextSecrets
982
1214
  ];
983
1215
 
@@ -1018,7 +1250,7 @@ const cleanPackageCache = {
1018
1250
  const diagnostics = [];
1019
1251
  for (const inst of instructions) if (inst.instruction === "RUN") {
1020
1252
  const { args } = inst;
1021
- 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));
1253
+ 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));
1022
1254
  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));
1023
1255
  }
1024
1256
  return diagnostics;
@@ -1028,7 +1260,40 @@ const cleanPackageCache = {
1028
1260
  key: "docker-doctor/clean-package-cache",
1029
1261
  message: "Clean up package manager cache in the same RUN layer"
1030
1262
  };
1031
- 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");
1263
+ const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
1264
+ const WHITESPACE_RE = /\s+/u;
1265
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
1266
+ const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
1267
+ "npm",
1268
+ "yarn",
1269
+ "pnpm",
1270
+ "bun"
1271
+ ]);
1272
+ const INSTALL_VERBS = /* @__PURE__ */ new Set([
1273
+ "install",
1274
+ "ci",
1275
+ "i"
1276
+ ]);
1277
+ const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
1278
+ "-g",
1279
+ "--global",
1280
+ "--production",
1281
+ "--omit=dev",
1282
+ "--only=prod",
1283
+ "--only=production",
1284
+ "--prod",
1285
+ "-P",
1286
+ "-p"
1287
+ ]);
1288
+ const segmentInstallsDevDependencies = (segment) => {
1289
+ const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
1290
+ const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
1291
+ if (commandStart === -1) return false;
1292
+ const [manager, verb, ...options] = tokens.slice(commandStart);
1293
+ if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
1294
+ return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
1295
+ };
1296
+ const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
1032
1297
  const avoidDevDependencies = {
1033
1298
  category: "Image Size",
1034
1299
  check(instructions, file) {
@@ -1074,11 +1339,12 @@ const imageSizeRules = [
1074
1339
 
1075
1340
  //#endregion
1076
1341
  //#region ../core/src/rules/performance.ts
1342
+ const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
1077
1343
  const useMultiStage = {
1078
1344
  category: "Performance",
1079
1345
  check(instructions, file) {
1080
1346
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
1081
- 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)];
1347
+ 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)];
1082
1348
  }
1083
1349
  return [];
1084
1350
  },
@@ -1137,7 +1403,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
1137
1403
  const lastSlash = dockerfilePath.lastIndexOf("/");
1138
1404
  const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
1139
1405
  const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
1140
- return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
1406
+ const perDockerfile = `${dockerfilePath}.dockerignore`;
1407
+ return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
1141
1408
  };
1142
1409
  const useDockerignore = {
1143
1410
  category: "Performance",
@@ -1549,13 +1816,13 @@ const calculateScore = (diagnostics) => {
1549
1816
 
1550
1817
  //#endregion
1551
1818
  //#region ../core/src/report.ts
1552
- const REPORT_SCHEMA_VERSION = 3;
1819
+ const REPORT_SCHEMA_VERSION = 4;
1553
1820
  const resolveCategory = (diagnostic) => {
1554
1821
  const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1555
1822
  if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1556
1823
  return category;
1557
1824
  };
1558
- const toJsonReport = (diagnostics, score, label, project) => ({
1825
+ const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
1559
1826
  diagnostics: diagnostics.map((d) => ({
1560
1827
  category: resolveCategory(d),
1561
1828
  column: d.column,
@@ -1566,13 +1833,17 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1566
1833
  rule: d.rule,
1567
1834
  severity: d.severity
1568
1835
  })),
1836
+ failures: failures.map(({ file, message }) => ({
1837
+ file,
1838
+ message
1839
+ })),
1569
1840
  label,
1570
1841
  project,
1571
- schemaVersion: 3,
1842
+ schemaVersion: 4,
1572
1843
  score,
1573
1844
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1574
1845
  });
1575
1846
 
1576
1847
  //#endregion
1577
1848
  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
1849
+ //# sourceMappingURL=src-BLeXr_hR.mjs.map