@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.
- package/dist/cli.cjs +37 -15
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +37 -15
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +15 -13
- package/dist/index.d.mts +15 -13
- package/dist/index.mjs +1 -1
- package/dist/{src-NqSwkGiS.mjs → src-CklEIXTX.mjs} +312 -91
- package/dist/src-CklEIXTX.mjs.map +1 -0
- package/dist/{src-DYLANvt2.cjs → src-D4rVJg36.cjs} +312 -91
- package/dist/src-D4rVJg36.cjs.map +1 -0
- package/package.json +4 -4
- package/skill/docker-doctor/references/explain.md +6 -5
- package/dist/src-DYLANvt2.cjs.map +0 -1
- package/dist/src-NqSwkGiS.mjs.map +0 -1
|
@@ -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.
|
|
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 =
|
|
82
|
+
const relative = toPosixRelative(rootDir, file);
|
|
82
83
|
if (isIgnored(relative)) continue;
|
|
83
84
|
const base = path.basename(file).toLowerCase();
|
|
84
|
-
if (base
|
|
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) =>
|
|
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" &&
|
|
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())
|
|
869
|
-
|
|
870
|
-
name,
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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-
|
|
1716
|
+
//# sourceMappingURL=src-CklEIXTX.mjs.map
|