@docker-doctor/cli 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +372 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.mts +2 -0
- package/dist/cli.mjs +368 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.cjs +20 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +99 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +99 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -0
- package/dist/src-D3U-LUsr.cjs +983 -0
- package/dist/src-D3U-LUsr.cjs.map +1 -0
- package/dist/src-IXht-l0J.mjs +881 -0
- package/dist/src-IXht-l0J.mjs.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
|
|
8
|
+
//#region package.json
|
|
9
|
+
var package_default = {
|
|
10
|
+
name: "docker-doctor",
|
|
11
|
+
version: "0.0.0",
|
|
12
|
+
description: "Static analysis for Dockerfile and Docker Compose files",
|
|
13
|
+
keywords: [
|
|
14
|
+
"best-practices",
|
|
15
|
+
"diagnostics",
|
|
16
|
+
"docker",
|
|
17
|
+
"docker-compose",
|
|
18
|
+
"dockerfile",
|
|
19
|
+
"linter",
|
|
20
|
+
"performance",
|
|
21
|
+
"security"
|
|
22
|
+
],
|
|
23
|
+
homepage: "https://docker-doctor.vercel.app",
|
|
24
|
+
bugs: { "url": "https://github.com/PunGrumpy/docker-doctor/issues" },
|
|
25
|
+
license: "MIT",
|
|
26
|
+
author: {
|
|
27
|
+
"name": "Noppakorn Kaewsalabnil",
|
|
28
|
+
"url": "https://www.pungrumpy.com"
|
|
29
|
+
},
|
|
30
|
+
repository: {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/PunGrumpy/docker-doctor.git",
|
|
33
|
+
"directory": "packages/docker-doctor"
|
|
34
|
+
},
|
|
35
|
+
bin: { "docker-doctor": "./dist/cli.mjs" },
|
|
36
|
+
files: ["dist", "LICENSE"],
|
|
37
|
+
type: "module",
|
|
38
|
+
sideEffects: false,
|
|
39
|
+
exports: { ".": {
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "./dist/index.d.mts",
|
|
42
|
+
"default": "./dist/index.mjs"
|
|
43
|
+
},
|
|
44
|
+
"require": {
|
|
45
|
+
"types": "./dist/index.d.cts",
|
|
46
|
+
"default": "./dist/index.cjs"
|
|
47
|
+
}
|
|
48
|
+
} },
|
|
49
|
+
publishConfig: {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org/"
|
|
52
|
+
},
|
|
53
|
+
scripts: {
|
|
54
|
+
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsdown",
|
|
55
|
+
"dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"clean": "git clean -xdf .turbo node_modules dist"
|
|
58
|
+
},
|
|
59
|
+
dependencies: {
|
|
60
|
+
"@docker-doctor/core": "workspace:*",
|
|
61
|
+
"chalk": "^5.4.1",
|
|
62
|
+
"commander": "^15.0.0",
|
|
63
|
+
"effect": "4.0.0-beta.70",
|
|
64
|
+
"yaml": "^2.7.0"
|
|
65
|
+
},
|
|
66
|
+
devDependencies: {
|
|
67
|
+
"@types/node": "^26",
|
|
68
|
+
"tsdown": "^0.22.4",
|
|
69
|
+
"typescript": "^6"
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region ../core/src/project-info/discover.ts
|
|
75
|
+
const walk = async (dir, fileList = []) => {
|
|
76
|
+
const files = await fs.readdir(dir, { withFileTypes: true });
|
|
77
|
+
await Promise.all(files.map(async (file) => {
|
|
78
|
+
const filePath = path.join(dir, file.name);
|
|
79
|
+
if (file.isDirectory()) {
|
|
80
|
+
if (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
|
|
81
|
+
await walk(filePath, fileList);
|
|
82
|
+
} else fileList.push(filePath);
|
|
83
|
+
}));
|
|
84
|
+
return fileList;
|
|
85
|
+
};
|
|
86
|
+
const discoverProject = async (rootDir) => {
|
|
87
|
+
const allFiles = await walk(rootDir);
|
|
88
|
+
const dockerfiles = [];
|
|
89
|
+
const composeFiles = [];
|
|
90
|
+
for (const file of allFiles) {
|
|
91
|
+
const base = path.basename(file).toLowerCase();
|
|
92
|
+
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(path.relative(rootDir, file));
|
|
93
|
+
if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(path.relative(rootDir, file));
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
composeFiles,
|
|
97
|
+
dockerfiles
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region ../core/src/parsers/dockerfile-parser.ts
|
|
103
|
+
const parseDockerfile = (content) => {
|
|
104
|
+
const instructions = [];
|
|
105
|
+
const lines = content.split(/\r?\n/u);
|
|
106
|
+
let currentInstruction = "";
|
|
107
|
+
let currentArgs = "";
|
|
108
|
+
let startLine = 0;
|
|
109
|
+
let rawAccumulator = [];
|
|
110
|
+
for (const [i, rawLine] of lines.entries()) {
|
|
111
|
+
const trimmed = rawLine.trim();
|
|
112
|
+
const lineNum = i + 1;
|
|
113
|
+
if (!currentInstruction && (trimmed === "" || trimmed.startsWith("#"))) continue;
|
|
114
|
+
rawAccumulator.push(rawLine);
|
|
115
|
+
let lineContent = trimmed;
|
|
116
|
+
if (lineContent.startsWith("#")) continue;
|
|
117
|
+
const hasContinuation = lineContent.endsWith("\\");
|
|
118
|
+
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
119
|
+
if (currentInstruction) currentArgs += (currentArgs ? " " : "") + lineContent;
|
|
120
|
+
else {
|
|
121
|
+
startLine = lineNum;
|
|
122
|
+
const match = lineContent.match(/^(?<inst>[A-Z]+)\s+(?<args>.*)$/iu);
|
|
123
|
+
if (match?.groups) {
|
|
124
|
+
currentInstruction = match.groups.inst.toUpperCase();
|
|
125
|
+
currentArgs = match.groups.args;
|
|
126
|
+
} else {
|
|
127
|
+
const word = lineContent.trim().toUpperCase();
|
|
128
|
+
if ([
|
|
129
|
+
"RUN",
|
|
130
|
+
"CMD",
|
|
131
|
+
"ENTRYPOINT",
|
|
132
|
+
"EXPOSE",
|
|
133
|
+
"USER",
|
|
134
|
+
"WORKDIR"
|
|
135
|
+
].includes(word)) {
|
|
136
|
+
currentInstruction = word;
|
|
137
|
+
currentArgs = "";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!hasContinuation) {
|
|
142
|
+
if (currentInstruction) instructions.push({
|
|
143
|
+
args: currentArgs,
|
|
144
|
+
instruction: currentInstruction,
|
|
145
|
+
line: startLine,
|
|
146
|
+
raw: rawAccumulator.join("\n")
|
|
147
|
+
});
|
|
148
|
+
currentInstruction = "";
|
|
149
|
+
currentArgs = "";
|
|
150
|
+
rawAccumulator = [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (currentInstruction) instructions.push({
|
|
154
|
+
args: currentArgs,
|
|
155
|
+
instruction: currentInstruction,
|
|
156
|
+
line: startLine,
|
|
157
|
+
raw: rawAccumulator.join("\n")
|
|
158
|
+
});
|
|
159
|
+
return instructions;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region ../core/src/errors/config-error.ts
|
|
164
|
+
var ConfigError = class extends Data.TaggedError("ConfigError") {};
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region ../core/src/errors/file-not-found-error.ts
|
|
168
|
+
var FileNotFoundError = class extends Data.TaggedError("FileNotFoundError") {};
|
|
169
|
+
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region ../core/src/errors/parse-error.ts
|
|
172
|
+
var ParseError = class extends Data.TaggedError("ParseError") {};
|
|
173
|
+
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region ../core/src/parsers/compose-parser.ts
|
|
176
|
+
const parseCompose = (content, filepath) => {
|
|
177
|
+
try {
|
|
178
|
+
return parse(content);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new ParseError({
|
|
181
|
+
file: filepath,
|
|
182
|
+
message: error instanceof Error ? error.message : String(error)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region ../core/src/rules/best-practices.ts
|
|
189
|
+
const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
190
|
+
file,
|
|
191
|
+
help,
|
|
192
|
+
line,
|
|
193
|
+
message,
|
|
194
|
+
rule: ruleKey,
|
|
195
|
+
severity
|
|
196
|
+
});
|
|
197
|
+
const requireHealthcheck = {
|
|
198
|
+
category: "Best Practices",
|
|
199
|
+
check(instructions, file) {
|
|
200
|
+
const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
|
|
201
|
+
const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
|
|
202
|
+
if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic$4(file, this.key, this.defaultSeverity, "No HEALTHCHECK instruction found. Containers running services should expose healthchecks to enable auto-healing.", this.help, 1)];
|
|
203
|
+
return [];
|
|
204
|
+
},
|
|
205
|
+
defaultSeverity: "info",
|
|
206
|
+
help: "Use HEALTHCHECK (e.g., 'HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1') so Docker can monitor the container's live status.",
|
|
207
|
+
key: "docker-doctor/require-healthcheck",
|
|
208
|
+
message: "Add a HEALTHCHECK instruction"
|
|
209
|
+
};
|
|
210
|
+
const preferCopyOverAdd = {
|
|
211
|
+
category: "Best Practices",
|
|
212
|
+
check(instructions, file) {
|
|
213
|
+
const diagnostics = [];
|
|
214
|
+
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
215
|
+
const [src] = inst.args.split(/\s+/u);
|
|
216
|
+
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
217
|
+
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
218
|
+
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
|
|
219
|
+
}
|
|
220
|
+
return diagnostics;
|
|
221
|
+
},
|
|
222
|
+
defaultSeverity: "warning",
|
|
223
|
+
help: "Use COPY instead of ADD unless you explicitly need auto-extraction of local compressed archives (tar, zip, etc.).",
|
|
224
|
+
key: "docker-doctor/prefer-copy-over-add",
|
|
225
|
+
message: "Prefer COPY over ADD"
|
|
226
|
+
};
|
|
227
|
+
const useExecForm = {
|
|
228
|
+
category: "Best Practices",
|
|
229
|
+
check(instructions, file) {
|
|
230
|
+
const diagnostics = [];
|
|
231
|
+
for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
|
|
232
|
+
const args = inst.args.trim();
|
|
233
|
+
if (!args.startsWith("[") || !args.endsWith("]")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
|
|
234
|
+
}
|
|
235
|
+
return diagnostics;
|
|
236
|
+
},
|
|
237
|
+
defaultSeverity: "warning",
|
|
238
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
239
|
+
key: "docker-doctor/use-exec-form",
|
|
240
|
+
message: "Use exec form for CMD and ENTRYPOINT"
|
|
241
|
+
};
|
|
242
|
+
const requireLabels = {
|
|
243
|
+
category: "Best Practices",
|
|
244
|
+
check(instructions, file) {
|
|
245
|
+
if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic$4(file, this.key, this.defaultSeverity, "No LABEL metadata was found in this Dockerfile. Adding labels helps identify build information, maintainers, and descriptions.", this.help, 1)];
|
|
246
|
+
return [];
|
|
247
|
+
},
|
|
248
|
+
defaultSeverity: "info",
|
|
249
|
+
help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
|
|
250
|
+
key: "docker-doctor/require-labels",
|
|
251
|
+
message: "Add LABEL metadata to images"
|
|
252
|
+
};
|
|
253
|
+
const combineAptUpdateInstall = {
|
|
254
|
+
category: "Best Practices",
|
|
255
|
+
check(instructions, file) {
|
|
256
|
+
const diagnostics = [];
|
|
257
|
+
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
258
|
+
const hasUpdate = inst.args.includes("apt-get update");
|
|
259
|
+
const hasInstall = inst.args.includes("apt-get install");
|
|
260
|
+
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN apt-get update used without apt-get install in the same instruction. This can cause caching issues and build failures.", this.help, inst.line));
|
|
261
|
+
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN apt-get install used without apt-get update in the same instruction. Always combine them to ensure up-to-date package installation.", this.help, inst.line));
|
|
262
|
+
}
|
|
263
|
+
return diagnostics;
|
|
264
|
+
},
|
|
265
|
+
defaultSeverity: "warning",
|
|
266
|
+
help: "Combine 'apt-get update' and 'apt-get install' in the same RUN instruction (e.g. 'RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf /var/lib/apt/lists/*').",
|
|
267
|
+
key: "docker-doctor/combine-apt-update-install",
|
|
268
|
+
message: "Combine apt-get update and apt-get install"
|
|
269
|
+
};
|
|
270
|
+
const usePipefail = {
|
|
271
|
+
category: "Best Practices",
|
|
272
|
+
check(instructions, file) {
|
|
273
|
+
const diagnostics = [];
|
|
274
|
+
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
275
|
+
const { raw } = inst;
|
|
276
|
+
if (/(?<!\|)\|(?!\|)/u.test(raw) && !raw.includes("pipefail")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
|
|
277
|
+
}
|
|
278
|
+
return diagnostics;
|
|
279
|
+
},
|
|
280
|
+
defaultSeverity: "warning",
|
|
281
|
+
help: "Prepend 'set -o pipefail &&' to pipe commands, or use exec form with a shell that supports it (e.g., RUN ['/bin/bash', '-c', 'set -o pipefail && ...']).",
|
|
282
|
+
key: "docker-doctor/use-pipefail",
|
|
283
|
+
message: "Use pipefail to catch pipeline command failures"
|
|
284
|
+
};
|
|
285
|
+
const absoluteWorkdir = {
|
|
286
|
+
category: "Best Practices",
|
|
287
|
+
check(instructions, file) {
|
|
288
|
+
const diagnostics = [];
|
|
289
|
+
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
290
|
+
const path = inst.args.trim();
|
|
291
|
+
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `WORKDIR specifies a relative path '${path}'. For clarity and reliability, always use absolute paths.`, this.help, inst.line));
|
|
292
|
+
}
|
|
293
|
+
return diagnostics;
|
|
294
|
+
},
|
|
295
|
+
defaultSeverity: "warning",
|
|
296
|
+
help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
|
|
297
|
+
key: "docker-doctor/absolute-workdir",
|
|
298
|
+
message: "Use absolute paths for WORKDIR"
|
|
299
|
+
};
|
|
300
|
+
const avoidRunCd = {
|
|
301
|
+
category: "Best Practices",
|
|
302
|
+
check(instructions, file) {
|
|
303
|
+
const diagnostics = [];
|
|
304
|
+
for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "Avoid using 'cd' in RUN instructions. Use WORKDIR instead to change the working directory stably across layers.", this.help, inst.line));
|
|
305
|
+
return diagnostics;
|
|
306
|
+
},
|
|
307
|
+
defaultSeverity: "info",
|
|
308
|
+
help: "Use the WORKDIR instruction instead of 'cd' inside RUN to establish directory context.",
|
|
309
|
+
key: "docker-doctor/avoid-run-cd",
|
|
310
|
+
message: "Avoid changing directories with cd in RUN"
|
|
311
|
+
};
|
|
312
|
+
const sortMultilineArgs = {
|
|
313
|
+
category: "Best Practices",
|
|
314
|
+
check(instructions, file) {
|
|
315
|
+
const diagnostics = [];
|
|
316
|
+
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
317
|
+
const { raw } = inst;
|
|
318
|
+
const isPackageInstall = raw.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
|
|
319
|
+
const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
|
|
320
|
+
if (isPackageInstall && hasContinuation) {
|
|
321
|
+
const packages = raw.split(/\r?\n/u).slice(1).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("&&") && !line.startsWith("-") && !line.includes("rm -rf")).map((line) => line.endsWith("\\") ? line.slice(0, -1).trim() : line).filter(Boolean);
|
|
322
|
+
if (packages.length > 1) {
|
|
323
|
+
const sorted = packages.toSorted((a, b) => a.localeCompare(b));
|
|
324
|
+
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "Multi-line package arguments are not sorted alphanumerically. Keeping them sorted makes maintenance easier and prevents duplicates.", this.help, inst.line));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return diagnostics;
|
|
329
|
+
},
|
|
330
|
+
defaultSeverity: "info",
|
|
331
|
+
help: "Sort multi-line package installation lists (e.g. apk/apt package lists) alphabetically.",
|
|
332
|
+
key: "docker-doctor/sort-multiline-args",
|
|
333
|
+
message: "Sort multi-line arguments alphanumerically"
|
|
334
|
+
};
|
|
335
|
+
const useraddNoLogInit = {
|
|
336
|
+
category: "Best Practices",
|
|
337
|
+
check(instructions, file) {
|
|
338
|
+
const diagnostics = [];
|
|
339
|
+
for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction runs 'useradd' without '--no-log-init'. This can cause excessive disk space usage / exhaustion under Go's sparse tar archive bug when large UIDs are used.", this.help, inst.line));
|
|
340
|
+
return diagnostics;
|
|
341
|
+
},
|
|
342
|
+
defaultSeverity: "warning",
|
|
343
|
+
help: "Pass '--no-log-init' flag to useradd (e.g., 'RUN useradd --no-log-init -r -g mygroup myuser').",
|
|
344
|
+
key: "docker-doctor/useradd-no-log-init",
|
|
345
|
+
message: "Use --no-log-init with useradd"
|
|
346
|
+
};
|
|
347
|
+
const bestPracticesRules = [
|
|
348
|
+
requireHealthcheck,
|
|
349
|
+
preferCopyOverAdd,
|
|
350
|
+
useExecForm,
|
|
351
|
+
requireLabels,
|
|
352
|
+
combineAptUpdateInstall,
|
|
353
|
+
usePipefail,
|
|
354
|
+
absoluteWorkdir,
|
|
355
|
+
avoidRunCd,
|
|
356
|
+
sortMultilineArgs,
|
|
357
|
+
useraddNoLogInit
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region ../core/src/rules/compose.ts
|
|
362
|
+
const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
|
|
363
|
+
file,
|
|
364
|
+
help,
|
|
365
|
+
message,
|
|
366
|
+
rule: ruleKey,
|
|
367
|
+
severity
|
|
368
|
+
});
|
|
369
|
+
const noVersionKey = {
|
|
370
|
+
category: "Compose",
|
|
371
|
+
check(composeContent, file) {
|
|
372
|
+
if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic$3(file, this.key, this.defaultSeverity, "The 'version' property is deprecated. Remove it to use standard Compose spec behavior.", this.help)];
|
|
373
|
+
return [];
|
|
374
|
+
},
|
|
375
|
+
defaultSeverity: "warning",
|
|
376
|
+
help: "The 'version' key is deprecated by the Compose specification. Omitting it defaults to the latest specification.",
|
|
377
|
+
key: "docker-doctor/no-version-key",
|
|
378
|
+
message: "Remove the 'version' key from Compose file"
|
|
379
|
+
};
|
|
380
|
+
const requireResourceLimits = {
|
|
381
|
+
category: "Compose",
|
|
382
|
+
check(composeContent, file) {
|
|
383
|
+
const diagnostics = [];
|
|
384
|
+
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
385
|
+
const { services } = composeContent;
|
|
386
|
+
if (services && typeof services === "object") {
|
|
387
|
+
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
388
|
+
const limits = (config.deploy?.resources)?.limits;
|
|
389
|
+
if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' does not have CPU or memory limits defined. A resource leak in this service could crash the host.`, this.help));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return diagnostics;
|
|
394
|
+
},
|
|
395
|
+
defaultSeverity: "warning",
|
|
396
|
+
help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
|
|
397
|
+
key: "docker-doctor/require-resource-limits",
|
|
398
|
+
message: "Define resource limits for services"
|
|
399
|
+
};
|
|
400
|
+
const requireRestartPolicy = {
|
|
401
|
+
category: "Compose",
|
|
402
|
+
check(composeContent, file) {
|
|
403
|
+
const diagnostics = [];
|
|
404
|
+
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
405
|
+
const { services } = composeContent;
|
|
406
|
+
if (services && typeof services === "object") {
|
|
407
|
+
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
408
|
+
const hasRestart = "restart" in config;
|
|
409
|
+
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
410
|
+
if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' has no restart policy configured. It will not restart if it crashes or if the host reboots.`, this.help));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return diagnostics;
|
|
415
|
+
},
|
|
416
|
+
defaultSeverity: "warning",
|
|
417
|
+
help: "Define 'restart: always' or 'restart: unless-stopped' (or deploy.restart_policy) so services restart on crashes or host reboot.",
|
|
418
|
+
key: "docker-doctor/require-restart-policy",
|
|
419
|
+
message: "Set restart policy for services"
|
|
420
|
+
};
|
|
421
|
+
const useDependsOnCondition = {
|
|
422
|
+
category: "Compose",
|
|
423
|
+
check(composeContent, file) {
|
|
424
|
+
const diagnostics = [];
|
|
425
|
+
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
426
|
+
const { services } = composeContent;
|
|
427
|
+
if (services && typeof services === "object") {
|
|
428
|
+
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
429
|
+
const dependsOn = config.depends_on;
|
|
430
|
+
if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' uses shorthand depends_on list. This only checks if containers are started, not if they are ready/healthy.`, this.help));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return diagnostics;
|
|
435
|
+
},
|
|
436
|
+
defaultSeverity: "info",
|
|
437
|
+
help: "Instead of a simple service list, use 'depends_on: { dependency: { condition: service_healthy } }' to ensure dependencies are fully ready before starting.",
|
|
438
|
+
key: "docker-doctor/use-depends-on-condition",
|
|
439
|
+
message: "Use long-form depends_on with healthcheck conditions"
|
|
440
|
+
};
|
|
441
|
+
const composeRules = [
|
|
442
|
+
noVersionKey,
|
|
443
|
+
requireResourceLimits,
|
|
444
|
+
requireRestartPolicy,
|
|
445
|
+
useDependsOnCondition
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region ../core/src/rules/image-size.ts
|
|
450
|
+
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
451
|
+
file,
|
|
452
|
+
help,
|
|
453
|
+
line,
|
|
454
|
+
message,
|
|
455
|
+
rule: ruleKey,
|
|
456
|
+
severity
|
|
457
|
+
});
|
|
458
|
+
const preferSlimBase = {
|
|
459
|
+
category: "Image Size",
|
|
460
|
+
check(instructions, file) {
|
|
461
|
+
const diagnostics = [];
|
|
462
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
463
|
+
const [imagePart] = inst.args.split(/\s+/u);
|
|
464
|
+
if (imagePart === "scratch") continue;
|
|
465
|
+
const colonIndex = imagePart.indexOf(":");
|
|
466
|
+
if (colonIndex !== -1) {
|
|
467
|
+
const tag = imagePart.slice(colonIndex + 1).toLowerCase();
|
|
468
|
+
const isSlim = tag.includes("alpine") || tag.includes("slim") || tag.includes("distroless");
|
|
469
|
+
const isSha = tag.startsWith("sha256:");
|
|
470
|
+
const isStageReference = instructions.some((other) => other.line < inst.line && other.instruction === "FROM" && other.args.toLowerCase().includes(` as ${imagePart.toLowerCase()}`));
|
|
471
|
+
if (!isSlim && !isSha && !isStageReference) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Base image '${imagePart}' may be a full-OS distribution. Consider using a slim or alpine alternative.`, this.help, inst.line));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return diagnostics;
|
|
475
|
+
},
|
|
476
|
+
defaultSeverity: "info",
|
|
477
|
+
help: "Prefer tags with '-slim', '-alpine', or use distroless base images to minimize the default operating system footprint.",
|
|
478
|
+
key: "docker-doctor/prefer-slim-base",
|
|
479
|
+
message: "Use slim, alpine, or distroless base images"
|
|
480
|
+
};
|
|
481
|
+
const cleanPackageCache = {
|
|
482
|
+
category: "Image Size",
|
|
483
|
+
check(instructions, file) {
|
|
484
|
+
const diagnostics = [];
|
|
485
|
+
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
486
|
+
const { args } = inst;
|
|
487
|
+
if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
|
|
488
|
+
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
|
|
489
|
+
}
|
|
490
|
+
return diagnostics;
|
|
491
|
+
},
|
|
492
|
+
defaultSeverity: "warning",
|
|
493
|
+
help: "For apt-get, append '&& rm -rf /var/lib/apt/lists/*'. For apk, use 'apk add --no-cache'. For dnf/yum, run 'yum clean all'.",
|
|
494
|
+
key: "docker-doctor/clean-package-cache",
|
|
495
|
+
message: "Clean up package manager cache in the same RUN layer"
|
|
496
|
+
};
|
|
497
|
+
const avoidDevDependencies = {
|
|
498
|
+
category: "Image Size",
|
|
499
|
+
check(instructions, file) {
|
|
500
|
+
const diagnostics = [];
|
|
501
|
+
let isLastStage = false;
|
|
502
|
+
let fromCount = 0;
|
|
503
|
+
for (const inst of instructions) if (inst.instruction === "FROM") fromCount += 1;
|
|
504
|
+
let currentStage = 0;
|
|
505
|
+
for (const inst of instructions) {
|
|
506
|
+
if (inst.instruction === "FROM") {
|
|
507
|
+
currentStage += 1;
|
|
508
|
+
isLastStage = currentStage === fromCount;
|
|
509
|
+
}
|
|
510
|
+
if (isLastStage && inst.instruction === "RUN") {
|
|
511
|
+
const { args } = inst;
|
|
512
|
+
if ((args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' in the final stage without omitting devDependencies.`, this.help, inst.line));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return diagnostics;
|
|
516
|
+
},
|
|
517
|
+
defaultSeverity: "warning",
|
|
518
|
+
help: "For Node.js, run 'npm prune --production' or install only production dependencies ('npm ci --omit=dev') in the runtime stage.",
|
|
519
|
+
key: "docker-doctor/avoid-dev-dependencies",
|
|
520
|
+
message: "Avoid installing development dependencies in final production stage"
|
|
521
|
+
};
|
|
522
|
+
const imageSizeRules = [
|
|
523
|
+
preferSlimBase,
|
|
524
|
+
cleanPackageCache,
|
|
525
|
+
avoidDevDependencies
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region ../core/src/rules/performance.ts
|
|
530
|
+
const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
|
|
531
|
+
file,
|
|
532
|
+
help,
|
|
533
|
+
line,
|
|
534
|
+
message,
|
|
535
|
+
rule: ruleKey,
|
|
536
|
+
severity
|
|
537
|
+
});
|
|
538
|
+
const useMultiStage = {
|
|
539
|
+
category: "Performance",
|
|
540
|
+
check(instructions, file) {
|
|
541
|
+
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
542
|
+
if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || inst.args.includes("make")))) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
|
|
543
|
+
}
|
|
544
|
+
return [];
|
|
545
|
+
},
|
|
546
|
+
defaultSeverity: "info",
|
|
547
|
+
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
548
|
+
key: "docker-doctor/use-multi-stage",
|
|
549
|
+
message: "Consider using multi-stage builds"
|
|
550
|
+
};
|
|
551
|
+
const orderLayers = {
|
|
552
|
+
category: "Performance",
|
|
553
|
+
check(instructions, file) {
|
|
554
|
+
const diagnostics = [];
|
|
555
|
+
let copyAllLine = -1;
|
|
556
|
+
for (const inst of instructions) {
|
|
557
|
+
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
558
|
+
const [src] = inst.args.split(/\s+/u);
|
|
559
|
+
if ((src === "." || src === "./" || src === "*" || src.includes("src")) && copyAllLine === -1) copyAllLine = inst.line;
|
|
560
|
+
}
|
|
561
|
+
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
562
|
+
const args = inst.args.toLowerCase();
|
|
563
|
+
if (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install") || args.includes("bun install") || args.includes("pip install") || args.includes("cargo fetch")) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Running package installation command '${inst.args}' after copying application files (at line ${copyAllLine}). This invalidates the cache on any code changes.`, this.help, inst.line));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return diagnostics;
|
|
567
|
+
},
|
|
568
|
+
defaultSeverity: "warning",
|
|
569
|
+
help: "Copy dependency definition files (like package.json, lockfiles) and run install commands BEFORE copying the rest of the application source code.",
|
|
570
|
+
key: "docker-doctor/order-layers",
|
|
571
|
+
message: "Order layers to maximize build cache utility"
|
|
572
|
+
};
|
|
573
|
+
const minimizeLayers = {
|
|
574
|
+
category: "Performance",
|
|
575
|
+
check(instructions, file) {
|
|
576
|
+
const diagnostics = [];
|
|
577
|
+
let consecutiveRunCount = 0;
|
|
578
|
+
let firstRunLine = -1;
|
|
579
|
+
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
580
|
+
if (consecutiveRunCount === 0) firstRunLine = inst.line;
|
|
581
|
+
consecutiveRunCount += 1;
|
|
582
|
+
} else {
|
|
583
|
+
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
|
|
584
|
+
consecutiveRunCount = 0;
|
|
585
|
+
}
|
|
586
|
+
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
|
|
587
|
+
return diagnostics;
|
|
588
|
+
},
|
|
589
|
+
defaultSeverity: "info",
|
|
590
|
+
help: "Combine consecutive RUN instructions using '&&' and '\\' to reduce the total layer count and image size.",
|
|
591
|
+
key: "docker-doctor/minimize-layers",
|
|
592
|
+
message: "Minimize the number of image layers"
|
|
593
|
+
};
|
|
594
|
+
const useDockerignore = {
|
|
595
|
+
category: "Performance",
|
|
596
|
+
check(instructions, file, context) {
|
|
597
|
+
if (instructions.some((inst) => {
|
|
598
|
+
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
599
|
+
const [src] = inst.args.split(/\s+/u);
|
|
600
|
+
return src === "." || src === "./" || src === "*";
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}) && context?.projectFiles) {
|
|
604
|
+
if (!context.projectFiles.some((f) => f.endsWith(".dockerignore"))) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Using COPY/ADD with wildcard/directory, but no .dockerignore file was found in the workspace. This can copy local build folders and secrets.", this.help, 1)];
|
|
605
|
+
}
|
|
606
|
+
return [];
|
|
607
|
+
},
|
|
608
|
+
defaultSeverity: "warning",
|
|
609
|
+
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
610
|
+
key: "docker-doctor/use-dockerignore",
|
|
611
|
+
message: "Ensure .dockerignore is used"
|
|
612
|
+
};
|
|
613
|
+
const performanceRules = [
|
|
614
|
+
useMultiStage,
|
|
615
|
+
orderLayers,
|
|
616
|
+
minimizeLayers,
|
|
617
|
+
useDockerignore
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region ../core/src/rules/security.ts
|
|
622
|
+
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
623
|
+
file,
|
|
624
|
+
help,
|
|
625
|
+
line,
|
|
626
|
+
message,
|
|
627
|
+
rule: ruleKey,
|
|
628
|
+
severity
|
|
629
|
+
});
|
|
630
|
+
const noRootUser = {
|
|
631
|
+
category: "Security",
|
|
632
|
+
check(instructions, file) {
|
|
633
|
+
let lastUser = "root";
|
|
634
|
+
let lastUserLine = 1;
|
|
635
|
+
for (const inst of instructions) if (inst.instruction === "USER") {
|
|
636
|
+
lastUser = inst.args.trim().toLowerCase();
|
|
637
|
+
lastUserLine = inst.line;
|
|
638
|
+
}
|
|
639
|
+
if (lastUser === "root" || lastUser === "0" || lastUser === "0:0") return [createDiagnostic(file, this.key, this.defaultSeverity, "The container runs as root. Running as root allows potential container breakout vulnerabilities.", this.help, lastUserLine)];
|
|
640
|
+
return [];
|
|
641
|
+
},
|
|
642
|
+
defaultSeverity: "warning",
|
|
643
|
+
help: "Add a non-root user (e.g., 'USER node' or 'USER 1000') to improve security.",
|
|
644
|
+
key: "docker-doctor/no-root-user",
|
|
645
|
+
message: "Container should not run as root user"
|
|
646
|
+
};
|
|
647
|
+
const noSecretsInEnv = {
|
|
648
|
+
category: "Security",
|
|
649
|
+
check(instructions, file) {
|
|
650
|
+
const diagnostics = [];
|
|
651
|
+
const secretKeywords = [
|
|
652
|
+
/password/iu,
|
|
653
|
+
/secret/iu,
|
|
654
|
+
/token/iu,
|
|
655
|
+
/api_key/iu,
|
|
656
|
+
/private_key/iu,
|
|
657
|
+
/auth/iu
|
|
658
|
+
];
|
|
659
|
+
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
660
|
+
const parts = inst.args.split(/\s+/u);
|
|
661
|
+
for (const part of parts) {
|
|
662
|
+
const eqIndex = part.indexOf("=");
|
|
663
|
+
let key = "";
|
|
664
|
+
let value = "";
|
|
665
|
+
if (eqIndex > 0) {
|
|
666
|
+
key = part.slice(0, eqIndex);
|
|
667
|
+
value = part.slice(eqIndex + 1);
|
|
668
|
+
} else key = part;
|
|
669
|
+
if (secretKeywords.some((regex) => regex.test(key)) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return diagnostics;
|
|
673
|
+
},
|
|
674
|
+
defaultSeverity: "error",
|
|
675
|
+
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
676
|
+
key: "docker-doctor/no-secrets-in-env",
|
|
677
|
+
message: "Do not store secrets in ENV or ARG instructions"
|
|
678
|
+
};
|
|
679
|
+
const pinImageVersion = {
|
|
680
|
+
category: "Security",
|
|
681
|
+
check(instructions, file) {
|
|
682
|
+
const diagnostics = [];
|
|
683
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
684
|
+
const [imagePart] = inst.args.split(/\s+/u);
|
|
685
|
+
if (imagePart === "scratch") continue;
|
|
686
|
+
const colonIndex = imagePart.indexOf(":");
|
|
687
|
+
const atIndex = imagePart.indexOf("@");
|
|
688
|
+
if (colonIndex === -1 && atIndex === -1) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' does not specify a tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
689
|
+
else if (colonIndex !== -1) {
|
|
690
|
+
if (imagePart.slice(colonIndex + 1) === "latest") diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' uses the mutable 'latest' tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return diagnostics;
|
|
694
|
+
},
|
|
695
|
+
defaultSeverity: "warning",
|
|
696
|
+
help: "Specify a concrete tag instead of 'latest' or no tag (e.g., 'node:22.2.0-alpine' instead of 'node').",
|
|
697
|
+
key: "docker-doctor/pin-image-version",
|
|
698
|
+
message: "Always pin base image versions to specific tags"
|
|
699
|
+
};
|
|
700
|
+
const noAddRemote = {
|
|
701
|
+
category: "Security",
|
|
702
|
+
check(instructions, file) {
|
|
703
|
+
const diagnostics = [];
|
|
704
|
+
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
705
|
+
const [src] = inst.args.split(/\s+/u);
|
|
706
|
+
if (src.startsWith("http://") || src.startsWith("https://")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction uses a remote URL '${src}'. Remote files added via ADD cannot be cleaned up in later layers, increasing image size.`, this.help, inst.line));
|
|
707
|
+
}
|
|
708
|
+
return diagnostics;
|
|
709
|
+
},
|
|
710
|
+
defaultSeverity: "warning",
|
|
711
|
+
help: "Use 'RUN curl' or 'RUN wget' instead of ADD for remote URLs, and delete the downloaded archive in the same layer to minimize size.",
|
|
712
|
+
key: "docker-doctor/no-add-remote",
|
|
713
|
+
message: "Avoid using ADD with remote URLs"
|
|
714
|
+
};
|
|
715
|
+
const securityRules = [
|
|
716
|
+
noRootUser,
|
|
717
|
+
noSecretsInEnv,
|
|
718
|
+
pinImageVersion,
|
|
719
|
+
noAddRemote
|
|
720
|
+
];
|
|
721
|
+
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region ../core/src/rules/index.ts
|
|
724
|
+
const allDockerfileRules = [
|
|
725
|
+
...securityRules,
|
|
726
|
+
...performanceRules,
|
|
727
|
+
...bestPracticesRules,
|
|
728
|
+
...imageSizeRules
|
|
729
|
+
];
|
|
730
|
+
const allComposeRules = [...composeRules];
|
|
731
|
+
const allRules = [...allDockerfileRules, ...allComposeRules];
|
|
732
|
+
const findRule = (key) => allRules.find((rule) => rule.key === key);
|
|
733
|
+
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region ../core/src/runners/dockerfile-runner.ts
|
|
736
|
+
const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
|
|
737
|
+
const diagnostics = [];
|
|
738
|
+
for (const rule of allDockerfileRules) {
|
|
739
|
+
const configSeverity = rulesConfig?.[rule.key];
|
|
740
|
+
if (configSeverity === "off") continue;
|
|
741
|
+
const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
|
|
742
|
+
if (configSeverity) for (const diag of ruleDiagnostics) diag.severity = configSeverity;
|
|
743
|
+
diagnostics.push(...ruleDiagnostics);
|
|
744
|
+
}
|
|
745
|
+
return diagnostics;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region ../core/src/runners/compose-runner.ts
|
|
750
|
+
const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
751
|
+
const diagnostics = [];
|
|
752
|
+
for (const rule of allComposeRules) {
|
|
753
|
+
const configSeverity = rulesConfig?.[rule.key];
|
|
754
|
+
if (configSeverity === "off") continue;
|
|
755
|
+
const ruleDiagnostics = rule.check(composeContent, file);
|
|
756
|
+
if (configSeverity) for (const diag of ruleDiagnostics) diag.severity = configSeverity;
|
|
757
|
+
diagnostics.push(...ruleDiagnostics);
|
|
758
|
+
}
|
|
759
|
+
return diagnostics;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
//#endregion
|
|
763
|
+
//#region ../core/src/schemas/config.ts
|
|
764
|
+
const RuleSeveritySchema = Schema.Union([
|
|
765
|
+
Schema.Literal("error"),
|
|
766
|
+
Schema.Literal("warning"),
|
|
767
|
+
Schema.Literal("info"),
|
|
768
|
+
Schema.Literal("off")
|
|
769
|
+
]);
|
|
770
|
+
const DockerDoctorConfigSchema = Schema.Struct({
|
|
771
|
+
categories: Schema.optional(Schema.Struct({
|
|
772
|
+
"Best Practices": Schema.optional(RuleSeveritySchema),
|
|
773
|
+
Compose: Schema.optional(RuleSeveritySchema),
|
|
774
|
+
"Image Size": Schema.optional(RuleSeveritySchema),
|
|
775
|
+
Performance: Schema.optional(RuleSeveritySchema),
|
|
776
|
+
Security: Schema.optional(RuleSeveritySchema)
|
|
777
|
+
})),
|
|
778
|
+
ignore: Schema.optional(Schema.Struct({ files: Schema.optional(Schema.Array(Schema.String)) })),
|
|
779
|
+
rules: Schema.optional(Schema.Record(Schema.String, RuleSeveritySchema))
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region ../core/src/config/loader.ts
|
|
784
|
+
const fileExists = async (filePath) => {
|
|
785
|
+
try {
|
|
786
|
+
await fs.access(filePath);
|
|
787
|
+
return true;
|
|
788
|
+
} catch {
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
const importConfig = async (filePath) => {
|
|
793
|
+
if (filePath.endsWith(".json")) try {
|
|
794
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
795
|
+
return JSON.parse(content);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
throw new ConfigError({ message: `Failed to parse config JSON: ${error instanceof Error ? error.message : String(error)}` });
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const configModule = await import(filePath);
|
|
801
|
+
return configModule.default || configModule;
|
|
802
|
+
} catch (error) {
|
|
803
|
+
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${error instanceof Error ? error.message : String(error)}` });
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
const loadConfig = async (rootDir, customPath) => {
|
|
807
|
+
let configObject = null;
|
|
808
|
+
if (customPath) {
|
|
809
|
+
const fullPath = path.resolve(rootDir, customPath);
|
|
810
|
+
if (!await fileExists(fullPath)) throw new ConfigError({ message: `Specified config file not found at ${fullPath}` });
|
|
811
|
+
configObject = await importConfig(fullPath);
|
|
812
|
+
} else {
|
|
813
|
+
for (const cand of [
|
|
814
|
+
"docker-doctor.config.ts",
|
|
815
|
+
"docker-doctor.config.js",
|
|
816
|
+
"docker-doctor.config.mjs",
|
|
817
|
+
"docker-doctor.config.cjs",
|
|
818
|
+
"docker-doctor.config.json"
|
|
819
|
+
]) {
|
|
820
|
+
const fullPath = path.join(rootDir, cand);
|
|
821
|
+
if (await fileExists(fullPath)) {
|
|
822
|
+
configObject = await importConfig(fullPath);
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (!configObject) {
|
|
827
|
+
const pkgPath = path.join(rootDir, "package.json");
|
|
828
|
+
if (await fileExists(pkgPath)) try {
|
|
829
|
+
const pkgContent = await fs.readFile(pkgPath, "utf-8");
|
|
830
|
+
const pkgJson = JSON.parse(pkgContent);
|
|
831
|
+
if (pkgJson.dockerDoctor) configObject = pkgJson.dockerDoctor;
|
|
832
|
+
} catch {}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (!configObject) return {};
|
|
836
|
+
try {
|
|
837
|
+
return Schema.decodeSync(DockerDoctorConfigSchema)(configObject);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
throw new ConfigError({ message: `Invalid configuration format: ${error instanceof Error ? error.message : String(error)}` });
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region ../core/src/scoring.ts
|
|
845
|
+
const calculateScore = (diagnostics) => {
|
|
846
|
+
let penalty = 0;
|
|
847
|
+
for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
|
|
848
|
+
else if (diag.severity === "warning") penalty += 4;
|
|
849
|
+
else if (diag.severity === "info") penalty += 1;
|
|
850
|
+
const score = Math.max(0, 100 - penalty);
|
|
851
|
+
let label = "Critical 🚨";
|
|
852
|
+
if (score >= 90) label = "Excellent 🏆";
|
|
853
|
+
else if (score >= 75) label = "Good ✅";
|
|
854
|
+
else if (score >= 50) label = "Needs Work ⚠️";
|
|
855
|
+
return {
|
|
856
|
+
label,
|
|
857
|
+
score
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
//#endregion
|
|
862
|
+
//#region ../core/src/report.ts
|
|
863
|
+
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
864
|
+
diagnostics: diagnostics.map((d) => ({
|
|
865
|
+
column: d.column,
|
|
866
|
+
file: d.file,
|
|
867
|
+
help: d.help,
|
|
868
|
+
line: d.line,
|
|
869
|
+
message: d.message,
|
|
870
|
+
rule: d.rule,
|
|
871
|
+
severity: d.severity
|
|
872
|
+
})),
|
|
873
|
+
label,
|
|
874
|
+
project,
|
|
875
|
+
score,
|
|
876
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
//#endregion
|
|
880
|
+
export { runDockerfileRules as a, parseCompose as c, package_default as d, runComposeRules as i, parseDockerfile as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, discoverProject as u };
|
|
881
|
+
//# sourceMappingURL=src-IXht-l0J.mjs.map
|