@docker-doctor/cli 0.5.2 → 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 +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-BOGzkp17.mjs → src-CklEIXTX.mjs} +195 -57
- package/dist/src-CklEIXTX.mjs.map +1 -0
- package/dist/{src-D3jzc3GX.cjs → src-D4rVJg36.cjs} +195 -57
- package/dist/src-D4rVJg36.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/src-BOGzkp17.mjs.map +0 -1
- package/dist/src-D3jzc3GX.cjs.map +0 -1
package/dist/cli.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const require_src = require('./src-
|
|
2
|
+
const require_src = require('./src-D4rVJg36.cjs');
|
|
3
3
|
let node_fs_promises = require("node:fs/promises");
|
|
4
4
|
node_fs_promises = require_src.__toESM(node_fs_promises, 1);
|
|
5
5
|
let node_path = require("node:path");
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as runDockerfileRules, c as createComposeLocator, d as discoverProject, f as version, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-
|
|
2
|
+
import { a as runDockerfileRules, c as createComposeLocator, d as discoverProject, f as version, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CklEIXTX.mjs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import os from "node:os";
|
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
3
|
-
const require_src = require('./src-
|
|
3
|
+
const require_src = require('./src-D4rVJg36.cjs');
|
|
4
4
|
|
|
5
5
|
//#region ../core/src/config/define-config.ts
|
|
6
6
|
const defineConfig = (config) => config;
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-
|
|
2
|
+
import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CklEIXTX.mjs";
|
|
3
3
|
|
|
4
4
|
//#region ../core/src/config/define-config.ts
|
|
5
5
|
const defineConfig = (config) => config;
|
|
@@ -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
|
|
@@ -833,6 +833,125 @@ const pinModelVersion = {
|
|
|
833
833
|
};
|
|
834
834
|
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
835
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
|
+
|
|
836
955
|
//#endregion
|
|
837
956
|
//#region ../core/src/rules/secret-keywords.ts
|
|
838
957
|
const SECRET_KEY_PATTERNS = [
|
|
@@ -867,55 +986,6 @@ const noPrivilegedService = {
|
|
|
867
986
|
key: "docker-doctor/no-privileged-service",
|
|
868
987
|
message: "Do not run services in privileged mode"
|
|
869
988
|
};
|
|
870
|
-
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
871
|
-
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
872
|
-
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, "");
|
|
874
|
-
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
|
-
const splitShortSyntax = (spec) => {
|
|
880
|
-
const parts = [];
|
|
881
|
-
let depth = 0;
|
|
882
|
-
let current = "";
|
|
883
|
-
for (const char of spec) {
|
|
884
|
-
if (char === "{") depth += 1;
|
|
885
|
-
else if (char === "}") depth = Math.max(0, depth - 1);
|
|
886
|
-
if (char === ":" && depth === 0) {
|
|
887
|
-
parts.push(current);
|
|
888
|
-
current = "";
|
|
889
|
-
continue;
|
|
890
|
-
}
|
|
891
|
-
current += char;
|
|
892
|
-
}
|
|
893
|
-
parts.push(current);
|
|
894
|
-
return parts;
|
|
895
|
-
};
|
|
896
|
-
const volumeMount = (volume) => {
|
|
897
|
-
if (typeof volume === "string") {
|
|
898
|
-
const [source, target] = splitShortSyntax(volume);
|
|
899
|
-
return target === void 0 ? void 0 : {
|
|
900
|
-
source,
|
|
901
|
-
target
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
if (volume && typeof volume === "object") {
|
|
905
|
-
const { source, target } = volume;
|
|
906
|
-
return {
|
|
907
|
-
source: typeof source === "string" ? source : void 0,
|
|
908
|
-
target: typeof target === "string" ? target : void 0
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
};
|
|
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;
|
|
918
|
-
};
|
|
919
989
|
const noDockerSocketMount = {
|
|
920
990
|
category: "Compose",
|
|
921
991
|
check(composeContent, file, context) {
|
|
@@ -923,12 +993,15 @@ const noDockerSocketMount = {
|
|
|
923
993
|
for (const [name, config] of composeServices(composeContent)) {
|
|
924
994
|
const { volumes } = config;
|
|
925
995
|
if (!Array.isArray(volumes)) continue;
|
|
926
|
-
for (const [index, volume] of volumes.entries())
|
|
927
|
-
|
|
928
|
-
name,
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
+
}
|
|
932
1005
|
}
|
|
933
1006
|
return diagnostics;
|
|
934
1007
|
},
|
|
@@ -937,6 +1010,69 @@ const noDockerSocketMount = {
|
|
|
937
1010
|
key: "docker-doctor/no-docker-socket-mount",
|
|
938
1011
|
message: "Do not bind-mount the Docker socket into services"
|
|
939
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
|
+
};
|
|
940
1076
|
const noPlaintextSecrets = {
|
|
941
1077
|
category: "Compose",
|
|
942
1078
|
check(composeContent, file, context) {
|
|
@@ -978,6 +1114,8 @@ const noPlaintextSecrets = {
|
|
|
978
1114
|
const composeSecurityRules = [
|
|
979
1115
|
noPrivilegedService,
|
|
980
1116
|
noDockerSocketMount,
|
|
1117
|
+
noBroadBindMount,
|
|
1118
|
+
preferReadOnlyBindMount,
|
|
981
1119
|
noPlaintextSecrets
|
|
982
1120
|
];
|
|
983
1121
|
|
|
@@ -1575,4 +1713,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1575
1713
|
|
|
1576
1714
|
//#endregion
|
|
1577
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 };
|
|
1578
|
-
//# sourceMappingURL=src-
|
|
1716
|
+
//# sourceMappingURL=src-CklEIXTX.mjs.map
|