@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
|
@@ -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.
|
|
36
|
+
var version = "0.5.3";
|
|
37
37
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region ../core/src/project-info/ignore.ts
|
|
@@ -100,6 +100,7 @@ const walk = async (dir, fileList = []) => {
|
|
|
100
100
|
}));
|
|
101
101
|
return fileList;
|
|
102
102
|
};
|
|
103
|
+
const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
|
|
103
104
|
const discoverProject = async (rootDir, options) => {
|
|
104
105
|
const allFiles = await walk(rootDir);
|
|
105
106
|
const dockerfiles = [];
|
|
@@ -107,10 +108,13 @@ const discoverProject = async (rootDir, options) => {
|
|
|
107
108
|
const dockerignores = [];
|
|
108
109
|
const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
|
|
109
110
|
for (const file of allFiles) {
|
|
110
|
-
const relative =
|
|
111
|
+
const relative = toPosixRelative(rootDir, file);
|
|
111
112
|
if (isIgnored(relative)) continue;
|
|
112
113
|
const base = node_path.default.basename(file).toLowerCase();
|
|
113
|
-
if (base
|
|
114
|
+
if (base.endsWith(".dockerignore")) {
|
|
115
|
+
dockerignores.push(relative);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
114
118
|
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
|
|
115
119
|
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);
|
|
116
120
|
}
|
|
@@ -121,6 +125,72 @@ const discoverProject = async (rootDir, options) => {
|
|
|
121
125
|
};
|
|
122
126
|
};
|
|
123
127
|
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region ../core/src/errors/config-error.ts
|
|
130
|
+
var ConfigError = class extends Error {
|
|
131
|
+
_tag = "ConfigError";
|
|
132
|
+
constructor(options) {
|
|
133
|
+
super(options.message);
|
|
134
|
+
this.name = "ConfigError";
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region ../core/src/errors/parse-error.ts
|
|
140
|
+
var ParseError = class extends Error {
|
|
141
|
+
_tag = "ParseError";
|
|
142
|
+
file;
|
|
143
|
+
constructor(options) {
|
|
144
|
+
super(options.message);
|
|
145
|
+
this.name = "ParseError";
|
|
146
|
+
this.file = options.file;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region ../core/src/parsers/shell-quotes.ts
|
|
152
|
+
/**
|
|
153
|
+
* Replaces every character inside a single- or double-quoted region with a
|
|
154
|
+
* space, keeping the quotes themselves and the string length, so callers can
|
|
155
|
+
* run position-sensitive regexes over shell text without matching inside
|
|
156
|
+
* string literals. A backslash inside double quotes escapes the next
|
|
157
|
+
* character; single quotes have no escapes (POSIX sh). An unclosed quote
|
|
158
|
+
* masks to the end of the text.
|
|
159
|
+
*
|
|
160
|
+
* What this deliberately does NOT do: it is not a tokenizer. It performs no
|
|
161
|
+
* variable or command expansion, knows nothing about subshells, backslash
|
|
162
|
+
* line continuations, `$'...'` or `<<` heredoc bodies, and does not split
|
|
163
|
+
* words. It only blanks quoted spans so regexes keep their positions.
|
|
164
|
+
*/
|
|
165
|
+
const maskQuotedText = (text) => {
|
|
166
|
+
let out = "";
|
|
167
|
+
let quote = null;
|
|
168
|
+
let index = 0;
|
|
169
|
+
while (index < text.length) {
|
|
170
|
+
const char = text[index];
|
|
171
|
+
if (quote === null) {
|
|
172
|
+
if (char === "\"" || char === "'") quote = char;
|
|
173
|
+
out += char;
|
|
174
|
+
index += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (char === quote) {
|
|
178
|
+
quote = null;
|
|
179
|
+
out += char;
|
|
180
|
+
index += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (quote === "\"" && char === "\\" && index + 1 < text.length) {
|
|
184
|
+
out += " ";
|
|
185
|
+
index += 2;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
out += " ";
|
|
189
|
+
index += 1;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
};
|
|
193
|
+
|
|
124
194
|
//#endregion
|
|
125
195
|
//#region ../core/src/parsers/dockerfile-parser.ts
|
|
126
196
|
const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
@@ -183,7 +253,10 @@ const matchInstructionKeyword = (lineContent) => {
|
|
|
183
253
|
};
|
|
184
254
|
return null;
|
|
185
255
|
};
|
|
186
|
-
const findHeredocDelimiters = (lineContent) =>
|
|
256
|
+
const findHeredocDelimiters = (lineContent) => {
|
|
257
|
+
const masked = maskQuotedText(lineContent);
|
|
258
|
+
return [...lineContent.matchAll(HEREDOC_OPENER_RE)].filter((match) => masked[match.index] === "<").map((match) => match.groups?.delim ?? "");
|
|
259
|
+
};
|
|
187
260
|
const processHeredocLine = (state, trimmed) => {
|
|
188
261
|
if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
|
|
189
262
|
else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
|
|
@@ -206,7 +279,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
|
|
|
206
279
|
if (state.heredocQueue.length > 0) return;
|
|
207
280
|
if (!hasContinuation) closeInstruction(state);
|
|
208
281
|
};
|
|
209
|
-
const parseDockerfile = (content) => {
|
|
282
|
+
const parseDockerfile = (content, file = "Dockerfile") => {
|
|
210
283
|
const state = createParserState();
|
|
211
284
|
const lines = content.split(/\r?\n/u);
|
|
212
285
|
for (const [i, rawLine] of lines.entries()) {
|
|
@@ -219,32 +292,14 @@ const parseDockerfile = (content) => {
|
|
|
219
292
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
220
293
|
else processInstructionLine(state, trimmed, lineNum);
|
|
221
294
|
}
|
|
295
|
+
if (state.heredocQueue.length > 0) throw new ParseError({
|
|
296
|
+
file,
|
|
297
|
+
message: `Unterminated heredoc: delimiter "${state.heredocQueue[0]}" (opened on line ${state.startLine}) never closed. BuildKit rejects this Dockerfile too.`
|
|
298
|
+
});
|
|
222
299
|
closeInstruction(state);
|
|
223
300
|
return state.instructions;
|
|
224
301
|
};
|
|
225
302
|
|
|
226
|
-
//#endregion
|
|
227
|
-
//#region ../core/src/errors/config-error.ts
|
|
228
|
-
var ConfigError = class extends Error {
|
|
229
|
-
_tag = "ConfigError";
|
|
230
|
-
constructor(options) {
|
|
231
|
-
super(options.message);
|
|
232
|
-
this.name = "ConfigError";
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
//#endregion
|
|
237
|
-
//#region ../core/src/errors/parse-error.ts
|
|
238
|
-
var ParseError = class extends Error {
|
|
239
|
-
_tag = "ParseError";
|
|
240
|
-
file;
|
|
241
|
-
constructor(options) {
|
|
242
|
-
super(options.message);
|
|
243
|
-
this.name = "ParseError";
|
|
244
|
-
this.file = options.file;
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
|
|
248
303
|
//#endregion
|
|
249
304
|
//#region ../core/src/parsers/compose-parser.ts
|
|
250
305
|
const parseCompose = (content, filepath) => {
|
|
@@ -478,6 +533,7 @@ const combineAptUpdateInstall = {
|
|
|
478
533
|
};
|
|
479
534
|
const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
|
|
480
535
|
const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
|
|
536
|
+
const CD_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*cd(?=\s|$)/u;
|
|
481
537
|
const shellDirectiveEnablesPipefail = (args) => {
|
|
482
538
|
const argv = parseExecForm(args);
|
|
483
539
|
return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
|
|
@@ -505,8 +561,9 @@ const usePipefail = {
|
|
|
505
561
|
}
|
|
506
562
|
if (inst.instruction !== "RUN") continue;
|
|
507
563
|
const { args } = inst;
|
|
508
|
-
if (!HAS_PIPE_RE.test(args)) continue;
|
|
509
564
|
const execArgv = parseExecForm(args);
|
|
565
|
+
const pipeText = execArgv === null ? maskQuotedText(args) : execArgv.join(" ");
|
|
566
|
+
if (!HAS_PIPE_RE.test(pipeText)) continue;
|
|
510
567
|
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));
|
|
511
568
|
}
|
|
512
569
|
return diagnostics;
|
|
@@ -535,7 +592,7 @@ const avoidRunCd = {
|
|
|
535
592
|
category: "Best Practices",
|
|
536
593
|
check(instructions, file) {
|
|
537
594
|
const diagnostics = [];
|
|
538
|
-
for (const inst of instructions) if (inst.instruction === "RUN" &&
|
|
595
|
+
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));
|
|
539
596
|
return diagnostics;
|
|
540
597
|
},
|
|
541
598
|
defaultSeverity: "info",
|
|
@@ -598,12 +655,13 @@ const bestPracticesRules = [
|
|
|
598
655
|
* with a null body (`web:` with nothing under it) is returned as an empty
|
|
599
656
|
* config so rules still check it: it is the least-configured service in
|
|
600
657
|
* the file, not a service to skip. Scalar and array bodies are invalid
|
|
601
|
-
* compose and are dropped.
|
|
658
|
+
* compose and are dropped. A `services:` written as a YAML sequence is
|
|
659
|
+
* invalid Compose and yields no services.
|
|
602
660
|
*/
|
|
603
661
|
const composeServices = (composeContent) => {
|
|
604
662
|
if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
|
|
605
663
|
const { services } = composeContent;
|
|
606
|
-
if (!services || typeof services !== "object") return [];
|
|
664
|
+
if (!services || typeof services !== "object" || Array.isArray(services)) return [];
|
|
607
665
|
const entries = [];
|
|
608
666
|
for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
|
|
609
667
|
else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
|
|
@@ -804,6 +862,125 @@ const pinModelVersion = {
|
|
|
804
862
|
};
|
|
805
863
|
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
806
864
|
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region ../core/src/rules/compose-mounts.ts
|
|
867
|
+
/**
|
|
868
|
+
* Shared parsing for Compose `volumes:` entries, used by every rule that
|
|
869
|
+
* reasons about bind mounts. A Compose volume entry is either the short
|
|
870
|
+
* `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
|
|
871
|
+
* to a `VolumeMount`.
|
|
872
|
+
*/
|
|
873
|
+
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
874
|
+
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
875
|
+
const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
|
|
876
|
+
const resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
877
|
+
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
878
|
+
const splitShortSyntax = (spec) => {
|
|
879
|
+
const parts = [];
|
|
880
|
+
let depth = 0;
|
|
881
|
+
let current = "";
|
|
882
|
+
for (const char of spec) {
|
|
883
|
+
if (char === "{") depth += 1;
|
|
884
|
+
else if (char === "}") depth = Math.max(0, depth - 1);
|
|
885
|
+
if (char === ":" && depth === 0) {
|
|
886
|
+
parts.push(current);
|
|
887
|
+
current = "";
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
current += char;
|
|
891
|
+
}
|
|
892
|
+
parts.push(current);
|
|
893
|
+
return parts;
|
|
894
|
+
};
|
|
895
|
+
const DRIVE_LETTER = /^[A-Za-z]$/u;
|
|
896
|
+
const joinDriveLetter = (parts) => {
|
|
897
|
+
const [first, second, ...rest] = parts;
|
|
898
|
+
if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
|
|
899
|
+
return parts;
|
|
900
|
+
};
|
|
901
|
+
const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
|
|
902
|
+
const volumeMount = (volume) => {
|
|
903
|
+
if (typeof volume === "string") {
|
|
904
|
+
const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
|
|
905
|
+
if (target === void 0) return;
|
|
906
|
+
return {
|
|
907
|
+
readOnly: shortSyntaxReadOnly(mode),
|
|
908
|
+
source,
|
|
909
|
+
target,
|
|
910
|
+
type: void 0
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
if (volume && typeof volume === "object") {
|
|
914
|
+
const { read_only: readOnly, source, target, type } = volume;
|
|
915
|
+
return {
|
|
916
|
+
readOnly: readOnly === true,
|
|
917
|
+
source: typeof source === "string" ? source : void 0,
|
|
918
|
+
target: typeof target === "string" ? target : void 0,
|
|
919
|
+
type: typeof type === "string" ? type : void 0
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
/**
|
|
924
|
+
* The host path a bind mount reads from, with interpolation defaults
|
|
925
|
+
* applied and Windows separators folded to `/`. Undefined when the entry is
|
|
926
|
+
* not a bind mount (named volume, tmpfs, anonymous volume) or when the
|
|
927
|
+
* source is an interpolation with no default, which names no host path.
|
|
928
|
+
*/
|
|
929
|
+
const bindMountSource = (mount) => {
|
|
930
|
+
if (mount.type !== void 0 && mount.type !== "bind") return;
|
|
931
|
+
if (!mount.source) return;
|
|
932
|
+
const resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
|
|
933
|
+
if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
|
|
934
|
+
return resolved;
|
|
935
|
+
};
|
|
936
|
+
const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
|
|
937
|
+
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
938
|
+
/**
|
|
939
|
+
* Whether the entry bind-mounts the Docker socket, under any of its host
|
|
940
|
+
* spellings. A bare `${VAR}` source names no host path, but a socket target
|
|
941
|
+
* still tells the story.
|
|
942
|
+
*/
|
|
943
|
+
const mountsDockerSocket = (mount) => {
|
|
944
|
+
const source = bindMountSource(mount);
|
|
945
|
+
if (source !== void 0) return isDockerSocketPath(source);
|
|
946
|
+
if (mount.type !== void 0 && mount.type !== "bind") return false;
|
|
947
|
+
if (!mount.source) return false;
|
|
948
|
+
return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
949
|
+
};
|
|
950
|
+
const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
|
|
951
|
+
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
|
|
952
|
+
const SYSTEM_DIRECTORIES = [
|
|
953
|
+
"/etc",
|
|
954
|
+
"/root",
|
|
955
|
+
"/home",
|
|
956
|
+
"/Users",
|
|
957
|
+
"/proc",
|
|
958
|
+
"/sys",
|
|
959
|
+
"/boot",
|
|
960
|
+
"/run",
|
|
961
|
+
"/var/run"
|
|
962
|
+
];
|
|
963
|
+
const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
|
|
964
|
+
/**
|
|
965
|
+
* Classifies where on the host a bind-mount source points. `project` is a
|
|
966
|
+
* relative path that stays inside the Compose project directory; everything
|
|
967
|
+
* else reaches outside it.
|
|
968
|
+
*/
|
|
969
|
+
const classifyHostPath = (hostPath) => {
|
|
970
|
+
const collapsed = node_path.default.posix.normalize(hostPath);
|
|
971
|
+
const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
|
|
972
|
+
if (normalized === "~") return "home";
|
|
973
|
+
if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
|
|
974
|
+
if (normalized === "." || normalized.startsWith("./")) return "project";
|
|
975
|
+
if (normalized === ".." || normalized.startsWith("../")) return "parent";
|
|
976
|
+
if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
|
|
977
|
+
if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
|
|
978
|
+
if (normalized === "/") return "root";
|
|
979
|
+
if (!normalized.startsWith("/")) return "project";
|
|
980
|
+
for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
|
|
981
|
+
return "host";
|
|
982
|
+
};
|
|
983
|
+
|
|
807
984
|
//#endregion
|
|
808
985
|
//#region ../core/src/rules/secret-keywords.ts
|
|
809
986
|
const SECRET_KEY_PATTERNS = [
|
|
@@ -838,55 +1015,6 @@ const noPrivilegedService = {
|
|
|
838
1015
|
key: "docker-doctor/no-privileged-service",
|
|
839
1016
|
message: "Do not run services in privileged mode"
|
|
840
1017
|
};
|
|
841
|
-
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
842
|
-
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
843
|
-
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
844
|
-
const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
845
|
-
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
846
|
-
const isDockerSocketPath = (rawPath) => {
|
|
847
|
-
const normalized = rawPath.replaceAll("\\", "/");
|
|
848
|
-
return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
|
|
849
|
-
};
|
|
850
|
-
const splitShortSyntax = (spec) => {
|
|
851
|
-
const parts = [];
|
|
852
|
-
let depth = 0;
|
|
853
|
-
let current = "";
|
|
854
|
-
for (const char of spec) {
|
|
855
|
-
if (char === "{") depth += 1;
|
|
856
|
-
else if (char === "}") depth = Math.max(0, depth - 1);
|
|
857
|
-
if (char === ":" && depth === 0) {
|
|
858
|
-
parts.push(current);
|
|
859
|
-
current = "";
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
current += char;
|
|
863
|
-
}
|
|
864
|
-
parts.push(current);
|
|
865
|
-
return parts;
|
|
866
|
-
};
|
|
867
|
-
const volumeMount = (volume) => {
|
|
868
|
-
if (typeof volume === "string") {
|
|
869
|
-
const [source, target] = splitShortSyntax(volume);
|
|
870
|
-
return target === void 0 ? void 0 : {
|
|
871
|
-
source,
|
|
872
|
-
target
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
if (volume && typeof volume === "object") {
|
|
876
|
-
const { source, target } = volume;
|
|
877
|
-
return {
|
|
878
|
-
source: typeof source === "string" ? source : void 0,
|
|
879
|
-
target: typeof target === "string" ? target : void 0
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
};
|
|
883
|
-
const mountsDockerSocket = (volume) => {
|
|
884
|
-
const mount = volumeMount(volume);
|
|
885
|
-
if (!mount?.source) return false;
|
|
886
|
-
const resolvedSource = resolveInterpolationDefaults(mount.source);
|
|
887
|
-
if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
|
|
888
|
-
return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
889
|
-
};
|
|
890
1018
|
const noDockerSocketMount = {
|
|
891
1019
|
category: "Compose",
|
|
892
1020
|
check(composeContent, file, context) {
|
|
@@ -894,12 +1022,15 @@ const noDockerSocketMount = {
|
|
|
894
1022
|
for (const [name, config] of composeServices(composeContent)) {
|
|
895
1023
|
const { volumes } = config;
|
|
896
1024
|
if (!Array.isArray(volumes)) continue;
|
|
897
|
-
for (const [index, volume] of volumes.entries())
|
|
898
|
-
|
|
899
|
-
name,
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1025
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1026
|
+
const mount = volumeMount(volume);
|
|
1027
|
+
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?.([
|
|
1028
|
+
"services",
|
|
1029
|
+
name,
|
|
1030
|
+
"volumes",
|
|
1031
|
+
index
|
|
1032
|
+
])));
|
|
1033
|
+
}
|
|
903
1034
|
}
|
|
904
1035
|
return diagnostics;
|
|
905
1036
|
},
|
|
@@ -908,6 +1039,69 @@ const noDockerSocketMount = {
|
|
|
908
1039
|
key: "docker-doctor/no-docker-socket-mount",
|
|
909
1040
|
message: "Do not bind-mount the Docker socket into services"
|
|
910
1041
|
};
|
|
1042
|
+
const BROAD_SCOPE_REASONS = {
|
|
1043
|
+
home: "the whole home directory",
|
|
1044
|
+
"home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
|
|
1045
|
+
parent: "a directory above the Compose project",
|
|
1046
|
+
root: "the host's root filesystem",
|
|
1047
|
+
system: "a host system directory"
|
|
1048
|
+
};
|
|
1049
|
+
const noBroadBindMount = {
|
|
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 || mountsDockerSocket(mount)) continue;
|
|
1059
|
+
const source = bindMountSource(mount);
|
|
1060
|
+
if (source === void 0) continue;
|
|
1061
|
+
const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
|
|
1062
|
+
if (reason === void 0) continue;
|
|
1063
|
+
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?.([
|
|
1064
|
+
"services",
|
|
1065
|
+
name,
|
|
1066
|
+
"volumes",
|
|
1067
|
+
index
|
|
1068
|
+
])));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return diagnostics;
|
|
1072
|
+
},
|
|
1073
|
+
defaultSeverity: "warning",
|
|
1074
|
+
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.",
|
|
1075
|
+
key: "docker-doctor/no-broad-bind-mount",
|
|
1076
|
+
message: "Do not bind-mount broad host directories into services"
|
|
1077
|
+
};
|
|
1078
|
+
const preferReadOnlyBindMount = {
|
|
1079
|
+
category: "Compose",
|
|
1080
|
+
check(composeContent, file, context) {
|
|
1081
|
+
const diagnostics = [];
|
|
1082
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
1083
|
+
const { volumes } = config;
|
|
1084
|
+
if (!Array.isArray(volumes)) continue;
|
|
1085
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1086
|
+
const mount = volumeMount(volume);
|
|
1087
|
+
if (!mount || mount.readOnly || mountsDockerSocket(mount)) continue;
|
|
1088
|
+
const source = bindMountSource(mount);
|
|
1089
|
+
if (source === void 0 || classifyHostPath(source) === "project") continue;
|
|
1090
|
+
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?.([
|
|
1091
|
+
"services",
|
|
1092
|
+
name,
|
|
1093
|
+
"volumes",
|
|
1094
|
+
index
|
|
1095
|
+
])));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return diagnostics;
|
|
1099
|
+
},
|
|
1100
|
+
defaultSeverity: "info",
|
|
1101
|
+
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.",
|
|
1102
|
+
key: "docker-doctor/prefer-read-only-bind-mount",
|
|
1103
|
+
message: "Bind mounts outside the project should be read-only"
|
|
1104
|
+
};
|
|
911
1105
|
const noPlaintextSecrets = {
|
|
912
1106
|
category: "Compose",
|
|
913
1107
|
check(composeContent, file, context) {
|
|
@@ -949,6 +1143,8 @@ const noPlaintextSecrets = {
|
|
|
949
1143
|
const composeSecurityRules = [
|
|
950
1144
|
noPrivilegedService,
|
|
951
1145
|
noDockerSocketMount,
|
|
1146
|
+
noBroadBindMount,
|
|
1147
|
+
preferReadOnlyBindMount,
|
|
952
1148
|
noPlaintextSecrets
|
|
953
1149
|
];
|
|
954
1150
|
|
|
@@ -1271,12 +1467,16 @@ const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[r
|
|
|
1271
1467
|
//#endregion
|
|
1272
1468
|
//#region ../core/src/runners/dockerfile-runner.ts
|
|
1273
1469
|
const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
|
|
1470
|
+
if (instructions.length === 0) return [];
|
|
1274
1471
|
const diagnostics = [];
|
|
1275
1472
|
for (const rule of allDockerfileRules) {
|
|
1276
1473
|
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
1277
1474
|
if (severity === "off") continue;
|
|
1278
1475
|
const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
|
|
1279
|
-
|
|
1476
|
+
for (const diag of ruleDiagnostics) {
|
|
1477
|
+
diag.category = rule.category;
|
|
1478
|
+
if (severity !== rule.defaultSeverity) diag.severity = severity;
|
|
1479
|
+
}
|
|
1280
1480
|
diagnostics.push(...ruleDiagnostics);
|
|
1281
1481
|
}
|
|
1282
1482
|
return diagnostics;
|
|
@@ -1290,7 +1490,10 @@ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, lo
|
|
|
1290
1490
|
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
1291
1491
|
if (severity === "off") continue;
|
|
1292
1492
|
const ruleDiagnostics = rule.check(composeContent, file, { locate });
|
|
1293
|
-
|
|
1493
|
+
for (const diag of ruleDiagnostics) {
|
|
1494
|
+
diag.category = rule.category;
|
|
1495
|
+
if (severity !== rule.defaultSeverity) diag.severity = severity;
|
|
1496
|
+
}
|
|
1294
1497
|
diagnostics.push(...ruleDiagnostics);
|
|
1295
1498
|
}
|
|
1296
1499
|
return diagnostics;
|
|
@@ -1400,6 +1603,16 @@ const parseConfigFile = async (filePath, format, parse) => {
|
|
|
1400
1603
|
throw new ConfigError({ message: `Failed to parse config ${format}: ${msg}` });
|
|
1401
1604
|
}
|
|
1402
1605
|
};
|
|
1606
|
+
const TYPESCRIPT_CONFIG_PATTERN = /\.[cm]?ts$/u;
|
|
1607
|
+
const TYPE_STRIPPING_FAILURES = {
|
|
1608
|
+
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)",
|
|
1609
|
+
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"
|
|
1610
|
+
};
|
|
1611
|
+
const typeStrippingFailure = (filePath, error) => {
|
|
1612
|
+
if (!TYPESCRIPT_CONFIG_PATTERN.test(filePath)) return;
|
|
1613
|
+
if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string") return;
|
|
1614
|
+
return TYPE_STRIPPING_FAILURES[error.code];
|
|
1615
|
+
};
|
|
1403
1616
|
const importConfig = async (filePath) => {
|
|
1404
1617
|
if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
|
|
1405
1618
|
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", yaml.parse);
|
|
@@ -1407,6 +1620,8 @@ const importConfig = async (filePath) => {
|
|
|
1407
1620
|
const configModule = await import(filePath);
|
|
1408
1621
|
return configModule.default || configModule;
|
|
1409
1622
|
} catch (error) {
|
|
1623
|
+
const reason = typeStrippingFailure(filePath, error);
|
|
1624
|
+
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.` });
|
|
1410
1625
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1411
1626
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
1412
1627
|
}
|
|
@@ -1501,9 +1716,15 @@ const calculateScore = (diagnostics) => {
|
|
|
1501
1716
|
|
|
1502
1717
|
//#endregion
|
|
1503
1718
|
//#region ../core/src/report.ts
|
|
1504
|
-
const REPORT_SCHEMA_VERSION =
|
|
1719
|
+
const REPORT_SCHEMA_VERSION = 3;
|
|
1720
|
+
const resolveCategory = (diagnostic) => {
|
|
1721
|
+
const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
|
|
1722
|
+
if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
|
|
1723
|
+
return category;
|
|
1724
|
+
};
|
|
1505
1725
|
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
1506
1726
|
diagnostics: diagnostics.map((d) => ({
|
|
1727
|
+
category: resolveCategory(d),
|
|
1507
1728
|
column: d.column,
|
|
1508
1729
|
file: d.file,
|
|
1509
1730
|
help: d.help,
|
|
@@ -1514,7 +1735,7 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1514
1735
|
})),
|
|
1515
1736
|
label,
|
|
1516
1737
|
project,
|
|
1517
|
-
schemaVersion:
|
|
1738
|
+
schemaVersion: 3,
|
|
1518
1739
|
score,
|
|
1519
1740
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1520
1741
|
});
|
|
@@ -1598,4 +1819,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1598
1819
|
return version;
|
|
1599
1820
|
}
|
|
1600
1821
|
});
|
|
1601
|
-
//# sourceMappingURL=src-
|
|
1822
|
+
//# sourceMappingURL=src-D4rVJg36.cjs.map
|