@docker-doctor/cli 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.cjs +5 -6
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +5 -6
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +14 -5
- package/dist/index.d.mts +14 -5
- package/dist/index.mjs +1 -1
- package/dist/{src-TK9DRoRo.mjs → src-CpGd43y0.mjs} +409 -110
- package/dist/src-CpGd43y0.mjs.map +1 -0
- package/dist/{src-ILbJuJmE.cjs → src-F90_EKmA.cjs} +413 -108
- package/dist/src-F90_EKmA.cjs.map +1 -0
- package/package.json +1 -1
- package/skill/docker-doctor/SKILL.md +1 -1
- package/skill/docker-doctor/references/explain.md +2 -2
- package/dist/src-ILbJuJmE.cjs.map +0 -1
- package/dist/src-TK9DRoRo.mjs.map +0 -1
|
@@ -1,10 +1,62 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { parse } from "yaml";
|
|
4
|
+
import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
|
|
5
5
|
|
|
6
6
|
//#region package.json
|
|
7
|
-
var version = "0.
|
|
7
|
+
var version = "0.5.0";
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region ../core/src/project-info/ignore.ts
|
|
11
|
+
const GLOB_SPECIALS_RE = /[.+^${}()|[\]\\]/gu;
|
|
12
|
+
/**
|
|
13
|
+
* Compiles one glob pattern from `ignore.files` to a RegExp over
|
|
14
|
+
* POSIX-style relative paths. Supported syntax is the subset the docs
|
|
15
|
+
* promise: `**` crosses directory separators (`**` followed by `/` matches
|
|
16
|
+
* zero or more whole segments), `*` and `?` stay within one segment.
|
|
17
|
+
* Brace expansion and character classes are not supported.
|
|
18
|
+
*/
|
|
19
|
+
const globToRegExp = (pattern) => {
|
|
20
|
+
let source = "^";
|
|
21
|
+
let index = 0;
|
|
22
|
+
while (index < pattern.length) {
|
|
23
|
+
const char = pattern[index];
|
|
24
|
+
if (char === "*") {
|
|
25
|
+
if (pattern[index + 1] === "*") {
|
|
26
|
+
if (pattern[index + 2] === "/") {
|
|
27
|
+
source += "(?:[^/]*/)*";
|
|
28
|
+
index += 3;
|
|
29
|
+
} else {
|
|
30
|
+
source += ".*";
|
|
31
|
+
index += 2;
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
source += "[^/]*";
|
|
35
|
+
index += 1;
|
|
36
|
+
}
|
|
37
|
+
} else if (char === "?") {
|
|
38
|
+
source += "[^/]";
|
|
39
|
+
index += 1;
|
|
40
|
+
} else {
|
|
41
|
+
source += char.replace(GLOB_SPECIALS_RE, String.raw`\$&`);
|
|
42
|
+
index += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new RegExp(`${source}$`, "u");
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Builds a predicate over root-relative paths from `ignore.files`
|
|
49
|
+
* patterns. Windows separators in the tested path are normalized to `/`
|
|
50
|
+
* before matching, so patterns are always written POSIX-style.
|
|
51
|
+
*/
|
|
52
|
+
const createIgnoreMatcher = (patterns) => {
|
|
53
|
+
if (!patterns || patterns.length === 0) return () => false;
|
|
54
|
+
const regexps = patterns.map(globToRegExp);
|
|
55
|
+
return (relativePath) => {
|
|
56
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
57
|
+
return regexps.some((regexp) => regexp.test(normalized));
|
|
58
|
+
};
|
|
59
|
+
};
|
|
8
60
|
|
|
9
61
|
//#endregion
|
|
10
62
|
//#region ../core/src/project-info/discover.ts
|
|
@@ -19,16 +71,19 @@ const walk = async (dir, fileList = []) => {
|
|
|
19
71
|
}));
|
|
20
72
|
return fileList;
|
|
21
73
|
};
|
|
22
|
-
const discoverProject = async (rootDir) => {
|
|
74
|
+
const discoverProject = async (rootDir, options) => {
|
|
23
75
|
const allFiles = await walk(rootDir);
|
|
24
76
|
const dockerfiles = [];
|
|
25
77
|
const composeFiles = [];
|
|
26
78
|
const dockerignores = [];
|
|
79
|
+
const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
|
|
27
80
|
for (const file of allFiles) {
|
|
81
|
+
const relative = path.relative(rootDir, file);
|
|
82
|
+
if (isIgnored(relative)) continue;
|
|
28
83
|
const base = path.basename(file).toLowerCase();
|
|
29
|
-
if (base === ".dockerignore") dockerignores.push(
|
|
30
|
-
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(
|
|
31
|
-
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(
|
|
84
|
+
if (base === ".dockerignore") dockerignores.push(relative);
|
|
85
|
+
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
|
|
86
|
+
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(relative);
|
|
32
87
|
}
|
|
33
88
|
return {
|
|
34
89
|
composeFiles: composeFiles.toSorted(),
|
|
@@ -107,7 +162,6 @@ const processHeredocLine = (state, trimmed) => {
|
|
|
107
162
|
};
|
|
108
163
|
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
109
164
|
let lineContent = trimmed;
|
|
110
|
-
if (lineContent.startsWith("#")) return;
|
|
111
165
|
const hasContinuation = lineContent.endsWith("\\");
|
|
112
166
|
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
113
167
|
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
@@ -130,7 +184,8 @@ const parseDockerfile = (content) => {
|
|
|
130
184
|
const trimmed = rawLine.trim();
|
|
131
185
|
const lineNum = i + 1;
|
|
132
186
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
133
|
-
if (!
|
|
187
|
+
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
188
|
+
if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
|
|
134
189
|
state.rawAccumulator.push(rawLine);
|
|
135
190
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
136
191
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -173,6 +228,40 @@ const parseCompose = (content, filepath) => {
|
|
|
173
228
|
});
|
|
174
229
|
}
|
|
175
230
|
};
|
|
231
|
+
/**
|
|
232
|
+
* Builds a {@link ComposeLocator} over the same source text a compose object
|
|
233
|
+
* was parsed from, so rules can attach line numbers to their diagnostics.
|
|
234
|
+
*
|
|
235
|
+
* Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
|
|
236
|
+
* at the merge site, so paths through them resolve to `undefined` — callers
|
|
237
|
+
* fall back to an unnumbered diagnostic, which matches the old behavior.
|
|
238
|
+
*/
|
|
239
|
+
const createComposeLocator = (content) => {
|
|
240
|
+
const lineCounter = new LineCounter();
|
|
241
|
+
const doc = parseDocument(content, {
|
|
242
|
+
lineCounter,
|
|
243
|
+
merge: true
|
|
244
|
+
});
|
|
245
|
+
return (path) => {
|
|
246
|
+
let node = doc.contents;
|
|
247
|
+
let offset;
|
|
248
|
+
for (const segment of path) {
|
|
249
|
+
if (isAlias(node)) node = node.resolve(doc);
|
|
250
|
+
if (isMap(node)) {
|
|
251
|
+
const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
|
|
252
|
+
if (!pair || !isScalar(pair.key)) return;
|
|
253
|
+
offset = pair.key.range?.[0];
|
|
254
|
+
node = pair.value;
|
|
255
|
+
} else if (isSeq(node) && typeof segment === "number") {
|
|
256
|
+
const item = node.items[segment];
|
|
257
|
+
if (item === void 0 || item === null) return;
|
|
258
|
+
offset = item.range?.[0];
|
|
259
|
+
node = item;
|
|
260
|
+
} else return;
|
|
261
|
+
}
|
|
262
|
+
return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
|
|
263
|
+
};
|
|
264
|
+
};
|
|
176
265
|
|
|
177
266
|
//#endregion
|
|
178
267
|
//#region ../core/src/parsers/exec-form.ts
|
|
@@ -228,6 +317,33 @@ const parseImageRef = (ref) => {
|
|
|
228
317
|
tag
|
|
229
318
|
};
|
|
230
319
|
};
|
|
320
|
+
/**
|
|
321
|
+
* Docker Hardened Images (free catalog since Dec 2025) are pulled from the
|
|
322
|
+
* dhi.io registry. Enterprise mirrors live under a plain Docker Hub org
|
|
323
|
+
* namespace and cannot be recognized from the ref alone, so they keep the
|
|
324
|
+
* default rule behavior.
|
|
325
|
+
*/
|
|
326
|
+
const isHardenedImage = (imagePart) => imagePart.toLowerCase().startsWith("dhi.io/");
|
|
327
|
+
/**
|
|
328
|
+
* DHI runtime variants ship no shell or package manager and run as a
|
|
329
|
+
* nonroot user by default. The `-dev` variants keep a shell for build
|
|
330
|
+
* stages and are not assumed to be nonroot.
|
|
331
|
+
*/
|
|
332
|
+
const isHardenedRuntimeImage = (imagePart) => {
|
|
333
|
+
if (!isHardenedImage(imagePart)) return false;
|
|
334
|
+
const { tag } = parseImageRef(imagePart);
|
|
335
|
+
return !(tag === "dev" || tag?.endsWith("-dev"));
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Why a reference would resolve differently over time: no tag at all, or
|
|
339
|
+
* the mutable `latest` tag without a digest. `undefined` means the ref is
|
|
340
|
+
* pinned. Shared by every pinning rule (base images, service images,
|
|
341
|
+
* models) so they agree on what counts as pinned.
|
|
342
|
+
*/
|
|
343
|
+
const mutableRefIssue = (ref) => {
|
|
344
|
+
if (!(ref.tag || ref.digest)) return "untagged";
|
|
345
|
+
if (ref.tag === "latest" && !ref.digest) return "latest";
|
|
346
|
+
};
|
|
231
347
|
const parseFromArgs = (args) => {
|
|
232
348
|
const parts = args.split(/\s+/u).filter(Boolean);
|
|
233
349
|
const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
|
|
@@ -248,8 +364,8 @@ const collectStageAliases = (instructions) => {
|
|
|
248
364
|
};
|
|
249
365
|
|
|
250
366
|
//#endregion
|
|
251
|
-
//#region ../core/src/rules/
|
|
252
|
-
const createDiagnostic
|
|
367
|
+
//#region ../core/src/rules/create-diagnostic.ts
|
|
368
|
+
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
253
369
|
file,
|
|
254
370
|
help,
|
|
255
371
|
line,
|
|
@@ -257,12 +373,15 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
|
257
373
|
rule: ruleKey,
|
|
258
374
|
severity
|
|
259
375
|
});
|
|
376
|
+
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region ../core/src/rules/best-practices.ts
|
|
260
379
|
const requireHealthcheck = {
|
|
261
380
|
category: "Best Practices",
|
|
262
381
|
check(instructions, file) {
|
|
263
382
|
const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
|
|
264
383
|
const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
|
|
265
|
-
if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic
|
|
384
|
+
if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic(file, this.key, this.defaultSeverity, "No HEALTHCHECK instruction found. Containers running services should expose healthchecks to enable auto-healing.", this.help, 1)];
|
|
266
385
|
return [];
|
|
267
386
|
},
|
|
268
387
|
defaultSeverity: "info",
|
|
@@ -279,7 +398,7 @@ const preferCopyOverAdd = {
|
|
|
279
398
|
if (!src) continue;
|
|
280
399
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
281
400
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
282
|
-
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic
|
|
401
|
+
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic(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));
|
|
283
402
|
}
|
|
284
403
|
return diagnostics;
|
|
285
404
|
},
|
|
@@ -292,7 +411,7 @@ const useExecForm = {
|
|
|
292
411
|
category: "Best Practices",
|
|
293
412
|
check(instructions, file) {
|
|
294
413
|
const diagnostics = [];
|
|
295
|
-
for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic
|
|
414
|
+
for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic(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));
|
|
296
415
|
return diagnostics;
|
|
297
416
|
},
|
|
298
417
|
defaultSeverity: "warning",
|
|
@@ -303,7 +422,7 @@ const useExecForm = {
|
|
|
303
422
|
const requireLabels = {
|
|
304
423
|
category: "Best Practices",
|
|
305
424
|
check(instructions, file) {
|
|
306
|
-
if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic
|
|
425
|
+
if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic(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)];
|
|
307
426
|
return [];
|
|
308
427
|
},
|
|
309
428
|
defaultSeverity: "info",
|
|
@@ -318,8 +437,8 @@ const combineAptUpdateInstall = {
|
|
|
318
437
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
319
438
|
const hasUpdate = inst.args.includes("apt-get update");
|
|
320
439
|
const hasInstall = inst.args.includes("apt-get install");
|
|
321
|
-
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic
|
|
322
|
-
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic
|
|
440
|
+
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic(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));
|
|
441
|
+
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic(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));
|
|
323
442
|
}
|
|
324
443
|
return diagnostics;
|
|
325
444
|
},
|
|
@@ -359,7 +478,7 @@ const usePipefail = {
|
|
|
359
478
|
const { args } = inst;
|
|
360
479
|
if (!HAS_PIPE_RE.test(args)) continue;
|
|
361
480
|
const execArgv = parseExecForm(args);
|
|
362
|
-
if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic
|
|
481
|
+
if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic(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));
|
|
363
482
|
}
|
|
364
483
|
return diagnostics;
|
|
365
484
|
},
|
|
@@ -374,7 +493,7 @@ const absoluteWorkdir = {
|
|
|
374
493
|
const diagnostics = [];
|
|
375
494
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
376
495
|
const path = inst.args.trim();
|
|
377
|
-
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic
|
|
496
|
+
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `WORKDIR specifies a relative path '${path}'. For clarity and reliability, always use absolute paths.`, this.help, inst.line));
|
|
378
497
|
}
|
|
379
498
|
return diagnostics;
|
|
380
499
|
},
|
|
@@ -387,7 +506,7 @@ const avoidRunCd = {
|
|
|
387
506
|
category: "Best Practices",
|
|
388
507
|
check(instructions, file) {
|
|
389
508
|
const diagnostics = [];
|
|
390
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic
|
|
509
|
+
for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic(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));
|
|
391
510
|
return diagnostics;
|
|
392
511
|
},
|
|
393
512
|
defaultSeverity: "info",
|
|
@@ -407,7 +526,7 @@ const sortMultilineArgs = {
|
|
|
407
526
|
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);
|
|
408
527
|
if (packages.length > 1) {
|
|
409
528
|
const sorted = packages.toSorted((a, b) => a.localeCompare(b));
|
|
410
|
-
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic
|
|
529
|
+
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic(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));
|
|
411
530
|
}
|
|
412
531
|
}
|
|
413
532
|
}
|
|
@@ -422,7 +541,7 @@ const useraddNoLogInit = {
|
|
|
422
541
|
category: "Best Practices",
|
|
423
542
|
check(instructions, file) {
|
|
424
543
|
const diagnostics = [];
|
|
425
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic
|
|
544
|
+
for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic(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));
|
|
426
545
|
return diagnostics;
|
|
427
546
|
},
|
|
428
547
|
defaultSeverity: "warning",
|
|
@@ -443,19 +562,31 @@ const bestPracticesRules = [
|
|
|
443
562
|
useraddNoLogInit
|
|
444
563
|
];
|
|
445
564
|
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region ../core/src/rules/compose-services.ts
|
|
567
|
+
/**
|
|
568
|
+
* Narrows an unknown compose document to its service entries. A service
|
|
569
|
+
* with a null body (`web:` with nothing under it) is returned as an empty
|
|
570
|
+
* config so rules still check it: it is the least-configured service in
|
|
571
|
+
* the file, not a service to skip. Scalar and array bodies are invalid
|
|
572
|
+
* compose and are dropped.
|
|
573
|
+
*/
|
|
574
|
+
const composeServices = (composeContent) => {
|
|
575
|
+
if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
|
|
576
|
+
const { services } = composeContent;
|
|
577
|
+
if (!services || typeof services !== "object") return [];
|
|
578
|
+
const entries = [];
|
|
579
|
+
for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
|
|
580
|
+
else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
|
|
581
|
+
return entries;
|
|
582
|
+
};
|
|
583
|
+
|
|
446
584
|
//#endregion
|
|
447
585
|
//#region ../core/src/rules/compose.ts
|
|
448
|
-
const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
|
|
449
|
-
file,
|
|
450
|
-
help,
|
|
451
|
-
message,
|
|
452
|
-
rule: ruleKey,
|
|
453
|
-
severity
|
|
454
|
-
});
|
|
455
586
|
const noVersionKey = {
|
|
456
587
|
category: "Compose",
|
|
457
|
-
check(composeContent, file) {
|
|
458
|
-
if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic
|
|
588
|
+
check(composeContent, file, context) {
|
|
589
|
+
if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic(file, this.key, this.defaultSeverity, "The 'version' property is deprecated. Remove it to use standard Compose spec behavior.", this.help, context?.locate?.(["version"]))];
|
|
459
590
|
return [];
|
|
460
591
|
},
|
|
461
592
|
defaultSeverity: "warning",
|
|
@@ -465,16 +596,11 @@ const noVersionKey = {
|
|
|
465
596
|
};
|
|
466
597
|
const requireResourceLimits = {
|
|
467
598
|
category: "Compose",
|
|
468
|
-
check(composeContent, file) {
|
|
599
|
+
check(composeContent, file, context) {
|
|
469
600
|
const diagnostics = [];
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
if (
|
|
473
|
-
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
474
|
-
const limits = (config.deploy?.resources)?.limits;
|
|
475
|
-
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));
|
|
476
|
-
}
|
|
477
|
-
}
|
|
601
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
602
|
+
const limits = (config.deploy?.resources)?.limits;
|
|
603
|
+
if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic(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, context?.locate?.(["services", name])));
|
|
478
604
|
}
|
|
479
605
|
return diagnostics;
|
|
480
606
|
},
|
|
@@ -485,17 +611,12 @@ const requireResourceLimits = {
|
|
|
485
611
|
};
|
|
486
612
|
const requireRestartPolicy = {
|
|
487
613
|
category: "Compose",
|
|
488
|
-
check(composeContent, file) {
|
|
614
|
+
check(composeContent, file, context) {
|
|
489
615
|
const diagnostics = [];
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const hasRestart = "restart" in config;
|
|
495
|
-
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
496
|
-
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));
|
|
497
|
-
}
|
|
498
|
-
}
|
|
616
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
617
|
+
const hasRestart = "restart" in config;
|
|
618
|
+
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
619
|
+
if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic(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, context?.locate?.(["services", name])));
|
|
499
620
|
}
|
|
500
621
|
return diagnostics;
|
|
501
622
|
},
|
|
@@ -506,16 +627,15 @@ const requireRestartPolicy = {
|
|
|
506
627
|
};
|
|
507
628
|
const useDependsOnCondition = {
|
|
508
629
|
category: "Compose",
|
|
509
|
-
check(composeContent, file) {
|
|
630
|
+
check(composeContent, file, context) {
|
|
510
631
|
const diagnostics = [];
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
632
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
633
|
+
const dependsOn = config.depends_on;
|
|
634
|
+
if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic(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, context?.locate?.([
|
|
635
|
+
"services",
|
|
636
|
+
name,
|
|
637
|
+
"depends_on"
|
|
638
|
+
]) ?? context?.locate?.(["services", name])));
|
|
519
639
|
}
|
|
520
640
|
return diagnostics;
|
|
521
641
|
},
|
|
@@ -524,23 +644,211 @@ const useDependsOnCondition = {
|
|
|
524
644
|
key: "docker-doctor/use-depends-on-condition",
|
|
525
645
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
526
646
|
};
|
|
647
|
+
const pinServiceImage = {
|
|
648
|
+
category: "Compose",
|
|
649
|
+
check(composeContent, file, context) {
|
|
650
|
+
const diagnostics = [];
|
|
651
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
652
|
+
const { image } = config;
|
|
653
|
+
if (typeof image !== "string" || "build" in config) continue;
|
|
654
|
+
const ref = parseImageRef(image);
|
|
655
|
+
if (ref.isVariable) continue;
|
|
656
|
+
const issue = mutableRefIssue(ref);
|
|
657
|
+
if (!issue) continue;
|
|
658
|
+
const detail = issue === "untagged" ? `Service '${name}' image '${image}' does not specify a tag.` : `Service '${name}' image '${image}' uses the mutable 'latest' tag.`;
|
|
659
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch a different image.`, this.help, context?.locate?.([
|
|
660
|
+
"services",
|
|
661
|
+
name,
|
|
662
|
+
"image"
|
|
663
|
+
])));
|
|
664
|
+
}
|
|
665
|
+
return diagnostics;
|
|
666
|
+
},
|
|
667
|
+
defaultSeverity: "warning",
|
|
668
|
+
help: "Pin the image to a specific tag or digest (e.g. `nginx:1.27-alpine`) so deploys are reproducible and a rollback actually rolls back.",
|
|
669
|
+
key: "docker-doctor/pin-service-image",
|
|
670
|
+
message: "Pin service images to a specific tag or digest"
|
|
671
|
+
};
|
|
527
672
|
const composeRules = [
|
|
528
673
|
noVersionKey,
|
|
529
674
|
requireResourceLimits,
|
|
530
675
|
requireRestartPolicy,
|
|
531
|
-
useDependsOnCondition
|
|
676
|
+
useDependsOnCondition,
|
|
677
|
+
pinServiceImage
|
|
678
|
+
];
|
|
679
|
+
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region ../core/src/rules/compose-models.ts
|
|
682
|
+
/**
|
|
683
|
+
* Narrows an unknown compose document to its top-level `models:` entries
|
|
684
|
+
* (Docker Model Runner, Compose ≥ 2.35).
|
|
685
|
+
*/
|
|
686
|
+
const topLevelModels = (composeContent) => {
|
|
687
|
+
if (!composeContent || typeof composeContent !== "object" || !("models" in composeContent)) return {};
|
|
688
|
+
const { models } = composeContent;
|
|
689
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) return {};
|
|
690
|
+
return models;
|
|
691
|
+
};
|
|
692
|
+
const undefinedModelReference = {
|
|
693
|
+
category: "Compose",
|
|
694
|
+
check(composeContent, file, context) {
|
|
695
|
+
const diagnostics = [];
|
|
696
|
+
const defined = new Set(Object.keys(topLevelModels(composeContent)));
|
|
697
|
+
const flag = (serviceName, modelName, pathTail) => {
|
|
698
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${serviceName}' references model '${modelName}', which is not declared in the top-level models section. Compose cannot resolve it.`, this.help, context?.locate?.([
|
|
699
|
+
"services",
|
|
700
|
+
serviceName,
|
|
701
|
+
"models",
|
|
702
|
+
pathTail
|
|
703
|
+
])));
|
|
704
|
+
};
|
|
705
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
706
|
+
const { models } = config;
|
|
707
|
+
if (Array.isArray(models)) {
|
|
708
|
+
for (const [index, entry] of models.entries()) if (typeof entry === "string" && !defined.has(entry)) flag(name, entry, index);
|
|
709
|
+
} else if (models && typeof models === "object") {
|
|
710
|
+
for (const modelName of Object.keys(models)) if (!defined.has(modelName)) flag(name, modelName, modelName);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return diagnostics;
|
|
714
|
+
},
|
|
715
|
+
defaultSeverity: "error",
|
|
716
|
+
help: "Every name under a service's `models:` must match an entry in the top-level `models:` element. Declare the model there (with its `model:` OCI artifact) or fix the reference.",
|
|
717
|
+
key: "docker-doctor/undefined-model-reference",
|
|
718
|
+
message: "Service model references must be declared in top-level models"
|
|
719
|
+
};
|
|
720
|
+
const pinModelVersion = {
|
|
721
|
+
category: "Compose",
|
|
722
|
+
check(composeContent, file, context) {
|
|
723
|
+
const diagnostics = [];
|
|
724
|
+
for (const [name, config] of Object.entries(topLevelModels(composeContent))) {
|
|
725
|
+
if (!config || typeof config !== "object") continue;
|
|
726
|
+
const { model } = config;
|
|
727
|
+
if (typeof model !== "string") continue;
|
|
728
|
+
const ref = parseImageRef(model);
|
|
729
|
+
if (ref.isVariable) continue;
|
|
730
|
+
const issue = mutableRefIssue(ref);
|
|
731
|
+
if (!issue) continue;
|
|
732
|
+
const detail = issue === "untagged" ? `Model '${name}' artifact '${model}' does not specify a tag.` : `Model '${name}' artifact '${model}' uses the mutable 'latest' tag.`;
|
|
733
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.([
|
|
734
|
+
"models",
|
|
735
|
+
name,
|
|
736
|
+
"model"
|
|
737
|
+
])));
|
|
738
|
+
}
|
|
739
|
+
return diagnostics;
|
|
740
|
+
},
|
|
741
|
+
defaultSeverity: "warning",
|
|
742
|
+
help: "Pin the model to a specific tag (e.g. `ai/gemma3:4B-Q4_0`) so every environment runs the same weights. Model behavior differences are far harder to debug than software version drift.",
|
|
743
|
+
key: "docker-doctor/pin-model-version",
|
|
744
|
+
message: "Pin models to a specific tag or digest"
|
|
745
|
+
};
|
|
746
|
+
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
747
|
+
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region ../core/src/rules/secret-keywords.ts
|
|
750
|
+
const SECRET_KEY_PATTERNS = [
|
|
751
|
+
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
752
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
753
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
754
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
755
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
756
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
757
|
+
];
|
|
758
|
+
const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
759
|
+
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region ../core/src/rules/compose-security.ts
|
|
762
|
+
const DOCKER_SOCKET = "/var/run/docker.sock";
|
|
763
|
+
const noPrivilegedService = {
|
|
764
|
+
category: "Compose",
|
|
765
|
+
check(composeContent, file, context) {
|
|
766
|
+
const diagnostics = [];
|
|
767
|
+
for (const [name, config] of composeServices(composeContent)) if (config.privileged === true) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' runs in privileged mode. A privileged container has full access to the host's devices and kernel, so compromising this service compromises the host.`, this.help, context?.locate?.([
|
|
768
|
+
"services",
|
|
769
|
+
name,
|
|
770
|
+
"privileged"
|
|
771
|
+
])));
|
|
772
|
+
return diagnostics;
|
|
773
|
+
},
|
|
774
|
+
defaultSeverity: "error",
|
|
775
|
+
help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
|
|
776
|
+
key: "docker-doctor/no-privileged-service",
|
|
777
|
+
message: "Do not run services in privileged mode"
|
|
778
|
+
};
|
|
779
|
+
const mountsDockerSocket = (volume) => {
|
|
780
|
+
if (typeof volume === "string") return volume.split(":")[0] === DOCKER_SOCKET;
|
|
781
|
+
if (volume && typeof volume === "object") return volume.source === DOCKER_SOCKET;
|
|
782
|
+
return false;
|
|
783
|
+
};
|
|
784
|
+
const noDockerSocketMount = {
|
|
785
|
+
category: "Compose",
|
|
786
|
+
check(composeContent, file, context) {
|
|
787
|
+
const diagnostics = [];
|
|
788
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
789
|
+
const { volumes } = config;
|
|
790
|
+
if (!Array.isArray(volumes)) continue;
|
|
791
|
+
for (const [index, volume] of volumes.entries()) if (mountsDockerSocket(volume)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' bind-mounts the Docker socket. Anything running in this container can control the Docker daemon: start privileged containers, read every volume, and escape to the host.`, this.help, context?.locate?.([
|
|
792
|
+
"services",
|
|
793
|
+
name,
|
|
794
|
+
"volumes",
|
|
795
|
+
index
|
|
796
|
+
])));
|
|
797
|
+
}
|
|
798
|
+
return diagnostics;
|
|
799
|
+
},
|
|
800
|
+
defaultSeverity: "error",
|
|
801
|
+
help: "If the service genuinely needs the Docker API (agent tooling like MCP gateways often does), prefer `use_api_socket: true` or a filtering socket proxy over a raw bind mount of `/var/run/docker.sock`.",
|
|
802
|
+
key: "docker-doctor/no-docker-socket-mount",
|
|
803
|
+
message: "Do not bind-mount the Docker socket into services"
|
|
804
|
+
};
|
|
805
|
+
const isLiteralSecretValue = (value) => typeof value === "string" && value.length > 0 && !value.startsWith("$");
|
|
806
|
+
const noPlaintextSecrets = {
|
|
807
|
+
category: "Compose",
|
|
808
|
+
check(composeContent, file, context) {
|
|
809
|
+
const diagnostics = [];
|
|
810
|
+
const flag = (name, key, line) => {
|
|
811
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret in service '${name}' environment: '${key}'. A literal value here lives in version control in plain text.`, this.help, line));
|
|
812
|
+
};
|
|
813
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
814
|
+
const { environment } = config;
|
|
815
|
+
if (Array.isArray(environment)) for (const [index, entry] of environment.entries()) {
|
|
816
|
+
if (typeof entry !== "string") continue;
|
|
817
|
+
const eqIndex = entry.indexOf("=");
|
|
818
|
+
if (eqIndex <= 0) continue;
|
|
819
|
+
const key = entry.slice(0, eqIndex);
|
|
820
|
+
const value = entry.slice(eqIndex + 1);
|
|
821
|
+
if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
822
|
+
"services",
|
|
823
|
+
name,
|
|
824
|
+
"environment",
|
|
825
|
+
index
|
|
826
|
+
]));
|
|
827
|
+
}
|
|
828
|
+
else if (environment && typeof environment === "object") {
|
|
829
|
+
for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
830
|
+
"services",
|
|
831
|
+
name,
|
|
832
|
+
"environment",
|
|
833
|
+
key
|
|
834
|
+
]));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return diagnostics;
|
|
838
|
+
},
|
|
839
|
+
defaultSeverity: "warning",
|
|
840
|
+
help: "Move the value to an `env_file` kept out of version control, interpolate it from the host environment (`${VAR}`), or use Compose `secrets:`.",
|
|
841
|
+
key: "docker-doctor/no-plaintext-secrets",
|
|
842
|
+
message: "Avoid literal secret values in Compose environment"
|
|
843
|
+
};
|
|
844
|
+
const composeSecurityRules = [
|
|
845
|
+
noPrivilegedService,
|
|
846
|
+
noDockerSocketMount,
|
|
847
|
+
noPlaintextSecrets
|
|
532
848
|
];
|
|
533
849
|
|
|
534
850
|
//#endregion
|
|
535
851
|
//#region ../core/src/rules/image-size.ts
|
|
536
|
-
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
537
|
-
file,
|
|
538
|
-
help,
|
|
539
|
-
line,
|
|
540
|
-
message,
|
|
541
|
-
rule: ruleKey,
|
|
542
|
-
severity
|
|
543
|
-
});
|
|
544
852
|
const preferSlimBase = {
|
|
545
853
|
category: "Image Size",
|
|
546
854
|
check(instructions, file) {
|
|
@@ -551,10 +859,11 @@ const preferSlimBase = {
|
|
|
551
859
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
552
860
|
const ref = parseImageRef(imagePart);
|
|
553
861
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
862
|
+
if (isHardenedImage(imagePart)) continue;
|
|
554
863
|
if (ref.digest) continue;
|
|
555
864
|
if (!ref.tag) continue;
|
|
556
865
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
557
|
-
if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic
|
|
866
|
+
if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic(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));
|
|
558
867
|
}
|
|
559
868
|
return diagnostics;
|
|
560
869
|
},
|
|
@@ -563,14 +872,20 @@ const preferSlimBase = {
|
|
|
563
872
|
key: "docker-doctor/prefer-slim-base",
|
|
564
873
|
message: "Prefer slim, alpine, or distroless base images"
|
|
565
874
|
};
|
|
875
|
+
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
876
|
+
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
877
|
+
const cacheMountTargets = (args) => [...args.matchAll(BUILDKIT_MOUNT_FLAG_RE)].map((match) => (match.groups?.spec ?? "").split(",")).filter((options) => options.includes("type=cache")).flatMap((options) => options.filter((option) => CACHE_TARGET_KEY_RE.test(option)).map((option) => option.slice(option.indexOf("=") + 1)));
|
|
878
|
+
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
879
|
+
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
880
|
+
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
566
881
|
const cleanPackageCache = {
|
|
567
882
|
category: "Image Size",
|
|
568
883
|
check(instructions, file) {
|
|
569
884
|
const diagnostics = [];
|
|
570
885
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
571
886
|
const { args } = inst;
|
|
572
|
-
if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic
|
|
573
|
-
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic
|
|
887
|
+
if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(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));
|
|
888
|
+
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk") && !hasCacheMountFor(args, APK_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
|
|
574
889
|
}
|
|
575
890
|
return diagnostics;
|
|
576
891
|
},
|
|
@@ -607,7 +922,7 @@ const avoidDevDependencies = {
|
|
|
607
922
|
for (const inst of stage.runs) {
|
|
608
923
|
if (!installsDevDependencies(inst.args)) continue;
|
|
609
924
|
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
610
|
-
diagnostics.push(createDiagnostic
|
|
925
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
611
926
|
}
|
|
612
927
|
}
|
|
613
928
|
return diagnostics;
|
|
@@ -625,19 +940,11 @@ const imageSizeRules = [
|
|
|
625
940
|
|
|
626
941
|
//#endregion
|
|
627
942
|
//#region ../core/src/rules/performance.ts
|
|
628
|
-
const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
|
|
629
|
-
file,
|
|
630
|
-
help,
|
|
631
|
-
line,
|
|
632
|
-
message,
|
|
633
|
-
rule: ruleKey,
|
|
634
|
-
severity
|
|
635
|
-
});
|
|
636
943
|
const useMultiStage = {
|
|
637
944
|
category: "Performance",
|
|
638
945
|
check(instructions, file) {
|
|
639
946
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
640
|
-
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
|
|
947
|
+
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(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)];
|
|
641
948
|
}
|
|
642
949
|
return [];
|
|
643
950
|
},
|
|
@@ -661,7 +968,7 @@ const orderLayers = {
|
|
|
661
968
|
}
|
|
662
969
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
663
970
|
const args = inst.args.toLowerCase();
|
|
664
|
-
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
|
|
971
|
+
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(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));
|
|
665
972
|
}
|
|
666
973
|
}
|
|
667
974
|
return diagnostics;
|
|
@@ -681,10 +988,10 @@ const minimizeLayers = {
|
|
|
681
988
|
if (consecutiveRunCount === 0) firstRunLine = inst.line;
|
|
682
989
|
consecutiveRunCount += 1;
|
|
683
990
|
} else {
|
|
684
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
991
|
+
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic(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));
|
|
685
992
|
consecutiveRunCount = 0;
|
|
686
993
|
}
|
|
687
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
994
|
+
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic(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));
|
|
688
995
|
return diagnostics;
|
|
689
996
|
},
|
|
690
997
|
defaultSeverity: "info",
|
|
@@ -709,7 +1016,7 @@ const useDockerignore = {
|
|
|
709
1016
|
return src === "." || src === "./" || src === "*";
|
|
710
1017
|
}
|
|
711
1018
|
return false;
|
|
712
|
-
}) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic
|
|
1019
|
+
}) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic(file, this.key, this.defaultSeverity, "Using COPY/ADD with a wildcard or directory, but no .dockerignore file was found next to the Dockerfile or at the project root. This can copy local build folders and secrets.", this.help, 1)];
|
|
713
1020
|
return [];
|
|
714
1021
|
},
|
|
715
1022
|
defaultSeverity: "warning",
|
|
@@ -726,14 +1033,6 @@ const performanceRules = [
|
|
|
726
1033
|
|
|
727
1034
|
//#endregion
|
|
728
1035
|
//#region ../core/src/rules/security.ts
|
|
729
|
-
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
730
|
-
file,
|
|
731
|
-
help,
|
|
732
|
-
line,
|
|
733
|
-
message,
|
|
734
|
-
rule: ruleKey,
|
|
735
|
-
severity
|
|
736
|
-
});
|
|
737
1036
|
const isRootUser = (value) => {
|
|
738
1037
|
const [user] = value.split(":");
|
|
739
1038
|
return user === "root" || user === "0";
|
|
@@ -747,7 +1046,8 @@ const noRootUser = {
|
|
|
747
1046
|
let lastUserLine = 1;
|
|
748
1047
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
749
1048
|
const { base, stage } = parseFromArgs(inst.args);
|
|
750
|
-
|
|
1049
|
+
const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
|
|
1050
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
|
|
751
1051
|
lastUserLine = inst.line;
|
|
752
1052
|
currentStage = stage?.toLowerCase() ?? null;
|
|
753
1053
|
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
@@ -768,21 +1068,13 @@ const noSecretsInEnv = {
|
|
|
768
1068
|
category: "Security",
|
|
769
1069
|
check(instructions, file) {
|
|
770
1070
|
const diagnostics = [];
|
|
771
|
-
const secretKeywords = [
|
|
772
|
-
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
773
|
-
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
774
|
-
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
775
|
-
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
776
|
-
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
777
|
-
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
778
|
-
];
|
|
779
1071
|
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
780
1072
|
const args = inst.args.trim();
|
|
781
1073
|
if (inst.instruction === "ENV" && !args.includes("=")) {
|
|
782
1074
|
const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
|
|
783
1075
|
if (match?.groups) {
|
|
784
1076
|
const { key, value } = match.groups;
|
|
785
|
-
if (
|
|
1077
|
+
if (isSecretKey(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));
|
|
786
1078
|
}
|
|
787
1079
|
} else {
|
|
788
1080
|
const parts = args.split(/\s+/u);
|
|
@@ -794,7 +1086,7 @@ const noSecretsInEnv = {
|
|
|
794
1086
|
key = part.slice(0, eqIndex);
|
|
795
1087
|
value = part.slice(eqIndex + 1);
|
|
796
1088
|
} else key = part;
|
|
797
|
-
if (
|
|
1089
|
+
if (isSecretKey(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));
|
|
798
1090
|
}
|
|
799
1091
|
}
|
|
800
1092
|
}
|
|
@@ -815,8 +1107,11 @@ const pinImageVersion = {
|
|
|
815
1107
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
816
1108
|
const ref = parseImageRef(imagePart);
|
|
817
1109
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
818
|
-
|
|
819
|
-
|
|
1110
|
+
const issue = mutableRefIssue(ref);
|
|
1111
|
+
if (issue) {
|
|
1112
|
+
const detail = issue === "untagged" ? `Base image '${imagePart}' does not specify a tag.` : `Base image '${imagePart}' uses the mutable 'latest' tag.`;
|
|
1113
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} This makes builds non-deterministic.`, this.help, inst.line));
|
|
1114
|
+
}
|
|
820
1115
|
}
|
|
821
1116
|
return diagnostics;
|
|
822
1117
|
},
|
|
@@ -856,7 +1151,11 @@ const allDockerfileRules = [
|
|
|
856
1151
|
...bestPracticesRules,
|
|
857
1152
|
...imageSizeRules
|
|
858
1153
|
];
|
|
859
|
-
const allComposeRules = [
|
|
1154
|
+
const allComposeRules = [
|
|
1155
|
+
...composeRules,
|
|
1156
|
+
...composeSecurityRules,
|
|
1157
|
+
...composeModelRules
|
|
1158
|
+
];
|
|
860
1159
|
const allRules = [...allDockerfileRules, ...allComposeRules];
|
|
861
1160
|
const findRule = (key) => allRules.find((rule) => rule.key === key);
|
|
862
1161
|
|
|
@@ -880,12 +1179,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
|
|
|
880
1179
|
|
|
881
1180
|
//#endregion
|
|
882
1181
|
//#region ../core/src/runners/compose-runner.ts
|
|
883
|
-
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
|
|
1182
|
+
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
|
|
884
1183
|
const diagnostics = [];
|
|
885
1184
|
for (const rule of allComposeRules) {
|
|
886
1185
|
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
887
1186
|
if (severity === "off") continue;
|
|
888
|
-
const ruleDiagnostics = rule.check(composeContent, file);
|
|
1187
|
+
const ruleDiagnostics = rule.check(composeContent, file, { locate });
|
|
889
1188
|
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
890
1189
|
diagnostics.push(...ruleDiagnostics);
|
|
891
1190
|
}
|
|
@@ -1116,5 +1415,5 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1116
1415
|
});
|
|
1117
1416
|
|
|
1118
1417
|
//#endregion
|
|
1119
|
-
export { runDockerfileRules as a,
|
|
1120
|
-
//# sourceMappingURL=src-
|
|
1418
|
+
export { runDockerfileRules as a, createComposeLocator as c, discoverProject as d, version as f, runComposeRules as i, parseCompose as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, parseDockerfile as u };
|
|
1419
|
+
//# sourceMappingURL=src-CpGd43y0.mjs.map
|