@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,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: DKRD016
|
|
4
|
+
description: COPY --from names a stage that is not defined before it
|
|
5
|
+
id: DKRD016
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: docker
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/docker/rules/
|
|
10
|
+
---
|
|
11
|
+
COPY --from names a stage that is not defined before it
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: DKRD017
|
|
4
|
+
description: ARG before the first FROM is not used by any FROM or stage
|
|
5
|
+
id: DKRD017
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: docker
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/docker/rules/
|
|
10
|
+
---
|
|
11
|
+
ARG before the first FROM is not used by any FROM or stage
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: DKRD018
|
|
4
|
+
description: Relative WORKDIR before any absolute WORKDIR in the stage
|
|
5
|
+
id: DKRD018
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: docker
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/docker/rules/
|
|
10
|
+
---
|
|
11
|
+
Relative WORKDIR before any absolute WORKDIR in the stage
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: DKRD019
|
|
4
|
+
description: ADD fetches a remote URL without --checksum
|
|
5
|
+
id: DKRD019
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: docker
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/docker/rules/
|
|
10
|
+
---
|
|
11
|
+
ADD fetches a remote URL without --checksum
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: DKRD020
|
|
4
|
+
description: apt-get update in a different RUN from apt-get install — the install can use a stale package index
|
|
5
|
+
id: DKRD020
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: docker
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/docker/rules/
|
|
10
|
+
---
|
|
11
|
+
apt-get update in a different RUN from apt-get install — the install can use a stale package index
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: lint-rule
|
|
3
|
+
title: DKRS002
|
|
4
|
+
description: Per-kind Dockerfile fields written in a different order
|
|
5
|
+
id: DKRS002
|
|
6
|
+
severity: warning
|
|
7
|
+
category: correctness
|
|
8
|
+
lexicon: docker
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/docker/rules/
|
|
10
|
+
---
|
|
11
|
+
Per-kind Dockerfile fields written in a different order
|
|
12
|
+
|
|
13
|
+
## Applies to
|
|
14
|
+
|
|
15
|
+
- [Dockerfile](/types/Dockerfile.md)
|
|
@@ -24,7 +24,10 @@ A Dockerfile definition with ordered build instructions
|
|
|
24
24
|
- `entrypoint`: Set the entrypoint executable
|
|
25
25
|
- `cmd`: Default command arguments
|
|
26
26
|
- `healthcheck`: Health check instruction
|
|
27
|
+
- `globalArgs`: ARG instructions written before the first FROM
|
|
28
|
+
- `steps`: Instructions after FROM, written in the order given; one instruction per step
|
|
27
29
|
|
|
28
30
|
## Governed by
|
|
29
31
|
|
|
30
|
-
- [DKRD012](/rules/DKRD012.md): Dockerfile has no USER instruction — container runs as root
|
|
32
|
+
- [DKRD012](/rules/DKRD012.md): Dockerfile's final stage has no USER instruction, or ends on USER root — container runs as root
|
|
33
|
+
- [DKRS002](/rules/DKRS002.md): Per-kind Dockerfile fields written in a different order
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAe7F,eAAO,MAAM,YAAY,EAAE,aA8O1B,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD019: ADD of a remote URL without --checksum
|
|
3
|
+
*
|
|
4
|
+
* ADD fetches an http(s) URL at build time and trusts whatever comes back.
|
|
5
|
+
* `ADD --checksum=sha256:...` makes the build fail when the content changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { eachDockerfile, stageLabel } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
export const dkrd019: PostSynthCheck = {
|
|
12
|
+
id: "DKRD019",
|
|
13
|
+
description: "ADD fetches a remote URL without --checksum",
|
|
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 instr of stage.instructions) {
|
|
21
|
+
if (instr.instruction !== "ADD") continue;
|
|
22
|
+
const words = instr.value.split(/\s+/);
|
|
23
|
+
if (words.some((w) => w.startsWith("--checksum="))) continue;
|
|
24
|
+
const url = words.find((w) => /^https?:\/\//i.test(w));
|
|
25
|
+
if (!url) continue;
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
checkId: "DKRD019",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
message: `${fileName} ${stageLabel(stage)}: ADD ${url} has no --checksum, so the build accepts whatever the URL serves. Add --checksum=sha256:<digest>.`,
|
|
30
|
+
lexicon: "docker",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD020: apt-get update in its own RUN
|
|
3
|
+
*
|
|
4
|
+
* A RUN that only runs `apt-get update` is cached as its own layer, so a
|
|
5
|
+
* later `apt-get install` in another RUN can install from a stale package
|
|
6
|
+
* index. Update and install in the same RUN.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { commandText, eachDockerfile, stageLabel } from "./docker-helpers";
|
|
11
|
+
|
|
12
|
+
const UPDATE = /\bapt(-get)?\s+update\b/;
|
|
13
|
+
const INSTALL = /\bapt(-get)?\s+(install|upgrade|dist-upgrade)\b/;
|
|
14
|
+
|
|
15
|
+
export const dkrd020: PostSynthCheck = {
|
|
16
|
+
id: "DKRD020",
|
|
17
|
+
description: "apt-get update in a different RUN from apt-get install — the install can use a stale package index",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
23
|
+
for (const stage of parsed.stages) {
|
|
24
|
+
const runs = stage.instructions.filter((i) => i.instruction === "RUN");
|
|
25
|
+
const updateAt = runs.findIndex((r) => UPDATE.test(commandText(r)) && !INSTALL.test(commandText(r)));
|
|
26
|
+
if (updateAt === -1) continue;
|
|
27
|
+
if (!runs.slice(updateAt + 1).some((r) => INSTALL.test(commandText(r)))) continue;
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "DKRD020",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `${fileName} ${stageLabel(stage)}: apt-get update runs in its own RUN, and a later RUN installs from that cached index. Run update and install in one RUN: apt-get update && apt-get install -y --no-install-recommends ...`,
|
|
32
|
+
lexicon: "docker",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRS002: Per-kind Dockerfile fields written in a different order
|
|
3
|
+
*
|
|
4
|
+
* A Dockerfile stage without `steps` writes its per-kind fields (`run`,
|
|
5
|
+
* `copy`, `user`, ...) in a fixed order: ARG, ENV, WORKDIR, USER, RUN, COPY,
|
|
6
|
+
* ADD, EXPOSE, VOLUME, LABEL, ENTRYPOINT, CMD, HEALTHCHECK. When the fields
|
|
7
|
+
* are declared in another order (`copy` before `run`, `run` before `user`)
|
|
8
|
+
* the generated Dockerfile does not run them in the order they read in.
|
|
9
|
+
*
|
|
10
|
+
* The fix rewrites the fields as `steps` in the fixed order, so the
|
|
11
|
+
* generated Dockerfile stays the same, with a comment to reorder them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LintRule, LintDiagnostic, LintContext, LintFix } from "@intentius/chant/lint/rule";
|
|
15
|
+
import { ts } from "@intentius/chant/lint/typescript";
|
|
16
|
+
import { PER_KIND_ORDER, type PerKindField } from "../../dockerfile-order";
|
|
17
|
+
|
|
18
|
+
const PER_KIND = new Set<string>(PER_KIND_ORDER);
|
|
19
|
+
/** Per-kind fields that take one string; the rest take a list. */
|
|
20
|
+
const SINGLE = new Set<string>(["workdir", "user", "entrypoint", "cmd", "healthcheck"]);
|
|
21
|
+
|
|
22
|
+
const FIX_NOTE = "// Written by the DKRS002 fix in the fixed order the per-kind fields used, so the output is unchanged. Reorder the steps as the build needs.";
|
|
23
|
+
|
|
24
|
+
function propName(p: ts.ObjectLiteralElementLike): string | undefined {
|
|
25
|
+
if (!(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p))) return undefined;
|
|
26
|
+
const n = p.name;
|
|
27
|
+
if (ts.isIdentifier(n) || ts.isStringLiteral(n)) return n.text;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDockerfileNew(node: ts.Node): node is ts.NewExpression {
|
|
32
|
+
if (!ts.isNewExpression(node)) return false;
|
|
33
|
+
const e = node.expression;
|
|
34
|
+
return (ts.isIdentifier(e) && e.text === "Dockerfile") || (ts.isPropertyAccessExpression(e) && e.name.text === "Dockerfile");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The stage-shaped object literals of one `new Dockerfile({...})`. */
|
|
38
|
+
function stageLiterals(arg: ts.ObjectLiteralExpression): ts.ObjectLiteralExpression[] {
|
|
39
|
+
const stagesProp = arg.properties.find((p) => propName(p) === "stages");
|
|
40
|
+
if (!stagesProp) return [arg];
|
|
41
|
+
// With stages, the top-level per-kind fields are not written at all.
|
|
42
|
+
if (!ts.isPropertyAssignment(stagesProp) || !ts.isArrayLiteralExpression(stagesProp.initializer)) return [];
|
|
43
|
+
return stagesProp.initializer.elements.filter(ts.isObjectLiteralExpression);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `{ key: value }` step texts for one per-kind field, or undefined when its value can't be split. */
|
|
47
|
+
function stepTexts(sf: ts.SourceFile, key: string, p: ts.ObjectLiteralElementLike): string[] | undefined {
|
|
48
|
+
if (!ts.isPropertyAssignment(p)) return undefined;
|
|
49
|
+
const init = p.initializer;
|
|
50
|
+
if (SINGLE.has(key)) return [`{ ${key}: ${init.getText(sf)} }`];
|
|
51
|
+
if (!ts.isArrayLiteralExpression(init)) return undefined;
|
|
52
|
+
const out: string[] = [];
|
|
53
|
+
for (const el of init.elements) {
|
|
54
|
+
if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return undefined;
|
|
55
|
+
out.push(`{ ${key}: ${el.getText(sf)} }`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildFix(sf: ts.SourceFile, obj: ts.ObjectLiteralExpression, perKind: ts.ObjectLiteralElementLike[]): LintFix | undefined {
|
|
61
|
+
const byKey = new Map<string, ts.ObjectLiteralElementLike>();
|
|
62
|
+
for (const p of perKind) byKey.set(propName(p)!, p);
|
|
63
|
+
const steps: string[] = [];
|
|
64
|
+
for (const key of PER_KIND_ORDER) {
|
|
65
|
+
const p = byKey.get(key);
|
|
66
|
+
if (!p) continue;
|
|
67
|
+
const texts = stepTexts(sf, key, p);
|
|
68
|
+
if (!texts) return undefined;
|
|
69
|
+
steps.push(...texts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const first = perKind[0]!;
|
|
73
|
+
const last = perKind[perKind.length - 1]!;
|
|
74
|
+
const start = first.getStart(sf);
|
|
75
|
+
const end = last.getEnd();
|
|
76
|
+
const lineStart = sf.text.lastIndexOf("\n", start - 1) + 1;
|
|
77
|
+
const indent = /^[ \t]*/.exec(sf.text.slice(lineStart, start))![0];
|
|
78
|
+
// Other props between the first and last per-kind field (rare) are kept, ahead of steps.
|
|
79
|
+
const kept = obj.properties.filter((p) => p.getStart(sf) > start && p.getEnd() < end && !perKind.includes(p)).map((p) => p.getText(sf));
|
|
80
|
+
const stepsText = `${FIX_NOTE}\n${indent}steps: [\n${steps.map((s) => `${indent} ${s},`).join("\n")}\n${indent}]`;
|
|
81
|
+
return { range: [start, end], replacement: [...kept, stepsText].join(`,\n${indent}`), kind: "replace" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function check(context: LintContext): LintDiagnostic[] {
|
|
85
|
+
const { sourceFile: sf } = context;
|
|
86
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
87
|
+
|
|
88
|
+
const visit = (node: ts.Node): void => {
|
|
89
|
+
if (isDockerfileNew(node)) {
|
|
90
|
+
const arg = node.arguments?.[0];
|
|
91
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
92
|
+
for (const obj of stageLiterals(arg)) {
|
|
93
|
+
if (obj.properties.some((p) => propName(p) === "steps")) continue;
|
|
94
|
+
const perKind = obj.properties.filter((p) => PER_KIND.has(propName(p) ?? ""));
|
|
95
|
+
const declared = perKind.map((p) => propName(p)!);
|
|
96
|
+
const fixed = [...declared].sort((a, b) => PER_KIND_ORDER.indexOf(a as PerKindField) - PER_KIND_ORDER.indexOf(b as PerKindField));
|
|
97
|
+
if (declared.join() === fixed.join()) continue;
|
|
98
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(perKind[0]!.getStart(sf));
|
|
99
|
+
const fix = buildFix(sf, obj, perKind);
|
|
100
|
+
diagnostics.push({
|
|
101
|
+
file: sf.fileName,
|
|
102
|
+
line: line + 1,
|
|
103
|
+
column: character + 1,
|
|
104
|
+
ruleId: "DKRS002",
|
|
105
|
+
severity: "warning",
|
|
106
|
+
message:
|
|
107
|
+
`Dockerfile fields declared as ${declared.join(", ")} are written in the fixed order ${fixed.join(", ")}. ` +
|
|
108
|
+
`Use steps to write the instructions in the order the build needs${fix ? " (chant lint --fix rewrites them as steps)" : ""}.`,
|
|
109
|
+
...(fix ? { fix } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ts.forEachChild(node, visit);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
visit(sf);
|
|
118
|
+
return diagnostics;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const perKindOrderRule: LintRule = {
|
|
122
|
+
id: "DKRS002",
|
|
123
|
+
severity: "warning",
|
|
124
|
+
category: "correctness",
|
|
125
|
+
description: "Dockerfile per-kind fields are written in a fixed order that differs from the declared order — use steps",
|
|
126
|
+
check,
|
|
127
|
+
fix(context: LintContext): LintFix[] {
|
|
128
|
+
return check(context).flatMap((d) => (d.fix ? [d.fix] : []));
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -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
|
+
};
|