@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
|
@@ -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.
|
|
7
|
+
var version = "0.6.0";
|
|
8
8
|
|
|
9
9
|
//#endregion
|
|
10
10
|
//#region ../core/src/project-info/ignore.ts
|
|
@@ -60,20 +60,66 @@ const createIgnoreMatcher = (patterns) => {
|
|
|
60
60
|
|
|
61
61
|
//#endregion
|
|
62
62
|
//#region ../core/src/project-info/discover.ts
|
|
63
|
-
const
|
|
64
|
-
|
|
63
|
+
const PRUNED_DIRECTORIES = [
|
|
64
|
+
".git",
|
|
65
|
+
".hg",
|
|
66
|
+
".svn",
|
|
67
|
+
"node_modules",
|
|
68
|
+
"bower_components",
|
|
69
|
+
".pnpm-store",
|
|
70
|
+
".yarn",
|
|
71
|
+
".venv",
|
|
72
|
+
"venv",
|
|
73
|
+
"__pycache__",
|
|
74
|
+
".tox",
|
|
75
|
+
".mypy_cache",
|
|
76
|
+
".pytest_cache",
|
|
77
|
+
".ruff_cache",
|
|
78
|
+
".gradle",
|
|
79
|
+
".turbo",
|
|
80
|
+
".next",
|
|
81
|
+
".nuxt",
|
|
82
|
+
".svelte-kit",
|
|
83
|
+
".parcel-cache",
|
|
84
|
+
".cache",
|
|
85
|
+
"coverage",
|
|
86
|
+
"dist"
|
|
87
|
+
];
|
|
88
|
+
const PRUNED = new Set(PRUNED_DIRECTORIES);
|
|
89
|
+
const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
|
|
90
|
+
const SUBTREE_SUFFIX = "/**";
|
|
91
|
+
const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
|
|
92
|
+
const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
93
|
+
const walk = async (dir, options, fileList = []) => {
|
|
94
|
+
let files;
|
|
95
|
+
try {
|
|
96
|
+
files = await fs.readdir(dir, { withFileTypes: true });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (dir !== options.rootDir && isPermissionError(error)) {
|
|
99
|
+
options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
|
|
100
|
+
return fileList;
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
65
104
|
await Promise.all(files.map(async (file) => {
|
|
66
105
|
const filePath = path.join(dir, file.name);
|
|
106
|
+
if (file.isSymbolicLink()) {
|
|
107
|
+
fileList.push(filePath);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
67
110
|
if (file.isDirectory()) {
|
|
68
|
-
if (
|
|
69
|
-
await walk(filePath, fileList);
|
|
111
|
+
if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
|
|
112
|
+
await walk(filePath, options, fileList);
|
|
70
113
|
} else fileList.push(filePath);
|
|
71
114
|
}));
|
|
72
115
|
return fileList;
|
|
73
116
|
};
|
|
74
|
-
const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
|
|
75
117
|
const discoverProject = async (rootDir, options) => {
|
|
76
|
-
const allFiles = await walk(rootDir
|
|
118
|
+
const allFiles = await walk(rootDir, {
|
|
119
|
+
isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
|
|
120
|
+
onSkippedDirectory: options?.onSkippedDirectory,
|
|
121
|
+
rootDir
|
|
122
|
+
});
|
|
77
123
|
const dockerfiles = [];
|
|
78
124
|
const composeFiles = [];
|
|
79
125
|
const dockerignores = [];
|
|
@@ -258,7 +304,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
|
|
|
258
304
|
const lineNum = i + 1;
|
|
259
305
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
260
306
|
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
261
|
-
if (!
|
|
307
|
+
if (!insideHeredoc && trimmed === "") continue;
|
|
262
308
|
state.rawAccumulator.push(rawLine);
|
|
263
309
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
264
310
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -429,6 +475,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
|
429
475
|
severity
|
|
430
476
|
});
|
|
431
477
|
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region ../core/src/rules/package-managers.ts
|
|
480
|
+
const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
|
|
481
|
+
const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
|
|
482
|
+
const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
|
|
483
|
+
const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
|
|
484
|
+
|
|
432
485
|
//#endregion
|
|
433
486
|
//#region ../core/src/rules/best-practices.ts
|
|
434
487
|
const requireHealthcheck = {
|
|
@@ -444,6 +497,16 @@ const requireHealthcheck = {
|
|
|
444
497
|
key: "docker-doctor/require-healthcheck",
|
|
445
498
|
message: "Add a HEALTHCHECK instruction"
|
|
446
499
|
};
|
|
500
|
+
const AUTO_EXTRACT_SUFFIXES = [
|
|
501
|
+
".tar",
|
|
502
|
+
".tar.gz",
|
|
503
|
+
".tgz",
|
|
504
|
+
".tar.bz2",
|
|
505
|
+
".tbz2",
|
|
506
|
+
".tbz",
|
|
507
|
+
".tar.xz",
|
|
508
|
+
".txz"
|
|
509
|
+
];
|
|
447
510
|
const preferCopyOverAdd = {
|
|
448
511
|
category: "Best Practices",
|
|
449
512
|
check(instructions, file) {
|
|
@@ -452,7 +515,7 @@ const preferCopyOverAdd = {
|
|
|
452
515
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
453
516
|
if (!src) continue;
|
|
454
517
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
455
|
-
const isArchive =
|
|
518
|
+
const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
|
|
456
519
|
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));
|
|
457
520
|
}
|
|
458
521
|
return diagnostics;
|
|
@@ -490,8 +553,8 @@ const combineAptUpdateInstall = {
|
|
|
490
553
|
check(instructions, file) {
|
|
491
554
|
const diagnostics = [];
|
|
492
555
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
493
|
-
const hasUpdate = inst.args
|
|
494
|
-
const hasInstall = inst.args
|
|
556
|
+
const hasUpdate = hasAptGetUpdate(inst.args);
|
|
557
|
+
const hasInstall = hasAptGetInstall(inst.args);
|
|
495
558
|
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));
|
|
496
559
|
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));
|
|
497
560
|
}
|
|
@@ -571,18 +634,32 @@ const avoidRunCd = {
|
|
|
571
634
|
key: "docker-doctor/avoid-run-cd",
|
|
572
635
|
message: "Avoid changing directories with cd in RUN"
|
|
573
636
|
};
|
|
637
|
+
const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
|
|
638
|
+
const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
|
|
639
|
+
const WHITESPACE_RE$1 = /\s+/u;
|
|
640
|
+
const collectPackageList = (raw) => {
|
|
641
|
+
const start = PACKAGE_LIST_START_RE.exec(raw);
|
|
642
|
+
if (!start) return [];
|
|
643
|
+
const packages = [];
|
|
644
|
+
const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
|
|
645
|
+
for (const token of tokens) {
|
|
646
|
+
if (SHELL_SEPARATOR_RE$1.test(token)) break;
|
|
647
|
+
if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
|
|
648
|
+
}
|
|
649
|
+
return packages;
|
|
650
|
+
};
|
|
574
651
|
const sortMultilineArgs = {
|
|
575
652
|
category: "Best Practices",
|
|
576
653
|
check(instructions, file) {
|
|
577
654
|
const diagnostics = [];
|
|
578
655
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
579
656
|
const { raw } = inst;
|
|
580
|
-
const isPackageInstall = raw
|
|
657
|
+
const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
|
|
581
658
|
const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
|
|
582
659
|
if (isPackageInstall && hasContinuation) {
|
|
583
|
-
const packages = raw
|
|
660
|
+
const packages = collectPackageList(raw);
|
|
584
661
|
if (packages.length > 1) {
|
|
585
|
-
const sorted = packages.toSorted(
|
|
662
|
+
const sorted = packages.toSorted();
|
|
586
663
|
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));
|
|
587
664
|
}
|
|
588
665
|
}
|
|
@@ -834,48 +911,21 @@ const pinModelVersion = {
|
|
|
834
911
|
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
835
912
|
|
|
836
913
|
//#endregion
|
|
837
|
-
//#region ../core/src/rules/
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
845
|
-
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
846
|
-
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
847
|
-
];
|
|
848
|
-
const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
849
|
-
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
850
|
-
const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
|
|
851
|
-
|
|
852
|
-
//#endregion
|
|
853
|
-
//#region ../core/src/rules/compose-security.ts
|
|
854
|
-
const noPrivilegedService = {
|
|
855
|
-
category: "Compose",
|
|
856
|
-
check(composeContent, file, context) {
|
|
857
|
-
const diagnostics = [];
|
|
858
|
-
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?.([
|
|
859
|
-
"services",
|
|
860
|
-
name,
|
|
861
|
-
"privileged"
|
|
862
|
-
])));
|
|
863
|
-
return diagnostics;
|
|
864
|
-
},
|
|
865
|
-
defaultSeverity: "error",
|
|
866
|
-
help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
|
|
867
|
-
key: "docker-doctor/no-privileged-service",
|
|
868
|
-
message: "Do not run services in privileged mode"
|
|
869
|
-
};
|
|
870
|
-
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
914
|
+
//#region ../core/src/rules/compose-mounts.ts
|
|
915
|
+
/**
|
|
916
|
+
* Shared parsing for Compose `volumes:` entries, used by every rule that
|
|
917
|
+
* reasons about bind mounts. A Compose volume entry is either the short
|
|
918
|
+
* `[SOURCE:]TARGET[:MODE]` string or the long-syntax object; both collapse
|
|
919
|
+
* to a `VolumeMount`.
|
|
920
|
+
*/
|
|
871
921
|
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
872
922
|
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
873
|
-
const
|
|
923
|
+
const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
|
|
924
|
+
const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
|
|
925
|
+
const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
|
|
926
|
+
const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
|
|
927
|
+
const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
874
928
|
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
929
|
const splitShortSyntax = (spec) => {
|
|
880
930
|
const parts = [];
|
|
881
931
|
let depth = 0;
|
|
@@ -893,28 +943,137 @@ const splitShortSyntax = (spec) => {
|
|
|
893
943
|
parts.push(current);
|
|
894
944
|
return parts;
|
|
895
945
|
};
|
|
946
|
+
const DRIVE_LETTER = /^[A-Za-z]$/u;
|
|
947
|
+
const joinDriveLetter = (parts) => {
|
|
948
|
+
const [first, second, ...rest] = parts;
|
|
949
|
+
if (first !== void 0 && second !== void 0 && DRIVE_LETTER.test(first) && /^[\\/]/u.test(second)) return [`${first}:${second}`, ...rest];
|
|
950
|
+
return parts;
|
|
951
|
+
};
|
|
952
|
+
const shortSyntaxReadOnly = (mode) => mode !== void 0 && mode.split(",").includes("ro");
|
|
896
953
|
const volumeMount = (volume) => {
|
|
897
954
|
if (typeof volume === "string") {
|
|
898
|
-
const [source, target] = splitShortSyntax(volume);
|
|
899
|
-
|
|
955
|
+
const [source, target, mode] = joinDriveLetter(splitShortSyntax(volume));
|
|
956
|
+
if (target === void 0) return;
|
|
957
|
+
return {
|
|
958
|
+
readOnly: shortSyntaxReadOnly(mode),
|
|
900
959
|
source,
|
|
901
|
-
target
|
|
960
|
+
target,
|
|
961
|
+
type: void 0
|
|
902
962
|
};
|
|
903
963
|
}
|
|
904
964
|
if (volume && typeof volume === "object") {
|
|
905
|
-
const { source, target } = volume;
|
|
965
|
+
const { read_only: readOnly, source, target, type } = volume;
|
|
906
966
|
return {
|
|
967
|
+
readOnly: readOnly === true,
|
|
907
968
|
source: typeof source === "string" ? source : void 0,
|
|
908
|
-
target: typeof target === "string" ? target : void 0
|
|
969
|
+
target: typeof target === "string" ? target : void 0,
|
|
970
|
+
type: typeof type === "string" ? type : void 0
|
|
909
971
|
};
|
|
910
972
|
}
|
|
911
973
|
};
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
974
|
+
/**
|
|
975
|
+
* The host path a bind mount reads from, with interpolation defaults
|
|
976
|
+
* applied and Windows separators folded to `/`. Undefined when the entry is
|
|
977
|
+
* not a bind mount (named volume, tmpfs, anonymous volume) or when the
|
|
978
|
+
* source starts with an interpolation that has no default. A source such as
|
|
979
|
+
* `${DATA_DIR}/pg` names no known host location, so no rule can say whether
|
|
980
|
+
* it is inside the project.
|
|
981
|
+
*/
|
|
982
|
+
const bindMountSource = (mount) => {
|
|
983
|
+
if (mount.type !== void 0 && mount.type !== "bind") return;
|
|
984
|
+
if (!mount.source) return;
|
|
985
|
+
const withDefaults = applyFoldsAndDefaults(mount.source);
|
|
986
|
+
if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
|
|
987
|
+
const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
|
|
988
|
+
if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
|
|
989
|
+
return resolved;
|
|
990
|
+
};
|
|
991
|
+
const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || hostPath.endsWith("/pipe/docker_engine");
|
|
992
|
+
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
993
|
+
/**
|
|
994
|
+
* Whether the entry bind-mounts the Docker socket, under any of its host
|
|
995
|
+
* spellings. A source that starts with `${VAR}` names no known host path,
|
|
996
|
+
* but a `/docker.sock` suffix or a socket target still identifies it.
|
|
997
|
+
*/
|
|
998
|
+
const mountsDockerSocket = (mount) => {
|
|
999
|
+
const source = bindMountSource(mount);
|
|
1000
|
+
if (source !== void 0) return isDockerSocketPath(source);
|
|
1001
|
+
if (mount.type !== void 0 && mount.type !== "bind") return false;
|
|
1002
|
+
if (!mount.source) return false;
|
|
1003
|
+
const remainder = resolveInterpolationDefaults(mount.source);
|
|
1004
|
+
return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
1005
|
+
};
|
|
1006
|
+
const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
|
|
1007
|
+
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
|
|
1008
|
+
const SYSTEM_DIRECTORIES = [
|
|
1009
|
+
"/etc",
|
|
1010
|
+
"/root",
|
|
1011
|
+
"/home",
|
|
1012
|
+
"/Users",
|
|
1013
|
+
"/proc",
|
|
1014
|
+
"/sys",
|
|
1015
|
+
"/boot",
|
|
1016
|
+
"/run",
|
|
1017
|
+
"/var/run"
|
|
1018
|
+
];
|
|
1019
|
+
const startsWithDirectory = (hostPath, directory) => hostPath === directory || hostPath.startsWith(`${directory}/`);
|
|
1020
|
+
/**
|
|
1021
|
+
* Classifies where on the host a bind-mount source points. `project` is a
|
|
1022
|
+
* relative path that stays inside the Compose project directory; everything
|
|
1023
|
+
* else reaches outside it.
|
|
1024
|
+
*/
|
|
1025
|
+
const classifyHostPath = (hostPath) => {
|
|
1026
|
+
const collapsed = path.posix.normalize(hostPath);
|
|
1027
|
+
const normalized = collapsed.length > 1 ? collapsed.replace(/\/+$/u, "") : collapsed;
|
|
1028
|
+
if (normalized === "~") return "home";
|
|
1029
|
+
if (normalized.startsWith("~/")) return normalized.startsWith("~/.") ? "home-dotfile" : "host";
|
|
1030
|
+
if (normalized === "." || normalized.startsWith("./")) return "project";
|
|
1031
|
+
if (normalized === ".." || normalized.startsWith("../")) return "parent";
|
|
1032
|
+
if (WINDOWS_DRIVE_ROOT.test(normalized)) return "root";
|
|
1033
|
+
if (WINDOWS_DRIVE_PREFIX.test(normalized)) return "host";
|
|
1034
|
+
if (normalized === "/") return "root";
|
|
1035
|
+
if (!normalized.startsWith("/")) return "project";
|
|
1036
|
+
for (const directory of SYSTEM_DIRECTORIES) if (startsWithDirectory(normalized, directory)) return "system";
|
|
1037
|
+
return "host";
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region ../core/src/rules/secret-keywords.ts
|
|
1042
|
+
const SECRET_KEY_PATTERNS = [
|
|
1043
|
+
/password(?:[_-]|$)/iu,
|
|
1044
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
1045
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
1046
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
1047
|
+
/(?:^|[_-])apikey(?:[_-]|$)/iu,
|
|
1048
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
1049
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
1050
|
+
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
1051
|
+
];
|
|
1052
|
+
const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
1053
|
+
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
1054
|
+
const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
|
|
1055
|
+
const isLiteralSecretValue = (value) => {
|
|
1056
|
+
const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
|
|
1057
|
+
return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region ../core/src/rules/compose-security.ts
|
|
1062
|
+
const noPrivilegedService = {
|
|
1063
|
+
category: "Compose",
|
|
1064
|
+
check(composeContent, file, context) {
|
|
1065
|
+
const diagnostics = [];
|
|
1066
|
+
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?.([
|
|
1067
|
+
"services",
|
|
1068
|
+
name,
|
|
1069
|
+
"privileged"
|
|
1070
|
+
])));
|
|
1071
|
+
return diagnostics;
|
|
1072
|
+
},
|
|
1073
|
+
defaultSeverity: "error",
|
|
1074
|
+
help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
|
|
1075
|
+
key: "docker-doctor/no-privileged-service",
|
|
1076
|
+
message: "Do not run services in privileged mode"
|
|
918
1077
|
};
|
|
919
1078
|
const noDockerSocketMount = {
|
|
920
1079
|
category: "Compose",
|
|
@@ -923,12 +1082,15 @@ const noDockerSocketMount = {
|
|
|
923
1082
|
for (const [name, config] of composeServices(composeContent)) {
|
|
924
1083
|
const { volumes } = config;
|
|
925
1084
|
if (!Array.isArray(volumes)) continue;
|
|
926
|
-
for (const [index, volume] of volumes.entries())
|
|
927
|
-
|
|
928
|
-
name,
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1085
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1086
|
+
const mount = volumeMount(volume);
|
|
1087
|
+
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?.([
|
|
1088
|
+
"services",
|
|
1089
|
+
name,
|
|
1090
|
+
"volumes",
|
|
1091
|
+
index
|
|
1092
|
+
])));
|
|
1093
|
+
}
|
|
932
1094
|
}
|
|
933
1095
|
return diagnostics;
|
|
934
1096
|
},
|
|
@@ -937,6 +1099,73 @@ const noDockerSocketMount = {
|
|
|
937
1099
|
key: "docker-doctor/no-docker-socket-mount",
|
|
938
1100
|
message: "Do not bind-mount the Docker socket into services"
|
|
939
1101
|
};
|
|
1102
|
+
const BROAD_SCOPE_REASONS = {
|
|
1103
|
+
home: "the whole home directory",
|
|
1104
|
+
"home-dotfile": "a hidden directory under the home directory, where credentials and tool configuration live",
|
|
1105
|
+
parent: "a directory above the Compose project",
|
|
1106
|
+
root: "the host's root filesystem",
|
|
1107
|
+
system: "a host system directory"
|
|
1108
|
+
};
|
|
1109
|
+
const noBroadBindMount = {
|
|
1110
|
+
category: "Compose",
|
|
1111
|
+
check(composeContent, file, context) {
|
|
1112
|
+
const diagnostics = [];
|
|
1113
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
1114
|
+
const { volumes } = config;
|
|
1115
|
+
if (!Array.isArray(volumes)) continue;
|
|
1116
|
+
for (const [index, volume] of volumes.entries()) {
|
|
1117
|
+
const mount = volumeMount(volume);
|
|
1118
|
+
if (!mount || mountsDockerSocket(mount)) continue;
|
|
1119
|
+
const source = bindMountSource(mount);
|
|
1120
|
+
if (source === void 0) continue;
|
|
1121
|
+
const reason = BROAD_SCOPE_REASONS[classifyHostPath(source)];
|
|
1122
|
+
if (reason === void 0) continue;
|
|
1123
|
+
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?.([
|
|
1124
|
+
"services",
|
|
1125
|
+
name,
|
|
1126
|
+
"volumes",
|
|
1127
|
+
index
|
|
1128
|
+
])));
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return diagnostics;
|
|
1132
|
+
},
|
|
1133
|
+
defaultSeverity: "warning",
|
|
1134
|
+
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.",
|
|
1135
|
+
key: "docker-doctor/no-broad-bind-mount",
|
|
1136
|
+
message: "Do not bind-mount broad host directories into services"
|
|
1137
|
+
};
|
|
1138
|
+
const preferReadOnlyBindMount = {
|
|
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 || mount.readOnly || mountsDockerSocket(mount)) continue;
|
|
1148
|
+
const source = bindMountSource(mount);
|
|
1149
|
+
if (source === void 0 || classifyHostPath(source) === "project") continue;
|
|
1150
|
+
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?.([
|
|
1151
|
+
"services",
|
|
1152
|
+
name,
|
|
1153
|
+
"volumes",
|
|
1154
|
+
index
|
|
1155
|
+
])));
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return diagnostics;
|
|
1159
|
+
},
|
|
1160
|
+
defaultSeverity: "info",
|
|
1161
|
+
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.",
|
|
1162
|
+
key: "docker-doctor/prefer-read-only-bind-mount",
|
|
1163
|
+
message: "Bind mounts outside the project should be read-only"
|
|
1164
|
+
};
|
|
1165
|
+
const literalValue = (value) => {
|
|
1166
|
+
if (typeof value === "string") return value;
|
|
1167
|
+
if (typeof value === "number") return String(value);
|
|
1168
|
+
};
|
|
940
1169
|
const noPlaintextSecrets = {
|
|
941
1170
|
category: "Compose",
|
|
942
1171
|
check(composeContent, file, context) {
|
|
@@ -952,15 +1181,16 @@ const noPlaintextSecrets = {
|
|
|
952
1181
|
if (eqIndex <= 0) continue;
|
|
953
1182
|
const key = entry.slice(0, eqIndex);
|
|
954
1183
|
const value = entry.slice(eqIndex + 1);
|
|
955
|
-
if (isSecretKey(key) &&
|
|
1184
|
+
if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
956
1185
|
"services",
|
|
957
1186
|
name,
|
|
958
1187
|
"environment",
|
|
959
1188
|
index
|
|
960
1189
|
]));
|
|
961
1190
|
}
|
|
962
|
-
else if (environment && typeof environment === "object") {
|
|
963
|
-
|
|
1191
|
+
else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
|
|
1192
|
+
const literal = literalValue(value);
|
|
1193
|
+
if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
|
|
964
1194
|
"services",
|
|
965
1195
|
name,
|
|
966
1196
|
"environment",
|
|
@@ -978,6 +1208,8 @@ const noPlaintextSecrets = {
|
|
|
978
1208
|
const composeSecurityRules = [
|
|
979
1209
|
noPrivilegedService,
|
|
980
1210
|
noDockerSocketMount,
|
|
1211
|
+
noBroadBindMount,
|
|
1212
|
+
preferReadOnlyBindMount,
|
|
981
1213
|
noPlaintextSecrets
|
|
982
1214
|
];
|
|
983
1215
|
|
|
@@ -1018,7 +1250,7 @@ const cleanPackageCache = {
|
|
|
1018
1250
|
const diagnostics = [];
|
|
1019
1251
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
1020
1252
|
const { args } = inst;
|
|
1021
|
-
if (args
|
|
1253
|
+
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));
|
|
1022
1254
|
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));
|
|
1023
1255
|
}
|
|
1024
1256
|
return diagnostics;
|
|
@@ -1028,7 +1260,40 @@ const cleanPackageCache = {
|
|
|
1028
1260
|
key: "docker-doctor/clean-package-cache",
|
|
1029
1261
|
message: "Clean up package manager cache in the same RUN layer"
|
|
1030
1262
|
};
|
|
1031
|
-
const
|
|
1263
|
+
const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
|
|
1264
|
+
const WHITESPACE_RE = /\s+/u;
|
|
1265
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
|
1266
|
+
const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
|
|
1267
|
+
"npm",
|
|
1268
|
+
"yarn",
|
|
1269
|
+
"pnpm",
|
|
1270
|
+
"bun"
|
|
1271
|
+
]);
|
|
1272
|
+
const INSTALL_VERBS = /* @__PURE__ */ new Set([
|
|
1273
|
+
"install",
|
|
1274
|
+
"ci",
|
|
1275
|
+
"i"
|
|
1276
|
+
]);
|
|
1277
|
+
const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
|
|
1278
|
+
"-g",
|
|
1279
|
+
"--global",
|
|
1280
|
+
"--production",
|
|
1281
|
+
"--omit=dev",
|
|
1282
|
+
"--only=prod",
|
|
1283
|
+
"--only=production",
|
|
1284
|
+
"--prod",
|
|
1285
|
+
"-P",
|
|
1286
|
+
"-p"
|
|
1287
|
+
]);
|
|
1288
|
+
const segmentInstallsDevDependencies = (segment) => {
|
|
1289
|
+
const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
|
|
1290
|
+
const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
|
|
1291
|
+
if (commandStart === -1) return false;
|
|
1292
|
+
const [manager, verb, ...options] = tokens.slice(commandStart);
|
|
1293
|
+
if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
|
|
1294
|
+
return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
|
|
1295
|
+
};
|
|
1296
|
+
const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
|
|
1032
1297
|
const avoidDevDependencies = {
|
|
1033
1298
|
category: "Image Size",
|
|
1034
1299
|
check(instructions, file) {
|
|
@@ -1074,11 +1339,12 @@ const imageSizeRules = [
|
|
|
1074
1339
|
|
|
1075
1340
|
//#endregion
|
|
1076
1341
|
//#region ../core/src/rules/performance.ts
|
|
1342
|
+
const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
|
|
1077
1343
|
const useMultiStage = {
|
|
1078
1344
|
category: "Performance",
|
|
1079
1345
|
check(instructions, file) {
|
|
1080
1346
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
1081
|
-
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
|
|
1347
|
+
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)];
|
|
1082
1348
|
}
|
|
1083
1349
|
return [];
|
|
1084
1350
|
},
|
|
@@ -1137,7 +1403,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
|
|
|
1137
1403
|
const lastSlash = dockerfilePath.lastIndexOf("/");
|
|
1138
1404
|
const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
|
|
1139
1405
|
const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
|
|
1140
|
-
|
|
1406
|
+
const perDockerfile = `${dockerfilePath}.dockerignore`;
|
|
1407
|
+
return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
|
|
1141
1408
|
};
|
|
1142
1409
|
const useDockerignore = {
|
|
1143
1410
|
category: "Performance",
|
|
@@ -1549,13 +1816,13 @@ const calculateScore = (diagnostics) => {
|
|
|
1549
1816
|
|
|
1550
1817
|
//#endregion
|
|
1551
1818
|
//#region ../core/src/report.ts
|
|
1552
|
-
const REPORT_SCHEMA_VERSION =
|
|
1819
|
+
const REPORT_SCHEMA_VERSION = 4;
|
|
1553
1820
|
const resolveCategory = (diagnostic) => {
|
|
1554
1821
|
const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
|
|
1555
1822
|
if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
|
|
1556
1823
|
return category;
|
|
1557
1824
|
};
|
|
1558
|
-
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
1825
|
+
const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
|
|
1559
1826
|
diagnostics: diagnostics.map((d) => ({
|
|
1560
1827
|
category: resolveCategory(d),
|
|
1561
1828
|
column: d.column,
|
|
@@ -1566,13 +1833,17 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1566
1833
|
rule: d.rule,
|
|
1567
1834
|
severity: d.severity
|
|
1568
1835
|
})),
|
|
1836
|
+
failures: failures.map(({ file, message }) => ({
|
|
1837
|
+
file,
|
|
1838
|
+
message
|
|
1839
|
+
})),
|
|
1569
1840
|
label,
|
|
1570
1841
|
project,
|
|
1571
|
-
schemaVersion:
|
|
1842
|
+
schemaVersion: 4,
|
|
1572
1843
|
score,
|
|
1573
1844
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1574
1845
|
});
|
|
1575
1846
|
|
|
1576
1847
|
//#endregion
|
|
1577
1848
|
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-
|
|
1849
|
+
//# sourceMappingURL=src-BLeXr_hR.mjs.map
|