@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.
package/dist/index.d.mts CHANGED
@@ -45,7 +45,9 @@ interface DockerDoctorConfig {
45
45
  }
46
46
  //#endregion
47
47
  //#region ../core/src/project-info/discover.d.ts
48
- declare const discoverProject: (rootDir: string) => Promise<ProjectInfo>;
48
+ declare const discoverProject: (rootDir: string, options?: {
49
+ ignoreFiles?: readonly string[];
50
+ }) => Promise<ProjectInfo>;
49
51
  //#endregion
50
52
  //#region ../core/src/parsers/dockerfile-parser.d.ts
51
53
  declare const parseDockerfile: (content: string) => DockerfileInstruction[];
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CvOv-hL3.mjs";
2
+ import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CpGd43y0.mjs";
3
3
 
4
4
  //#region ../core/src/config/define-config.ts
5
5
  const defineConfig = (config) => config;
@@ -4,7 +4,59 @@ import path from "node:path";
4
4
  import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.4.4";
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(),
@@ -262,6 +317,33 @@ const parseImageRef = (ref) => {
262
317
  tag
263
318
  };
264
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
+ };
265
347
  const parseFromArgs = (args) => {
266
348
  const parts = args.split(/\s+/u).filter(Boolean);
267
349
  const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
@@ -480,6 +562,25 @@ const bestPracticesRules = [
480
562
  useraddNoLogInit
481
563
  ];
482
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
+
483
584
  //#endregion
484
585
  //#region ../core/src/rules/compose.ts
485
586
  const noVersionKey = {
@@ -497,14 +598,9 @@ const requireResourceLimits = {
497
598
  category: "Compose",
498
599
  check(composeContent, file, context) {
499
600
  const diagnostics = [];
500
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
501
- const { services } = composeContent;
502
- if (services && typeof services === "object") {
503
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
504
- const limits = (config.deploy?.resources)?.limits;
505
- 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])));
506
- }
507
- }
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])));
508
604
  }
509
605
  return diagnostics;
510
606
  },
@@ -517,15 +613,10 @@ const requireRestartPolicy = {
517
613
  category: "Compose",
518
614
  check(composeContent, file, context) {
519
615
  const diagnostics = [];
520
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
521
- const { services } = composeContent;
522
- if (services && typeof services === "object") {
523
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
524
- const hasRestart = "restart" in config;
525
- const hasDeployRestart = config.deploy?.restart_policy !== void 0;
526
- 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])));
527
- }
528
- }
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])));
529
620
  }
530
621
  return diagnostics;
531
622
  },
@@ -538,18 +629,13 @@ const useDependsOnCondition = {
538
629
  category: "Compose",
539
630
  check(composeContent, file, context) {
540
631
  const diagnostics = [];
541
- if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
542
- const { services } = composeContent;
543
- if (services && typeof services === "object") {
544
- for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
545
- const dependsOn = config.depends_on;
546
- 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?.([
547
- "services",
548
- name,
549
- "depends_on"
550
- ]) ?? context?.locate?.(["services", name])));
551
- }
552
- }
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])));
553
639
  }
554
640
  return diagnostics;
555
641
  },
@@ -558,11 +644,207 @@ const useDependsOnCondition = {
558
644
  key: "docker-doctor/use-depends-on-condition",
559
645
  message: "Use long-form depends_on with healthcheck conditions"
560
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
+ };
561
672
  const composeRules = [
562
673
  noVersionKey,
563
674
  requireResourceLimits,
564
675
  requireRestartPolicy,
565
- 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
566
848
  ];
567
849
 
568
850
  //#endregion
@@ -577,6 +859,7 @@ const preferSlimBase = {
577
859
  if (!imagePart || isScratch(imagePart)) continue;
578
860
  const ref = parseImageRef(imagePart);
579
861
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
862
+ if (isHardenedImage(imagePart)) continue;
580
863
  if (ref.digest) continue;
581
864
  if (!ref.tag) continue;
582
865
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
@@ -763,7 +1046,8 @@ const noRootUser = {
763
1046
  let lastUserLine = 1;
764
1047
  for (const inst of instructions) if (inst.instruction === "FROM") {
765
1048
  const { base, stage } = parseFromArgs(inst.args);
766
- lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
1049
+ const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1050
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
767
1051
  lastUserLine = inst.line;
768
1052
  currentStage = stage?.toLowerCase() ?? null;
769
1053
  if (currentStage) stageUser.set(currentStage, lastUser);
@@ -784,21 +1068,13 @@ const noSecretsInEnv = {
784
1068
  category: "Security",
785
1069
  check(instructions, file) {
786
1070
  const diagnostics = [];
787
- const secretKeywords = [
788
- /(?:^|[_-])password(?:[_-]|$)/iu,
789
- /(?:^|[_-])secret(?:[_-]|$)/iu,
790
- /(?:^|[_-])token(?:[_-]|$)/iu,
791
- /(?:^|[_-])api_key(?:[_-]|$)/iu,
792
- /(?:^|[_-])private_key(?:[_-]|$)/iu,
793
- /(?:^|[_-])auth(?:[_-]|$)/iu
794
- ];
795
1071
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
796
1072
  const args = inst.args.trim();
797
1073
  if (inst.instruction === "ENV" && !args.includes("=")) {
798
1074
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
799
1075
  if (match?.groups) {
800
1076
  const { key, value } = match.groups;
801
- 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));
802
1078
  }
803
1079
  } else {
804
1080
  const parts = args.split(/\s+/u);
@@ -810,7 +1086,7 @@ const noSecretsInEnv = {
810
1086
  key = part.slice(0, eqIndex);
811
1087
  value = part.slice(eqIndex + 1);
812
1088
  } else key = part;
813
- 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));
814
1090
  }
815
1091
  }
816
1092
  }
@@ -831,8 +1107,11 @@ const pinImageVersion = {
831
1107
  if (!imagePart || isScratch(imagePart)) continue;
832
1108
  const ref = parseImageRef(imagePart);
833
1109
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
834
- 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));
835
- 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
+ }
836
1115
  }
837
1116
  return diagnostics;
838
1117
  },
@@ -872,7 +1151,11 @@ const allDockerfileRules = [
872
1151
  ...bestPracticesRules,
873
1152
  ...imageSizeRules
874
1153
  ];
875
- const allComposeRules = [...composeRules];
1154
+ const allComposeRules = [
1155
+ ...composeRules,
1156
+ ...composeSecurityRules,
1157
+ ...composeModelRules
1158
+ ];
876
1159
  const allRules = [...allDockerfileRules, ...allComposeRules];
877
1160
  const findRule = (key) => allRules.find((rule) => rule.key === key);
878
1161
 
@@ -1133,4 +1416,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1133
1416
 
1134
1417
  //#endregion
1135
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 };
1136
- //# sourceMappingURL=src-CvOv-hL3.mjs.map
1419
+ //# sourceMappingURL=src-CpGd43y0.mjs.map