@docker-doctor/cli 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,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.1";
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,11 @@ 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
+ const hasDeployLimits = Boolean(limits?.cpus || limits?.memory);
604
+ const hasServiceLevelLimits = Boolean(config.mem_limit || config.cpus || config.cpu_quota);
605
+ if (!(hasDeployLimits || hasServiceLevelLimits)) 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
606
  }
509
607
  return diagnostics;
510
608
  },
@@ -517,15 +615,10 @@ const requireRestartPolicy = {
517
615
  category: "Compose",
518
616
  check(composeContent, file, context) {
519
617
  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
- }
618
+ for (const [name, config] of composeServices(composeContent)) {
619
+ const hasRestart = "restart" in config;
620
+ const hasDeployRestart = config.deploy?.restart_policy !== void 0;
621
+ 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
622
  }
530
623
  return diagnostics;
531
624
  },
@@ -538,18 +631,13 @@ const useDependsOnCondition = {
538
631
  category: "Compose",
539
632
  check(composeContent, file, context) {
540
633
  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
- }
634
+ for (const [name, config] of composeServices(composeContent)) {
635
+ const dependsOn = config.depends_on;
636
+ 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?.([
637
+ "services",
638
+ name,
639
+ "depends_on"
640
+ ]) ?? context?.locate?.(["services", name])));
553
641
  }
554
642
  return diagnostics;
555
643
  },
@@ -558,11 +646,281 @@ const useDependsOnCondition = {
558
646
  key: "docker-doctor/use-depends-on-condition",
559
647
  message: "Use long-form depends_on with healthcheck conditions"
560
648
  };
649
+ const pinServiceImage = {
650
+ category: "Compose",
651
+ check(composeContent, file, context) {
652
+ const diagnostics = [];
653
+ for (const [name, config] of composeServices(composeContent)) {
654
+ const { image } = config;
655
+ if (typeof image !== "string" || "build" in config) continue;
656
+ const ref = parseImageRef(image);
657
+ if (ref.isVariable) continue;
658
+ const issue = mutableRefIssue(ref);
659
+ if (!issue) continue;
660
+ const detail = issue === "untagged" ? `Service '${name}' image '${image}' does not specify a tag.` : `Service '${name}' image '${image}' uses the mutable 'latest' tag.`;
661
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch a different image.`, this.help, context?.locate?.([
662
+ "services",
663
+ name,
664
+ "image"
665
+ ])));
666
+ }
667
+ return diagnostics;
668
+ },
669
+ defaultSeverity: "warning",
670
+ 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.",
671
+ key: "docker-doctor/pin-service-image",
672
+ message: "Pin service images to a specific tag or digest"
673
+ };
561
674
  const composeRules = [
562
675
  noVersionKey,
563
676
  requireResourceLimits,
564
677
  requireRestartPolicy,
565
- useDependsOnCondition
678
+ useDependsOnCondition,
679
+ pinServiceImage
680
+ ];
681
+
682
+ //#endregion
683
+ //#region ../core/src/rules/compose-models.ts
684
+ /**
685
+ * Narrows an unknown compose document to its top-level `models:` entries
686
+ * (Docker Model Runner, Compose ≥ 2.35).
687
+ */
688
+ const topLevelModels = (composeContent) => {
689
+ if (!composeContent || typeof composeContent !== "object" || !("models" in composeContent)) return {};
690
+ const { models } = composeContent;
691
+ if (!models || typeof models !== "object" || Array.isArray(models)) return {};
692
+ return models;
693
+ };
694
+ const undefinedModelReference = {
695
+ category: "Compose",
696
+ check(composeContent, file, context) {
697
+ const diagnostics = [];
698
+ const defined = new Set(Object.keys(topLevelModels(composeContent)));
699
+ const flag = (serviceName, modelName, pathTail) => {
700
+ 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?.([
701
+ "services",
702
+ serviceName,
703
+ "models",
704
+ pathTail
705
+ ])));
706
+ };
707
+ for (const [name, config] of composeServices(composeContent)) {
708
+ const { models } = config;
709
+ if (Array.isArray(models)) {
710
+ for (const [index, entry] of models.entries()) if (typeof entry === "string" && !defined.has(entry)) flag(name, entry, index);
711
+ } else if (models && typeof models === "object") {
712
+ for (const modelName of Object.keys(models)) if (!defined.has(modelName)) flag(name, modelName, modelName);
713
+ }
714
+ }
715
+ return diagnostics;
716
+ },
717
+ defaultSeverity: "error",
718
+ 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.",
719
+ key: "docker-doctor/undefined-model-reference",
720
+ message: "Service model references must be declared in top-level models"
721
+ };
722
+ const collectModelBindings = (composeContent) => {
723
+ const bindings = [];
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") bindings.push({
728
+ model,
729
+ path: [
730
+ "models",
731
+ name,
732
+ "model"
733
+ ],
734
+ subject: `Model '${name}' artifact`
735
+ });
736
+ }
737
+ for (const [name, config] of composeServices(composeContent)) {
738
+ const { provider } = config;
739
+ if (!provider || typeof provider !== "object") continue;
740
+ const { type, options } = provider;
741
+ if (type !== "model" || !options || typeof options !== "object") continue;
742
+ const { model } = options;
743
+ if (typeof model === "string") bindings.push({
744
+ model,
745
+ path: [
746
+ "services",
747
+ name,
748
+ "provider",
749
+ "options",
750
+ "model"
751
+ ],
752
+ subject: `Service '${name}' model provider`
753
+ });
754
+ }
755
+ return bindings;
756
+ };
757
+ const pinModelVersion = {
758
+ category: "Compose",
759
+ check(composeContent, file, context) {
760
+ const diagnostics = [];
761
+ for (const { subject, model, path } of collectModelBindings(composeContent)) {
762
+ const ref = parseImageRef(model);
763
+ if (ref.isVariable) continue;
764
+ const issue = mutableRefIssue(ref);
765
+ if (!issue) continue;
766
+ const detail = issue === "untagged" ? `${subject} '${model}' does not specify a tag.` : `${subject} '${model}' uses the mutable 'latest' tag.`;
767
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.(path)));
768
+ }
769
+ return diagnostics;
770
+ },
771
+ defaultSeverity: "warning",
772
+ 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.",
773
+ key: "docker-doctor/pin-model-version",
774
+ message: "Pin models to a specific tag or digest"
775
+ };
776
+ const composeModelRules = [undefinedModelReference, pinModelVersion];
777
+
778
+ //#endregion
779
+ //#region ../core/src/rules/secret-keywords.ts
780
+ const SECRET_KEY_PATTERNS = [
781
+ /password(?:[_-]|$)/iu,
782
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
783
+ /(?:^|[_-])token(?:[_-]|$)/iu,
784
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
785
+ /(?:^|[_-])apikey(?:[_-]|$)/iu,
786
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
787
+ /(?:^|[_-])auth(?:[_-]|$)/iu,
788
+ /(?:^|[_-])pat(?:[_-]|$)/iu
789
+ ];
790
+ const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
791
+ const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
792
+ const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
793
+
794
+ //#endregion
795
+ //#region ../core/src/rules/compose-security.ts
796
+ const noPrivilegedService = {
797
+ category: "Compose",
798
+ check(composeContent, file, context) {
799
+ const diagnostics = [];
800
+ 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?.([
801
+ "services",
802
+ name,
803
+ "privileged"
804
+ ])));
805
+ return diagnostics;
806
+ },
807
+ defaultSeverity: "error",
808
+ help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
809
+ key: "docker-doctor/no-privileged-service",
810
+ message: "Do not run services in privileged mode"
811
+ };
812
+ const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
813
+ const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
814
+ const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
815
+ const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
816
+ const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
817
+ const isDockerSocketPath = (rawPath) => {
818
+ const normalized = rawPath.replaceAll("\\", "/");
819
+ return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
820
+ };
821
+ const splitShortSyntax = (spec) => {
822
+ const parts = [];
823
+ let depth = 0;
824
+ let current = "";
825
+ for (const char of spec) {
826
+ if (char === "{") depth += 1;
827
+ else if (char === "}") depth = Math.max(0, depth - 1);
828
+ if (char === ":" && depth === 0) {
829
+ parts.push(current);
830
+ current = "";
831
+ continue;
832
+ }
833
+ current += char;
834
+ }
835
+ parts.push(current);
836
+ return parts;
837
+ };
838
+ const volumeMount = (volume) => {
839
+ if (typeof volume === "string") {
840
+ const [source, target] = splitShortSyntax(volume);
841
+ return target === void 0 ? void 0 : {
842
+ source,
843
+ target
844
+ };
845
+ }
846
+ if (volume && typeof volume === "object") {
847
+ const { source, target } = volume;
848
+ return {
849
+ source: typeof source === "string" ? source : void 0,
850
+ target: typeof target === "string" ? target : void 0
851
+ };
852
+ }
853
+ };
854
+ const mountsDockerSocket = (volume) => {
855
+ const mount = volumeMount(volume);
856
+ if (!mount?.source) return false;
857
+ const resolvedSource = resolveInterpolationDefaults(mount.source);
858
+ if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
859
+ return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
860
+ };
861
+ const noDockerSocketMount = {
862
+ category: "Compose",
863
+ check(composeContent, file, context) {
864
+ const diagnostics = [];
865
+ for (const [name, config] of composeServices(composeContent)) {
866
+ const { volumes } = config;
867
+ if (!Array.isArray(volumes)) continue;
868
+ 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?.([
869
+ "services",
870
+ name,
871
+ "volumes",
872
+ index
873
+ ])));
874
+ }
875
+ return diagnostics;
876
+ },
877
+ defaultSeverity: "error",
878
+ 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`.",
879
+ key: "docker-doctor/no-docker-socket-mount",
880
+ message: "Do not bind-mount the Docker socket into services"
881
+ };
882
+ const noPlaintextSecrets = {
883
+ category: "Compose",
884
+ check(composeContent, file, context) {
885
+ const diagnostics = [];
886
+ const flag = (name, key, line) => {
887
+ 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));
888
+ };
889
+ for (const [name, config] of composeServices(composeContent)) {
890
+ const { environment } = config;
891
+ if (Array.isArray(environment)) for (const [index, entry] of environment.entries()) {
892
+ if (typeof entry !== "string") continue;
893
+ const eqIndex = entry.indexOf("=");
894
+ if (eqIndex <= 0) continue;
895
+ const key = entry.slice(0, eqIndex);
896
+ const value = entry.slice(eqIndex + 1);
897
+ if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
898
+ "services",
899
+ name,
900
+ "environment",
901
+ index
902
+ ]));
903
+ }
904
+ else if (environment && typeof environment === "object") {
905
+ for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
906
+ "services",
907
+ name,
908
+ "environment",
909
+ key
910
+ ]));
911
+ }
912
+ }
913
+ return diagnostics;
914
+ },
915
+ defaultSeverity: "warning",
916
+ help: "Move the value to an `env_file` kept out of version control, interpolate it from the host environment (`${VAR}`), or use Compose `secrets:`.",
917
+ key: "docker-doctor/no-plaintext-secrets",
918
+ message: "Avoid literal secret values in Compose environment"
919
+ };
920
+ const composeSecurityRules = [
921
+ noPrivilegedService,
922
+ noDockerSocketMount,
923
+ noPlaintextSecrets
566
924
  ];
567
925
 
568
926
  //#endregion
@@ -577,6 +935,7 @@ const preferSlimBase = {
577
935
  if (!imagePart || isScratch(imagePart)) continue;
578
936
  const ref = parseImageRef(imagePart);
579
937
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
938
+ if (isHardenedImage(imagePart)) continue;
580
939
  if (ref.digest) continue;
581
940
  if (!ref.tag) continue;
582
941
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
@@ -763,7 +1122,8 @@ const noRootUser = {
763
1122
  let lastUserLine = 1;
764
1123
  for (const inst of instructions) if (inst.instruction === "FROM") {
765
1124
  const { base, stage } = parseFromArgs(inst.args);
766
- lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
1125
+ const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1126
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
767
1127
  lastUserLine = inst.line;
768
1128
  currentStage = stage?.toLowerCase() ?? null;
769
1129
  if (currentStage) stageUser.set(currentStage, lastUser);
@@ -784,21 +1144,13 @@ const noSecretsInEnv = {
784
1144
  category: "Security",
785
1145
  check(instructions, file) {
786
1146
  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
1147
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
796
1148
  const args = inst.args.trim();
797
1149
  if (inst.instruction === "ENV" && !args.includes("=")) {
798
1150
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
799
1151
  if (match?.groups) {
800
1152
  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));
1153
+ if (isSecretKey(key) && isLiteralSecretValue(value) && !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
1154
  }
803
1155
  } else {
804
1156
  const parts = args.split(/\s+/u);
@@ -810,7 +1162,7 @@ const noSecretsInEnv = {
810
1162
  key = part.slice(0, eqIndex);
811
1163
  value = part.slice(eqIndex + 1);
812
1164
  } 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));
1165
+ if (isSecretKey(key) && isLiteralSecretValue(value) && !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
1166
  }
815
1167
  }
816
1168
  }
@@ -831,8 +1183,11 @@ const pinImageVersion = {
831
1183
  if (!imagePart || isScratch(imagePart)) continue;
832
1184
  const ref = parseImageRef(imagePart);
833
1185
  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));
1186
+ const issue = mutableRefIssue(ref);
1187
+ if (issue) {
1188
+ const detail = issue === "untagged" ? `Base image '${imagePart}' does not specify a tag.` : `Base image '${imagePart}' uses the mutable 'latest' tag.`;
1189
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} This makes builds non-deterministic.`, this.help, inst.line));
1190
+ }
836
1191
  }
837
1192
  return diagnostics;
838
1193
  },
@@ -872,7 +1227,11 @@ const allDockerfileRules = [
872
1227
  ...bestPracticesRules,
873
1228
  ...imageSizeRules
874
1229
  ];
875
- const allComposeRules = [...composeRules];
1230
+ const allComposeRules = [
1231
+ ...composeRules,
1232
+ ...composeSecurityRules,
1233
+ ...composeModelRules
1234
+ ];
876
1235
  const allRules = [...allDockerfileRules, ...allComposeRules];
877
1236
  const findRule = (key) => allRules.find((rule) => rule.key === key);
878
1237
 
@@ -1133,4 +1492,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1133
1492
 
1134
1493
  //#endregion
1135
1494
  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
1495
+ //# sourceMappingURL=src-NqSwkGiS.mjs.map