@intentius/chant-lexicon-docker 0.124.30 → 0.124.32

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.
Files changed (97) hide show
  1. package/dist/codegen/docs-cli.js +7 -0
  2. package/dist/codegen/docs.js +163 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-compose.js +16 -0
  5. package/dist/codegen/generate-dockerfile.js +16 -0
  6. package/dist/codegen/generate.d.ts.map +1 -1
  7. package/dist/codegen/generate.js +212 -0
  8. package/dist/codegen/naming.js +45 -0
  9. package/dist/codegen/package.js +40 -0
  10. package/dist/codegen/patches.js +31 -0
  11. package/dist/codegen/versions.js +41 -0
  12. package/dist/compose-key.d.ts +19 -0
  13. package/dist/compose-key.d.ts.map +1 -0
  14. package/dist/compose-key.js +26 -0
  15. package/dist/composites/catalog.js +113 -0
  16. package/dist/composites/docker-otel-collector.js +43 -0
  17. package/dist/composites/docker-web-service.js +51 -0
  18. package/dist/composites/index.js +6 -0
  19. package/dist/coverage.js +35 -0
  20. package/dist/default-labels.js +52 -0
  21. package/dist/detect.js +8 -0
  22. package/dist/dockerfile-order.js +16 -0
  23. package/dist/generated/index.d.ts +10 -0
  24. package/dist/generated/index.d.ts.map +1 -1
  25. package/dist/generated/index.js +8 -0
  26. package/dist/generated/runtime.js +4 -0
  27. package/dist/import/adapter.d.ts.map +1 -1
  28. package/dist/import/adapter.js +86 -0
  29. package/dist/import/generator.d.ts.map +1 -1
  30. package/dist/import/generator.js +159 -0
  31. package/dist/import/parser.d.ts +31 -0
  32. package/dist/import/parser.d.ts.map +1 -1
  33. package/dist/import/parser.js +181 -0
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +20 -0
  37. package/dist/integrity.json +3 -3
  38. package/dist/interpolation.js +54 -0
  39. package/dist/lint/audit-catalog.js +25 -0
  40. package/dist/lint/audit-lineage.js +39 -0
  41. package/dist/lint/post-synth/add-url-checksum.js +35 -0
  42. package/dist/lint/post-synth/apt-no-recommends.js +34 -0
  43. package/dist/lint/post-synth/apt-update-alone.js +34 -0
  44. package/dist/lint/post-synth/cache-busting-copy.js +61 -0
  45. package/dist/lint/post-synth/copy-from-stage.js +53 -0
  46. package/dist/lint/post-synth/docker-helpers.js +148 -0
  47. package/dist/lint/post-synth/index.js +30 -0
  48. package/dist/lint/post-synth/no-latest-image.js +30 -0
  49. package/dist/lint/post-synth/no-root-user.js +35 -0
  50. package/dist/lint/post-synth/prefer-copy.js +43 -0
  51. package/dist/lint/post-synth/relative-workdir.js +34 -0
  52. package/dist/lint/post-synth/repeated-cmd.js +30 -0
  53. package/dist/lint/post-synth/root-after-user.js +39 -0
  54. package/dist/lint/post-synth/ssh-port-exposed.js +58 -0
  55. package/dist/lint/post-synth/unused-global-arg.js +34 -0
  56. package/dist/lint/post-synth/unused-volume.js +41 -0
  57. package/dist/lint/rules/data/deprecated-images.js +26 -0
  58. package/dist/lint/rules/data/known-base-images.js +18 -0
  59. package/dist/lint/rules/index.js +5 -0
  60. package/dist/lint/rules/no-latest-tag.js +57 -0
  61. package/dist/lint/rules/per-kind-order.js +129 -0
  62. package/dist/list-artifacts.js +133 -0
  63. package/dist/lsp/completions.d.ts.map +1 -1
  64. package/dist/lsp/completions.js +18 -0
  65. package/dist/lsp/hover.d.ts.map +1 -1
  66. package/dist/lsp/hover.js +33 -0
  67. package/dist/manifest.json +1 -1
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +229 -0
  70. package/dist/serializer.d.ts.map +1 -1
  71. package/dist/serializer.js +390 -0
  72. package/dist/spec/fetch-compose.js +28 -0
  73. package/dist/spec/fetch-engine.js +19 -0
  74. package/dist/spec/parse-compose.js +160 -0
  75. package/dist/spec/parse-engine.js +36 -0
  76. package/dist/types/index.d.ts +10 -0
  77. package/dist/validate-cli.js +16 -0
  78. package/dist/validate.js +37 -0
  79. package/dist/variables.js +42 -0
  80. package/package.json +8 -8
  81. package/src/codegen/generate.test.ts +10 -0
  82. package/src/codegen/generate.ts +3 -0
  83. package/src/codegen/package.ts +2 -2
  84. package/src/compose-key.ts +31 -0
  85. package/src/generated/index.d.ts +10 -0
  86. package/src/generated/index.ts +10 -0
  87. package/src/import/adapter.ts +2 -1
  88. package/src/import/generator.test.ts +5 -4
  89. package/src/import/generator.ts +75 -48
  90. package/src/import/parser.ts +58 -65
  91. package/src/import/roundtrip.test.ts +46 -3
  92. package/src/import/testdata/names-and-fields.yaml +44 -0
  93. package/src/index.ts +3 -0
  94. package/src/lsp/completions.ts +2 -1
  95. package/src/lsp/hover.ts +2 -1
  96. package/src/serializer.test.ts +31 -0
  97. package/src/serializer.ts +15 -2
@@ -0,0 +1,34 @@
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
+ import { commandText, eachDockerfile, stageLabel } from "./docker-helpers.js";
9
+ const UPDATE = /\bapt(-get)?\s+update\b/;
10
+ const INSTALL = /\bapt(-get)?\s+(install|upgrade|dist-upgrade)\b/;
11
+ export const dkrd020 = {
12
+ id: "DKRD020",
13
+ description: "apt-get update in a different RUN from apt-get install — the install can use a stale package index",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
17
+ for (const stage of parsed.stages) {
18
+ const runs = stage.instructions.filter((i) => i.instruction === "RUN");
19
+ const updateAt = runs.findIndex((r) => UPDATE.test(commandText(r)) && !INSTALL.test(commandText(r)));
20
+ if (updateAt === -1)
21
+ continue;
22
+ if (!runs.slice(updateAt + 1).some((r) => INSTALL.test(commandText(r))))
23
+ continue;
24
+ diagnostics.push({
25
+ checkId: "DKRD020",
26
+ severity: "warning",
27
+ 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 ...`,
28
+ lexicon: "docker",
29
+ });
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,61 @@
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
+ import { commandText, eachDockerfile, instrText, stageLabel } from "./docker-helpers.js";
10
+ /** Installs whose inputs are a few manifest files rather than the whole source tree. */
11
+ const INSTALL = [
12
+ /\bnpm\s+(ci|install|i)(\s+--?[\w-]+(=\S+)?)*\s*($|&&|;|\|)/m,
13
+ /\byarn(\s+install)?(\s+--[\w-]+)*\s*($|&&|;|\|)/m,
14
+ /\bpnpm\s+(install|i)\b/,
15
+ /\bbun\s+install\b/,
16
+ /\bpip3?\s+install\b[^&;|]*\s-r\s/,
17
+ /\bpoetry\s+install\b/,
18
+ /\buv\s+sync\b/,
19
+ /\bpipenv\s+install\b/,
20
+ /\bgo\s+mod\s+download\b/,
21
+ /\bbundle\s+install\b/,
22
+ /\bcomposer\s+install\b/,
23
+ // A string, not a regex literal: the egress scan reads a bare `fetch` in code as a call.
24
+ new RegExp("\\bcargo\\s+fetch\\b"),
25
+ /\bdotnet\s+restore\b/,
26
+ /\bmvn\b[^&;|]*\bdependency:go-offline\b/,
27
+ ];
28
+ /** `COPY . <dest>` (or `./`) from the build context, not from another stage. */
29
+ function copiesWholeContext(value) {
30
+ const words = value.split(/\s+/).filter((w) => w.length > 0);
31
+ if (words.some((w) => w.startsWith("--from")))
32
+ return false;
33
+ const args = words.filter((w) => !w.startsWith("--"));
34
+ if (args[0]?.startsWith("["))
35
+ return false;
36
+ return args.slice(0, -1).some((src) => src === "." || src === "./");
37
+ }
38
+ export const dkrd013 = {
39
+ id: "DKRD013",
40
+ description: "Whole build context copied before a dependency install — every source change re-runs the install",
41
+ check(ctx) {
42
+ const diagnostics = [];
43
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
44
+ for (const stage of parsed.stages) {
45
+ const copyAt = stage.instructions.findIndex((i) => i.instruction === "COPY" && copiesWholeContext(i.value));
46
+ if (copyAt === -1)
47
+ continue;
48
+ const install = stage.instructions.slice(copyAt + 1).find((i) => i.instruction === "RUN" && INSTALL.some((re) => re.test(commandText(i))));
49
+ if (!install)
50
+ continue;
51
+ diagnostics.push({
52
+ checkId: "DKRD013",
53
+ severity: "warning",
54
+ 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.`,
55
+ lexicon: "docker",
56
+ });
57
+ }
58
+ }
59
+ return diagnostics;
60
+ },
61
+ };
@@ -0,0 +1,53 @@
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
+ import { eachDockerfile, stageLabel } from "./docker-helpers.js";
9
+ /** Looks like an image reference rather than a stage name: has a tag, digest or registry path. */
10
+ function looksLikeImage(ref) {
11
+ return /[:/@]/.test(ref);
12
+ }
13
+ export const dkrd016 = {
14
+ id: "DKRD016",
15
+ description: "COPY --from names a stage that is not defined before it",
16
+ check(ctx) {
17
+ const diagnostics = [];
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 !== "COPY" && instr.instruction !== "ADD")
22
+ continue;
23
+ const m = instr.value.match(/--from=(\S+)/);
24
+ if (!m)
25
+ continue;
26
+ const ref = m[1];
27
+ if (ref.includes("$"))
28
+ continue;
29
+ const earlier = parsed.stages.slice(0, stage.index);
30
+ if (/^\d+$/.test(ref)) {
31
+ if (Number(ref) < stage.index)
32
+ continue;
33
+ }
34
+ else if (earlier.some((s) => s.as?.toLowerCase() === ref.toLowerCase())) {
35
+ continue;
36
+ }
37
+ const later = parsed.stages.slice(stage.index).find((s) => s.as?.toLowerCase() === ref.toLowerCase() || String(s.index) === ref);
38
+ if (!later && looksLikeImage(ref))
39
+ 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
+ return diagnostics;
52
+ },
53
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Shared YAML traversal utilities for Docker post-synth checks.
3
+ */
4
+ export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
5
+ import { splitDockerfileInstructions } from "../../import/parser.js";
6
+ /**
7
+ * Extract services section from serialized docker-compose.yml.
8
+ */
9
+ export function extractServices(yaml) {
10
+ const services = new Map();
11
+ const servicesIdx = yaml.search(/^services:\s*$/m);
12
+ if (servicesIdx === -1)
13
+ return services;
14
+ const afterServices = yaml.slice(servicesIdx + yaml.slice(servicesIdx).indexOf("\n") + 1);
15
+ // Stop at next top-level key
16
+ const endMatch = afterServices.search(/^[a-z]/m);
17
+ const servicesContent = endMatch === -1 ? afterServices : afterServices.slice(0, endMatch);
18
+ // Split by service entries (2-space indent + name:)
19
+ const sections = servicesContent.split(/\n(?= [a-z][a-z0-9_-]*:)/);
20
+ for (const section of sections) {
21
+ const nameMatch = section.match(/^\s{2}([a-z][a-z0-9_-]*):/);
22
+ if (!nameMatch)
23
+ continue;
24
+ const name = nameMatch[1];
25
+ const svc = { name };
26
+ const imageMatch = section.match(/^\s{4}image:\s+(.+)$/m);
27
+ if (imageMatch)
28
+ svc.image = imageMatch[1].trim().replace(/^['"]|['"]$/g, "");
29
+ const portsMatch = section.match(/^\s{4}ports:\n((?:\s{6}- .+\n?)+)/m);
30
+ if (portsMatch) {
31
+ svc.ports = [];
32
+ for (const line of portsMatch[1].split("\n")) {
33
+ const item = line.match(/^\s{6}-\s+['"]?(.+?)['"]?$/);
34
+ if (item)
35
+ svc.ports.push(item[1].trim());
36
+ }
37
+ }
38
+ const volsMatch = section.match(/^\s{4}volumes:\n((?:\s{6}- .+\n?)+)/m);
39
+ if (volsMatch) {
40
+ svc.volumes = [];
41
+ for (const line of volsMatch[1].split("\n")) {
42
+ const item = line.match(/^\s{6}-\s+['"]?(.+?)['"]?$/);
43
+ if (item)
44
+ svc.volumes.push(item[1].trim());
45
+ }
46
+ }
47
+ services.set(name, svc);
48
+ }
49
+ return services;
50
+ }
51
+ /**
52
+ * Extract named volumes from the top-level volumes: section.
53
+ */
54
+ export function extractNamedVolumes(yaml) {
55
+ const volumes = new Set();
56
+ const volumesIdx = yaml.search(/^volumes:\s*$/m);
57
+ if (volumesIdx === -1)
58
+ return volumes;
59
+ const afterVolumes = yaml.slice(volumesIdx + yaml.slice(volumesIdx).indexOf("\n") + 1);
60
+ const endMatch = afterVolumes.search(/^[a-z]/m);
61
+ const volumesContent = endMatch === -1 ? afterVolumes : afterVolumes.slice(0, endMatch);
62
+ for (const line of volumesContent.split("\n")) {
63
+ const match = line.match(/^\s{2}([a-z][a-z0-9_-]*):/);
64
+ if (match)
65
+ volumes.add(match[1]);
66
+ }
67
+ return volumes;
68
+ }
69
+ /**
70
+ * Check if an image tag represents :latest or is untagged.
71
+ */
72
+ export function isLatestOrUntagged(image) {
73
+ if (!image || image.startsWith("${"))
74
+ return false;
75
+ if (image.endsWith(":latest"))
76
+ return true;
77
+ const parts = image.split("/");
78
+ const lastPart = parts[parts.length - 1];
79
+ return !lastPart.includes(":") && !lastPart.includes("@");
80
+ }
81
+ /**
82
+ * Get Dockerfile content from SerializerResult files map.
83
+ */
84
+ export function extractDockerfiles(output) {
85
+ const files = new Map();
86
+ if (typeof output !== "object" || output === null)
87
+ return files;
88
+ if (!("files" in output))
89
+ return files;
90
+ const outputFiles = output.files;
91
+ for (const [name, content] of Object.entries(outputFiles)) {
92
+ if (name.startsWith("Dockerfile")) {
93
+ files.set(name, content);
94
+ }
95
+ }
96
+ return files;
97
+ }
98
+ /** A Dockerfile's global ARGs and stages, each stage's instructions in order. */
99
+ export function parseDockerfileStages(content) {
100
+ const out = { globalArgs: [], stages: [] };
101
+ for (const instr of splitDockerfileInstructions(content)) {
102
+ if (instr.instruction === "FROM") {
103
+ const words = instr.value.split(/\s+/).filter((w) => !w.startsWith("--"));
104
+ const asAt = words.findIndex((w) => w.toLowerCase() === "as");
105
+ out.stages.push({
106
+ index: out.stages.length,
107
+ from: words[0] ?? "",
108
+ ...(asAt > 0 && words[asAt + 1] ? { as: words[asAt + 1] } : {}),
109
+ instructions: [],
110
+ });
111
+ }
112
+ else if (out.stages.length > 0) {
113
+ out.stages[out.stages.length - 1].instructions.push(instr);
114
+ }
115
+ else if (instr.instruction === "ARG") {
116
+ out.globalArgs.push(instr);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ /** How a stage is named in a message: `stage "build"` or `stage 2`. */
122
+ export function stageLabel(stage) {
123
+ return stage.as ? `stage "${stage.as}"` : `stage ${stage.index + 1}`;
124
+ }
125
+ /** The first line of an instruction, for a message. */
126
+ export function instrText(instr) {
127
+ const first = instr.value.split("\n")[0].replace(/\s*\\$/, "");
128
+ return `${instr.instruction} ${first}`;
129
+ }
130
+ /** Whether a USER value is root (by name or uid 0, with or without a group). */
131
+ export function isRootUser(value) {
132
+ const user = value.trim().split(":")[0];
133
+ return user === "root" || user === "0";
134
+ }
135
+ /** Every generated Dockerfile in a build's outputs, parsed. */
136
+ export function eachDockerfile(outputs) {
137
+ const out = [];
138
+ for (const [, output] of outputs) {
139
+ for (const [fileName, content] of extractDockerfiles(output)) {
140
+ out.push({ fileName, content, parsed: parseDockerfileStages(content) });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+ /** A RUN value with its line continuations joined, for matching commands. */
146
+ export function commandText(instr) {
147
+ return instr.value.replace(/\\[ \t]*\n/g, " ");
148
+ }
@@ -0,0 +1,30 @@
1
+ import { dkrd001 } from "./no-latest-image.js";
2
+ import { dkrd002 } from "./unused-volume.js";
3
+ import { dkrd003 } from "./ssh-port-exposed.js";
4
+ import { dkrd010 } from "./apt-no-recommends.js";
5
+ import { dkrd011 } from "./prefer-copy.js";
6
+ import { dkrd012 } from "./no-root-user.js";
7
+ import { dkrd013 } from "./cache-busting-copy.js";
8
+ import { dkrd014 } from "./root-after-user.js";
9
+ import { dkrd015 } from "./repeated-cmd.js";
10
+ import { dkrd016 } from "./copy-from-stage.js";
11
+ import { dkrd017 } from "./unused-global-arg.js";
12
+ import { dkrd018 } from "./relative-workdir.js";
13
+ import { dkrd019 } from "./add-url-checksum.js";
14
+ import { dkrd020 } from "./apt-update-alone.js";
15
+ export const postSynthChecks = [
16
+ dkrd001,
17
+ dkrd002,
18
+ dkrd003,
19
+ dkrd010,
20
+ dkrd011,
21
+ dkrd012,
22
+ dkrd013,
23
+ dkrd014,
24
+ dkrd015,
25
+ dkrd016,
26
+ dkrd017,
27
+ dkrd018,
28
+ dkrd019,
29
+ dkrd020,
30
+ ];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * DKRD001: No Latest Image Tag
3
+ *
4
+ * Detects services using :latest or untagged images in docker-compose.yml.
5
+ */
6
+ import { getPrimaryOutput, extractServices, isLatestOrUntagged } from "./docker-helpers.js";
7
+ export const dkrd001 = {
8
+ id: "DKRD001",
9
+ description: "Service uses :latest or untagged image — specify an explicit version tag",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ for (const [_outputName, output] of ctx.outputs) {
13
+ const yaml = getPrimaryOutput(output);
14
+ if (!yaml)
15
+ continue;
16
+ const services = extractServices(yaml);
17
+ for (const [name, svc] of services) {
18
+ if (svc.image && isLatestOrUntagged(svc.image)) {
19
+ diagnostics.push({
20
+ checkId: "DKRD001",
21
+ severity: "warning",
22
+ message: `Service "${name}" uses image "${svc.image}" which is :latest or untagged. Use an explicit version tag for reproducible builds.`,
23
+ lexicon: "docker",
24
+ });
25
+ }
26
+ }
27
+ }
28
+ return diagnostics;
29
+ },
30
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * DKRD012: No Root User
3
+ *
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.
7
+ */
8
+ import { eachDockerfile, isRootUser, stageLabel } from "./docker-helpers.js";
9
+ export const dkrd012 = {
10
+ id: "DKRD012",
11
+ description: "Dockerfile's final stage has no USER instruction, or ends on USER root — container runs as root",
12
+ check(ctx) {
13
+ const diagnostics = [];
14
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
15
+ const final = parsed.stages[parsed.stages.length - 1];
16
+ if (!final)
17
+ continue;
18
+ const users = final.instructions.filter((i) => i.instruction === "USER");
19
+ const last = users[users.length - 1];
20
+ let message;
21
+ if (!last) {
22
+ const anyUser = parsed.stages.some((s) => s.instructions.some((i) => i.instruction === "USER"));
23
+ message = anyUser
24
+ ? `${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.`
25
+ : `${fileName}: No USER instruction found. The container will run as root. Add a USER instruction to improve security.`;
26
+ }
27
+ else if (isRootUser(last.value)) {
28
+ 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.`;
29
+ }
30
+ if (message)
31
+ diagnostics.push({ checkId: "DKRD012", severity: "warning", message, lexicon: "docker" });
32
+ }
33
+ return diagnostics;
34
+ },
35
+ };
@@ -0,0 +1,43 @@
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
+ import { extractDockerfiles } from "./docker-helpers.js";
8
+ function looksLikeUrl(src) {
9
+ return src.startsWith("http://") || src.startsWith("https://") || src.startsWith("ftp://");
10
+ }
11
+ function looksLikeArchive(src) {
12
+ return /\.(tar|tar\.gz|tgz|tar\.bz2|tar\.xz|zip)$/i.test(src);
13
+ }
14
+ export const dkrd011 = {
15
+ id: "DKRD011",
16
+ description: "Prefer COPY over ADD when not fetching URLs or extracting archives",
17
+ check(ctx) {
18
+ const diagnostics = [];
19
+ for (const [_outputName, output] of ctx.outputs) {
20
+ const dockerfiles = extractDockerfiles(output);
21
+ for (const [fileName, content] of dockerfiles) {
22
+ const lines = content.split("\n");
23
+ for (const line of lines) {
24
+ const trimmed = line.trim();
25
+ if (/^ADD\s+/.test(trimmed)) {
26
+ // Extract src argument (first token after ADD)
27
+ const parts = trimmed.replace(/^ADD\s+/, "").split(/\s+/);
28
+ const src = parts[0];
29
+ if (!looksLikeUrl(src) && !looksLikeArchive(src)) {
30
+ diagnostics.push({
31
+ checkId: "DKRD011",
32
+ severity: "info",
33
+ message: `${fileName}: Use COPY instead of ADD when not fetching URLs or extracting archives. ADD "${src}" could be COPY.`,
34
+ lexicon: "docker",
35
+ });
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return diagnostics;
42
+ },
43
+ };
@@ -0,0 +1,34 @@
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
+ import { eachDockerfile, stageLabel } from "./docker-helpers.js";
9
+ export const dkrd018 = {
10
+ id: "DKRD018",
11
+ description: "Relative WORKDIR before any absolute WORKDIR in the stage",
12
+ check(ctx) {
13
+ const diagnostics = [];
14
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
15
+ for (const stage of parsed.stages) {
16
+ for (const instr of stage.instructions) {
17
+ if (instr.instruction !== "WORKDIR")
18
+ continue;
19
+ const dir = instr.value.trim().replace(/^["']|["']$/g, "");
20
+ if (dir.startsWith("/") || dir.startsWith("$") || /^[A-Za-z]:[\\/]/.test(dir))
21
+ break;
22
+ diagnostics.push({
23
+ checkId: "DKRD018",
24
+ severity: "warning",
25
+ message: `${fileName} ${stageLabel(stage)}: WORKDIR ${dir} is relative to the base image's working directory. Use an absolute path.`,
26
+ lexicon: "docker",
27
+ });
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,30 @@
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
+ import { eachDockerfile, instrText, stageLabel } from "./docker-helpers.js";
8
+ export const dkrd015 = {
9
+ id: "DKRD015",
10
+ description: "More than one CMD or ENTRYPOINT in a stage — only the last one takes effect",
11
+ check(ctx) {
12
+ const diagnostics = [];
13
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
14
+ for (const stage of parsed.stages) {
15
+ for (const kw of ["CMD", "ENTRYPOINT"]) {
16
+ const all = stage.instructions.filter((i) => i.instruction === kw);
17
+ if (all.length < 2)
18
+ continue;
19
+ diagnostics.push({
20
+ checkId: "DKRD015",
21
+ severity: "warning",
22
+ message: `${fileName} ${stageLabel(stage)}: ${all.length} ${kw} instructions; only the last (${instrText(all[all.length - 1])}) takes effect. Remove the others.`,
23
+ lexicon: "docker",
24
+ });
25
+ }
26
+ }
27
+ }
28
+ return diagnostics;
29
+ },
30
+ };
@@ -0,0 +1,39 @@
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
+ import { commandText, eachDockerfile, instrText, isRootUser, stageLabel } from "./docker-helpers.js";
9
+ 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/;
10
+ export const dkrd014 = {
11
+ id: "DKRD014",
12
+ description: "RUN needs root but comes after USER switched to a non-root user",
13
+ check(ctx) {
14
+ const diagnostics = [];
15
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
16
+ for (const stage of parsed.stages) {
17
+ let user;
18
+ for (const instr of stage.instructions) {
19
+ if (instr.instruction === "USER") {
20
+ user = instr.value.trim();
21
+ continue;
22
+ }
23
+ if (instr.instruction !== "RUN" || user === undefined || isRootUser(user))
24
+ continue;
25
+ const cmd = commandText(instr);
26
+ if (/\bsudo\b/.test(cmd) || !ROOT_ONLY.test(cmd))
27
+ continue;
28
+ diagnostics.push({
29
+ checkId: "DKRD014",
30
+ severity: "warning",
31
+ 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.`,
32
+ lexicon: "docker",
33
+ });
34
+ }
35
+ }
36
+ }
37
+ return diagnostics;
38
+ },
39
+ };
@@ -0,0 +1,58 @@
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
+ import { getPrimaryOutput, extractServices } from "./docker-helpers.js";
8
+ function exposesSshPort(port) {
9
+ // Formats: "22", "22/tcp", "0.0.0.0:22:22", "127.0.0.1:22:22", "host:container"
10
+ const normalized = port.trim();
11
+ const parts = normalized.split(":");
12
+ if (parts.length === 1) {
13
+ // Just a port number: "22" or "22/tcp"
14
+ const portNum = parts[0].split("/")[0];
15
+ return portNum === "22";
16
+ }
17
+ if (parts.length === 2) {
18
+ // "hostPort:containerPort" e.g. "22:22" — no IP binding, accessible on all interfaces
19
+ const hostPort = parts[0].split("/")[0];
20
+ return hostPort === "22";
21
+ }
22
+ if (parts.length >= 3) {
23
+ // "ip:hostPort:containerPort" e.g. "127.0.0.1:22:22" or "0.0.0.0:22:22"
24
+ const ip = parts[0];
25
+ const hostPort = parts[1].split("/")[0];
26
+ // Only flag if port is 22 AND not bound to loopback
27
+ if (hostPort !== "22")
28
+ return false;
29
+ return ip !== "127.0.0.1" && ip !== "::1" && ip !== "localhost";
30
+ }
31
+ return false;
32
+ }
33
+ export const dkrd003 = {
34
+ id: "DKRD003",
35
+ description: "Service exposes SSH port (22) externally — this is a security risk",
36
+ check(ctx) {
37
+ const diagnostics = [];
38
+ for (const [_outputName, output] of ctx.outputs) {
39
+ const yaml = getPrimaryOutput(output);
40
+ if (!yaml)
41
+ continue;
42
+ const services = extractServices(yaml);
43
+ for (const [name, svc] of services) {
44
+ for (const port of svc.ports ?? []) {
45
+ if (exposesSshPort(port)) {
46
+ diagnostics.push({
47
+ checkId: "DKRD003",
48
+ severity: "error",
49
+ message: `Service "${name}" exposes SSH port 22 externally (port mapping: "${port}"). This is a security risk.`,
50
+ lexicon: "docker",
51
+ });
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return diagnostics;
57
+ },
58
+ };
@@ -0,0 +1,34 @@
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
+ import { eachDockerfile } from "./docker-helpers.js";
9
+ export const dkrd017 = {
10
+ id: "DKRD017",
11
+ description: "ARG before the first FROM is not used by any FROM or stage",
12
+ check(ctx) {
13
+ const diagnostics = [];
14
+ for (const { fileName, content, parsed } of eachDockerfile(ctx.outputs)) {
15
+ const fromLines = content.split("\n").filter((l) => /^\s*FROM\s/i.test(l)).join("\n");
16
+ for (const arg of parsed.globalArgs) {
17
+ const name = arg.value.trim().split("=")[0].trim();
18
+ if (!name)
19
+ continue;
20
+ const used = new RegExp(`\\$\\{?${name}\\b`).test(fromLines);
21
+ const redeclared = parsed.stages.some((s) => s.instructions.some((i) => i.instruction === "ARG" && i.value.trim().split("=")[0].trim() === name));
22
+ if (used || redeclared)
23
+ continue;
24
+ diagnostics.push({
25
+ checkId: "DKRD017",
26
+ severity: "warning",
27
+ 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.`,
28
+ lexicon: "docker",
29
+ });
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };