@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/README.md +1 -1
- package/dist/cli.cjs +4 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +4 -5
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-DwuAaQcq.cjs → src-DYLANvt2.cjs} +409 -50
- package/dist/src-DYLANvt2.cjs.map +1 -0
- package/dist/{src-CvOv-hL3.mjs → src-NqSwkGiS.mjs} +409 -50
- package/dist/src-NqSwkGiS.mjs.map +1 -0
- package/package.json +1 -1
- package/skill/docker-doctor/SKILL.md +1 -1
- package/skill/docker-doctor/references/explain.md +2 -2
- package/dist/src-CvOv-hL3.mjs.map +0 -1
- package/dist/src-DwuAaQcq.cjs.map +0 -1
|
@@ -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.
|
|
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(
|
|
30
|
-
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(
|
|
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(
|
|
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
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
542
|
-
const
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
835
|
-
|
|
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 = [
|
|
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-
|
|
1495
|
+
//# sourceMappingURL=src-NqSwkGiS.mjs.map
|