@docker-doctor/cli 0.4.2 → 0.4.3

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.
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { parse } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.4.2";
7
+ var version = "0.4.3";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/discover.ts
@@ -174,6 +174,79 @@ const parseCompose = (content, filepath) => {
174
174
  }
175
175
  };
176
176
 
177
+ //#endregion
178
+ //#region ../core/src/parsers/exec-form.ts
179
+ const parseExecForm = (args) => {
180
+ const trimmed = args.trim();
181
+ if (!trimmed.startsWith("[")) return null;
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(trimmed);
185
+ } catch {
186
+ return null;
187
+ }
188
+ if (!Array.isArray(parsed)) return null;
189
+ if (!parsed.every((el) => typeof el === "string")) return null;
190
+ return parsed;
191
+ };
192
+
193
+ //#endregion
194
+ //#region ../core/src/parsers/image-ref.ts
195
+ const parseImageRef = (ref) => {
196
+ if (ref.includes("${") || ref.startsWith("$")) return {
197
+ isVariable: true,
198
+ name: ref
199
+ };
200
+ let remainder = ref;
201
+ let digest;
202
+ const atIndex = remainder.indexOf("@");
203
+ if (atIndex !== -1) {
204
+ digest = remainder.slice(atIndex + 1);
205
+ remainder = remainder.slice(0, atIndex);
206
+ }
207
+ let tag;
208
+ const lastColonIndex = remainder.lastIndexOf(":");
209
+ const lastSlashIndex = remainder.lastIndexOf("/");
210
+ if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
211
+ tag = remainder.slice(lastColonIndex + 1);
212
+ remainder = remainder.slice(0, lastColonIndex);
213
+ }
214
+ let registry;
215
+ const firstSlashIndex = remainder.indexOf("/");
216
+ if (firstSlashIndex !== -1) {
217
+ const firstSegment = remainder.slice(0, firstSlashIndex);
218
+ if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
219
+ registry = firstSegment;
220
+ remainder = remainder.slice(firstSlashIndex + 1);
221
+ }
222
+ }
223
+ return {
224
+ digest,
225
+ isVariable: false,
226
+ name: remainder,
227
+ registry,
228
+ tag
229
+ };
230
+ };
231
+ const parseFromArgs = (args) => {
232
+ const parts = args.split(/\s+/u).filter(Boolean);
233
+ const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
234
+ return {
235
+ base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
236
+ stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
237
+ };
238
+ };
239
+ const isScratch = (base) => base?.toLowerCase() === "scratch";
240
+ const collectStageAliases = (instructions) => {
241
+ const aliases = /* @__PURE__ */ new Set();
242
+ for (const inst of instructions) {
243
+ if (inst.instruction !== "FROM") continue;
244
+ const { stage } = parseFromArgs(inst.args);
245
+ if (stage) aliases.add(stage.toLowerCase());
246
+ }
247
+ return aliases;
248
+ };
249
+
177
250
  //#endregion
178
251
  //#region ../core/src/rules/best-practices.ts
179
252
  const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
@@ -193,7 +266,7 @@ const requireHealthcheck = {
193
266
  return [];
194
267
  },
195
268
  defaultSeverity: "info",
196
- help: "Use HEALTHCHECK (e.g., 'HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1') so Docker can monitor the container's live status.",
269
+ help: "Use HEALTHCHECK (e.g., `HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1`) so Docker can monitor the container's live status.",
197
270
  key: "docker-doctor/require-healthcheck",
198
271
  message: "Add a HEALTHCHECK instruction"
199
272
  };
@@ -219,14 +292,11 @@ const useExecForm = {
219
292
  category: "Best Practices",
220
293
  check(instructions, file) {
221
294
  const diagnostics = [];
222
- for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
223
- const args = inst.args.trim();
224
- if (!args.startsWith("[") || !args.endsWith("]")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
225
- }
295
+ for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
226
296
  return diagnostics;
227
297
  },
228
298
  defaultSeverity: "warning",
229
- help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
299
+ help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
230
300
  key: "docker-doctor/use-exec-form",
231
301
  message: "Use exec form for CMD and ENTRYPOINT"
232
302
  };
@@ -237,7 +307,7 @@ const requireLabels = {
237
307
  return [];
238
308
  },
239
309
  defaultSeverity: "info",
240
- help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
310
+ help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
241
311
  key: "docker-doctor/require-labels",
242
312
  message: "Add LABEL metadata to images"
243
313
  };
@@ -254,22 +324,47 @@ const combineAptUpdateInstall = {
254
324
  return diagnostics;
255
325
  },
256
326
  defaultSeverity: "warning",
257
- help: "Combine 'apt-get update' and 'apt-get install' in the same RUN instruction (e.g. 'RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf /var/lib/apt/lists/*').",
327
+ help: "Combine `apt-get update` and `apt-get install` in the same RUN instruction (e.g. `RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf /var/lib/apt/lists/*`).",
258
328
  key: "docker-doctor/combine-apt-update-install",
259
329
  message: "Combine apt-get update and apt-get install"
260
330
  };
331
+ const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
332
+ const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
333
+ const shellDirectiveEnablesPipefail = (args) => {
334
+ const argv = parseExecForm(args);
335
+ return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
336
+ };
261
337
  const usePipefail = {
262
338
  category: "Best Practices",
263
339
  check(instructions, file) {
264
340
  const diagnostics = [];
265
- for (const inst of instructions) if (inst.instruction === "RUN") {
266
- const { raw } = inst;
267
- if (/(?<!\|)\|(?!\|)/u.test(raw) && !raw.includes("pipefail")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
341
+ const stagePipefail = /* @__PURE__ */ new Map();
342
+ let shellHasPipefail = false;
343
+ let currentStage = null;
344
+ for (const inst of instructions) {
345
+ if (inst.instruction === "FROM") {
346
+ const { base, stage } = parseFromArgs(inst.args);
347
+ const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
348
+ shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
349
+ currentStage = stage?.toLowerCase() ?? null;
350
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
351
+ continue;
352
+ }
353
+ if (inst.instruction === "SHELL") {
354
+ shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
355
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
356
+ continue;
357
+ }
358
+ if (inst.instruction !== "RUN") continue;
359
+ const { args } = inst;
360
+ if (!HAS_PIPE_RE.test(args)) continue;
361
+ const execArgv = parseExecForm(args);
362
+ if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
268
363
  }
269
364
  return diagnostics;
270
365
  },
271
366
  defaultSeverity: "warning",
272
- help: "Prepend 'set -o pipefail &&' to pipe commands, or use exec form with a shell that supports it (e.g., RUN ['/bin/bash', '-c', 'set -o pipefail && ...']).",
367
+ help: "Prepend `set -o pipefail &&` to pipe commands, use exec form with a shell that supports it (e.g., `RUN [\"/bin/bash\", \"-c\", \"set -o pipefail && ...\"]`), or set `SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]` at the top of the stage.",
273
368
  key: "docker-doctor/use-pipefail",
274
369
  message: "Use pipefail to catch pipeline command failures"
275
370
  };
@@ -284,7 +379,7 @@ const absoluteWorkdir = {
284
379
  return diagnostics;
285
380
  },
286
381
  defaultSeverity: "warning",
287
- help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
382
+ help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
288
383
  key: "docker-doctor/absolute-workdir",
289
384
  message: "Use absolute paths for WORKDIR"
290
385
  };
@@ -296,7 +391,7 @@ const avoidRunCd = {
296
391
  return diagnostics;
297
392
  },
298
393
  defaultSeverity: "info",
299
- help: "Use the WORKDIR instruction instead of 'cd' inside RUN to establish directory context.",
394
+ help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
300
395
  key: "docker-doctor/avoid-run-cd",
301
396
  message: "Avoid changing directories with cd in RUN"
302
397
  };
@@ -331,7 +426,7 @@ const useraddNoLogInit = {
331
426
  return diagnostics;
332
427
  },
333
428
  defaultSeverity: "warning",
334
- help: "Pass '--no-log-init' flag to useradd (e.g., 'RUN useradd --no-log-init -r -g mygroup myuser').",
429
+ help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
335
430
  key: "docker-doctor/useradd-no-log-init",
336
431
  message: "Use --no-log-init with useradd"
337
432
  };
@@ -364,9 +459,9 @@ const noVersionKey = {
364
459
  return [];
365
460
  },
366
461
  defaultSeverity: "warning",
367
- help: "The 'version' key is deprecated by the Compose specification. Omitting it defaults to the latest specification.",
462
+ help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
368
463
  key: "docker-doctor/no-version-key",
369
- message: "Remove the 'version' key from Compose file"
464
+ message: "Remove the `version` key from the Compose file"
370
465
  };
371
466
  const requireResourceLimits = {
372
467
  category: "Compose",
@@ -384,7 +479,7 @@ const requireResourceLimits = {
384
479
  return diagnostics;
385
480
  },
386
481
  defaultSeverity: "warning",
387
- help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
482
+ help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
388
483
  key: "docker-doctor/require-resource-limits",
389
484
  message: "Define resource limits for services"
390
485
  };
@@ -405,7 +500,7 @@ const requireRestartPolicy = {
405
500
  return diagnostics;
406
501
  },
407
502
  defaultSeverity: "warning",
408
- help: "Define 'restart: always' or 'restart: unless-stopped' (or deploy.restart_policy) so services restart on crashes or host reboot.",
503
+ help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
409
504
  key: "docker-doctor/require-restart-policy",
410
505
  message: "Set restart policy for services"
411
506
  };
@@ -425,7 +520,7 @@ const useDependsOnCondition = {
425
520
  return diagnostics;
426
521
  },
427
522
  defaultSeverity: "info",
428
- help: "Instead of a simple service list, use 'depends_on: { dependency: { condition: service_healthy } }' to ensure dependencies are fully ready before starting.",
523
+ help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
429
524
  key: "docker-doctor/use-depends-on-condition",
430
525
  message: "Use long-form depends_on with healthcheck conditions"
431
526
  };
@@ -436,54 +531,6 @@ const composeRules = [
436
531
  useDependsOnCondition
437
532
  ];
438
533
 
439
- //#endregion
440
- //#region ../core/src/parsers/image-ref.ts
441
- const parseImageRef = (ref) => {
442
- if (ref.includes("${") || ref.startsWith("$")) return {
443
- isVariable: true,
444
- name: ref
445
- };
446
- let remainder = ref;
447
- let digest;
448
- const atIndex = remainder.indexOf("@");
449
- if (atIndex !== -1) {
450
- digest = remainder.slice(atIndex + 1);
451
- remainder = remainder.slice(0, atIndex);
452
- }
453
- let tag;
454
- const lastColonIndex = remainder.lastIndexOf(":");
455
- const lastSlashIndex = remainder.lastIndexOf("/");
456
- if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
457
- tag = remainder.slice(lastColonIndex + 1);
458
- remainder = remainder.slice(0, lastColonIndex);
459
- }
460
- let registry;
461
- const firstSlashIndex = remainder.indexOf("/");
462
- if (firstSlashIndex !== -1) {
463
- const firstSegment = remainder.slice(0, firstSlashIndex);
464
- if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
465
- registry = firstSegment;
466
- remainder = remainder.slice(firstSlashIndex + 1);
467
- }
468
- }
469
- return {
470
- digest,
471
- isVariable: false,
472
- name: remainder,
473
- registry,
474
- tag
475
- };
476
- };
477
- const collectStageAliases = (instructions) => {
478
- const aliases = /* @__PURE__ */ new Set();
479
- for (const inst of instructions) {
480
- if (inst.instruction !== "FROM") continue;
481
- const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
482
- if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
483
- }
484
- return aliases;
485
- };
486
-
487
534
  //#endregion
488
535
  //#region ../core/src/rules/image-size.ts
489
536
  const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
@@ -500,8 +547,8 @@ const preferSlimBase = {
500
547
  const diagnostics = [];
501
548
  const stageAliases = collectStageAliases(instructions);
502
549
  for (const inst of instructions) if (inst.instruction === "FROM") {
503
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
504
- if (!imagePart || imagePart === "scratch") continue;
550
+ const imagePart = parseFromArgs(inst.args).base;
551
+ if (!imagePart || isScratch(imagePart)) continue;
505
552
  const ref = parseImageRef(imagePart);
506
553
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
507
554
  if (ref.digest) continue;
@@ -512,9 +559,9 @@ const preferSlimBase = {
512
559
  return diagnostics;
513
560
  },
514
561
  defaultSeverity: "info",
515
- help: "Prefer tags with '-slim', '-alpine', or use distroless base images to minimize the default operating system footprint.",
562
+ help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
516
563
  key: "docker-doctor/prefer-slim-base",
517
- message: "Use slim, alpine, or distroless base images"
564
+ message: "Prefer slim, alpine, or distroless base images"
518
565
  };
519
566
  const cleanPackageCache = {
520
567
  category: "Image Size",
@@ -528,34 +575,47 @@ const cleanPackageCache = {
528
575
  return diagnostics;
529
576
  },
530
577
  defaultSeverity: "warning",
531
- help: "For apt-get, append '&& rm -rf /var/lib/apt/lists/*'. For apk, use 'apk add --no-cache'. For dnf/yum, run 'yum clean all'.",
578
+ help: "For apt-get, append `&& rm -rf /var/lib/apt/lists/*`. For apk, use `apk add --no-cache`. For dnf/yum, run `yum clean all`.",
532
579
  key: "docker-doctor/clean-package-cache",
533
580
  message: "Clean up package manager cache in the same RUN layer"
534
581
  };
582
+ 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");
535
583
  const avoidDevDependencies = {
536
584
  category: "Image Size",
537
585
  check(instructions, file) {
538
586
  const diagnostics = [];
539
- let isLastStage = false;
540
- let fromCount = 0;
541
- for (const inst of instructions) if (inst.instruction === "FROM") fromCount += 1;
542
- let currentStage = 0;
543
- for (const inst of instructions) {
544
- if (inst.instruction === "FROM") {
545
- currentStage += 1;
546
- isLastStage = currentStage === fromCount;
547
- }
548
- if (isLastStage && inst.instruction === "RUN") {
549
- const { args } = inst;
550
- if ((args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' in the final stage without omitting devDependencies.`, this.help, inst.line));
587
+ const stages = [];
588
+ for (const inst of instructions) if (inst.instruction === "FROM") {
589
+ const { base, stage } = parseFromArgs(inst.args);
590
+ stages.push({
591
+ base: base?.toLowerCase() ?? "",
592
+ name: stage?.toLowerCase() ?? null,
593
+ runs: []
594
+ });
595
+ } else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
596
+ if (stages.length === 0) return diagnostics;
597
+ const auditedIndices = [];
598
+ let index = stages.length - 1;
599
+ while (index >= 0) {
600
+ auditedIndices.push(index);
601
+ const { base } = stages[index];
602
+ index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
603
+ }
604
+ const finalIndex = stages.length - 1;
605
+ for (const stageIndex of auditedIndices.toReversed()) {
606
+ const stage = stages[stageIndex];
607
+ for (const inst of stage.runs) {
608
+ if (!installsDevDependencies(inst.args)) continue;
609
+ const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
610
+ diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
551
611
  }
552
612
  }
553
613
  return diagnostics;
554
614
  },
555
615
  defaultSeverity: "warning",
556
- help: "For Node.js, run 'npm prune --production' or install only production dependencies ('npm ci --omit=dev') in the runtime stage.",
616
+ help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
557
617
  key: "docker-doctor/avoid-dev-dependencies",
558
- message: "Avoid installing development dependencies in final production stage"
618
+ message: "Avoid installing dev dependencies in the final stage"
559
619
  };
560
620
  const imageSizeRules = [
561
621
  preferSlimBase,
@@ -584,7 +644,7 @@ const useMultiStage = {
584
644
  defaultSeverity: "info",
585
645
  help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
586
646
  key: "docker-doctor/use-multi-stage",
587
- message: "Consider using multi-stage builds"
647
+ message: "Use multi-stage builds"
588
648
  };
589
649
  const orderLayers = {
590
650
  category: "Performance",
@@ -628,7 +688,7 @@ const minimizeLayers = {
628
688
  return diagnostics;
629
689
  },
630
690
  defaultSeverity: "info",
631
- help: "Combine consecutive RUN instructions using '&&' and '\\' to reduce the total layer count and image size.",
691
+ help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
632
692
  key: "docker-doctor/minimize-layers",
633
693
  message: "Minimize the number of image layers"
634
694
  };
@@ -655,7 +715,7 @@ const useDockerignore = {
655
715
  defaultSeverity: "warning",
656
716
  help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
657
717
  key: "docker-doctor/use-dockerignore",
658
- message: "Ensure .dockerignore is used"
718
+ message: "Add a .dockerignore file"
659
719
  };
660
720
  const performanceRules = [
661
721
  useMultiStage,
@@ -681,22 +741,28 @@ const isRootUser = (value) => {
681
741
  const noRootUser = {
682
742
  category: "Security",
683
743
  check(instructions, file) {
744
+ const stageUser = /* @__PURE__ */ new Map();
745
+ let currentStage = null;
684
746
  let lastUser = "root";
685
747
  let lastUserLine = 1;
686
748
  for (const inst of instructions) if (inst.instruction === "FROM") {
687
- lastUser = "root";
749
+ const { base, stage } = parseFromArgs(inst.args);
750
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
688
751
  lastUserLine = inst.line;
752
+ currentStage = stage?.toLowerCase() ?? null;
753
+ if (currentStage) stageUser.set(currentStage, lastUser);
689
754
  } else if (inst.instruction === "USER") {
690
755
  lastUser = inst.args.trim().toLowerCase();
691
756
  lastUserLine = inst.line;
757
+ if (currentStage) stageUser.set(currentStage, lastUser);
692
758
  }
693
759
  if (isRootUser(lastUser)) return [createDiagnostic(file, this.key, this.defaultSeverity, "The container runs as root. Running as root allows potential container breakout vulnerabilities.", this.help, lastUserLine)];
694
760
  return [];
695
761
  },
696
762
  defaultSeverity: "warning",
697
- help: "Add a non-root user (e.g., 'USER node' or 'USER 1000') to improve security.",
763
+ help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
698
764
  key: "docker-doctor/no-root-user",
699
- message: "Container should not run as root user"
765
+ message: "Run the container as a non-root user"
700
766
  };
701
767
  const noSecretsInEnv = {
702
768
  category: "Security",
@@ -737,7 +803,7 @@ const noSecretsInEnv = {
737
803
  defaultSeverity: "error",
738
804
  help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
739
805
  key: "docker-doctor/no-secrets-in-env",
740
- message: "Do not store secrets in ENV or ARG instructions"
806
+ message: "Avoid storing secrets in ENV or ARG instructions"
741
807
  };
742
808
  const pinImageVersion = {
743
809
  category: "Security",
@@ -745,8 +811,8 @@ const pinImageVersion = {
745
811
  const diagnostics = [];
746
812
  const stageAliases = collectStageAliases(instructions);
747
813
  for (const inst of instructions) if (inst.instruction === "FROM") {
748
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
749
- if (!imagePart || imagePart === "scratch") continue;
814
+ const imagePart = parseFromArgs(inst.args).base;
815
+ if (!imagePart || isScratch(imagePart)) continue;
750
816
  const ref = parseImageRef(imagePart);
751
817
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
752
818
  if (!(ref.tag || ref.digest)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' does not specify a tag. This makes builds non-deterministic.`, this.help, inst.line));
@@ -755,9 +821,9 @@ const pinImageVersion = {
755
821
  return diagnostics;
756
822
  },
757
823
  defaultSeverity: "warning",
758
- help: "Specify a concrete tag instead of 'latest' or no tag (e.g., 'node:22.2.0-alpine' instead of 'node').",
824
+ help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
759
825
  key: "docker-doctor/pin-image-version",
760
- message: "Always pin base image versions to specific tags"
826
+ message: "Pin base images to a specific tag or digest"
761
827
  };
762
828
  const noAddRemote = {
763
829
  category: "Security",
@@ -771,7 +837,7 @@ const noAddRemote = {
771
837
  return diagnostics;
772
838
  },
773
839
  defaultSeverity: "warning",
774
- 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.",
840
+ 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.",
775
841
  key: "docker-doctor/no-add-remote",
776
842
  message: "Avoid using ADD with remote URLs"
777
843
  };
@@ -888,6 +954,30 @@ const validateConfig = (input) => {
888
954
  return result;
889
955
  };
890
956
 
957
+ //#endregion
958
+ //#region ../core/src/config/unknown-keys.ts
959
+ const KNOWN_CATEGORIES = [
960
+ "Best Practices",
961
+ "Compose",
962
+ "Image Size",
963
+ "Performance",
964
+ "Security"
965
+ ];
966
+ const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
967
+ const collectUnknownConfigKeys = (raw) => {
968
+ if (typeof raw !== "object" || raw === null) return {
969
+ categories: [],
970
+ rules: []
971
+ };
972
+ const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
973
+ const knownCategories = new Set(KNOWN_CATEGORIES);
974
+ const { categories, rules } = raw;
975
+ return {
976
+ categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
977
+ rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
978
+ };
979
+ };
980
+
891
981
  //#endregion
892
982
  //#region ../core/src/config/loader.ts
893
983
  const fileExists = async (filePath) => {
@@ -917,7 +1007,12 @@ const importConfig = async (filePath) => {
917
1007
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
918
1008
  }
919
1009
  };
920
- const loadConfig = async (rootDir, customPath) => {
1010
+ const warnUnknownKeys = (raw, onWarning) => {
1011
+ const unknown = collectUnknownConfigKeys(raw);
1012
+ for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
1013
+ for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
1014
+ };
1015
+ const loadConfig = async (rootDir, customPath, onWarning) => {
921
1016
  let configObject = null;
922
1017
  if (customPath) {
923
1018
  const fullPath = path.resolve(rootDir, customPath);
@@ -950,7 +1045,9 @@ const loadConfig = async (rootDir, customPath) => {
950
1045
  }
951
1046
  if (!configObject) return {};
952
1047
  try {
953
- return validateConfig(configObject);
1048
+ const config = validateConfig(configObject);
1049
+ if (onWarning) warnUnknownKeys(configObject, onWarning);
1050
+ return config;
954
1051
  } catch (error) {
955
1052
  const msg = error instanceof Error ? error.message : String(error);
956
1053
  throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
@@ -1020,4 +1117,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1020
1117
 
1021
1118
  //#endregion
1022
1119
  export { runDockerfileRules as a, parseCompose as c, version as d, runComposeRules as i, parseDockerfile as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, discoverProject as u };
1023
- //# sourceMappingURL=src-BuUGk4ND.mjs.map
1120
+ //# sourceMappingURL=src-TK9DRoRo.mjs.map