@docker-doctor/cli 0.4.4 → 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.4";
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(),
@@ -291,6 +346,33 @@ const parseImageRef = (ref) => {
291
346
  tag
292
347
  };
293
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
+ };
294
376
  const parseFromArgs = (args) => {
295
377
  const parts = args.split(/\s+/u).filter(Boolean);
296
378
  const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
@@ -509,6 +591,25 @@ const bestPracticesRules = [
509
591
  useraddNoLogInit
510
592
  ];
511
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
+
512
613
  //#endregion
513
614
  //#region ../core/src/rules/compose.ts
514
615
  const noVersionKey = {
@@ -526,14 +627,9 @@ const requireResourceLimits = {
526
627
  category: "Compose",
527
628
  check(composeContent, file, context) {
528
629
  const diagnostics = [];
529
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
530
- const { services } = composeContent;
531
- if (services && typeof services === "object") {
532
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
533
- const limits = (config.deploy?.resources)?.limits;
534
- 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])));
535
- }
536
- }
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])));
537
633
  }
538
634
  return diagnostics;
539
635
  },
@@ -546,15 +642,10 @@ const requireRestartPolicy = {
546
642
  category: "Compose",
547
643
  check(composeContent, file, context) {
548
644
  const diagnostics = [];
549
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
550
- const { services } = composeContent;
551
- if (services && typeof services === "object") {
552
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
553
- const hasRestart = "restart" in config;
554
- const hasDeployRestart = config.deploy?.restart_policy !== void 0;
555
- 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])));
556
- }
557
- }
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])));
558
649
  }
559
650
  return diagnostics;
560
651
  },
@@ -567,18 +658,13 @@ const useDependsOnCondition = {
567
658
  category: "Compose",
568
659
  check(composeContent, file, context) {
569
660
  const diagnostics = [];
570
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
571
- const { services } = composeContent;
572
- if (services && typeof services === "object") {
573
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
574
- const dependsOn = config.depends_on;
575
- 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?.([
576
- "services",
577
- name,
578
- "depends_on"
579
- ]) ?? context?.locate?.(["services", name])));
580
- }
581
- }
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])));
582
668
  }
583
669
  return diagnostics;
584
670
  },
@@ -587,11 +673,207 @@ const useDependsOnCondition = {
587
673
  key: "docker-doctor/use-depends-on-condition",
588
674
  message: "Use long-form depends_on with healthcheck conditions"
589
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
+ };
590
701
  const composeRules = [
591
702
  noVersionKey,
592
703
  requireResourceLimits,
593
704
  requireRestartPolicy,
594
- 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
595
877
  ];
596
878
 
597
879
  //#endregion
@@ -606,6 +888,7 @@ const preferSlimBase = {
606
888
  if (!imagePart || isScratch(imagePart)) continue;
607
889
  const ref = parseImageRef(imagePart);
608
890
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
891
+ if (isHardenedImage(imagePart)) continue;
609
892
  if (ref.digest) continue;
610
893
  if (!ref.tag) continue;
611
894
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
@@ -792,7 +1075,8 @@ const noRootUser = {
792
1075
  let lastUserLine = 1;
793
1076
  for (const inst of instructions) if (inst.instruction === "FROM") {
794
1077
  const { base, stage } = parseFromArgs(inst.args);
795
- lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
1078
+ const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1079
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
796
1080
  lastUserLine = inst.line;
797
1081
  currentStage = stage?.toLowerCase() ?? null;
798
1082
  if (currentStage) stageUser.set(currentStage, lastUser);
@@ -813,21 +1097,13 @@ const noSecretsInEnv = {
813
1097
  category: "Security",
814
1098
  check(instructions, file) {
815
1099
  const diagnostics = [];
816
- const secretKeywords = [
817
- /(?:^|[_-])password(?:[_-]|$)/iu,
818
- /(?:^|[_-])secret(?:[_-]|$)/iu,
819
- /(?:^|[_-])token(?:[_-]|$)/iu,
820
- /(?:^|[_-])api_key(?:[_-]|$)/iu,
821
- /(?:^|[_-])private_key(?:[_-]|$)/iu,
822
- /(?:^|[_-])auth(?:[_-]|$)/iu
823
- ];
824
1100
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
825
1101
  const args = inst.args.trim();
826
1102
  if (inst.instruction === "ENV" && !args.includes("=")) {
827
1103
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
828
1104
  if (match?.groups) {
829
1105
  const { key, value } = match.groups;
830
- 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));
831
1107
  }
832
1108
  } else {
833
1109
  const parts = args.split(/\s+/u);
@@ -839,7 +1115,7 @@ const noSecretsInEnv = {
839
1115
  key = part.slice(0, eqIndex);
840
1116
  value = part.slice(eqIndex + 1);
841
1117
  } else key = part;
842
- 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));
843
1119
  }
844
1120
  }
845
1121
  }
@@ -860,8 +1136,11 @@ const pinImageVersion = {
860
1136
  if (!imagePart || isScratch(imagePart)) continue;
861
1137
  const ref = parseImageRef(imagePart);
862
1138
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
863
- 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));
864
- 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
+ }
865
1144
  }
866
1145
  return diagnostics;
867
1146
  },
@@ -901,7 +1180,11 @@ const allDockerfileRules = [
901
1180
  ...bestPracticesRules,
902
1181
  ...imageSizeRules
903
1182
  ];
904
- const allComposeRules = [...composeRules];
1183
+ const allComposeRules = [
1184
+ ...composeRules,
1185
+ ...composeSecurityRules,
1186
+ ...composeModelRules
1187
+ ];
905
1188
  const allRules = [...allDockerfileRules, ...allComposeRules];
906
1189
  const findRule = (key) => allRules.find((rule) => rule.key === key);
907
1190
 
@@ -1239,4 +1522,4 @@ Object.defineProperty(exports, 'version', {
1239
1522
  return version;
1240
1523
  }
1241
1524
  });
1242
- //# sourceMappingURL=src-DwuAaQcq.cjs.map
1525
+ //# sourceMappingURL=src-F90_EKmA.cjs.map