@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.
@@ -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.5.3";
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 walk = async (dir, fileList = []) => {
93
- const files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
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 (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
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>\w+)\k<quote>/gu;
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 (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
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("${") || ref.startsWith("$")) return {
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 = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
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.includes("apt-get update");
523
- const hasInstall = inst.args.includes("apt-get install");
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 path = inst.args.trim();
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.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
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.split(/\r?\n/u).slice(1).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("&&") && !line.startsWith("-") && !line.includes("rm -rf")).map((line) => line.endsWith("\\") ? line.slice(0, -1).trim() : line).filter(Boolean);
719
+ const packages = collectPackageList(raw);
613
720
  if (packages.length > 1) {
614
- const sorted = packages.toSorted((a, b) => a.localeCompare(b));
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" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) 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));
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` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
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 resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
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 === true,
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 is an interpolation with no default, which names no host path.
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 resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
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 bare `${VAR}` source names no host path, but a socket target
941
- * still tells the story.
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
- return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
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 isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
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 isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
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 === true) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' runs in privileged mode. A privileged container has full access to the host's devices and kernel, so compromising this service compromises the host.`, this.help, context?.locate?.([
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) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
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
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
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 inst of instructions) if (inst.instruction === "FROM") {
1159
- const imagePart = parseFromArgs(inst.args).base;
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 (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) 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, inst.line));
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.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
1189
- if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk") && !hasCacheMountFor(args, APK_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
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 installsDevDependencies = (args) => (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune");
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
- for (const inst of instructions) if (inst.instruction === "FROM") {
1205
- const { base, stage } = parseFromArgs(inst.args);
1206
- stages.push({
1207
- base: base?.toLowerCase() ?? "",
1208
- name: stage?.toLowerCase() ?? null,
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.includes("make")))) 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)];
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
- return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
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 src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
1430
- if (!src) continue;
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: "Use `RUN curl` or `RUN wget` instead of ADD for remote URLs, and delete the downloaded archive in the same layer to minimize size.",
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 = 3;
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: 3,
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-D4rVJg36.cjs.map
2026
+ //# sourceMappingURL=src-C7XK6Kp-.cjs.map