@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
|
@@ -33,7 +33,7 @@ node_path = __toESM(node_path, 1);
|
|
|
33
33
|
let yaml = require("yaml");
|
|
34
34
|
|
|
35
35
|
//#region package.json
|
|
36
|
-
var version = "0.4.
|
|
36
|
+
var version = "0.4.4";
|
|
37
37
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region ../core/src/project-info/discover.ts
|
|
@@ -136,7 +136,6 @@ const processHeredocLine = (state, trimmed) => {
|
|
|
136
136
|
};
|
|
137
137
|
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
138
138
|
let lineContent = trimmed;
|
|
139
|
-
if (lineContent.startsWith("#")) return;
|
|
140
139
|
const hasContinuation = lineContent.endsWith("\\");
|
|
141
140
|
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
142
141
|
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
@@ -159,7 +158,8 @@ const parseDockerfile = (content) => {
|
|
|
159
158
|
const trimmed = rawLine.trim();
|
|
160
159
|
const lineNum = i + 1;
|
|
161
160
|
const insideHeredoc = state.heredocQueue.length > 0;
|
|
162
|
-
if (!
|
|
161
|
+
if (!insideHeredoc && trimmed.startsWith("#")) continue;
|
|
162
|
+
if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
|
|
163
163
|
state.rawAccumulator.push(rawLine);
|
|
164
164
|
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
165
165
|
else processInstructionLine(state, trimmed, lineNum);
|
|
@@ -202,10 +202,117 @@ const parseCompose = (content, filepath) => {
|
|
|
202
202
|
});
|
|
203
203
|
}
|
|
204
204
|
};
|
|
205
|
+
/**
|
|
206
|
+
* Builds a {@link ComposeLocator} over the same source text a compose object
|
|
207
|
+
* was parsed from, so rules can attach line numbers to their diagnostics.
|
|
208
|
+
*
|
|
209
|
+
* Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
|
|
210
|
+
* at the merge site, so paths through them resolve to `undefined` — callers
|
|
211
|
+
* fall back to an unnumbered diagnostic, which matches the old behavior.
|
|
212
|
+
*/
|
|
213
|
+
const createComposeLocator = (content) => {
|
|
214
|
+
const lineCounter = new yaml.LineCounter();
|
|
215
|
+
const doc = (0, yaml.parseDocument)(content, {
|
|
216
|
+
lineCounter,
|
|
217
|
+
merge: true
|
|
218
|
+
});
|
|
219
|
+
return (path) => {
|
|
220
|
+
let node = doc.contents;
|
|
221
|
+
let offset;
|
|
222
|
+
for (const segment of path) {
|
|
223
|
+
if ((0, yaml.isAlias)(node)) node = node.resolve(doc);
|
|
224
|
+
if ((0, yaml.isMap)(node)) {
|
|
225
|
+
const pair = node.items.find((item) => (0, yaml.isScalar)(item.key) && String(item.key.value) === String(segment));
|
|
226
|
+
if (!pair || !(0, yaml.isScalar)(pair.key)) return;
|
|
227
|
+
offset = pair.key.range?.[0];
|
|
228
|
+
node = pair.value;
|
|
229
|
+
} else if ((0, yaml.isSeq)(node) && typeof segment === "number") {
|
|
230
|
+
const item = node.items[segment];
|
|
231
|
+
if (item === void 0 || item === null) return;
|
|
232
|
+
offset = item.range?.[0];
|
|
233
|
+
node = item;
|
|
234
|
+
} else return;
|
|
235
|
+
}
|
|
236
|
+
return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
|
|
237
|
+
};
|
|
238
|
+
};
|
|
205
239
|
|
|
206
240
|
//#endregion
|
|
207
|
-
//#region ../core/src/
|
|
208
|
-
const
|
|
241
|
+
//#region ../core/src/parsers/exec-form.ts
|
|
242
|
+
const parseExecForm = (args) => {
|
|
243
|
+
const trimmed = args.trim();
|
|
244
|
+
if (!trimmed.startsWith("[")) return null;
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(trimmed);
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (!Array.isArray(parsed)) return null;
|
|
252
|
+
if (!parsed.every((el) => typeof el === "string")) return null;
|
|
253
|
+
return parsed;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
258
|
+
const parseImageRef = (ref) => {
|
|
259
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
260
|
+
isVariable: true,
|
|
261
|
+
name: ref
|
|
262
|
+
};
|
|
263
|
+
let remainder = ref;
|
|
264
|
+
let digest;
|
|
265
|
+
const atIndex = remainder.indexOf("@");
|
|
266
|
+
if (atIndex !== -1) {
|
|
267
|
+
digest = remainder.slice(atIndex + 1);
|
|
268
|
+
remainder = remainder.slice(0, atIndex);
|
|
269
|
+
}
|
|
270
|
+
let tag;
|
|
271
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
272
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
273
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
274
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
275
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
276
|
+
}
|
|
277
|
+
let registry;
|
|
278
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
279
|
+
if (firstSlashIndex !== -1) {
|
|
280
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
281
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
282
|
+
registry = firstSegment;
|
|
283
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
digest,
|
|
288
|
+
isVariable: false,
|
|
289
|
+
name: remainder,
|
|
290
|
+
registry,
|
|
291
|
+
tag
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
const parseFromArgs = (args) => {
|
|
295
|
+
const parts = args.split(/\s+/u).filter(Boolean);
|
|
296
|
+
const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
|
|
297
|
+
return {
|
|
298
|
+
base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
|
|
299
|
+
stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
const isScratch = (base) => base?.toLowerCase() === "scratch";
|
|
303
|
+
const collectStageAliases = (instructions) => {
|
|
304
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
305
|
+
for (const inst of instructions) {
|
|
306
|
+
if (inst.instruction !== "FROM") continue;
|
|
307
|
+
const { stage } = parseFromArgs(inst.args);
|
|
308
|
+
if (stage) aliases.add(stage.toLowerCase());
|
|
309
|
+
}
|
|
310
|
+
return aliases;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region ../core/src/rules/create-diagnostic.ts
|
|
315
|
+
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
209
316
|
file,
|
|
210
317
|
help,
|
|
211
318
|
line,
|
|
@@ -213,16 +320,19 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
|
213
320
|
rule: ruleKey,
|
|
214
321
|
severity
|
|
215
322
|
});
|
|
323
|
+
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region ../core/src/rules/best-practices.ts
|
|
216
326
|
const requireHealthcheck = {
|
|
217
327
|
category: "Best Practices",
|
|
218
328
|
check(instructions, file) {
|
|
219
329
|
const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
|
|
220
330
|
const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
|
|
221
|
-
if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic
|
|
331
|
+
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)];
|
|
222
332
|
return [];
|
|
223
333
|
},
|
|
224
334
|
defaultSeverity: "info",
|
|
225
|
-
help: "Use HEALTHCHECK (e.g.,
|
|
335
|
+
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.",
|
|
226
336
|
key: "docker-doctor/require-healthcheck",
|
|
227
337
|
message: "Add a HEALTHCHECK instruction"
|
|
228
338
|
};
|
|
@@ -235,7 +345,7 @@ const preferCopyOverAdd = {
|
|
|
235
345
|
if (!src) continue;
|
|
236
346
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
237
347
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
238
|
-
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic
|
|
348
|
+
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));
|
|
239
349
|
}
|
|
240
350
|
return diagnostics;
|
|
241
351
|
},
|
|
@@ -248,25 +358,22 @@ const useExecForm = {
|
|
|
248
358
|
category: "Best Practices",
|
|
249
359
|
check(instructions, file) {
|
|
250
360
|
const diagnostics = [];
|
|
251
|
-
for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
|
|
252
|
-
const args = inst.args.trim();
|
|
253
|
-
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));
|
|
254
|
-
}
|
|
361
|
+
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));
|
|
255
362
|
return diagnostics;
|
|
256
363
|
},
|
|
257
364
|
defaultSeverity: "warning",
|
|
258
|
-
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
365
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
259
366
|
key: "docker-doctor/use-exec-form",
|
|
260
367
|
message: "Use exec form for CMD and ENTRYPOINT"
|
|
261
368
|
};
|
|
262
369
|
const requireLabels = {
|
|
263
370
|
category: "Best Practices",
|
|
264
371
|
check(instructions, file) {
|
|
265
|
-
if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic
|
|
372
|
+
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)];
|
|
266
373
|
return [];
|
|
267
374
|
},
|
|
268
375
|
defaultSeverity: "info",
|
|
269
|
-
help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
|
|
376
|
+
help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
|
|
270
377
|
key: "docker-doctor/require-labels",
|
|
271
378
|
message: "Add LABEL metadata to images"
|
|
272
379
|
};
|
|
@@ -277,28 +384,53 @@ const combineAptUpdateInstall = {
|
|
|
277
384
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
278
385
|
const hasUpdate = inst.args.includes("apt-get update");
|
|
279
386
|
const hasInstall = inst.args.includes("apt-get install");
|
|
280
|
-
if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic
|
|
281
|
-
else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic
|
|
387
|
+
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));
|
|
388
|
+
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));
|
|
282
389
|
}
|
|
283
390
|
return diagnostics;
|
|
284
391
|
},
|
|
285
392
|
defaultSeverity: "warning",
|
|
286
|
-
help: "Combine
|
|
393
|
+
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/*`).",
|
|
287
394
|
key: "docker-doctor/combine-apt-update-install",
|
|
288
395
|
message: "Combine apt-get update and apt-get install"
|
|
289
396
|
};
|
|
397
|
+
const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
|
|
398
|
+
const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
|
|
399
|
+
const shellDirectiveEnablesPipefail = (args) => {
|
|
400
|
+
const argv = parseExecForm(args);
|
|
401
|
+
return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
|
|
402
|
+
};
|
|
290
403
|
const usePipefail = {
|
|
291
404
|
category: "Best Practices",
|
|
292
405
|
check(instructions, file) {
|
|
293
406
|
const diagnostics = [];
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
407
|
+
const stagePipefail = /* @__PURE__ */ new Map();
|
|
408
|
+
let shellHasPipefail = false;
|
|
409
|
+
let currentStage = null;
|
|
410
|
+
for (const inst of instructions) {
|
|
411
|
+
if (inst.instruction === "FROM") {
|
|
412
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
413
|
+
const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
|
|
414
|
+
shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
|
|
415
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
416
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (inst.instruction === "SHELL") {
|
|
420
|
+
shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
|
|
421
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (inst.instruction !== "RUN") continue;
|
|
425
|
+
const { args } = inst;
|
|
426
|
+
if (!HAS_PIPE_RE.test(args)) continue;
|
|
427
|
+
const execArgv = parseExecForm(args);
|
|
428
|
+
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));
|
|
297
429
|
}
|
|
298
430
|
return diagnostics;
|
|
299
431
|
},
|
|
300
432
|
defaultSeverity: "warning",
|
|
301
|
-
help: "Prepend
|
|
433
|
+
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.",
|
|
302
434
|
key: "docker-doctor/use-pipefail",
|
|
303
435
|
message: "Use pipefail to catch pipeline command failures"
|
|
304
436
|
};
|
|
@@ -308,12 +440,12 @@ const absoluteWorkdir = {
|
|
|
308
440
|
const diagnostics = [];
|
|
309
441
|
for (const inst of instructions) if (inst.instruction === "WORKDIR") {
|
|
310
442
|
const path = inst.args.trim();
|
|
311
|
-
if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic
|
|
443
|
+
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));
|
|
312
444
|
}
|
|
313
445
|
return diagnostics;
|
|
314
446
|
},
|
|
315
447
|
defaultSeverity: "warning",
|
|
316
|
-
help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
|
|
448
|
+
help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
|
|
317
449
|
key: "docker-doctor/absolute-workdir",
|
|
318
450
|
message: "Use absolute paths for WORKDIR"
|
|
319
451
|
};
|
|
@@ -321,11 +453,11 @@ const avoidRunCd = {
|
|
|
321
453
|
category: "Best Practices",
|
|
322
454
|
check(instructions, file) {
|
|
323
455
|
const diagnostics = [];
|
|
324
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic
|
|
456
|
+
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));
|
|
325
457
|
return diagnostics;
|
|
326
458
|
},
|
|
327
459
|
defaultSeverity: "info",
|
|
328
|
-
help: "Use the WORKDIR instruction instead of
|
|
460
|
+
help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
|
|
329
461
|
key: "docker-doctor/avoid-run-cd",
|
|
330
462
|
message: "Avoid changing directories with cd in RUN"
|
|
331
463
|
};
|
|
@@ -341,7 +473,7 @@ const sortMultilineArgs = {
|
|
|
341
473
|
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);
|
|
342
474
|
if (packages.length > 1) {
|
|
343
475
|
const sorted = packages.toSorted((a, b) => a.localeCompare(b));
|
|
344
|
-
if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic
|
|
476
|
+
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));
|
|
345
477
|
}
|
|
346
478
|
}
|
|
347
479
|
}
|
|
@@ -356,11 +488,11 @@ const useraddNoLogInit = {
|
|
|
356
488
|
category: "Best Practices",
|
|
357
489
|
check(instructions, file) {
|
|
358
490
|
const diagnostics = [];
|
|
359
|
-
for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic
|
|
491
|
+
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));
|
|
360
492
|
return diagnostics;
|
|
361
493
|
},
|
|
362
494
|
defaultSeverity: "warning",
|
|
363
|
-
help: "Pass
|
|
495
|
+
help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
364
496
|
key: "docker-doctor/useradd-no-log-init",
|
|
365
497
|
message: "Use --no-log-init with useradd"
|
|
366
498
|
};
|
|
@@ -379,47 +511,40 @@ const bestPracticesRules = [
|
|
|
379
511
|
|
|
380
512
|
//#endregion
|
|
381
513
|
//#region ../core/src/rules/compose.ts
|
|
382
|
-
const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
|
|
383
|
-
file,
|
|
384
|
-
help,
|
|
385
|
-
message,
|
|
386
|
-
rule: ruleKey,
|
|
387
|
-
severity
|
|
388
|
-
});
|
|
389
514
|
const noVersionKey = {
|
|
390
515
|
category: "Compose",
|
|
391
|
-
check(composeContent, file) {
|
|
392
|
-
if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic
|
|
516
|
+
check(composeContent, file, context) {
|
|
517
|
+
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"]))];
|
|
393
518
|
return [];
|
|
394
519
|
},
|
|
395
520
|
defaultSeverity: "warning",
|
|
396
|
-
help: "The
|
|
521
|
+
help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
|
|
397
522
|
key: "docker-doctor/no-version-key",
|
|
398
|
-
message: "Remove the
|
|
523
|
+
message: "Remove the `version` key from the Compose file"
|
|
399
524
|
};
|
|
400
525
|
const requireResourceLimits = {
|
|
401
526
|
category: "Compose",
|
|
402
|
-
check(composeContent, file) {
|
|
527
|
+
check(composeContent, file, context) {
|
|
403
528
|
const diagnostics = [];
|
|
404
529
|
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
405
530
|
const { services } = composeContent;
|
|
406
531
|
if (services && typeof services === "object") {
|
|
407
532
|
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
408
533
|
const limits = (config.deploy?.resources)?.limits;
|
|
409
|
-
if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic
|
|
534
|
+
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])));
|
|
410
535
|
}
|
|
411
536
|
}
|
|
412
537
|
}
|
|
413
538
|
return diagnostics;
|
|
414
539
|
},
|
|
415
540
|
defaultSeverity: "warning",
|
|
416
|
-
help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
|
|
541
|
+
help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
|
|
417
542
|
key: "docker-doctor/require-resource-limits",
|
|
418
543
|
message: "Define resource limits for services"
|
|
419
544
|
};
|
|
420
545
|
const requireRestartPolicy = {
|
|
421
546
|
category: "Compose",
|
|
422
|
-
check(composeContent, file) {
|
|
547
|
+
check(composeContent, file, context) {
|
|
423
548
|
const diagnostics = [];
|
|
424
549
|
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
425
550
|
const { services } = composeContent;
|
|
@@ -427,34 +552,38 @@ const requireRestartPolicy = {
|
|
|
427
552
|
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
428
553
|
const hasRestart = "restart" in config;
|
|
429
554
|
const hasDeployRestart = config.deploy?.restart_policy !== void 0;
|
|
430
|
-
if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic
|
|
555
|
+
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])));
|
|
431
556
|
}
|
|
432
557
|
}
|
|
433
558
|
}
|
|
434
559
|
return diagnostics;
|
|
435
560
|
},
|
|
436
561
|
defaultSeverity: "warning",
|
|
437
|
-
help: "Define
|
|
562
|
+
help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
|
|
438
563
|
key: "docker-doctor/require-restart-policy",
|
|
439
564
|
message: "Set restart policy for services"
|
|
440
565
|
};
|
|
441
566
|
const useDependsOnCondition = {
|
|
442
567
|
category: "Compose",
|
|
443
|
-
check(composeContent, file) {
|
|
568
|
+
check(composeContent, file, context) {
|
|
444
569
|
const diagnostics = [];
|
|
445
570
|
if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
|
|
446
571
|
const { services } = composeContent;
|
|
447
572
|
if (services && typeof services === "object") {
|
|
448
573
|
for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
|
|
449
574
|
const dependsOn = config.depends_on;
|
|
450
|
-
if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic
|
|
575
|
+
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?.([
|
|
576
|
+
"services",
|
|
577
|
+
name,
|
|
578
|
+
"depends_on"
|
|
579
|
+
]) ?? context?.locate?.(["services", name])));
|
|
451
580
|
}
|
|
452
581
|
}
|
|
453
582
|
}
|
|
454
583
|
return diagnostics;
|
|
455
584
|
},
|
|
456
585
|
defaultSeverity: "info",
|
|
457
|
-
help: "Instead of a simple service list, use
|
|
586
|
+
help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
|
|
458
587
|
key: "docker-doctor/use-depends-on-condition",
|
|
459
588
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
460
589
|
};
|
|
@@ -465,126 +594,89 @@ const composeRules = [
|
|
|
465
594
|
useDependsOnCondition
|
|
466
595
|
];
|
|
467
596
|
|
|
468
|
-
//#endregion
|
|
469
|
-
//#region ../core/src/parsers/image-ref.ts
|
|
470
|
-
const parseImageRef = (ref) => {
|
|
471
|
-
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
472
|
-
isVariable: true,
|
|
473
|
-
name: ref
|
|
474
|
-
};
|
|
475
|
-
let remainder = ref;
|
|
476
|
-
let digest;
|
|
477
|
-
const atIndex = remainder.indexOf("@");
|
|
478
|
-
if (atIndex !== -1) {
|
|
479
|
-
digest = remainder.slice(atIndex + 1);
|
|
480
|
-
remainder = remainder.slice(0, atIndex);
|
|
481
|
-
}
|
|
482
|
-
let tag;
|
|
483
|
-
const lastColonIndex = remainder.lastIndexOf(":");
|
|
484
|
-
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
485
|
-
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
486
|
-
tag = remainder.slice(lastColonIndex + 1);
|
|
487
|
-
remainder = remainder.slice(0, lastColonIndex);
|
|
488
|
-
}
|
|
489
|
-
let registry;
|
|
490
|
-
const firstSlashIndex = remainder.indexOf("/");
|
|
491
|
-
if (firstSlashIndex !== -1) {
|
|
492
|
-
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
493
|
-
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
494
|
-
registry = firstSegment;
|
|
495
|
-
remainder = remainder.slice(firstSlashIndex + 1);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
return {
|
|
499
|
-
digest,
|
|
500
|
-
isVariable: false,
|
|
501
|
-
name: remainder,
|
|
502
|
-
registry,
|
|
503
|
-
tag
|
|
504
|
-
};
|
|
505
|
-
};
|
|
506
|
-
const collectStageAliases = (instructions) => {
|
|
507
|
-
const aliases = /* @__PURE__ */ new Set();
|
|
508
|
-
for (const inst of instructions) {
|
|
509
|
-
if (inst.instruction !== "FROM") continue;
|
|
510
|
-
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
511
|
-
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
512
|
-
}
|
|
513
|
-
return aliases;
|
|
514
|
-
};
|
|
515
|
-
|
|
516
597
|
//#endregion
|
|
517
598
|
//#region ../core/src/rules/image-size.ts
|
|
518
|
-
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
519
|
-
file,
|
|
520
|
-
help,
|
|
521
|
-
line,
|
|
522
|
-
message,
|
|
523
|
-
rule: ruleKey,
|
|
524
|
-
severity
|
|
525
|
-
});
|
|
526
599
|
const preferSlimBase = {
|
|
527
600
|
category: "Image Size",
|
|
528
601
|
check(instructions, file) {
|
|
529
602
|
const diagnostics = [];
|
|
530
603
|
const stageAliases = collectStageAliases(instructions);
|
|
531
604
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
532
|
-
const imagePart = inst.args
|
|
533
|
-
if (!imagePart || imagePart
|
|
605
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
606
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
534
607
|
const ref = parseImageRef(imagePart);
|
|
535
608
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
536
609
|
if (ref.digest) continue;
|
|
537
610
|
if (!ref.tag) continue;
|
|
538
611
|
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
539
|
-
if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic
|
|
612
|
+
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));
|
|
540
613
|
}
|
|
541
614
|
return diagnostics;
|
|
542
615
|
},
|
|
543
616
|
defaultSeverity: "info",
|
|
544
|
-
help: "Prefer tags with
|
|
617
|
+
help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
|
|
545
618
|
key: "docker-doctor/prefer-slim-base",
|
|
546
|
-
message: "
|
|
619
|
+
message: "Prefer slim, alpine, or distroless base images"
|
|
547
620
|
};
|
|
621
|
+
const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
|
|
622
|
+
const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
|
|
623
|
+
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)));
|
|
624
|
+
const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
|
|
625
|
+
const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
|
|
626
|
+
const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
|
|
548
627
|
const cleanPackageCache = {
|
|
549
628
|
category: "Image Size",
|
|
550
629
|
check(instructions, file) {
|
|
551
630
|
const diagnostics = [];
|
|
552
631
|
for (const inst of instructions) if (inst.instruction === "RUN") {
|
|
553
632
|
const { args } = inst;
|
|
554
|
-
if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic
|
|
555
|
-
if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic
|
|
633
|
+
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));
|
|
634
|
+
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));
|
|
556
635
|
}
|
|
557
636
|
return diagnostics;
|
|
558
637
|
},
|
|
559
638
|
defaultSeverity: "warning",
|
|
560
|
-
help: "For apt-get, append
|
|
639
|
+
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`.",
|
|
561
640
|
key: "docker-doctor/clean-package-cache",
|
|
562
641
|
message: "Clean up package manager cache in the same RUN layer"
|
|
563
642
|
};
|
|
643
|
+
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");
|
|
564
644
|
const avoidDevDependencies = {
|
|
565
645
|
category: "Image Size",
|
|
566
646
|
check(instructions, file) {
|
|
567
647
|
const diagnostics = [];
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
648
|
+
const stages = [];
|
|
649
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
650
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
651
|
+
stages.push({
|
|
652
|
+
base: base?.toLowerCase() ?? "",
|
|
653
|
+
name: stage?.toLowerCase() ?? null,
|
|
654
|
+
runs: []
|
|
655
|
+
});
|
|
656
|
+
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
657
|
+
if (stages.length === 0) return diagnostics;
|
|
658
|
+
const auditedIndices = [];
|
|
659
|
+
let index = stages.length - 1;
|
|
660
|
+
while (index >= 0) {
|
|
661
|
+
auditedIndices.push(index);
|
|
662
|
+
const { base } = stages[index];
|
|
663
|
+
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
664
|
+
}
|
|
665
|
+
const finalIndex = stages.length - 1;
|
|
666
|
+
for (const stageIndex of auditedIndices.toReversed()) {
|
|
667
|
+
const stage = stages[stageIndex];
|
|
668
|
+
for (const inst of stage.runs) {
|
|
669
|
+
if (!installsDevDependencies(inst.args)) continue;
|
|
670
|
+
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
671
|
+
diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
580
672
|
}
|
|
581
673
|
}
|
|
582
674
|
return diagnostics;
|
|
583
675
|
},
|
|
584
676
|
defaultSeverity: "warning",
|
|
585
|
-
help: "For Node.js, run
|
|
677
|
+
help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
|
|
586
678
|
key: "docker-doctor/avoid-dev-dependencies",
|
|
587
|
-
message: "Avoid installing
|
|
679
|
+
message: "Avoid installing dev dependencies in the final stage"
|
|
588
680
|
};
|
|
589
681
|
const imageSizeRules = [
|
|
590
682
|
preferSlimBase,
|
|
@@ -594,26 +686,18 @@ const imageSizeRules = [
|
|
|
594
686
|
|
|
595
687
|
//#endregion
|
|
596
688
|
//#region ../core/src/rules/performance.ts
|
|
597
|
-
const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
|
|
598
|
-
file,
|
|
599
|
-
help,
|
|
600
|
-
line,
|
|
601
|
-
message,
|
|
602
|
-
rule: ruleKey,
|
|
603
|
-
severity
|
|
604
|
-
});
|
|
605
689
|
const useMultiStage = {
|
|
606
690
|
category: "Performance",
|
|
607
691
|
check(instructions, file) {
|
|
608
692
|
if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
|
|
609
|
-
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
|
|
693
|
+
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)];
|
|
610
694
|
}
|
|
611
695
|
return [];
|
|
612
696
|
},
|
|
613
697
|
defaultSeverity: "info",
|
|
614
698
|
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
615
699
|
key: "docker-doctor/use-multi-stage",
|
|
616
|
-
message: "
|
|
700
|
+
message: "Use multi-stage builds"
|
|
617
701
|
};
|
|
618
702
|
const orderLayers = {
|
|
619
703
|
category: "Performance",
|
|
@@ -630,7 +714,7 @@ const orderLayers = {
|
|
|
630
714
|
}
|
|
631
715
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
632
716
|
const args = inst.args.toLowerCase();
|
|
633
|
-
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
|
|
717
|
+
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));
|
|
634
718
|
}
|
|
635
719
|
}
|
|
636
720
|
return diagnostics;
|
|
@@ -650,14 +734,14 @@ const minimizeLayers = {
|
|
|
650
734
|
if (consecutiveRunCount === 0) firstRunLine = inst.line;
|
|
651
735
|
consecutiveRunCount += 1;
|
|
652
736
|
} else {
|
|
653
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
737
|
+
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));
|
|
654
738
|
consecutiveRunCount = 0;
|
|
655
739
|
}
|
|
656
|
-
if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic
|
|
740
|
+
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));
|
|
657
741
|
return diagnostics;
|
|
658
742
|
},
|
|
659
743
|
defaultSeverity: "info",
|
|
660
|
-
help: "Combine consecutive RUN instructions using
|
|
744
|
+
help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
|
|
661
745
|
key: "docker-doctor/minimize-layers",
|
|
662
746
|
message: "Minimize the number of image layers"
|
|
663
747
|
};
|
|
@@ -678,13 +762,13 @@ const useDockerignore = {
|
|
|
678
762
|
return src === "." || src === "./" || src === "*";
|
|
679
763
|
}
|
|
680
764
|
return false;
|
|
681
|
-
}) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic
|
|
765
|
+
}) && 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)];
|
|
682
766
|
return [];
|
|
683
767
|
},
|
|
684
768
|
defaultSeverity: "warning",
|
|
685
769
|
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
686
770
|
key: "docker-doctor/use-dockerignore",
|
|
687
|
-
message: "
|
|
771
|
+
message: "Add a .dockerignore file"
|
|
688
772
|
};
|
|
689
773
|
const performanceRules = [
|
|
690
774
|
useMultiStage,
|
|
@@ -695,14 +779,6 @@ const performanceRules = [
|
|
|
695
779
|
|
|
696
780
|
//#endregion
|
|
697
781
|
//#region ../core/src/rules/security.ts
|
|
698
|
-
const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
699
|
-
file,
|
|
700
|
-
help,
|
|
701
|
-
line,
|
|
702
|
-
message,
|
|
703
|
-
rule: ruleKey,
|
|
704
|
-
severity
|
|
705
|
-
});
|
|
706
782
|
const isRootUser = (value) => {
|
|
707
783
|
const [user] = value.split(":");
|
|
708
784
|
return user === "root" || user === "0";
|
|
@@ -710,22 +786,28 @@ const isRootUser = (value) => {
|
|
|
710
786
|
const noRootUser = {
|
|
711
787
|
category: "Security",
|
|
712
788
|
check(instructions, file) {
|
|
789
|
+
const stageUser = /* @__PURE__ */ new Map();
|
|
790
|
+
let currentStage = null;
|
|
713
791
|
let lastUser = "root";
|
|
714
792
|
let lastUserLine = 1;
|
|
715
793
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
716
|
-
|
|
794
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
795
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
|
|
717
796
|
lastUserLine = inst.line;
|
|
797
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
798
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
718
799
|
} else if (inst.instruction === "USER") {
|
|
719
800
|
lastUser = inst.args.trim().toLowerCase();
|
|
720
801
|
lastUserLine = inst.line;
|
|
802
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
721
803
|
}
|
|
722
804
|
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)];
|
|
723
805
|
return [];
|
|
724
806
|
},
|
|
725
807
|
defaultSeverity: "warning",
|
|
726
|
-
help: "Add a non-root user (e.g.,
|
|
808
|
+
help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
|
|
727
809
|
key: "docker-doctor/no-root-user",
|
|
728
|
-
message: "
|
|
810
|
+
message: "Run the container as a non-root user"
|
|
729
811
|
};
|
|
730
812
|
const noSecretsInEnv = {
|
|
731
813
|
category: "Security",
|
|
@@ -766,7 +848,7 @@ const noSecretsInEnv = {
|
|
|
766
848
|
defaultSeverity: "error",
|
|
767
849
|
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
768
850
|
key: "docker-doctor/no-secrets-in-env",
|
|
769
|
-
message: "
|
|
851
|
+
message: "Avoid storing secrets in ENV or ARG instructions"
|
|
770
852
|
};
|
|
771
853
|
const pinImageVersion = {
|
|
772
854
|
category: "Security",
|
|
@@ -774,8 +856,8 @@ const pinImageVersion = {
|
|
|
774
856
|
const diagnostics = [];
|
|
775
857
|
const stageAliases = collectStageAliases(instructions);
|
|
776
858
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
777
|
-
const imagePart = inst.args
|
|
778
|
-
if (!imagePart || imagePart
|
|
859
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
860
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
779
861
|
const ref = parseImageRef(imagePart);
|
|
780
862
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
781
863
|
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));
|
|
@@ -784,9 +866,9 @@ const pinImageVersion = {
|
|
|
784
866
|
return diagnostics;
|
|
785
867
|
},
|
|
786
868
|
defaultSeverity: "warning",
|
|
787
|
-
help: "Specify a concrete tag instead of
|
|
869
|
+
help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
|
|
788
870
|
key: "docker-doctor/pin-image-version",
|
|
789
|
-
message: "
|
|
871
|
+
message: "Pin base images to a specific tag or digest"
|
|
790
872
|
};
|
|
791
873
|
const noAddRemote = {
|
|
792
874
|
category: "Security",
|
|
@@ -800,7 +882,7 @@ const noAddRemote = {
|
|
|
800
882
|
return diagnostics;
|
|
801
883
|
},
|
|
802
884
|
defaultSeverity: "warning",
|
|
803
|
-
help: "Use
|
|
885
|
+
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.",
|
|
804
886
|
key: "docker-doctor/no-add-remote",
|
|
805
887
|
message: "Avoid using ADD with remote URLs"
|
|
806
888
|
};
|
|
@@ -843,12 +925,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
|
|
|
843
925
|
|
|
844
926
|
//#endregion
|
|
845
927
|
//#region ../core/src/runners/compose-runner.ts
|
|
846
|
-
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
|
|
928
|
+
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
|
|
847
929
|
const diagnostics = [];
|
|
848
930
|
for (const rule of allComposeRules) {
|
|
849
931
|
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
850
932
|
if (severity === "off") continue;
|
|
851
|
-
const ruleDiagnostics = rule.check(composeContent, file);
|
|
933
|
+
const ruleDiagnostics = rule.check(composeContent, file, { locate });
|
|
852
934
|
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
853
935
|
diagnostics.push(...ruleDiagnostics);
|
|
854
936
|
}
|
|
@@ -917,6 +999,30 @@ const validateConfig = (input) => {
|
|
|
917
999
|
return result;
|
|
918
1000
|
};
|
|
919
1001
|
|
|
1002
|
+
//#endregion
|
|
1003
|
+
//#region ../core/src/config/unknown-keys.ts
|
|
1004
|
+
const KNOWN_CATEGORIES = [
|
|
1005
|
+
"Best Practices",
|
|
1006
|
+
"Compose",
|
|
1007
|
+
"Image Size",
|
|
1008
|
+
"Performance",
|
|
1009
|
+
"Security"
|
|
1010
|
+
];
|
|
1011
|
+
const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
|
|
1012
|
+
const collectUnknownConfigKeys = (raw) => {
|
|
1013
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
1014
|
+
categories: [],
|
|
1015
|
+
rules: []
|
|
1016
|
+
};
|
|
1017
|
+
const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
|
|
1018
|
+
const knownCategories = new Set(KNOWN_CATEGORIES);
|
|
1019
|
+
const { categories, rules } = raw;
|
|
1020
|
+
return {
|
|
1021
|
+
categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
|
|
1022
|
+
rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
|
|
1023
|
+
};
|
|
1024
|
+
};
|
|
1025
|
+
|
|
920
1026
|
//#endregion
|
|
921
1027
|
//#region ../core/src/config/loader.ts
|
|
922
1028
|
const fileExists = async (filePath) => {
|
|
@@ -946,7 +1052,12 @@ const importConfig = async (filePath) => {
|
|
|
946
1052
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
947
1053
|
}
|
|
948
1054
|
};
|
|
949
|
-
const
|
|
1055
|
+
const warnUnknownKeys = (raw, onWarning) => {
|
|
1056
|
+
const unknown = collectUnknownConfigKeys(raw);
|
|
1057
|
+
for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
|
|
1058
|
+
for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
|
|
1059
|
+
};
|
|
1060
|
+
const loadConfig = async (rootDir, customPath, onWarning) => {
|
|
950
1061
|
let configObject = null;
|
|
951
1062
|
if (customPath) {
|
|
952
1063
|
const fullPath = node_path.default.resolve(rootDir, customPath);
|
|
@@ -979,7 +1090,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
979
1090
|
}
|
|
980
1091
|
if (!configObject) return {};
|
|
981
1092
|
try {
|
|
982
|
-
|
|
1093
|
+
const config = validateConfig(configObject);
|
|
1094
|
+
if (onWarning) warnUnknownKeys(configObject, onWarning);
|
|
1095
|
+
return config;
|
|
983
1096
|
} catch (error) {
|
|
984
1097
|
const msg = error instanceof Error ? error.message : String(error);
|
|
985
1098
|
throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
|
|
@@ -1066,6 +1179,12 @@ Object.defineProperty(exports, 'calculateScore', {
|
|
|
1066
1179
|
return calculateScore;
|
|
1067
1180
|
}
|
|
1068
1181
|
});
|
|
1182
|
+
Object.defineProperty(exports, 'createComposeLocator', {
|
|
1183
|
+
enumerable: true,
|
|
1184
|
+
get: function () {
|
|
1185
|
+
return createComposeLocator;
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1069
1188
|
Object.defineProperty(exports, 'discoverProject', {
|
|
1070
1189
|
enumerable: true,
|
|
1071
1190
|
get: function () {
|
|
@@ -1120,4 +1239,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1120
1239
|
return version;
|
|
1121
1240
|
}
|
|
1122
1241
|
});
|
|
1123
|
-
//# sourceMappingURL=src-
|
|
1242
|
+
//# sourceMappingURL=src-DwuAaQcq.cjs.map
|