@docker-doctor/cli 0.4.3 → 0.5.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.
@@ -33,7 +33,59 @@ node_path = __toESM(node_path, 1);
33
33
  let yaml = require("yaml");
34
34
 
35
35
  //#region package.json
36
- var version = "0.4.3";
36
+ var version = "0.5.0";
37
+
38
+ //#endregion
39
+ //#region ../core/src/project-info/ignore.ts
40
+ const GLOB_SPECIALS_RE = /[.+^${}()|[\]\\]/gu;
41
+ /**
42
+ * Compiles one glob pattern from `ignore.files` to a RegExp over
43
+ * POSIX-style relative paths. Supported syntax is the subset the docs
44
+ * promise: `**` crosses directory separators (`**` followed by `/` matches
45
+ * zero or more whole segments), `*` and `?` stay within one segment.
46
+ * Brace expansion and character classes are not supported.
47
+ */
48
+ const globToRegExp = (pattern) => {
49
+ let source = "^";
50
+ let index = 0;
51
+ while (index < pattern.length) {
52
+ const char = pattern[index];
53
+ if (char === "*") {
54
+ if (pattern[index + 1] === "*") {
55
+ if (pattern[index + 2] === "/") {
56
+ source += "(?:[^/]*/)*";
57
+ index += 3;
58
+ } else {
59
+ source += ".*";
60
+ index += 2;
61
+ }
62
+ } else {
63
+ source += "[^/]*";
64
+ index += 1;
65
+ }
66
+ } else if (char === "?") {
67
+ source += "[^/]";
68
+ index += 1;
69
+ } else {
70
+ source += char.replace(GLOB_SPECIALS_RE, String.raw`\$&`);
71
+ index += 1;
72
+ }
73
+ }
74
+ return new RegExp(`${source}$`, "u");
75
+ };
76
+ /**
77
+ * Builds a predicate over root-relative paths from `ignore.files`
78
+ * patterns. Windows separators in the tested path are normalized to `/`
79
+ * before matching, so patterns are always written POSIX-style.
80
+ */
81
+ const createIgnoreMatcher = (patterns) => {
82
+ if (!patterns || patterns.length === 0) return () => false;
83
+ const regexps = patterns.map(globToRegExp);
84
+ return (relativePath) => {
85
+ const normalized = relativePath.replaceAll("\\", "/");
86
+ return regexps.some((regexp) => regexp.test(normalized));
87
+ };
88
+ };
37
89
 
38
90
  //#endregion
39
91
  //#region ../core/src/project-info/discover.ts
@@ -48,16 +100,19 @@ const walk = async (dir, fileList = []) => {
48
100
  }));
49
101
  return fileList;
50
102
  };
51
- const discoverProject = async (rootDir) => {
103
+ const discoverProject = async (rootDir, options) => {
52
104
  const allFiles = await walk(rootDir);
53
105
  const dockerfiles = [];
54
106
  const composeFiles = [];
55
107
  const dockerignores = [];
108
+ const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
56
109
  for (const file of allFiles) {
110
+ const relative = node_path.default.relative(rootDir, file);
111
+ if (isIgnored(relative)) continue;
57
112
  const base = node_path.default.basename(file).toLowerCase();
58
- if (base === ".dockerignore") dockerignores.push(node_path.default.relative(rootDir, file));
59
- if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(node_path.default.relative(rootDir, file));
60
- 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(node_path.default.relative(rootDir, file));
113
+ if (base === ".dockerignore") dockerignores.push(relative);
114
+ if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
115
+ 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(relative);
61
116
  }
62
117
  return {
63
118
  composeFiles: composeFiles.toSorted(),
@@ -136,7 +191,6 @@ const processHeredocLine = (state, trimmed) => {
136
191
  };
137
192
  const processInstructionLine = (state, trimmed, lineNum) => {
138
193
  let lineContent = trimmed;
139
- if (lineContent.startsWith("#")) return;
140
194
  const hasContinuation = lineContent.endsWith("\\");
141
195
  if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
142
196
  if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
@@ -159,7 +213,8 @@ const parseDockerfile = (content) => {
159
213
  const trimmed = rawLine.trim();
160
214
  const lineNum = i + 1;
161
215
  const insideHeredoc = state.heredocQueue.length > 0;
162
- if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
216
+ if (!insideHeredoc && trimmed.startsWith("#")) continue;
217
+ if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
163
218
  state.rawAccumulator.push(rawLine);
164
219
  if (insideHeredoc) processHeredocLine(state, trimmed);
165
220
  else processInstructionLine(state, trimmed, lineNum);
@@ -202,6 +257,40 @@ const parseCompose = (content, filepath) => {
202
257
  });
203
258
  }
204
259
  };
260
+ /**
261
+ * Builds a {@link ComposeLocator} over the same source text a compose object
262
+ * was parsed from, so rules can attach line numbers to their diagnostics.
263
+ *
264
+ * Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
265
+ * at the merge site, so paths through them resolve to `undefined` — callers
266
+ * fall back to an unnumbered diagnostic, which matches the old behavior.
267
+ */
268
+ const createComposeLocator = (content) => {
269
+ const lineCounter = new yaml.LineCounter();
270
+ const doc = (0, yaml.parseDocument)(content, {
271
+ lineCounter,
272
+ merge: true
273
+ });
274
+ return (path) => {
275
+ let node = doc.contents;
276
+ let offset;
277
+ for (const segment of path) {
278
+ if ((0, yaml.isAlias)(node)) node = node.resolve(doc);
279
+ if ((0, yaml.isMap)(node)) {
280
+ const pair = node.items.find((item) => (0, yaml.isScalar)(item.key) && String(item.key.value) === String(segment));
281
+ if (!pair || !(0, yaml.isScalar)(pair.key)) return;
282
+ offset = pair.key.range?.[0];
283
+ node = pair.value;
284
+ } else if ((0, yaml.isSeq)(node) && typeof segment === "number") {
285
+ const item = node.items[segment];
286
+ if (item === void 0 || item === null) return;
287
+ offset = item.range?.[0];
288
+ node = item;
289
+ } else return;
290
+ }
291
+ return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
292
+ };
293
+ };
205
294
 
206
295
  //#endregion
207
296
  //#region ../core/src/parsers/exec-form.ts
@@ -257,6 +346,33 @@ const parseImageRef = (ref) => {
257
346
  tag
258
347
  };
259
348
  };
349
+ /**
350
+ * Docker Hardened Images (free catalog since Dec 2025) are pulled from the
351
+ * dhi.io registry. Enterprise mirrors live under a plain Docker Hub org
352
+ * namespace and cannot be recognized from the ref alone, so they keep the
353
+ * default rule behavior.
354
+ */
355
+ const isHardenedImage = (imagePart) => imagePart.toLowerCase().startsWith("dhi.io/");
356
+ /**
357
+ * DHI runtime variants ship no shell or package manager and run as a
358
+ * nonroot user by default. The `-dev` variants keep a shell for build
359
+ * stages and are not assumed to be nonroot.
360
+ */
361
+ const isHardenedRuntimeImage = (imagePart) => {
362
+ if (!isHardenedImage(imagePart)) return false;
363
+ const { tag } = parseImageRef(imagePart);
364
+ return !(tag === "dev" || tag?.endsWith("-dev"));
365
+ };
366
+ /**
367
+ * Why a reference would resolve differently over time: no tag at all, or
368
+ * the mutable `latest` tag without a digest. `undefined` means the ref is
369
+ * pinned. Shared by every pinning rule (base images, service images,
370
+ * models) so they agree on what counts as pinned.
371
+ */
372
+ const mutableRefIssue = (ref) => {
373
+ if (!(ref.tag || ref.digest)) return "untagged";
374
+ if (ref.tag === "latest" && !ref.digest) return "latest";
375
+ };
260
376
  const parseFromArgs = (args) => {
261
377
  const parts = args.split(/\s+/u).filter(Boolean);
262
378
  const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
@@ -277,8 +393,8 @@ const collectStageAliases = (instructions) => {
277
393
  };
278
394
 
279
395
  //#endregion
280
- //#region ../core/src/rules/best-practices.ts
281
- const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
396
+ //#region ../core/src/rules/create-diagnostic.ts
397
+ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
282
398
  file,
283
399
  help,
284
400
  line,
@@ -286,12 +402,15 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
286
402
  rule: ruleKey,
287
403
  severity
288
404
  });
405
+
406
+ //#endregion
407
+ //#region ../core/src/rules/best-practices.ts
289
408
  const requireHealthcheck = {
290
409
  category: "Best Practices",
291
410
  check(instructions, file) {
292
411
  const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
293
412
  const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
294
- if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic$4(file, this.key, this.defaultSeverity, "No HEALTHCHECK instruction found. Containers running services should expose healthchecks to enable auto-healing.", this.help, 1)];
413
+ if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic(file, this.key, this.defaultSeverity, "No HEALTHCHECK instruction found. Containers running services should expose healthchecks to enable auto-healing.", this.help, 1)];
295
414
  return [];
296
415
  },
297
416
  defaultSeverity: "info",
@@ -308,7 +427,7 @@ const preferCopyOverAdd = {
308
427
  if (!src) continue;
309
428
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
310
429
  const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
311
- if (!isRemote && !isArchive) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
430
+ if (!isRemote && !isArchive) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
312
431
  }
313
432
  return diagnostics;
314
433
  },
@@ -321,7 +440,7 @@ const useExecForm = {
321
440
  category: "Best Practices",
322
441
  check(instructions, file) {
323
442
  const diagnostics = [];
324
- 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));
443
+ for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic(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));
325
444
  return diagnostics;
326
445
  },
327
446
  defaultSeverity: "warning",
@@ -332,7 +451,7 @@ const useExecForm = {
332
451
  const requireLabels = {
333
452
  category: "Best Practices",
334
453
  check(instructions, file) {
335
- if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic$4(file, this.key, this.defaultSeverity, "No LABEL metadata was found in this Dockerfile. Adding labels helps identify build information, maintainers, and descriptions.", this.help, 1)];
454
+ if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic(file, this.key, this.defaultSeverity, "No LABEL metadata was found in this Dockerfile. Adding labels helps identify build information, maintainers, and descriptions.", this.help, 1)];
336
455
  return [];
337
456
  },
338
457
  defaultSeverity: "info",
@@ -347,8 +466,8 @@ const combineAptUpdateInstall = {
347
466
  for (const inst of instructions) if (inst.instruction === "RUN") {
348
467
  const hasUpdate = inst.args.includes("apt-get update");
349
468
  const hasInstall = inst.args.includes("apt-get install");
350
- if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN apt-get update used without apt-get install in the same instruction. This can cause caching issues and build failures.", this.help, inst.line));
351
- else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN apt-get install used without apt-get update in the same instruction. Always combine them to ensure up-to-date package installation.", this.help, inst.line));
469
+ if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get update used without apt-get install in the same instruction. This can cause caching issues and build failures.", this.help, inst.line));
470
+ else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get install used without apt-get update in the same instruction. Always combine them to ensure up-to-date package installation.", this.help, inst.line));
352
471
  }
353
472
  return diagnostics;
354
473
  },
@@ -388,7 +507,7 @@ const usePipefail = {
388
507
  const { args } = inst;
389
508
  if (!HAS_PIPE_RE.test(args)) continue;
390
509
  const execArgv = parseExecForm(args);
391
- 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));
510
+ if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic(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));
392
511
  }
393
512
  return diagnostics;
394
513
  },
@@ -403,7 +522,7 @@ const absoluteWorkdir = {
403
522
  const diagnostics = [];
404
523
  for (const inst of instructions) if (inst.instruction === "WORKDIR") {
405
524
  const path = inst.args.trim();
406
- if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `WORKDIR specifies a relative path '${path}'. For clarity and reliability, always use absolute paths.`, this.help, inst.line));
525
+ 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));
407
526
  }
408
527
  return diagnostics;
409
528
  },
@@ -416,7 +535,7 @@ const avoidRunCd = {
416
535
  category: "Best Practices",
417
536
  check(instructions, file) {
418
537
  const diagnostics = [];
419
- for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic$4(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));
538
+ for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(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));
420
539
  return diagnostics;
421
540
  },
422
541
  defaultSeverity: "info",
@@ -436,7 +555,7 @@ const sortMultilineArgs = {
436
555
  const packages = raw.split(/\r?\n/u).slice(1).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("&&") && !line.startsWith("-") && !line.includes("rm -rf")).map((line) => line.endsWith("\\") ? line.slice(0, -1).trim() : line).filter(Boolean);
437
556
  if (packages.length > 1) {
438
557
  const sorted = packages.toSorted((a, b) => a.localeCompare(b));
439
- if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "Multi-line package arguments are not sorted alphanumerically. Keeping them sorted makes maintenance easier and prevents duplicates.", this.help, inst.line));
558
+ if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Multi-line package arguments are not sorted alphanumerically. Keeping them sorted makes maintenance easier and prevents duplicates.", this.help, inst.line));
440
559
  }
441
560
  }
442
561
  }
@@ -451,7 +570,7 @@ const useraddNoLogInit = {
451
570
  category: "Best Practices",
452
571
  check(instructions, file) {
453
572
  const diagnostics = [];
454
- for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic$4(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));
573
+ 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));
455
574
  return diagnostics;
456
575
  },
457
576
  defaultSeverity: "warning",
@@ -472,19 +591,31 @@ const bestPracticesRules = [
472
591
  useraddNoLogInit
473
592
  ];
474
593
 
594
+ //#endregion
595
+ //#region ../core/src/rules/compose-services.ts
596
+ /**
597
+ * Narrows an unknown compose document to its service entries. A service
598
+ * with a null body (`web:` with nothing under it) is returned as an empty
599
+ * config so rules still check it: it is the least-configured service in
600
+ * the file, not a service to skip. Scalar and array bodies are invalid
601
+ * compose and are dropped.
602
+ */
603
+ const composeServices = (composeContent) => {
604
+ if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
605
+ const { services } = composeContent;
606
+ if (!services || typeof services !== "object") return [];
607
+ const entries = [];
608
+ for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
609
+ else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
610
+ return entries;
611
+ };
612
+
475
613
  //#endregion
476
614
  //#region ../core/src/rules/compose.ts
477
- const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
478
- file,
479
- help,
480
- message,
481
- rule: ruleKey,
482
- severity
483
- });
484
615
  const noVersionKey = {
485
616
  category: "Compose",
486
- check(composeContent, file) {
487
- if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic$3(file, this.key, this.defaultSeverity, "The 'version' property is deprecated. Remove it to use standard Compose spec behavior.", this.help)];
617
+ check(composeContent, file, context) {
618
+ if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic(file, this.key, this.defaultSeverity, "The 'version' property is deprecated. Remove it to use standard Compose spec behavior.", this.help, context?.locate?.(["version"]))];
488
619
  return [];
489
620
  },
490
621
  defaultSeverity: "warning",
@@ -494,16 +625,11 @@ const noVersionKey = {
494
625
  };
495
626
  const requireResourceLimits = {
496
627
  category: "Compose",
497
- check(composeContent, file) {
628
+ check(composeContent, file, context) {
498
629
  const diagnostics = [];
499
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
500
- const { services } = composeContent;
501
- if (services && typeof services === "object") {
502
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
503
- const limits = (config.deploy?.resources)?.limits;
504
- if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' does not have CPU or memory limits defined. A resource leak in this service could crash the host.`, this.help));
505
- }
506
- }
630
+ for (const [name, config] of composeServices(composeContent)) {
631
+ const limits = (config.deploy?.resources)?.limits;
632
+ if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' does not have CPU or memory limits defined. A resource leak in this service could crash the host.`, this.help, context?.locate?.(["services", name])));
507
633
  }
508
634
  return diagnostics;
509
635
  },
@@ -514,17 +640,12 @@ const requireResourceLimits = {
514
640
  };
515
641
  const requireRestartPolicy = {
516
642
  category: "Compose",
517
- check(composeContent, file) {
643
+ check(composeContent, file, context) {
518
644
  const diagnostics = [];
519
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
520
- const { services } = composeContent;
521
- if (services && typeof services === "object") {
522
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
523
- const hasRestart = "restart" in config;
524
- const hasDeployRestart = config.deploy?.restart_policy !== void 0;
525
- if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' has no restart policy configured. It will not restart if it crashes or if the host reboots.`, this.help));
526
- }
527
- }
645
+ for (const [name, config] of composeServices(composeContent)) {
646
+ const hasRestart = "restart" in config;
647
+ const hasDeployRestart = config.deploy?.restart_policy !== void 0;
648
+ if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' has no restart policy configured. It will not restart if it crashes or if the host reboots.`, this.help, context?.locate?.(["services", name])));
528
649
  }
529
650
  return diagnostics;
530
651
  },
@@ -535,16 +656,15 @@ const requireRestartPolicy = {
535
656
  };
536
657
  const useDependsOnCondition = {
537
658
  category: "Compose",
538
- check(composeContent, file) {
659
+ check(composeContent, file, context) {
539
660
  const diagnostics = [];
540
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
541
- const { services } = composeContent;
542
- if (services && typeof services === "object") {
543
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
544
- const dependsOn = config.depends_on;
545
- if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' uses shorthand depends_on list. This only checks if containers are started, not if they are ready/healthy.`, this.help));
546
- }
547
- }
661
+ for (const [name, config] of composeServices(composeContent)) {
662
+ const dependsOn = config.depends_on;
663
+ if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' uses shorthand depends_on list. This only checks if containers are started, not if they are ready/healthy.`, this.help, context?.locate?.([
664
+ "services",
665
+ name,
666
+ "depends_on"
667
+ ]) ?? context?.locate?.(["services", name])));
548
668
  }
549
669
  return diagnostics;
550
670
  },
@@ -553,23 +673,211 @@ const useDependsOnCondition = {
553
673
  key: "docker-doctor/use-depends-on-condition",
554
674
  message: "Use long-form depends_on with healthcheck conditions"
555
675
  };
676
+ const pinServiceImage = {
677
+ category: "Compose",
678
+ check(composeContent, file, context) {
679
+ const diagnostics = [];
680
+ for (const [name, config] of composeServices(composeContent)) {
681
+ const { image } = config;
682
+ if (typeof image !== "string" || "build" in config) continue;
683
+ const ref = parseImageRef(image);
684
+ if (ref.isVariable) continue;
685
+ const issue = mutableRefIssue(ref);
686
+ if (!issue) continue;
687
+ const detail = issue === "untagged" ? `Service '${name}' image '${image}' does not specify a tag.` : `Service '${name}' image '${image}' uses the mutable 'latest' tag.`;
688
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch a different image.`, this.help, context?.locate?.([
689
+ "services",
690
+ name,
691
+ "image"
692
+ ])));
693
+ }
694
+ return diagnostics;
695
+ },
696
+ defaultSeverity: "warning",
697
+ help: "Pin the image to a specific tag or digest (e.g. `nginx:1.27-alpine`) so deploys are reproducible and a rollback actually rolls back.",
698
+ key: "docker-doctor/pin-service-image",
699
+ message: "Pin service images to a specific tag or digest"
700
+ };
556
701
  const composeRules = [
557
702
  noVersionKey,
558
703
  requireResourceLimits,
559
704
  requireRestartPolicy,
560
- useDependsOnCondition
705
+ useDependsOnCondition,
706
+ pinServiceImage
707
+ ];
708
+
709
+ //#endregion
710
+ //#region ../core/src/rules/compose-models.ts
711
+ /**
712
+ * Narrows an unknown compose document to its top-level `models:` entries
713
+ * (Docker Model Runner, Compose ≥ 2.35).
714
+ */
715
+ const topLevelModels = (composeContent) => {
716
+ if (!composeContent || typeof composeContent !== "object" || !("models" in composeContent)) return {};
717
+ const { models } = composeContent;
718
+ if (!models || typeof models !== "object" || Array.isArray(models)) return {};
719
+ return models;
720
+ };
721
+ const undefinedModelReference = {
722
+ category: "Compose",
723
+ check(composeContent, file, context) {
724
+ const diagnostics = [];
725
+ const defined = new Set(Object.keys(topLevelModels(composeContent)));
726
+ const flag = (serviceName, modelName, pathTail) => {
727
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${serviceName}' references model '${modelName}', which is not declared in the top-level models section. Compose cannot resolve it.`, this.help, context?.locate?.([
728
+ "services",
729
+ serviceName,
730
+ "models",
731
+ pathTail
732
+ ])));
733
+ };
734
+ for (const [name, config] of composeServices(composeContent)) {
735
+ const { models } = config;
736
+ if (Array.isArray(models)) {
737
+ for (const [index, entry] of models.entries()) if (typeof entry === "string" && !defined.has(entry)) flag(name, entry, index);
738
+ } else if (models && typeof models === "object") {
739
+ for (const modelName of Object.keys(models)) if (!defined.has(modelName)) flag(name, modelName, modelName);
740
+ }
741
+ }
742
+ return diagnostics;
743
+ },
744
+ defaultSeverity: "error",
745
+ help: "Every name under a service's `models:` must match an entry in the top-level `models:` element. Declare the model there (with its `model:` OCI artifact) or fix the reference.",
746
+ key: "docker-doctor/undefined-model-reference",
747
+ message: "Service model references must be declared in top-level models"
748
+ };
749
+ const pinModelVersion = {
750
+ category: "Compose",
751
+ check(composeContent, file, context) {
752
+ const diagnostics = [];
753
+ for (const [name, config] of Object.entries(topLevelModels(composeContent))) {
754
+ if (!config || typeof config !== "object") continue;
755
+ const { model } = config;
756
+ if (typeof model !== "string") continue;
757
+ const ref = parseImageRef(model);
758
+ if (ref.isVariable) continue;
759
+ const issue = mutableRefIssue(ref);
760
+ if (!issue) continue;
761
+ const detail = issue === "untagged" ? `Model '${name}' artifact '${model}' does not specify a tag.` : `Model '${name}' artifact '${model}' uses the mutable 'latest' tag.`;
762
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.([
763
+ "models",
764
+ name,
765
+ "model"
766
+ ])));
767
+ }
768
+ return diagnostics;
769
+ },
770
+ defaultSeverity: "warning",
771
+ help: "Pin the model to a specific tag (e.g. `ai/gemma3:4B-Q4_0`) so every environment runs the same weights. Model behavior differences are far harder to debug than software version drift.",
772
+ key: "docker-doctor/pin-model-version",
773
+ message: "Pin models to a specific tag or digest"
774
+ };
775
+ const composeModelRules = [undefinedModelReference, pinModelVersion];
776
+
777
+ //#endregion
778
+ //#region ../core/src/rules/secret-keywords.ts
779
+ const SECRET_KEY_PATTERNS = [
780
+ /(?:^|[_-])password(?:[_-]|$)/iu,
781
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
782
+ /(?:^|[_-])token(?:[_-]|$)/iu,
783
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
784
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
785
+ /(?:^|[_-])auth(?:[_-]|$)/iu
786
+ ];
787
+ const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
788
+
789
+ //#endregion
790
+ //#region ../core/src/rules/compose-security.ts
791
+ const DOCKER_SOCKET = "/var/run/docker.sock";
792
+ const noPrivilegedService = {
793
+ category: "Compose",
794
+ check(composeContent, file, context) {
795
+ const diagnostics = [];
796
+ 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?.([
797
+ "services",
798
+ name,
799
+ "privileged"
800
+ ])));
801
+ return diagnostics;
802
+ },
803
+ defaultSeverity: "error",
804
+ help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
805
+ key: "docker-doctor/no-privileged-service",
806
+ message: "Do not run services in privileged mode"
807
+ };
808
+ const mountsDockerSocket = (volume) => {
809
+ if (typeof volume === "string") return volume.split(":")[0] === DOCKER_SOCKET;
810
+ if (volume && typeof volume === "object") return volume.source === DOCKER_SOCKET;
811
+ return false;
812
+ };
813
+ const noDockerSocketMount = {
814
+ category: "Compose",
815
+ check(composeContent, file, context) {
816
+ const diagnostics = [];
817
+ for (const [name, config] of composeServices(composeContent)) {
818
+ const { volumes } = config;
819
+ if (!Array.isArray(volumes)) continue;
820
+ for (const [index, volume] of volumes.entries()) if (mountsDockerSocket(volume)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' bind-mounts the Docker socket. Anything running in this container can control the Docker daemon: start privileged containers, read every volume, and escape to the host.`, this.help, context?.locate?.([
821
+ "services",
822
+ name,
823
+ "volumes",
824
+ index
825
+ ])));
826
+ }
827
+ return diagnostics;
828
+ },
829
+ defaultSeverity: "error",
830
+ help: "If the service genuinely needs the Docker API (agent tooling like MCP gateways often does), prefer `use_api_socket: true` or a filtering socket proxy over a raw bind mount of `/var/run/docker.sock`.",
831
+ key: "docker-doctor/no-docker-socket-mount",
832
+ message: "Do not bind-mount the Docker socket into services"
833
+ };
834
+ const isLiteralSecretValue = (value) => typeof value === "string" && value.length > 0 && !value.startsWith("$");
835
+ const noPlaintextSecrets = {
836
+ category: "Compose",
837
+ check(composeContent, file, context) {
838
+ const diagnostics = [];
839
+ const flag = (name, key, line) => {
840
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret in service '${name}' environment: '${key}'. A literal value here lives in version control in plain text.`, this.help, line));
841
+ };
842
+ for (const [name, config] of composeServices(composeContent)) {
843
+ const { environment } = config;
844
+ if (Array.isArray(environment)) for (const [index, entry] of environment.entries()) {
845
+ if (typeof entry !== "string") continue;
846
+ const eqIndex = entry.indexOf("=");
847
+ if (eqIndex <= 0) continue;
848
+ const key = entry.slice(0, eqIndex);
849
+ const value = entry.slice(eqIndex + 1);
850
+ if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
851
+ "services",
852
+ name,
853
+ "environment",
854
+ index
855
+ ]));
856
+ }
857
+ else if (environment && typeof environment === "object") {
858
+ for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
859
+ "services",
860
+ name,
861
+ "environment",
862
+ key
863
+ ]));
864
+ }
865
+ }
866
+ return diagnostics;
867
+ },
868
+ defaultSeverity: "warning",
869
+ help: "Move the value to an `env_file` kept out of version control, interpolate it from the host environment (`${VAR}`), or use Compose `secrets:`.",
870
+ key: "docker-doctor/no-plaintext-secrets",
871
+ message: "Avoid literal secret values in Compose environment"
872
+ };
873
+ const composeSecurityRules = [
874
+ noPrivilegedService,
875
+ noDockerSocketMount,
876
+ noPlaintextSecrets
561
877
  ];
562
878
 
563
879
  //#endregion
564
880
  //#region ../core/src/rules/image-size.ts
565
- const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
566
- file,
567
- help,
568
- line,
569
- message,
570
- rule: ruleKey,
571
- severity
572
- });
573
881
  const preferSlimBase = {
574
882
  category: "Image Size",
575
883
  check(instructions, file) {
@@ -580,10 +888,11 @@ const preferSlimBase = {
580
888
  if (!imagePart || isScratch(imagePart)) continue;
581
889
  const ref = parseImageRef(imagePart);
582
890
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
891
+ if (isHardenedImage(imagePart)) continue;
583
892
  if (ref.digest) continue;
584
893
  if (!ref.tag) continue;
585
894
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
586
- 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));
895
+ 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));
587
896
  }
588
897
  return diagnostics;
589
898
  },
@@ -592,14 +901,20 @@ const preferSlimBase = {
592
901
  key: "docker-doctor/prefer-slim-base",
593
902
  message: "Prefer slim, alpine, or distroless base images"
594
903
  };
904
+ const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
905
+ const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
906
+ 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)));
907
+ const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
908
+ const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
909
+ const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
595
910
  const cleanPackageCache = {
596
911
  category: "Image Size",
597
912
  check(instructions, file) {
598
913
  const diagnostics = [];
599
914
  for (const inst of instructions) if (inst.instruction === "RUN") {
600
915
  const { args } = inst;
601
- if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic$2(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));
602
- if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
916
+ if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
917
+ 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));
603
918
  }
604
919
  return diagnostics;
605
920
  },
@@ -636,7 +951,7 @@ const avoidDevDependencies = {
636
951
  for (const inst of stage.runs) {
637
952
  if (!installsDevDependencies(inst.args)) continue;
638
953
  const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
639
- diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
954
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
640
955
  }
641
956
  }
642
957
  return diagnostics;
@@ -654,19 +969,11 @@ const imageSizeRules = [
654
969
 
655
970
  //#endregion
656
971
  //#region ../core/src/rules/performance.ts
657
- const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
658
- file,
659
- help,
660
- line,
661
- message,
662
- rule: ruleKey,
663
- severity
664
- });
665
972
  const useMultiStage = {
666
973
  category: "Performance",
667
974
  check(instructions, file) {
668
975
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
669
- if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || inst.args.includes("make")))) return [createDiagnostic$1(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)];
976
+ if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || inst.args.includes("make")))) return [createDiagnostic(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
670
977
  }
671
978
  return [];
672
979
  },
@@ -690,7 +997,7 @@ const orderLayers = {
690
997
  }
691
998
  if (inst.instruction === "RUN" && copyAllLine !== -1) {
692
999
  const args = inst.args.toLowerCase();
693
- if (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install") || args.includes("bun install") || args.includes("pip install") || args.includes("cargo fetch")) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Running package installation command '${inst.args}' after copying application files (at line ${copyAllLine}). This invalidates the cache on any code changes.`, this.help, inst.line));
1000
+ if (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install") || args.includes("bun install") || args.includes("pip install") || args.includes("cargo fetch")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package installation command '${inst.args}' after copying application files (at line ${copyAllLine}). This invalidates the cache on any code changes.`, this.help, inst.line));
694
1001
  }
695
1002
  }
696
1003
  return diagnostics;
@@ -710,10 +1017,10 @@ const minimizeLayers = {
710
1017
  if (consecutiveRunCount === 0) firstRunLine = inst.line;
711
1018
  consecutiveRunCount += 1;
712
1019
  } else {
713
- if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
1020
+ if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
714
1021
  consecutiveRunCount = 0;
715
1022
  }
716
- if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
1023
+ if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
717
1024
  return diagnostics;
718
1025
  },
719
1026
  defaultSeverity: "info",
@@ -738,7 +1045,7 @@ const useDockerignore = {
738
1045
  return src === "." || src === "./" || src === "*";
739
1046
  }
740
1047
  return false;
741
- }) && 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)];
1048
+ }) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic(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)];
742
1049
  return [];
743
1050
  },
744
1051
  defaultSeverity: "warning",
@@ -755,14 +1062,6 @@ const performanceRules = [
755
1062
 
756
1063
  //#endregion
757
1064
  //#region ../core/src/rules/security.ts
758
- const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
759
- file,
760
- help,
761
- line,
762
- message,
763
- rule: ruleKey,
764
- severity
765
- });
766
1065
  const isRootUser = (value) => {
767
1066
  const [user] = value.split(":");
768
1067
  return user === "root" || user === "0";
@@ -776,7 +1075,8 @@ const noRootUser = {
776
1075
  let lastUserLine = 1;
777
1076
  for (const inst of instructions) if (inst.instruction === "FROM") {
778
1077
  const { base, stage } = parseFromArgs(inst.args);
779
- lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
1078
+ const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1079
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
780
1080
  lastUserLine = inst.line;
781
1081
  currentStage = stage?.toLowerCase() ?? null;
782
1082
  if (currentStage) stageUser.set(currentStage, lastUser);
@@ -797,21 +1097,13 @@ const noSecretsInEnv = {
797
1097
  category: "Security",
798
1098
  check(instructions, file) {
799
1099
  const diagnostics = [];
800
- const secretKeywords = [
801
- /(?:^|[_-])password(?:[_-]|$)/iu,
802
- /(?:^|[_-])secret(?:[_-]|$)/iu,
803
- /(?:^|[_-])token(?:[_-]|$)/iu,
804
- /(?:^|[_-])api_key(?:[_-]|$)/iu,
805
- /(?:^|[_-])private_key(?:[_-]|$)/iu,
806
- /(?:^|[_-])auth(?:[_-]|$)/iu
807
- ];
808
1100
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
809
1101
  const args = inst.args.trim();
810
1102
  if (inst.instruction === "ENV" && !args.includes("=")) {
811
1103
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
812
1104
  if (match?.groups) {
813
1105
  const { key, value } = match.groups;
814
- if (secretKeywords.some((regex) => regex.test(key)) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
1106
+ if (isSecretKey(key) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
815
1107
  }
816
1108
  } else {
817
1109
  const parts = args.split(/\s+/u);
@@ -823,7 +1115,7 @@ const noSecretsInEnv = {
823
1115
  key = part.slice(0, eqIndex);
824
1116
  value = part.slice(eqIndex + 1);
825
1117
  } else key = part;
826
- if (secretKeywords.some((regex) => regex.test(key)) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
1118
+ if (isSecretKey(key) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
827
1119
  }
828
1120
  }
829
1121
  }
@@ -844,8 +1136,11 @@ const pinImageVersion = {
844
1136
  if (!imagePart || isScratch(imagePart)) continue;
845
1137
  const ref = parseImageRef(imagePart);
846
1138
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
847
- 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));
848
- else if (ref.tag === "latest" && !ref.digest) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' uses the mutable 'latest' tag. This makes builds non-deterministic.`, this.help, inst.line));
1139
+ const issue = mutableRefIssue(ref);
1140
+ if (issue) {
1141
+ const detail = issue === "untagged" ? `Base image '${imagePart}' does not specify a tag.` : `Base image '${imagePart}' uses the mutable 'latest' tag.`;
1142
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} This makes builds non-deterministic.`, this.help, inst.line));
1143
+ }
849
1144
  }
850
1145
  return diagnostics;
851
1146
  },
@@ -885,7 +1180,11 @@ const allDockerfileRules = [
885
1180
  ...bestPracticesRules,
886
1181
  ...imageSizeRules
887
1182
  ];
888
- const allComposeRules = [...composeRules];
1183
+ const allComposeRules = [
1184
+ ...composeRules,
1185
+ ...composeSecurityRules,
1186
+ ...composeModelRules
1187
+ ];
889
1188
  const allRules = [...allDockerfileRules, ...allComposeRules];
890
1189
  const findRule = (key) => allRules.find((rule) => rule.key === key);
891
1190
 
@@ -909,12 +1208,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
909
1208
 
910
1209
  //#endregion
911
1210
  //#region ../core/src/runners/compose-runner.ts
912
- const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
1211
+ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
913
1212
  const diagnostics = [];
914
1213
  for (const rule of allComposeRules) {
915
1214
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
916
1215
  if (severity === "off") continue;
917
- const ruleDiagnostics = rule.check(composeContent, file);
1216
+ const ruleDiagnostics = rule.check(composeContent, file, { locate });
918
1217
  if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
919
1218
  diagnostics.push(...ruleDiagnostics);
920
1219
  }
@@ -1163,6 +1462,12 @@ Object.defineProperty(exports, 'calculateScore', {
1163
1462
  return calculateScore;
1164
1463
  }
1165
1464
  });
1465
+ Object.defineProperty(exports, 'createComposeLocator', {
1466
+ enumerable: true,
1467
+ get: function () {
1468
+ return createComposeLocator;
1469
+ }
1470
+ });
1166
1471
  Object.defineProperty(exports, 'discoverProject', {
1167
1472
  enumerable: true,
1168
1473
  get: function () {
@@ -1217,4 +1522,4 @@ Object.defineProperty(exports, 'version', {
1217
1522
  return version;
1218
1523
  }
1219
1524
  });
1220
- //# sourceMappingURL=src-ILbJuJmE.cjs.map
1525
+ //# sourceMappingURL=src-F90_EKmA.cjs.map