@docker-doctor/cli 0.6.0 → 0.7.0

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