@intentius/chant-lexicon-docker 0.124.21 → 0.124.22
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/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/dockerfile-order.d.ts +13 -0
- package/dist/dockerfile-order.d.ts.map +1 -0
- package/dist/generated/index.d.ts +121 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/adapter.d.ts.map +1 -1
- package/dist/import/parser.d.ts +23 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/integrity.json +17 -8
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/add-url-checksum.d.ts +9 -0
- package/dist/lint/post-synth/add-url-checksum.d.ts.map +1 -0
- package/dist/lint/post-synth/apt-update-alone.d.ts +10 -0
- package/dist/lint/post-synth/apt-update-alone.d.ts.map +1 -0
- package/dist/lint/post-synth/cache-busting-copy.d.ts +11 -0
- package/dist/lint/post-synth/cache-busting-copy.d.ts.map +1 -0
- package/dist/lint/post-synth/copy-from-stage.d.ts +10 -0
- package/dist/lint/post-synth/copy-from-stage.d.ts.map +1 -0
- package/dist/lint/post-synth/docker-helpers.d.ts +35 -0
- package/dist/lint/post-synth/docker-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/no-root-user.d.ts +3 -2
- package/dist/lint/post-synth/no-root-user.d.ts.map +1 -1
- package/dist/lint/post-synth/relative-workdir.d.ts +10 -0
- package/dist/lint/post-synth/relative-workdir.d.ts.map +1 -0
- package/dist/lint/post-synth/repeated-cmd.d.ts +9 -0
- package/dist/lint/post-synth/repeated-cmd.d.ts.map +1 -0
- package/dist/lint/post-synth/root-after-user.d.ts +10 -0
- package/dist/lint/post-synth/root-after-user.d.ts.map +1 -0
- package/dist/lint/post-synth/unused-global-arg.d.ts +10 -0
- package/dist/lint/post-synth/unused-global-arg.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +1 -0
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lint/rules/per-kind-order.d.ts +15 -0
- package/dist/lint/rules/per-kind-order.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +6 -0
- package/dist/okf/index.md +10 -1
- package/dist/okf/rules/DKRD012.md +2 -2
- package/dist/okf/rules/DKRD013.md +11 -0
- package/dist/okf/rules/DKRD014.md +11 -0
- package/dist/okf/rules/DKRD015.md +11 -0
- package/dist/okf/rules/DKRD016.md +11 -0
- package/dist/okf/rules/DKRD017.md +11 -0
- package/dist/okf/rules/DKRD018.md +11 -0
- package/dist/okf/rules/DKRD019.md +11 -0
- package/dist/okf/rules/DKRD020.md +11 -0
- package/dist/okf/rules/DKRS002.md +15 -0
- package/dist/okf/types/Dockerfile.md +4 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/add-url-checksum.ts +38 -0
- package/dist/rules/apt-update-alone.ts +39 -0
- package/dist/rules/cache-busting-copy.ts +64 -0
- package/dist/rules/copy-from-stage.ts +54 -0
- package/dist/rules/docker-helpers.ts +80 -0
- package/dist/rules/no-root-user.ts +19 -17
- package/dist/rules/per-kind-order.ts +130 -0
- package/dist/rules/relative-workdir.ts +38 -0
- package/dist/rules/repeated-cmd.ts +35 -0
- package/dist/rules/root-after-user.ts +44 -0
- package/dist/rules/unused-global-arg.ts +38 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-docker-patterns.md +91 -22
- package/dist/skills/chant-docker.md +42 -16
- package/dist/spec/parse-engine.d.ts +9 -0
- package/dist/spec/parse-engine.d.ts.map +1 -1
- package/dist/types/index.d.ts +81 -0
- package/package.json +3 -3
- package/src/codegen/__snapshots__/okf.test.ts.snap +155 -4
- package/src/codegen/docs.ts +14 -0
- package/src/codegen/generate.ts +77 -19
- package/src/dockerfile-order.ts +20 -0
- package/src/docs-examples.test.ts +141 -0
- package/src/generated/index.d.ts +81 -0
- package/src/generated/index.ts +117 -0
- package/src/generated/lexicon-docker.json +6 -0
- package/src/import/adapter.ts +8 -2
- package/src/import/generator.ts +49 -33
- package/src/import/parser.ts +82 -13
- package/src/import/roundtrip.test.ts +105 -0
- package/src/lint/audit-catalog.ts +9 -1
- package/src/lint/audit-lineage.ts +14 -0
- package/src/lint/post-synth/add-url-checksum.ts +38 -0
- package/src/lint/post-synth/apt-update-alone.ts +39 -0
- package/src/lint/post-synth/cache-busting-copy.ts +64 -0
- package/src/lint/post-synth/copy-from-stage.ts +54 -0
- package/src/lint/post-synth/docker-helpers.ts +80 -0
- package/src/lint/post-synth/index.ts +16 -0
- package/src/lint/post-synth/no-root-user.ts +19 -17
- package/src/lint/post-synth/post-synth.test.ts +149 -0
- package/src/lint/post-synth/relative-workdir.ts +38 -0
- package/src/lint/post-synth/repeated-cmd.ts +35 -0
- package/src/lint/post-synth/root-after-user.ts +44 -0
- package/src/lint/post-synth/unused-global-arg.ts +38 -0
- package/src/lint/rules/index.ts +1 -0
- package/src/lint/rules/per-kind-order.ts +130 -0
- package/src/lint/rules/rules.test.ts +88 -0
- package/src/plugin.test.ts +4 -0
- package/src/plugin.ts +2 -1
- package/src/serializer.test.ts +112 -0
- package/src/serializer.ts +58 -11
- package/src/skills/chant-docker-patterns.md +91 -22
- package/src/skills/chant-docker.md +42 -16
- package/src/spec/parse-engine.ts +17 -5
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD013: Whole build context copied before installing dependencies
|
|
3
|
+
*
|
|
4
|
+
* `COPY . .` followed by a dependency install (npm ci, pip install -r,
|
|
5
|
+
* go mod download, ...) in the same stage invalidates the install layer on
|
|
6
|
+
* every source change. Copy the dependency manifests first, install, then
|
|
7
|
+
* copy the rest.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { commandText, eachDockerfile, instrText, stageLabel } from "./docker-helpers";
|
|
12
|
+
|
|
13
|
+
/** Installs whose inputs are a few manifest files rather than the whole source tree. */
|
|
14
|
+
const INSTALL = [
|
|
15
|
+
/\bnpm\s+(ci|install|i)(\s+--?[\w-]+(=\S+)?)*\s*($|&&|;|\|)/m,
|
|
16
|
+
/\byarn(\s+install)?(\s+--[\w-]+)*\s*($|&&|;|\|)/m,
|
|
17
|
+
/\bpnpm\s+(install|i)\b/,
|
|
18
|
+
/\bbun\s+install\b/,
|
|
19
|
+
/\bpip3?\s+install\b[^&;|]*\s-r\s/,
|
|
20
|
+
/\bpoetry\s+install\b/,
|
|
21
|
+
/\buv\s+sync\b/,
|
|
22
|
+
/\bpipenv\s+install\b/,
|
|
23
|
+
/\bgo\s+mod\s+download\b/,
|
|
24
|
+
/\bbundle\s+install\b/,
|
|
25
|
+
/\bcomposer\s+install\b/,
|
|
26
|
+
/\bcargo\s+fetch\b/,
|
|
27
|
+
/\bdotnet\s+restore\b/,
|
|
28
|
+
/\bmvn\b[^&;|]*\bdependency:go-offline\b/,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** `COPY . <dest>` (or `./`) from the build context, not from another stage. */
|
|
32
|
+
function copiesWholeContext(value: string): boolean {
|
|
33
|
+
const words = value.split(/\s+/).filter((w) => w.length > 0);
|
|
34
|
+
if (words.some((w) => w.startsWith("--from"))) return false;
|
|
35
|
+
const args = words.filter((w) => !w.startsWith("--"));
|
|
36
|
+
if (args[0]?.startsWith("[")) return false;
|
|
37
|
+
return args.slice(0, -1).some((src) => src === "." || src === "./");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const dkrd013: PostSynthCheck = {
|
|
41
|
+
id: "DKRD013",
|
|
42
|
+
description: "Whole build context copied before a dependency install — every source change re-runs the install",
|
|
43
|
+
|
|
44
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
45
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
46
|
+
|
|
47
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
48
|
+
for (const stage of parsed.stages) {
|
|
49
|
+
const copyAt = stage.instructions.findIndex((i) => i.instruction === "COPY" && copiesWholeContext(i.value));
|
|
50
|
+
if (copyAt === -1) continue;
|
|
51
|
+
const install = stage.instructions.slice(copyAt + 1).find((i) => i.instruction === "RUN" && INSTALL.some((re) => re.test(commandText(i))));
|
|
52
|
+
if (!install) continue;
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
checkId: "DKRD013",
|
|
55
|
+
severity: "warning",
|
|
56
|
+
message: `${fileName} ${stageLabel(stage)}: ${instrText(stage.instructions[copyAt]!)} comes before ${instrText(install)}, so any source change re-runs the install. Copy the dependency manifests first, run the install, then copy the rest.`,
|
|
57
|
+
lexicon: "docker",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return diagnostics;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD016: COPY --from names no earlier stage
|
|
3
|
+
*
|
|
4
|
+
* `COPY --from=<name>` reads from an earlier stage with that name (or
|
|
5
|
+
* index). A name that only a later stage has, or that no stage has, is
|
|
6
|
+
* pulled as an image instead, which is almost never what was meant.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { eachDockerfile, stageLabel } from "./docker-helpers";
|
|
11
|
+
|
|
12
|
+
/** Looks like an image reference rather than a stage name: has a tag, digest or registry path. */
|
|
13
|
+
function looksLikeImage(ref: string): boolean {
|
|
14
|
+
return /[:/@]/.test(ref);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const dkrd016: PostSynthCheck = {
|
|
18
|
+
id: "DKRD016",
|
|
19
|
+
description: "COPY --from names a stage that is not defined before it",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
25
|
+
for (const stage of parsed.stages) {
|
|
26
|
+
for (const instr of stage.instructions) {
|
|
27
|
+
if (instr.instruction !== "COPY" && instr.instruction !== "ADD") continue;
|
|
28
|
+
const m = instr.value.match(/--from=(\S+)/);
|
|
29
|
+
if (!m) continue;
|
|
30
|
+
const ref = m[1]!;
|
|
31
|
+
if (ref.includes("$")) continue;
|
|
32
|
+
const earlier = parsed.stages.slice(0, stage.index);
|
|
33
|
+
if (/^\d+$/.test(ref)) {
|
|
34
|
+
if (Number(ref) < stage.index) continue;
|
|
35
|
+
} else if (earlier.some((s) => s.as?.toLowerCase() === ref.toLowerCase())) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const later = parsed.stages.slice(stage.index).find((s) => s.as?.toLowerCase() === ref.toLowerCase() || String(s.index) === ref);
|
|
39
|
+
if (!later && looksLikeImage(ref)) continue;
|
|
40
|
+
diagnostics.push({
|
|
41
|
+
checkId: "DKRD016",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
message: later
|
|
44
|
+
? `${fileName} ${stageLabel(stage)}: ${instr.instruction} --from=${ref} names ${stageLabel(later)}, which is not defined until later. Move that stage before this one.`
|
|
45
|
+
: `${fileName} ${stageLabel(stage)}: ${instr.instruction} --from=${ref} names no stage, so Docker pulls an image called "${ref}". Name an earlier stage, or give the image a tag.`,
|
|
46
|
+
lexicon: "docker",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
6
|
+
import { splitDockerfileInstructions } from "../../import/parser";
|
|
6
7
|
|
|
7
8
|
export interface ParsedService {
|
|
8
9
|
name: string;
|
|
@@ -112,3 +113,82 @@ export function extractDockerfiles(output: unknown): Map<string, string> {
|
|
|
112
113
|
}
|
|
113
114
|
return files;
|
|
114
115
|
}
|
|
116
|
+
|
|
117
|
+
// ── Dockerfile structure ──────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export interface DockerfileInstr {
|
|
120
|
+
/** Upper-case keyword, e.g. "RUN". */
|
|
121
|
+
instruction: string;
|
|
122
|
+
/** The rest of the instruction as written. */
|
|
123
|
+
value: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ParsedStage {
|
|
127
|
+
/** 0-based position among the stages. */
|
|
128
|
+
index: number;
|
|
129
|
+
/** The FROM image (or earlier stage) without `--platform` and `AS`. */
|
|
130
|
+
from: string;
|
|
131
|
+
as?: string;
|
|
132
|
+
instructions: DockerfileInstr[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ParsedDockerfile {
|
|
136
|
+
/** ARG instructions before the first FROM. */
|
|
137
|
+
globalArgs: DockerfileInstr[];
|
|
138
|
+
stages: ParsedStage[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A Dockerfile's global ARGs and stages, each stage's instructions in order. */
|
|
142
|
+
export function parseDockerfileStages(content: string): ParsedDockerfile {
|
|
143
|
+
const out: ParsedDockerfile = { globalArgs: [], stages: [] };
|
|
144
|
+
for (const instr of splitDockerfileInstructions(content)) {
|
|
145
|
+
if (instr.instruction === "FROM") {
|
|
146
|
+
const words = instr.value.split(/\s+/).filter((w) => !w.startsWith("--"));
|
|
147
|
+
const asAt = words.findIndex((w) => w.toLowerCase() === "as");
|
|
148
|
+
out.stages.push({
|
|
149
|
+
index: out.stages.length,
|
|
150
|
+
from: words[0] ?? "",
|
|
151
|
+
...(asAt > 0 && words[asAt + 1] ? { as: words[asAt + 1] } : {}),
|
|
152
|
+
instructions: [],
|
|
153
|
+
});
|
|
154
|
+
} else if (out.stages.length > 0) {
|
|
155
|
+
out.stages[out.stages.length - 1]!.instructions.push(instr);
|
|
156
|
+
} else if (instr.instruction === "ARG") {
|
|
157
|
+
out.globalArgs.push(instr);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** How a stage is named in a message: `stage "build"` or `stage 2`. */
|
|
164
|
+
export function stageLabel(stage: ParsedStage): string {
|
|
165
|
+
return stage.as ? `stage "${stage.as}"` : `stage ${stage.index + 1}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The first line of an instruction, for a message. */
|
|
169
|
+
export function instrText(instr: DockerfileInstr): string {
|
|
170
|
+
const first = instr.value.split("\n")[0]!.replace(/\s*\\$/, "");
|
|
171
|
+
return `${instr.instruction} ${first}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether a USER value is root (by name or uid 0, with or without a group). */
|
|
175
|
+
export function isRootUser(value: string): boolean {
|
|
176
|
+
const user = value.trim().split(":")[0]!;
|
|
177
|
+
return user === "root" || user === "0";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every generated Dockerfile in a build's outputs, parsed. */
|
|
181
|
+
export function eachDockerfile(outputs: Map<string, unknown>): Array<{ fileName: string; content: string; parsed: ParsedDockerfile }> {
|
|
182
|
+
const out: Array<{ fileName: string; content: string; parsed: ParsedDockerfile }> = [];
|
|
183
|
+
for (const [, output] of outputs) {
|
|
184
|
+
for (const [fileName, content] of extractDockerfiles(output)) {
|
|
185
|
+
out.push({ fileName, content, parsed: parseDockerfileStages(content) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A RUN value with its line continuations joined, for matching commands. */
|
|
192
|
+
export function commandText(instr: DockerfileInstr): string {
|
|
193
|
+
return instr.value.replace(/\\[ \t]*\n/g, " ");
|
|
194
|
+
}
|
|
@@ -6,6 +6,14 @@ import { dkrd003 } from "./ssh-port-exposed";
|
|
|
6
6
|
import { dkrd010 } from "./apt-no-recommends";
|
|
7
7
|
import { dkrd011 } from "./prefer-copy";
|
|
8
8
|
import { dkrd012 } from "./no-root-user";
|
|
9
|
+
import { dkrd013 } from "./cache-busting-copy";
|
|
10
|
+
import { dkrd014 } from "./root-after-user";
|
|
11
|
+
import { dkrd015 } from "./repeated-cmd";
|
|
12
|
+
import { dkrd016 } from "./copy-from-stage";
|
|
13
|
+
import { dkrd017 } from "./unused-global-arg";
|
|
14
|
+
import { dkrd018 } from "./relative-workdir";
|
|
15
|
+
import { dkrd019 } from "./add-url-checksum";
|
|
16
|
+
import { dkrd020 } from "./apt-update-alone";
|
|
9
17
|
|
|
10
18
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
11
19
|
dkrd001,
|
|
@@ -14,4 +22,12 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
14
22
|
dkrd010,
|
|
15
23
|
dkrd011,
|
|
16
24
|
dkrd012,
|
|
25
|
+
dkrd013,
|
|
26
|
+
dkrd014,
|
|
27
|
+
dkrd015,
|
|
28
|
+
dkrd016,
|
|
29
|
+
dkrd017,
|
|
30
|
+
dkrd018,
|
|
31
|
+
dkrd019,
|
|
32
|
+
dkrd020,
|
|
17
33
|
];
|
|
@@ -1,34 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DKRD012: No Root User
|
|
3
3
|
*
|
|
4
|
-
* Warns when
|
|
5
|
-
*
|
|
4
|
+
* Warns when the container would run as root: the final stage has no USER
|
|
5
|
+
* instruction, or its last USER is root. USER in an earlier stage does not
|
|
6
|
+
* carry into the final image.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
-
import {
|
|
10
|
+
import { eachDockerfile, isRootUser, stageLabel } from "./docker-helpers";
|
|
10
11
|
|
|
11
12
|
export const dkrd012: PostSynthCheck = {
|
|
12
13
|
id: "DKRD012",
|
|
13
|
-
description: "Dockerfile has no USER instruction — container runs as root",
|
|
14
|
+
description: "Dockerfile's final stage has no USER instruction, or ends on USER root — container runs as root",
|
|
14
15
|
|
|
15
16
|
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
17
|
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
18
|
|
|
18
|
-
for (const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
19
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
20
|
+
const final = parsed.stages[parsed.stages.length - 1];
|
|
21
|
+
if (!final) continue;
|
|
22
|
+
const users = final.instructions.filter((i) => i.instruction === "USER");
|
|
23
|
+
const last = users[users.length - 1];
|
|
24
|
+
let message: string | undefined;
|
|
25
|
+
if (!last) {
|
|
26
|
+
const anyUser = parsed.stages.some((s) => s.instructions.some((i) => i.instruction === "USER"));
|
|
27
|
+
message = anyUser
|
|
28
|
+
? `${fileName}: The final ${stageLabel(final)} has no USER instruction; a USER in an earlier stage does not carry over. The container will run as root. Add a USER step to the final stage.`
|
|
29
|
+
: `${fileName}: No USER instruction found. The container will run as root. Add a USER instruction to improve security.`;
|
|
30
|
+
} else if (isRootUser(last.value)) {
|
|
31
|
+
message = `${fileName}: The final ${stageLabel(final)} ends on USER ${last.value.trim()}, so the container runs as root. Switch to an unprivileged USER after the root setup steps.`;
|
|
31
32
|
}
|
|
33
|
+
if (message) diagnostics.push({ checkId: "DKRD012", severity: "warning", message, lexicon: "docker" });
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
return diagnostics;
|
|
@@ -6,6 +6,14 @@ import { dkrd003 } from "./ssh-port-exposed";
|
|
|
6
6
|
import { dkrd010 } from "./apt-no-recommends";
|
|
7
7
|
import { dkrd011 } from "./prefer-copy";
|
|
8
8
|
import { dkrd012 } from "./no-root-user";
|
|
9
|
+
import { dkrd013 } from "./cache-busting-copy";
|
|
10
|
+
import { dkrd014 } from "./root-after-user";
|
|
11
|
+
import { dkrd015 } from "./repeated-cmd";
|
|
12
|
+
import { dkrd016 } from "./copy-from-stage";
|
|
13
|
+
import { dkrd017 } from "./unused-global-arg";
|
|
14
|
+
import { dkrd018 } from "./relative-workdir";
|
|
15
|
+
import { dkrd019 } from "./add-url-checksum";
|
|
16
|
+
import { dkrd020 } from "./apt-update-alone";
|
|
9
17
|
|
|
10
18
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
11
19
|
|
|
@@ -179,3 +187,144 @@ describe("DKRD012: no-root-user", () => {
|
|
|
179
187
|
expect(diags).toHaveLength(0);
|
|
180
188
|
});
|
|
181
189
|
});
|
|
190
|
+
|
|
191
|
+
describe("DKRD012: final stage", () => {
|
|
192
|
+
const run = (lines: string[]) => dkrd012.check(makeDockerfileCtx("Dockerfile.app", lines.join("\n") + "\n"));
|
|
193
|
+
|
|
194
|
+
test("flags a final stage with no USER when only an earlier stage sets one", () => {
|
|
195
|
+
const diags = run(["FROM node:20-alpine AS build", "USER node", "RUN npm ci", "FROM node:20-alpine", "CMD node index.js"]);
|
|
196
|
+
expect(diags).toHaveLength(1);
|
|
197
|
+
expect(diags[0].message).toContain("final stage 2 has no USER");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("flags a final stage that ends on USER root", () => {
|
|
201
|
+
const diags = run(["FROM debian:12-slim", "USER app", "USER 0:0", "CMD x"]);
|
|
202
|
+
expect(diags).toHaveLength(1);
|
|
203
|
+
expect(diags[0].message).toContain("ends on USER 0:0");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("does not flag USER root for setup followed by a non-root USER", () => {
|
|
207
|
+
expect(run(["FROM debian:12-slim", "USER root", "RUN apt-get update", "USER app", "CMD x"])).toHaveLength(0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const df = (lines: string[]) => makeDockerfileCtx("Dockerfile.app", lines.join("\n") + "\n");
|
|
212
|
+
|
|
213
|
+
describe("DKRD013: cache-busting copy", () => {
|
|
214
|
+
test("flags COPY . . before npm ci", () => {
|
|
215
|
+
const diags = dkrd013.check(df(["FROM node:20-alpine", "WORKDIR /app", "COPY . .", "RUN npm ci", "RUN npm run build"]));
|
|
216
|
+
expect(diags).toHaveLength(1);
|
|
217
|
+
expect(diags[0].message).toContain("COPY . . comes before RUN npm ci");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("flags pip install -r and go mod download, across a continuation", () => {
|
|
221
|
+
expect(dkrd013.check(df(["FROM python:3.12-slim", "COPY . /app", "RUN pip install --no-cache-dir \\", " -r /app/requirements.txt"]))).toHaveLength(1);
|
|
222
|
+
expect(dkrd013.check(df(["FROM golang:1.23", "COPY ./ ./", "RUN go mod download"]))).toHaveLength(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("does not flag manifests copied first, COPY --from, or a package added by name", () => {
|
|
226
|
+
expect(dkrd013.check(df(["FROM node:20-alpine", "COPY package*.json ./", "RUN npm ci", "COPY . .", "RUN npm run build"]))).toHaveLength(0);
|
|
227
|
+
expect(dkrd013.check(df(["FROM node:20-alpine", "COPY --from=build /app .", "RUN npm ci"]))).toHaveLength(0);
|
|
228
|
+
expect(dkrd013.check(df(["FROM node:20-alpine", "COPY . .", "RUN npm install -g serve"]))).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("DKRD014: root-only RUN after USER", () => {
|
|
233
|
+
test("flags apt-get after a non-root USER", () => {
|
|
234
|
+
const diags = dkrd014.check(df(["FROM debian:12-slim", "USER app", "RUN apt-get update && apt-get install -y curl"]));
|
|
235
|
+
expect(diags).toHaveLength(1);
|
|
236
|
+
expect(diags[0].message).toContain("runs as USER app");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("does not flag root setup before USER, USER root, or sudo", () => {
|
|
240
|
+
expect(dkrd014.check(df(["FROM debian:12-slim", "RUN useradd app", "USER app", "RUN whoami"]))).toHaveLength(0);
|
|
241
|
+
expect(dkrd014.check(df(["FROM debian:12-slim", "USER app", "USER root", "RUN chown app /data", "USER app"]))).toHaveLength(0);
|
|
242
|
+
expect(dkrd014.check(df(["FROM debian:12-slim", "USER app", "RUN sudo apt-get install -y curl"]))).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a USER in one stage does not carry into the next", () => {
|
|
246
|
+
expect(dkrd014.check(df(["FROM debian:12-slim AS a", "USER app", "FROM debian:12-slim", "RUN apt-get install -y curl"]))).toHaveLength(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("DKRD015: repeated CMD or ENTRYPOINT", () => {
|
|
251
|
+
test("flags two CMDs in a stage", () => {
|
|
252
|
+
const diags = dkrd015.check(df(["FROM alpine:3.20", 'CMD ["a"]', "RUN true", 'CMD ["b"]']));
|
|
253
|
+
expect(diags).toHaveLength(1);
|
|
254
|
+
expect(diags[0].message).toContain('2 CMD instructions; only the last (CMD ["b"])');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("does not flag one CMD per stage", () => {
|
|
258
|
+
expect(dkrd015.check(df(["FROM alpine:3.20 AS a", "CMD a", "FROM alpine:3.20", "ENTRYPOINT x", "CMD b"]))).toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("DKRD016: COPY --from stage", () => {
|
|
263
|
+
test("flags a stage that is only defined later", () => {
|
|
264
|
+
const diags = dkrd016.check(df(["FROM alpine:3.20 AS final", "COPY --from=build /out /out", "FROM alpine:3.20 AS build"]));
|
|
265
|
+
expect(diags).toHaveLength(1);
|
|
266
|
+
expect(diags[0].message).toContain('names stage "build", which is not defined until later');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("flags a name no stage has", () => {
|
|
270
|
+
const diags = dkrd016.check(df(["FROM alpine:3.20 AS build", "FROM alpine:3.20", "COPY --from=biuld /out /out"]));
|
|
271
|
+
expect(diags).toHaveLength(1);
|
|
272
|
+
expect(diags[0].message).toContain('pulls an image called "biuld"');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("does not flag earlier stages by name or index, or tagged images", () => {
|
|
276
|
+
expect(dkrd016.check(df([
|
|
277
|
+
"FROM alpine:3.20 AS build",
|
|
278
|
+
"FROM alpine:3.20",
|
|
279
|
+
"COPY --from=build /a /a",
|
|
280
|
+
"COPY --from=0 /b /b",
|
|
281
|
+
"COPY --from=nginx:1.27 /etc/nginx /etc/nginx",
|
|
282
|
+
]))).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("DKRD017: unused global ARG", () => {
|
|
287
|
+
test("flags an ARG before FROM that nothing uses", () => {
|
|
288
|
+
const diags = dkrd017.check(df(["ARG UNUSED=1", "FROM alpine:3.20"]));
|
|
289
|
+
expect(diags).toHaveLength(1);
|
|
290
|
+
expect(diags[0].message).toContain("ARG UNUSED");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("does not flag an ARG used in FROM or declared again in a stage", () => {
|
|
294
|
+
expect(dkrd017.check(df(["ARG NODE_VERSION=20", "FROM node:${NODE_VERSION}-alpine"]))).toHaveLength(0);
|
|
295
|
+
expect(dkrd017.check(df(["ARG VERSION=1", "FROM alpine:3.20", "ARG VERSION", "RUN echo $VERSION"]))).toHaveLength(0);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("DKRD018: relative WORKDIR", () => {
|
|
300
|
+
test("flags a relative WORKDIR before any absolute one", () => {
|
|
301
|
+
const diags = dkrd018.check(df(["FROM alpine:3.20", "WORKDIR app"]));
|
|
302
|
+
expect(diags).toHaveLength(1);
|
|
303
|
+
expect(diags[0].message).toContain("WORKDIR app is relative");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("does not flag a relative WORKDIR after an absolute one", () => {
|
|
307
|
+
expect(dkrd018.check(df(["FROM alpine:3.20", "WORKDIR /src", "WORKDIR app"]))).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("DKRD019: ADD URL without checksum", () => {
|
|
312
|
+
test("flags ADD of an https URL without --checksum", () => {
|
|
313
|
+
expect(dkrd019.check(df(["FROM alpine:3.20", "ADD https://example.com/tool.tgz /tmp/"]))).toHaveLength(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("does not flag --checksum or a local file", () => {
|
|
317
|
+
expect(dkrd019.check(df(["FROM alpine:3.20", "ADD --checksum=sha256:abc https://example.com/tool.tgz /tmp/", "ADD local.tgz /tmp/"]))).toHaveLength(0);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("DKRD020: apt-get update alone", () => {
|
|
322
|
+
test("flags update in one RUN and install in a later one", () => {
|
|
323
|
+
const diags = dkrd020.check(df(["FROM debian:12-slim", "RUN apt-get update", "RUN apt-get install -y --no-install-recommends curl"]));
|
|
324
|
+
expect(diags).toHaveLength(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("does not flag update and install in one RUN", () => {
|
|
328
|
+
expect(dkrd020.check(df(["FROM debian:12-slim", "RUN apt-get update \\", " && apt-get install -y --no-install-recommends curl"]))).toHaveLength(0);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD018: Relative WORKDIR
|
|
3
|
+
*
|
|
4
|
+
* A relative WORKDIR resolves against whatever directory the base image
|
|
5
|
+
* left, which can change with the image. Use an absolute path, at least for
|
|
6
|
+
* the first WORKDIR of a stage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { eachDockerfile, stageLabel } from "./docker-helpers";
|
|
11
|
+
|
|
12
|
+
export const dkrd018: PostSynthCheck = {
|
|
13
|
+
id: "DKRD018",
|
|
14
|
+
description: "Relative WORKDIR before any absolute WORKDIR in the stage",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
20
|
+
for (const stage of parsed.stages) {
|
|
21
|
+
for (const instr of stage.instructions) {
|
|
22
|
+
if (instr.instruction !== "WORKDIR") continue;
|
|
23
|
+
const dir = instr.value.trim().replace(/^["']|["']$/g, "");
|
|
24
|
+
if (dir.startsWith("/") || dir.startsWith("$") || /^[A-Za-z]:[\\/]/.test(dir)) break;
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "DKRD018",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
message: `${fileName} ${stageLabel(stage)}: WORKDIR ${dir} is relative to the base image's working directory. Use an absolute path.`,
|
|
29
|
+
lexicon: "docker",
|
|
30
|
+
});
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD015: More than one CMD or ENTRYPOINT in a stage
|
|
3
|
+
*
|
|
4
|
+
* Only the last CMD and the last ENTRYPOINT of a stage take effect; the
|
|
5
|
+
* earlier ones are silently ignored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { eachDockerfile, instrText, stageLabel } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
export const dkrd015: PostSynthCheck = {
|
|
12
|
+
id: "DKRD015",
|
|
13
|
+
description: "More than one CMD or ENTRYPOINT in a stage — only the last one takes effect",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
19
|
+
for (const stage of parsed.stages) {
|
|
20
|
+
for (const kw of ["CMD", "ENTRYPOINT"]) {
|
|
21
|
+
const all = stage.instructions.filter((i) => i.instruction === kw);
|
|
22
|
+
if (all.length < 2) continue;
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
checkId: "DKRD015",
|
|
25
|
+
severity: "warning",
|
|
26
|
+
message: `${fileName} ${stageLabel(stage)}: ${all.length} ${kw} instructions; only the last (${instrText(all[all.length - 1]!)}) takes effect. Remove the others.`,
|
|
27
|
+
lexicon: "docker",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return diagnostics;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD014: Root-only command after a non-root USER
|
|
3
|
+
*
|
|
4
|
+
* A RUN that installs packages or manages users and ownership (apt-get,
|
|
5
|
+
* apk add, useradd, chown, ...) fails or misbehaves after USER switches to
|
|
6
|
+
* an unprivileged user. Run root setup first, then switch USER.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { commandText, eachDockerfile, instrText, isRootUser, stageLabel } from "./docker-helpers";
|
|
11
|
+
|
|
12
|
+
const ROOT_ONLY = /(^|[\s;&|(])(apt-get\s+(install|update|upgrade|dist-upgrade|remove|purge)|apt\s+(install|update|upgrade)|apk\s+(add|update|upgrade|del)|yum\s+(install|update)|dnf\s+(install|update|upgrade)|microdnf\s+install|zypper\s+(install|in|refresh)|useradd|adduser|groupadd|addgroup|usermod|chown)\b/;
|
|
13
|
+
|
|
14
|
+
export const dkrd014: PostSynthCheck = {
|
|
15
|
+
id: "DKRD014",
|
|
16
|
+
description: "RUN needs root but comes after USER switched to a non-root user",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
22
|
+
for (const stage of parsed.stages) {
|
|
23
|
+
let user: string | undefined;
|
|
24
|
+
for (const instr of stage.instructions) {
|
|
25
|
+
if (instr.instruction === "USER") {
|
|
26
|
+
user = instr.value.trim();
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (instr.instruction !== "RUN" || user === undefined || isRootUser(user)) continue;
|
|
30
|
+
const cmd = commandText(instr);
|
|
31
|
+
if (/\bsudo\b/.test(cmd) || !ROOT_ONLY.test(cmd)) continue;
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "DKRD014",
|
|
34
|
+
severity: "warning",
|
|
35
|
+
message: `${fileName} ${stageLabel(stage)}: ${instrText(instr)} needs root but runs as USER ${user}. Move it before the USER step, or switch to USER root for it.`,
|
|
36
|
+
lexicon: "docker",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD017: ARG before FROM that nothing uses
|
|
3
|
+
*
|
|
4
|
+
* An ARG declared before the first FROM is only visible to FROM lines, and
|
|
5
|
+
* to stages that declare it again (`ARG NAME`). One that neither uses does
|
|
6
|
+
* nothing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { eachDockerfile } from "./docker-helpers";
|
|
11
|
+
|
|
12
|
+
export const dkrd017: PostSynthCheck = {
|
|
13
|
+
id: "DKRD017",
|
|
14
|
+
description: "ARG before the first FROM is not used by any FROM or stage",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const { fileName, content, parsed } of eachDockerfile(ctx.outputs)) {
|
|
20
|
+
const fromLines = content.split("\n").filter((l) => /^\s*FROM\s/i.test(l)).join("\n");
|
|
21
|
+
for (const arg of parsed.globalArgs) {
|
|
22
|
+
const name = arg.value.trim().split("=")[0]!.trim();
|
|
23
|
+
if (!name) continue;
|
|
24
|
+
const used = new RegExp(`\\$\\{?${name}\\b`).test(fromLines);
|
|
25
|
+
const redeclared = parsed.stages.some((s) => s.instructions.some((i) => i.instruction === "ARG" && i.value.trim().split("=")[0]!.trim() === name));
|
|
26
|
+
if (used || redeclared) continue;
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "DKRD017",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `${fileName}: ARG ${name} is declared before FROM but no FROM uses it and no stage declares it again with ARG ${name}. Use it, declare it in the stage that needs it, or remove it.`,
|
|
31
|
+
lexicon: "docker",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
package/src/lint/rules/index.ts
CHANGED