@docker-doctor/cli 0.4.1 → 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.1";
7
+ var version = "0.4.3";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/discover.ts
@@ -31,9 +31,9 @@ const discoverProject = async (rootDir) => {
31
31
  if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(path.relative(rootDir, file));
32
32
  }
33
33
  return {
34
- composeFiles,
35
- dockerfiles,
36
- dockerignores
34
+ composeFiles: composeFiles.toSorted(),
35
+ dockerfiles: dockerfiles.toSorted(),
36
+ dockerignores: dockerignores.toSorted()
37
37
  };
38
38
  };
39
39
 
@@ -59,8 +59,13 @@ const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
59
59
  "VOLUME",
60
60
  "WORKDIR"
61
61
  ]);
62
+ const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
63
+ "ADD",
64
+ "COPY",
65
+ "RUN"
66
+ ]);
62
67
  const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
63
- const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
68
+ const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
64
69
  const createParserState = () => ({
65
70
  currentArgs: "",
66
71
  currentInstruction: "",
@@ -114,7 +119,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
114
119
  state.currentArgs = matched.args;
115
120
  }
116
121
  }
117
- if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
122
+ if (HEREDOC_INSTRUCTIONS.has(state.currentInstruction)) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
118
123
  if (state.heredocQueue.length > 0) return;
119
124
  if (!hasContinuation) closeInstruction(state);
120
125
  };
@@ -160,7 +165,7 @@ var ParseError = class extends Error {
160
165
  //#region ../core/src/parsers/compose-parser.ts
161
166
  const parseCompose = (content, filepath) => {
162
167
  try {
163
- return parse(content);
168
+ return parse(content, { merge: true });
164
169
  } catch (error) {
165
170
  throw new ParseError({
166
171
  file: filepath,
@@ -169,6 +174,79 @@ const parseCompose = (content, filepath) => {
169
174
  }
170
175
  };
171
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
+
172
250
  //#endregion
173
251
  //#region ../core/src/rules/best-practices.ts
174
252
  const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
@@ -188,7 +266,7 @@ const requireHealthcheck = {
188
266
  return [];
189
267
  },
190
268
  defaultSeverity: "info",
191
- 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.",
192
270
  key: "docker-doctor/require-healthcheck",
193
271
  message: "Add a HEALTHCHECK instruction"
194
272
  };
@@ -214,14 +292,11 @@ const useExecForm = {
214
292
  category: "Best Practices",
215
293
  check(instructions, file) {
216
294
  const diagnostics = [];
217
- for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
218
- const args = inst.args.trim();
219
- 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));
220
- }
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));
221
296
  return diagnostics;
222
297
  },
223
298
  defaultSeverity: "warning",
224
- 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.",
225
300
  key: "docker-doctor/use-exec-form",
226
301
  message: "Use exec form for CMD and ENTRYPOINT"
227
302
  };
@@ -232,7 +307,7 @@ const requireLabels = {
232
307
  return [];
233
308
  },
234
309
  defaultSeverity: "info",
235
- 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.",
236
311
  key: "docker-doctor/require-labels",
237
312
  message: "Add LABEL metadata to images"
238
313
  };
@@ -249,22 +324,47 @@ const combineAptUpdateInstall = {
249
324
  return diagnostics;
250
325
  },
251
326
  defaultSeverity: "warning",
252
- 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/*`).",
253
328
  key: "docker-doctor/combine-apt-update-install",
254
329
  message: "Combine apt-get update and apt-get install"
255
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
+ };
256
337
  const usePipefail = {
257
338
  category: "Best Practices",
258
339
  check(instructions, file) {
259
340
  const diagnostics = [];
260
- for (const inst of instructions) if (inst.instruction === "RUN") {
261
- const { raw } = inst;
262
- 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));
263
363
  }
264
364
  return diagnostics;
265
365
  },
266
366
  defaultSeverity: "warning",
267
- 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.",
268
368
  key: "docker-doctor/use-pipefail",
269
369
  message: "Use pipefail to catch pipeline command failures"
270
370
  };
@@ -279,7 +379,7 @@ const absoluteWorkdir = {
279
379
  return diagnostics;
280
380
  },
281
381
  defaultSeverity: "warning",
282
- 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`).",
283
383
  key: "docker-doctor/absolute-workdir",
284
384
  message: "Use absolute paths for WORKDIR"
285
385
  };
@@ -291,7 +391,7 @@ const avoidRunCd = {
291
391
  return diagnostics;
292
392
  },
293
393
  defaultSeverity: "info",
294
- 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.",
295
395
  key: "docker-doctor/avoid-run-cd",
296
396
  message: "Avoid changing directories with cd in RUN"
297
397
  };
@@ -326,7 +426,7 @@ const useraddNoLogInit = {
326
426
  return diagnostics;
327
427
  },
328
428
  defaultSeverity: "warning",
329
- 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`).",
330
430
  key: "docker-doctor/useradd-no-log-init",
331
431
  message: "Use --no-log-init with useradd"
332
432
  };
@@ -359,9 +459,9 @@ const noVersionKey = {
359
459
  return [];
360
460
  },
361
461
  defaultSeverity: "warning",
362
- 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.",
363
463
  key: "docker-doctor/no-version-key",
364
- message: "Remove the 'version' key from Compose file"
464
+ message: "Remove the `version` key from the Compose file"
365
465
  };
366
466
  const requireResourceLimits = {
367
467
  category: "Compose",
@@ -379,7 +479,7 @@ const requireResourceLimits = {
379
479
  return diagnostics;
380
480
  },
381
481
  defaultSeverity: "warning",
382
- 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.",
383
483
  key: "docker-doctor/require-resource-limits",
384
484
  message: "Define resource limits for services"
385
485
  };
@@ -400,7 +500,7 @@ const requireRestartPolicy = {
400
500
  return diagnostics;
401
501
  },
402
502
  defaultSeverity: "warning",
403
- 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.",
404
504
  key: "docker-doctor/require-restart-policy",
405
505
  message: "Set restart policy for services"
406
506
  };
@@ -420,7 +520,7 @@ const useDependsOnCondition = {
420
520
  return diagnostics;
421
521
  },
422
522
  defaultSeverity: "info",
423
- 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.",
424
524
  key: "docker-doctor/use-depends-on-condition",
425
525
  message: "Use long-form depends_on with healthcheck conditions"
426
526
  };
@@ -431,54 +531,6 @@ const composeRules = [
431
531
  useDependsOnCondition
432
532
  ];
433
533
 
434
- //#endregion
435
- //#region ../core/src/parsers/image-ref.ts
436
- const parseImageRef = (ref) => {
437
- if (ref.includes("${") || ref.startsWith("$")) return {
438
- isVariable: true,
439
- name: ref
440
- };
441
- let remainder = ref;
442
- let digest;
443
- const atIndex = remainder.indexOf("@");
444
- if (atIndex !== -1) {
445
- digest = remainder.slice(atIndex + 1);
446
- remainder = remainder.slice(0, atIndex);
447
- }
448
- let tag;
449
- const lastColonIndex = remainder.lastIndexOf(":");
450
- const lastSlashIndex = remainder.lastIndexOf("/");
451
- if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
452
- tag = remainder.slice(lastColonIndex + 1);
453
- remainder = remainder.slice(0, lastColonIndex);
454
- }
455
- let registry;
456
- const firstSlashIndex = remainder.indexOf("/");
457
- if (firstSlashIndex !== -1) {
458
- const firstSegment = remainder.slice(0, firstSlashIndex);
459
- if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
460
- registry = firstSegment;
461
- remainder = remainder.slice(firstSlashIndex + 1);
462
- }
463
- }
464
- return {
465
- digest,
466
- isVariable: false,
467
- name: remainder,
468
- registry,
469
- tag
470
- };
471
- };
472
- const collectStageAliases = (instructions) => {
473
- const aliases = /* @__PURE__ */ new Set();
474
- for (const inst of instructions) {
475
- if (inst.instruction !== "FROM") continue;
476
- const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
477
- if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
478
- }
479
- return aliases;
480
- };
481
-
482
534
  //#endregion
483
535
  //#region ../core/src/rules/image-size.ts
484
536
  const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
@@ -495,21 +547,21 @@ const preferSlimBase = {
495
547
  const diagnostics = [];
496
548
  const stageAliases = collectStageAliases(instructions);
497
549
  for (const inst of instructions) if (inst.instruction === "FROM") {
498
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
499
- if (!imagePart || imagePart === "scratch") continue;
550
+ const imagePart = parseFromArgs(inst.args).base;
551
+ if (!imagePart || isScratch(imagePart)) continue;
500
552
  const ref = parseImageRef(imagePart);
501
553
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
502
554
  if (ref.digest) continue;
503
555
  if (!ref.tag) continue;
504
- const tag = ref.tag.toLowerCase();
505
- if (!(tag.includes("alpine") || tag.includes("slim") || tag.includes("distroless"))) diagnostics.push(createDiagnostic$2(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));
556
+ const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
557
+ if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic$2(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));
506
558
  }
507
559
  return diagnostics;
508
560
  },
509
561
  defaultSeverity: "info",
510
- 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.",
511
563
  key: "docker-doctor/prefer-slim-base",
512
- message: "Use slim, alpine, or distroless base images"
564
+ message: "Prefer slim, alpine, or distroless base images"
513
565
  };
514
566
  const cleanPackageCache = {
515
567
  category: "Image Size",
@@ -523,34 +575,47 @@ const cleanPackageCache = {
523
575
  return diagnostics;
524
576
  },
525
577
  defaultSeverity: "warning",
526
- 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`.",
527
579
  key: "docker-doctor/clean-package-cache",
528
580
  message: "Clean up package manager cache in the same RUN layer"
529
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");
530
583
  const avoidDevDependencies = {
531
584
  category: "Image Size",
532
585
  check(instructions, file) {
533
586
  const diagnostics = [];
534
- let isLastStage = false;
535
- let fromCount = 0;
536
- for (const inst of instructions) if (inst.instruction === "FROM") fromCount += 1;
537
- let currentStage = 0;
538
- for (const inst of instructions) {
539
- if (inst.instruction === "FROM") {
540
- currentStage += 1;
541
- isLastStage = currentStage === fromCount;
542
- }
543
- if (isLastStage && inst.instruction === "RUN") {
544
- const { args } = inst;
545
- 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));
546
611
  }
547
612
  }
548
613
  return diagnostics;
549
614
  },
550
615
  defaultSeverity: "warning",
551
- 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.",
552
617
  key: "docker-doctor/avoid-dev-dependencies",
553
- message: "Avoid installing development dependencies in final production stage"
618
+ message: "Avoid installing dev dependencies in the final stage"
554
619
  };
555
620
  const imageSizeRules = [
556
621
  preferSlimBase,
@@ -579,7 +644,7 @@ const useMultiStage = {
579
644
  defaultSeverity: "info",
580
645
  help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
581
646
  key: "docker-doctor/use-multi-stage",
582
- message: "Consider using multi-stage builds"
647
+ message: "Use multi-stage builds"
583
648
  };
584
649
  const orderLayers = {
585
650
  category: "Performance",
@@ -623,10 +688,16 @@ const minimizeLayers = {
623
688
  return diagnostics;
624
689
  },
625
690
  defaultSeverity: "info",
626
- 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.",
627
692
  key: "docker-doctor/minimize-layers",
628
693
  message: "Minimize the number of image layers"
629
694
  };
695
+ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
696
+ const lastSlash = dockerfilePath.lastIndexOf("/");
697
+ const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
698
+ const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
699
+ return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
700
+ };
630
701
  const useDockerignore = {
631
702
  category: "Performance",
632
703
  check(instructions, file, context) {
@@ -638,15 +709,13 @@ const useDockerignore = {
638
709
  return src === "." || src === "./" || src === "*";
639
710
  }
640
711
  return false;
641
- }) && context?.projectFiles) {
642
- if (!context.projectFiles.some((f) => f.endsWith(".dockerignore"))) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Using COPY/ADD with wildcard/directory, but no .dockerignore file was found in the workspace. This can copy local build folders and secrets.", this.help, 1)];
643
- }
712
+ }) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Using COPY/ADD with a wildcard or directory, but no .dockerignore file was found next to the Dockerfile or at the project root. This can copy local build folders and secrets.", this.help, 1)];
644
713
  return [];
645
714
  },
646
715
  defaultSeverity: "warning",
647
716
  help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
648
717
  key: "docker-doctor/use-dockerignore",
649
- message: "Ensure .dockerignore is used"
718
+ message: "Add a .dockerignore file"
650
719
  };
651
720
  const performanceRules = [
652
721
  useMultiStage,
@@ -665,25 +734,35 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
665
734
  rule: ruleKey,
666
735
  severity
667
736
  });
737
+ const isRootUser = (value) => {
738
+ const [user] = value.split(":");
739
+ return user === "root" || user === "0";
740
+ };
668
741
  const noRootUser = {
669
742
  category: "Security",
670
743
  check(instructions, file) {
744
+ const stageUser = /* @__PURE__ */ new Map();
745
+ let currentStage = null;
671
746
  let lastUser = "root";
672
747
  let lastUserLine = 1;
673
748
  for (const inst of instructions) if (inst.instruction === "FROM") {
674
- lastUser = "root";
749
+ const { base, stage } = parseFromArgs(inst.args);
750
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
675
751
  lastUserLine = inst.line;
752
+ currentStage = stage?.toLowerCase() ?? null;
753
+ if (currentStage) stageUser.set(currentStage, lastUser);
676
754
  } else if (inst.instruction === "USER") {
677
755
  lastUser = inst.args.trim().toLowerCase();
678
756
  lastUserLine = inst.line;
757
+ if (currentStage) stageUser.set(currentStage, lastUser);
679
758
  }
680
- if (lastUser === "root" || lastUser === "0" || lastUser === "0:0") return [createDiagnostic(file, this.key, this.defaultSeverity, "The container runs as root. Running as root allows potential container breakout vulnerabilities.", this.help, lastUserLine)];
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)];
681
760
  return [];
682
761
  },
683
762
  defaultSeverity: "warning",
684
- 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.",
685
764
  key: "docker-doctor/no-root-user",
686
- message: "Container should not run as root user"
765
+ message: "Run the container as a non-root user"
687
766
  };
688
767
  const noSecretsInEnv = {
689
768
  category: "Security",
@@ -724,7 +803,7 @@ const noSecretsInEnv = {
724
803
  defaultSeverity: "error",
725
804
  help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
726
805
  key: "docker-doctor/no-secrets-in-env",
727
- message: "Do not store secrets in ENV or ARG instructions"
806
+ message: "Avoid storing secrets in ENV or ARG instructions"
728
807
  };
729
808
  const pinImageVersion = {
730
809
  category: "Security",
@@ -732,8 +811,8 @@ const pinImageVersion = {
732
811
  const diagnostics = [];
733
812
  const stageAliases = collectStageAliases(instructions);
734
813
  for (const inst of instructions) if (inst.instruction === "FROM") {
735
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
736
- if (!imagePart || imagePart === "scratch") continue;
814
+ const imagePart = parseFromArgs(inst.args).base;
815
+ if (!imagePart || isScratch(imagePart)) continue;
737
816
  const ref = parseImageRef(imagePart);
738
817
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
739
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));
@@ -742,9 +821,9 @@ const pinImageVersion = {
742
821
  return diagnostics;
743
822
  },
744
823
  defaultSeverity: "warning",
745
- 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`).",
746
825
  key: "docker-doctor/pin-image-version",
747
- message: "Always pin base image versions to specific tags"
826
+ message: "Pin base images to a specific tag or digest"
748
827
  };
749
828
  const noAddRemote = {
750
829
  category: "Security",
@@ -758,7 +837,7 @@ const noAddRemote = {
758
837
  return diagnostics;
759
838
  },
760
839
  defaultSeverity: "warning",
761
- 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.",
762
841
  key: "docker-doctor/no-add-remote",
763
842
  message: "Avoid using ADD with remote URLs"
764
843
  };
@@ -781,15 +860,19 @@ const allComposeRules = [...composeRules];
781
860
  const allRules = [...allDockerfileRules, ...allComposeRules];
782
861
  const findRule = (key) => allRules.find((rule) => rule.key === key);
783
862
 
863
+ //#endregion
864
+ //#region ../core/src/runners/resolve-severity.ts
865
+ const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[rule.key] ?? categoriesConfig?.[rule.category] ?? rule.defaultSeverity;
866
+
784
867
  //#endregion
785
868
  //#region ../core/src/runners/dockerfile-runner.ts
786
- const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
869
+ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
787
870
  const diagnostics = [];
788
871
  for (const rule of allDockerfileRules) {
789
- const configSeverity = rulesConfig?.[rule.key];
790
- if (configSeverity === "off") continue;
872
+ const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
873
+ if (severity === "off") continue;
791
874
  const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
792
- if (configSeverity) for (const diag of ruleDiagnostics) diag.severity = configSeverity;
875
+ if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
793
876
  diagnostics.push(...ruleDiagnostics);
794
877
  }
795
878
  return diagnostics;
@@ -797,13 +880,13 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
797
880
 
798
881
  //#endregion
799
882
  //#region ../core/src/runners/compose-runner.ts
800
- const runComposeRules = (composeContent, file, rulesConfig) => {
883
+ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
801
884
  const diagnostics = [];
802
885
  for (const rule of allComposeRules) {
803
- const configSeverity = rulesConfig?.[rule.key];
804
- if (configSeverity === "off") continue;
886
+ const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
887
+ if (severity === "off") continue;
805
888
  const ruleDiagnostics = rule.check(composeContent, file);
806
- if (configSeverity) for (const diag of ruleDiagnostics) diag.severity = configSeverity;
889
+ if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
807
890
  diagnostics.push(...ruleDiagnostics);
808
891
  }
809
892
  return diagnostics;
@@ -871,6 +954,30 @@ const validateConfig = (input) => {
871
954
  return result;
872
955
  };
873
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
+
874
981
  //#endregion
875
982
  //#region ../core/src/config/loader.ts
876
983
  const fileExists = async (filePath) => {
@@ -900,7 +1007,12 @@ const importConfig = async (filePath) => {
900
1007
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
901
1008
  }
902
1009
  };
903
- 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) => {
904
1016
  let configObject = null;
905
1017
  if (customPath) {
906
1018
  const fullPath = path.resolve(rootDir, customPath);
@@ -933,7 +1045,9 @@ const loadConfig = async (rootDir, customPath) => {
933
1045
  }
934
1046
  if (!configObject) return {};
935
1047
  try {
936
- return validateConfig(configObject);
1048
+ const config = validateConfig(configObject);
1049
+ if (onWarning) warnUnknownKeys(configObject, onWarning);
1050
+ return config;
937
1051
  } catch (error) {
938
1052
  const msg = error instanceof Error ? error.message : String(error);
939
1053
  throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
@@ -1003,4 +1117,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1003
1117
 
1004
1118
  //#endregion
1005
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 };
1006
- //# sourceMappingURL=src-DHveWSuN.mjs.map
1120
+ //# sourceMappingURL=src-TK9DRoRo.mjs.map