@docker-doctor/cli 0.4.2 → 0.4.4
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 +7 -0
- package/dist/cli.cjs +21 -13
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +21 -13
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +12 -5
- package/dist/index.d.mts +12 -5
- package/dist/index.mjs +1 -1
- package/dist/{src-BuUGk4ND.mjs → src-CvOv-hL3.mjs} +280 -167
- package/dist/src-CvOv-hL3.mjs.map +1 -0
- package/dist/{src-909bBBPN.cjs → src-DwuAaQcq.cjs} +284 -165
- package/dist/src-DwuAaQcq.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/src-909bBBPN.cjs.map +0 -1
- package/dist/src-BuUGk4ND.mjs.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
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.4.
|
|
7
|
+
var version = "0.4.4";
|
|
8
8
|
|
|
9
9
|
//#endregion
|
|
10
10
|
//#region ../core/src/project-info/discover.ts
|
|
@@ -107,7 +107,6 @@ const processHeredocLine = (state, trimmed) => {
|
|
|
107
107
|
};
|
|
108
108
|
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
109
109
|
let lineContent = trimmed;
|
|
110
|
-
if (lineContent.startsWith("#")) return;
|
|
111
110
|
const hasContinuation = lineContent.endsWith("\\");
|
|
112
111
|
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
113
112
|
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
@@ -130,7 +129,8 @@ const parseDockerfile = (content) => {
|
|
|
130
129
|
const trimmed = rawLine.trim();
|
|
131
130
|
const lineNum = i + 1;
|
|
132
131
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
133
|
-
if (!
|
|
132
|
+
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
133
|
+
if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
|
|
134
134
|
state.rawAccumulator.push(rawLine);
|
|
135
135
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
136
136
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -173,10 +173,117 @@ const parseCompose = (content, filepath) => {
|
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
175
|
};
|
|
176
|
+
/**
|
|
177
|
+
* Builds a {@link ComposeLocator} over the same source text a compose object
|
|
178
|
+
* was parsed from, so rules can attach line numbers to their diagnostics.
|
|
179
|
+
*
|
|
180
|
+
* Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
|
|
181
|
+
* at the merge site, so paths through them resolve to `undefined` — callers
|
|
182
|
+
* fall back to an unnumbered diagnostic, which matches the old behavior.
|
|
183
|
+
*/
|
|
184
|
+
const createComposeLocator = (content) => {
|
|
185
|
+
const lineCounter = new LineCounter();
|
|
186
|
+
const doc = parseDocument(content, {
|
|
187
|
+
lineCounter,
|
|
188
|
+
merge: true
|
|
189
|
+
});
|
|
190
|
+
return (path) => {
|
|
191
|
+
let node = doc.contents;
|
|
192
|
+
let offset;
|
|
193
|
+
for (const segment of path) {
|
|
194
|
+
if (isAlias(node)) node = node.resolve(doc);
|
|
195
|
+
if (isMap(node)) {
|
|
196
|
+
const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
|
|
197
|
+
if (!pair || !isScalar(pair.key)) return;
|
|
198
|
+
offset = pair.key.range?.[0];
|
|
199
|
+
node = pair.value;
|
|
200
|
+
} else if (isSeq(node) && typeof segment === "number") {
|
|
201
|
+
const item = node.items[segment];
|
|
202
|
+
if (item === void 0 || item === null) return;
|
|
203
|
+
offset = item.range?.[0];
|
|
204
|
+
node = item;
|
|
205
|
+
} else return;
|
|
206
|
+
}
|
|
207
|
+
return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
|
|
208
|
+
};
|
|
209
|
+
};
|
|
176
210
|
|
|
177
211
|
//#endregion
|
|
178
|
-
//#region ../core/src/
|
|
179
|
-
const
|
|
212
|
+
//#region ../core/src/parsers/exec-form.ts
|
|
213
|
+
const parseExecForm = (args) => {
|
|
214
|
+
const trimmed = args.trim();
|
|
215
|
+
if (!trimmed.startsWith("[")) return null;
|
|
216
|
+
let parsed;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(trimmed);
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(parsed)) return null;
|
|
223
|
+
if (!parsed.every((el) => typeof el === "string")) return null;
|
|
224
|
+
return parsed;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
229
|
+
const parseImageRef = (ref) => {
|
|
230
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
231
|
+
isVariable: true,
|
|
232
|
+
name: ref
|
|
233
|
+
};
|
|
234
|
+
let remainder = ref;
|
|
235
|
+
let digest;
|
|
236
|
+
const atIndex = remainder.indexOf("@");
|
|
237
|
+
if (atIndex !== -1) {
|
|
238
|
+
digest = remainder.slice(atIndex + 1);
|
|
239
|
+
remainder = remainder.slice(0, atIndex);
|
|
240
|
+
}
|
|
241
|
+
let tag;
|
|
242
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
243
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
244
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
245
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
246
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
247
|
+
}
|
|
248
|
+
let registry;
|
|
249
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
250
|
+
if (firstSlashIndex !== -1) {
|
|
251
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
252
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
253
|
+
registry = firstSegment;
|
|
254
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
digest,
|
|
259
|
+
isVariable: false,
|
|
260
|
+
name: remainder,
|
|
261
|
+
registry,
|
|
262
|
+
tag
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
const parseFromArgs = (args) => {
|
|
266
|
+
const parts = args.split(/\s+/u).filter(Boolean);
|
|
267
|
+
const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
|
|
268
|
+
return {
|
|
269
|
+
base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
|
|
270
|
+
stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
const isScratch = (base) => base?.toLowerCase() === "scratch";
|
|
274
|
+
const collectStageAliases = (instructions) => {
|
|
275
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
276
|
+
for (const inst of instructions) {
|
|
277
|
+
if (inst.instruction !== "FROM") continue;
|
|
278
|
+
const { stage } = parseFromArgs(inst.args);
|
|
279
|
+
if (stage) aliases.add(stage.toLowerCase());
|
|
280
|
+
}
|
|
281
|
+
return aliases;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region ../core/src/rules/create-diagnostic.ts
|
|
286
|
+
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
180
287
|
file,
|
|
181
288
|
help,
|
|
182
289
|
line,
|
|
@@ -184,16 +291,19 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
|
184
291
|
rule: ruleKey,
|
|
185
292
|
severity
|
|
186
293
|
});
|
|
294
|
+
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region ../core/src/rules/best-practices.ts
|
|
187
297
|
const requireHealthcheck = {
|
|
188
298
|
category: "Best Practices",
|
|
189
299
|
check(instructions, file) {
|
|
190
300
|
const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
|
|
191
301
|
const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
|
|
192
|
-
if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic
|
|
302
|
+
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)];
|
|
193
303
|
return [];
|
|
194
304
|
},
|
|
195
305
|
defaultSeverity: "info",
|
|
196
|
-
help: "Use HEALTHCHECK (e.g.,
|
|
306
|
+
help: "Use HEALTHCHECK (e.g., `HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1`) so Docker can monitor the container's live status.",
|
|
197
307
|
key: "docker-doctor/require-healthcheck",
|
|
198
308
|
message: "Add a HEALTHCHECK instruction"
|
|
199
309
|
};
|
|
@@ -206,7 +316,7 @@ const preferCopyOverAdd = {
|
|
|
206
316
|
if (!src) continue;
|
|
207
317
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
208
318
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
209
|
-
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic
|
|
319
|
+
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));
|
|
210
320
|
}
|
|
211
321
|
return diagnostics;
|
|
212
322
|
},
|
|
@@ -219,25 +329,22 @@ const useExecForm = {
|
|
|
219
329
|
category: "Best Practices",
|
|
220
330
|
check(instructions, file) {
|
|
221
331
|
const diagnostics = [];
|
|
222
|
-
for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
|
|
223
|
-
const args = inst.args.trim();
|
|
224
|
-
if (!args.startsWith("[") || !args.endsWith("]")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
|
|
225
|
-
}
|
|
332
|
+
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));
|
|
226
333
|
return diagnostics;
|
|
227
334
|
},
|
|
228
335
|
defaultSeverity: "warning",
|
|
229
|
-
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
336
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
230
337
|
key: "docker-doctor/use-exec-form",
|
|
231
338
|
message: "Use exec form for CMD and ENTRYPOINT"
|
|
232
339
|
};
|
|
233
340
|
const requireLabels = {
|
|
234
341
|
category: "Best Practices",
|
|
235
342
|
check(instructions, file) {
|
|
236
|
-
if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic
|
|
343
|
+
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)];
|
|
237
344
|
return [];
|
|
238
345
|
},
|
|
239
346
|
defaultSeverity: "info",
|
|
240
|
-
help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
|
|
347
|
+
help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
|
|
241
348
|
key: "docker-doctor/require-labels",
|
|
242
349
|
message: "Add LABEL metadata to images"
|
|
243
350
|
};
|
|
@@ -248,28 +355,53 @@ const combineAptUpdateInstall = {
|
|
|
248
355
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
249
356
|
const hasUpdate = inst.args.includes("apt-get update");
|
|
250
357
|
const hasInstall = inst.args.includes("apt-get install");
|
|
251
|
-
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic
|
|
252
|
-
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic
|
|
358
|
+
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));
|
|
359
|
+
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));
|
|
253
360
|
}
|
|
254
361
|
return diagnostics;
|
|
255
362
|
},
|
|
256
363
|
defaultSeverity: "warning",
|
|
257
|
-
help: "Combine
|
|
364
|
+
help: "Combine `apt-get update` and `apt-get install` in the same RUN instruction (e.g. `RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf /var/lib/apt/lists/*`).",
|
|
258
365
|
key: "docker-doctor/combine-apt-update-install",
|
|
259
366
|
message: "Combine apt-get update and apt-get install"
|
|
260
367
|
};
|
|
368
|
+
const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
|
|
369
|
+
const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
|
|
370
|
+
const shellDirectiveEnablesPipefail = (args) => {
|
|
371
|
+
const argv = parseExecForm(args);
|
|
372
|
+
return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
|
|
373
|
+
};
|
|
261
374
|
const usePipefail = {
|
|
262
375
|
category: "Best Practices",
|
|
263
376
|
check(instructions, file) {
|
|
264
377
|
const diagnostics = [];
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
378
|
+
const stagePipefail = /* @__PURE__ */ new Map();
|
|
379
|
+
let shellHasPipefail = false;
|
|
380
|
+
let currentStage = null;
|
|
381
|
+
for (const inst of instructions) {
|
|
382
|
+
if (inst.instruction === "FROM") {
|
|
383
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
384
|
+
const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
|
|
385
|
+
shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
|
|
386
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
387
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (inst.instruction === "SHELL") {
|
|
391
|
+
shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
|
|
392
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (inst.instruction !== "RUN") continue;
|
|
396
|
+
const { args } = inst;
|
|
397
|
+
if (!HAS_PIPE_RE.test(args)) continue;
|
|
398
|
+
const execArgv = parseExecForm(args);
|
|
399
|
+
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));
|
|
268
400
|
}
|
|
269
401
|
return diagnostics;
|
|
270
402
|
},
|
|
271
403
|
defaultSeverity: "warning",
|
|
272
|
-
help: "Prepend
|
|
404
|
+
help: "Prepend `set -o pipefail &&` to pipe commands, use exec form with a shell that supports it (e.g., `RUN [\"/bin/bash\", \"-c\", \"set -o pipefail && ...\"]`), or set `SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]` at the top of the stage.",
|
|
273
405
|
key: "docker-doctor/use-pipefail",
|
|
274
406
|
message: "Use pipefail to catch pipeline command failures"
|
|
275
407
|
};
|
|
@@ -279,12 +411,12 @@ const absoluteWorkdir = {
|
|
|
279
411
|
const diagnostics = [];
|
|
280
412
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
281
413
|
const path = inst.args.trim();
|
|
282
|
-
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic
|
|
414
|
+
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));
|
|
283
415
|
}
|
|
284
416
|
return diagnostics;
|
|
285
417
|
},
|
|
286
418
|
defaultSeverity: "warning",
|
|
287
|
-
help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
|
|
419
|
+
help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
|
|
288
420
|
key: "docker-doctor/absolute-workdir",
|
|
289
421
|
message: "Use absolute paths for WORKDIR"
|
|
290
422
|
};
|
|
@@ -292,11 +424,11 @@ const avoidRunCd = {
|
|
|
292
424
|
category: "Best Practices",
|
|
293
425
|
check(instructions, file) {
|
|
294
426
|
const diagnostics = [];
|
|
295
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic
|
|
427
|
+
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));
|
|
296
428
|
return diagnostics;
|
|
297
429
|
},
|
|
298
430
|
defaultSeverity: "info",
|
|
299
|
-
help: "Use the WORKDIR instruction instead of
|
|
431
|
+
help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
|
|
300
432
|
key: "docker-doctor/avoid-run-cd",
|
|
301
433
|
message: "Avoid changing directories with cd in RUN"
|
|
302
434
|
};
|
|
@@ -312,7 +444,7 @@ const sortMultilineArgs = {
|
|
|
312
444
|
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);
|
|
313
445
|
if (packages.length > 1) {
|
|
314
446
|
const sorted = packages.toSorted((a, b) => a.localeCompare(b));
|
|
315
|
-
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic
|
|
447
|
+
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));
|
|
316
448
|
}
|
|
317
449
|
}
|
|
318
450
|
}
|
|
@@ -327,11 +459,11 @@ const useraddNoLogInit = {
|
|
|
327
459
|
category: "Best Practices",
|
|
328
460
|
check(instructions, file) {
|
|
329
461
|
const diagnostics = [];
|
|
330
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic
|
|
462
|
+
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));
|
|
331
463
|
return diagnostics;
|
|
332
464
|
},
|
|
333
465
|
defaultSeverity: "warning",
|
|
334
|
-
help: "Pass
|
|
466
|
+
help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
335
467
|
key: "docker-doctor/useradd-no-log-init",
|
|
336
468
|
message: "Use --no-log-init with useradd"
|
|
337
469
|
};
|
|
@@ -350,47 +482,40 @@ const bestPracticesRules = [
|
|
|
350
482
|
|
|
351
483
|
//#endregion
|
|
352
484
|
//#region ../core/src/rules/compose.ts
|
|
353
|
-
const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
|
|
354
|
-
file,
|
|
355
|
-
help,
|
|
356
|
-
message,
|
|
357
|
-
rule: ruleKey,
|
|
358
|
-
severity
|
|
359
|
-
});
|
|
360
485
|
const noVersionKey = {
|
|
361
486
|
category: "Compose",
|
|
362
|
-
check(composeContent, file) {
|
|
363
|
-
if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic
|
|
487
|
+
check(composeContent, file, context) {
|
|
488
|
+
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"]))];
|
|
364
489
|
return [];
|
|
365
490
|
},
|
|
366
491
|
defaultSeverity: "warning",
|
|
367
|
-
help: "The
|
|
492
|
+
help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
|
|
368
493
|
key: "docker-doctor/no-version-key",
|
|
369
|
-
message: "Remove the
|
|
494
|
+
message: "Remove the `version` key from the Compose file"
|
|
370
495
|
};
|
|
371
496
|
const requireResourceLimits = {
|
|
372
497
|
category: "Compose",
|
|
373
|
-
check(composeContent, file) {
|
|
498
|
+
check(composeContent, file, context) {
|
|
374
499
|
const diagnostics = [];
|
|
375
500
|
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
376
501
|
const { services } = composeContent;
|
|
377
502
|
if (services && typeof services === "object") {
|
|
378
503
|
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
379
504
|
const limits = (config.deploy?.resources)?.limits;
|
|
380
|
-
if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic
|
|
505
|
+
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])));
|
|
381
506
|
}
|
|
382
507
|
}
|
|
383
508
|
}
|
|
384
509
|
return diagnostics;
|
|
385
510
|
},
|
|
386
511
|
defaultSeverity: "warning",
|
|
387
|
-
help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
|
|
512
|
+
help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
|
|
388
513
|
key: "docker-doctor/require-resource-limits",
|
|
389
514
|
message: "Define resource limits for services"
|
|
390
515
|
};
|
|
391
516
|
const requireRestartPolicy = {
|
|
392
517
|
category: "Compose",
|
|
393
|
-
check(composeContent, file) {
|
|
518
|
+
check(composeContent, file, context) {
|
|
394
519
|
const diagnostics = [];
|
|
395
520
|
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
396
521
|
const { services } = composeContent;
|
|
@@ -398,34 +523,38 @@ const requireRestartPolicy = {
|
|
|
398
523
|
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
399
524
|
const hasRestart = "restart" in config;
|
|
400
525
|
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
401
|
-
if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic
|
|
526
|
+
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])));
|
|
402
527
|
}
|
|
403
528
|
}
|
|
404
529
|
}
|
|
405
530
|
return diagnostics;
|
|
406
531
|
},
|
|
407
532
|
defaultSeverity: "warning",
|
|
408
|
-
help: "Define
|
|
533
|
+
help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
|
|
409
534
|
key: "docker-doctor/require-restart-policy",
|
|
410
535
|
message: "Set restart policy for services"
|
|
411
536
|
};
|
|
412
537
|
const useDependsOnCondition = {
|
|
413
538
|
category: "Compose",
|
|
414
|
-
check(composeContent, file) {
|
|
539
|
+
check(composeContent, file, context) {
|
|
415
540
|
const diagnostics = [];
|
|
416
541
|
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
417
542
|
const { services } = composeContent;
|
|
418
543
|
if (services && typeof services === "object") {
|
|
419
544
|
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
420
545
|
const dependsOn = config.depends_on;
|
|
421
|
-
if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic
|
|
546
|
+
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?.([
|
|
547
|
+
"services",
|
|
548
|
+
name,
|
|
549
|
+
"depends_on"
|
|
550
|
+
]) ?? context?.locate?.(["services", name])));
|
|
422
551
|
}
|
|
423
552
|
}
|
|
424
553
|
}
|
|
425
554
|
return diagnostics;
|
|
426
555
|
},
|
|
427
556
|
defaultSeverity: "info",
|
|
428
|
-
help: "Instead of a simple service list, use
|
|
557
|
+
help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
|
|
429
558
|
key: "docker-doctor/use-depends-on-condition",
|
|
430
559
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
431
560
|
};
|
|
@@ -436,126 +565,89 @@ const composeRules = [
|
|
|
436
565
|
useDependsOnCondition
|
|
437
566
|
];
|
|
438
567
|
|
|
439
|
-
//#endregion
|
|
440
|
-
//#region ../core/src/parsers/image-ref.ts
|
|
441
|
-
const parseImageRef = (ref) => {
|
|
442
|
-
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
443
|
-
isVariable: true,
|
|
444
|
-
name: ref
|
|
445
|
-
};
|
|
446
|
-
let remainder = ref;
|
|
447
|
-
let digest;
|
|
448
|
-
const atIndex = remainder.indexOf("@");
|
|
449
|
-
if (atIndex !== -1) {
|
|
450
|
-
digest = remainder.slice(atIndex + 1);
|
|
451
|
-
remainder = remainder.slice(0, atIndex);
|
|
452
|
-
}
|
|
453
|
-
let tag;
|
|
454
|
-
const lastColonIndex = remainder.lastIndexOf(":");
|
|
455
|
-
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
456
|
-
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
457
|
-
tag = remainder.slice(lastColonIndex + 1);
|
|
458
|
-
remainder = remainder.slice(0, lastColonIndex);
|
|
459
|
-
}
|
|
460
|
-
let registry;
|
|
461
|
-
const firstSlashIndex = remainder.indexOf("/");
|
|
462
|
-
if (firstSlashIndex !== -1) {
|
|
463
|
-
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
464
|
-
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
465
|
-
registry = firstSegment;
|
|
466
|
-
remainder = remainder.slice(firstSlashIndex + 1);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
return {
|
|
470
|
-
digest,
|
|
471
|
-
isVariable: false,
|
|
472
|
-
name: remainder,
|
|
473
|
-
registry,
|
|
474
|
-
tag
|
|
475
|
-
};
|
|
476
|
-
};
|
|
477
|
-
const collectStageAliases = (instructions) => {
|
|
478
|
-
const aliases = /* @__PURE__ */ new Set();
|
|
479
|
-
for (const inst of instructions) {
|
|
480
|
-
if (inst.instruction !== "FROM") continue;
|
|
481
|
-
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
482
|
-
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
483
|
-
}
|
|
484
|
-
return aliases;
|
|
485
|
-
};
|
|
486
|
-
|
|
487
568
|
//#endregion
|
|
488
569
|
//#region ../core/src/rules/image-size.ts
|
|
489
|
-
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
490
|
-
file,
|
|
491
|
-
help,
|
|
492
|
-
line,
|
|
493
|
-
message,
|
|
494
|
-
rule: ruleKey,
|
|
495
|
-
severity
|
|
496
|
-
});
|
|
497
570
|
const preferSlimBase = {
|
|
498
571
|
category: "Image Size",
|
|
499
572
|
check(instructions, file) {
|
|
500
573
|
const diagnostics = [];
|
|
501
574
|
const stageAliases = collectStageAliases(instructions);
|
|
502
575
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
503
|
-
const imagePart = inst.args
|
|
504
|
-
if (!imagePart || imagePart
|
|
576
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
577
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
505
578
|
const ref = parseImageRef(imagePart);
|
|
506
579
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
507
580
|
if (ref.digest) continue;
|
|
508
581
|
if (!ref.tag) continue;
|
|
509
582
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
510
|
-
if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic
|
|
583
|
+
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));
|
|
511
584
|
}
|
|
512
585
|
return diagnostics;
|
|
513
586
|
},
|
|
514
587
|
defaultSeverity: "info",
|
|
515
|
-
help: "Prefer tags with
|
|
588
|
+
help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
|
|
516
589
|
key: "docker-doctor/prefer-slim-base",
|
|
517
|
-
message: "
|
|
590
|
+
message: "Prefer slim, alpine, or distroless base images"
|
|
518
591
|
};
|
|
592
|
+
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
593
|
+
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
594
|
+
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)));
|
|
595
|
+
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
596
|
+
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
597
|
+
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
519
598
|
const cleanPackageCache = {
|
|
520
599
|
category: "Image Size",
|
|
521
600
|
check(instructions, file) {
|
|
522
601
|
const diagnostics = [];
|
|
523
602
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
524
603
|
const { args } = inst;
|
|
525
|
-
if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic
|
|
526
|
-
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic
|
|
604
|
+
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));
|
|
605
|
+
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));
|
|
527
606
|
}
|
|
528
607
|
return diagnostics;
|
|
529
608
|
},
|
|
530
609
|
defaultSeverity: "warning",
|
|
531
|
-
help: "For apt-get, append
|
|
610
|
+
help: "For apt-get, append `&& rm -rf /var/lib/apt/lists/*`. For apk, use `apk add --no-cache`. For dnf/yum, run `yum clean all`.",
|
|
532
611
|
key: "docker-doctor/clean-package-cache",
|
|
533
612
|
message: "Clean up package manager cache in the same RUN layer"
|
|
534
613
|
};
|
|
614
|
+
const installsDevDependencies = (args) => (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune");
|
|
535
615
|
const avoidDevDependencies = {
|
|
536
616
|
category: "Image Size",
|
|
537
617
|
check(instructions, file) {
|
|
538
618
|
const diagnostics = [];
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
619
|
+
const stages = [];
|
|
620
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
621
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
622
|
+
stages.push({
|
|
623
|
+
base: base?.toLowerCase() ?? "",
|
|
624
|
+
name: stage?.toLowerCase() ?? null,
|
|
625
|
+
runs: []
|
|
626
|
+
});
|
|
627
|
+
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
628
|
+
if (stages.length === 0) return diagnostics;
|
|
629
|
+
const auditedIndices = [];
|
|
630
|
+
let index = stages.length - 1;
|
|
631
|
+
while (index >= 0) {
|
|
632
|
+
auditedIndices.push(index);
|
|
633
|
+
const { base } = stages[index];
|
|
634
|
+
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
635
|
+
}
|
|
636
|
+
const finalIndex = stages.length - 1;
|
|
637
|
+
for (const stageIndex of auditedIndices.toReversed()) {
|
|
638
|
+
const stage = stages[stageIndex];
|
|
639
|
+
for (const inst of stage.runs) {
|
|
640
|
+
if (!installsDevDependencies(inst.args)) continue;
|
|
641
|
+
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
642
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
551
643
|
}
|
|
552
644
|
}
|
|
553
645
|
return diagnostics;
|
|
554
646
|
},
|
|
555
647
|
defaultSeverity: "warning",
|
|
556
|
-
help: "For Node.js, run
|
|
648
|
+
help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
|
|
557
649
|
key: "docker-doctor/avoid-dev-dependencies",
|
|
558
|
-
message: "Avoid installing
|
|
650
|
+
message: "Avoid installing dev dependencies in the final stage"
|
|
559
651
|
};
|
|
560
652
|
const imageSizeRules = [
|
|
561
653
|
preferSlimBase,
|
|
@@ -565,26 +657,18 @@ const imageSizeRules = [
|
|
|
565
657
|
|
|
566
658
|
//#endregion
|
|
567
659
|
//#region ../core/src/rules/performance.ts
|
|
568
|
-
const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
|
|
569
|
-
file,
|
|
570
|
-
help,
|
|
571
|
-
line,
|
|
572
|
-
message,
|
|
573
|
-
rule: ruleKey,
|
|
574
|
-
severity
|
|
575
|
-
});
|
|
576
660
|
const useMultiStage = {
|
|
577
661
|
category: "Performance",
|
|
578
662
|
check(instructions, file) {
|
|
579
663
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
580
|
-
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
|
|
664
|
+
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)];
|
|
581
665
|
}
|
|
582
666
|
return [];
|
|
583
667
|
},
|
|
584
668
|
defaultSeverity: "info",
|
|
585
669
|
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
586
670
|
key: "docker-doctor/use-multi-stage",
|
|
587
|
-
message: "
|
|
671
|
+
message: "Use multi-stage builds"
|
|
588
672
|
};
|
|
589
673
|
const orderLayers = {
|
|
590
674
|
category: "Performance",
|
|
@@ -601,7 +685,7 @@ const orderLayers = {
|
|
|
601
685
|
}
|
|
602
686
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
603
687
|
const args = inst.args.toLowerCase();
|
|
604
|
-
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
|
|
688
|
+
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));
|
|
605
689
|
}
|
|
606
690
|
}
|
|
607
691
|
return diagnostics;
|
|
@@ -621,14 +705,14 @@ const minimizeLayers = {
|
|
|
621
705
|
if (consecutiveRunCount === 0) firstRunLine = inst.line;
|
|
622
706
|
consecutiveRunCount += 1;
|
|
623
707
|
} else {
|
|
624
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
708
|
+
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));
|
|
625
709
|
consecutiveRunCount = 0;
|
|
626
710
|
}
|
|
627
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
711
|
+
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));
|
|
628
712
|
return diagnostics;
|
|
629
713
|
},
|
|
630
714
|
defaultSeverity: "info",
|
|
631
|
-
help: "Combine consecutive RUN instructions using
|
|
715
|
+
help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
|
|
632
716
|
key: "docker-doctor/minimize-layers",
|
|
633
717
|
message: "Minimize the number of image layers"
|
|
634
718
|
};
|
|
@@ -649,13 +733,13 @@ const useDockerignore = {
|
|
|
649
733
|
return src === "." || src === "./" || src === "*";
|
|
650
734
|
}
|
|
651
735
|
return false;
|
|
652
|
-
}) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic
|
|
736
|
+
}) && 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)];
|
|
653
737
|
return [];
|
|
654
738
|
},
|
|
655
739
|
defaultSeverity: "warning",
|
|
656
740
|
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
657
741
|
key: "docker-doctor/use-dockerignore",
|
|
658
|
-
message: "
|
|
742
|
+
message: "Add a .dockerignore file"
|
|
659
743
|
};
|
|
660
744
|
const performanceRules = [
|
|
661
745
|
useMultiStage,
|
|
@@ -666,14 +750,6 @@ const performanceRules = [
|
|
|
666
750
|
|
|
667
751
|
//#endregion
|
|
668
752
|
//#region ../core/src/rules/security.ts
|
|
669
|
-
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
670
|
-
file,
|
|
671
|
-
help,
|
|
672
|
-
line,
|
|
673
|
-
message,
|
|
674
|
-
rule: ruleKey,
|
|
675
|
-
severity
|
|
676
|
-
});
|
|
677
753
|
const isRootUser = (value) => {
|
|
678
754
|
const [user] = value.split(":");
|
|
679
755
|
return user === "root" || user === "0";
|
|
@@ -681,22 +757,28 @@ const isRootUser = (value) => {
|
|
|
681
757
|
const noRootUser = {
|
|
682
758
|
category: "Security",
|
|
683
759
|
check(instructions, file) {
|
|
760
|
+
const stageUser = /* @__PURE__ */ new Map();
|
|
761
|
+
let currentStage = null;
|
|
684
762
|
let lastUser = "root";
|
|
685
763
|
let lastUserLine = 1;
|
|
686
764
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
687
|
-
|
|
765
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
766
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
|
|
688
767
|
lastUserLine = inst.line;
|
|
768
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
769
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
689
770
|
} else if (inst.instruction === "USER") {
|
|
690
771
|
lastUser = inst.args.trim().toLowerCase();
|
|
691
772
|
lastUserLine = inst.line;
|
|
773
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
692
774
|
}
|
|
693
775
|
if (isRootUser(lastUser)) return [createDiagnostic(file, this.key, this.defaultSeverity, "The container runs as root. Running as root allows potential container breakout vulnerabilities.", this.help, lastUserLine)];
|
|
694
776
|
return [];
|
|
695
777
|
},
|
|
696
778
|
defaultSeverity: "warning",
|
|
697
|
-
help: "Add a non-root user (e.g.,
|
|
779
|
+
help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
|
|
698
780
|
key: "docker-doctor/no-root-user",
|
|
699
|
-
message: "
|
|
781
|
+
message: "Run the container as a non-root user"
|
|
700
782
|
};
|
|
701
783
|
const noSecretsInEnv = {
|
|
702
784
|
category: "Security",
|
|
@@ -737,7 +819,7 @@ const noSecretsInEnv = {
|
|
|
737
819
|
defaultSeverity: "error",
|
|
738
820
|
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
739
821
|
key: "docker-doctor/no-secrets-in-env",
|
|
740
|
-
message: "
|
|
822
|
+
message: "Avoid storing secrets in ENV or ARG instructions"
|
|
741
823
|
};
|
|
742
824
|
const pinImageVersion = {
|
|
743
825
|
category: "Security",
|
|
@@ -745,8 +827,8 @@ const pinImageVersion = {
|
|
|
745
827
|
const diagnostics = [];
|
|
746
828
|
const stageAliases = collectStageAliases(instructions);
|
|
747
829
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
748
|
-
const imagePart = inst.args
|
|
749
|
-
if (!imagePart || imagePart
|
|
830
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
831
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
750
832
|
const ref = parseImageRef(imagePart);
|
|
751
833
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
752
834
|
if (!(ref.tag || ref.digest)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' does not specify a tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
@@ -755,9 +837,9 @@ const pinImageVersion = {
|
|
|
755
837
|
return diagnostics;
|
|
756
838
|
},
|
|
757
839
|
defaultSeverity: "warning",
|
|
758
|
-
help: "Specify a concrete tag instead of
|
|
840
|
+
help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
|
|
759
841
|
key: "docker-doctor/pin-image-version",
|
|
760
|
-
message: "
|
|
842
|
+
message: "Pin base images to a specific tag or digest"
|
|
761
843
|
};
|
|
762
844
|
const noAddRemote = {
|
|
763
845
|
category: "Security",
|
|
@@ -771,7 +853,7 @@ const noAddRemote = {
|
|
|
771
853
|
return diagnostics;
|
|
772
854
|
},
|
|
773
855
|
defaultSeverity: "warning",
|
|
774
|
-
help: "Use
|
|
856
|
+
help: "Use `RUN curl` or `RUN wget` instead of ADD for remote URLs, and delete the downloaded archive in the same layer to minimize size.",
|
|
775
857
|
key: "docker-doctor/no-add-remote",
|
|
776
858
|
message: "Avoid using ADD with remote URLs"
|
|
777
859
|
};
|
|
@@ -814,12 +896,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
|
|
|
814
896
|
|
|
815
897
|
//#endregion
|
|
816
898
|
//#region ../core/src/runners/compose-runner.ts
|
|
817
|
-
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
|
|
899
|
+
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
|
|
818
900
|
const diagnostics = [];
|
|
819
901
|
for (const rule of allComposeRules) {
|
|
820
902
|
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
821
903
|
if (severity === "off") continue;
|
|
822
|
-
const ruleDiagnostics = rule.check(composeContent, file);
|
|
904
|
+
const ruleDiagnostics = rule.check(composeContent, file, { locate });
|
|
823
905
|
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
824
906
|
diagnostics.push(...ruleDiagnostics);
|
|
825
907
|
}
|
|
@@ -888,6 +970,30 @@ const validateConfig = (input) => {
|
|
|
888
970
|
return result;
|
|
889
971
|
};
|
|
890
972
|
|
|
973
|
+
//#endregion
|
|
974
|
+
//#region ../core/src/config/unknown-keys.ts
|
|
975
|
+
const KNOWN_CATEGORIES = [
|
|
976
|
+
"Best Practices",
|
|
977
|
+
"Compose",
|
|
978
|
+
"Image Size",
|
|
979
|
+
"Performance",
|
|
980
|
+
"Security"
|
|
981
|
+
];
|
|
982
|
+
const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
|
|
983
|
+
const collectUnknownConfigKeys = (raw) => {
|
|
984
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
985
|
+
categories: [],
|
|
986
|
+
rules: []
|
|
987
|
+
};
|
|
988
|
+
const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
|
|
989
|
+
const knownCategories = new Set(KNOWN_CATEGORIES);
|
|
990
|
+
const { categories, rules } = raw;
|
|
991
|
+
return {
|
|
992
|
+
categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
|
|
993
|
+
rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
|
|
994
|
+
};
|
|
995
|
+
};
|
|
996
|
+
|
|
891
997
|
//#endregion
|
|
892
998
|
//#region ../core/src/config/loader.ts
|
|
893
999
|
const fileExists = async (filePath) => {
|
|
@@ -917,7 +1023,12 @@ const importConfig = async (filePath) => {
|
|
|
917
1023
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
918
1024
|
}
|
|
919
1025
|
};
|
|
920
|
-
const
|
|
1026
|
+
const warnUnknownKeys = (raw, onWarning) => {
|
|
1027
|
+
const unknown = collectUnknownConfigKeys(raw);
|
|
1028
|
+
for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
|
|
1029
|
+
for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
|
|
1030
|
+
};
|
|
1031
|
+
const loadConfig = async (rootDir, customPath, onWarning) => {
|
|
921
1032
|
let configObject = null;
|
|
922
1033
|
if (customPath) {
|
|
923
1034
|
const fullPath = path.resolve(rootDir, customPath);
|
|
@@ -950,7 +1061,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
950
1061
|
}
|
|
951
1062
|
if (!configObject) return {};
|
|
952
1063
|
try {
|
|
953
|
-
|
|
1064
|
+
const config = validateConfig(configObject);
|
|
1065
|
+
if (onWarning) warnUnknownKeys(configObject, onWarning);
|
|
1066
|
+
return config;
|
|
954
1067
|
} catch (error) {
|
|
955
1068
|
const msg = error instanceof Error ? error.message : String(error);
|
|
956
1069
|
throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
|
|
@@ -1019,5 +1132,5 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1019
1132
|
});
|
|
1020
1133
|
|
|
1021
1134
|
//#endregion
|
|
1022
|
-
export { runDockerfileRules as a,
|
|
1023
|
-
//# sourceMappingURL=src-
|
|
1135
|
+
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 };
|
|
1136
|
+
//# sourceMappingURL=src-CvOv-hL3.mjs.map
|