@docker-doctor/cli 0.4.1 → 0.4.3
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 +43 -1
- package/dist/cli.cjs +125 -55
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +125 -55
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/{src-DOPSHVJr.cjs → src-ILbJuJmE.cjs} +237 -123
- package/dist/src-ILbJuJmE.cjs.map +1 -0
- package/dist/{src-DHveWSuN.mjs → src-TK9DRoRo.mjs} +237 -123
- package/dist/src-TK9DRoRo.mjs.map +1 -0
- package/package.json +4 -1
- package/dist/src-DHveWSuN.mjs.map +0 -1
- package/dist/src-DOPSHVJr.cjs.map +0 -1
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
|
|
6
6
|
//#region package.json
|
|
7
|
-
var version = "0.4.
|
|
7
|
+
var version = "0.4.3";
|
|
8
8
|
|
|
9
9
|
//#endregion
|
|
10
10
|
//#region ../core/src/project-info/discover.ts
|
|
@@ -31,9 +31,9 @@ const discoverProject = async (rootDir) => {
|
|
|
31
31
|
if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(path.relative(rootDir, file));
|
|
32
32
|
}
|
|
33
33
|
return {
|
|
34
|
-
composeFiles,
|
|
35
|
-
dockerfiles,
|
|
36
|
-
dockerignores
|
|
34
|
+
composeFiles: composeFiles.toSorted(),
|
|
35
|
+
dockerfiles: dockerfiles.toSorted(),
|
|
36
|
+
dockerignores: dockerignores.toSorted()
|
|
37
37
|
};
|
|
38
38
|
};
|
|
39
39
|
|
|
@@ -59,8 +59,13 @@ const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
|
59
59
|
"VOLUME",
|
|
60
60
|
"WORKDIR"
|
|
61
61
|
]);
|
|
62
|
+
const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
|
|
63
|
+
"ADD",
|
|
64
|
+
"COPY",
|
|
65
|
+
"RUN"
|
|
66
|
+
]);
|
|
62
67
|
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
63
|
-
const HEREDOC_OPENER_RE =
|
|
68
|
+
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
|
|
64
69
|
const createParserState = () => ({
|
|
65
70
|
currentArgs: "",
|
|
66
71
|
currentInstruction: "",
|
|
@@ -114,7 +119,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
|
|
|
114
119
|
state.currentArgs = matched.args;
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
|
-
if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
122
|
+
if (HEREDOC_INSTRUCTIONS.has(state.currentInstruction)) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
118
123
|
if (state.heredocQueue.length > 0) return;
|
|
119
124
|
if (!hasContinuation) closeInstruction(state);
|
|
120
125
|
};
|
|
@@ -160,7 +165,7 @@ var ParseError = class extends Error {
|
|
|
160
165
|
//#region ../core/src/parsers/compose-parser.ts
|
|
161
166
|
const parseCompose = (content, filepath) => {
|
|
162
167
|
try {
|
|
163
|
-
return parse(content);
|
|
168
|
+
return parse(content, { merge: true });
|
|
164
169
|
} catch (error) {
|
|
165
170
|
throw new ParseError({
|
|
166
171
|
file: filepath,
|
|
@@ -169,6 +174,79 @@ const parseCompose = (content, filepath) => {
|
|
|
169
174
|
}
|
|
170
175
|
};
|
|
171
176
|
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region ../core/src/parsers/exec-form.ts
|
|
179
|
+
const parseExecForm = (args) => {
|
|
180
|
+
const trimmed = args.trim();
|
|
181
|
+
if (!trimmed.startsWith("[")) return null;
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(trimmed);
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (!Array.isArray(parsed)) return null;
|
|
189
|
+
if (!parsed.every((el) => typeof el === "string")) return null;
|
|
190
|
+
return parsed;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
195
|
+
const parseImageRef = (ref) => {
|
|
196
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
197
|
+
isVariable: true,
|
|
198
|
+
name: ref
|
|
199
|
+
};
|
|
200
|
+
let remainder = ref;
|
|
201
|
+
let digest;
|
|
202
|
+
const atIndex = remainder.indexOf("@");
|
|
203
|
+
if (atIndex !== -1) {
|
|
204
|
+
digest = remainder.slice(atIndex + 1);
|
|
205
|
+
remainder = remainder.slice(0, atIndex);
|
|
206
|
+
}
|
|
207
|
+
let tag;
|
|
208
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
209
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
210
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
211
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
212
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
213
|
+
}
|
|
214
|
+
let registry;
|
|
215
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
216
|
+
if (firstSlashIndex !== -1) {
|
|
217
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
218
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
219
|
+
registry = firstSegment;
|
|
220
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
digest,
|
|
225
|
+
isVariable: false,
|
|
226
|
+
name: remainder,
|
|
227
|
+
registry,
|
|
228
|
+
tag
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
const parseFromArgs = (args) => {
|
|
232
|
+
const parts = args.split(/\s+/u).filter(Boolean);
|
|
233
|
+
const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
|
|
234
|
+
return {
|
|
235
|
+
base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
|
|
236
|
+
stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
const isScratch = (base) => base?.toLowerCase() === "scratch";
|
|
240
|
+
const collectStageAliases = (instructions) => {
|
|
241
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
242
|
+
for (const inst of instructions) {
|
|
243
|
+
if (inst.instruction !== "FROM") continue;
|
|
244
|
+
const { stage } = parseFromArgs(inst.args);
|
|
245
|
+
if (stage) aliases.add(stage.toLowerCase());
|
|
246
|
+
}
|
|
247
|
+
return aliases;
|
|
248
|
+
};
|
|
249
|
+
|
|
172
250
|
//#endregion
|
|
173
251
|
//#region ../core/src/rules/best-practices.ts
|
|
174
252
|
const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -188,7 +266,7 @@ const requireHealthcheck = {
|
|
|
188
266
|
return [];
|
|
189
267
|
},
|
|
190
268
|
defaultSeverity: "info",
|
|
191
|
-
help: "Use HEALTHCHECK (e.g.,
|
|
269
|
+
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.",
|
|
192
270
|
key: "docker-doctor/require-healthcheck",
|
|
193
271
|
message: "Add a HEALTHCHECK instruction"
|
|
194
272
|
};
|
|
@@ -214,14 +292,11 @@ const useExecForm = {
|
|
|
214
292
|
category: "Best Practices",
|
|
215
293
|
check(instructions, file) {
|
|
216
294
|
const diagnostics = [];
|
|
217
|
-
for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
|
|
218
|
-
const args = inst.args.trim();
|
|
219
|
-
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));
|
|
220
|
-
}
|
|
295
|
+
for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) 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));
|
|
221
296
|
return diagnostics;
|
|
222
297
|
},
|
|
223
298
|
defaultSeverity: "warning",
|
|
224
|
-
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
299
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
225
300
|
key: "docker-doctor/use-exec-form",
|
|
226
301
|
message: "Use exec form for CMD and ENTRYPOINT"
|
|
227
302
|
};
|
|
@@ -232,7 +307,7 @@ const requireLabels = {
|
|
|
232
307
|
return [];
|
|
233
308
|
},
|
|
234
309
|
defaultSeverity: "info",
|
|
235
|
-
help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
|
|
310
|
+
help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
|
|
236
311
|
key: "docker-doctor/require-labels",
|
|
237
312
|
message: "Add LABEL metadata to images"
|
|
238
313
|
};
|
|
@@ -249,22 +324,47 @@ const combineAptUpdateInstall = {
|
|
|
249
324
|
return diagnostics;
|
|
250
325
|
},
|
|
251
326
|
defaultSeverity: "warning",
|
|
252
|
-
help: "Combine
|
|
327
|
+
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/*`).",
|
|
253
328
|
key: "docker-doctor/combine-apt-update-install",
|
|
254
329
|
message: "Combine apt-get update and apt-get install"
|
|
255
330
|
};
|
|
331
|
+
const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
|
|
332
|
+
const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
|
|
333
|
+
const shellDirectiveEnablesPipefail = (args) => {
|
|
334
|
+
const argv = parseExecForm(args);
|
|
335
|
+
return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
|
|
336
|
+
};
|
|
256
337
|
const usePipefail = {
|
|
257
338
|
category: "Best Practices",
|
|
258
339
|
check(instructions, file) {
|
|
259
340
|
const diagnostics = [];
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
341
|
+
const stagePipefail = /* @__PURE__ */ new Map();
|
|
342
|
+
let shellHasPipefail = false;
|
|
343
|
+
let currentStage = null;
|
|
344
|
+
for (const inst of instructions) {
|
|
345
|
+
if (inst.instruction === "FROM") {
|
|
346
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
347
|
+
const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
|
|
348
|
+
shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
|
|
349
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
350
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (inst.instruction === "SHELL") {
|
|
354
|
+
shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
|
|
355
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (inst.instruction !== "RUN") continue;
|
|
359
|
+
const { args } = inst;
|
|
360
|
+
if (!HAS_PIPE_RE.test(args)) continue;
|
|
361
|
+
const execArgv = parseExecForm(args);
|
|
362
|
+
if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
|
|
263
363
|
}
|
|
264
364
|
return diagnostics;
|
|
265
365
|
},
|
|
266
366
|
defaultSeverity: "warning",
|
|
267
|
-
help: "Prepend
|
|
367
|
+
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.",
|
|
268
368
|
key: "docker-doctor/use-pipefail",
|
|
269
369
|
message: "Use pipefail to catch pipeline command failures"
|
|
270
370
|
};
|
|
@@ -279,7 +379,7 @@ const absoluteWorkdir = {
|
|
|
279
379
|
return diagnostics;
|
|
280
380
|
},
|
|
281
381
|
defaultSeverity: "warning",
|
|
282
|
-
help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
|
|
382
|
+
help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
|
|
283
383
|
key: "docker-doctor/absolute-workdir",
|
|
284
384
|
message: "Use absolute paths for WORKDIR"
|
|
285
385
|
};
|
|
@@ -291,7 +391,7 @@ const avoidRunCd = {
|
|
|
291
391
|
return diagnostics;
|
|
292
392
|
},
|
|
293
393
|
defaultSeverity: "info",
|
|
294
|
-
help: "Use the WORKDIR instruction instead of
|
|
394
|
+
help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
|
|
295
395
|
key: "docker-doctor/avoid-run-cd",
|
|
296
396
|
message: "Avoid changing directories with cd in RUN"
|
|
297
397
|
};
|
|
@@ -326,7 +426,7 @@ const useraddNoLogInit = {
|
|
|
326
426
|
return diagnostics;
|
|
327
427
|
},
|
|
328
428
|
defaultSeverity: "warning",
|
|
329
|
-
help: "Pass
|
|
429
|
+
help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
330
430
|
key: "docker-doctor/useradd-no-log-init",
|
|
331
431
|
message: "Use --no-log-init with useradd"
|
|
332
432
|
};
|
|
@@ -359,9 +459,9 @@ const noVersionKey = {
|
|
|
359
459
|
return [];
|
|
360
460
|
},
|
|
361
461
|
defaultSeverity: "warning",
|
|
362
|
-
help: "The
|
|
462
|
+
help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
|
|
363
463
|
key: "docker-doctor/no-version-key",
|
|
364
|
-
message: "Remove the
|
|
464
|
+
message: "Remove the `version` key from the Compose file"
|
|
365
465
|
};
|
|
366
466
|
const requireResourceLimits = {
|
|
367
467
|
category: "Compose",
|
|
@@ -379,7 +479,7 @@ const requireResourceLimits = {
|
|
|
379
479
|
return diagnostics;
|
|
380
480
|
},
|
|
381
481
|
defaultSeverity: "warning",
|
|
382
|
-
help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
|
|
482
|
+
help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
|
|
383
483
|
key: "docker-doctor/require-resource-limits",
|
|
384
484
|
message: "Define resource limits for services"
|
|
385
485
|
};
|
|
@@ -400,7 +500,7 @@ const requireRestartPolicy = {
|
|
|
400
500
|
return diagnostics;
|
|
401
501
|
},
|
|
402
502
|
defaultSeverity: "warning",
|
|
403
|
-
help: "Define
|
|
503
|
+
help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
|
|
404
504
|
key: "docker-doctor/require-restart-policy",
|
|
405
505
|
message: "Set restart policy for services"
|
|
406
506
|
};
|
|
@@ -420,7 +520,7 @@ const useDependsOnCondition = {
|
|
|
420
520
|
return diagnostics;
|
|
421
521
|
},
|
|
422
522
|
defaultSeverity: "info",
|
|
423
|
-
help: "Instead of a simple service list, use
|
|
523
|
+
help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
|
|
424
524
|
key: "docker-doctor/use-depends-on-condition",
|
|
425
525
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
426
526
|
};
|
|
@@ -431,54 +531,6 @@ const composeRules = [
|
|
|
431
531
|
useDependsOnCondition
|
|
432
532
|
];
|
|
433
533
|
|
|
434
|
-
//#endregion
|
|
435
|
-
//#region ../core/src/parsers/image-ref.ts
|
|
436
|
-
const parseImageRef = (ref) => {
|
|
437
|
-
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
438
|
-
isVariable: true,
|
|
439
|
-
name: ref
|
|
440
|
-
};
|
|
441
|
-
let remainder = ref;
|
|
442
|
-
let digest;
|
|
443
|
-
const atIndex = remainder.indexOf("@");
|
|
444
|
-
if (atIndex !== -1) {
|
|
445
|
-
digest = remainder.slice(atIndex + 1);
|
|
446
|
-
remainder = remainder.slice(0, atIndex);
|
|
447
|
-
}
|
|
448
|
-
let tag;
|
|
449
|
-
const lastColonIndex = remainder.lastIndexOf(":");
|
|
450
|
-
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
451
|
-
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
452
|
-
tag = remainder.slice(lastColonIndex + 1);
|
|
453
|
-
remainder = remainder.slice(0, lastColonIndex);
|
|
454
|
-
}
|
|
455
|
-
let registry;
|
|
456
|
-
const firstSlashIndex = remainder.indexOf("/");
|
|
457
|
-
if (firstSlashIndex !== -1) {
|
|
458
|
-
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
459
|
-
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
460
|
-
registry = firstSegment;
|
|
461
|
-
remainder = remainder.slice(firstSlashIndex + 1);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
return {
|
|
465
|
-
digest,
|
|
466
|
-
isVariable: false,
|
|
467
|
-
name: remainder,
|
|
468
|
-
registry,
|
|
469
|
-
tag
|
|
470
|
-
};
|
|
471
|
-
};
|
|
472
|
-
const collectStageAliases = (instructions) => {
|
|
473
|
-
const aliases = /* @__PURE__ */ new Set();
|
|
474
|
-
for (const inst of instructions) {
|
|
475
|
-
if (inst.instruction !== "FROM") continue;
|
|
476
|
-
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
477
|
-
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
478
|
-
}
|
|
479
|
-
return aliases;
|
|
480
|
-
};
|
|
481
|
-
|
|
482
534
|
//#endregion
|
|
483
535
|
//#region ../core/src/rules/image-size.ts
|
|
484
536
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -495,21 +547,21 @@ const preferSlimBase = {
|
|
|
495
547
|
const diagnostics = [];
|
|
496
548
|
const stageAliases = collectStageAliases(instructions);
|
|
497
549
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
498
|
-
const imagePart = inst.args
|
|
499
|
-
if (!imagePart || imagePart
|
|
550
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
551
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
500
552
|
const ref = parseImageRef(imagePart);
|
|
501
553
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
502
554
|
if (ref.digest) continue;
|
|
503
555
|
if (!ref.tag) continue;
|
|
504
|
-
const
|
|
505
|
-
if (!(
|
|
556
|
+
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
557
|
+
if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Base image '${imagePart}' may be a full-OS distribution. Consider using a slim or alpine alternative.`, this.help, inst.line));
|
|
506
558
|
}
|
|
507
559
|
return diagnostics;
|
|
508
560
|
},
|
|
509
561
|
defaultSeverity: "info",
|
|
510
|
-
help: "Prefer tags with
|
|
562
|
+
help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
|
|
511
563
|
key: "docker-doctor/prefer-slim-base",
|
|
512
|
-
message: "
|
|
564
|
+
message: "Prefer slim, alpine, or distroless base images"
|
|
513
565
|
};
|
|
514
566
|
const cleanPackageCache = {
|
|
515
567
|
category: "Image Size",
|
|
@@ -523,34 +575,47 @@ const cleanPackageCache = {
|
|
|
523
575
|
return diagnostics;
|
|
524
576
|
},
|
|
525
577
|
defaultSeverity: "warning",
|
|
526
|
-
help: "For apt-get, append
|
|
578
|
+
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`.",
|
|
527
579
|
key: "docker-doctor/clean-package-cache",
|
|
528
580
|
message: "Clean up package manager cache in the same RUN layer"
|
|
529
581
|
};
|
|
582
|
+
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");
|
|
530
583
|
const avoidDevDependencies = {
|
|
531
584
|
category: "Image Size",
|
|
532
585
|
check(instructions, file) {
|
|
533
586
|
const diagnostics = [];
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
587
|
+
const stages = [];
|
|
588
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
589
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
590
|
+
stages.push({
|
|
591
|
+
base: base?.toLowerCase() ?? "",
|
|
592
|
+
name: stage?.toLowerCase() ?? null,
|
|
593
|
+
runs: []
|
|
594
|
+
});
|
|
595
|
+
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
596
|
+
if (stages.length === 0) return diagnostics;
|
|
597
|
+
const auditedIndices = [];
|
|
598
|
+
let index = stages.length - 1;
|
|
599
|
+
while (index >= 0) {
|
|
600
|
+
auditedIndices.push(index);
|
|
601
|
+
const { base } = stages[index];
|
|
602
|
+
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
603
|
+
}
|
|
604
|
+
const finalIndex = stages.length - 1;
|
|
605
|
+
for (const stageIndex of auditedIndices.toReversed()) {
|
|
606
|
+
const stage = stages[stageIndex];
|
|
607
|
+
for (const inst of stage.runs) {
|
|
608
|
+
if (!installsDevDependencies(inst.args)) continue;
|
|
609
|
+
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
610
|
+
diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
546
611
|
}
|
|
547
612
|
}
|
|
548
613
|
return diagnostics;
|
|
549
614
|
},
|
|
550
615
|
defaultSeverity: "warning",
|
|
551
|
-
help: "For Node.js, run
|
|
616
|
+
help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
|
|
552
617
|
key: "docker-doctor/avoid-dev-dependencies",
|
|
553
|
-
message: "Avoid installing
|
|
618
|
+
message: "Avoid installing dev dependencies in the final stage"
|
|
554
619
|
};
|
|
555
620
|
const imageSizeRules = [
|
|
556
621
|
preferSlimBase,
|
|
@@ -579,7 +644,7 @@ const useMultiStage = {
|
|
|
579
644
|
defaultSeverity: "info",
|
|
580
645
|
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
581
646
|
key: "docker-doctor/use-multi-stage",
|
|
582
|
-
message: "
|
|
647
|
+
message: "Use multi-stage builds"
|
|
583
648
|
};
|
|
584
649
|
const orderLayers = {
|
|
585
650
|
category: "Performance",
|
|
@@ -623,10 +688,16 @@ const minimizeLayers = {
|
|
|
623
688
|
return diagnostics;
|
|
624
689
|
},
|
|
625
690
|
defaultSeverity: "info",
|
|
626
|
-
help: "Combine consecutive RUN instructions using
|
|
691
|
+
help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
|
|
627
692
|
key: "docker-doctor/minimize-layers",
|
|
628
693
|
message: "Minimize the number of image layers"
|
|
629
694
|
};
|
|
695
|
+
const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
|
|
696
|
+
const lastSlash = dockerfilePath.lastIndexOf("/");
|
|
697
|
+
const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
|
|
698
|
+
const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
|
|
699
|
+
return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
|
|
700
|
+
};
|
|
630
701
|
const useDockerignore = {
|
|
631
702
|
category: "Performance",
|
|
632
703
|
check(instructions, file, context) {
|
|
@@ -638,15 +709,13 @@ const useDockerignore = {
|
|
|
638
709
|
return src === "." || src === "./" || src === "*";
|
|
639
710
|
}
|
|
640
711
|
return false;
|
|
641
|
-
}) && context?.projectFiles)
|
|
642
|
-
if (!context.projectFiles.some((f) => f.endsWith(".dockerignore"))) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Using COPY/ADD with wildcard/directory, but no .dockerignore file was found in the workspace. This can copy local build folders and secrets.", this.help, 1)];
|
|
643
|
-
}
|
|
712
|
+
}) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic$1(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)];
|
|
644
713
|
return [];
|
|
645
714
|
},
|
|
646
715
|
defaultSeverity: "warning",
|
|
647
716
|
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
648
717
|
key: "docker-doctor/use-dockerignore",
|
|
649
|
-
message: "
|
|
718
|
+
message: "Add a .dockerignore file"
|
|
650
719
|
};
|
|
651
720
|
const performanceRules = [
|
|
652
721
|
useMultiStage,
|
|
@@ -665,25 +734,35 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
|
665
734
|
rule: ruleKey,
|
|
666
735
|
severity
|
|
667
736
|
});
|
|
737
|
+
const isRootUser = (value) => {
|
|
738
|
+
const [user] = value.split(":");
|
|
739
|
+
return user === "root" || user === "0";
|
|
740
|
+
};
|
|
668
741
|
const noRootUser = {
|
|
669
742
|
category: "Security",
|
|
670
743
|
check(instructions, file) {
|
|
744
|
+
const stageUser = /* @__PURE__ */ new Map();
|
|
745
|
+
let currentStage = null;
|
|
671
746
|
let lastUser = "root";
|
|
672
747
|
let lastUserLine = 1;
|
|
673
748
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
674
|
-
|
|
749
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
750
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
|
|
675
751
|
lastUserLine = inst.line;
|
|
752
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
753
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
676
754
|
} else if (inst.instruction === "USER") {
|
|
677
755
|
lastUser = inst.args.trim().toLowerCase();
|
|
678
756
|
lastUserLine = inst.line;
|
|
757
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
679
758
|
}
|
|
680
|
-
if (lastUser
|
|
759
|
+
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)];
|
|
681
760
|
return [];
|
|
682
761
|
},
|
|
683
762
|
defaultSeverity: "warning",
|
|
684
|
-
help: "Add a non-root user (e.g.,
|
|
763
|
+
help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
|
|
685
764
|
key: "docker-doctor/no-root-user",
|
|
686
|
-
message: "
|
|
765
|
+
message: "Run the container as a non-root user"
|
|
687
766
|
};
|
|
688
767
|
const noSecretsInEnv = {
|
|
689
768
|
category: "Security",
|
|
@@ -724,7 +803,7 @@ const noSecretsInEnv = {
|
|
|
724
803
|
defaultSeverity: "error",
|
|
725
804
|
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
726
805
|
key: "docker-doctor/no-secrets-in-env",
|
|
727
|
-
message: "
|
|
806
|
+
message: "Avoid storing secrets in ENV or ARG instructions"
|
|
728
807
|
};
|
|
729
808
|
const pinImageVersion = {
|
|
730
809
|
category: "Security",
|
|
@@ -732,8 +811,8 @@ const pinImageVersion = {
|
|
|
732
811
|
const diagnostics = [];
|
|
733
812
|
const stageAliases = collectStageAliases(instructions);
|
|
734
813
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
735
|
-
const imagePart = inst.args
|
|
736
|
-
if (!imagePart || imagePart
|
|
814
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
815
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
737
816
|
const ref = parseImageRef(imagePart);
|
|
738
817
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
739
818
|
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));
|
|
@@ -742,9 +821,9 @@ const pinImageVersion = {
|
|
|
742
821
|
return diagnostics;
|
|
743
822
|
},
|
|
744
823
|
defaultSeverity: "warning",
|
|
745
|
-
help: "Specify a concrete tag instead of
|
|
824
|
+
help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
|
|
746
825
|
key: "docker-doctor/pin-image-version",
|
|
747
|
-
message: "
|
|
826
|
+
message: "Pin base images to a specific tag or digest"
|
|
748
827
|
};
|
|
749
828
|
const noAddRemote = {
|
|
750
829
|
category: "Security",
|
|
@@ -758,7 +837,7 @@ const noAddRemote = {
|
|
|
758
837
|
return diagnostics;
|
|
759
838
|
},
|
|
760
839
|
defaultSeverity: "warning",
|
|
761
|
-
help: "Use
|
|
840
|
+
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.",
|
|
762
841
|
key: "docker-doctor/no-add-remote",
|
|
763
842
|
message: "Avoid using ADD with remote URLs"
|
|
764
843
|
};
|
|
@@ -781,15 +860,19 @@ const allComposeRules = [...composeRules];
|
|
|
781
860
|
const allRules = [...allDockerfileRules, ...allComposeRules];
|
|
782
861
|
const findRule = (key) => allRules.find((rule) => rule.key === key);
|
|
783
862
|
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region ../core/src/runners/resolve-severity.ts
|
|
865
|
+
const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[rule.key] ?? categoriesConfig?.[rule.category] ?? rule.defaultSeverity;
|
|
866
|
+
|
|
784
867
|
//#endregion
|
|
785
868
|
//#region ../core/src/runners/dockerfile-runner.ts
|
|
786
|
-
const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
|
|
869
|
+
const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
|
|
787
870
|
const diagnostics = [];
|
|
788
871
|
for (const rule of allDockerfileRules) {
|
|
789
|
-
const
|
|
790
|
-
if (
|
|
872
|
+
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
873
|
+
if (severity === "off") continue;
|
|
791
874
|
const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
|
|
792
|
-
if (
|
|
875
|
+
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
793
876
|
diagnostics.push(...ruleDiagnostics);
|
|
794
877
|
}
|
|
795
878
|
return diagnostics;
|
|
@@ -797,13 +880,13 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
|
|
|
797
880
|
|
|
798
881
|
//#endregion
|
|
799
882
|
//#region ../core/src/runners/compose-runner.ts
|
|
800
|
-
const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
883
|
+
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
|
|
801
884
|
const diagnostics = [];
|
|
802
885
|
for (const rule of allComposeRules) {
|
|
803
|
-
const
|
|
804
|
-
if (
|
|
886
|
+
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
887
|
+
if (severity === "off") continue;
|
|
805
888
|
const ruleDiagnostics = rule.check(composeContent, file);
|
|
806
|
-
if (
|
|
889
|
+
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
807
890
|
diagnostics.push(...ruleDiagnostics);
|
|
808
891
|
}
|
|
809
892
|
return diagnostics;
|
|
@@ -871,6 +954,30 @@ const validateConfig = (input) => {
|
|
|
871
954
|
return result;
|
|
872
955
|
};
|
|
873
956
|
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region ../core/src/config/unknown-keys.ts
|
|
959
|
+
const KNOWN_CATEGORIES = [
|
|
960
|
+
"Best Practices",
|
|
961
|
+
"Compose",
|
|
962
|
+
"Image Size",
|
|
963
|
+
"Performance",
|
|
964
|
+
"Security"
|
|
965
|
+
];
|
|
966
|
+
const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
|
|
967
|
+
const collectUnknownConfigKeys = (raw) => {
|
|
968
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
969
|
+
categories: [],
|
|
970
|
+
rules: []
|
|
971
|
+
};
|
|
972
|
+
const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
|
|
973
|
+
const knownCategories = new Set(KNOWN_CATEGORIES);
|
|
974
|
+
const { categories, rules } = raw;
|
|
975
|
+
return {
|
|
976
|
+
categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
|
|
977
|
+
rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
|
|
978
|
+
};
|
|
979
|
+
};
|
|
980
|
+
|
|
874
981
|
//#endregion
|
|
875
982
|
//#region ../core/src/config/loader.ts
|
|
876
983
|
const fileExists = async (filePath) => {
|
|
@@ -900,7 +1007,12 @@ const importConfig = async (filePath) => {
|
|
|
900
1007
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
901
1008
|
}
|
|
902
1009
|
};
|
|
903
|
-
const
|
|
1010
|
+
const warnUnknownKeys = (raw, onWarning) => {
|
|
1011
|
+
const unknown = collectUnknownConfigKeys(raw);
|
|
1012
|
+
for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
|
|
1013
|
+
for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
|
|
1014
|
+
};
|
|
1015
|
+
const loadConfig = async (rootDir, customPath, onWarning) => {
|
|
904
1016
|
let configObject = null;
|
|
905
1017
|
if (customPath) {
|
|
906
1018
|
const fullPath = path.resolve(rootDir, customPath);
|
|
@@ -933,7 +1045,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
933
1045
|
}
|
|
934
1046
|
if (!configObject) return {};
|
|
935
1047
|
try {
|
|
936
|
-
|
|
1048
|
+
const config = validateConfig(configObject);
|
|
1049
|
+
if (onWarning) warnUnknownKeys(configObject, onWarning);
|
|
1050
|
+
return config;
|
|
937
1051
|
} catch (error) {
|
|
938
1052
|
const msg = error instanceof Error ? error.message : String(error);
|
|
939
1053
|
throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
|
|
@@ -1003,4 +1117,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1003
1117
|
|
|
1004
1118
|
//#endregion
|
|
1005
1119
|
export { runDockerfileRules as a, parseCompose as c, version as d, runComposeRules as i, parseDockerfile as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, discoverProject as u };
|
|
1006
|
-
//# sourceMappingURL=src-
|
|
1120
|
+
//# sourceMappingURL=src-TK9DRoRo.mjs.map
|