@docker-doctor/cli 0.5.1 → 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.
@@ -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.1";
7
+ var version = "0.5.3";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/ignore.ts
@@ -71,6 +71,7 @@ const walk = async (dir, fileList = []) => {
71
71
  }));
72
72
  return fileList;
73
73
  };
74
+ const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
74
75
  const discoverProject = async (rootDir, options) => {
75
76
  const allFiles = await walk(rootDir);
76
77
  const dockerfiles = [];
@@ -78,10 +79,13 @@ const discoverProject = async (rootDir, options) => {
78
79
  const dockerignores = [];
79
80
  const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
80
81
  for (const file of allFiles) {
81
- const relative = path.relative(rootDir, file);
82
+ const relative = toPosixRelative(rootDir, file);
82
83
  if (isIgnored(relative)) continue;
83
84
  const base = path.basename(file).toLowerCase();
84
- if (base === ".dockerignore") dockerignores.push(relative);
85
+ if (base.endsWith(".dockerignore")) {
86
+ dockerignores.push(relative);
87
+ continue;
88
+ }
85
89
  if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
86
90
  if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(relative);
87
91
  }
@@ -92,6 +96,72 @@ const discoverProject = async (rootDir, options) => {
92
96
  };
93
97
  };
94
98
 
99
+ //#endregion
100
+ //#region ../core/src/errors/config-error.ts
101
+ var ConfigError = class extends Error {
102
+ _tag = "ConfigError";
103
+ constructor(options) {
104
+ super(options.message);
105
+ this.name = "ConfigError";
106
+ }
107
+ };
108
+
109
+ //#endregion
110
+ //#region ../core/src/errors/parse-error.ts
111
+ var ParseError = class extends Error {
112
+ _tag = "ParseError";
113
+ file;
114
+ constructor(options) {
115
+ super(options.message);
116
+ this.name = "ParseError";
117
+ this.file = options.file;
118
+ }
119
+ };
120
+
121
+ //#endregion
122
+ //#region ../core/src/parsers/shell-quotes.ts
123
+ /**
124
+ * Replaces every character inside a single- or double-quoted region with a
125
+ * space, keeping the quotes themselves and the string length, so callers can
126
+ * run position-sensitive regexes over shell text without matching inside
127
+ * string literals. A backslash inside double quotes escapes the next
128
+ * character; single quotes have no escapes (POSIX sh). An unclosed quote
129
+ * masks to the end of the text.
130
+ *
131
+ * What this deliberately does NOT do: it is not a tokenizer. It performs no
132
+ * variable or command expansion, knows nothing about subshells, backslash
133
+ * line continuations, `$'...'` or `<<` heredoc bodies, and does not split
134
+ * words. It only blanks quoted spans so regexes keep their positions.
135
+ */
136
+ const maskQuotedText = (text) => {
137
+ let out = "";
138
+ let quote = null;
139
+ let index = 0;
140
+ while (index < text.length) {
141
+ const char = text[index];
142
+ if (quote === null) {
143
+ if (char === "\"" || char === "'") quote = char;
144
+ out += char;
145
+ index += 1;
146
+ continue;
147
+ }
148
+ if (char === quote) {
149
+ quote = null;
150
+ out += char;
151
+ index += 1;
152
+ continue;
153
+ }
154
+ if (quote === "\"" && char === "\\" && index + 1 < text.length) {
155
+ out += " ";
156
+ index += 2;
157
+ continue;
158
+ }
159
+ out += " ";
160
+ index += 1;
161
+ }
162
+ return out;
163
+ };
164
+
95
165
  //#endregion
96
166
  //#region ../core/src/parsers/dockerfile-parser.ts
97
167
  const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
@@ -154,7 +224,10 @@ const matchInstructionKeyword = (lineContent) => {
154
224
  };
155
225
  return null;
156
226
  };
157
- const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
227
+ const findHeredocDelimiters = (lineContent) => {
228
+ const masked = maskQuotedText(lineContent);
229
+ return [...lineContent.matchAll(HEREDOC_OPENER_RE)].filter((match) => masked[match.index] === "<").map((match) => match.groups?.delim ?? "");
230
+ };
158
231
  const processHeredocLine = (state, trimmed) => {
159
232
  if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
160
233
  else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
@@ -177,7 +250,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
177
250
  if (state.heredocQueue.length > 0) return;
178
251
  if (!hasContinuation) closeInstruction(state);
179
252
  };
180
- const parseDockerfile = (content) => {
253
+ const parseDockerfile = (content, file = "Dockerfile") => {
181
254
  const state = createParserState();
182
255
  const lines = content.split(/\r?\n/u);
183
256
  for (const [i, rawLine] of lines.entries()) {
@@ -190,32 +263,14 @@ const parseDockerfile = (content) => {
190
263
  if (insideHeredoc) processHeredocLine(state, trimmed);
191
264
  else processInstructionLine(state, trimmed, lineNum);
192
265
  }
266
+ if (state.heredocQueue.length > 0) throw new ParseError({
267
+ file,
268
+ message: `Unterminated heredoc: delimiter "${state.heredocQueue[0]}" (opened on line ${state.startLine}) never closed. BuildKit rejects this Dockerfile too.`
269
+ });
193
270
  closeInstruction(state);
194
271
  return state.instructions;
195
272
  };
196
273
 
197
- //#endregion
198
- //#region ../core/src/errors/config-error.ts
199
- var ConfigError = class extends Error {
200
- _tag = "ConfigError";
201
- constructor(options) {
202
- super(options.message);
203
- this.name = "ConfigError";
204
- }
205
- };
206
-
207
- //#endregion
208
- //#region ../core/src/errors/parse-error.ts
209
- var ParseError = class extends Error {
210
- _tag = "ParseError";
211
- file;
212
- constructor(options) {
213
- super(options.message);
214
- this.name = "ParseError";
215
- this.file = options.file;
216
- }
217
- };
218
-
219
274
  //#endregion
220
275
  //#region ../core/src/parsers/compose-parser.ts
221
276
  const parseCompose = (content, filepath) => {
@@ -449,6 +504,7 @@ const combineAptUpdateInstall = {
449
504
  };
450
505
  const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
451
506
  const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
507
+ const CD_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*cd(?=\s|$)/u;
452
508
  const shellDirectiveEnablesPipefail = (args) => {
453
509
  const argv = parseExecForm(args);
454
510
  return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
@@ -476,8 +532,9 @@ const usePipefail = {
476
532
  }
477
533
  if (inst.instruction !== "RUN") continue;
478
534
  const { args } = inst;
479
- if (!HAS_PIPE_RE.test(args)) continue;
480
535
  const execArgv = parseExecForm(args);
536
+ const pipeText = execArgv === null ? maskQuotedText(args) : execArgv.join(" ");
537
+ if (!HAS_PIPE_RE.test(pipeText)) continue;
481
538
  if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
482
539
  }
483
540
  return diagnostics;
@@ -506,7 +563,7 @@ const avoidRunCd = {
506
563
  category: "Best Practices",
507
564
  check(instructions, file) {
508
565
  const diagnostics = [];
509
- for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Avoid using 'cd' in RUN instructions. Use WORKDIR instead to change the working directory stably across layers.", this.help, inst.line));
566
+ for (const inst of instructions) if (inst.instruction === "RUN" && CD_COMMAND_RE.test(maskQuotedText(inst.args))) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Avoid using 'cd' in RUN instructions. Use WORKDIR instead to change the working directory stably across layers.", this.help, inst.line));
510
567
  return diagnostics;
511
568
  },
512
569
  defaultSeverity: "info",
@@ -569,12 +626,13 @@ const bestPracticesRules = [
569
626
  * with a null body (`web:` with nothing under it) is returned as an empty
570
627
  * config so rules still check it: it is the least-configured service in
571
628
  * the file, not a service to skip. Scalar and array bodies are invalid
572
- * compose and are dropped.
629
+ * compose and are dropped. A `services:` written as a YAML sequence is
630
+ * invalid Compose and yields no services.
573
631
  */
574
632
  const composeServices = (composeContent) => {
575
633
  if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
576
634
  const { services } = composeContent;
577
- if (!services || typeof services !== "object") return [];
635
+ if (!services || typeof services !== "object" || Array.isArray(services)) return [];
578
636
  const entries = [];
579
637
  for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
580
638
  else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
@@ -775,6 +833,125 @@ const pinModelVersion = {
775
833
  };
776
834
  const composeModelRules = [undefinedModelReference, pinModelVersion];
777
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
+
778
955
  //#endregion
779
956
  //#region ../core/src/rules/secret-keywords.ts
780
957
  const SECRET_KEY_PATTERNS = [
@@ -809,55 +986,6 @@ const noPrivilegedService = {
809
986
  key: "docker-doctor/no-privileged-service",
810
987
  message: "Do not run services in privileged mode"
811
988
  };
812
- const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
813
- const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
814
- const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
815
- const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
816
- const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
817
- const isDockerSocketPath = (rawPath) => {
818
- const normalized = rawPath.replaceAll("\\", "/");
819
- return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
820
- };
821
- const splitShortSyntax = (spec) => {
822
- const parts = [];
823
- let depth = 0;
824
- let current = "";
825
- for (const char of spec) {
826
- if (char === "{") depth += 1;
827
- else if (char === "}") depth = Math.max(0, depth - 1);
828
- if (char === ":" && depth === 0) {
829
- parts.push(current);
830
- current = "";
831
- continue;
832
- }
833
- current += char;
834
- }
835
- parts.push(current);
836
- return parts;
837
- };
838
- const volumeMount = (volume) => {
839
- if (typeof volume === "string") {
840
- const [source, target] = splitShortSyntax(volume);
841
- return target === void 0 ? void 0 : {
842
- source,
843
- target
844
- };
845
- }
846
- if (volume && typeof volume === "object") {
847
- const { source, target } = volume;
848
- return {
849
- source: typeof source === "string" ? source : void 0,
850
- target: typeof target === "string" ? target : void 0
851
- };
852
- }
853
- };
854
- const mountsDockerSocket = (volume) => {
855
- const mount = volumeMount(volume);
856
- if (!mount?.source) return false;
857
- const resolvedSource = resolveInterpolationDefaults(mount.source);
858
- if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
859
- return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
860
- };
861
989
  const noDockerSocketMount = {
862
990
  category: "Compose",
863
991
  check(composeContent, file, context) {
@@ -865,12 +993,15 @@ const noDockerSocketMount = {
865
993
  for (const [name, config] of composeServices(composeContent)) {
866
994
  const { volumes } = config;
867
995
  if (!Array.isArray(volumes)) continue;
868
- 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?.([
869
- "services",
870
- name,
871
- "volumes",
872
- index
873
- ])));
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
+ }
874
1005
  }
875
1006
  return diagnostics;
876
1007
  },
@@ -879,6 +1010,69 @@ const noDockerSocketMount = {
879
1010
  key: "docker-doctor/no-docker-socket-mount",
880
1011
  message: "Do not bind-mount the Docker socket into services"
881
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
+ };
882
1076
  const noPlaintextSecrets = {
883
1077
  category: "Compose",
884
1078
  check(composeContent, file, context) {
@@ -920,6 +1114,8 @@ const noPlaintextSecrets = {
920
1114
  const composeSecurityRules = [
921
1115
  noPrivilegedService,
922
1116
  noDockerSocketMount,
1117
+ noBroadBindMount,
1118
+ preferReadOnlyBindMount,
923
1119
  noPlaintextSecrets
924
1120
  ];
925
1121
 
@@ -1242,12 +1438,16 @@ const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[r
1242
1438
  //#endregion
1243
1439
  //#region ../core/src/runners/dockerfile-runner.ts
1244
1440
  const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
1441
+ if (instructions.length === 0) return [];
1245
1442
  const diagnostics = [];
1246
1443
  for (const rule of allDockerfileRules) {
1247
1444
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
1248
1445
  if (severity === "off") continue;
1249
1446
  const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
1250
- if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
1447
+ for (const diag of ruleDiagnostics) {
1448
+ diag.category = rule.category;
1449
+ if (severity !== rule.defaultSeverity) diag.severity = severity;
1450
+ }
1251
1451
  diagnostics.push(...ruleDiagnostics);
1252
1452
  }
1253
1453
  return diagnostics;
@@ -1261,7 +1461,10 @@ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, lo
1261
1461
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
1262
1462
  if (severity === "off") continue;
1263
1463
  const ruleDiagnostics = rule.check(composeContent, file, { locate });
1264
- if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
1464
+ for (const diag of ruleDiagnostics) {
1465
+ diag.category = rule.category;
1466
+ if (severity !== rule.defaultSeverity) diag.severity = severity;
1467
+ }
1265
1468
  diagnostics.push(...ruleDiagnostics);
1266
1469
  }
1267
1470
  return diagnostics;
@@ -1371,6 +1574,16 @@ const parseConfigFile = async (filePath, format, parse) => {
1371
1574
  throw new ConfigError({ message: `Failed to parse config ${format}: ${msg}` });
1372
1575
  }
1373
1576
  };
1577
+ const TYPESCRIPT_CONFIG_PATTERN = /\.[cm]?ts$/u;
1578
+ const TYPE_STRIPPING_FAILURES = {
1579
+ ERR_UNKNOWN_FILE_EXTENSION: "this Node.js is not stripping TypeScript types, which needs 22.18 or newer with stripping left on (--no-experimental-strip-types turns it off)",
1580
+ ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING: "Node.js never strips TypeScript types for files under node_modules, on any version, so the config has to live outside it"
1581
+ };
1582
+ const typeStrippingFailure = (filePath, error) => {
1583
+ if (!TYPESCRIPT_CONFIG_PATTERN.test(filePath)) return;
1584
+ if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string") return;
1585
+ return TYPE_STRIPPING_FAILURES[error.code];
1586
+ };
1374
1587
  const importConfig = async (filePath) => {
1375
1588
  if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
1376
1589
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", parse);
@@ -1378,6 +1591,8 @@ const importConfig = async (filePath) => {
1378
1591
  const configModule = await import(filePath);
1379
1592
  return configModule.default || configModule;
1380
1593
  } catch (error) {
1594
+ const reason = typeStrippingFailure(filePath, error);
1595
+ if (reason) throw new ConfigError({ message: `Cannot load ${filePath} on Node.js ${process.version}: ${reason}. A docker-doctor.config.yaml or .json needs no TypeScript support at all.` });
1381
1596
  const msg = error instanceof Error ? error.message : String(error);
1382
1597
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
1383
1598
  }
@@ -1472,9 +1687,15 @@ const calculateScore = (diagnostics) => {
1472
1687
 
1473
1688
  //#endregion
1474
1689
  //#region ../core/src/report.ts
1475
- const REPORT_SCHEMA_VERSION = 2;
1690
+ const REPORT_SCHEMA_VERSION = 3;
1691
+ const resolveCategory = (diagnostic) => {
1692
+ const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1693
+ if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1694
+ return category;
1695
+ };
1476
1696
  const toJsonReport = (diagnostics, score, label, project) => ({
1477
1697
  diagnostics: diagnostics.map((d) => ({
1698
+ category: resolveCategory(d),
1478
1699
  column: d.column,
1479
1700
  file: d.file,
1480
1701
  help: d.help,
@@ -1485,11 +1706,11 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1485
1706
  })),
1486
1707
  label,
1487
1708
  project,
1488
- schemaVersion: 2,
1709
+ schemaVersion: 3,
1489
1710
  score,
1490
1711
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1491
1712
  });
1492
1713
 
1493
1714
  //#endregion
1494
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 };
1495
- //# sourceMappingURL=src-NqSwkGiS.mjs.map
1716
+ //# sourceMappingURL=src-CklEIXTX.mjs.map