@docker-doctor/cli 0.6.0 → 0.7.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/dist/cli.cjs +67 -21
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +67 -21
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-BLeXr_hR.mjs → src-D2DWrv5B.mjs} +134 -59
- package/dist/src-D2DWrv5B.mjs.map +1 -0
- package/dist/{src-BpAv-H0Z.cjs → src-DiT93yVt.cjs} +134 -59
- package/dist/src-DiT93yVt.cjs.map +1 -0
- package/package.json +1 -1
- package/skill/docker-doctor/SKILL.md +7 -5
- package/skill/docker-doctor/references/explain.md +2 -2
- package/dist/src-BLeXr_hR.mjs.map +0 -1
- package/dist/src-BpAv-H0Z.cjs.map +0 -1
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-DiT93yVt.cjs');
|
|
4
4
|
|
|
5
5
|
//#region ../core/src/config/define-config.ts
|
|
6
6
|
const defineConfig = (config) => config;
|
package/dist/index.d.cts
CHANGED
|
@@ -74,7 +74,7 @@ export declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorC
|
|
|
74
74
|
export declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
|
|
75
75
|
//#endregion
|
|
76
76
|
//#region ../core/src/scoring.d.ts
|
|
77
|
-
export declare const calculateScore: (diagnostics: Diagnostic[]) => {
|
|
77
|
+
export declare const calculateScore: (diagnostics: Diagnostic[], analyzedFileCount?: number) => {
|
|
78
78
|
score: number;
|
|
79
79
|
label: string;
|
|
80
80
|
};
|
package/dist/index.d.mts
CHANGED
|
@@ -74,7 +74,7 @@ export declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorC
|
|
|
74
74
|
export declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
|
|
75
75
|
//#endregion
|
|
76
76
|
//#region ../core/src/scoring.d.ts
|
|
77
|
-
export declare const calculateScore: (diagnostics: Diagnostic[]) => {
|
|
77
|
+
export declare const calculateScore: (diagnostics: Diagnostic[], analyzedFileCount?: number) => {
|
|
78
78
|
score: number;
|
|
79
79
|
label: string;
|
|
80
80
|
};
|
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-D2DWrv5B.mjs";
|
|
3
3
|
|
|
4
4
|
//#region ../core/src/config/define-config.ts
|
|
5
5
|
const defineConfig = (config) => config;
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
5
6
|
|
|
6
7
|
//#region package.json
|
|
7
|
-
var version = "0.
|
|
8
|
+
var version = "0.7.0";
|
|
8
9
|
|
|
9
10
|
//#endregion
|
|
10
11
|
//#region ../core/src/project-info/ignore.ts
|
|
@@ -44,6 +45,17 @@ const globToRegExp = (pattern) => {
|
|
|
44
45
|
}
|
|
45
46
|
return new RegExp(`${source}$`, "u");
|
|
46
47
|
};
|
|
48
|
+
const LEADING_DOT_SLASH_RE = /^(?:\.\/)+/u;
|
|
49
|
+
/**
|
|
50
|
+
* Accepts two spellings people bring from `.gitignore`. A leading `./` is
|
|
51
|
+
* dropped because patterns are already root-relative, and a trailing `/`
|
|
52
|
+
* means the whole directory. `vendor/` and `./vendor/**` both become
|
|
53
|
+
* `vendor/**`.
|
|
54
|
+
*/
|
|
55
|
+
const normalizeIgnorePattern = (pattern) => {
|
|
56
|
+
const rootRelative = pattern.replace(LEADING_DOT_SLASH_RE, "");
|
|
57
|
+
return rootRelative.endsWith("/") ? `${rootRelative}**` : rootRelative;
|
|
58
|
+
};
|
|
47
59
|
/**
|
|
48
60
|
* Builds a predicate over root-relative paths from `ignore.files`
|
|
49
61
|
* patterns. Windows separators in the tested path are normalized to `/`
|
|
@@ -51,7 +63,7 @@ const globToRegExp = (pattern) => {
|
|
|
51
63
|
*/
|
|
52
64
|
const createIgnoreMatcher = (patterns) => {
|
|
53
65
|
if (!patterns || patterns.length === 0) return () => false;
|
|
54
|
-
const regexps = patterns.map(globToRegExp);
|
|
66
|
+
const regexps = patterns.map(normalizeIgnorePattern).map(globToRegExp);
|
|
55
67
|
return (relativePath) => {
|
|
56
68
|
const normalized = relativePath.replaceAll("\\", "/");
|
|
57
69
|
return regexps.some((regexp) => regexp.test(normalized));
|
|
@@ -88,7 +100,7 @@ const PRUNED_DIRECTORIES = [
|
|
|
88
100
|
const PRUNED = new Set(PRUNED_DIRECTORIES);
|
|
89
101
|
const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
|
|
90
102
|
const SUBTREE_SUFFIX = "/**";
|
|
91
|
-
const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
|
|
103
|
+
const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.map(normalizeIgnorePattern).filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
|
|
92
104
|
const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
93
105
|
const walk = async (dir, options, fileList = []) => {
|
|
94
106
|
let files;
|
|
@@ -236,7 +248,7 @@ const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
|
|
|
236
248
|
"RUN"
|
|
237
249
|
]);
|
|
238
250
|
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
239
|
-
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim
|
|
251
|
+
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>[^\s'"<>;&|()]+)\k<quote>/gu;
|
|
240
252
|
const createParserState = () => ({
|
|
241
253
|
currentArgs: "",
|
|
242
254
|
currentInstruction: "",
|
|
@@ -383,7 +395,7 @@ const parseExecForm = (args) => {
|
|
|
383
395
|
//#endregion
|
|
384
396
|
//#region ../core/src/parsers/image-ref.ts
|
|
385
397
|
const parseImageRef = (ref) => {
|
|
386
|
-
if (ref.includes("$
|
|
398
|
+
if (ref.includes("$")) return {
|
|
387
399
|
isVariable: true,
|
|
388
400
|
name: ref
|
|
389
401
|
};
|
|
@@ -435,6 +447,15 @@ const isHardenedRuntimeImage = (imagePart) => {
|
|
|
435
447
|
const { tag } = parseImageRef(imagePart);
|
|
436
448
|
return !(tag === "dev" || tag?.endsWith("-dev"));
|
|
437
449
|
};
|
|
450
|
+
const DISTROLESS_NONROOT_TAG_RE = /(?:^|-)nonroot(?:-|$)/u;
|
|
451
|
+
/**
|
|
452
|
+
* Distroless images tagged `nonroot` (also `debug-nonroot` and the
|
|
453
|
+
* per-architecture `nonroot-amd64` forms) run as uid 65532.
|
|
454
|
+
*/
|
|
455
|
+
const isDistrolessNonroot = (imagePart) => {
|
|
456
|
+
const { name, registry, tag } = parseImageRef(imagePart);
|
|
457
|
+
return registry === "gcr.io" && name.startsWith("distroless/") && tag !== void 0 && DISTROLESS_NONROOT_TAG_RE.test(tag);
|
|
458
|
+
};
|
|
438
459
|
/**
|
|
439
460
|
* Why a reference would resolve differently over time: no tag at all, or
|
|
440
461
|
* the mutable `latest` tag without a digest. `undefined` means the ref is
|
|
@@ -464,6 +485,11 @@ const collectStageAliases = (instructions) => {
|
|
|
464
485
|
return aliases;
|
|
465
486
|
};
|
|
466
487
|
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region ../core/src/parsers/run-flags.ts
|
|
490
|
+
const LEADING_RUN_FLAGS_RE = /^(?:--[a-z][a-z-]*(?:=\S*)?\s+)+/u;
|
|
491
|
+
const stripRunFlags = (args) => args.replace(LEADING_RUN_FLAGS_RE, "");
|
|
492
|
+
|
|
467
493
|
//#endregion
|
|
468
494
|
//#region ../core/src/rules/create-diagnostic.ts
|
|
469
495
|
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -607,12 +633,14 @@ const usePipefail = {
|
|
|
607
633
|
key: "docker-doctor/use-pipefail",
|
|
608
634
|
message: "Use pipefail to catch pipeline command failures"
|
|
609
635
|
};
|
|
636
|
+
const SURROUNDING_QUOTES_RE$1 = /^(?<quote>["'])(?<inner>.*)\k<quote>$/u;
|
|
610
637
|
const absoluteWorkdir = {
|
|
611
638
|
category: "Best Practices",
|
|
612
639
|
check(instructions, file) {
|
|
613
640
|
const diagnostics = [];
|
|
614
641
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
615
|
-
const
|
|
642
|
+
const trimmed = inst.args.trim();
|
|
643
|
+
const path = trimmed.match(SURROUNDING_QUOTES_RE$1)?.groups?.inner ?? trimmed;
|
|
616
644
|
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `WORKDIR specifies a relative path '${path}'. For clarity and reliability, always use absolute paths.`, this.help, inst.line));
|
|
617
645
|
}
|
|
618
646
|
return diagnostics;
|
|
@@ -626,7 +654,7 @@ const avoidRunCd = {
|
|
|
626
654
|
category: "Best Practices",
|
|
627
655
|
check(instructions, file) {
|
|
628
656
|
const diagnostics = [];
|
|
629
|
-
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));
|
|
657
|
+
for (const inst of instructions) if (inst.instruction === "RUN" && CD_COMMAND_RE.test(maskQuotedText(stripRunFlags(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));
|
|
630
658
|
return diagnostics;
|
|
631
659
|
},
|
|
632
660
|
defaultSeverity: "info",
|
|
@@ -637,11 +665,13 @@ const avoidRunCd = {
|
|
|
637
665
|
const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
|
|
638
666
|
const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
|
|
639
667
|
const WHITESPACE_RE$1 = /\s+/u;
|
|
668
|
+
const UNCONTINUED_NEWLINE_RE = /(?<!\\[ \t]*)\r?\n/u;
|
|
640
669
|
const collectPackageList = (raw) => {
|
|
641
670
|
const start = PACKAGE_LIST_START_RE.exec(raw);
|
|
642
671
|
if (!start) return [];
|
|
643
672
|
const packages = [];
|
|
644
|
-
const
|
|
673
|
+
const [command] = raw.slice(start.index + start[0].length).split(UNCONTINUED_NEWLINE_RE);
|
|
674
|
+
const tokens = command.split(WHITESPACE_RE$1);
|
|
645
675
|
for (const token of tokens) {
|
|
646
676
|
if (SHELL_SEPARATOR_RE$1.test(token)) break;
|
|
647
677
|
if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
|
|
@@ -671,15 +701,18 @@ const sortMultilineArgs = {
|
|
|
671
701
|
key: "docker-doctor/sort-multiline-args",
|
|
672
702
|
message: "Sort multi-line arguments alphanumerically"
|
|
673
703
|
};
|
|
704
|
+
const USERADD_COMMAND_RE = /\buseradd\b/u;
|
|
705
|
+
const NO_LOG_INIT_FLAG_RE = /(?:^|\s)(?:--no-log-init|-[A-Za-z]*l[A-Za-z]*)(?=\s|$)/u;
|
|
706
|
+
const useraddWritesLoginLogs = (args) => args.split(SHELL_SEPARATOR_RE$1).some((command) => USERADD_COMMAND_RE.test(command) && !NO_LOG_INIT_FLAG_RE.test(command));
|
|
674
707
|
const useraddNoLogInit = {
|
|
675
708
|
category: "Best Practices",
|
|
676
709
|
check(instructions, file) {
|
|
677
710
|
const diagnostics = [];
|
|
678
|
-
for (const inst of instructions) if (inst.instruction === "RUN" &&
|
|
711
|
+
for (const inst of instructions) if (inst.instruction === "RUN" && useraddWritesLoginLogs(inst.args)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN instruction runs 'useradd' without '--no-log-init'. This can cause excessive disk space usage / exhaustion under Go's sparse tar archive bug when large UIDs are used.", this.help, inst.line));
|
|
679
712
|
return diagnostics;
|
|
680
713
|
},
|
|
681
714
|
defaultSeverity: "warning",
|
|
682
|
-
help: "Pass `--no-log-init`
|
|
715
|
+
help: "Pass `--no-log-init` or its short form `-l` to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
683
716
|
key: "docker-doctor/useradd-no-log-init",
|
|
684
717
|
message: "Use --no-log-init with useradd"
|
|
685
718
|
};
|
|
@@ -698,6 +731,13 @@ const bestPracticesRules = [
|
|
|
698
731
|
|
|
699
732
|
//#endregion
|
|
700
733
|
//#region ../core/src/rules/compose-services.ts
|
|
734
|
+
const TRUE_SPELLINGS = /* @__PURE__ */ new Set([
|
|
735
|
+
"true",
|
|
736
|
+
"y",
|
|
737
|
+
"yes",
|
|
738
|
+
"on"
|
|
739
|
+
]);
|
|
740
|
+
const isComposeTrue = (value) => value === true || typeof value === "string" && TRUE_SPELLINGS.has(value.trim().toLowerCase());
|
|
701
741
|
/**
|
|
702
742
|
* Narrows an unknown compose document to its service entries. A service
|
|
703
743
|
* with a null body (`web:` with nothing under it) is returned as an empty
|
|
@@ -964,7 +1004,7 @@ const volumeMount = (volume) => {
|
|
|
964
1004
|
if (volume && typeof volume === "object") {
|
|
965
1005
|
const { read_only: readOnly, source, target, type } = volume;
|
|
966
1006
|
return {
|
|
967
|
-
readOnly: readOnly
|
|
1007
|
+
readOnly: isComposeTrue(readOnly),
|
|
968
1008
|
source: typeof source === "string" ? source : void 0,
|
|
969
1009
|
target: typeof target === "string" ? target : void 0,
|
|
970
1010
|
type: typeof type === "string" ? type : void 0
|
|
@@ -1049,12 +1089,14 @@ const SECRET_KEY_PATTERNS = [
|
|
|
1049
1089
|
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
1050
1090
|
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
1051
1091
|
];
|
|
1052
|
-
const
|
|
1092
|
+
const SECRET_LOCATION_SUFFIX = /[_-](?:file|path)$/iu;
|
|
1093
|
+
const isSecretKey = (key) => !SECRET_LOCATION_SUFFIX.test(key) && SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
1053
1094
|
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
1095
|
+
const SWITCH_VALUE = /^(?:true|false|yes|no|on|off)$/iu;
|
|
1054
1096
|
const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
|
|
1055
1097
|
const isLiteralSecretValue = (value) => {
|
|
1056
1098
|
const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
|
|
1057
|
-
return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
|
|
1099
|
+
return unquoted.length > 0 && !unquoted.startsWith("$") && !SWITCH_VALUE.test(unquoted) && !CREDENTIAL_FREE_URL.test(unquoted);
|
|
1058
1100
|
};
|
|
1059
1101
|
|
|
1060
1102
|
//#endregion
|
|
@@ -1063,7 +1105,7 @@ const noPrivilegedService = {
|
|
|
1063
1105
|
category: "Compose",
|
|
1064
1106
|
check(composeContent, file, context) {
|
|
1065
1107
|
const diagnostics = [];
|
|
1066
|
-
for (const [name, config] of composeServices(composeContent)) if (config.privileged
|
|
1108
|
+
for (const [name, config] of composeServices(composeContent)) if (isComposeTrue(config.privileged)) 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
1109
|
"services",
|
|
1068
1110
|
name,
|
|
1069
1111
|
"privileged"
|
|
@@ -1215,13 +1257,56 @@ const composeSecurityRules = [
|
|
|
1215
1257
|
|
|
1216
1258
|
//#endregion
|
|
1217
1259
|
//#region ../core/src/rules/image-size.ts
|
|
1260
|
+
const NODE_ENV_ASSIGNMENT_RE = /(?:^|\s)NODE_ENV(?:=|\s+)["']?(?<value>[^\s"']*)/u;
|
|
1261
|
+
const collectStages = (instructions) => {
|
|
1262
|
+
const stages = [];
|
|
1263
|
+
const productionEnv = [];
|
|
1264
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
1265
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
1266
|
+
const baseKey = base?.toLowerCase() ?? "";
|
|
1267
|
+
const parent = stages.findIndex((s) => s.name !== null && s.name === baseKey);
|
|
1268
|
+
productionEnv.push(parent !== -1 && productionEnv[parent]);
|
|
1269
|
+
stages.push({
|
|
1270
|
+
base: baseKey,
|
|
1271
|
+
from: inst,
|
|
1272
|
+
name: stage?.toLowerCase() ?? null,
|
|
1273
|
+
runs: []
|
|
1274
|
+
});
|
|
1275
|
+
} else if (inst.instruction === "ENV" && stages.length > 0) {
|
|
1276
|
+
const value = NODE_ENV_ASSIGNMENT_RE.exec(inst.args)?.groups?.value;
|
|
1277
|
+
if (value !== void 0) productionEnv[stages.length - 1] = value === "production";
|
|
1278
|
+
} else if (inst.instruction === "RUN" && stages.length > 0 && !productionEnv[stages.length - 1]) stages.at(-1)?.runs.push(inst);
|
|
1279
|
+
return stages;
|
|
1280
|
+
};
|
|
1281
|
+
const shippedStages = (stages) => {
|
|
1282
|
+
const shipped = [];
|
|
1283
|
+
let index = stages.length - 1;
|
|
1284
|
+
while (index >= 0) {
|
|
1285
|
+
shipped.unshift(stages[index]);
|
|
1286
|
+
const { base } = stages[index];
|
|
1287
|
+
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
1288
|
+
}
|
|
1289
|
+
return shipped;
|
|
1290
|
+
};
|
|
1291
|
+
const MINIMAL_BASE_MARKERS = [
|
|
1292
|
+
"alpine",
|
|
1293
|
+
"slim",
|
|
1294
|
+
"distroless",
|
|
1295
|
+
"busybox",
|
|
1296
|
+
"chainguard",
|
|
1297
|
+
"wolfi",
|
|
1298
|
+
"chiseled",
|
|
1299
|
+
"minideb",
|
|
1300
|
+
"nanoserver"
|
|
1301
|
+
];
|
|
1302
|
+
const MINIMAL_BASE_SEGMENT_RE = /(?:^|[/_. -])(?:minimal|micro)(?:$|[/_. -])/u;
|
|
1218
1303
|
const preferSlimBase = {
|
|
1219
1304
|
category: "Image Size",
|
|
1220
1305
|
check(instructions, file) {
|
|
1221
1306
|
const diagnostics = [];
|
|
1222
1307
|
const stageAliases = collectStageAliases(instructions);
|
|
1223
|
-
for (const
|
|
1224
|
-
const imagePart = parseFromArgs(
|
|
1308
|
+
for (const { from } of shippedStages(collectStages(instructions))) {
|
|
1309
|
+
const imagePart = parseFromArgs(from.args).base;
|
|
1225
1310
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
1226
1311
|
const ref = parseImageRef(imagePart);
|
|
1227
1312
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
@@ -1229,7 +1314,7 @@ const preferSlimBase = {
|
|
|
1229
1314
|
if (ref.digest) continue;
|
|
1230
1315
|
if (!ref.tag) continue;
|
|
1231
1316
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
1232
|
-
if (!(
|
|
1317
|
+
if (!(MINIMAL_BASE_MARKERS.some((marker) => haystack.includes(marker)) || MINIMAL_BASE_SEGMENT_RE.test(haystack))) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' may be a full-OS distribution. Consider using a slim or alpine alternative.`, this.help, from.line));
|
|
1233
1318
|
}
|
|
1234
1319
|
return diagnostics;
|
|
1235
1320
|
},
|
|
@@ -1241,6 +1326,8 @@ const preferSlimBase = {
|
|
|
1241
1326
|
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
1242
1327
|
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
1243
1328
|
const cacheMountTargets = (args) => [...args.matchAll(BUILDKIT_MOUNT_FLAG_RE)].map((match) => (match.groups?.spec ?? "").split(",")).filter((options) => options.includes("type=cache")).flatMap((options) => options.filter((option) => CACHE_TARGET_KEY_RE.test(option)).map((option) => option.slice(option.indexOf("=") + 1)));
|
|
1329
|
+
const RECURSIVE_RM_RE = /\brm\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*[rR][A-Za-z]*\s(?<operands>[^;&|]*)/gu;
|
|
1330
|
+
const removesRecursively = (args, dir) => [...args.matchAll(RECURSIVE_RM_RE)].some((match) => (match.groups?.operands ?? "").includes(dir));
|
|
1244
1331
|
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
1245
1332
|
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
1246
1333
|
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
@@ -1250,8 +1337,8 @@ const cleanPackageCache = {
|
|
|
1250
1337
|
const diagnostics = [];
|
|
1251
1338
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
1252
1339
|
const { args } = inst;
|
|
1253
|
-
if (hasAptGetInstall(args) && !args
|
|
1254
|
-
if (args.includes("apk add") && !args.includes("--no-cache") && !args
|
|
1340
|
+
if (hasAptGetInstall(args) && !removesRecursively(args, "/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));
|
|
1341
|
+
if (args.includes("apk add") && !args.includes("--no-cache") && !removesRecursively(args, "/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));
|
|
1255
1342
|
}
|
|
1256
1343
|
return diagnostics;
|
|
1257
1344
|
},
|
|
@@ -1278,6 +1365,7 @@ const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
|
|
|
1278
1365
|
"-g",
|
|
1279
1366
|
"--global",
|
|
1280
1367
|
"--production",
|
|
1368
|
+
"--production=true",
|
|
1281
1369
|
"--omit=dev",
|
|
1282
1370
|
"--only=prod",
|
|
1283
1371
|
"--only=production",
|
|
@@ -1293,36 +1381,17 @@ const segmentInstallsDevDependencies = (segment) => {
|
|
|
1293
1381
|
if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
|
|
1294
1382
|
return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
|
|
1295
1383
|
};
|
|
1296
|
-
const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
|
|
1384
|
+
const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(stripRunFlags(args)).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
|
|
1297
1385
|
const avoidDevDependencies = {
|
|
1298
1386
|
category: "Image Size",
|
|
1299
1387
|
check(instructions, file) {
|
|
1300
1388
|
const diagnostics = [];
|
|
1301
|
-
const stages =
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
runs: []
|
|
1308
|
-
});
|
|
1309
|
-
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
1310
|
-
if (stages.length === 0) return diagnostics;
|
|
1311
|
-
const auditedIndices = [];
|
|
1312
|
-
let index = stages.length - 1;
|
|
1313
|
-
while (index >= 0) {
|
|
1314
|
-
auditedIndices.push(index);
|
|
1315
|
-
const { base } = stages[index];
|
|
1316
|
-
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
1317
|
-
}
|
|
1318
|
-
const finalIndex = stages.length - 1;
|
|
1319
|
-
for (const stageIndex of auditedIndices.toReversed()) {
|
|
1320
|
-
const stage = stages[stageIndex];
|
|
1321
|
-
for (const inst of stage.runs) {
|
|
1322
|
-
if (!installsDevDependencies(inst.args)) continue;
|
|
1323
|
-
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
1324
|
-
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
1325
|
-
}
|
|
1389
|
+
const stages = collectStages(instructions);
|
|
1390
|
+
const finalStage = stages.at(-1);
|
|
1391
|
+
for (const stage of shippedStages(stages)) for (const inst of stage.runs) {
|
|
1392
|
+
if (!installsDevDependencies(inst.args)) continue;
|
|
1393
|
+
const where = stage === finalStage ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
1394
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
1326
1395
|
}
|
|
1327
1396
|
return diagnostics;
|
|
1328
1397
|
},
|
|
@@ -1344,7 +1413,7 @@ const useMultiStage = {
|
|
|
1344
1413
|
category: "Performance",
|
|
1345
1414
|
check(instructions, file) {
|
|
1346
1415
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
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)];
|
|
1416
|
+
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(stripRunFlags(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)];
|
|
1348
1417
|
}
|
|
1349
1418
|
return [];
|
|
1350
1419
|
},
|
|
@@ -1447,7 +1516,7 @@ const noRootUser = {
|
|
|
1447
1516
|
let lastUserLine = 1;
|
|
1448
1517
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
1449
1518
|
const { base, stage } = parseFromArgs(inst.args);
|
|
1450
|
-
const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
|
|
1519
|
+
const baseDefaultUser = base && (isHardenedRuntimeImage(base) || isDistrolessNonroot(base)) ? "nonroot" : "root";
|
|
1451
1520
|
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
|
|
1452
1521
|
lastUserLine = inst.line;
|
|
1453
1522
|
currentStage = stage?.toLowerCase() ?? null;
|
|
@@ -1526,14 +1595,16 @@ const noAddRemote = {
|
|
|
1526
1595
|
check(instructions, file) {
|
|
1527
1596
|
const diagnostics = [];
|
|
1528
1597
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1598
|
+
const parts = inst.args.split(/\s+/u);
|
|
1599
|
+
const src = parts.find((p) => !p.startsWith("--"));
|
|
1600
|
+
const isVerified = parts.some((p) => p.startsWith("--checksum="));
|
|
1601
|
+
if (!src || isVerified) continue;
|
|
1531
1602
|
if (src.startsWith("http://") || src.startsWith("https://")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction uses a remote URL '${src}'. Remote files added via ADD cannot be cleaned up in later layers, increasing image size.`, this.help, inst.line));
|
|
1532
1603
|
}
|
|
1533
1604
|
return diagnostics;
|
|
1534
1605
|
},
|
|
1535
1606
|
defaultSeverity: "warning",
|
|
1536
|
-
help: "
|
|
1607
|
+
help: "Add `--checksum=sha256:<digest>` so BuildKit verifies the download, or use `RUN curl` or `RUN wget` and delete the downloaded archive in the same layer to minimize size.",
|
|
1537
1608
|
key: "docker-doctor/no-add-remote",
|
|
1538
1609
|
message: "Avoid using ADD with remote URLs"
|
|
1539
1610
|
};
|
|
@@ -1717,7 +1788,7 @@ const importConfig = async (filePath) => {
|
|
|
1717
1788
|
if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
|
|
1718
1789
|
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", parse);
|
|
1719
1790
|
try {
|
|
1720
|
-
const configModule = await import(filePath);
|
|
1791
|
+
const configModule = await import(pathToFileURL(filePath).href);
|
|
1721
1792
|
return configModule.default || configModule;
|
|
1722
1793
|
} catch (error) {
|
|
1723
1794
|
const reason = typeStrippingFailure(filePath, error);
|
|
@@ -1801,11 +1872,15 @@ const getScoreBucket = (score) => {
|
|
|
1801
1872
|
for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
|
|
1802
1873
|
return SCORE_BUCKETS.at(-1);
|
|
1803
1874
|
};
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1875
|
+
const SEVERITY_PENALTY = {
|
|
1876
|
+
error: 10,
|
|
1877
|
+
info: 1,
|
|
1878
|
+
warning: 4
|
|
1879
|
+
};
|
|
1880
|
+
const calculateScore = (diagnostics, analyzedFileCount = 1) => {
|
|
1881
|
+
let totalPenalty = 0;
|
|
1882
|
+
for (const diag of diagnostics) totalPenalty += SEVERITY_PENALTY[diag.severity];
|
|
1883
|
+
const penalty = totalPenalty / Math.max(1, analyzedFileCount);
|
|
1809
1884
|
const score = Math.round(100 * Math.exp(-penalty / 70));
|
|
1810
1885
|
const bucket = getScoreBucket(score);
|
|
1811
1886
|
return {
|
|
@@ -1816,7 +1891,7 @@ const calculateScore = (diagnostics) => {
|
|
|
1816
1891
|
|
|
1817
1892
|
//#endregion
|
|
1818
1893
|
//#region ../core/src/report.ts
|
|
1819
|
-
const REPORT_SCHEMA_VERSION =
|
|
1894
|
+
const REPORT_SCHEMA_VERSION = 5;
|
|
1820
1895
|
const resolveCategory = (diagnostic) => {
|
|
1821
1896
|
const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
|
|
1822
1897
|
if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
|
|
@@ -1839,11 +1914,11 @@ const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
|
|
|
1839
1914
|
})),
|
|
1840
1915
|
label,
|
|
1841
1916
|
project,
|
|
1842
|
-
schemaVersion:
|
|
1917
|
+
schemaVersion: 5,
|
|
1843
1918
|
score,
|
|
1844
1919
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1845
1920
|
});
|
|
1846
1921
|
|
|
1847
1922
|
//#endregion
|
|
1848
1923
|
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 };
|
|
1849
|
-
//# sourceMappingURL=src-
|
|
1924
|
+
//# sourceMappingURL=src-D2DWrv5B.mjs.map
|