@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.
@@ -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.6.0";
37
+ var version = "0.7.0";
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));
@@ -117,7 +129,7 @@ const PRUNED_DIRECTORIES = [
117
129
  const PRUNED = new Set(PRUNED_DIRECTORIES);
118
130
  const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
119
131
  const SUBTREE_SUFFIX = "/**";
120
- const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
132
+ const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.map(normalizeIgnorePattern).filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
121
133
  const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
122
134
  const walk = async (dir, options, fileList = []) => {
123
135
  let files;
@@ -265,7 +277,7 @@ const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
265
277
  "RUN"
266
278
  ]);
267
279
  const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
268
- const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
280
+ const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>[^\s'"<>;&|()]+)\k<quote>/gu;
269
281
  const createParserState = () => ({
270
282
  currentArgs: "",
271
283
  currentInstruction: "",
@@ -412,7 +424,7 @@ const parseExecForm = (args) => {
412
424
  //#endregion
413
425
  //#region ../core/src/parsers/image-ref.ts
414
426
  const parseImageRef = (ref) => {
415
- if (ref.includes("${") || ref.startsWith("$")) return {
427
+ if (ref.includes("$")) return {
416
428
  isVariable: true,
417
429
  name: ref
418
430
  };
@@ -464,6 +476,15 @@ const isHardenedRuntimeImage = (imagePart) => {
464
476
  const { tag } = parseImageRef(imagePart);
465
477
  return !(tag === "dev" || tag?.endsWith("-dev"));
466
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
+ };
467
488
  /**
468
489
  * Why a reference would resolve differently over time: no tag at all, or
469
490
  * the mutable `latest` tag without a digest. `undefined` means the ref is
@@ -493,6 +514,11 @@ const collectStageAliases = (instructions) => {
493
514
  return aliases;
494
515
  };
495
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
+
496
522
  //#endregion
497
523
  //#region ../core/src/rules/create-diagnostic.ts
498
524
  const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
@@ -636,12 +662,14 @@ const usePipefail = {
636
662
  key: "docker-doctor/use-pipefail",
637
663
  message: "Use pipefail to catch pipeline command failures"
638
664
  };
665
+ const SURROUNDING_QUOTES_RE$1 = /^(?<quote>["'])(?<inner>.*)\k<quote>$/u;
639
666
  const absoluteWorkdir = {
640
667
  category: "Best Practices",
641
668
  check(instructions, file) {
642
669
  const diagnostics = [];
643
670
  for (const inst of instructions) if (inst.instruction === "WORKDIR") {
644
- const path = inst.args.trim();
671
+ const trimmed = inst.args.trim();
672
+ const path = trimmed.match(SURROUNDING_QUOTES_RE$1)?.groups?.inner ?? trimmed;
645
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));
646
674
  }
647
675
  return diagnostics;
@@ -655,7 +683,7 @@ const avoidRunCd = {
655
683
  category: "Best Practices",
656
684
  check(instructions, file) {
657
685
  const diagnostics = [];
658
- 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));
659
687
  return diagnostics;
660
688
  },
661
689
  defaultSeverity: "info",
@@ -666,11 +694,13 @@ const avoidRunCd = {
666
694
  const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
667
695
  const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
668
696
  const WHITESPACE_RE$1 = /\s+/u;
697
+ const UNCONTINUED_NEWLINE_RE = /(?<!\\[ \t]*)\r?\n/u;
669
698
  const collectPackageList = (raw) => {
670
699
  const start = PACKAGE_LIST_START_RE.exec(raw);
671
700
  if (!start) return [];
672
701
  const packages = [];
673
- const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
702
+ const [command] = raw.slice(start.index + start[0].length).split(UNCONTINUED_NEWLINE_RE);
703
+ const tokens = command.split(WHITESPACE_RE$1);
674
704
  for (const token of tokens) {
675
705
  if (SHELL_SEPARATOR_RE$1.test(token)) break;
676
706
  if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
@@ -700,15 +730,18 @@ const sortMultilineArgs = {
700
730
  key: "docker-doctor/sort-multiline-args",
701
731
  message: "Sort multi-line arguments alphanumerically"
702
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));
703
736
  const useraddNoLogInit = {
704
737
  category: "Best Practices",
705
738
  check(instructions, file) {
706
739
  const diagnostics = [];
707
- 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));
708
741
  return diagnostics;
709
742
  },
710
743
  defaultSeverity: "warning",
711
- 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`).",
712
745
  key: "docker-doctor/useradd-no-log-init",
713
746
  message: "Use --no-log-init with useradd"
714
747
  };
@@ -727,6 +760,13 @@ const bestPracticesRules = [
727
760
 
728
761
  //#endregion
729
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());
730
770
  /**
731
771
  * Narrows an unknown compose document to its service entries. A service
732
772
  * with a null body (`web:` with nothing under it) is returned as an empty
@@ -993,7 +1033,7 @@ const volumeMount = (volume) => {
993
1033
  if (volume && typeof volume === "object") {
994
1034
  const { read_only: readOnly, source, target, type } = volume;
995
1035
  return {
996
- readOnly: readOnly === true,
1036
+ readOnly: isComposeTrue(readOnly),
997
1037
  source: typeof source === "string" ? source : void 0,
998
1038
  target: typeof target === "string" ? target : void 0,
999
1039
  type: typeof type === "string" ? type : void 0
@@ -1078,12 +1118,14 @@ const SECRET_KEY_PATTERNS = [
1078
1118
  /(?:^|[_-])auth(?:[_-]|$)/iu,
1079
1119
  /(?:^|[_-])pat(?:[_-]|$)/iu
1080
1120
  ];
1081
- 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));
1082
1123
  const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
1124
+ const SWITCH_VALUE = /^(?:true|false|yes|no|on|off)$/iu;
1083
1125
  const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
1084
1126
  const isLiteralSecretValue = (value) => {
1085
1127
  const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
1086
- return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
1128
+ return unquoted.length > 0 && !unquoted.startsWith("$") && !SWITCH_VALUE.test(unquoted) && !CREDENTIAL_FREE_URL.test(unquoted);
1087
1129
  };
1088
1130
 
1089
1131
  //#endregion
@@ -1092,7 +1134,7 @@ const noPrivilegedService = {
1092
1134
  category: "Compose",
1093
1135
  check(composeContent, file, context) {
1094
1136
  const diagnostics = [];
1095
- 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?.([
1096
1138
  "services",
1097
1139
  name,
1098
1140
  "privileged"
@@ -1244,13 +1286,56 @@ const composeSecurityRules = [
1244
1286
 
1245
1287
  //#endregion
1246
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;
1247
1332
  const preferSlimBase = {
1248
1333
  category: "Image Size",
1249
1334
  check(instructions, file) {
1250
1335
  const diagnostics = [];
1251
1336
  const stageAliases = collectStageAliases(instructions);
1252
- for (const inst of instructions) if (inst.instruction === "FROM") {
1253
- const imagePart = parseFromArgs(inst.args).base;
1337
+ for (const { from } of shippedStages(collectStages(instructions))) {
1338
+ const imagePart = parseFromArgs(from.args).base;
1254
1339
  if (!imagePart || isScratch(imagePart)) continue;
1255
1340
  const ref = parseImageRef(imagePart);
1256
1341
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
@@ -1258,7 +1343,7 @@ const preferSlimBase = {
1258
1343
  if (ref.digest) continue;
1259
1344
  if (!ref.tag) continue;
1260
1345
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
1261
- 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));
1262
1347
  }
1263
1348
  return diagnostics;
1264
1349
  },
@@ -1270,6 +1355,8 @@ const preferSlimBase = {
1270
1355
  const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
1271
1356
  const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
1272
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));
1273
1360
  const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
1274
1361
  const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
1275
1362
  const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
@@ -1279,8 +1366,8 @@ const cleanPackageCache = {
1279
1366
  const diagnostics = [];
1280
1367
  for (const inst of instructions) if (inst.instruction === "RUN") {
1281
1368
  const { args } = inst;
1282
- 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));
1283
- 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));
1284
1371
  }
1285
1372
  return diagnostics;
1286
1373
  },
@@ -1307,6 +1394,7 @@ const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
1307
1394
  "-g",
1308
1395
  "--global",
1309
1396
  "--production",
1397
+ "--production=true",
1310
1398
  "--omit=dev",
1311
1399
  "--only=prod",
1312
1400
  "--only=production",
@@ -1322,36 +1410,17 @@ const segmentInstallsDevDependencies = (segment) => {
1322
1410
  if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
1323
1411
  return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
1324
1412
  };
1325
- const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
1413
+ const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(stripRunFlags(args)).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
1326
1414
  const avoidDevDependencies = {
1327
1415
  category: "Image Size",
1328
1416
  check(instructions, file) {
1329
1417
  const diagnostics = [];
1330
- const stages = [];
1331
- for (const inst of instructions) if (inst.instruction === "FROM") {
1332
- const { base, stage } = parseFromArgs(inst.args);
1333
- stages.push({
1334
- base: base?.toLowerCase() ?? "",
1335
- name: stage?.toLowerCase() ?? null,
1336
- runs: []
1337
- });
1338
- } else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
1339
- if (stages.length === 0) return diagnostics;
1340
- const auditedIndices = [];
1341
- let index = stages.length - 1;
1342
- while (index >= 0) {
1343
- auditedIndices.push(index);
1344
- const { base } = stages[index];
1345
- index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
1346
- }
1347
- const finalIndex = stages.length - 1;
1348
- for (const stageIndex of auditedIndices.toReversed()) {
1349
- const stage = stages[stageIndex];
1350
- for (const inst of stage.runs) {
1351
- if (!installsDevDependencies(inst.args)) continue;
1352
- const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
1353
- diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
1354
- }
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));
1355
1424
  }
1356
1425
  return diagnostics;
1357
1426
  },
@@ -1373,7 +1442,7 @@ const useMultiStage = {
1373
1442
  category: "Performance",
1374
1443
  check(instructions, file) {
1375
1444
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
1376
- 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)];
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)];
1377
1446
  }
1378
1447
  return [];
1379
1448
  },
@@ -1476,7 +1545,7 @@ const noRootUser = {
1476
1545
  let lastUserLine = 1;
1477
1546
  for (const inst of instructions) if (inst.instruction === "FROM") {
1478
1547
  const { base, stage } = parseFromArgs(inst.args);
1479
- const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1548
+ const baseDefaultUser = base && (isHardenedRuntimeImage(base) || isDistrolessNonroot(base)) ? "nonroot" : "root";
1480
1549
  lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
1481
1550
  lastUserLine = inst.line;
1482
1551
  currentStage = stage?.toLowerCase() ?? null;
@@ -1555,14 +1624,16 @@ const noAddRemote = {
1555
1624
  check(instructions, file) {
1556
1625
  const diagnostics = [];
1557
1626
  for (const inst of instructions) if (inst.instruction === "ADD") {
1558
- const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
1559
- 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;
1560
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));
1561
1632
  }
1562
1633
  return diagnostics;
1563
1634
  },
1564
1635
  defaultSeverity: "warning",
1565
- 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.",
1566
1637
  key: "docker-doctor/no-add-remote",
1567
1638
  message: "Avoid using ADD with remote URLs"
1568
1639
  };
@@ -1746,7 +1817,7 @@ const importConfig = async (filePath) => {
1746
1817
  if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
1747
1818
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", yaml.parse);
1748
1819
  try {
1749
- const configModule = await import(filePath);
1820
+ const configModule = await import((0, node_url.pathToFileURL)(filePath).href);
1750
1821
  return configModule.default || configModule;
1751
1822
  } catch (error) {
1752
1823
  const reason = typeStrippingFailure(filePath, error);
@@ -1830,11 +1901,15 @@ const getScoreBucket = (score) => {
1830
1901
  for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
1831
1902
  return SCORE_BUCKETS.at(-1);
1832
1903
  };
1833
- const calculateScore = (diagnostics) => {
1834
- let penalty = 0;
1835
- for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
1836
- else if (diag.severity === "warning") penalty += 4;
1837
- else if (diag.severity === "info") penalty += 1;
1904
+ const SEVERITY_PENALTY = {
1905
+ error: 10,
1906
+ info: 1,
1907
+ warning: 4
1908
+ };
1909
+ const calculateScore = (diagnostics, analyzedFileCount = 1) => {
1910
+ let totalPenalty = 0;
1911
+ for (const diag of diagnostics) totalPenalty += SEVERITY_PENALTY[diag.severity];
1912
+ const penalty = totalPenalty / Math.max(1, analyzedFileCount);
1838
1913
  const score = Math.round(100 * Math.exp(-penalty / 70));
1839
1914
  const bucket = getScoreBucket(score);
1840
1915
  return {
@@ -1845,7 +1920,7 @@ const calculateScore = (diagnostics) => {
1845
1920
 
1846
1921
  //#endregion
1847
1922
  //#region ../core/src/report.ts
1848
- const REPORT_SCHEMA_VERSION = 4;
1923
+ const REPORT_SCHEMA_VERSION = 5;
1849
1924
  const resolveCategory = (diagnostic) => {
1850
1925
  const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1851
1926
  if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
@@ -1868,7 +1943,7 @@ const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
1868
1943
  })),
1869
1944
  label,
1870
1945
  project,
1871
- schemaVersion: 4,
1946
+ schemaVersion: 5,
1872
1947
  score,
1873
1948
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1874
1949
  });
@@ -1952,4 +2027,4 @@ Object.defineProperty(exports, 'version', {
1952
2027
  return version;
1953
2028
  }
1954
2029
  });
1955
- //# sourceMappingURL=src-BpAv-H0Z.cjs.map
2030
+ //# sourceMappingURL=src-DiT93yVt.cjs.map