@docker-doctor/cli 0.5.3 → 0.6.1
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 +129 -30
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +129 -30
- 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-D4rVJg36.cjs → src-C7XK6Kp-.cjs} +280 -76
- package/dist/src-C7XK6Kp-.cjs.map +1 -0
- package/dist/{src-CklEIXTX.mjs → src-DiEWLJ6g.mjs} +280 -76
- package/dist/src-DiEWLJ6g.mjs.map +1 -0
- package/package.json +2 -1
- package/skill/docker-doctor/SKILL.md +7 -5
- package/skill/docker-doctor/references/explain.md +2 -2
- package/dist/src-CklEIXTX.mjs.map +0 -1
- package/dist/src-D4rVJg36.cjs.map +0 -1
|
@@ -31,9 +31,10 @@ node_fs_promises = __toESM(node_fs_promises, 1);
|
|
|
31
31
|
let node_path = require("node:path");
|
|
32
32
|
node_path = __toESM(node_path, 1);
|
|
33
33
|
let yaml = require("yaml");
|
|
34
|
+
let node_url = require("node:url");
|
|
34
35
|
|
|
35
36
|
//#region package.json
|
|
36
|
-
var version = "0.
|
|
37
|
+
var version = "0.6.1";
|
|
37
38
|
|
|
38
39
|
//#endregion
|
|
39
40
|
//#region ../core/src/project-info/ignore.ts
|
|
@@ -73,6 +74,17 @@ const globToRegExp = (pattern) => {
|
|
|
73
74
|
}
|
|
74
75
|
return new RegExp(`${source}$`, "u");
|
|
75
76
|
};
|
|
77
|
+
const LEADING_DOT_SLASH_RE = /^(?:\.\/)+/u;
|
|
78
|
+
/**
|
|
79
|
+
* Accepts two spellings people bring from `.gitignore`. A leading `./` is
|
|
80
|
+
* dropped because patterns are already root-relative, and a trailing `/`
|
|
81
|
+
* means the whole directory. `vendor/` and `./vendor/**` both become
|
|
82
|
+
* `vendor/**`.
|
|
83
|
+
*/
|
|
84
|
+
const normalizeIgnorePattern = (pattern) => {
|
|
85
|
+
const rootRelative = pattern.replace(LEADING_DOT_SLASH_RE, "");
|
|
86
|
+
return rootRelative.endsWith("/") ? `${rootRelative}**` : rootRelative;
|
|
87
|
+
};
|
|
76
88
|
/**
|
|
77
89
|
* Builds a predicate over root-relative paths from `ignore.files`
|
|
78
90
|
* patterns. Windows separators in the tested path are normalized to `/`
|
|
@@ -80,7 +92,7 @@ const globToRegExp = (pattern) => {
|
|
|
80
92
|
*/
|
|
81
93
|
const createIgnoreMatcher = (patterns) => {
|
|
82
94
|
if (!patterns || patterns.length === 0) return () => false;
|
|
83
|
-
const regexps = patterns.map(globToRegExp);
|
|
95
|
+
const regexps = patterns.map(normalizeIgnorePattern).map(globToRegExp);
|
|
84
96
|
return (relativePath) => {
|
|
85
97
|
const normalized = relativePath.replaceAll("\\", "/");
|
|
86
98
|
return regexps.some((regexp) => regexp.test(normalized));
|
|
@@ -89,20 +101,66 @@ const createIgnoreMatcher = (patterns) => {
|
|
|
89
101
|
|
|
90
102
|
//#endregion
|
|
91
103
|
//#region ../core/src/project-info/discover.ts
|
|
92
|
-
const
|
|
93
|
-
|
|
104
|
+
const PRUNED_DIRECTORIES = [
|
|
105
|
+
".git",
|
|
106
|
+
".hg",
|
|
107
|
+
".svn",
|
|
108
|
+
"node_modules",
|
|
109
|
+
"bower_components",
|
|
110
|
+
".pnpm-store",
|
|
111
|
+
".yarn",
|
|
112
|
+
".venv",
|
|
113
|
+
"venv",
|
|
114
|
+
"__pycache__",
|
|
115
|
+
".tox",
|
|
116
|
+
".mypy_cache",
|
|
117
|
+
".pytest_cache",
|
|
118
|
+
".ruff_cache",
|
|
119
|
+
".gradle",
|
|
120
|
+
".turbo",
|
|
121
|
+
".next",
|
|
122
|
+
".nuxt",
|
|
123
|
+
".svelte-kit",
|
|
124
|
+
".parcel-cache",
|
|
125
|
+
".cache",
|
|
126
|
+
"coverage",
|
|
127
|
+
"dist"
|
|
128
|
+
];
|
|
129
|
+
const PRUNED = new Set(PRUNED_DIRECTORIES);
|
|
130
|
+
const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
|
|
131
|
+
const SUBTREE_SUFFIX = "/**";
|
|
132
|
+
const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.map(normalizeIgnorePattern).filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
|
|
133
|
+
const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
134
|
+
const walk = async (dir, options, fileList = []) => {
|
|
135
|
+
let files;
|
|
136
|
+
try {
|
|
137
|
+
files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (dir !== options.rootDir && isPermissionError(error)) {
|
|
140
|
+
options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
|
|
141
|
+
return fileList;
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
94
145
|
await Promise.all(files.map(async (file) => {
|
|
95
146
|
const filePath = node_path.default.join(dir, file.name);
|
|
147
|
+
if (file.isSymbolicLink()) {
|
|
148
|
+
fileList.push(filePath);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
96
151
|
if (file.isDirectory()) {
|
|
97
|
-
if (
|
|
98
|
-
await walk(filePath, fileList);
|
|
152
|
+
if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
|
|
153
|
+
await walk(filePath, options, fileList);
|
|
99
154
|
} else fileList.push(filePath);
|
|
100
155
|
}));
|
|
101
156
|
return fileList;
|
|
102
157
|
};
|
|
103
|
-
const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
|
|
104
158
|
const discoverProject = async (rootDir, options) => {
|
|
105
|
-
const allFiles = await walk(rootDir
|
|
159
|
+
const allFiles = await walk(rootDir, {
|
|
160
|
+
isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
|
|
161
|
+
onSkippedDirectory: options?.onSkippedDirectory,
|
|
162
|
+
rootDir
|
|
163
|
+
});
|
|
106
164
|
const dockerfiles = [];
|
|
107
165
|
const composeFiles = [];
|
|
108
166
|
const dockerignores = [];
|
|
@@ -219,7 +277,7 @@ const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
|
|
|
219
277
|
"RUN"
|
|
220
278
|
]);
|
|
221
279
|
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
222
|
-
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim
|
|
280
|
+
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>[^\s'"<>;&|()]+)\k<quote>/gu;
|
|
223
281
|
const createParserState = () => ({
|
|
224
282
|
currentArgs: "",
|
|
225
283
|
currentInstruction: "",
|
|
@@ -287,7 +345,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
|
|
|
287
345
|
const lineNum = i + 1;
|
|
288
346
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
289
347
|
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
290
|
-
if (!
|
|
348
|
+
if (!insideHeredoc && trimmed === "") continue;
|
|
291
349
|
state.rawAccumulator.push(rawLine);
|
|
292
350
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
293
351
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -366,7 +424,7 @@ const parseExecForm = (args) => {
|
|
|
366
424
|
//#endregion
|
|
367
425
|
//#region ../core/src/parsers/image-ref.ts
|
|
368
426
|
const parseImageRef = (ref) => {
|
|
369
|
-
if (ref.includes("$
|
|
427
|
+
if (ref.includes("$")) return {
|
|
370
428
|
isVariable: true,
|
|
371
429
|
name: ref
|
|
372
430
|
};
|
|
@@ -418,6 +476,15 @@ const isHardenedRuntimeImage = (imagePart) => {
|
|
|
418
476
|
const { tag } = parseImageRef(imagePart);
|
|
419
477
|
return !(tag === "dev" || tag?.endsWith("-dev"));
|
|
420
478
|
};
|
|
479
|
+
const DISTROLESS_NONROOT_TAG_RE = /(?:^|-)nonroot(?:-|$)/u;
|
|
480
|
+
/**
|
|
481
|
+
* Distroless images tagged `nonroot` (also `debug-nonroot` and the
|
|
482
|
+
* per-architecture `nonroot-amd64` forms) run as uid 65532.
|
|
483
|
+
*/
|
|
484
|
+
const isDistrolessNonroot = (imagePart) => {
|
|
485
|
+
const { name, registry, tag } = parseImageRef(imagePart);
|
|
486
|
+
return registry === "gcr.io" && name.startsWith("distroless/") && tag !== void 0 && DISTROLESS_NONROOT_TAG_RE.test(tag);
|
|
487
|
+
};
|
|
421
488
|
/**
|
|
422
489
|
* Why a reference would resolve differently over time: no tag at all, or
|
|
423
490
|
* the mutable `latest` tag without a digest. `undefined` means the ref is
|
|
@@ -447,6 +514,11 @@ const collectStageAliases = (instructions) => {
|
|
|
447
514
|
return aliases;
|
|
448
515
|
};
|
|
449
516
|
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region ../core/src/parsers/run-flags.ts
|
|
519
|
+
const LEADING_RUN_FLAGS_RE = /^(?:--[a-z][a-z-]*(?:=\S*)?\s+)+/u;
|
|
520
|
+
const stripRunFlags = (args) => args.replace(LEADING_RUN_FLAGS_RE, "");
|
|
521
|
+
|
|
450
522
|
//#endregion
|
|
451
523
|
//#region ../core/src/rules/create-diagnostic.ts
|
|
452
524
|
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -458,6 +530,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
|
458
530
|
severity
|
|
459
531
|
});
|
|
460
532
|
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region ../core/src/rules/package-managers.ts
|
|
535
|
+
const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
|
|
536
|
+
const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
|
|
537
|
+
const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
|
|
538
|
+
const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
|
|
539
|
+
|
|
461
540
|
//#endregion
|
|
462
541
|
//#region ../core/src/rules/best-practices.ts
|
|
463
542
|
const requireHealthcheck = {
|
|
@@ -473,6 +552,16 @@ const requireHealthcheck = {
|
|
|
473
552
|
key: "docker-doctor/require-healthcheck",
|
|
474
553
|
message: "Add a HEALTHCHECK instruction"
|
|
475
554
|
};
|
|
555
|
+
const AUTO_EXTRACT_SUFFIXES = [
|
|
556
|
+
".tar",
|
|
557
|
+
".tar.gz",
|
|
558
|
+
".tgz",
|
|
559
|
+
".tar.bz2",
|
|
560
|
+
".tbz2",
|
|
561
|
+
".tbz",
|
|
562
|
+
".tar.xz",
|
|
563
|
+
".txz"
|
|
564
|
+
];
|
|
476
565
|
const preferCopyOverAdd = {
|
|
477
566
|
category: "Best Practices",
|
|
478
567
|
check(instructions, file) {
|
|
@@ -481,7 +570,7 @@ const preferCopyOverAdd = {
|
|
|
481
570
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
482
571
|
if (!src) continue;
|
|
483
572
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
484
|
-
const isArchive =
|
|
573
|
+
const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
|
|
485
574
|
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
575
|
}
|
|
487
576
|
return diagnostics;
|
|
@@ -519,8 +608,8 @@ const combineAptUpdateInstall = {
|
|
|
519
608
|
check(instructions, file) {
|
|
520
609
|
const diagnostics = [];
|
|
521
610
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
522
|
-
const hasUpdate = inst.args
|
|
523
|
-
const hasInstall = inst.args
|
|
611
|
+
const hasUpdate = hasAptGetUpdate(inst.args);
|
|
612
|
+
const hasInstall = hasAptGetInstall(inst.args);
|
|
524
613
|
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
614
|
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
615
|
}
|
|
@@ -573,12 +662,14 @@ const usePipefail = {
|
|
|
573
662
|
key: "docker-doctor/use-pipefail",
|
|
574
663
|
message: "Use pipefail to catch pipeline command failures"
|
|
575
664
|
};
|
|
665
|
+
const SURROUNDING_QUOTES_RE$1 = /^(?<quote>["'])(?<inner>.*)\k<quote>$/u;
|
|
576
666
|
const absoluteWorkdir = {
|
|
577
667
|
category: "Best Practices",
|
|
578
668
|
check(instructions, file) {
|
|
579
669
|
const diagnostics = [];
|
|
580
670
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
581
|
-
const
|
|
671
|
+
const trimmed = inst.args.trim();
|
|
672
|
+
const path = trimmed.match(SURROUNDING_QUOTES_RE$1)?.groups?.inner ?? trimmed;
|
|
582
673
|
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));
|
|
583
674
|
}
|
|
584
675
|
return diagnostics;
|
|
@@ -592,7 +683,7 @@ const avoidRunCd = {
|
|
|
592
683
|
category: "Best Practices",
|
|
593
684
|
check(instructions, file) {
|
|
594
685
|
const diagnostics = [];
|
|
595
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && CD_COMMAND_RE.test(maskQuotedText(inst.args))) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Avoid using 'cd' in RUN instructions. Use WORKDIR instead to change the working directory stably across layers.", this.help, inst.line));
|
|
686
|
+
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));
|
|
596
687
|
return diagnostics;
|
|
597
688
|
},
|
|
598
689
|
defaultSeverity: "info",
|
|
@@ -600,18 +691,34 @@ const avoidRunCd = {
|
|
|
600
691
|
key: "docker-doctor/avoid-run-cd",
|
|
601
692
|
message: "Avoid changing directories with cd in RUN"
|
|
602
693
|
};
|
|
694
|
+
const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
|
|
695
|
+
const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
|
|
696
|
+
const WHITESPACE_RE$1 = /\s+/u;
|
|
697
|
+
const UNCONTINUED_NEWLINE_RE = /(?<!\\[ \t]*)\r?\n/u;
|
|
698
|
+
const collectPackageList = (raw) => {
|
|
699
|
+
const start = PACKAGE_LIST_START_RE.exec(raw);
|
|
700
|
+
if (!start) return [];
|
|
701
|
+
const packages = [];
|
|
702
|
+
const [command] = raw.slice(start.index + start[0].length).split(UNCONTINUED_NEWLINE_RE);
|
|
703
|
+
const tokens = command.split(WHITESPACE_RE$1);
|
|
704
|
+
for (const token of tokens) {
|
|
705
|
+
if (SHELL_SEPARATOR_RE$1.test(token)) break;
|
|
706
|
+
if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
|
|
707
|
+
}
|
|
708
|
+
return packages;
|
|
709
|
+
};
|
|
603
710
|
const sortMultilineArgs = {
|
|
604
711
|
category: "Best Practices",
|
|
605
712
|
check(instructions, file) {
|
|
606
713
|
const diagnostics = [];
|
|
607
714
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
608
715
|
const { raw } = inst;
|
|
609
|
-
const isPackageInstall = raw
|
|
716
|
+
const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
|
|
610
717
|
const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
|
|
611
718
|
if (isPackageInstall && hasContinuation) {
|
|
612
|
-
const packages = raw
|
|
719
|
+
const packages = collectPackageList(raw);
|
|
613
720
|
if (packages.length > 1) {
|
|
614
|
-
const sorted = packages.toSorted(
|
|
721
|
+
const sorted = packages.toSorted();
|
|
615
722
|
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
723
|
}
|
|
617
724
|
}
|
|
@@ -623,15 +730,18 @@ const sortMultilineArgs = {
|
|
|
623
730
|
key: "docker-doctor/sort-multiline-args",
|
|
624
731
|
message: "Sort multi-line arguments alphanumerically"
|
|
625
732
|
};
|
|
733
|
+
const USERADD_COMMAND_RE = /\buseradd\b/u;
|
|
734
|
+
const NO_LOG_INIT_FLAG_RE = /(?:^|\s)(?:--no-log-init|-[A-Za-z]*l[A-Za-z]*)(?=\s|$)/u;
|
|
735
|
+
const useraddWritesLoginLogs = (args) => args.split(SHELL_SEPARATOR_RE$1).some((command) => USERADD_COMMAND_RE.test(command) && !NO_LOG_INIT_FLAG_RE.test(command));
|
|
626
736
|
const useraddNoLogInit = {
|
|
627
737
|
category: "Best Practices",
|
|
628
738
|
check(instructions, file) {
|
|
629
739
|
const diagnostics = [];
|
|
630
|
-
for (const inst of instructions) if (inst.instruction === "RUN" &&
|
|
740
|
+
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));
|
|
631
741
|
return diagnostics;
|
|
632
742
|
},
|
|
633
743
|
defaultSeverity: "warning",
|
|
634
|
-
help: "Pass `--no-log-init`
|
|
744
|
+
help: "Pass `--no-log-init` or its short form `-l` to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
635
745
|
key: "docker-doctor/useradd-no-log-init",
|
|
636
746
|
message: "Use --no-log-init with useradd"
|
|
637
747
|
};
|
|
@@ -650,6 +760,13 @@ const bestPracticesRules = [
|
|
|
650
760
|
|
|
651
761
|
//#endregion
|
|
652
762
|
//#region ../core/src/rules/compose-services.ts
|
|
763
|
+
const TRUE_SPELLINGS = /* @__PURE__ */ new Set([
|
|
764
|
+
"true",
|
|
765
|
+
"y",
|
|
766
|
+
"yes",
|
|
767
|
+
"on"
|
|
768
|
+
]);
|
|
769
|
+
const isComposeTrue = (value) => value === true || typeof value === "string" && TRUE_SPELLINGS.has(value.trim().toLowerCase());
|
|
653
770
|
/**
|
|
654
771
|
* Narrows an unknown compose document to its service entries. A service
|
|
655
772
|
* with a null body (`web:` with nothing under it) is returned as an empty
|
|
@@ -873,7 +990,10 @@ const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
|
873
990
|
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
874
991
|
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
875
992
|
const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
|
|
876
|
-
const
|
|
993
|
+
const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
|
|
994
|
+
const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
|
|
995
|
+
const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
|
|
996
|
+
const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
877
997
|
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
878
998
|
const splitShortSyntax = (spec) => {
|
|
879
999
|
const parts = [];
|
|
@@ -913,7 +1033,7 @@ const volumeMount = (volume) => {
|
|
|
913
1033
|
if (volume && typeof volume === "object") {
|
|
914
1034
|
const { read_only: readOnly, source, target, type } = volume;
|
|
915
1035
|
return {
|
|
916
|
-
readOnly: readOnly
|
|
1036
|
+
readOnly: isComposeTrue(readOnly),
|
|
917
1037
|
source: typeof source === "string" ? source : void 0,
|
|
918
1038
|
target: typeof target === "string" ? target : void 0,
|
|
919
1039
|
type: typeof type === "string" ? type : void 0
|
|
@@ -924,12 +1044,16 @@ const volumeMount = (volume) => {
|
|
|
924
1044
|
* The host path a bind mount reads from, with interpolation defaults
|
|
925
1045
|
* applied and Windows separators folded to `/`. Undefined when the entry is
|
|
926
1046
|
* not a bind mount (named volume, tmpfs, anonymous volume) or when the
|
|
927
|
-
* source
|
|
1047
|
+
* source starts with an interpolation that has no default. A source such as
|
|
1048
|
+
* `${DATA_DIR}/pg` names no known host location, so no rule can say whether
|
|
1049
|
+
* it is inside the project.
|
|
928
1050
|
*/
|
|
929
1051
|
const bindMountSource = (mount) => {
|
|
930
1052
|
if (mount.type !== void 0 && mount.type !== "bind") return;
|
|
931
1053
|
if (!mount.source) return;
|
|
932
|
-
const
|
|
1054
|
+
const withDefaults = applyFoldsAndDefaults(mount.source);
|
|
1055
|
+
if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
|
|
1056
|
+
const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
|
|
933
1057
|
if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
|
|
934
1058
|
return resolved;
|
|
935
1059
|
};
|
|
@@ -937,15 +1061,16 @@ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || ho
|
|
|
937
1061
|
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
938
1062
|
/**
|
|
939
1063
|
* Whether the entry bind-mounts the Docker socket, under any of its host
|
|
940
|
-
* spellings. A
|
|
941
|
-
* still
|
|
1064
|
+
* spellings. A source that starts with `${VAR}` names no known host path,
|
|
1065
|
+
* but a `/docker.sock` suffix or a socket target still identifies it.
|
|
942
1066
|
*/
|
|
943
1067
|
const mountsDockerSocket = (mount) => {
|
|
944
1068
|
const source = bindMountSource(mount);
|
|
945
1069
|
if (source !== void 0) return isDockerSocketPath(source);
|
|
946
1070
|
if (mount.type !== void 0 && mount.type !== "bind") return false;
|
|
947
1071
|
if (!mount.source) return false;
|
|
948
|
-
|
|
1072
|
+
const remainder = resolveInterpolationDefaults(mount.source);
|
|
1073
|
+
return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
949
1074
|
};
|
|
950
1075
|
const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
|
|
951
1076
|
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
|
|
@@ -993,9 +1118,15 @@ const SECRET_KEY_PATTERNS = [
|
|
|
993
1118
|
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
994
1119
|
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
995
1120
|
];
|
|
996
|
-
const
|
|
1121
|
+
const SECRET_LOCATION_SUFFIX = /[_-](?:file|path)$/iu;
|
|
1122
|
+
const isSecretKey = (key) => !SECRET_LOCATION_SUFFIX.test(key) && SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
997
1123
|
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
998
|
-
const
|
|
1124
|
+
const SWITCH_VALUE = /^(?:true|false|yes|no|on|off)$/iu;
|
|
1125
|
+
const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
|
|
1126
|
+
const isLiteralSecretValue = (value) => {
|
|
1127
|
+
const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
|
|
1128
|
+
return unquoted.length > 0 && !unquoted.startsWith("$") && !SWITCH_VALUE.test(unquoted) && !CREDENTIAL_FREE_URL.test(unquoted);
|
|
1129
|
+
};
|
|
999
1130
|
|
|
1000
1131
|
//#endregion
|
|
1001
1132
|
//#region ../core/src/rules/compose-security.ts
|
|
@@ -1003,7 +1134,7 @@ const noPrivilegedService = {
|
|
|
1003
1134
|
category: "Compose",
|
|
1004
1135
|
check(composeContent, file, context) {
|
|
1005
1136
|
const diagnostics = [];
|
|
1006
|
-
for (const [name, config] of composeServices(composeContent)) if (config.privileged
|
|
1137
|
+
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?.([
|
|
1007
1138
|
"services",
|
|
1008
1139
|
name,
|
|
1009
1140
|
"privileged"
|
|
@@ -1102,6 +1233,10 @@ const preferReadOnlyBindMount = {
|
|
|
1102
1233
|
key: "docker-doctor/prefer-read-only-bind-mount",
|
|
1103
1234
|
message: "Bind mounts outside the project should be read-only"
|
|
1104
1235
|
};
|
|
1236
|
+
const literalValue = (value) => {
|
|
1237
|
+
if (typeof value === "string") return value;
|
|
1238
|
+
if (typeof value === "number") return String(value);
|
|
1239
|
+
};
|
|
1105
1240
|
const noPlaintextSecrets = {
|
|
1106
1241
|
category: "Compose",
|
|
1107
1242
|
check(composeContent, file, context) {
|
|
@@ -1117,15 +1252,16 @@ const noPlaintextSecrets = {
|
|
|
1117
1252
|
if (eqIndex <= 0) continue;
|
|
1118
1253
|
const key = entry.slice(0, eqIndex);
|
|
1119
1254
|
const value = entry.slice(eqIndex + 1);
|
|
1120
|
-
if (isSecretKey(key) &&
|
|
1255
|
+
if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
1121
1256
|
"services",
|
|
1122
1257
|
name,
|
|
1123
1258
|
"environment",
|
|
1124
1259
|
index
|
|
1125
1260
|
]));
|
|
1126
1261
|
}
|
|
1127
|
-
else if (environment && typeof environment === "object") {
|
|
1128
|
-
|
|
1262
|
+
else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
|
|
1263
|
+
const literal = literalValue(value);
|
|
1264
|
+
if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
|
|
1129
1265
|
"services",
|
|
1130
1266
|
name,
|
|
1131
1267
|
"environment",
|
|
@@ -1150,13 +1286,56 @@ const composeSecurityRules = [
|
|
|
1150
1286
|
|
|
1151
1287
|
//#endregion
|
|
1152
1288
|
//#region ../core/src/rules/image-size.ts
|
|
1289
|
+
const NODE_ENV_ASSIGNMENT_RE = /(?:^|\s)NODE_ENV(?:=|\s+)["']?(?<value>[^\s"']*)/u;
|
|
1290
|
+
const collectStages = (instructions) => {
|
|
1291
|
+
const stages = [];
|
|
1292
|
+
const productionEnv = [];
|
|
1293
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
1294
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
1295
|
+
const baseKey = base?.toLowerCase() ?? "";
|
|
1296
|
+
const parent = stages.findIndex((s) => s.name !== null && s.name === baseKey);
|
|
1297
|
+
productionEnv.push(parent !== -1 && productionEnv[parent]);
|
|
1298
|
+
stages.push({
|
|
1299
|
+
base: baseKey,
|
|
1300
|
+
from: inst,
|
|
1301
|
+
name: stage?.toLowerCase() ?? null,
|
|
1302
|
+
runs: []
|
|
1303
|
+
});
|
|
1304
|
+
} else if (inst.instruction === "ENV" && stages.length > 0) {
|
|
1305
|
+
const value = NODE_ENV_ASSIGNMENT_RE.exec(inst.args)?.groups?.value;
|
|
1306
|
+
if (value !== void 0) productionEnv[stages.length - 1] = value === "production";
|
|
1307
|
+
} else if (inst.instruction === "RUN" && stages.length > 0 && !productionEnv[stages.length - 1]) stages.at(-1)?.runs.push(inst);
|
|
1308
|
+
return stages;
|
|
1309
|
+
};
|
|
1310
|
+
const shippedStages = (stages) => {
|
|
1311
|
+
const shipped = [];
|
|
1312
|
+
let index = stages.length - 1;
|
|
1313
|
+
while (index >= 0) {
|
|
1314
|
+
shipped.unshift(stages[index]);
|
|
1315
|
+
const { base } = stages[index];
|
|
1316
|
+
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
1317
|
+
}
|
|
1318
|
+
return shipped;
|
|
1319
|
+
};
|
|
1320
|
+
const MINIMAL_BASE_MARKERS = [
|
|
1321
|
+
"alpine",
|
|
1322
|
+
"slim",
|
|
1323
|
+
"distroless",
|
|
1324
|
+
"busybox",
|
|
1325
|
+
"chainguard",
|
|
1326
|
+
"wolfi",
|
|
1327
|
+
"chiseled",
|
|
1328
|
+
"minideb",
|
|
1329
|
+
"nanoserver"
|
|
1330
|
+
];
|
|
1331
|
+
const MINIMAL_BASE_SEGMENT_RE = /(?:^|[/_. -])(?:minimal|micro)(?:$|[/_. -])/u;
|
|
1153
1332
|
const preferSlimBase = {
|
|
1154
1333
|
category: "Image Size",
|
|
1155
1334
|
check(instructions, file) {
|
|
1156
1335
|
const diagnostics = [];
|
|
1157
1336
|
const stageAliases = collectStageAliases(instructions);
|
|
1158
|
-
for (const
|
|
1159
|
-
const imagePart = parseFromArgs(
|
|
1337
|
+
for (const { from } of shippedStages(collectStages(instructions))) {
|
|
1338
|
+
const imagePart = parseFromArgs(from.args).base;
|
|
1160
1339
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
1161
1340
|
const ref = parseImageRef(imagePart);
|
|
1162
1341
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
@@ -1164,7 +1343,7 @@ const preferSlimBase = {
|
|
|
1164
1343
|
if (ref.digest) continue;
|
|
1165
1344
|
if (!ref.tag) continue;
|
|
1166
1345
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
1167
|
-
if (!(
|
|
1346
|
+
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));
|
|
1168
1347
|
}
|
|
1169
1348
|
return diagnostics;
|
|
1170
1349
|
},
|
|
@@ -1176,6 +1355,8 @@ const preferSlimBase = {
|
|
|
1176
1355
|
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
1177
1356
|
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
1178
1357
|
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)));
|
|
1358
|
+
const RECURSIVE_RM_RE = /\brm\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*[rR][A-Za-z]*\s(?<operands>[^;&|]*)/gu;
|
|
1359
|
+
const removesRecursively = (args, dir) => [...args.matchAll(RECURSIVE_RM_RE)].some((match) => (match.groups?.operands ?? "").includes(dir));
|
|
1179
1360
|
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
1180
1361
|
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
1181
1362
|
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
@@ -1185,8 +1366,8 @@ const cleanPackageCache = {
|
|
|
1185
1366
|
const diagnostics = [];
|
|
1186
1367
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
1187
1368
|
const { args } = inst;
|
|
1188
|
-
if (args
|
|
1189
|
-
if (args.includes("apk add") && !args.includes("--no-cache") && !args
|
|
1369
|
+
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));
|
|
1370
|
+
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));
|
|
1190
1371
|
}
|
|
1191
1372
|
return diagnostics;
|
|
1192
1373
|
},
|
|
@@ -1195,36 +1376,51 @@ const cleanPackageCache = {
|
|
|
1195
1376
|
key: "docker-doctor/clean-package-cache",
|
|
1196
1377
|
message: "Clean up package manager cache in the same RUN layer"
|
|
1197
1378
|
};
|
|
1198
|
-
const
|
|
1379
|
+
const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
|
|
1380
|
+
const WHITESPACE_RE = /\s+/u;
|
|
1381
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
|
1382
|
+
const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
|
|
1383
|
+
"npm",
|
|
1384
|
+
"yarn",
|
|
1385
|
+
"pnpm",
|
|
1386
|
+
"bun"
|
|
1387
|
+
]);
|
|
1388
|
+
const INSTALL_VERBS = /* @__PURE__ */ new Set([
|
|
1389
|
+
"install",
|
|
1390
|
+
"ci",
|
|
1391
|
+
"i"
|
|
1392
|
+
]);
|
|
1393
|
+
const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
|
|
1394
|
+
"-g",
|
|
1395
|
+
"--global",
|
|
1396
|
+
"--production",
|
|
1397
|
+
"--production=true",
|
|
1398
|
+
"--omit=dev",
|
|
1399
|
+
"--only=prod",
|
|
1400
|
+
"--only=production",
|
|
1401
|
+
"--prod",
|
|
1402
|
+
"-P",
|
|
1403
|
+
"-p"
|
|
1404
|
+
]);
|
|
1405
|
+
const segmentInstallsDevDependencies = (segment) => {
|
|
1406
|
+
const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
|
|
1407
|
+
const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
|
|
1408
|
+
if (commandStart === -1) return false;
|
|
1409
|
+
const [manager, verb, ...options] = tokens.slice(commandStart);
|
|
1410
|
+
if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
|
|
1411
|
+
return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
|
|
1412
|
+
};
|
|
1413
|
+
const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(stripRunFlags(args)).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
|
|
1199
1414
|
const avoidDevDependencies = {
|
|
1200
1415
|
category: "Image Size",
|
|
1201
1416
|
check(instructions, file) {
|
|
1202
1417
|
const diagnostics = [];
|
|
1203
|
-
const stages =
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
runs: []
|
|
1210
|
-
});
|
|
1211
|
-
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
1212
|
-
if (stages.length === 0) return diagnostics;
|
|
1213
|
-
const auditedIndices = [];
|
|
1214
|
-
let index = stages.length - 1;
|
|
1215
|
-
while (index >= 0) {
|
|
1216
|
-
auditedIndices.push(index);
|
|
1217
|
-
const { base } = stages[index];
|
|
1218
|
-
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
1219
|
-
}
|
|
1220
|
-
const finalIndex = stages.length - 1;
|
|
1221
|
-
for (const stageIndex of auditedIndices.toReversed()) {
|
|
1222
|
-
const stage = stages[stageIndex];
|
|
1223
|
-
for (const inst of stage.runs) {
|
|
1224
|
-
if (!installsDevDependencies(inst.args)) continue;
|
|
1225
|
-
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
1226
|
-
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
1227
|
-
}
|
|
1418
|
+
const stages = collectStages(instructions);
|
|
1419
|
+
const finalStage = stages.at(-1);
|
|
1420
|
+
for (const stage of shippedStages(stages)) for (const inst of stage.runs) {
|
|
1421
|
+
if (!installsDevDependencies(inst.args)) continue;
|
|
1422
|
+
const where = stage === finalStage ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
1423
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
1228
1424
|
}
|
|
1229
1425
|
return diagnostics;
|
|
1230
1426
|
},
|
|
@@ -1241,11 +1437,12 @@ const imageSizeRules = [
|
|
|
1241
1437
|
|
|
1242
1438
|
//#endregion
|
|
1243
1439
|
//#region ../core/src/rules/performance.ts
|
|
1440
|
+
const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
|
|
1244
1441
|
const useMultiStage = {
|
|
1245
1442
|
category: "Performance",
|
|
1246
1443
|
check(instructions, file) {
|
|
1247
1444
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
1248
|
-
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
|
|
1445
|
+
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)];
|
|
1249
1446
|
}
|
|
1250
1447
|
return [];
|
|
1251
1448
|
},
|
|
@@ -1304,7 +1501,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
|
|
|
1304
1501
|
const lastSlash = dockerfilePath.lastIndexOf("/");
|
|
1305
1502
|
const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
|
|
1306
1503
|
const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
|
|
1307
|
-
|
|
1504
|
+
const perDockerfile = `${dockerfilePath}.dockerignore`;
|
|
1505
|
+
return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
|
|
1308
1506
|
};
|
|
1309
1507
|
const useDockerignore = {
|
|
1310
1508
|
category: "Performance",
|
|
@@ -1347,7 +1545,7 @@ const noRootUser = {
|
|
|
1347
1545
|
let lastUserLine = 1;
|
|
1348
1546
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
1349
1547
|
const { base, stage } = parseFromArgs(inst.args);
|
|
1350
|
-
const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
|
|
1548
|
+
const baseDefaultUser = base && (isHardenedRuntimeImage(base) || isDistrolessNonroot(base)) ? "nonroot" : "root";
|
|
1351
1549
|
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
|
|
1352
1550
|
lastUserLine = inst.line;
|
|
1353
1551
|
currentStage = stage?.toLowerCase() ?? null;
|
|
@@ -1426,14 +1624,16 @@ const noAddRemote = {
|
|
|
1426
1624
|
check(instructions, file) {
|
|
1427
1625
|
const diagnostics = [];
|
|
1428
1626
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
1429
|
-
const
|
|
1430
|
-
|
|
1627
|
+
const parts = inst.args.split(/\s+/u);
|
|
1628
|
+
const src = parts.find((p) => !p.startsWith("--"));
|
|
1629
|
+
const isVerified = parts.some((p) => p.startsWith("--checksum="));
|
|
1630
|
+
if (!src || isVerified) continue;
|
|
1431
1631
|
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));
|
|
1432
1632
|
}
|
|
1433
1633
|
return diagnostics;
|
|
1434
1634
|
},
|
|
1435
1635
|
defaultSeverity: "warning",
|
|
1436
|
-
help: "
|
|
1636
|
+
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.",
|
|
1437
1637
|
key: "docker-doctor/no-add-remote",
|
|
1438
1638
|
message: "Avoid using ADD with remote URLs"
|
|
1439
1639
|
};
|
|
@@ -1617,7 +1817,7 @@ const importConfig = async (filePath) => {
|
|
|
1617
1817
|
if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
|
|
1618
1818
|
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", yaml.parse);
|
|
1619
1819
|
try {
|
|
1620
|
-
const configModule = await import(filePath);
|
|
1820
|
+
const configModule = await import((0, node_url.pathToFileURL)(filePath).href);
|
|
1621
1821
|
return configModule.default || configModule;
|
|
1622
1822
|
} catch (error) {
|
|
1623
1823
|
const reason = typeStrippingFailure(filePath, error);
|
|
@@ -1716,13 +1916,13 @@ const calculateScore = (diagnostics) => {
|
|
|
1716
1916
|
|
|
1717
1917
|
//#endregion
|
|
1718
1918
|
//#region ../core/src/report.ts
|
|
1719
|
-
const REPORT_SCHEMA_VERSION =
|
|
1919
|
+
const REPORT_SCHEMA_VERSION = 4;
|
|
1720
1920
|
const resolveCategory = (diagnostic) => {
|
|
1721
1921
|
const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
|
|
1722
1922
|
if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
|
|
1723
1923
|
return category;
|
|
1724
1924
|
};
|
|
1725
|
-
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
1925
|
+
const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
|
|
1726
1926
|
diagnostics: diagnostics.map((d) => ({
|
|
1727
1927
|
category: resolveCategory(d),
|
|
1728
1928
|
column: d.column,
|
|
@@ -1733,9 +1933,13 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1733
1933
|
rule: d.rule,
|
|
1734
1934
|
severity: d.severity
|
|
1735
1935
|
})),
|
|
1936
|
+
failures: failures.map(({ file, message }) => ({
|
|
1937
|
+
file,
|
|
1938
|
+
message
|
|
1939
|
+
})),
|
|
1736
1940
|
label,
|
|
1737
1941
|
project,
|
|
1738
|
-
schemaVersion:
|
|
1942
|
+
schemaVersion: 4,
|
|
1739
1943
|
score,
|
|
1740
1944
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1741
1945
|
});
|
|
@@ -1819,4 +2023,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1819
2023
|
return version;
|
|
1820
2024
|
}
|
|
1821
2025
|
});
|
|
1822
|
-
//# sourceMappingURL=src-
|
|
2026
|
+
//# sourceMappingURL=src-C7XK6Kp-.cjs.map
|