@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
|
@@ -33,7 +33,59 @@ node_path = __toESM(node_path, 1);
|
|
|
33
33
|
let yaml = require("yaml");
|
|
34
34
|
|
|
35
35
|
//#region package.json
|
|
36
|
-
var version = "0.
|
|
36
|
+
var version = "0.5.0";
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region ../core/src/project-info/ignore.ts
|
|
40
|
+
const GLOB_SPECIALS_RE = /[.+^${}()|[\]\\]/gu;
|
|
41
|
+
/**
|
|
42
|
+
* Compiles one glob pattern from `ignore.files` to a RegExp over
|
|
43
|
+
* POSIX-style relative paths. Supported syntax is the subset the docs
|
|
44
|
+
* promise: `**` crosses directory separators (`**` followed by `/` matches
|
|
45
|
+
* zero or more whole segments), `*` and `?` stay within one segment.
|
|
46
|
+
* Brace expansion and character classes are not supported.
|
|
47
|
+
*/
|
|
48
|
+
const globToRegExp = (pattern) => {
|
|
49
|
+
let source = "^";
|
|
50
|
+
let index = 0;
|
|
51
|
+
while (index < pattern.length) {
|
|
52
|
+
const char = pattern[index];
|
|
53
|
+
if (char === "*") {
|
|
54
|
+
if (pattern[index + 1] === "*") {
|
|
55
|
+
if (pattern[index + 2] === "/") {
|
|
56
|
+
source += "(?:[^/]*/)*";
|
|
57
|
+
index += 3;
|
|
58
|
+
} else {
|
|
59
|
+
source += ".*";
|
|
60
|
+
index += 2;
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
source += "[^/]*";
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
} else if (char === "?") {
|
|
67
|
+
source += "[^/]";
|
|
68
|
+
index += 1;
|
|
69
|
+
} else {
|
|
70
|
+
source += char.replace(GLOB_SPECIALS_RE, String.raw`\$&`);
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return new RegExp(`${source}$`, "u");
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Builds a predicate over root-relative paths from `ignore.files`
|
|
78
|
+
* patterns. Windows separators in the tested path are normalized to `/`
|
|
79
|
+
* before matching, so patterns are always written POSIX-style.
|
|
80
|
+
*/
|
|
81
|
+
const createIgnoreMatcher = (patterns) => {
|
|
82
|
+
if (!patterns || patterns.length === 0) return () => false;
|
|
83
|
+
const regexps = patterns.map(globToRegExp);
|
|
84
|
+
return (relativePath) => {
|
|
85
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
86
|
+
return regexps.some((regexp) => regexp.test(normalized));
|
|
87
|
+
};
|
|
88
|
+
};
|
|
37
89
|
|
|
38
90
|
//#endregion
|
|
39
91
|
//#region ../core/src/project-info/discover.ts
|
|
@@ -48,16 +100,19 @@ const walk = async (dir, fileList = []) => {
|
|
|
48
100
|
}));
|
|
49
101
|
return fileList;
|
|
50
102
|
};
|
|
51
|
-
const discoverProject = async (rootDir) => {
|
|
103
|
+
const discoverProject = async (rootDir, options) => {
|
|
52
104
|
const allFiles = await walk(rootDir);
|
|
53
105
|
const dockerfiles = [];
|
|
54
106
|
const composeFiles = [];
|
|
55
107
|
const dockerignores = [];
|
|
108
|
+
const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
|
|
56
109
|
for (const file of allFiles) {
|
|
110
|
+
const relative = node_path.default.relative(rootDir, file);
|
|
111
|
+
if (isIgnored(relative)) continue;
|
|
57
112
|
const base = node_path.default.basename(file).toLowerCase();
|
|
58
|
-
if (base === ".dockerignore") dockerignores.push(
|
|
59
|
-
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(
|
|
60
|
-
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(
|
|
113
|
+
if (base === ".dockerignore") dockerignores.push(relative);
|
|
114
|
+
if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
|
|
115
|
+
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);
|
|
61
116
|
}
|
|
62
117
|
return {
|
|
63
118
|
composeFiles: composeFiles.toSorted(),
|
|
@@ -136,7 +191,6 @@ const processHeredocLine = (state, trimmed) => {
|
|
|
136
191
|
};
|
|
137
192
|
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
138
193
|
let lineContent = trimmed;
|
|
139
|
-
if (lineContent.startsWith("#")) return;
|
|
140
194
|
const hasContinuation = lineContent.endsWith("\\");
|
|
141
195
|
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
142
196
|
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
@@ -159,7 +213,8 @@ const parseDockerfile = (content) => {
|
|
|
159
213
|
const trimmed = rawLine.trim();
|
|
160
214
|
const lineNum = i + 1;
|
|
161
215
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
162
|
-
if (!
|
|
216
|
+
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
217
|
+
if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
|
|
163
218
|
state.rawAccumulator.push(rawLine);
|
|
164
219
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
165
220
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -202,6 +257,40 @@ const parseCompose = (content, filepath) => {
|
|
|
202
257
|
});
|
|
203
258
|
}
|
|
204
259
|
};
|
|
260
|
+
/**
|
|
261
|
+
* Builds a {@link ComposeLocator} over the same source text a compose object
|
|
262
|
+
* was parsed from, so rules can attach line numbers to their diagnostics.
|
|
263
|
+
*
|
|
264
|
+
* Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
|
|
265
|
+
* at the merge site, so paths through them resolve to `undefined` — callers
|
|
266
|
+
* fall back to an unnumbered diagnostic, which matches the old behavior.
|
|
267
|
+
*/
|
|
268
|
+
const createComposeLocator = (content) => {
|
|
269
|
+
const lineCounter = new yaml.LineCounter();
|
|
270
|
+
const doc = (0, yaml.parseDocument)(content, {
|
|
271
|
+
lineCounter,
|
|
272
|
+
merge: true
|
|
273
|
+
});
|
|
274
|
+
return (path) => {
|
|
275
|
+
let node = doc.contents;
|
|
276
|
+
let offset;
|
|
277
|
+
for (const segment of path) {
|
|
278
|
+
if ((0, yaml.isAlias)(node)) node = node.resolve(doc);
|
|
279
|
+
if ((0, yaml.isMap)(node)) {
|
|
280
|
+
const pair = node.items.find((item) => (0, yaml.isScalar)(item.key) && String(item.key.value) === String(segment));
|
|
281
|
+
if (!pair || !(0, yaml.isScalar)(pair.key)) return;
|
|
282
|
+
offset = pair.key.range?.[0];
|
|
283
|
+
node = pair.value;
|
|
284
|
+
} else if ((0, yaml.isSeq)(node) && typeof segment === "number") {
|
|
285
|
+
const item = node.items[segment];
|
|
286
|
+
if (item === void 0 || item === null) return;
|
|
287
|
+
offset = item.range?.[0];
|
|
288
|
+
node = item;
|
|
289
|
+
} else return;
|
|
290
|
+
}
|
|
291
|
+
return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
|
|
292
|
+
};
|
|
293
|
+
};
|
|
205
294
|
|
|
206
295
|
//#endregion
|
|
207
296
|
//#region ../core/src/parsers/exec-form.ts
|
|
@@ -257,6 +346,33 @@ const parseImageRef = (ref) => {
|
|
|
257
346
|
tag
|
|
258
347
|
};
|
|
259
348
|
};
|
|
349
|
+
/**
|
|
350
|
+
* Docker Hardened Images (free catalog since Dec 2025) are pulled from the
|
|
351
|
+
* dhi.io registry. Enterprise mirrors live under a plain Docker Hub org
|
|
352
|
+
* namespace and cannot be recognized from the ref alone, so they keep the
|
|
353
|
+
* default rule behavior.
|
|
354
|
+
*/
|
|
355
|
+
const isHardenedImage = (imagePart) => imagePart.toLowerCase().startsWith("dhi.io/");
|
|
356
|
+
/**
|
|
357
|
+
* DHI runtime variants ship no shell or package manager and run as a
|
|
358
|
+
* nonroot user by default. The `-dev` variants keep a shell for build
|
|
359
|
+
* stages and are not assumed to be nonroot.
|
|
360
|
+
*/
|
|
361
|
+
const isHardenedRuntimeImage = (imagePart) => {
|
|
362
|
+
if (!isHardenedImage(imagePart)) return false;
|
|
363
|
+
const { tag } = parseImageRef(imagePart);
|
|
364
|
+
return !(tag === "dev" || tag?.endsWith("-dev"));
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* Why a reference would resolve differently over time: no tag at all, or
|
|
368
|
+
* the mutable `latest` tag without a digest. `undefined` means the ref is
|
|
369
|
+
* pinned. Shared by every pinning rule (base images, service images,
|
|
370
|
+
* models) so they agree on what counts as pinned.
|
|
371
|
+
*/
|
|
372
|
+
const mutableRefIssue = (ref) => {
|
|
373
|
+
if (!(ref.tag || ref.digest)) return "untagged";
|
|
374
|
+
if (ref.tag === "latest" && !ref.digest) return "latest";
|
|
375
|
+
};
|
|
260
376
|
const parseFromArgs = (args) => {
|
|
261
377
|
const parts = args.split(/\s+/u).filter(Boolean);
|
|
262
378
|
const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
|
|
@@ -277,8 +393,8 @@ const collectStageAliases = (instructions) => {
|
|
|
277
393
|
};
|
|
278
394
|
|
|
279
395
|
//#endregion
|
|
280
|
-
//#region ../core/src/rules/
|
|
281
|
-
const createDiagnostic
|
|
396
|
+
//#region ../core/src/rules/create-diagnostic.ts
|
|
397
|
+
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
282
398
|
file,
|
|
283
399
|
help,
|
|
284
400
|
line,
|
|
@@ -286,12 +402,15 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
|
286
402
|
rule: ruleKey,
|
|
287
403
|
severity
|
|
288
404
|
});
|
|
405
|
+
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region ../core/src/rules/best-practices.ts
|
|
289
408
|
const requireHealthcheck = {
|
|
290
409
|
category: "Best Practices",
|
|
291
410
|
check(instructions, file) {
|
|
292
411
|
const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
|
|
293
412
|
const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
|
|
294
|
-
if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic
|
|
413
|
+
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)];
|
|
295
414
|
return [];
|
|
296
415
|
},
|
|
297
416
|
defaultSeverity: "info",
|
|
@@ -308,7 +427,7 @@ const preferCopyOverAdd = {
|
|
|
308
427
|
if (!src) continue;
|
|
309
428
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
310
429
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
311
|
-
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic
|
|
430
|
+
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));
|
|
312
431
|
}
|
|
313
432
|
return diagnostics;
|
|
314
433
|
},
|
|
@@ -321,7 +440,7 @@ const useExecForm = {
|
|
|
321
440
|
category: "Best Practices",
|
|
322
441
|
check(instructions, file) {
|
|
323
442
|
const diagnostics = [];
|
|
324
|
-
for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic
|
|
443
|
+
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));
|
|
325
444
|
return diagnostics;
|
|
326
445
|
},
|
|
327
446
|
defaultSeverity: "warning",
|
|
@@ -332,7 +451,7 @@ const useExecForm = {
|
|
|
332
451
|
const requireLabels = {
|
|
333
452
|
category: "Best Practices",
|
|
334
453
|
check(instructions, file) {
|
|
335
|
-
if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic
|
|
454
|
+
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)];
|
|
336
455
|
return [];
|
|
337
456
|
},
|
|
338
457
|
defaultSeverity: "info",
|
|
@@ -347,8 +466,8 @@ const combineAptUpdateInstall = {
|
|
|
347
466
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
348
467
|
const hasUpdate = inst.args.includes("apt-get update");
|
|
349
468
|
const hasInstall = inst.args.includes("apt-get install");
|
|
350
|
-
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic
|
|
351
|
-
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic
|
|
469
|
+
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));
|
|
470
|
+
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));
|
|
352
471
|
}
|
|
353
472
|
return diagnostics;
|
|
354
473
|
},
|
|
@@ -388,7 +507,7 @@ const usePipefail = {
|
|
|
388
507
|
const { args } = inst;
|
|
389
508
|
if (!HAS_PIPE_RE.test(args)) continue;
|
|
390
509
|
const execArgv = parseExecForm(args);
|
|
391
|
-
if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic
|
|
510
|
+
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));
|
|
392
511
|
}
|
|
393
512
|
return diagnostics;
|
|
394
513
|
},
|
|
@@ -403,7 +522,7 @@ const absoluteWorkdir = {
|
|
|
403
522
|
const diagnostics = [];
|
|
404
523
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
405
524
|
const path = inst.args.trim();
|
|
406
|
-
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic
|
|
525
|
+
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));
|
|
407
526
|
}
|
|
408
527
|
return diagnostics;
|
|
409
528
|
},
|
|
@@ -416,7 +535,7 @@ const avoidRunCd = {
|
|
|
416
535
|
category: "Best Practices",
|
|
417
536
|
check(instructions, file) {
|
|
418
537
|
const diagnostics = [];
|
|
419
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic
|
|
538
|
+
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));
|
|
420
539
|
return diagnostics;
|
|
421
540
|
},
|
|
422
541
|
defaultSeverity: "info",
|
|
@@ -436,7 +555,7 @@ const sortMultilineArgs = {
|
|
|
436
555
|
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);
|
|
437
556
|
if (packages.length > 1) {
|
|
438
557
|
const sorted = packages.toSorted((a, b) => a.localeCompare(b));
|
|
439
|
-
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic
|
|
558
|
+
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));
|
|
440
559
|
}
|
|
441
560
|
}
|
|
442
561
|
}
|
|
@@ -451,7 +570,7 @@ const useraddNoLogInit = {
|
|
|
451
570
|
category: "Best Practices",
|
|
452
571
|
check(instructions, file) {
|
|
453
572
|
const diagnostics = [];
|
|
454
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic
|
|
573
|
+
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));
|
|
455
574
|
return diagnostics;
|
|
456
575
|
},
|
|
457
576
|
defaultSeverity: "warning",
|
|
@@ -472,19 +591,31 @@ const bestPracticesRules = [
|
|
|
472
591
|
useraddNoLogInit
|
|
473
592
|
];
|
|
474
593
|
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region ../core/src/rules/compose-services.ts
|
|
596
|
+
/**
|
|
597
|
+
* Narrows an unknown compose document to its service entries. A service
|
|
598
|
+
* with a null body (`web:` with nothing under it) is returned as an empty
|
|
599
|
+
* config so rules still check it: it is the least-configured service in
|
|
600
|
+
* the file, not a service to skip. Scalar and array bodies are invalid
|
|
601
|
+
* compose and are dropped.
|
|
602
|
+
*/
|
|
603
|
+
const composeServices = (composeContent) => {
|
|
604
|
+
if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
|
|
605
|
+
const { services } = composeContent;
|
|
606
|
+
if (!services || typeof services !== "object") return [];
|
|
607
|
+
const entries = [];
|
|
608
|
+
for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
|
|
609
|
+
else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
|
|
610
|
+
return entries;
|
|
611
|
+
};
|
|
612
|
+
|
|
475
613
|
//#endregion
|
|
476
614
|
//#region ../core/src/rules/compose.ts
|
|
477
|
-
const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
|
|
478
|
-
file,
|
|
479
|
-
help,
|
|
480
|
-
message,
|
|
481
|
-
rule: ruleKey,
|
|
482
|
-
severity
|
|
483
|
-
});
|
|
484
615
|
const noVersionKey = {
|
|
485
616
|
category: "Compose",
|
|
486
|
-
check(composeContent, file) {
|
|
487
|
-
if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic
|
|
617
|
+
check(composeContent, file, context) {
|
|
618
|
+
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"]))];
|
|
488
619
|
return [];
|
|
489
620
|
},
|
|
490
621
|
defaultSeverity: "warning",
|
|
@@ -494,16 +625,11 @@ const noVersionKey = {
|
|
|
494
625
|
};
|
|
495
626
|
const requireResourceLimits = {
|
|
496
627
|
category: "Compose",
|
|
497
|
-
check(composeContent, file) {
|
|
628
|
+
check(composeContent, file, context) {
|
|
498
629
|
const diagnostics = [];
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
if (
|
|
502
|
-
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
503
|
-
const limits = (config.deploy?.resources)?.limits;
|
|
504
|
-
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));
|
|
505
|
-
}
|
|
506
|
-
}
|
|
630
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
631
|
+
const limits = (config.deploy?.resources)?.limits;
|
|
632
|
+
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])));
|
|
507
633
|
}
|
|
508
634
|
return diagnostics;
|
|
509
635
|
},
|
|
@@ -514,17 +640,12 @@ const requireResourceLimits = {
|
|
|
514
640
|
};
|
|
515
641
|
const requireRestartPolicy = {
|
|
516
642
|
category: "Compose",
|
|
517
|
-
check(composeContent, file) {
|
|
643
|
+
check(composeContent, file, context) {
|
|
518
644
|
const diagnostics = [];
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const hasRestart = "restart" in config;
|
|
524
|
-
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
525
|
-
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));
|
|
526
|
-
}
|
|
527
|
-
}
|
|
645
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
646
|
+
const hasRestart = "restart" in config;
|
|
647
|
+
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
648
|
+
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])));
|
|
528
649
|
}
|
|
529
650
|
return diagnostics;
|
|
530
651
|
},
|
|
@@ -535,16 +656,15 @@ const requireRestartPolicy = {
|
|
|
535
656
|
};
|
|
536
657
|
const useDependsOnCondition = {
|
|
537
658
|
category: "Compose",
|
|
538
|
-
check(composeContent, file) {
|
|
659
|
+
check(composeContent, file, context) {
|
|
539
660
|
const diagnostics = [];
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
661
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
662
|
+
const dependsOn = config.depends_on;
|
|
663
|
+
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?.([
|
|
664
|
+
"services",
|
|
665
|
+
name,
|
|
666
|
+
"depends_on"
|
|
667
|
+
]) ?? context?.locate?.(["services", name])));
|
|
548
668
|
}
|
|
549
669
|
return diagnostics;
|
|
550
670
|
},
|
|
@@ -553,23 +673,211 @@ const useDependsOnCondition = {
|
|
|
553
673
|
key: "docker-doctor/use-depends-on-condition",
|
|
554
674
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
555
675
|
};
|
|
676
|
+
const pinServiceImage = {
|
|
677
|
+
category: "Compose",
|
|
678
|
+
check(composeContent, file, context) {
|
|
679
|
+
const diagnostics = [];
|
|
680
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
681
|
+
const { image } = config;
|
|
682
|
+
if (typeof image !== "string" || "build" in config) continue;
|
|
683
|
+
const ref = parseImageRef(image);
|
|
684
|
+
if (ref.isVariable) continue;
|
|
685
|
+
const issue = mutableRefIssue(ref);
|
|
686
|
+
if (!issue) continue;
|
|
687
|
+
const detail = issue === "untagged" ? `Service '${name}' image '${image}' does not specify a tag.` : `Service '${name}' image '${image}' uses the mutable 'latest' tag.`;
|
|
688
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch a different image.`, this.help, context?.locate?.([
|
|
689
|
+
"services",
|
|
690
|
+
name,
|
|
691
|
+
"image"
|
|
692
|
+
])));
|
|
693
|
+
}
|
|
694
|
+
return diagnostics;
|
|
695
|
+
},
|
|
696
|
+
defaultSeverity: "warning",
|
|
697
|
+
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.",
|
|
698
|
+
key: "docker-doctor/pin-service-image",
|
|
699
|
+
message: "Pin service images to a specific tag or digest"
|
|
700
|
+
};
|
|
556
701
|
const composeRules = [
|
|
557
702
|
noVersionKey,
|
|
558
703
|
requireResourceLimits,
|
|
559
704
|
requireRestartPolicy,
|
|
560
|
-
useDependsOnCondition
|
|
705
|
+
useDependsOnCondition,
|
|
706
|
+
pinServiceImage
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
//#endregion
|
|
710
|
+
//#region ../core/src/rules/compose-models.ts
|
|
711
|
+
/**
|
|
712
|
+
* Narrows an unknown compose document to its top-level `models:` entries
|
|
713
|
+
* (Docker Model Runner, Compose ≥ 2.35).
|
|
714
|
+
*/
|
|
715
|
+
const topLevelModels = (composeContent) => {
|
|
716
|
+
if (!composeContent || typeof composeContent !== "object" || !("models" in composeContent)) return {};
|
|
717
|
+
const { models } = composeContent;
|
|
718
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) return {};
|
|
719
|
+
return models;
|
|
720
|
+
};
|
|
721
|
+
const undefinedModelReference = {
|
|
722
|
+
category: "Compose",
|
|
723
|
+
check(composeContent, file, context) {
|
|
724
|
+
const diagnostics = [];
|
|
725
|
+
const defined = new Set(Object.keys(topLevelModels(composeContent)));
|
|
726
|
+
const flag = (serviceName, modelName, pathTail) => {
|
|
727
|
+
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?.([
|
|
728
|
+
"services",
|
|
729
|
+
serviceName,
|
|
730
|
+
"models",
|
|
731
|
+
pathTail
|
|
732
|
+
])));
|
|
733
|
+
};
|
|
734
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
735
|
+
const { models } = config;
|
|
736
|
+
if (Array.isArray(models)) {
|
|
737
|
+
for (const [index, entry] of models.entries()) if (typeof entry === "string" && !defined.has(entry)) flag(name, entry, index);
|
|
738
|
+
} else if (models && typeof models === "object") {
|
|
739
|
+
for (const modelName of Object.keys(models)) if (!defined.has(modelName)) flag(name, modelName, modelName);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return diagnostics;
|
|
743
|
+
},
|
|
744
|
+
defaultSeverity: "error",
|
|
745
|
+
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.",
|
|
746
|
+
key: "docker-doctor/undefined-model-reference",
|
|
747
|
+
message: "Service model references must be declared in top-level models"
|
|
748
|
+
};
|
|
749
|
+
const pinModelVersion = {
|
|
750
|
+
category: "Compose",
|
|
751
|
+
check(composeContent, file, context) {
|
|
752
|
+
const diagnostics = [];
|
|
753
|
+
for (const [name, config] of Object.entries(topLevelModels(composeContent))) {
|
|
754
|
+
if (!config || typeof config !== "object") continue;
|
|
755
|
+
const { model } = config;
|
|
756
|
+
if (typeof model !== "string") continue;
|
|
757
|
+
const ref = parseImageRef(model);
|
|
758
|
+
if (ref.isVariable) continue;
|
|
759
|
+
const issue = mutableRefIssue(ref);
|
|
760
|
+
if (!issue) continue;
|
|
761
|
+
const detail = issue === "untagged" ? `Model '${name}' artifact '${model}' does not specify a tag.` : `Model '${name}' artifact '${model}' uses the mutable 'latest' tag.`;
|
|
762
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.([
|
|
763
|
+
"models",
|
|
764
|
+
name,
|
|
765
|
+
"model"
|
|
766
|
+
])));
|
|
767
|
+
}
|
|
768
|
+
return diagnostics;
|
|
769
|
+
},
|
|
770
|
+
defaultSeverity: "warning",
|
|
771
|
+
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.",
|
|
772
|
+
key: "docker-doctor/pin-model-version",
|
|
773
|
+
message: "Pin models to a specific tag or digest"
|
|
774
|
+
};
|
|
775
|
+
const composeModelRules = [undefinedModelReference, pinModelVersion];
|
|
776
|
+
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region ../core/src/rules/secret-keywords.ts
|
|
779
|
+
const SECRET_KEY_PATTERNS = [
|
|
780
|
+
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
781
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
782
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
783
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
784
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
785
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
786
|
+
];
|
|
787
|
+
const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
|
|
788
|
+
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region ../core/src/rules/compose-security.ts
|
|
791
|
+
const DOCKER_SOCKET = "/var/run/docker.sock";
|
|
792
|
+
const noPrivilegedService = {
|
|
793
|
+
category: "Compose",
|
|
794
|
+
check(composeContent, file, context) {
|
|
795
|
+
const diagnostics = [];
|
|
796
|
+
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?.([
|
|
797
|
+
"services",
|
|
798
|
+
name,
|
|
799
|
+
"privileged"
|
|
800
|
+
])));
|
|
801
|
+
return diagnostics;
|
|
802
|
+
},
|
|
803
|
+
defaultSeverity: "error",
|
|
804
|
+
help: "Remove `privileged: true` and grant only what the service needs: specific capabilities via `cap_add`, or individual device access via `devices`.",
|
|
805
|
+
key: "docker-doctor/no-privileged-service",
|
|
806
|
+
message: "Do not run services in privileged mode"
|
|
807
|
+
};
|
|
808
|
+
const mountsDockerSocket = (volume) => {
|
|
809
|
+
if (typeof volume === "string") return volume.split(":")[0] === DOCKER_SOCKET;
|
|
810
|
+
if (volume && typeof volume === "object") return volume.source === DOCKER_SOCKET;
|
|
811
|
+
return false;
|
|
812
|
+
};
|
|
813
|
+
const noDockerSocketMount = {
|
|
814
|
+
category: "Compose",
|
|
815
|
+
check(composeContent, file, context) {
|
|
816
|
+
const diagnostics = [];
|
|
817
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
818
|
+
const { volumes } = config;
|
|
819
|
+
if (!Array.isArray(volumes)) continue;
|
|
820
|
+
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?.([
|
|
821
|
+
"services",
|
|
822
|
+
name,
|
|
823
|
+
"volumes",
|
|
824
|
+
index
|
|
825
|
+
])));
|
|
826
|
+
}
|
|
827
|
+
return diagnostics;
|
|
828
|
+
},
|
|
829
|
+
defaultSeverity: "error",
|
|
830
|
+
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`.",
|
|
831
|
+
key: "docker-doctor/no-docker-socket-mount",
|
|
832
|
+
message: "Do not bind-mount the Docker socket into services"
|
|
833
|
+
};
|
|
834
|
+
const isLiteralSecretValue = (value) => typeof value === "string" && value.length > 0 && !value.startsWith("$");
|
|
835
|
+
const noPlaintextSecrets = {
|
|
836
|
+
category: "Compose",
|
|
837
|
+
check(composeContent, file, context) {
|
|
838
|
+
const diagnostics = [];
|
|
839
|
+
const flag = (name, key, line) => {
|
|
840
|
+
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));
|
|
841
|
+
};
|
|
842
|
+
for (const [name, config] of composeServices(composeContent)) {
|
|
843
|
+
const { environment } = config;
|
|
844
|
+
if (Array.isArray(environment)) for (const [index, entry] of environment.entries()) {
|
|
845
|
+
if (typeof entry !== "string") continue;
|
|
846
|
+
const eqIndex = entry.indexOf("=");
|
|
847
|
+
if (eqIndex <= 0) continue;
|
|
848
|
+
const key = entry.slice(0, eqIndex);
|
|
849
|
+
const value = entry.slice(eqIndex + 1);
|
|
850
|
+
if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
851
|
+
"services",
|
|
852
|
+
name,
|
|
853
|
+
"environment",
|
|
854
|
+
index
|
|
855
|
+
]));
|
|
856
|
+
}
|
|
857
|
+
else if (environment && typeof environment === "object") {
|
|
858
|
+
for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
|
|
859
|
+
"services",
|
|
860
|
+
name,
|
|
861
|
+
"environment",
|
|
862
|
+
key
|
|
863
|
+
]));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return diagnostics;
|
|
867
|
+
},
|
|
868
|
+
defaultSeverity: "warning",
|
|
869
|
+
help: "Move the value to an `env_file` kept out of version control, interpolate it from the host environment (`${VAR}`), or use Compose `secrets:`.",
|
|
870
|
+
key: "docker-doctor/no-plaintext-secrets",
|
|
871
|
+
message: "Avoid literal secret values in Compose environment"
|
|
872
|
+
};
|
|
873
|
+
const composeSecurityRules = [
|
|
874
|
+
noPrivilegedService,
|
|
875
|
+
noDockerSocketMount,
|
|
876
|
+
noPlaintextSecrets
|
|
561
877
|
];
|
|
562
878
|
|
|
563
879
|
//#endregion
|
|
564
880
|
//#region ../core/src/rules/image-size.ts
|
|
565
|
-
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
566
|
-
file,
|
|
567
|
-
help,
|
|
568
|
-
line,
|
|
569
|
-
message,
|
|
570
|
-
rule: ruleKey,
|
|
571
|
-
severity
|
|
572
|
-
});
|
|
573
881
|
const preferSlimBase = {
|
|
574
882
|
category: "Image Size",
|
|
575
883
|
check(instructions, file) {
|
|
@@ -580,10 +888,11 @@ const preferSlimBase = {
|
|
|
580
888
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
581
889
|
const ref = parseImageRef(imagePart);
|
|
582
890
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
891
|
+
if (isHardenedImage(imagePart)) continue;
|
|
583
892
|
if (ref.digest) continue;
|
|
584
893
|
if (!ref.tag) continue;
|
|
585
894
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
586
|
-
if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic
|
|
895
|
+
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));
|
|
587
896
|
}
|
|
588
897
|
return diagnostics;
|
|
589
898
|
},
|
|
@@ -592,14 +901,20 @@ const preferSlimBase = {
|
|
|
592
901
|
key: "docker-doctor/prefer-slim-base",
|
|
593
902
|
message: "Prefer slim, alpine, or distroless base images"
|
|
594
903
|
};
|
|
904
|
+
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
905
|
+
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
906
|
+
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)));
|
|
907
|
+
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
908
|
+
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
909
|
+
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
595
910
|
const cleanPackageCache = {
|
|
596
911
|
category: "Image Size",
|
|
597
912
|
check(instructions, file) {
|
|
598
913
|
const diagnostics = [];
|
|
599
914
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
600
915
|
const { args } = inst;
|
|
601
|
-
if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic
|
|
602
|
-
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic
|
|
916
|
+
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));
|
|
917
|
+
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));
|
|
603
918
|
}
|
|
604
919
|
return diagnostics;
|
|
605
920
|
},
|
|
@@ -636,7 +951,7 @@ const avoidDevDependencies = {
|
|
|
636
951
|
for (const inst of stage.runs) {
|
|
637
952
|
if (!installsDevDependencies(inst.args)) continue;
|
|
638
953
|
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
639
|
-
diagnostics.push(createDiagnostic
|
|
954
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
640
955
|
}
|
|
641
956
|
}
|
|
642
957
|
return diagnostics;
|
|
@@ -654,19 +969,11 @@ const imageSizeRules = [
|
|
|
654
969
|
|
|
655
970
|
//#endregion
|
|
656
971
|
//#region ../core/src/rules/performance.ts
|
|
657
|
-
const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
|
|
658
|
-
file,
|
|
659
|
-
help,
|
|
660
|
-
line,
|
|
661
|
-
message,
|
|
662
|
-
rule: ruleKey,
|
|
663
|
-
severity
|
|
664
|
-
});
|
|
665
972
|
const useMultiStage = {
|
|
666
973
|
category: "Performance",
|
|
667
974
|
check(instructions, file) {
|
|
668
975
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
669
|
-
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
|
|
976
|
+
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)];
|
|
670
977
|
}
|
|
671
978
|
return [];
|
|
672
979
|
},
|
|
@@ -690,7 +997,7 @@ const orderLayers = {
|
|
|
690
997
|
}
|
|
691
998
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
692
999
|
const args = inst.args.toLowerCase();
|
|
693
|
-
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
|
|
1000
|
+
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));
|
|
694
1001
|
}
|
|
695
1002
|
}
|
|
696
1003
|
return diagnostics;
|
|
@@ -710,10 +1017,10 @@ const minimizeLayers = {
|
|
|
710
1017
|
if (consecutiveRunCount === 0) firstRunLine = inst.line;
|
|
711
1018
|
consecutiveRunCount += 1;
|
|
712
1019
|
} else {
|
|
713
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
1020
|
+
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));
|
|
714
1021
|
consecutiveRunCount = 0;
|
|
715
1022
|
}
|
|
716
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
1023
|
+
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));
|
|
717
1024
|
return diagnostics;
|
|
718
1025
|
},
|
|
719
1026
|
defaultSeverity: "info",
|
|
@@ -738,7 +1045,7 @@ const useDockerignore = {
|
|
|
738
1045
|
return src === "." || src === "./" || src === "*";
|
|
739
1046
|
}
|
|
740
1047
|
return false;
|
|
741
|
-
}) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic
|
|
1048
|
+
}) && 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)];
|
|
742
1049
|
return [];
|
|
743
1050
|
},
|
|
744
1051
|
defaultSeverity: "warning",
|
|
@@ -755,14 +1062,6 @@ const performanceRules = [
|
|
|
755
1062
|
|
|
756
1063
|
//#endregion
|
|
757
1064
|
//#region ../core/src/rules/security.ts
|
|
758
|
-
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
759
|
-
file,
|
|
760
|
-
help,
|
|
761
|
-
line,
|
|
762
|
-
message,
|
|
763
|
-
rule: ruleKey,
|
|
764
|
-
severity
|
|
765
|
-
});
|
|
766
1065
|
const isRootUser = (value) => {
|
|
767
1066
|
const [user] = value.split(":");
|
|
768
1067
|
return user === "root" || user === "0";
|
|
@@ -776,7 +1075,8 @@ const noRootUser = {
|
|
|
776
1075
|
let lastUserLine = 1;
|
|
777
1076
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
778
1077
|
const { base, stage } = parseFromArgs(inst.args);
|
|
779
|
-
|
|
1078
|
+
const baseDefaultUser = base && isHardenedRuntimeImage(base) ? "nonroot" : "root";
|
|
1079
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? baseDefaultUser;
|
|
780
1080
|
lastUserLine = inst.line;
|
|
781
1081
|
currentStage = stage?.toLowerCase() ?? null;
|
|
782
1082
|
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
@@ -797,21 +1097,13 @@ const noSecretsInEnv = {
|
|
|
797
1097
|
category: "Security",
|
|
798
1098
|
check(instructions, file) {
|
|
799
1099
|
const diagnostics = [];
|
|
800
|
-
const secretKeywords = [
|
|
801
|
-
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
802
|
-
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
803
|
-
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
804
|
-
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
805
|
-
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
806
|
-
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
807
|
-
];
|
|
808
1100
|
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
809
1101
|
const args = inst.args.trim();
|
|
810
1102
|
if (inst.instruction === "ENV" && !args.includes("=")) {
|
|
811
1103
|
const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
|
|
812
1104
|
if (match?.groups) {
|
|
813
1105
|
const { key, value } = match.groups;
|
|
814
|
-
if (
|
|
1106
|
+
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));
|
|
815
1107
|
}
|
|
816
1108
|
} else {
|
|
817
1109
|
const parts = args.split(/\s+/u);
|
|
@@ -823,7 +1115,7 @@ const noSecretsInEnv = {
|
|
|
823
1115
|
key = part.slice(0, eqIndex);
|
|
824
1116
|
value = part.slice(eqIndex + 1);
|
|
825
1117
|
} else key = part;
|
|
826
|
-
if (
|
|
1118
|
+
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));
|
|
827
1119
|
}
|
|
828
1120
|
}
|
|
829
1121
|
}
|
|
@@ -844,8 +1136,11 @@ const pinImageVersion = {
|
|
|
844
1136
|
if (!imagePart || isScratch(imagePart)) continue;
|
|
845
1137
|
const ref = parseImageRef(imagePart);
|
|
846
1138
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
847
|
-
|
|
848
|
-
|
|
1139
|
+
const issue = mutableRefIssue(ref);
|
|
1140
|
+
if (issue) {
|
|
1141
|
+
const detail = issue === "untagged" ? `Base image '${imagePart}' does not specify a tag.` : `Base image '${imagePart}' uses the mutable 'latest' tag.`;
|
|
1142
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} This makes builds non-deterministic.`, this.help, inst.line));
|
|
1143
|
+
}
|
|
849
1144
|
}
|
|
850
1145
|
return diagnostics;
|
|
851
1146
|
},
|
|
@@ -885,7 +1180,11 @@ const allDockerfileRules = [
|
|
|
885
1180
|
...bestPracticesRules,
|
|
886
1181
|
...imageSizeRules
|
|
887
1182
|
];
|
|
888
|
-
const allComposeRules = [
|
|
1183
|
+
const allComposeRules = [
|
|
1184
|
+
...composeRules,
|
|
1185
|
+
...composeSecurityRules,
|
|
1186
|
+
...composeModelRules
|
|
1187
|
+
];
|
|
889
1188
|
const allRules = [...allDockerfileRules, ...allComposeRules];
|
|
890
1189
|
const findRule = (key) => allRules.find((rule) => rule.key === key);
|
|
891
1190
|
|
|
@@ -909,12 +1208,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
|
|
|
909
1208
|
|
|
910
1209
|
//#endregion
|
|
911
1210
|
//#region ../core/src/runners/compose-runner.ts
|
|
912
|
-
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
|
|
1211
|
+
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
|
|
913
1212
|
const diagnostics = [];
|
|
914
1213
|
for (const rule of allComposeRules) {
|
|
915
1214
|
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
916
1215
|
if (severity === "off") continue;
|
|
917
|
-
const ruleDiagnostics = rule.check(composeContent, file);
|
|
1216
|
+
const ruleDiagnostics = rule.check(composeContent, file, { locate });
|
|
918
1217
|
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
919
1218
|
diagnostics.push(...ruleDiagnostics);
|
|
920
1219
|
}
|
|
@@ -1163,6 +1462,12 @@ Object.defineProperty(exports, 'calculateScore', {
|
|
|
1163
1462
|
return calculateScore;
|
|
1164
1463
|
}
|
|
1165
1464
|
});
|
|
1465
|
+
Object.defineProperty(exports, 'createComposeLocator', {
|
|
1466
|
+
enumerable: true,
|
|
1467
|
+
get: function () {
|
|
1468
|
+
return createComposeLocator;
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1166
1471
|
Object.defineProperty(exports, 'discoverProject', {
|
|
1167
1472
|
enumerable: true,
|
|
1168
1473
|
get: function () {
|
|
@@ -1217,4 +1522,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1217
1522
|
return version;
|
|
1218
1523
|
}
|
|
1219
1524
|
});
|
|
1220
|
-
//# sourceMappingURL=src-
|
|
1525
|
+
//# sourceMappingURL=src-F90_EKmA.cjs.map
|