@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/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-CvOv-hL3.mjs → src-CpGd43y0.mjs} +333 -50
- package/dist/src-CpGd43y0.mjs.map +1 -0
- package/dist/{src-DwuAaQcq.cjs → src-F90_EKmA.cjs} +333 -50
- package/dist/src-F90_EKmA.cjs.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
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
|
|
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-
|
|
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.
|
|
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(
|
|
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,9 @@ const requireResourceLimits = {
|
|
|
497
598
|
category: "Compose",
|
|
498
599
|
check(composeContent, file, context) {
|
|
499
600
|
const diagnostics = [];
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
if (
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
542
|
-
const
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
835
|
-
|
|
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 = [
|
|
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-
|
|
1419
|
+
//# sourceMappingURL=src-CpGd43y0.mjs.map
|