@docker-doctor/cli 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.cjs +66 -13
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +66 -13
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +7 -1
- package/dist/index.d.mts +7 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-BOGzkp17.mjs → src-BLeXr_hR.mjs} +352 -81
- package/dist/src-BLeXr_hR.mjs.map +1 -0
- package/dist/{src-D3jzc3GX.cjs → src-BpAv-H0Z.cjs} +352 -81
- package/dist/src-BpAv-H0Z.cjs.map +1 -0
- package/package.json +2 -1
- package/dist/src-BOGzkp17.mjs.map +0 -1
- package/dist/src-D3jzc3GX.cjs.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.
|
|
36
|
+
var version = "0.6.0";
|
|
37
37
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region ../core/src/project-info/ignore.ts
|
|
@@ -89,20 +89,66 @@ const createIgnoreMatcher = (patterns) => {
|
|
|
89
89
|
|
|
90
90
|
//#endregion
|
|
91
91
|
//#region ../core/src/project-info/discover.ts
|
|
92
|
-
const
|
|
93
|
-
|
|
92
|
+
const PRUNED_DIRECTORIES = [
|
|
93
|
+
".git",
|
|
94
|
+
".hg",
|
|
95
|
+
".svn",
|
|
96
|
+
"node_modules",
|
|
97
|
+
"bower_components",
|
|
98
|
+
".pnpm-store",
|
|
99
|
+
".yarn",
|
|
100
|
+
".venv",
|
|
101
|
+
"venv",
|
|
102
|
+
"__pycache__",
|
|
103
|
+
".tox",
|
|
104
|
+
".mypy_cache",
|
|
105
|
+
".pytest_cache",
|
|
106
|
+
".ruff_cache",
|
|
107
|
+
".gradle",
|
|
108
|
+
".turbo",
|
|
109
|
+
".next",
|
|
110
|
+
".nuxt",
|
|
111
|
+
".svelte-kit",
|
|
112
|
+
".parcel-cache",
|
|
113
|
+
".cache",
|
|
114
|
+
"coverage",
|
|
115
|
+
"dist"
|
|
116
|
+
];
|
|
117
|
+
const PRUNED = new Set(PRUNED_DIRECTORIES);
|
|
118
|
+
const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
|
|
119
|
+
const SUBTREE_SUFFIX = "/**";
|
|
120
|
+
const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
|
|
121
|
+
const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
122
|
+
const walk = async (dir, options, fileList = []) => {
|
|
123
|
+
let files;
|
|
124
|
+
try {
|
|
125
|
+
files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (dir !== options.rootDir && isPermissionError(error)) {
|
|
128
|
+
options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
|
|
129
|
+
return fileList;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
94
133
|
await Promise.all(files.map(async (file) => {
|
|
95
134
|
const filePath = node_path.default.join(dir, file.name);
|
|
135
|
+
if (file.isSymbolicLink()) {
|
|
136
|
+
fileList.push(filePath);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
96
139
|
if (file.isDirectory()) {
|
|
97
|
-
if (
|
|
98
|
-
await walk(filePath, fileList);
|
|
140
|
+
if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
|
|
141
|
+
await walk(filePath, options, fileList);
|
|
99
142
|
} else fileList.push(filePath);
|
|
100
143
|
}));
|
|
101
144
|
return fileList;
|
|
102
145
|
};
|
|
103
|
-
const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
|
|
104
146
|
const discoverProject = async (rootDir, options) => {
|
|
105
|
-
const allFiles = await walk(rootDir
|
|
147
|
+
const allFiles = await walk(rootDir, {
|
|
148
|
+
isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
|
|
149
|
+
onSkippedDirectory: options?.onSkippedDirectory,
|
|
150
|
+
rootDir
|
|
151
|
+
});
|
|
106
152
|
const dockerfiles = [];
|
|
107
153
|
const composeFiles = [];
|
|
108
154
|
const dockerignores = [];
|
|
@@ -287,7 +333,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
|
|
|
287
333
|
const lineNum = i + 1;
|
|
288
334
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
289
335
|
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
290
|
-
if (!
|
|
336
|
+
if (!insideHeredoc && trimmed === "") continue;
|
|
291
337
|
state.rawAccumulator.push(rawLine);
|
|
292
338
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
293
339
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -458,6 +504,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
|
458
504
|
severity
|
|
459
505
|
});
|
|
460
506
|
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region ../core/src/rules/package-managers.ts
|
|
509
|
+
const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
|
|
510
|
+
const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
|
|
511
|
+
const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
|
|
512
|
+
const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
|
|
513
|
+
|
|
461
514
|
//#endregion
|
|
462
515
|
//#region ../core/src/rules/best-practices.ts
|
|
463
516
|
const requireHealthcheck = {
|
|
@@ -473,6 +526,16 @@ const requireHealthcheck = {
|
|
|
473
526
|
key: "docker-doctor/require-healthcheck",
|
|
474
527
|
message: "Add a HEALTHCHECK instruction"
|
|
475
528
|
};
|
|
529
|
+
const AUTO_EXTRACT_SUFFIXES = [
|
|
530
|
+
".tar",
|
|
531
|
+
".tar.gz",
|
|
532
|
+
".tgz",
|
|
533
|
+
".tar.bz2",
|
|
534
|
+
".tbz2",
|
|
535
|
+
".tbz",
|
|
536
|
+
".tar.xz",
|
|
537
|
+
".txz"
|
|
538
|
+
];
|
|
476
539
|
const preferCopyOverAdd = {
|
|
477
540
|
category: "Best Practices",
|
|
478
541
|
check(instructions, file) {
|
|
@@ -481,7 +544,7 @@ const preferCopyOverAdd = {
|
|
|
481
544
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
482
545
|
if (!src) continue;
|
|
483
546
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
484
|
-
const isArchive =
|
|
547
|
+
const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
|
|
485
548
|
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
|
|
486
549
|
}
|
|
487
550
|
return diagnostics;
|
|
@@ -519,8 +582,8 @@ const combineAptUpdateInstall = {
|
|
|
519
582
|
check(instructions, file) {
|
|
520
583
|
const diagnostics = [];
|
|
521
584
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
522
|
-
const hasUpdate = inst.args
|
|
523
|
-
const hasInstall = inst.args
|
|
585
|
+
const hasUpdate = hasAptGetUpdate(inst.args);
|
|
586
|
+
const hasInstall = hasAptGetInstall(inst.args);
|
|
524
587
|
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get update used without apt-get install in the same instruction. This can cause caching issues and build failures.", this.help, inst.line));
|
|
525
588
|
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get install used without apt-get update in the same instruction. Always combine them to ensure up-to-date package installation.", this.help, inst.line));
|
|
526
589
|
}
|
|
@@ -600,18 +663,32 @@ const avoidRunCd = {
|
|
|
600
663
|
key: "docker-doctor/avoid-run-cd",
|
|
601
664
|
message: "Avoid changing directories with cd in RUN"
|
|
602
665
|
};
|
|
666
|
+
const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
|
|
667
|
+
const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
|
|
668
|
+
const WHITESPACE_RE$1 = /\s+/u;
|
|
669
|
+
const collectPackageList = (raw) => {
|
|
670
|
+
const start = PACKAGE_LIST_START_RE.exec(raw);
|
|
671
|
+
if (!start) return [];
|
|
672
|
+
const packages = [];
|
|
673
|
+
const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
|
|
674
|
+
for (const token of tokens) {
|
|
675
|
+
if (SHELL_SEPARATOR_RE$1.test(token)) break;
|
|
676
|
+
if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
|
|
677
|
+
}
|
|
678
|
+
return packages;
|
|
679
|
+
};
|
|
603
680
|
const sortMultilineArgs = {
|
|
604
681
|
category: "Best Practices",
|
|
605
682
|
check(instructions, file) {
|
|
606
683
|
const diagnostics = [];
|
|
607
684
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
608
685
|
const { raw } = inst;
|
|
609
|
-
const isPackageInstall = raw
|
|
686
|
+
const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
|
|
610
687
|
const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
|
|
611
688
|
if (isPackageInstall && hasContinuation) {
|
|
612
|
-
const packages = raw
|
|
689
|
+
const packages = collectPackageList(raw);
|
|
613
690
|
if (packages.length > 1) {
|
|
614
|
-
const sorted = packages.toSorted(
|
|
691
|
+
const sorted = packages.toSorted();
|
|
615
692
|
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Multi-line package arguments are not sorted alphanumerically. Keeping them sorted makes maintenance easier and prevents duplicates.", this.help, inst.line));
|
|
616
693
|
}
|
|
617
694
|
}
|
|
@@ -863,48 +940,21 @@ const pinModelVersion = {
|
|
|
863
940
|
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
864
941
|
|
|
865
942
|
//#endregion
|
|
866
|
-
//#region ../core/src/rules/
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
874
|
-
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
875
|
-
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
876
|
-
];
|
|
877
|
-
const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
878
|
-
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
879
|
-
const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
|
|
880
|
-
|
|
881
|
-
//#endregion
|
|
882
|
-
//#region ../core/src/rules/compose-security.ts
|
|
883
|
-
const noPrivilegedService = {
|
|
884
|
-
category: "Compose",
|
|
885
|
-
check(composeContent, file, context) {
|
|
886
|
-
const diagnostics = [];
|
|
887
|
-
for (const [name, config] of composeServices(composeContent)) if (config.privileged === true) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' runs in privileged mode. A privileged container has full access to the host's devices and kernel, so compromising this service compromises the host.`, this.help, context?.locate?.([
|
|
888
|
-
"services",
|
|
889
|
-
name,
|
|
890
|
-
"privileged"
|
|
891
|
-
])));
|
|
892
|
-
return diagnostics;
|
|
893
|
-
},
|
|
894
|
-
defaultSeverity: "error",
|
|
895
|
-
help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
|
|
896
|
-
key: "docker-doctor/no-privileged-service",
|
|
897
|
-
message: "Do not run services in privileged mode"
|
|
898
|
-
};
|
|
899
|
-
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
943
|
+
//#region ../core/src/rules/compose-mounts.ts
|
|
944
|
+
/**
|
|
945
|
+
* Shared parsing for Compose `volumes:` entries, used by every rule that
|
|
946
|
+
* reasons about bind mounts. A Compose volume entry is either the short
|
|
947
|
+
* `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
|
|
948
|
+
* to a `VolumeMount`.
|
|
949
|
+
*/
|
|
900
950
|
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
901
951
|
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
902
|
-
const
|
|
952
|
+
const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
|
|
953
|
+
const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
|
|
954
|
+
const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
|
|
955
|
+
const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
|
|
956
|
+
const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
903
957
|
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
904
|
-
const isDockerSocketPath = (rawPath) => {
|
|
905
|
-
const normalized = rawPath.replaceAll("\\", "/");
|
|
906
|
-
return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
|
|
907
|
-
};
|
|
908
958
|
const splitShortSyntax = (spec) => {
|
|
909
959
|
const parts = [];
|
|
910
960
|
let depth = 0;
|
|
@@ -922,28 +972,137 @@ const splitShortSyntax = (spec) => {
|
|
|
922
972
|
parts.push(current);
|
|
923
973
|
return parts;
|
|
924
974
|
};
|
|
975
|
+
const DRIVE_LETTER = /^[A-Za-z]$/u;
|
|
976
|
+
const joinDriveLetter = (parts) => {
|
|
977
|
+
const [first, second, ...rest] = parts;
|
|
978
|
+
if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
|
|
979
|
+
return parts;
|
|
980
|
+
};
|
|
981
|
+
const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
|
|
925
982
|
const volumeMount = (volume) => {
|
|
926
983
|
if (typeof volume === "string") {
|
|
927
|
-
const [source, target] = splitShortSyntax(volume);
|
|
928
|
-
|
|
984
|
+
const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
|
|
985
|
+
if (target === void 0) return;
|
|
986
|
+
return {
|
|
987
|
+
readOnly: shortSyntaxReadOnly(mode),
|
|
929
988
|
source,
|
|
930
|
-
target
|
|
989
|
+
target,
|
|
990
|
+
type: void 0
|
|
931
991
|
};
|
|
932
992
|
}
|
|
933
993
|
if (volume && typeof volume === "object") {
|
|
934
|
-
const { source, target } = volume;
|
|
994
|
+
const { read_only: readOnly, source, target, type } = volume;
|
|
935
995
|
return {
|
|
996
|
+
readOnly: readOnly === true,
|
|
936
997
|
source: typeof source === "string" ? source : void 0,
|
|
937
|
-
target: typeof target === "string" ? target : void 0
|
|
998
|
+
target: typeof target === "string" ? target : void 0,
|
|
999
|
+
type: typeof type === "string" ? type : void 0
|
|
938
1000
|
};
|
|
939
1001
|
}
|
|
940
1002
|
};
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* The host path a bind mount reads from, with interpolation defaults
|
|
1005
|
+
* applied and Windows separators folded to `/`. Undefined when the entry is
|
|
1006
|
+
* not a bind mount (named volume, tmpfs, anonymous volume) or when the
|
|
1007
|
+
* source starts with an interpolation that has no default. A source such as
|
|
1008
|
+
* `${DATA_DIR}/pg` names no known host location, so no rule can say whether
|
|
1009
|
+
* it is inside the project.
|
|
1010
|
+
*/
|
|
1011
|
+
const bindMountSource = (mount) => {
|
|
1012
|
+
if (mount.type !== void 0 && mount.type !== "bind") return;
|
|
1013
|
+
if (!mount.source) return;
|
|
1014
|
+
const withDefaults = applyFoldsAndDefaults(mount.source);
|
|
1015
|
+
if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
|
|
1016
|
+
const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
|
|
1017
|
+
if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
|
|
1018
|
+
return resolved;
|
|
1019
|
+
};
|
|
1020
|
+
const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
|
|
1021
|
+
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
1022
|
+
/**
|
|
1023
|
+
* Whether the entry bind-mounts the Docker socket, under any of its host
|
|
1024
|
+
* spellings. A source that starts with `${VAR}` names no known host path,
|
|
1025
|
+
* but a `/docker.sock` suffix or a socket target still identifies it.
|
|
1026
|
+
*/
|
|
1027
|
+
const mountsDockerSocket = (mount) => {
|
|
1028
|
+
const source = bindMountSource(mount);
|
|
1029
|
+
if (source !== void 0) return isDockerSocketPath(source);
|
|
1030
|
+
if (mount.type !== void 0 && mount.type !== "bind") return false;
|
|
1031
|
+
if (!mount.source) return false;
|
|
1032
|
+
const remainder = resolveInterpolationDefaults(mount.source);
|
|
1033
|
+
return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
1034
|
+
};
|
|
1035
|
+
const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
|
|
1036
|
+
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
|
|
1037
|
+
const SYSTEM_DIRECTORIES = [
|
|
1038
|
+
"/etc",
|
|
1039
|
+
"/root",
|
|
1040
|
+
"/home",
|
|
1041
|
+
"/Users",
|
|
1042
|
+
"/proc",
|
|
1043
|
+
"/sys",
|
|
1044
|
+
"/boot",
|
|
1045
|
+
"/run",
|
|
1046
|
+
"/var/run"
|
|
1047
|
+
];
|
|
1048
|
+
const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
|
|
1049
|
+
/**
|
|
1050
|
+
* Classifies where on the host a bind-mount source points. `project` is a
|
|
1051
|
+
* relative path that stays inside the Compose project directory; everything
|
|
1052
|
+
* else reaches outside it.
|
|
1053
|
+
*/
|
|
1054
|
+
const classifyHostPath = (hostPath) => {
|
|
1055
|
+
const collapsed = node_path.default.posix.normalize(hostPath);
|
|
1056
|
+
const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
|
|
1057
|
+
if (normalized === "~") return "home";
|
|
1058
|
+
if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
|
|
1059
|
+
if (normalized === "." || normalized.startsWith("./")) return "project";
|
|
1060
|
+
if (normalized === ".." || normalized.startsWith("../")) return "parent";
|
|
1061
|
+
if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
|
|
1062
|
+
if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
|
|
1063
|
+
if (normalized === "/") return "root";
|
|
1064
|
+
if (!normalized.startsWith("/")) return "project";
|
|
1065
|
+
for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
|
|
1066
|
+
return "host";
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
//#endregion
|
|
1070
|
+
//#region ../core/src/rules/secret-keywords.ts
|
|
1071
|
+
const SECRET_KEY_PATTERNS = [
|
|
1072
|
+
/password(?:[_-]|$)/iu,
|
|
1073
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
1074
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
1075
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
1076
|
+
/(?:^|[_-])apikey(?:[_-]|$)/iu,
|
|
1077
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
1078
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
1079
|
+
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
1080
|
+
];
|
|
1081
|
+
const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
1082
|
+
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
1083
|
+
const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
|
|
1084
|
+
const isLiteralSecretValue = (value) => {
|
|
1085
|
+
const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
|
|
1086
|
+
return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region ../core/src/rules/compose-security.ts
|
|
1091
|
+
const noPrivilegedService = {
|
|
1092
|
+
category: "Compose",
|
|
1093
|
+
check(composeContent, file, context) {
|
|
1094
|
+
const diagnostics = [];
|
|
1095
|
+
for (const [name, config] of composeServices(composeContent)) if (config.privileged === true) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' runs in privileged mode. A privileged container has full access to the host's devices and kernel, so compromising this service compromises the host.`, this.help, context?.locate?.([
|
|
1096
|
+
"services",
|
|
1097
|
+
name,
|
|
1098
|
+
"privileged"
|
|
1099
|
+
])));
|
|
1100
|
+
return diagnostics;
|
|
1101
|
+
},
|
|
1102
|
+
defaultSeverity: "error",
|
|
1103
|
+
help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
|
|
1104
|
+
key: "docker-doctor/no-privileged-service",
|
|
1105
|
+
message: "Do not run services in privileged mode"
|
|
947
1106
|
};
|
|
948
1107
|
const noDockerSocketMount = {
|
|
949
1108
|
category: "Compose",
|
|
@@ -952,12 +1111,15 @@ const noDockerSocketMount = {
|
|
|
952
1111
|
for (const [name, config] of composeServices(composeContent)) {
|
|
953
1112
|
const { volumes } = config;
|
|
954
1113
|
if (!Array.isArray(volumes)) continue;
|
|
955
|
-
for (const [index, volume] of volumes.entries())
|
|
956
|
-
|
|
957
|
-
name,
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1114
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1115
|
+
const mount = volumeMount(volume);
|
|
1116
|
+
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?.([
|
|
1117
|
+
"services",
|
|
1118
|
+
name,
|
|
1119
|
+
"volumes",
|
|
1120
|
+
index
|
|
1121
|
+
])));
|
|
1122
|
+
}
|
|
961
1123
|
}
|
|
962
1124
|
return diagnostics;
|
|
963
1125
|
},
|
|
@@ -966,6 +1128,73 @@ const noDockerSocketMount = {
|
|
|
966
1128
|
key: "docker-doctor/no-docker-socket-mount",
|
|
967
1129
|
message: "Do not bind-mount the Docker socket into services"
|
|
968
1130
|
};
|
|
1131
|
+
const BROAD_SCOPE_REASONS = {
|
|
1132
|
+
home: "the whole home directory",
|
|
1133
|
+
"home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
|
|
1134
|
+
parent: "a directory above the Compose project",
|
|
1135
|
+
root: "the host's root filesystem",
|
|
1136
|
+
system: "a host system directory"
|
|
1137
|
+
};
|
|
1138
|
+
const noBroadBindMount = {
|
|
1139
|
+
category: "Compose",
|
|
1140
|
+
check(composeContent, file, context) {
|
|
1141
|
+
const diagnostics = [];
|
|
1142
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
1143
|
+
const { volumes } = config;
|
|
1144
|
+
if (!Array.isArray(volumes)) continue;
|
|
1145
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1146
|
+
const mount = volumeMount(volume);
|
|
1147
|
+
if (!mount || mountsDockerSocket(mount)) continue;
|
|
1148
|
+
const source = bindMountSource(mount);
|
|
1149
|
+
if (source === void 0) continue;
|
|
1150
|
+
const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
|
|
1151
|
+
if (reason === void 0) continue;
|
|
1152
|
+
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?.([
|
|
1153
|
+
"services",
|
|
1154
|
+
name,
|
|
1155
|
+
"volumes",
|
|
1156
|
+
index
|
|
1157
|
+
])));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return diagnostics;
|
|
1161
|
+
},
|
|
1162
|
+
defaultSeverity: "warning",
|
|
1163
|
+
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.",
|
|
1164
|
+
key: "docker-doctor/no-broad-bind-mount",
|
|
1165
|
+
message: "Do not bind-mount broad host directories into services"
|
|
1166
|
+
};
|
|
1167
|
+
const preferReadOnlyBindMount = {
|
|
1168
|
+
category: "Compose",
|
|
1169
|
+
check(composeContent, file, context) {
|
|
1170
|
+
const diagnostics = [];
|
|
1171
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
1172
|
+
const { volumes } = config;
|
|
1173
|
+
if (!Array.isArray(volumes)) continue;
|
|
1174
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1175
|
+
const mount = volumeMount(volume);
|
|
1176
|
+
if (!mount || mount.readOnly || mountsDockerSocket(mount)) continue;
|
|
1177
|
+
const source = bindMountSource(mount);
|
|
1178
|
+
if (source === void 0 || classifyHostPath(source) === "project") continue;
|
|
1179
|
+
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?.([
|
|
1180
|
+
"services",
|
|
1181
|
+
name,
|
|
1182
|
+
"volumes",
|
|
1183
|
+
index
|
|
1184
|
+
])));
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return diagnostics;
|
|
1188
|
+
},
|
|
1189
|
+
defaultSeverity: "info",
|
|
1190
|
+
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.",
|
|
1191
|
+
key: "docker-doctor/prefer-read-only-bind-mount",
|
|
1192
|
+
message: "Bind mounts outside the project should be read-only"
|
|
1193
|
+
};
|
|
1194
|
+
const literalValue = (value) => {
|
|
1195
|
+
if (typeof value === "string") return value;
|
|
1196
|
+
if (typeof value === "number") return String(value);
|
|
1197
|
+
};
|
|
969
1198
|
const noPlaintextSecrets = {
|
|
970
1199
|
category: "Compose",
|
|
971
1200
|
check(composeContent, file, context) {
|
|
@@ -981,15 +1210,16 @@ const noPlaintextSecrets = {
|
|
|
981
1210
|
if (eqIndex <= 0) continue;
|
|
982
1211
|
const key = entry.slice(0, eqIndex);
|
|
983
1212
|
const value = entry.slice(eqIndex + 1);
|
|
984
|
-
if (isSecretKey(key) &&
|
|
1213
|
+
if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
985
1214
|
"services",
|
|
986
1215
|
name,
|
|
987
1216
|
"environment",
|
|
988
1217
|
index
|
|
989
1218
|
]));
|
|
990
1219
|
}
|
|
991
|
-
else if (environment && typeof environment === "object") {
|
|
992
|
-
|
|
1220
|
+
else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
|
|
1221
|
+
const literal = literalValue(value);
|
|
1222
|
+
if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
|
|
993
1223
|
"services",
|
|
994
1224
|
name,
|
|
995
1225
|
"environment",
|
|
@@ -1007,6 +1237,8 @@ const noPlaintextSecrets = {
|
|
|
1007
1237
|
const composeSecurityRules = [
|
|
1008
1238
|
noPrivilegedService,
|
|
1009
1239
|
noDockerSocketMount,
|
|
1240
|
+
noBroadBindMount,
|
|
1241
|
+
preferReadOnlyBindMount,
|
|
1010
1242
|
noPlaintextSecrets
|
|
1011
1243
|
];
|
|
1012
1244
|
|
|
@@ -1047,7 +1279,7 @@ const cleanPackageCache = {
|
|
|
1047
1279
|
const diagnostics = [];
|
|
1048
1280
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
1049
1281
|
const { args } = inst;
|
|
1050
|
-
if (args
|
|
1282
|
+
if (hasAptGetInstall(args) && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
|
|
1051
1283
|
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk") && !hasCacheMountFor(args, APK_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
|
|
1052
1284
|
}
|
|
1053
1285
|
return diagnostics;
|
|
@@ -1057,7 +1289,40 @@ const cleanPackageCache = {
|
|
|
1057
1289
|
key: "docker-doctor/clean-package-cache",
|
|
1058
1290
|
message: "Clean up package manager cache in the same RUN layer"
|
|
1059
1291
|
};
|
|
1060
|
-
const
|
|
1292
|
+
const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
|
|
1293
|
+
const WHITESPACE_RE = /\s+/u;
|
|
1294
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
|
1295
|
+
const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
|
|
1296
|
+
"npm",
|
|
1297
|
+
"yarn",
|
|
1298
|
+
"pnpm",
|
|
1299
|
+
"bun"
|
|
1300
|
+
]);
|
|
1301
|
+
const INSTALL_VERBS = /* @__PURE__ */ new Set([
|
|
1302
|
+
"install",
|
|
1303
|
+
"ci",
|
|
1304
|
+
"i"
|
|
1305
|
+
]);
|
|
1306
|
+
const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
|
|
1307
|
+
"-g",
|
|
1308
|
+
"--global",
|
|
1309
|
+
"--production",
|
|
1310
|
+
"--omit=dev",
|
|
1311
|
+
"--only=prod",
|
|
1312
|
+
"--only=production",
|
|
1313
|
+
"--prod",
|
|
1314
|
+
"-P",
|
|
1315
|
+
"-p"
|
|
1316
|
+
]);
|
|
1317
|
+
const segmentInstallsDevDependencies = (segment) => {
|
|
1318
|
+
const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
|
|
1319
|
+
const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
|
|
1320
|
+
if (commandStart === -1) return false;
|
|
1321
|
+
const [manager, verb, ...options] = tokens.slice(commandStart);
|
|
1322
|
+
if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
|
|
1323
|
+
return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
|
|
1324
|
+
};
|
|
1325
|
+
const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
|
|
1061
1326
|
const avoidDevDependencies = {
|
|
1062
1327
|
category: "Image Size",
|
|
1063
1328
|
check(instructions, file) {
|
|
@@ -1103,11 +1368,12 @@ const imageSizeRules = [
|
|
|
1103
1368
|
|
|
1104
1369
|
//#endregion
|
|
1105
1370
|
//#region ../core/src/rules/performance.ts
|
|
1371
|
+
const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
|
|
1106
1372
|
const useMultiStage = {
|
|
1107
1373
|
category: "Performance",
|
|
1108
1374
|
check(instructions, file) {
|
|
1109
1375
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
1110
|
-
if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || inst.args
|
|
1376
|
+
if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || BUILD_TOOL_COMMAND_RE.test(maskQuotedText(inst.args))))) return [createDiagnostic(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
|
|
1111
1377
|
}
|
|
1112
1378
|
return [];
|
|
1113
1379
|
},
|
|
@@ -1166,7 +1432,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
|
|
|
1166
1432
|
const lastSlash = dockerfilePath.lastIndexOf("/");
|
|
1167
1433
|
const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
|
|
1168
1434
|
const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
|
|
1169
|
-
|
|
1435
|
+
const perDockerfile = `${dockerfilePath}.dockerignore`;
|
|
1436
|
+
return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
|
|
1170
1437
|
};
|
|
1171
1438
|
const useDockerignore = {
|
|
1172
1439
|
category: "Performance",
|
|
@@ -1578,13 +1845,13 @@ const calculateScore = (diagnostics) => {
|
|
|
1578
1845
|
|
|
1579
1846
|
//#endregion
|
|
1580
1847
|
//#region ../core/src/report.ts
|
|
1581
|
-
const REPORT_SCHEMA_VERSION =
|
|
1848
|
+
const REPORT_SCHEMA_VERSION = 4;
|
|
1582
1849
|
const resolveCategory = (diagnostic) => {
|
|
1583
1850
|
const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
|
|
1584
1851
|
if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
|
|
1585
1852
|
return category;
|
|
1586
1853
|
};
|
|
1587
|
-
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
1854
|
+
const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
|
|
1588
1855
|
diagnostics: diagnostics.map((d) => ({
|
|
1589
1856
|
category: resolveCategory(d),
|
|
1590
1857
|
column: d.column,
|
|
@@ -1595,9 +1862,13 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1595
1862
|
rule: d.rule,
|
|
1596
1863
|
severity: d.severity
|
|
1597
1864
|
})),
|
|
1865
|
+
failures: failures.map(({ file, message }) => ({
|
|
1866
|
+
file,
|
|
1867
|
+
message
|
|
1868
|
+
})),
|
|
1598
1869
|
label,
|
|
1599
1870
|
project,
|
|
1600
|
-
schemaVersion:
|
|
1871
|
+
schemaVersion: 4,
|
|
1601
1872
|
score,
|
|
1602
1873
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1603
1874
|
});
|
|
@@ -1681,4 +1952,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1681
1952
|
return version;
|
|
1682
1953
|
}
|
|
1683
1954
|
});
|
|
1684
|
-
//# sourceMappingURL=src-
|
|
1955
|
+
//# sourceMappingURL=src-BpAv-H0Z.cjs.map
|