@intentius/chant-lexicon-docker 0.1.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 +24 -0
- package/dist/integrity.json +19 -0
- package/dist/manifest.json +15 -0
- package/dist/meta.json +222 -0
- package/dist/rules/apt-no-recommends.ts +43 -0
- package/dist/rules/docker-helpers.ts +114 -0
- package/dist/rules/no-latest-image.ts +36 -0
- package/dist/rules/no-latest-tag.ts +63 -0
- package/dist/rules/no-root-user.ts +36 -0
- package/dist/rules/prefer-copy.ts +53 -0
- package/dist/rules/ssh-port-exposed.ts +68 -0
- package/dist/rules/unused-volume.ts +49 -0
- package/dist/skills/chant-docker-patterns.md +153 -0
- package/dist/skills/chant-docker.md +129 -0
- package/dist/types/index.d.ts +93 -0
- package/package.json +53 -0
- package/src/codegen/docs-cli.ts +10 -0
- package/src/codegen/docs.ts +12 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-compose.ts +21 -0
- package/src/codegen/generate-dockerfile.ts +21 -0
- package/src/codegen/generate.test.ts +105 -0
- package/src/codegen/generate.ts +158 -0
- package/src/codegen/naming.test.ts +81 -0
- package/src/codegen/naming.ts +54 -0
- package/src/codegen/package.ts +65 -0
- package/src/codegen/patches.ts +42 -0
- package/src/codegen/versions.ts +15 -0
- package/src/composites/index.ts +12 -0
- package/src/coverage.test.ts +33 -0
- package/src/coverage.ts +54 -0
- package/src/default-labels.test.ts +85 -0
- package/src/default-labels.ts +72 -0
- package/src/generated/index.d.ts +93 -0
- package/src/generated/index.ts +10 -0
- package/src/generated/lexicon-docker.json +222 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +133 -0
- package/src/import/generator.ts +127 -0
- package/src/import/parser.test.ts +137 -0
- package/src/import/parser.ts +190 -0
- package/src/import/roundtrip.test.ts +49 -0
- package/src/import/testdata/full.yaml +43 -0
- package/src/import/testdata/simple.yaml +9 -0
- package/src/import/testdata/webapp.yaml +41 -0
- package/src/index.ts +29 -0
- package/src/interpolation.test.ts +41 -0
- package/src/interpolation.ts +76 -0
- package/src/lint/post-synth/apt-no-recommends.ts +43 -0
- package/src/lint/post-synth/docker-helpers.ts +114 -0
- package/src/lint/post-synth/no-latest-image.ts +36 -0
- package/src/lint/post-synth/no-root-user.ts +36 -0
- package/src/lint/post-synth/post-synth.test.ts +181 -0
- package/src/lint/post-synth/prefer-copy.ts +53 -0
- package/src/lint/post-synth/ssh-port-exposed.ts +68 -0
- package/src/lint/post-synth/unused-volume.ts +49 -0
- package/src/lint/rules/data/deprecated-images.ts +28 -0
- package/src/lint/rules/data/known-base-images.ts +20 -0
- package/src/lint/rules/index.ts +5 -0
- package/src/lint/rules/no-latest-tag.ts +63 -0
- package/src/lint/rules/rules.test.ts +82 -0
- package/src/lsp/completions.test.ts +34 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +34 -0
- package/src/lsp/hover.ts +38 -0
- package/src/package-cli.ts +42 -0
- package/src/plugin.test.ts +117 -0
- package/src/plugin.ts +250 -0
- package/src/serializer.test.ts +294 -0
- package/src/serializer.ts +322 -0
- package/src/skills/chant-docker-patterns.md +153 -0
- package/src/skills/chant-docker.md +129 -0
- package/src/spec/fetch-compose.ts +35 -0
- package/src/spec/fetch-engine.ts +25 -0
- package/src/spec/parse-compose.ts +110 -0
- package/src/spec/parse-engine.ts +47 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +16 -0
- package/src/validate.ts +44 -0
- package/src/variables.test.ts +32 -0
- package/src/variables.ts +47 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Compose variable interpolation intrinsics.
|
|
3
|
+
*
|
|
4
|
+
* Docker Compose supports:
|
|
5
|
+
* ${VAR} — required variable (fails if unset)
|
|
6
|
+
* ${VAR:-default} — use default if VAR is unset or empty
|
|
7
|
+
* ${VAR:?error} — fail with error message if VAR is unset or empty
|
|
8
|
+
* ${VAR:+value} — use value if VAR is set
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { env } from "@intentius/chant-lexicon-docker";
|
|
12
|
+
*
|
|
13
|
+
* export const api = new Service({
|
|
14
|
+
* image: env("APP_IMAGE", { default: "myapp:latest" }),
|
|
15
|
+
* environment: {
|
|
16
|
+
* DB_URL: env("DB_URL", { required: true }),
|
|
17
|
+
* },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Serializes to:
|
|
21
|
+
* // services:
|
|
22
|
+
* // api:
|
|
23
|
+
* // image: ${APP_IMAGE:-myapp:latest}
|
|
24
|
+
* // environment:
|
|
25
|
+
* // DB_URL: ${DB_URL:?DB_URL is required}
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
29
|
+
|
|
30
|
+
export interface EnvOptions {
|
|
31
|
+
/** Default value if VAR is unset or empty: ${VAR:-default} */
|
|
32
|
+
default?: string;
|
|
33
|
+
/** If true and no default, emit ${VAR:?VAR is required}: fail on missing */
|
|
34
|
+
required?: boolean;
|
|
35
|
+
/** Error message for required variables: ${VAR:?errorMessage} */
|
|
36
|
+
errorMessage?: string;
|
|
37
|
+
/** Alternate value when VAR is set: ${VAR:+value} */
|
|
38
|
+
ifSet?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EnvIntrinsic {
|
|
42
|
+
readonly [INTRINSIC_MARKER]: true;
|
|
43
|
+
/** Emit the Docker Compose interpolation string */
|
|
44
|
+
toJSON(): string;
|
|
45
|
+
toString(): string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a Docker Compose variable interpolation intrinsic.
|
|
50
|
+
*
|
|
51
|
+
* @param name - Environment variable name
|
|
52
|
+
* @param opts - Interpolation options
|
|
53
|
+
*/
|
|
54
|
+
export function env(name: string, opts: EnvOptions = {}): EnvIntrinsic {
|
|
55
|
+
function interpolationString(): string {
|
|
56
|
+
if (opts.ifSet !== undefined) {
|
|
57
|
+
return `\${${name}:+${opts.ifSet}}`;
|
|
58
|
+
}
|
|
59
|
+
if (opts.default !== undefined) {
|
|
60
|
+
return `\${${name}:-${opts.default}}`;
|
|
61
|
+
}
|
|
62
|
+
if (opts.required || opts.errorMessage) {
|
|
63
|
+
const msg = opts.errorMessage ?? `${name} is required`;
|
|
64
|
+
return `\${${name}:?${msg}}`;
|
|
65
|
+
}
|
|
66
|
+
return `\${${name}}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const value = interpolationString();
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
[INTRINSIC_MARKER]: true,
|
|
73
|
+
toJSON: () => value,
|
|
74
|
+
toString: () => value,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD010: apt-get install without --no-install-recommends
|
|
3
|
+
*
|
|
4
|
+
* Detects RUN instructions with apt-get install missing
|
|
5
|
+
* --no-install-recommends, which leads to bloated images.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { extractDockerfiles } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
export const dkrd010: PostSynthCheck = {
|
|
12
|
+
id: "DKRD010",
|
|
13
|
+
description: "apt-get install without --no-install-recommends adds unnecessary packages",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [_outputName, output] of ctx.outputs) {
|
|
19
|
+
const dockerfiles = extractDockerfiles(output);
|
|
20
|
+
|
|
21
|
+
for (const [fileName, content] of dockerfiles) {
|
|
22
|
+
const lines = content.split("\n");
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i].trim();
|
|
25
|
+
if (
|
|
26
|
+
/^RUN\s+/.test(line) &&
|
|
27
|
+
line.includes("apt-get install") &&
|
|
28
|
+
!line.includes("--no-install-recommends")
|
|
29
|
+
) {
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
checkId: "DKRD010",
|
|
32
|
+
severity: "warning",
|
|
33
|
+
message: `${fileName}: RUN apt-get install should use --no-install-recommends to reduce image size.`,
|
|
34
|
+
lexicon: "docker",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared YAML traversal utilities for Docker post-synth checks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
6
|
+
|
|
7
|
+
export interface ParsedService {
|
|
8
|
+
name: string;
|
|
9
|
+
image?: string;
|
|
10
|
+
ports?: string[];
|
|
11
|
+
volumes?: string[];
|
|
12
|
+
build?: { dockerfile?: string; context?: string };
|
|
13
|
+
depends_on?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract services section from serialized docker-compose.yml.
|
|
18
|
+
*/
|
|
19
|
+
export function extractServices(yaml: string): Map<string, ParsedService> {
|
|
20
|
+
const services = new Map<string, ParsedService>();
|
|
21
|
+
|
|
22
|
+
const servicesIdx = yaml.search(/^services:\s*$/m);
|
|
23
|
+
if (servicesIdx === -1) return services;
|
|
24
|
+
|
|
25
|
+
const afterServices = yaml.slice(servicesIdx + yaml.slice(servicesIdx).indexOf("\n") + 1);
|
|
26
|
+
// Stop at next top-level key
|
|
27
|
+
const endMatch = afterServices.search(/^[a-z]/m);
|
|
28
|
+
const servicesContent = endMatch === -1 ? afterServices : afterServices.slice(0, endMatch);
|
|
29
|
+
|
|
30
|
+
// Split by service entries (2-space indent + name:)
|
|
31
|
+
const sections = servicesContent.split(/\n(?= [a-z][a-z0-9_-]*:)/);
|
|
32
|
+
|
|
33
|
+
for (const section of sections) {
|
|
34
|
+
const nameMatch = section.match(/^\s{2}([a-z][a-z0-9_-]*):/);
|
|
35
|
+
if (!nameMatch) continue;
|
|
36
|
+
|
|
37
|
+
const name = nameMatch[1];
|
|
38
|
+
const svc: ParsedService = { name };
|
|
39
|
+
|
|
40
|
+
const imageMatch = section.match(/^\s{4}image:\s+(.+)$/m);
|
|
41
|
+
if (imageMatch) svc.image = imageMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
|
42
|
+
|
|
43
|
+
const portsMatch = section.match(/^\s{4}ports:\n((?:\s{6}- .+\n?)+)/m);
|
|
44
|
+
if (portsMatch) {
|
|
45
|
+
svc.ports = [];
|
|
46
|
+
for (const line of portsMatch[1].split("\n")) {
|
|
47
|
+
const item = line.match(/^\s{6}-\s+['"]?(.+?)['"]?$/);
|
|
48
|
+
if (item) svc.ports.push(item[1].trim());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const volsMatch = section.match(/^\s{4}volumes:\n((?:\s{6}- .+\n?)+)/m);
|
|
53
|
+
if (volsMatch) {
|
|
54
|
+
svc.volumes = [];
|
|
55
|
+
for (const line of volsMatch[1].split("\n")) {
|
|
56
|
+
const item = line.match(/^\s{6}-\s+['"]?(.+?)['"]?$/);
|
|
57
|
+
if (item) svc.volumes.push(item[1].trim());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
services.set(name, svc);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return services;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract named volumes from the top-level volumes: section.
|
|
69
|
+
*/
|
|
70
|
+
export function extractNamedVolumes(yaml: string): Set<string> {
|
|
71
|
+
const volumes = new Set<string>();
|
|
72
|
+
|
|
73
|
+
const volumesIdx = yaml.search(/^volumes:\s*$/m);
|
|
74
|
+
if (volumesIdx === -1) return volumes;
|
|
75
|
+
|
|
76
|
+
const afterVolumes = yaml.slice(volumesIdx + yaml.slice(volumesIdx).indexOf("\n") + 1);
|
|
77
|
+
const endMatch = afterVolumes.search(/^[a-z]/m);
|
|
78
|
+
const volumesContent = endMatch === -1 ? afterVolumes : afterVolumes.slice(0, endMatch);
|
|
79
|
+
|
|
80
|
+
for (const line of volumesContent.split("\n")) {
|
|
81
|
+
const match = line.match(/^\s{2}([a-z][a-z0-9_-]*):/);
|
|
82
|
+
if (match) volumes.add(match[1]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return volumes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if an image tag represents :latest or is untagged.
|
|
90
|
+
*/
|
|
91
|
+
export function isLatestOrUntagged(image: string): boolean {
|
|
92
|
+
if (!image || image.startsWith("${")) return false;
|
|
93
|
+
if (image.endsWith(":latest")) return true;
|
|
94
|
+
const parts = image.split("/");
|
|
95
|
+
const lastPart = parts[parts.length - 1];
|
|
96
|
+
return !lastPart.includes(":") && !lastPart.includes("@");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get Dockerfile content from SerializerResult files map.
|
|
101
|
+
*/
|
|
102
|
+
export function extractDockerfiles(output: unknown): Map<string, string> {
|
|
103
|
+
const files = new Map<string, string>();
|
|
104
|
+
if (typeof output !== "object" || output === null) return files;
|
|
105
|
+
if (!("files" in output)) return files;
|
|
106
|
+
|
|
107
|
+
const outputFiles = (output as { files: Record<string, string> }).files;
|
|
108
|
+
for (const [name, content] of Object.entries(outputFiles)) {
|
|
109
|
+
if (name.startsWith("Dockerfile")) {
|
|
110
|
+
files.set(name, content);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return files;
|
|
114
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD001: No Latest Image Tag
|
|
3
|
+
*
|
|
4
|
+
* Detects services using :latest or untagged images in docker-compose.yml.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { getPrimaryOutput, extractServices, isLatestOrUntagged } from "./docker-helpers";
|
|
9
|
+
|
|
10
|
+
export const dkrd001: PostSynthCheck = {
|
|
11
|
+
id: "DKRD001",
|
|
12
|
+
description: "Service uses :latest or untagged image — specify an explicit version tag",
|
|
13
|
+
|
|
14
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
15
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
16
|
+
|
|
17
|
+
for (const [_outputName, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
if (!yaml) continue;
|
|
20
|
+
|
|
21
|
+
const services = extractServices(yaml);
|
|
22
|
+
for (const [name, svc] of services) {
|
|
23
|
+
if (svc.image && isLatestOrUntagged(svc.image)) {
|
|
24
|
+
diagnostics.push({
|
|
25
|
+
checkId: "DKRD001",
|
|
26
|
+
severity: "warning",
|
|
27
|
+
message: `Service "${name}" uses image "${svc.image}" which is :latest or untagged. Use an explicit version tag for reproducible builds.`,
|
|
28
|
+
lexicon: "docker",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return diagnostics;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD012: No Root User
|
|
3
|
+
*
|
|
4
|
+
* Warns when a Dockerfile has no USER instruction,
|
|
5
|
+
* meaning the container runs as root by default.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { extractDockerfiles } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
export const dkrd012: PostSynthCheck = {
|
|
12
|
+
id: "DKRD012",
|
|
13
|
+
description: "Dockerfile has no USER instruction — container runs as root",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [_outputName, output] of ctx.outputs) {
|
|
19
|
+
const dockerfiles = extractDockerfiles(output);
|
|
20
|
+
|
|
21
|
+
for (const [fileName, content] of dockerfiles) {
|
|
22
|
+
const hasUser = /^USER\s+/m.test(content);
|
|
23
|
+
if (!hasUser) {
|
|
24
|
+
diagnostics.push({
|
|
25
|
+
checkId: "DKRD012",
|
|
26
|
+
severity: "warning",
|
|
27
|
+
message: `${fileName}: No USER instruction found. The container will run as root. Add a USER instruction to improve security.`,
|
|
28
|
+
lexicon: "docker",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return diagnostics;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { dkrd001 } from "./no-latest-image";
|
|
4
|
+
import { dkrd002 } from "./unused-volume";
|
|
5
|
+
import { dkrd003 } from "./ssh-port-exposed";
|
|
6
|
+
import { dkrd010 } from "./apt-no-recommends";
|
|
7
|
+
import { dkrd011 } from "./prefer-copy";
|
|
8
|
+
import { dkrd012 } from "./no-root-user";
|
|
9
|
+
|
|
10
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
13
|
+
return {
|
|
14
|
+
outputs: new Map([["docker", yaml]]),
|
|
15
|
+
entities: new Map(),
|
|
16
|
+
buildResult: {
|
|
17
|
+
outputs: new Map([["docker", yaml]]),
|
|
18
|
+
entities: new Map(),
|
|
19
|
+
warnings: [],
|
|
20
|
+
errors: [],
|
|
21
|
+
sourceFileCount: 1,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeDockerfileCtx(dockerfileName: string, content: string): PostSynthContext {
|
|
27
|
+
const result = {
|
|
28
|
+
primary: "services:\n app:\n image: myapp:1.0\n",
|
|
29
|
+
files: { [dockerfileName]: content },
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
outputs: new Map([["docker", result as unknown as string]]),
|
|
33
|
+
entities: new Map(),
|
|
34
|
+
buildResult: {
|
|
35
|
+
outputs: new Map([["docker", result as unknown as string]]),
|
|
36
|
+
entities: new Map(),
|
|
37
|
+
warnings: [],
|
|
38
|
+
errors: [],
|
|
39
|
+
sourceFileCount: 1,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── DKRD001: no-latest-image ─────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("DKRD001: no-latest-image", () => {
|
|
47
|
+
test("flags :latest image", () => {
|
|
48
|
+
const yaml = `services:\n api:\n image: nginx:latest\n`;
|
|
49
|
+
const diags = dkrd001.check(makeCtx(yaml));
|
|
50
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
51
|
+
expect(diags[0].checkId).toBe("DKRD001");
|
|
52
|
+
expect(diags[0].severity).toBe("warning");
|
|
53
|
+
expect(diags[0].message).toContain("nginx:latest");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("flags untagged image", () => {
|
|
57
|
+
const yaml = `services:\n api:\n image: nginx\n`;
|
|
58
|
+
const diags = dkrd001.check(makeCtx(yaml));
|
|
59
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
60
|
+
expect(diags[0].checkId).toBe("DKRD001");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("does not flag versioned image", () => {
|
|
64
|
+
const yaml = `services:\n api:\n image: nginx:1.25-alpine\n`;
|
|
65
|
+
const diags = dkrd001.check(makeCtx(yaml));
|
|
66
|
+
expect(diags).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── DKRD002: unused-volume ──────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("DKRD002: unused-volume", () => {
|
|
73
|
+
test("flags unused named volume", () => {
|
|
74
|
+
const yaml = `services:\n api:\n image: myapp:1.0\n\nvolumes:\n mydata:\n`;
|
|
75
|
+
const diags = dkrd002.check(makeCtx(yaml));
|
|
76
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
77
|
+
expect(diags[0].checkId).toBe("DKRD002");
|
|
78
|
+
expect(diags[0].message).toContain("mydata");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("does not flag volume that is mounted", () => {
|
|
82
|
+
const yaml = `services:\n api:\n image: myapp:1.0\n volumes:\n - mydata:/data\n\nvolumes:\n mydata:\n`;
|
|
83
|
+
const diags = dkrd002.check(makeCtx(yaml));
|
|
84
|
+
expect(diags).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("no volumes section returns empty", () => {
|
|
88
|
+
const yaml = `services:\n api:\n image: myapp:1.0\n`;
|
|
89
|
+
const diags = dkrd002.check(makeCtx(yaml));
|
|
90
|
+
expect(diags).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── DKRD003: ssh-port-exposed ───────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe("DKRD003: ssh-port-exposed", () => {
|
|
97
|
+
test("flags port 22 exposure", () => {
|
|
98
|
+
const yaml = `services:\n bastion:\n image: ubuntu:22.04\n ports:\n - "22:22"\n`;
|
|
99
|
+
const diags = dkrd003.check(makeCtx(yaml));
|
|
100
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
101
|
+
expect(diags[0].checkId).toBe("DKRD003");
|
|
102
|
+
expect(diags[0].severity).toBe("error");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("does not flag other ports", () => {
|
|
106
|
+
const yaml = `services:\n api:\n image: myapp:1.0\n ports:\n - "8080:8080"\n`;
|
|
107
|
+
const diags = dkrd003.check(makeCtx(yaml));
|
|
108
|
+
expect(diags).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("does not flag localhost-bound port 22", () => {
|
|
112
|
+
const yaml = `services:\n bastion:\n image: ubuntu:22.04\n ports:\n - "127.0.0.1:22:22"\n`;
|
|
113
|
+
const diags = dkrd003.check(makeCtx(yaml));
|
|
114
|
+
expect(diags).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── DKRD010: apt-no-recommends ──────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("DKRD010: apt-no-recommends", () => {
|
|
121
|
+
test("flags apt-get install without --no-install-recommends", () => {
|
|
122
|
+
const dockerfile = `FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y curl\n`;
|
|
123
|
+
const diags = dkrd010.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
124
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
125
|
+
expect(diags[0].checkId).toBe("DKRD010");
|
|
126
|
+
expect(diags[0].severity).toBe("warning");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("does not flag with --no-install-recommends", () => {
|
|
130
|
+
const dockerfile = `FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y --no-install-recommends curl\n`;
|
|
131
|
+
const diags = dkrd010.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
132
|
+
expect(diags).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does not flag non-apt RUN", () => {
|
|
136
|
+
const dockerfile = `FROM node:20-alpine\nRUN npm ci\n`;
|
|
137
|
+
const diags = dkrd010.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
138
|
+
expect(diags).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── DKRD011: prefer-copy ────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe("DKRD011: prefer-copy", () => {
|
|
145
|
+
test("flags ADD with local file", () => {
|
|
146
|
+
const dockerfile = `FROM ubuntu:22.04\nADD src/ /app/\n`;
|
|
147
|
+
const diags = dkrd011.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
148
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
149
|
+
expect(diags[0].checkId).toBe("DKRD011");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("does not flag ADD with URL", () => {
|
|
153
|
+
const dockerfile = `FROM ubuntu:22.04\nADD https://example.com/file.tar.gz /tmp/\n`;
|
|
154
|
+
const diags = dkrd011.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
155
|
+
expect(diags).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("does not flag ADD with archive", () => {
|
|
159
|
+
const dockerfile = `FROM ubuntu:22.04\nADD src.tar.gz /app/\n`;
|
|
160
|
+
const diags = dkrd011.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
161
|
+
expect(diags).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── DKRD012: no-root-user ────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("DKRD012: no-root-user", () => {
|
|
168
|
+
test("flags Dockerfile without USER instruction", () => {
|
|
169
|
+
const dockerfile = `FROM node:20-alpine\nRUN npm ci\nCMD ["node", "index.js"]\n`;
|
|
170
|
+
const diags = dkrd012.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
171
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
172
|
+
expect(diags[0].checkId).toBe("DKRD012");
|
|
173
|
+
expect(diags[0].severity).toBe("warning");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("does not flag Dockerfile with USER instruction", () => {
|
|
177
|
+
const dockerfile = `FROM node:20-alpine\nRUN npm ci\nUSER node\nCMD ["node", "index.js"]\n`;
|
|
178
|
+
const diags = dkrd012.check(makeDockerfileCtx("Dockerfile.app", dockerfile));
|
|
179
|
+
expect(diags).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD011: Prefer COPY over ADD
|
|
3
|
+
*
|
|
4
|
+
* ADD has surprising behavior (auto-extracts archives, fetches URLs).
|
|
5
|
+
* Use COPY when you just need to copy local files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { extractDockerfiles } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
function looksLikeUrl(src: string): boolean {
|
|
12
|
+
return src.startsWith("http://") || src.startsWith("https://") || src.startsWith("ftp://");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function looksLikeArchive(src: string): boolean {
|
|
16
|
+
return /\.(tar|tar\.gz|tgz|tar\.bz2|tar\.xz|zip)$/i.test(src);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const dkrd011: PostSynthCheck = {
|
|
20
|
+
id: "DKRD011",
|
|
21
|
+
description: "Prefer COPY over ADD when not fetching URLs or extracting archives",
|
|
22
|
+
|
|
23
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
24
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
25
|
+
|
|
26
|
+
for (const [_outputName, output] of ctx.outputs) {
|
|
27
|
+
const dockerfiles = extractDockerfiles(output);
|
|
28
|
+
|
|
29
|
+
for (const [fileName, content] of dockerfiles) {
|
|
30
|
+
const lines = content.split("\n");
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
if (/^ADD\s+/.test(trimmed)) {
|
|
34
|
+
// Extract src argument (first token after ADD)
|
|
35
|
+
const parts = trimmed.replace(/^ADD\s+/, "").split(/\s+/);
|
|
36
|
+
const src = parts[0];
|
|
37
|
+
|
|
38
|
+
if (!looksLikeUrl(src) && !looksLikeArchive(src)) {
|
|
39
|
+
diagnostics.push({
|
|
40
|
+
checkId: "DKRD011",
|
|
41
|
+
severity: "info",
|
|
42
|
+
message: `${fileName}: Use COPY instead of ADD when not fetching URLs or extracting archives. ADD "${src}" could be COPY.`,
|
|
43
|
+
lexicon: "docker",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return diagnostics;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD003: SSH Port Exposed
|
|
3
|
+
*
|
|
4
|
+
* Detects services exposing port 22 (SSH) on the host.
|
|
5
|
+
* Exposing SSH externally is a security risk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractServices } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
function exposesSshPort(port: string): boolean {
|
|
12
|
+
// Formats: "22", "22/tcp", "0.0.0.0:22:22", "127.0.0.1:22:22", "host:container"
|
|
13
|
+
const normalized = port.trim();
|
|
14
|
+
const parts = normalized.split(":");
|
|
15
|
+
|
|
16
|
+
if (parts.length === 1) {
|
|
17
|
+
// Just a port number: "22" or "22/tcp"
|
|
18
|
+
const portNum = parts[0].split("/")[0];
|
|
19
|
+
return portNum === "22";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (parts.length === 2) {
|
|
23
|
+
// "hostPort:containerPort" e.g. "22:22" — no IP binding, accessible on all interfaces
|
|
24
|
+
const hostPort = parts[0].split("/")[0];
|
|
25
|
+
return hostPort === "22";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (parts.length >= 3) {
|
|
29
|
+
// "ip:hostPort:containerPort" e.g. "127.0.0.1:22:22" or "0.0.0.0:22:22"
|
|
30
|
+
const ip = parts[0];
|
|
31
|
+
const hostPort = parts[1].split("/")[0];
|
|
32
|
+
// Only flag if port is 22 AND not bound to loopback
|
|
33
|
+
if (hostPort !== "22") return false;
|
|
34
|
+
return ip !== "127.0.0.1" && ip !== "::1" && ip !== "localhost";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const dkrd003: PostSynthCheck = {
|
|
41
|
+
id: "DKRD003",
|
|
42
|
+
description: "Service exposes SSH port (22) externally — this is a security risk",
|
|
43
|
+
|
|
44
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
45
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
46
|
+
|
|
47
|
+
for (const [_outputName, output] of ctx.outputs) {
|
|
48
|
+
const yaml = getPrimaryOutput(output);
|
|
49
|
+
if (!yaml) continue;
|
|
50
|
+
|
|
51
|
+
const services = extractServices(yaml);
|
|
52
|
+
for (const [name, svc] of services) {
|
|
53
|
+
for (const port of svc.ports ?? []) {
|
|
54
|
+
if (exposesSshPort(port)) {
|
|
55
|
+
diagnostics.push({
|
|
56
|
+
checkId: "DKRD003",
|
|
57
|
+
severity: "error",
|
|
58
|
+
message: `Service "${name}" exposes SSH port 22 externally (port mapping: "${port}"). This is a security risk.`,
|
|
59
|
+
lexicon: "docker",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return diagnostics;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD002: Unused Named Volume
|
|
3
|
+
*
|
|
4
|
+
* Detects top-level named volumes that are not mounted by any service.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { getPrimaryOutput, extractServices, extractNamedVolumes } from "./docker-helpers";
|
|
9
|
+
|
|
10
|
+
export const dkrd002: PostSynthCheck = {
|
|
11
|
+
id: "DKRD002",
|
|
12
|
+
description: "Named volume is declared but not mounted by any service",
|
|
13
|
+
|
|
14
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
15
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
16
|
+
|
|
17
|
+
for (const [_outputName, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
if (!yaml) continue;
|
|
20
|
+
|
|
21
|
+
const namedVolumes = extractNamedVolumes(yaml);
|
|
22
|
+
if (namedVolumes.size === 0) continue;
|
|
23
|
+
|
|
24
|
+
const services = extractServices(yaml);
|
|
25
|
+
const mountedVolumes = new Set<string>();
|
|
26
|
+
|
|
27
|
+
for (const svc of services.values()) {
|
|
28
|
+
for (const vol of svc.volumes ?? []) {
|
|
29
|
+
// Volume mount format: "volname:/container/path" or just "volname"
|
|
30
|
+
const volumeName = vol.split(":")[0].trim();
|
|
31
|
+
mountedVolumes.add(volumeName);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const volName of namedVolumes) {
|
|
36
|
+
if (!mountedVolumes.has(volName)) {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
checkId: "DKRD002",
|
|
39
|
+
severity: "warning",
|
|
40
|
+
message: `Named volume "${volName}" is declared but not mounted by any service.`,
|
|
41
|
+
lexicon: "docker",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return diagnostics;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known deprecated or EOL Docker base images.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEPRECATED_IMAGES: Array<{ image: string; reason: string; replacement?: string }> = [
|
|
6
|
+
{
|
|
7
|
+
image: "centos:8",
|
|
8
|
+
reason: "CentOS 8 reached EOL on December 31, 2021",
|
|
9
|
+
replacement: "rockylinux:8 or almalinux:8",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
image: "centos:latest",
|
|
13
|
+
reason: "CentOS Stream is a rolling release; use a specific version",
|
|
14
|
+
replacement: "rockylinux:9 or almalinux:9",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
image: "node:lts",
|
|
18
|
+
reason: "Use a specific LTS version tag for reproducible builds",
|
|
19
|
+
replacement: "node:20-alpine or node:22-alpine",
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if an image reference matches a deprecated entry.
|
|
25
|
+
*/
|
|
26
|
+
export function findDeprecatedImage(imageRef: string): (typeof DEPRECATED_IMAGES)[number] | undefined {
|
|
27
|
+
return DEPRECATED_IMAGES.find((d) => imageRef === d.image || imageRef.startsWith(d.image + "@"));
|
|
28
|
+
}
|