@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.
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-NqSwkGiS.mjs";
3
3
 
4
4
  //#region ../core/src/config/define-config.ts
5
5
  const defineConfig = (config) => config;
@@ -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.1";
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,11 @@ 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
+ const hasDeployLimits = Boolean(limits?.cpus || limits?.memory);
633
+ const hasServiceLevelLimits = Boolean(config.mem_limit || config.cpus || config.cpu_quota);
634
+ 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])));
537
635
  }
538
636
  return diagnostics;
539
637
  },
@@ -546,15 +644,10 @@ const requireRestartPolicy = {
546
644
  category: "Compose",
547
645
  check(composeContent, file, context) {
548
646
  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
- }
647
+ for (const [name, config] of composeServices(composeContent)) {
648
+ const hasRestart = "restart" in config;
649
+ const hasDeployRestart = config.deploy?.restart_policy !== void 0;
650
+ 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
651
  }
559
652
  return diagnostics;
560
653
  },
@@ -567,18 +660,13 @@ const useDependsOnCondition = {
567
660
  category: "Compose",
568
661
  check(composeContent, file, context) {
569
662
  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
- }
663
+ for (const [name, config] of composeServices(composeContent)) {
664
+ const dependsOn = config.depends_on;
665
+ 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?.([
666
+ "services",
667
+ name,
668
+ "depends_on"
669
+ ]) ?? context?.locate?.(["services", name])));
582
670
  }
583
671
  return diagnostics;
584
672
  },
@@ -587,11 +675,281 @@ const useDependsOnCondition = {
587
675
  key: "docker-doctor/use-depends-on-condition",
588
676
  message: "Use long-form depends_on with healthcheck conditions"
589
677
  };
678
+ const pinServiceImage = {
679
+ category: "Compose",
680
+ check(composeContent, file, context) {
681
+ const diagnostics = [];
682
+ for (const [name, config] of composeServices(composeContent)) {
683
+ const { image } = config;
684
+ if (typeof image !== "string" || "build" in config) continue;
685
+ const ref = parseImageRef(image);
686
+ if (ref.isVariable) continue;
687
+ const issue = mutableRefIssue(ref);
688
+ if (!issue) continue;
689
+ const detail = issue === "untagged" ? `Service '${name}' image '${image}' does not specify a tag.` : `Service '${name}' image '${image}' uses the mutable 'latest' tag.`;
690
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch a different image.`, this.help, context?.locate?.([
691
+ "services",
692
+ name,
693
+ "image"
694
+ ])));
695
+ }
696
+ return diagnostics;
697
+ },
698
+ defaultSeverity: "warning",
699
+ 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.",
700
+ key: "docker-doctor/pin-service-image",
701
+ message: "Pin service images to a specific tag or digest"
702
+ };
590
703
  const composeRules = [
591
704
  noVersionKey,
592
705
  requireResourceLimits,
593
706
  requireRestartPolicy,
594
- useDependsOnCondition
707
+ useDependsOnCondition,
708
+ pinServiceImage
709
+ ];
710
+
711
+ //#endregion
712
+ //#region ../core/src/rules/compose-models.ts
713
+ /**
714
+ * Narrows an unknown compose document to its top-level `models:` entries
715
+ * (Docker Model Runner, Compose ≥ 2.35).
716
+ */
717
+ const topLevelModels = (composeContent) => {
718
+ if (!composeContent || typeof composeContent !== "object" || !("models" in composeContent)) return {};
719
+ const { models } = composeContent;
720
+ if (!models || typeof models !== "object" || Array.isArray(models)) return {};
721
+ return models;
722
+ };
723
+ const undefinedModelReference = {
724
+ category: "Compose",
725
+ check(composeContent, file, context) {
726
+ const diagnostics = [];
727
+ const defined = new Set(Object.keys(topLevelModels(composeContent)));
728
+ const flag = (serviceName, modelName, pathTail) => {
729
+ 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?.([
730
+ "services",
731
+ serviceName,
732
+ "models",
733
+ pathTail
734
+ ])));
735
+ };
736
+ for (const [name, config] of composeServices(composeContent)) {
737
+ const { models } = config;
738
+ if (Array.isArray(models)) {
739
+ for (const [index, entry] of models.entries()) if (typeof entry === "string" && !defined.has(entry)) flag(name, entry, index);
740
+ } else if (models && typeof models === "object") {
741
+ for (const modelName of Object.keys(models)) if (!defined.has(modelName)) flag(name, modelName, modelName);
742
+ }
743
+ }
744
+ return diagnostics;
745
+ },
746
+ defaultSeverity: "error",
747
+ 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.",
748
+ key: "docker-doctor/undefined-model-reference",
749
+ message: "Service model references must be declared in top-level models"
750
+ };
751
+ const collectModelBindings = (composeContent) => {
752
+ const bindings = [];
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") bindings.push({
757
+ model,
758
+ path: [
759
+ "models",
760
+ name,
761
+ "model"
762
+ ],
763
+ subject: `Model '${name}' artifact`
764
+ });
765
+ }
766
+ for (const [name, config] of composeServices(composeContent)) {
767
+ const { provider } = config;
768
+ if (!provider || typeof provider !== "object") continue;
769
+ const { type, options } = provider;
770
+ if (type !== "model" || !options || typeof options !== "object") continue;
771
+ const { model } = options;
772
+ if (typeof model === "string") bindings.push({
773
+ model,
774
+ path: [
775
+ "services",
776
+ name,
777
+ "provider",
778
+ "options",
779
+ "model"
780
+ ],
781
+ subject: `Service '${name}' model provider`
782
+ });
783
+ }
784
+ return bindings;
785
+ };
786
+ const pinModelVersion = {
787
+ category: "Compose",
788
+ check(composeContent, file, context) {
789
+ const diagnostics = [];
790
+ for (const { subject, model, path } of collectModelBindings(composeContent)) {
791
+ const ref = parseImageRef(model);
792
+ if (ref.isVariable) continue;
793
+ const issue = mutableRefIssue(ref);
794
+ if (!issue) continue;
795
+ const detail = issue === "untagged" ? `${subject} '${model}' does not specify a tag.` : `${subject} '${model}' uses the mutable 'latest' tag.`;
796
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.(path)));
797
+ }
798
+ return diagnostics;
799
+ },
800
+ defaultSeverity: "warning",
801
+ 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.",
802
+ key: "docker-doctor/pin-model-version",
803
+ message: "Pin models to a specific tag or digest"
804
+ };
805
+ const composeModelRules = [undefinedModelReference, pinModelVersion];
806
+
807
+ //#endregion
808
+ //#region ../core/src/rules/secret-keywords.ts
809
+ const SECRET_KEY_PATTERNS = [
810
+ /password(?:[_-]|$)/iu,
811
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
812
+ /(?:^|[_-])token(?:[_-]|$)/iu,
813
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
814
+ /(?:^|[_-])apikey(?:[_-]|$)/iu,
815
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
816
+ /(?:^|[_-])auth(?:[_-]|$)/iu,
817
+ /(?:^|[_-])pat(?:[_-]|$)/iu
818
+ ];
819
+ const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
820
+ const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
821
+ const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
822
+
823
+ //#endregion
824
+ //#region ../core/src/rules/compose-security.ts
825
+ const noPrivilegedService = {
826
+ category: "Compose",
827
+ check(composeContent, file, context) {
828
+ const diagnostics = [];
829
+ 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?.([
830
+ "services",
831
+ name,
832
+ "privileged"
833
+ ])));
834
+ return diagnostics;
835
+ },
836
+ defaultSeverity: "error",
837
+ help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
838
+ key: "docker-doctor/no-privileged-service",
839
+ message: "Do not run services in privileged mode"
840
+ };
841
+ const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
842
+ const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
843
+ const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
844
+ const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
845
+ const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
846
+ const isDockerSocketPath = (rawPath) => {
847
+ const normalized = rawPath.replaceAll("\\", "/");
848
+ return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
849
+ };
850
+ const splitShortSyntax = (spec) => {
851
+ const parts = [];
852
+ let depth = 0;
853
+ let current = "";
854
+ for (const char of spec) {
855
+ if (char === "{") depth += 1;
856
+ else if (char === "}") depth = Math.max(0, depth - 1);
857
+ if (char === ":" && depth === 0) {
858
+ parts.push(current);
859
+ current = "";
860
+ continue;
861
+ }
862
+ current += char;
863
+ }
864
+ parts.push(current);
865
+ return parts;
866
+ };
867
+ const volumeMount = (volume) => {
868
+ if (typeof volume === "string") {
869
+ const [source, target] = splitShortSyntax(volume);
870
+ return target === void 0 ? void 0 : {
871
+ source,
872
+ target
873
+ };
874
+ }
875
+ if (volume && typeof volume === "object") {
876
+ const { source, target } = volume;
877
+ return {
878
+ source: typeof source === "string" ? source : void 0,
879
+ target: typeof target === "string" ? target : void 0
880
+ };
881
+ }
882
+ };
883
+ const mountsDockerSocket = (volume) => {
884
+ const mount = volumeMount(volume);
885
+ if (!mount?.source) return false;
886
+ const resolvedSource = resolveInterpolationDefaults(mount.source);
887
+ if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
888
+ return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
889
+ };
890
+ const noDockerSocketMount = {
891
+ category: "Compose",
892
+ check(composeContent, file, context) {
893
+ const diagnostics = [];
894
+ for (const [name, config] of composeServices(composeContent)) {
895
+ const { volumes } = config;
896
+ if (!Array.isArray(volumes)) continue;
897
+ 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?.([
898
+ "services",
899
+ name,
900
+ "volumes",
901
+ index
902
+ ])));
903
+ }
904
+ return diagnostics;
905
+ },
906
+ defaultSeverity: "error",
907
+ 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`.",
908
+ key: "docker-doctor/no-docker-socket-mount",
909
+ message: "Do not bind-mount the Docker socket into services"
910
+ };
911
+ const noPlaintextSecrets = {
912
+ category: "Compose",
913
+ check(composeContent, file, context) {
914
+ const diagnostics = [];
915
+ const flag = (name, key, line) => {
916
+ 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));
917
+ };
918
+ for (const [name, config] of composeServices(composeContent)) {
919
+ const { environment } = config;
920
+ if (Array.isArray(environment)) for (const [index, entry] of environment.entries()) {
921
+ if (typeof entry !== "string") continue;
922
+ const eqIndex = entry.indexOf("=");
923
+ if (eqIndex <= 0) continue;
924
+ const key = entry.slice(0, eqIndex);
925
+ const value = entry.slice(eqIndex + 1);
926
+ if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
927
+ "services",
928
+ name,
929
+ "environment",
930
+ index
931
+ ]));
932
+ }
933
+ else if (environment && typeof environment === "object") {
934
+ for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
935
+ "services",
936
+ name,
937
+ "environment",
938
+ key
939
+ ]));
940
+ }
941
+ }
942
+ return diagnostics;
943
+ },
944
+ defaultSeverity: "warning",
945
+ help: "Move the value to an `env_file` kept out of version control, interpolate it from the host environment (`${VAR}`), or use Compose `secrets:`.",
946
+ key: "docker-doctor/no-plaintext-secrets",
947
+ message: "Avoid literal secret values in Compose environment"
948
+ };
949
+ const composeSecurityRules = [
950
+ noPrivilegedService,
951
+ noDockerSocketMount,
952
+ noPlaintextSecrets
595
953
  ];
596
954
 
597
955
  //#endregion
@@ -606,6 +964,7 @@ const preferSlimBase = {
606
964
  if (!imagePart || isScratch(imagePart)) continue;
607
965
  const ref = parseImageRef(imagePart);
608
966
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
967
+ if (isHardenedImage(imagePart)) continue;
609
968
  if (ref.digest) continue;
610
969
  if (!ref.tag) continue;
611
970
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
@@ -792,7 +1151,8 @@ const noRootUser = {
792
1151
  let lastUserLine = 1;
793
1152
  for (const inst of instructions) if (inst.instruction === "FROM") {
794
1153
  const { base, stage } = parseFromArgs(inst.args);
795
- lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
1154
+ const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
1155
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
796
1156
  lastUserLine = inst.line;
797
1157
  currentStage = stage?.toLowerCase() ?? null;
798
1158
  if (currentStage) stageUser.set(currentStage, lastUser);
@@ -813,21 +1173,13 @@ const noSecretsInEnv = {
813
1173
  category: "Security",
814
1174
  check(instructions, file) {
815
1175
  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
1176
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
825
1177
  const args = inst.args.trim();
826
1178
  if (inst.instruction === "ENV" && !args.includes("=")) {
827
1179
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
828
1180
  if (match?.groups) {
829
1181
  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));
1182
+ 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));
831
1183
  }
832
1184
  } else {
833
1185
  const parts = args.split(/\s+/u);
@@ -839,7 +1191,7 @@ const noSecretsInEnv = {
839
1191
  key = part.slice(0, eqIndex);
840
1192
  value = part.slice(eqIndex + 1);
841
1193
  } 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));
1194
+ 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));
843
1195
  }
844
1196
  }
845
1197
  }
@@ -860,8 +1212,11 @@ const pinImageVersion = {
860
1212
  if (!imagePart || isScratch(imagePart)) continue;
861
1213
  const ref = parseImageRef(imagePart);
862
1214
  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));
1215
+ const issue = mutableRefIssue(ref);
1216
+ if (issue) {
1217
+ const detail = issue === "untagged" ? `Base image '${imagePart}' does not specify a tag.` : `Base image '${imagePart}' uses the mutable 'latest' tag.`;
1218
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} This makes builds non-deterministic.`, this.help, inst.line));
1219
+ }
865
1220
  }
866
1221
  return diagnostics;
867
1222
  },
@@ -901,7 +1256,11 @@ const allDockerfileRules = [
901
1256
  ...bestPracticesRules,
902
1257
  ...imageSizeRules
903
1258
  ];
904
- const allComposeRules = [...composeRules];
1259
+ const allComposeRules = [
1260
+ ...composeRules,
1261
+ ...composeSecurityRules,
1262
+ ...composeModelRules
1263
+ ];
905
1264
  const allRules = [...allDockerfileRules, ...allComposeRules];
906
1265
  const findRule = (key) => allRules.find((rule) => rule.key === key);
907
1266
 
@@ -1239,4 +1598,4 @@ Object.defineProperty(exports, 'version', {
1239
1598
  return version;
1240
1599
  }
1241
1600
  });
1242
- //# sourceMappingURL=src-DwuAaQcq.cjs.map
1601
+ //# sourceMappingURL=src-DYLANvt2.cjs.map