@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
|
@@ -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.6.1";
|
|
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));
|
|
@@ -60,20 +72,66 @@ const createIgnoreMatcher = (patterns) => {
|
|
|
60
72
|
|
|
61
73
|
//#endregion
|
|
62
74
|
//#region ../core/src/project-info/discover.ts
|
|
63
|
-
const
|
|
64
|
-
|
|
75
|
+
const PRUNED_DIRECTORIES = [
|
|
76
|
+
".git",
|
|
77
|
+
".hg",
|
|
78
|
+
".svn",
|
|
79
|
+
"node_modules",
|
|
80
|
+
"bower_components",
|
|
81
|
+
".pnpm-store",
|
|
82
|
+
".yarn",
|
|
83
|
+
".venv",
|
|
84
|
+
"venv",
|
|
85
|
+
"__pycache__",
|
|
86
|
+
".tox",
|
|
87
|
+
".mypy_cache",
|
|
88
|
+
".pytest_cache",
|
|
89
|
+
".ruff_cache",
|
|
90
|
+
".gradle",
|
|
91
|
+
".turbo",
|
|
92
|
+
".next",
|
|
93
|
+
".nuxt",
|
|
94
|
+
".svelte-kit",
|
|
95
|
+
".parcel-cache",
|
|
96
|
+
".cache",
|
|
97
|
+
"coverage",
|
|
98
|
+
"dist"
|
|
99
|
+
];
|
|
100
|
+
const PRUNED = new Set(PRUNED_DIRECTORIES);
|
|
101
|
+
const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
|
|
102
|
+
const SUBTREE_SUFFIX = "/**";
|
|
103
|
+
const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.map(normalizeIgnorePattern).filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
|
|
104
|
+
const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
105
|
+
const walk = async (dir, options, fileList = []) => {
|
|
106
|
+
let files;
|
|
107
|
+
try {
|
|
108
|
+
files = await fs.readdir(dir, { withFileTypes: true });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (dir !== options.rootDir && isPermissionError(error)) {
|
|
111
|
+
options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
|
|
112
|
+
return fileList;
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
65
116
|
await Promise.all(files.map(async (file) => {
|
|
66
117
|
const filePath = path.join(dir, file.name);
|
|
118
|
+
if (file.isSymbolicLink()) {
|
|
119
|
+
fileList.push(filePath);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
67
122
|
if (file.isDirectory()) {
|
|
68
|
-
if (
|
|
69
|
-
await walk(filePath, fileList);
|
|
123
|
+
if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
|
|
124
|
+
await walk(filePath, options, fileList);
|
|
70
125
|
} else fileList.push(filePath);
|
|
71
126
|
}));
|
|
72
127
|
return fileList;
|
|
73
128
|
};
|
|
74
|
-
const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
|
|
75
129
|
const discoverProject = async (rootDir, options) => {
|
|
76
|
-
const allFiles = await walk(rootDir
|
|
130
|
+
const allFiles = await walk(rootDir, {
|
|
131
|
+
isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
|
|
132
|
+
onSkippedDirectory: options?.onSkippedDirectory,
|
|
133
|
+
rootDir
|
|
134
|
+
});
|
|
77
135
|
const dockerfiles = [];
|
|
78
136
|
const composeFiles = [];
|
|
79
137
|
const dockerignores = [];
|
|
@@ -190,7 +248,7 @@ const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
|
|
|
190
248
|
"RUN"
|
|
191
249
|
]);
|
|
192
250
|
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
193
|
-
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim
|
|
251
|
+
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>[^\s'"<>;&|()]+)\k<quote>/gu;
|
|
194
252
|
const createParserState = () => ({
|
|
195
253
|
currentArgs: "",
|
|
196
254
|
currentInstruction: "",
|
|
@@ -258,7 +316,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
|
|
|
258
316
|
const lineNum = i + 1;
|
|
259
317
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
260
318
|
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
261
|
-
if (!
|
|
319
|
+
if (!insideHeredoc && trimmed === "") continue;
|
|
262
320
|
state.rawAccumulator.push(rawLine);
|
|
263
321
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
264
322
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -337,7 +395,7 @@ const parseExecForm = (args) => {
|
|
|
337
395
|
//#endregion
|
|
338
396
|
//#region ../core/src/parsers/image-ref.ts
|
|
339
397
|
const parseImageRef = (ref) => {
|
|
340
|
-
if (ref.includes("$
|
|
398
|
+
if (ref.includes("$")) return {
|
|
341
399
|
isVariable: true,
|
|
342
400
|
name: ref
|
|
343
401
|
};
|
|
@@ -389,6 +447,15 @@ const isHardenedRuntimeImage = (imagePart) => {
|
|
|
389
447
|
const { tag } = parseImageRef(imagePart);
|
|
390
448
|
return !(tag === "dev" || tag?.endsWith("-dev"));
|
|
391
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
|
+
};
|
|
392
459
|
/**
|
|
393
460
|
* Why a reference would resolve differently over time: no tag at all, or
|
|
394
461
|
* the mutable `latest` tag without a digest. `undefined` means the ref is
|
|
@@ -418,6 +485,11 @@ const collectStageAliases = (instructions) => {
|
|
|
418
485
|
return aliases;
|
|
419
486
|
};
|
|
420
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
|
+
|
|
421
493
|
//#endregion
|
|
422
494
|
//#region ../core/src/rules/create-diagnostic.ts
|
|
423
495
|
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -429,6 +501,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
|
429
501
|
severity
|
|
430
502
|
});
|
|
431
503
|
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region ../core/src/rules/package-managers.ts
|
|
506
|
+
const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
|
|
507
|
+
const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
|
|
508
|
+
const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
|
|
509
|
+
const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
|
|
510
|
+
|
|
432
511
|
//#endregion
|
|
433
512
|
//#region ../core/src/rules/best-practices.ts
|
|
434
513
|
const requireHealthcheck = {
|
|
@@ -444,6 +523,16 @@ const requireHealthcheck = {
|
|
|
444
523
|
key: "docker-doctor/require-healthcheck",
|
|
445
524
|
message: "Add a HEALTHCHECK instruction"
|
|
446
525
|
};
|
|
526
|
+
const AUTO_EXTRACT_SUFFIXES = [
|
|
527
|
+
".tar",
|
|
528
|
+
".tar.gz",
|
|
529
|
+
".tgz",
|
|
530
|
+
".tar.bz2",
|
|
531
|
+
".tbz2",
|
|
532
|
+
".tbz",
|
|
533
|
+
".tar.xz",
|
|
534
|
+
".txz"
|
|
535
|
+
];
|
|
447
536
|
const preferCopyOverAdd = {
|
|
448
537
|
category: "Best Practices",
|
|
449
538
|
check(instructions, file) {
|
|
@@ -452,7 +541,7 @@ const preferCopyOverAdd = {
|
|
|
452
541
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
453
542
|
if (!src) continue;
|
|
454
543
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
455
|
-
const isArchive =
|
|
544
|
+
const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
|
|
456
545
|
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
546
|
}
|
|
458
547
|
return diagnostics;
|
|
@@ -490,8 +579,8 @@ const combineAptUpdateInstall = {
|
|
|
490
579
|
check(instructions, file) {
|
|
491
580
|
const diagnostics = [];
|
|
492
581
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
493
|
-
const hasUpdate = inst.args
|
|
494
|
-
const hasInstall = inst.args
|
|
582
|
+
const hasUpdate = hasAptGetUpdate(inst.args);
|
|
583
|
+
const hasInstall = hasAptGetInstall(inst.args);
|
|
495
584
|
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
585
|
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
586
|
}
|
|
@@ -544,12 +633,14 @@ const usePipefail = {
|
|
|
544
633
|
key: "docker-doctor/use-pipefail",
|
|
545
634
|
message: "Use pipefail to catch pipeline command failures"
|
|
546
635
|
};
|
|
636
|
+
const SURROUNDING_QUOTES_RE$1 = /^(?<quote>["'])(?<inner>.*)\k<quote>$/u;
|
|
547
637
|
const absoluteWorkdir = {
|
|
548
638
|
category: "Best Practices",
|
|
549
639
|
check(instructions, file) {
|
|
550
640
|
const diagnostics = [];
|
|
551
641
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
552
|
-
const
|
|
642
|
+
const trimmed = inst.args.trim();
|
|
643
|
+
const path = trimmed.match(SURROUNDING_QUOTES_RE$1)?.groups?.inner ?? trimmed;
|
|
553
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));
|
|
554
645
|
}
|
|
555
646
|
return diagnostics;
|
|
@@ -563,7 +654,7 @@ const avoidRunCd = {
|
|
|
563
654
|
category: "Best Practices",
|
|
564
655
|
check(instructions, file) {
|
|
565
656
|
const diagnostics = [];
|
|
566
|
-
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));
|
|
567
658
|
return diagnostics;
|
|
568
659
|
},
|
|
569
660
|
defaultSeverity: "info",
|
|
@@ -571,18 +662,34 @@ const avoidRunCd = {
|
|
|
571
662
|
key: "docker-doctor/avoid-run-cd",
|
|
572
663
|
message: "Avoid changing directories with cd in RUN"
|
|
573
664
|
};
|
|
665
|
+
const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
|
|
666
|
+
const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
|
|
667
|
+
const WHITESPACE_RE$1 = /\s+/u;
|
|
668
|
+
const UNCONTINUED_NEWLINE_RE = /(?<!\\[ \t]*)\r?\n/u;
|
|
669
|
+
const collectPackageList = (raw) => {
|
|
670
|
+
const start = PACKAGE_LIST_START_RE.exec(raw);
|
|
671
|
+
if (!start) return [];
|
|
672
|
+
const packages = [];
|
|
673
|
+
const [command] = raw.slice(start.index + start[0].length).split(UNCONTINUED_NEWLINE_RE);
|
|
674
|
+
const tokens = command.split(WHITESPACE_RE$1);
|
|
675
|
+
for (const token of tokens) {
|
|
676
|
+
if (SHELL_SEPARATOR_RE$1.test(token)) break;
|
|
677
|
+
if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
|
|
678
|
+
}
|
|
679
|
+
return packages;
|
|
680
|
+
};
|
|
574
681
|
const sortMultilineArgs = {
|
|
575
682
|
category: "Best Practices",
|
|
576
683
|
check(instructions, file) {
|
|
577
684
|
const diagnostics = [];
|
|
578
685
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
579
686
|
const { raw } = inst;
|
|
580
|
-
const isPackageInstall = raw
|
|
687
|
+
const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
|
|
581
688
|
const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
|
|
582
689
|
if (isPackageInstall && hasContinuation) {
|
|
583
|
-
const packages = raw
|
|
690
|
+
const packages = collectPackageList(raw);
|
|
584
691
|
if (packages.length > 1) {
|
|
585
|
-
const sorted = packages.toSorted(
|
|
692
|
+
const sorted = packages.toSorted();
|
|
586
693
|
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
694
|
}
|
|
588
695
|
}
|
|
@@ -594,15 +701,18 @@ const sortMultilineArgs = {
|
|
|
594
701
|
key: "docker-doctor/sort-multiline-args",
|
|
595
702
|
message: "Sort multi-line arguments alphanumerically"
|
|
596
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));
|
|
597
707
|
const useraddNoLogInit = {
|
|
598
708
|
category: "Best Practices",
|
|
599
709
|
check(instructions, file) {
|
|
600
710
|
const diagnostics = [];
|
|
601
|
-
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));
|
|
602
712
|
return diagnostics;
|
|
603
713
|
},
|
|
604
714
|
defaultSeverity: "warning",
|
|
605
|
-
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`).",
|
|
606
716
|
key: "docker-doctor/useradd-no-log-init",
|
|
607
717
|
message: "Use --no-log-init with useradd"
|
|
608
718
|
};
|
|
@@ -621,6 +731,13 @@ const bestPracticesRules = [
|
|
|
621
731
|
|
|
622
732
|
//#endregion
|
|
623
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());
|
|
624
741
|
/**
|
|
625
742
|
* Narrows an unknown compose document to its service entries. A service
|
|
626
743
|
* with a null body (`web:` with nothing under it) is returned as an empty
|
|
@@ -844,7 +961,10 @@ const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
|
844
961
|
const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
|
|
845
962
|
const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
846
963
|
const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
|
|
847
|
-
const
|
|
964
|
+
const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
|
|
965
|
+
const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
|
|
966
|
+
const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
|
|
967
|
+
const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
|
|
848
968
|
const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
|
|
849
969
|
const splitShortSyntax = (spec) => {
|
|
850
970
|
const parts = [];
|
|
@@ -884,7 +1004,7 @@ const volumeMount = (volume) => {
|
|
|
884
1004
|
if (volume && typeof volume === "object") {
|
|
885
1005
|
const { read_only: readOnly, source, target, type } = volume;
|
|
886
1006
|
return {
|
|
887
|
-
readOnly: readOnly
|
|
1007
|
+
readOnly: isComposeTrue(readOnly),
|
|
888
1008
|
source: typeof source === "string" ? source : void 0,
|
|
889
1009
|
target: typeof target === "string" ? target : void 0,
|
|
890
1010
|
type: typeof type === "string" ? type : void 0
|
|
@@ -895,12 +1015,16 @@ const volumeMount = (volume) => {
|
|
|
895
1015
|
* The host path a bind mount reads from, with interpolation defaults
|
|
896
1016
|
* applied and Windows separators folded to `/`. Undefined when the entry is
|
|
897
1017
|
* not a bind mount (named volume, tmpfs, anonymous volume) or when the
|
|
898
|
-
* source
|
|
1018
|
+
* source starts with an interpolation that has no default. A source such as
|
|
1019
|
+
* `${DATA_DIR}/pg` names no known host location, so no rule can say whether
|
|
1020
|
+
* it is inside the project.
|
|
899
1021
|
*/
|
|
900
1022
|
const bindMountSource = (mount) => {
|
|
901
1023
|
if (mount.type !== void 0 && mount.type !== "bind") return;
|
|
902
1024
|
if (!mount.source) return;
|
|
903
|
-
const
|
|
1025
|
+
const withDefaults = applyFoldsAndDefaults(mount.source);
|
|
1026
|
+
if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
|
|
1027
|
+
const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
|
|
904
1028
|
if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
|
|
905
1029
|
return resolved;
|
|
906
1030
|
};
|
|
@@ -908,15 +1032,16 @@ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || ho
|
|
|
908
1032
|
const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
|
|
909
1033
|
/**
|
|
910
1034
|
* Whether the entry bind-mounts the Docker socket, under any of its host
|
|
911
|
-
* spellings. A
|
|
912
|
-
* still
|
|
1035
|
+
* spellings. A source that starts with `${VAR}` names no known host path,
|
|
1036
|
+
* but a `/docker.sock` suffix or a socket target still identifies it.
|
|
913
1037
|
*/
|
|
914
1038
|
const mountsDockerSocket = (mount) => {
|
|
915
1039
|
const source = bindMountSource(mount);
|
|
916
1040
|
if (source !== void 0) return isDockerSocketPath(source);
|
|
917
1041
|
if (mount.type !== void 0 && mount.type !== "bind") return false;
|
|
918
1042
|
if (!mount.source) return false;
|
|
919
|
-
|
|
1043
|
+
const remainder = resolveInterpolationDefaults(mount.source);
|
|
1044
|
+
return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
|
|
920
1045
|
};
|
|
921
1046
|
const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
|
|
922
1047
|
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
|
|
@@ -964,9 +1089,15 @@ const SECRET_KEY_PATTERNS = [
|
|
|
964
1089
|
/(?:^|[_-])auth(?:[_-]|$)/iu,
|
|
965
1090
|
/(?:^|[_-])pat(?:[_-]|$)/iu
|
|
966
1091
|
];
|
|
967
|
-
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));
|
|
968
1094
|
const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
|
|
969
|
-
const
|
|
1095
|
+
const SWITCH_VALUE = /^(?:true|false|yes|no|on|off)$/iu;
|
|
1096
|
+
const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
|
|
1097
|
+
const isLiteralSecretValue = (value) => {
|
|
1098
|
+
const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
|
|
1099
|
+
return unquoted.length > 0 && !unquoted.startsWith("$") && !SWITCH_VALUE.test(unquoted) && !CREDENTIAL_FREE_URL.test(unquoted);
|
|
1100
|
+
};
|
|
970
1101
|
|
|
971
1102
|
//#endregion
|
|
972
1103
|
//#region ../core/src/rules/compose-security.ts
|
|
@@ -974,7 +1105,7 @@ const noPrivilegedService = {
|
|
|
974
1105
|
category: "Compose",
|
|
975
1106
|
check(composeContent, file, context) {
|
|
976
1107
|
const diagnostics = [];
|
|
977
|
-
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?.([
|
|
978
1109
|
"services",
|
|
979
1110
|
name,
|
|
980
1111
|
"privileged"
|
|
@@ -1073,6 +1204,10 @@ const preferReadOnlyBindMount = {
|
|
|
1073
1204
|
key: "docker-doctor/prefer-read-only-bind-mount",
|
|
1074
1205
|
message: "Bind mounts outside the project should be read-only"
|
|
1075
1206
|
};
|
|
1207
|
+
const literalValue = (value) => {
|
|
1208
|
+
if (typeof value === "string") return value;
|
|
1209
|
+
if (typeof value === "number") return String(value);
|
|
1210
|
+
};
|
|
1076
1211
|
const noPlaintextSecrets = {
|
|
1077
1212
|
category: "Compose",
|
|
1078
1213
|
check(composeContent, file, context) {
|
|
@@ -1088,15 +1223,16 @@ const noPlaintextSecrets = {
|
|
|
1088
1223
|
if (eqIndex <= 0) continue;
|
|
1089
1224
|
const key = entry.slice(0, eqIndex);
|
|
1090
1225
|
const value = entry.slice(eqIndex + 1);
|
|
1091
|
-
if (isSecretKey(key) &&
|
|
1226
|
+
if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
1092
1227
|
"services",
|
|
1093
1228
|
name,
|
|
1094
1229
|
"environment",
|
|
1095
1230
|
index
|
|
1096
1231
|
]));
|
|
1097
1232
|
}
|
|
1098
|
-
else if (environment && typeof environment === "object") {
|
|
1099
|
-
|
|
1233
|
+
else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
|
|
1234
|
+
const literal = literalValue(value);
|
|
1235
|
+
if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
|
|
1100
1236
|
"services",
|
|
1101
1237
|
name,
|
|
1102
1238
|
"environment",
|
|
@@ -1121,13 +1257,56 @@ const composeSecurityRules = [
|
|
|
1121
1257
|
|
|
1122
1258
|
//#endregion
|
|
1123
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;
|
|
1124
1303
|
const preferSlimBase = {
|
|
1125
1304
|
category: "Image Size",
|
|
1126
1305
|
check(instructions, file) {
|
|
1127
1306
|
const diagnostics = [];
|
|
1128
1307
|
const stageAliases = collectStageAliases(instructions);
|
|
1129
|
-
for (const
|
|
1130
|
-
const imagePart = parseFromArgs(
|
|
1308
|
+
for (const { from } of shippedStages(collectStages(instructions))) {
|
|
1309
|
+
const imagePart = parseFromArgs(from.args).base;
|
|
1131
1310
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
1132
1311
|
const ref = parseImageRef(imagePart);
|
|
1133
1312
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
@@ -1135,7 +1314,7 @@ const preferSlimBase = {
|
|
|
1135
1314
|
if (ref.digest) continue;
|
|
1136
1315
|
if (!ref.tag) continue;
|
|
1137
1316
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
1138
|
-
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));
|
|
1139
1318
|
}
|
|
1140
1319
|
return diagnostics;
|
|
1141
1320
|
},
|
|
@@ -1147,6 +1326,8 @@ const preferSlimBase = {
|
|
|
1147
1326
|
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
1148
1327
|
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
1149
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));
|
|
1150
1331
|
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
1151
1332
|
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
1152
1333
|
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
@@ -1156,8 +1337,8 @@ const cleanPackageCache = {
|
|
|
1156
1337
|
const diagnostics = [];
|
|
1157
1338
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
1158
1339
|
const { args } = inst;
|
|
1159
|
-
if (args
|
|
1160
|
-
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));
|
|
1161
1342
|
}
|
|
1162
1343
|
return diagnostics;
|
|
1163
1344
|
},
|
|
@@ -1166,36 +1347,51 @@ const cleanPackageCache = {
|
|
|
1166
1347
|
key: "docker-doctor/clean-package-cache",
|
|
1167
1348
|
message: "Clean up package manager cache in the same RUN layer"
|
|
1168
1349
|
};
|
|
1169
|
-
const
|
|
1350
|
+
const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
|
|
1351
|
+
const WHITESPACE_RE = /\s+/u;
|
|
1352
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
|
1353
|
+
const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
|
|
1354
|
+
"npm",
|
|
1355
|
+
"yarn",
|
|
1356
|
+
"pnpm",
|
|
1357
|
+
"bun"
|
|
1358
|
+
]);
|
|
1359
|
+
const INSTALL_VERBS = /* @__PURE__ */ new Set([
|
|
1360
|
+
"install",
|
|
1361
|
+
"ci",
|
|
1362
|
+
"i"
|
|
1363
|
+
]);
|
|
1364
|
+
const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
|
|
1365
|
+
"-g",
|
|
1366
|
+
"--global",
|
|
1367
|
+
"--production",
|
|
1368
|
+
"--production=true",
|
|
1369
|
+
"--omit=dev",
|
|
1370
|
+
"--only=prod",
|
|
1371
|
+
"--only=production",
|
|
1372
|
+
"--prod",
|
|
1373
|
+
"-P",
|
|
1374
|
+
"-p"
|
|
1375
|
+
]);
|
|
1376
|
+
const segmentInstallsDevDependencies = (segment) => {
|
|
1377
|
+
const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
|
|
1378
|
+
const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
|
|
1379
|
+
if (commandStart === -1) return false;
|
|
1380
|
+
const [manager, verb, ...options] = tokens.slice(commandStart);
|
|
1381
|
+
if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
|
|
1382
|
+
return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
|
|
1383
|
+
};
|
|
1384
|
+
const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(stripRunFlags(args)).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
|
|
1170
1385
|
const avoidDevDependencies = {
|
|
1171
1386
|
category: "Image Size",
|
|
1172
1387
|
check(instructions, file) {
|
|
1173
1388
|
const diagnostics = [];
|
|
1174
|
-
const stages =
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
runs: []
|
|
1181
|
-
});
|
|
1182
|
-
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
1183
|
-
if (stages.length === 0) return diagnostics;
|
|
1184
|
-
const auditedIndices = [];
|
|
1185
|
-
let index = stages.length - 1;
|
|
1186
|
-
while (index >= 0) {
|
|
1187
|
-
auditedIndices.push(index);
|
|
1188
|
-
const { base } = stages[index];
|
|
1189
|
-
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
1190
|
-
}
|
|
1191
|
-
const finalIndex = stages.length - 1;
|
|
1192
|
-
for (const stageIndex of auditedIndices.toReversed()) {
|
|
1193
|
-
const stage = stages[stageIndex];
|
|
1194
|
-
for (const inst of stage.runs) {
|
|
1195
|
-
if (!installsDevDependencies(inst.args)) continue;
|
|
1196
|
-
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
1197
|
-
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
1198
|
-
}
|
|
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));
|
|
1199
1395
|
}
|
|
1200
1396
|
return diagnostics;
|
|
1201
1397
|
},
|
|
@@ -1212,11 +1408,12 @@ const imageSizeRules = [
|
|
|
1212
1408
|
|
|
1213
1409
|
//#endregion
|
|
1214
1410
|
//#region ../core/src/rules/performance.ts
|
|
1411
|
+
const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
|
|
1215
1412
|
const useMultiStage = {
|
|
1216
1413
|
category: "Performance",
|
|
1217
1414
|
check(instructions, file) {
|
|
1218
1415
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
1219
|
-
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
|
|
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)];
|
|
1220
1417
|
}
|
|
1221
1418
|
return [];
|
|
1222
1419
|
},
|
|
@@ -1275,7 +1472,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
|
|
|
1275
1472
|
const lastSlash = dockerfilePath.lastIndexOf("/");
|
|
1276
1473
|
const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
|
|
1277
1474
|
const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
|
|
1278
|
-
|
|
1475
|
+
const perDockerfile = `${dockerfilePath}.dockerignore`;
|
|
1476
|
+
return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
|
|
1279
1477
|
};
|
|
1280
1478
|
const useDockerignore = {
|
|
1281
1479
|
category: "Performance",
|
|
@@ -1318,7 +1516,7 @@ const noRootUser = {
|
|
|
1318
1516
|
let lastUserLine = 1;
|
|
1319
1517
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
1320
1518
|
const { base, stage } = parseFromArgs(inst.args);
|
|
1321
|
-
const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
|
|
1519
|
+
const baseDefaultUser = base && (isHardenedRuntimeImage(base) || isDistrolessNonroot(base)) ? "nonroot" : "root";
|
|
1322
1520
|
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
|
|
1323
1521
|
lastUserLine = inst.line;
|
|
1324
1522
|
currentStage = stage?.toLowerCase() ?? null;
|
|
@@ -1397,14 +1595,16 @@ const noAddRemote = {
|
|
|
1397
1595
|
check(instructions, file) {
|
|
1398
1596
|
const diagnostics = [];
|
|
1399
1597
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
1400
|
-
const
|
|
1401
|
-
|
|
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;
|
|
1402
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));
|
|
1403
1603
|
}
|
|
1404
1604
|
return diagnostics;
|
|
1405
1605
|
},
|
|
1406
1606
|
defaultSeverity: "warning",
|
|
1407
|
-
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.",
|
|
1408
1608
|
key: "docker-doctor/no-add-remote",
|
|
1409
1609
|
message: "Avoid using ADD with remote URLs"
|
|
1410
1610
|
};
|
|
@@ -1588,7 +1788,7 @@ const importConfig = async (filePath) => {
|
|
|
1588
1788
|
if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
|
|
1589
1789
|
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", parse);
|
|
1590
1790
|
try {
|
|
1591
|
-
const configModule = await import(filePath);
|
|
1791
|
+
const configModule = await import(pathToFileURL(filePath).href);
|
|
1592
1792
|
return configModule.default || configModule;
|
|
1593
1793
|
} catch (error) {
|
|
1594
1794
|
const reason = typeStrippingFailure(filePath, error);
|
|
@@ -1687,13 +1887,13 @@ const calculateScore = (diagnostics) => {
|
|
|
1687
1887
|
|
|
1688
1888
|
//#endregion
|
|
1689
1889
|
//#region ../core/src/report.ts
|
|
1690
|
-
const REPORT_SCHEMA_VERSION =
|
|
1890
|
+
const REPORT_SCHEMA_VERSION = 4;
|
|
1691
1891
|
const resolveCategory = (diagnostic) => {
|
|
1692
1892
|
const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
|
|
1693
1893
|
if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
|
|
1694
1894
|
return category;
|
|
1695
1895
|
};
|
|
1696
|
-
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
1896
|
+
const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
|
|
1697
1897
|
diagnostics: diagnostics.map((d) => ({
|
|
1698
1898
|
category: resolveCategory(d),
|
|
1699
1899
|
column: d.column,
|
|
@@ -1704,13 +1904,17 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1704
1904
|
rule: d.rule,
|
|
1705
1905
|
severity: d.severity
|
|
1706
1906
|
})),
|
|
1907
|
+
failures: failures.map(({ file, message }) => ({
|
|
1908
|
+
file,
|
|
1909
|
+
message
|
|
1910
|
+
})),
|
|
1707
1911
|
label,
|
|
1708
1912
|
project,
|
|
1709
|
-
schemaVersion:
|
|
1913
|
+
schemaVersion: 4,
|
|
1710
1914
|
score,
|
|
1711
1915
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1712
1916
|
});
|
|
1713
1917
|
|
|
1714
1918
|
//#endregion
|
|
1715
1919
|
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 };
|
|
1716
|
-
//# sourceMappingURL=src-
|
|
1920
|
+
//# sourceMappingURL=src-DiEWLJ6g.mjs.map
|