@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.
@@ -1,10 +1,62 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { parse } from "yaml";
4
+ import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.4.3";
7
+ var version = "0.5.0";
8
+
9
+ //#endregion
10
+ //#region ../core/src/project-info/ignore.ts
11
+ const GLOB_SPECIALS_RE = /[.+^${}()|[\]\\]/gu;
12
+ /**
13
+ * Compiles one glob pattern from `ignore.files` to a RegExp over
14
+ * POSIX-style relative paths. Supported syntax is the subset the docs
15
+ * promise: `**` crosses directory separators (`**` followed by `/` matches
16
+ * zero or more whole segments), `*` and `?` stay within one segment.
17
+ * Brace expansion and character classes are not supported.
18
+ */
19
+ const globToRegExp = (pattern) => {
20
+ let source = "^";
21
+ let index = 0;
22
+ while (index < pattern.length) {
23
+ const char = pattern[index];
24
+ if (char === "*") {
25
+ if (pattern[index + 1] === "*") {
26
+ if (pattern[index + 2] === "/") {
27
+ source += "(?:[^/]*/)*";
28
+ index += 3;
29
+ } else {
30
+ source += ".*";
31
+ index += 2;
32
+ }
33
+ } else {
34
+ source += "[^/]*";
35
+ index += 1;
36
+ }
37
+ } else if (char === "?") {
38
+ source += "[^/]";
39
+ index += 1;
40
+ } else {
41
+ source += char.replace(GLOB_SPECIALS_RE, String.raw`\$&`);
42
+ index += 1;
43
+ }
44
+ }
45
+ return new RegExp(`${source}$`, "u");
46
+ };
47
+ /**
48
+ * Builds a predicate over root-relative paths from `ignore.files`
49
+ * patterns. Windows separators in the tested path are normalized to `/`
50
+ * before matching, so patterns are always written POSIX-style.
51
+ */
52
+ const createIgnoreMatcher = (patterns) => {
53
+ if (!patterns || patterns.length === 0) return () => false;
54
+ const regexps = patterns.map(globToRegExp);
55
+ return (relativePath) => {
56
+ const normalized = relativePath.replaceAll("\\", "/");
57
+ return regexps.some((regexp) => regexp.test(normalized));
58
+ };
59
+ };
8
60
 
9
61
  //#endregion
10
62
  //#region ../core/src/project-info/discover.ts
@@ -19,16 +71,19 @@ const walk = async (dir, fileList = []) => {
19
71
  }));
20
72
  return fileList;
21
73
  };
22
- const discoverProject = async (rootDir) => {
74
+ const discoverProject = async (rootDir, options) => {
23
75
  const allFiles = await walk(rootDir);
24
76
  const dockerfiles = [];
25
77
  const composeFiles = [];
26
78
  const dockerignores = [];
79
+ const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
27
80
  for (const file of allFiles) {
81
+ const relative = path.relative(rootDir, file);
82
+ if (isIgnored(relative)) continue;
28
83
  const base = path.basename(file).toLowerCase();
29
- if (base === ".dockerignore") dockerignores.push(path.relative(rootDir, file));
30
- if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(path.relative(rootDir, file));
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));
84
+ if (base === ".dockerignore") dockerignores.push(relative);
85
+ if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
86
+ 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);
32
87
  }
33
88
  return {
34
89
  composeFiles: composeFiles.toSorted(),
@@ -107,7 +162,6 @@ const processHeredocLine = (state, trimmed) => {
107
162
  };
108
163
  const processInstructionLine = (state, trimmed, lineNum) => {
109
164
  let lineContent = trimmed;
110
- if (lineContent.startsWith("#")) return;
111
165
  const hasContinuation = lineContent.endsWith("\\");
112
166
  if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
113
167
  if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
@@ -130,7 +184,8 @@ const parseDockerfile = (content) => {
130
184
  const trimmed = rawLine.trim();
131
185
  const lineNum = i + 1;
132
186
  const insideHeredoc = state.heredocQueue.length > 0;
133
- if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
187
+ if (!insideHeredoc && trimmed.startsWith("#")) continue;
188
+ if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
134
189
  state.rawAccumulator.push(rawLine);
135
190
  if (insideHeredoc) processHeredocLine(state, trimmed);
136
191
  else processInstructionLine(state, trimmed, lineNum);
@@ -173,6 +228,40 @@ const parseCompose = (content, filepath) => {
173
228
  });
174
229
  }
175
230
  };
231
+ /**
232
+ * Builds a {@link ComposeLocator} over the same source text a compose object
233
+ * was parsed from, so rules can attach line numbers to their diagnostics.
234
+ *
235
+ * Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
236
+ * at the merge site, so paths through them resolve to `undefined` — callers
237
+ * fall back to an unnumbered diagnostic, which matches the old behavior.
238
+ */
239
+ const createComposeLocator = (content) => {
240
+ const lineCounter = new LineCounter();
241
+ const doc = parseDocument(content, {
242
+ lineCounter,
243
+ merge: true
244
+ });
245
+ return (path) => {
246
+ let node = doc.contents;
247
+ let offset;
248
+ for (const segment of path) {
249
+ if (isAlias(node)) node = node.resolve(doc);
250
+ if (isMap(node)) {
251
+ const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
252
+ if (!pair || !isScalar(pair.key)) return;
253
+ offset = pair.key.range?.[0];
254
+ node = pair.value;
255
+ } else if (isSeq(node) && typeof segment === "number") {
256
+ const item = node.items[segment];
257
+ if (item === void 0 || item === null) return;
258
+ offset = item.range?.[0];
259
+ node = item;
260
+ } else return;
261
+ }
262
+ return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
263
+ };
264
+ };
176
265
 
177
266
  //#endregion
178
267
  //#region ../core/src/parsers/exec-form.ts
@@ -228,6 +317,33 @@ const parseImageRef = (ref) => {
228
317
  tag
229
318
  };
230
319
  };
320
+ /**
321
+ * Docker Hardened Images (free catalog since Dec 2025) are pulled from the
322
+ * dhi.io registry. Enterprise mirrors live under a plain Docker Hub org
323
+ * namespace and cannot be recognized from the ref alone, so they keep the
324
+ * default rule behavior.
325
+ */
326
+ const isHardenedImage = (imagePart) => imagePart.toLowerCase().startsWith("dhi.io/");
327
+ /**
328
+ * DHI runtime variants ship no shell or package manager and run as a
329
+ * nonroot user by default. The `-dev` variants keep a shell for build
330
+ * stages and are not assumed to be nonroot.
331
+ */
332
+ const isHardenedRuntimeImage = (imagePart) => {
333
+ if (!isHardenedImage(imagePart)) return false;
334
+ const { tag } = parseImageRef(imagePart);
335
+ return !(tag === "dev" || tag?.endsWith("-dev"));
336
+ };
337
+ /**
338
+ * Why a reference would resolve differently over time: no tag at all, or
339
+ * the mutable `latest` tag without a digest. `undefined` means the ref is
340
+ * pinned. Shared by every pinning rule (base images, service images,
341
+ * models) so they agree on what counts as pinned.
342
+ */
343
+ const mutableRefIssue = (ref) => {
344
+ if (!(ref.tag || ref.digest)) return "untagged";
345
+ if (ref.tag === "latest" && !ref.digest) return "latest";
346
+ };
231
347
  const parseFromArgs = (args) => {
232
348
  const parts = args.split(/\s+/u).filter(Boolean);
233
349
  const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
@@ -248,8 +364,8 @@ const collectStageAliases = (instructions) => {
248
364
  };
249
365
 
250
366
  //#endregion
251
- //#region ../core/src/rules/best-practices.ts
252
- const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
367
+ //#region ../core/src/rules/create-diagnostic.ts
368
+ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
253
369
  file,
254
370
  help,
255
371
  line,
@@ -257,12 +373,15 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
257
373
  rule: ruleKey,
258
374
  severity
259
375
  });
376
+
377
+ //#endregion
378
+ //#region ../core/src/rules/best-practices.ts
260
379
  const requireHealthcheck = {
261
380
  category: "Best Practices",
262
381
  check(instructions, file) {
263
382
  const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
264
383
  const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
265
- 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)];
384
+ 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)];
266
385
  return [];
267
386
  },
268
387
  defaultSeverity: "info",
@@ -279,7 +398,7 @@ const preferCopyOverAdd = {
279
398
  if (!src) continue;
280
399
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
281
400
  const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
282
- 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));
401
+ 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));
283
402
  }
284
403
  return diagnostics;
285
404
  },
@@ -292,7 +411,7 @@ const useExecForm = {
292
411
  category: "Best Practices",
293
412
  check(instructions, file) {
294
413
  const diagnostics = [];
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));
414
+ 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));
296
415
  return diagnostics;
297
416
  },
298
417
  defaultSeverity: "warning",
@@ -303,7 +422,7 @@ const useExecForm = {
303
422
  const requireLabels = {
304
423
  category: "Best Practices",
305
424
  check(instructions, file) {
306
- 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)];
425
+ 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)];
307
426
  return [];
308
427
  },
309
428
  defaultSeverity: "info",
@@ -318,8 +437,8 @@ const combineAptUpdateInstall = {
318
437
  for (const inst of instructions) if (inst.instruction === "RUN") {
319
438
  const hasUpdate = inst.args.includes("apt-get update");
320
439
  const hasInstall = inst.args.includes("apt-get install");
321
- 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));
322
- 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));
440
+ 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));
441
+ 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));
323
442
  }
324
443
  return diagnostics;
325
444
  },
@@ -359,7 +478,7 @@ const usePipefail = {
359
478
  const { args } = inst;
360
479
  if (!HAS_PIPE_RE.test(args)) continue;
361
480
  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));
481
+ 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));
363
482
  }
364
483
  return diagnostics;
365
484
  },
@@ -374,7 +493,7 @@ const absoluteWorkdir = {
374
493
  const diagnostics = [];
375
494
  for (const inst of instructions) if (inst.instruction === "WORKDIR") {
376
495
  const path = inst.args.trim();
377
- 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));
496
+ 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));
378
497
  }
379
498
  return diagnostics;
380
499
  },
@@ -387,7 +506,7 @@ const avoidRunCd = {
387
506
  category: "Best Practices",
388
507
  check(instructions, file) {
389
508
  const diagnostics = [];
390
- 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));
509
+ 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));
391
510
  return diagnostics;
392
511
  },
393
512
  defaultSeverity: "info",
@@ -407,7 +526,7 @@ const sortMultilineArgs = {
407
526
  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);
408
527
  if (packages.length > 1) {
409
528
  const sorted = packages.toSorted((a, b) => a.localeCompare(b));
410
- 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));
529
+ 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));
411
530
  }
412
531
  }
413
532
  }
@@ -422,7 +541,7 @@ const useraddNoLogInit = {
422
541
  category: "Best Practices",
423
542
  check(instructions, file) {
424
543
  const diagnostics = [];
425
- 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));
544
+ 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));
426
545
  return diagnostics;
427
546
  },
428
547
  defaultSeverity: "warning",
@@ -443,19 +562,31 @@ const bestPracticesRules = [
443
562
  useraddNoLogInit
444
563
  ];
445
564
 
565
+ //#endregion
566
+ //#region ../core/src/rules/compose-services.ts
567
+ /**
568
+ * Narrows an unknown compose document to its service entries. A service
569
+ * with a null body (`web:` with nothing under it) is returned as an empty
570
+ * config so rules still check it: it is the least-configured service in
571
+ * the file, not a service to skip. Scalar and array bodies are invalid
572
+ * compose and are dropped.
573
+ */
574
+ const composeServices = (composeContent) => {
575
+ if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
576
+ const { services } = composeContent;
577
+ if (!services || typeof services !== "object") return [];
578
+ const entries = [];
579
+ for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
580
+ else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
581
+ return entries;
582
+ };
583
+
446
584
  //#endregion
447
585
  //#region ../core/src/rules/compose.ts
448
- const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
449
- file,
450
- help,
451
- message,
452
- rule: ruleKey,
453
- severity
454
- });
455
586
  const noVersionKey = {
456
587
  category: "Compose",
457
- check(composeContent, file) {
458
- 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)];
588
+ check(composeContent, file, context) {
589
+ 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"]))];
459
590
  return [];
460
591
  },
461
592
  defaultSeverity: "warning",
@@ -465,16 +596,11 @@ const noVersionKey = {
465
596
  };
466
597
  const requireResourceLimits = {
467
598
  category: "Compose",
468
- check(composeContent, file) {
599
+ check(composeContent, file, context) {
469
600
  const diagnostics = [];
470
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
471
- const { services } = composeContent;
472
- if (services && typeof services === "object") {
473
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
474
- const limits = (config.deploy?.resources)?.limits;
475
- 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));
476
- }
477
- }
601
+ for (const [name, config] of composeServices(composeContent)) {
602
+ const limits = (config.deploy?.resources)?.limits;
603
+ 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])));
478
604
  }
479
605
  return diagnostics;
480
606
  },
@@ -485,17 +611,12 @@ const requireResourceLimits = {
485
611
  };
486
612
  const requireRestartPolicy = {
487
613
  category: "Compose",
488
- check(composeContent, file) {
614
+ check(composeContent, file, context) {
489
615
  const diagnostics = [];
490
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
491
- const { services } = composeContent;
492
- if (services && typeof services === "object") {
493
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
494
- const hasRestart = "restart" in config;
495
- const hasDeployRestart = config.deploy?.restart_policy !== void 0;
496
- 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));
497
- }
498
- }
616
+ for (const [name, config] of composeServices(composeContent)) {
617
+ const hasRestart = "restart" in config;
618
+ const hasDeployRestart = config.deploy?.restart_policy !== void 0;
619
+ 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])));
499
620
  }
500
621
  return diagnostics;
501
622
  },
@@ -506,16 +627,15 @@ const requireRestartPolicy = {
506
627
  };
507
628
  const useDependsOnCondition = {
508
629
  category: "Compose",
509
- check(composeContent, file) {
630
+ check(composeContent, file, context) {
510
631
  const diagnostics = [];
511
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
512
- const { services } = composeContent;
513
- if (services && typeof services === "object") {
514
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
515
- const dependsOn = config.depends_on;
516
- 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));
517
- }
518
- }
632
+ for (const [name, config] of composeServices(composeContent)) {
633
+ const dependsOn = config.depends_on;
634
+ 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?.([
635
+ "services",
636
+ name,
637
+ "depends_on"
638
+ ]) ?? context?.locate?.(["services", name])));
519
639
  }
520
640
  return diagnostics;
521
641
  },
@@ -524,23 +644,211 @@ const useDependsOnCondition = {
524
644
  key: "docker-doctor/use-depends-on-condition",
525
645
  message: "Use long-form depends_on with healthcheck conditions"
526
646
  };
647
+ const pinServiceImage = {
648
+ category: "Compose",
649
+ check(composeContent, file, context) {
650
+ const diagnostics = [];
651
+ for (const [name, config] of composeServices(composeContent)) {
652
+ const { image } = config;
653
+ if (typeof image !== "string" || "build" in config) continue;
654
+ const ref = parseImageRef(image);
655
+ if (ref.isVariable) continue;
656
+ const issue = mutableRefIssue(ref);
657
+ if (!issue) continue;
658
+ const detail = issue === "untagged" ? `Service '${name}' image '${image}' does not specify a tag.` : `Service '${name}' image '${image}' uses the mutable 'latest' tag.`;
659
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch a different image.`, this.help, context?.locate?.([
660
+ "services",
661
+ name,
662
+ "image"
663
+ ])));
664
+ }
665
+ return diagnostics;
666
+ },
667
+ defaultSeverity: "warning",
668
+ 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.",
669
+ key: "docker-doctor/pin-service-image",
670
+ message: "Pin service images to a specific tag or digest"
671
+ };
527
672
  const composeRules = [
528
673
  noVersionKey,
529
674
  requireResourceLimits,
530
675
  requireRestartPolicy,
531
- useDependsOnCondition
676
+ useDependsOnCondition,
677
+ pinServiceImage
678
+ ];
679
+
680
+ //#endregion
681
+ //#region ../core/src/rules/compose-models.ts
682
+ /**
683
+ * Narrows an unknown compose document to its top-level `models:` entries
684
+ * (Docker Model Runner, Compose ≥ 2.35).
685
+ */
686
+ const topLevelModels = (composeContent) => {
687
+ if (!composeContent || typeof composeContent !== "object" || !("models" in composeContent)) return {};
688
+ const { models } = composeContent;
689
+ if (!models || typeof models !== "object" || Array.isArray(models)) return {};
690
+ return models;
691
+ };
692
+ const undefinedModelReference = {
693
+ category: "Compose",
694
+ check(composeContent, file, context) {
695
+ const diagnostics = [];
696
+ const defined = new Set(Object.keys(topLevelModels(composeContent)));
697
+ const flag = (serviceName, modelName, pathTail) => {
698
+ 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?.([
699
+ "services",
700
+ serviceName,
701
+ "models",
702
+ pathTail
703
+ ])));
704
+ };
705
+ for (const [name, config] of composeServices(composeContent)) {
706
+ const { models } = config;
707
+ if (Array.isArray(models)) {
708
+ for (const [index, entry] of models.entries()) if (typeof entry === "string" && !defined.has(entry)) flag(name, entry, index);
709
+ } else if (models && typeof models === "object") {
710
+ for (const modelName of Object.keys(models)) if (!defined.has(modelName)) flag(name, modelName, modelName);
711
+ }
712
+ }
713
+ return diagnostics;
714
+ },
715
+ defaultSeverity: "error",
716
+ 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.",
717
+ key: "docker-doctor/undefined-model-reference",
718
+ message: "Service model references must be declared in top-level models"
719
+ };
720
+ const pinModelVersion = {
721
+ category: "Compose",
722
+ check(composeContent, file, context) {
723
+ const diagnostics = [];
724
+ for (const [name, config] of Object.entries(topLevelModels(composeContent))) {
725
+ if (!config || typeof config !== "object") continue;
726
+ const { model } = config;
727
+ if (typeof model !== "string") continue;
728
+ const ref = parseImageRef(model);
729
+ if (ref.isVariable) continue;
730
+ const issue = mutableRefIssue(ref);
731
+ if (!issue) continue;
732
+ const detail = issue === "untagged" ? `Model '${name}' artifact '${model}' does not specify a tag.` : `Model '${name}' artifact '${model}' uses the mutable 'latest' tag.`;
733
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.([
734
+ "models",
735
+ name,
736
+ "model"
737
+ ])));
738
+ }
739
+ return diagnostics;
740
+ },
741
+ defaultSeverity: "warning",
742
+ 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.",
743
+ key: "docker-doctor/pin-model-version",
744
+ message: "Pin models to a specific tag or digest"
745
+ };
746
+ const composeModelRules = [undefinedModelReference, pinModelVersion];
747
+
748
+ //#endregion
749
+ //#region ../core/src/rules/secret-keywords.ts
750
+ const SECRET_KEY_PATTERNS = [
751
+ /(?:^|[_-])password(?:[_-]|$)/iu,
752
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
753
+ /(?:^|[_-])token(?:[_-]|$)/iu,
754
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
755
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
756
+ /(?:^|[_-])auth(?:[_-]|$)/iu
757
+ ];
758
+ const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
759
+
760
+ //#endregion
761
+ //#region ../core/src/rules/compose-security.ts
762
+ const DOCKER_SOCKET = "/var/run/docker.sock";
763
+ const noPrivilegedService = {
764
+ category: "Compose",
765
+ check(composeContent, file, context) {
766
+ const diagnostics = [];
767
+ 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?.([
768
+ "services",
769
+ name,
770
+ "privileged"
771
+ ])));
772
+ return diagnostics;
773
+ },
774
+ defaultSeverity: "error",
775
+ help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
776
+ key: "docker-doctor/no-privileged-service",
777
+ message: "Do not run services in privileged mode"
778
+ };
779
+ const mountsDockerSocket = (volume) => {
780
+ if (typeof volume === "string") return volume.split(":")[0] === DOCKER_SOCKET;
781
+ if (volume && typeof volume === "object") return volume.source === DOCKER_SOCKET;
782
+ return false;
783
+ };
784
+ const noDockerSocketMount = {
785
+ category: "Compose",
786
+ check(composeContent, file, context) {
787
+ const diagnostics = [];
788
+ for (const [name, config] of composeServices(composeContent)) {
789
+ const { volumes } = config;
790
+ if (!Array.isArray(volumes)) continue;
791
+ 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?.([
792
+ "services",
793
+ name,
794
+ "volumes",
795
+ index
796
+ ])));
797
+ }
798
+ return diagnostics;
799
+ },
800
+ defaultSeverity: "error",
801
+ 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`.",
802
+ key: "docker-doctor/no-docker-socket-mount",
803
+ message: "Do not bind-mount the Docker socket into services"
804
+ };
805
+ const isLiteralSecretValue = (value) => typeof value === "string" && value.length > 0 && !value.startsWith("$");
806
+ const noPlaintextSecrets = {
807
+ category: "Compose",
808
+ check(composeContent, file, context) {
809
+ const diagnostics = [];
810
+ const flag = (name, key, line) => {
811
+ 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));
812
+ };
813
+ for (const [name, config] of composeServices(composeContent)) {
814
+ const { environment } = config;
815
+ if (Array.isArray(environment)) for (const [index, entry] of environment.entries()) {
816
+ if (typeof entry !== "string") continue;
817
+ const eqIndex = entry.indexOf("=");
818
+ if (eqIndex <= 0) continue;
819
+ const key = entry.slice(0, eqIndex);
820
+ const value = entry.slice(eqIndex + 1);
821
+ if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
822
+ "services",
823
+ name,
824
+ "environment",
825
+ index
826
+ ]));
827
+ }
828
+ else if (environment && typeof environment === "object") {
829
+ for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
830
+ "services",
831
+ name,
832
+ "environment",
833
+ key
834
+ ]));
835
+ }
836
+ }
837
+ return diagnostics;
838
+ },
839
+ defaultSeverity: "warning",
840
+ help: "Move the value to an `env_file` kept out of version control, interpolate it from the host environment (`${VAR}`), or use Compose `secrets:`.",
841
+ key: "docker-doctor/no-plaintext-secrets",
842
+ message: "Avoid literal secret values in Compose environment"
843
+ };
844
+ const composeSecurityRules = [
845
+ noPrivilegedService,
846
+ noDockerSocketMount,
847
+ noPlaintextSecrets
532
848
  ];
533
849
 
534
850
  //#endregion
535
851
  //#region ../core/src/rules/image-size.ts
536
- const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
537
- file,
538
- help,
539
- line,
540
- message,
541
- rule: ruleKey,
542
- severity
543
- });
544
852
  const preferSlimBase = {
545
853
  category: "Image Size",
546
854
  check(instructions, file) {
@@ -551,10 +859,11 @@ const preferSlimBase = {
551
859
  if (!imagePart || isScratch(imagePart)) continue;
552
860
  const ref = parseImageRef(imagePart);
553
861
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
862
+ if (isHardenedImage(imagePart)) continue;
554
863
  if (ref.digest) continue;
555
864
  if (!ref.tag) continue;
556
865
  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));
866
+ 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));
558
867
  }
559
868
  return diagnostics;
560
869
  },
@@ -563,14 +872,20 @@ const preferSlimBase = {
563
872
  key: "docker-doctor/prefer-slim-base",
564
873
  message: "Prefer slim, alpine, or distroless base images"
565
874
  };
875
+ const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
876
+ const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
877
+ 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)));
878
+ const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
879
+ const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
880
+ const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
566
881
  const cleanPackageCache = {
567
882
  category: "Image Size",
568
883
  check(instructions, file) {
569
884
  const diagnostics = [];
570
885
  for (const inst of instructions) if (inst.instruction === "RUN") {
571
886
  const { args } = inst;
572
- 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));
573
- 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));
887
+ 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));
888
+ 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));
574
889
  }
575
890
  return diagnostics;
576
891
  },
@@ -607,7 +922,7 @@ const avoidDevDependencies = {
607
922
  for (const inst of stage.runs) {
608
923
  if (!installsDevDependencies(inst.args)) continue;
609
924
  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));
925
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
611
926
  }
612
927
  }
613
928
  return diagnostics;
@@ -625,19 +940,11 @@ const imageSizeRules = [
625
940
 
626
941
  //#endregion
627
942
  //#region ../core/src/rules/performance.ts
628
- const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
629
- file,
630
- help,
631
- line,
632
- message,
633
- rule: ruleKey,
634
- severity
635
- });
636
943
  const useMultiStage = {
637
944
  category: "Performance",
638
945
  check(instructions, file) {
639
946
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
640
- 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)];
947
+ 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)];
641
948
  }
642
949
  return [];
643
950
  },
@@ -661,7 +968,7 @@ const orderLayers = {
661
968
  }
662
969
  if (inst.instruction === "RUN" && copyAllLine !== -1) {
663
970
  const args = inst.args.toLowerCase();
664
- 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));
971
+ 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));
665
972
  }
666
973
  }
667
974
  return diagnostics;
@@ -681,10 +988,10 @@ const minimizeLayers = {
681
988
  if (consecutiveRunCount === 0) firstRunLine = inst.line;
682
989
  consecutiveRunCount += 1;
683
990
  } else {
684
- 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));
991
+ 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));
685
992
  consecutiveRunCount = 0;
686
993
  }
687
- 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));
994
+ 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));
688
995
  return diagnostics;
689
996
  },
690
997
  defaultSeverity: "info",
@@ -709,7 +1016,7 @@ const useDockerignore = {
709
1016
  return src === "." || src === "./" || src === "*";
710
1017
  }
711
1018
  return false;
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)];
1019
+ }) && 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)];
713
1020
  return [];
714
1021
  },
715
1022
  defaultSeverity: "warning",
@@ -726,14 +1033,6 @@ const performanceRules = [
726
1033
 
727
1034
  //#endregion
728
1035
  //#region ../core/src/rules/security.ts
729
- const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
730
- file,
731
- help,
732
- line,
733
- message,
734
- rule: ruleKey,
735
- severity
736
- });
737
1036
  const isRootUser = (value) => {
738
1037
  const [user] = value.split(":");
739
1038
  return user === "root" || user === "0";
@@ -747,7 +1046,8 @@ const noRootUser = {
747
1046
  let lastUserLine = 1;
748
1047
  for (const inst of instructions) if (inst.instruction === "FROM") {
749
1048
  const { base, stage } = parseFromArgs(inst.args);
750
- lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
1049
+ const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1050
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
751
1051
  lastUserLine = inst.line;
752
1052
  currentStage = stage?.toLowerCase() ?? null;
753
1053
  if (currentStage) stageUser.set(currentStage, lastUser);
@@ -768,21 +1068,13 @@ const noSecretsInEnv = {
768
1068
  category: "Security",
769
1069
  check(instructions, file) {
770
1070
  const diagnostics = [];
771
- const secretKeywords = [
772
- /(?:^|[_-])password(?:[_-]|$)/iu,
773
- /(?:^|[_-])secret(?:[_-]|$)/iu,
774
- /(?:^|[_-])token(?:[_-]|$)/iu,
775
- /(?:^|[_-])api_key(?:[_-]|$)/iu,
776
- /(?:^|[_-])private_key(?:[_-]|$)/iu,
777
- /(?:^|[_-])auth(?:[_-]|$)/iu
778
- ];
779
1071
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
780
1072
  const args = inst.args.trim();
781
1073
  if (inst.instruction === "ENV" && !args.includes("=")) {
782
1074
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
783
1075
  if (match?.groups) {
784
1076
  const { key, value } = match.groups;
785
- 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));
1077
+ 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));
786
1078
  }
787
1079
  } else {
788
1080
  const parts = args.split(/\s+/u);
@@ -794,7 +1086,7 @@ const noSecretsInEnv = {
794
1086
  key = part.slice(0, eqIndex);
795
1087
  value = part.slice(eqIndex + 1);
796
1088
  } else key = part;
797
- 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));
1089
+ 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));
798
1090
  }
799
1091
  }
800
1092
  }
@@ -815,8 +1107,11 @@ const pinImageVersion = {
815
1107
  if (!imagePart || isScratch(imagePart)) continue;
816
1108
  const ref = parseImageRef(imagePart);
817
1109
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
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));
819
- 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));
1110
+ const issue = mutableRefIssue(ref);
1111
+ if (issue) {
1112
+ const detail = issue === "untagged" ? `Base image '${imagePart}' does not specify a tag.` : `Base image '${imagePart}' uses the mutable 'latest' tag.`;
1113
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} This makes builds non-deterministic.`, this.help, inst.line));
1114
+ }
820
1115
  }
821
1116
  return diagnostics;
822
1117
  },
@@ -856,7 +1151,11 @@ const allDockerfileRules = [
856
1151
  ...bestPracticesRules,
857
1152
  ...imageSizeRules
858
1153
  ];
859
- const allComposeRules = [...composeRules];
1154
+ const allComposeRules = [
1155
+ ...composeRules,
1156
+ ...composeSecurityRules,
1157
+ ...composeModelRules
1158
+ ];
860
1159
  const allRules = [...allDockerfileRules, ...allComposeRules];
861
1160
  const findRule = (key) => allRules.find((rule) => rule.key === key);
862
1161
 
@@ -880,12 +1179,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
880
1179
 
881
1180
  //#endregion
882
1181
  //#region ../core/src/runners/compose-runner.ts
883
- const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
1182
+ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
884
1183
  const diagnostics = [];
885
1184
  for (const rule of allComposeRules) {
886
1185
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
887
1186
  if (severity === "off") continue;
888
- const ruleDiagnostics = rule.check(composeContent, file);
1187
+ const ruleDiagnostics = rule.check(composeContent, file, { locate });
889
1188
  if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
890
1189
  diagnostics.push(...ruleDiagnostics);
891
1190
  }
@@ -1116,5 +1415,5 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1116
1415
  });
1117
1416
 
1118
1417
  //#endregion
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 };
1120
- //# sourceMappingURL=src-TK9DRoRo.mjs.map
1418
+ export { runDockerfileRules as a, createComposeLocator as c, discoverProject as d, version as f, runComposeRules as i, parseCompose as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, parseDockerfile as u };
1419
+ //# sourceMappingURL=src-CpGd43y0.mjs.map