@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.
@@ -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.5.3";
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 walk = async (dir, fileList = []) => {
64
- const files = await fs.readdir(dir, { withFileTypes: true });
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 (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
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>\w+)\k<quote>/gu;
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 (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
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("${") || ref.startsWith("$")) return {
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 = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
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.includes("apt-get update");
494
- const hasInstall = inst.args.includes("apt-get install");
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 path = inst.args.trim();
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.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
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.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);
690
+ const packages = collectPackageList(raw);
584
691
  if (packages.length > 1) {
585
- const sorted = packages.toSorted((a, b) => a.localeCompare(b));
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" && /\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));
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` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
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 resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
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 === true,
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 is an interpolation with no default, which names no host path.
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 resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
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 bare `${VAR}` source names no host path, but a socket target
912
- * still tells the story.
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
- return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
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 isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
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 isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
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 === 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?.([
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) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
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
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
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 inst of instructions) if (inst.instruction === "FROM") {
1130
- const imagePart = parseFromArgs(inst.args).base;
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 (!(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));
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.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));
1160
- 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));
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 installsDevDependencies = (args) => (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune");
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
- for (const inst of instructions) if (inst.instruction === "FROM") {
1176
- const { base, stage } = parseFromArgs(inst.args);
1177
- stages.push({
1178
- base: base?.toLowerCase() ?? "",
1179
- name: stage?.toLowerCase() ?? null,
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.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)];
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
- return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
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 src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
1401
- if (!src) continue;
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: "Use `RUN curl` or `RUN wget` instead of ADD for remote URLs, and delete the downloaded archive in the same layer to minimize size.",
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 = 3;
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: 3,
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-CklEIXTX.mjs.map
1920
+ //# sourceMappingURL=src-DiEWLJ6g.mjs.map