@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
|
@@ -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.3";
|
|
37
37
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region ../core/src/project-info/discover.ts
|
|
@@ -60,9 +60,9 @@ const discoverProject = async (rootDir) => {
|
|
|
60
60
|
if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(node_path.default.relative(rootDir, file));
|
|
61
61
|
}
|
|
62
62
|
return {
|
|
63
|
-
composeFiles,
|
|
64
|
-
dockerfiles,
|
|
65
|
-
dockerignores
|
|
63
|
+
composeFiles: composeFiles.toSorted(),
|
|
64
|
+
dockerfiles: dockerfiles.toSorted(),
|
|
65
|
+
dockerignores: dockerignores.toSorted()
|
|
66
66
|
};
|
|
67
67
|
};
|
|
68
68
|
|
|
@@ -88,8 +88,13 @@ const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
|
88
88
|
"VOLUME",
|
|
89
89
|
"WORKDIR"
|
|
90
90
|
]);
|
|
91
|
+
const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
|
|
92
|
+
"ADD",
|
|
93
|
+
"COPY",
|
|
94
|
+
"RUN"
|
|
95
|
+
]);
|
|
91
96
|
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
92
|
-
const HEREDOC_OPENER_RE =
|
|
97
|
+
const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
|
|
93
98
|
const createParserState = () => ({
|
|
94
99
|
currentArgs: "",
|
|
95
100
|
currentInstruction: "",
|
|
@@ -143,7 +148,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
|
|
|
143
148
|
state.currentArgs = matched.args;
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
|
-
if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
151
|
+
if (HEREDOC_INSTRUCTIONS.has(state.currentInstruction)) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
147
152
|
if (state.heredocQueue.length > 0) return;
|
|
148
153
|
if (!hasContinuation) closeInstruction(state);
|
|
149
154
|
};
|
|
@@ -189,7 +194,7 @@ var ParseError = class extends Error {
|
|
|
189
194
|
//#region ../core/src/parsers/compose-parser.ts
|
|
190
195
|
const parseCompose = (content, filepath) => {
|
|
191
196
|
try {
|
|
192
|
-
return (0, yaml.parse)(content);
|
|
197
|
+
return (0, yaml.parse)(content, { merge: true });
|
|
193
198
|
} catch (error) {
|
|
194
199
|
throw new ParseError({
|
|
195
200
|
file: filepath,
|
|
@@ -198,6 +203,79 @@ const parseCompose = (content, filepath) => {
|
|
|
198
203
|
}
|
|
199
204
|
};
|
|
200
205
|
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region ../core/src/parsers/exec-form.ts
|
|
208
|
+
const parseExecForm = (args) => {
|
|
209
|
+
const trimmed = args.trim();
|
|
210
|
+
if (!trimmed.startsWith("[")) return null;
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(trimmed);
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
if (!Array.isArray(parsed)) return null;
|
|
218
|
+
if (!parsed.every((el) => typeof el === "string")) return null;
|
|
219
|
+
return parsed;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
224
|
+
const parseImageRef = (ref) => {
|
|
225
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
226
|
+
isVariable: true,
|
|
227
|
+
name: ref
|
|
228
|
+
};
|
|
229
|
+
let remainder = ref;
|
|
230
|
+
let digest;
|
|
231
|
+
const atIndex = remainder.indexOf("@");
|
|
232
|
+
if (atIndex !== -1) {
|
|
233
|
+
digest = remainder.slice(atIndex + 1);
|
|
234
|
+
remainder = remainder.slice(0, atIndex);
|
|
235
|
+
}
|
|
236
|
+
let tag;
|
|
237
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
238
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
239
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
240
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
241
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
242
|
+
}
|
|
243
|
+
let registry;
|
|
244
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
245
|
+
if (firstSlashIndex !== -1) {
|
|
246
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
247
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
248
|
+
registry = firstSegment;
|
|
249
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
digest,
|
|
254
|
+
isVariable: false,
|
|
255
|
+
name: remainder,
|
|
256
|
+
registry,
|
|
257
|
+
tag
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
const parseFromArgs = (args) => {
|
|
261
|
+
const parts = args.split(/\s+/u).filter(Boolean);
|
|
262
|
+
const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
|
|
263
|
+
return {
|
|
264
|
+
base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
|
|
265
|
+
stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
const isScratch = (base) => base?.toLowerCase() === "scratch";
|
|
269
|
+
const collectStageAliases = (instructions) => {
|
|
270
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
271
|
+
for (const inst of instructions) {
|
|
272
|
+
if (inst.instruction !== "FROM") continue;
|
|
273
|
+
const { stage } = parseFromArgs(inst.args);
|
|
274
|
+
if (stage) aliases.add(stage.toLowerCase());
|
|
275
|
+
}
|
|
276
|
+
return aliases;
|
|
277
|
+
};
|
|
278
|
+
|
|
201
279
|
//#endregion
|
|
202
280
|
//#region ../core/src/rules/best-practices.ts
|
|
203
281
|
const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -217,7 +295,7 @@ const requireHealthcheck = {
|
|
|
217
295
|
return [];
|
|
218
296
|
},
|
|
219
297
|
defaultSeverity: "info",
|
|
220
|
-
help: "Use HEALTHCHECK (e.g.,
|
|
298
|
+
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.",
|
|
221
299
|
key: "docker-doctor/require-healthcheck",
|
|
222
300
|
message: "Add a HEALTHCHECK instruction"
|
|
223
301
|
};
|
|
@@ -243,14 +321,11 @@ const useExecForm = {
|
|
|
243
321
|
category: "Best Practices",
|
|
244
322
|
check(instructions, file) {
|
|
245
323
|
const diagnostics = [];
|
|
246
|
-
for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
|
|
247
|
-
const args = inst.args.trim();
|
|
248
|
-
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));
|
|
249
|
-
}
|
|
324
|
+
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));
|
|
250
325
|
return diagnostics;
|
|
251
326
|
},
|
|
252
327
|
defaultSeverity: "warning",
|
|
253
|
-
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
328
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
254
329
|
key: "docker-doctor/use-exec-form",
|
|
255
330
|
message: "Use exec form for CMD and ENTRYPOINT"
|
|
256
331
|
};
|
|
@@ -261,7 +336,7 @@ const requireLabels = {
|
|
|
261
336
|
return [];
|
|
262
337
|
},
|
|
263
338
|
defaultSeverity: "info",
|
|
264
|
-
help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
|
|
339
|
+
help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
|
|
265
340
|
key: "docker-doctor/require-labels",
|
|
266
341
|
message: "Add LABEL metadata to images"
|
|
267
342
|
};
|
|
@@ -278,22 +353,47 @@ const combineAptUpdateInstall = {
|
|
|
278
353
|
return diagnostics;
|
|
279
354
|
},
|
|
280
355
|
defaultSeverity: "warning",
|
|
281
|
-
help: "Combine
|
|
356
|
+
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/*`).",
|
|
282
357
|
key: "docker-doctor/combine-apt-update-install",
|
|
283
358
|
message: "Combine apt-get update and apt-get install"
|
|
284
359
|
};
|
|
360
|
+
const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
|
|
361
|
+
const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
|
|
362
|
+
const shellDirectiveEnablesPipefail = (args) => {
|
|
363
|
+
const argv = parseExecForm(args);
|
|
364
|
+
return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
|
|
365
|
+
};
|
|
285
366
|
const usePipefail = {
|
|
286
367
|
category: "Best Practices",
|
|
287
368
|
check(instructions, file) {
|
|
288
369
|
const diagnostics = [];
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
370
|
+
const stagePipefail = /* @__PURE__ */ new Map();
|
|
371
|
+
let shellHasPipefail = false;
|
|
372
|
+
let currentStage = null;
|
|
373
|
+
for (const inst of instructions) {
|
|
374
|
+
if (inst.instruction === "FROM") {
|
|
375
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
376
|
+
const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
|
|
377
|
+
shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
|
|
378
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
379
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (inst.instruction === "SHELL") {
|
|
383
|
+
shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
|
|
384
|
+
if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (inst.instruction !== "RUN") continue;
|
|
388
|
+
const { args } = inst;
|
|
389
|
+
if (!HAS_PIPE_RE.test(args)) continue;
|
|
390
|
+
const execArgv = parseExecForm(args);
|
|
391
|
+
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));
|
|
292
392
|
}
|
|
293
393
|
return diagnostics;
|
|
294
394
|
},
|
|
295
395
|
defaultSeverity: "warning",
|
|
296
|
-
help: "Prepend
|
|
396
|
+
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.",
|
|
297
397
|
key: "docker-doctor/use-pipefail",
|
|
298
398
|
message: "Use pipefail to catch pipeline command failures"
|
|
299
399
|
};
|
|
@@ -308,7 +408,7 @@ const absoluteWorkdir = {
|
|
|
308
408
|
return diagnostics;
|
|
309
409
|
},
|
|
310
410
|
defaultSeverity: "warning",
|
|
311
|
-
help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
|
|
411
|
+
help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
|
|
312
412
|
key: "docker-doctor/absolute-workdir",
|
|
313
413
|
message: "Use absolute paths for WORKDIR"
|
|
314
414
|
};
|
|
@@ -320,7 +420,7 @@ const avoidRunCd = {
|
|
|
320
420
|
return diagnostics;
|
|
321
421
|
},
|
|
322
422
|
defaultSeverity: "info",
|
|
323
|
-
help: "Use the WORKDIR instruction instead of
|
|
423
|
+
help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
|
|
324
424
|
key: "docker-doctor/avoid-run-cd",
|
|
325
425
|
message: "Avoid changing directories with cd in RUN"
|
|
326
426
|
};
|
|
@@ -355,7 +455,7 @@ const useraddNoLogInit = {
|
|
|
355
455
|
return diagnostics;
|
|
356
456
|
},
|
|
357
457
|
defaultSeverity: "warning",
|
|
358
|
-
help: "Pass
|
|
458
|
+
help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
359
459
|
key: "docker-doctor/useradd-no-log-init",
|
|
360
460
|
message: "Use --no-log-init with useradd"
|
|
361
461
|
};
|
|
@@ -388,9 +488,9 @@ const noVersionKey = {
|
|
|
388
488
|
return [];
|
|
389
489
|
},
|
|
390
490
|
defaultSeverity: "warning",
|
|
391
|
-
help: "The
|
|
491
|
+
help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
|
|
392
492
|
key: "docker-doctor/no-version-key",
|
|
393
|
-
message: "Remove the
|
|
493
|
+
message: "Remove the `version` key from the Compose file"
|
|
394
494
|
};
|
|
395
495
|
const requireResourceLimits = {
|
|
396
496
|
category: "Compose",
|
|
@@ -408,7 +508,7 @@ const requireResourceLimits = {
|
|
|
408
508
|
return diagnostics;
|
|
409
509
|
},
|
|
410
510
|
defaultSeverity: "warning",
|
|
411
|
-
help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
|
|
511
|
+
help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
|
|
412
512
|
key: "docker-doctor/require-resource-limits",
|
|
413
513
|
message: "Define resource limits for services"
|
|
414
514
|
};
|
|
@@ -429,7 +529,7 @@ const requireRestartPolicy = {
|
|
|
429
529
|
return diagnostics;
|
|
430
530
|
},
|
|
431
531
|
defaultSeverity: "warning",
|
|
432
|
-
help: "Define
|
|
532
|
+
help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
|
|
433
533
|
key: "docker-doctor/require-restart-policy",
|
|
434
534
|
message: "Set restart policy for services"
|
|
435
535
|
};
|
|
@@ -449,7 +549,7 @@ const useDependsOnCondition = {
|
|
|
449
549
|
return diagnostics;
|
|
450
550
|
},
|
|
451
551
|
defaultSeverity: "info",
|
|
452
|
-
help: "Instead of a simple service list, use
|
|
552
|
+
help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
|
|
453
553
|
key: "docker-doctor/use-depends-on-condition",
|
|
454
554
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
455
555
|
};
|
|
@@ -460,54 +560,6 @@ const composeRules = [
|
|
|
460
560
|
useDependsOnCondition
|
|
461
561
|
];
|
|
462
562
|
|
|
463
|
-
//#endregion
|
|
464
|
-
//#region ../core/src/parsers/image-ref.ts
|
|
465
|
-
const parseImageRef = (ref) => {
|
|
466
|
-
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
467
|
-
isVariable: true,
|
|
468
|
-
name: ref
|
|
469
|
-
};
|
|
470
|
-
let remainder = ref;
|
|
471
|
-
let digest;
|
|
472
|
-
const atIndex = remainder.indexOf("@");
|
|
473
|
-
if (atIndex !== -1) {
|
|
474
|
-
digest = remainder.slice(atIndex + 1);
|
|
475
|
-
remainder = remainder.slice(0, atIndex);
|
|
476
|
-
}
|
|
477
|
-
let tag;
|
|
478
|
-
const lastColonIndex = remainder.lastIndexOf(":");
|
|
479
|
-
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
480
|
-
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
481
|
-
tag = remainder.slice(lastColonIndex + 1);
|
|
482
|
-
remainder = remainder.slice(0, lastColonIndex);
|
|
483
|
-
}
|
|
484
|
-
let registry;
|
|
485
|
-
const firstSlashIndex = remainder.indexOf("/");
|
|
486
|
-
if (firstSlashIndex !== -1) {
|
|
487
|
-
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
488
|
-
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
489
|
-
registry = firstSegment;
|
|
490
|
-
remainder = remainder.slice(firstSlashIndex + 1);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
return {
|
|
494
|
-
digest,
|
|
495
|
-
isVariable: false,
|
|
496
|
-
name: remainder,
|
|
497
|
-
registry,
|
|
498
|
-
tag
|
|
499
|
-
};
|
|
500
|
-
};
|
|
501
|
-
const collectStageAliases = (instructions) => {
|
|
502
|
-
const aliases = /* @__PURE__ */ new Set();
|
|
503
|
-
for (const inst of instructions) {
|
|
504
|
-
if (inst.instruction !== "FROM") continue;
|
|
505
|
-
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
506
|
-
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
507
|
-
}
|
|
508
|
-
return aliases;
|
|
509
|
-
};
|
|
510
|
-
|
|
511
563
|
//#endregion
|
|
512
564
|
//#region ../core/src/rules/image-size.ts
|
|
513
565
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -524,21 +576,21 @@ const preferSlimBase = {
|
|
|
524
576
|
const diagnostics = [];
|
|
525
577
|
const stageAliases = collectStageAliases(instructions);
|
|
526
578
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
527
|
-
const imagePart = inst.args
|
|
528
|
-
if (!imagePart || imagePart
|
|
579
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
580
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
529
581
|
const ref = parseImageRef(imagePart);
|
|
530
582
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
531
583
|
if (ref.digest) continue;
|
|
532
584
|
if (!ref.tag) continue;
|
|
533
|
-
const
|
|
534
|
-
if (!(
|
|
585
|
+
const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
|
|
586
|
+
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));
|
|
535
587
|
}
|
|
536
588
|
return diagnostics;
|
|
537
589
|
},
|
|
538
590
|
defaultSeverity: "info",
|
|
539
|
-
help: "Prefer tags with
|
|
591
|
+
help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
|
|
540
592
|
key: "docker-doctor/prefer-slim-base",
|
|
541
|
-
message: "
|
|
593
|
+
message: "Prefer slim, alpine, or distroless base images"
|
|
542
594
|
};
|
|
543
595
|
const cleanPackageCache = {
|
|
544
596
|
category: "Image Size",
|
|
@@ -552,34 +604,47 @@ const cleanPackageCache = {
|
|
|
552
604
|
return diagnostics;
|
|
553
605
|
},
|
|
554
606
|
defaultSeverity: "warning",
|
|
555
|
-
help: "For apt-get, append
|
|
607
|
+
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`.",
|
|
556
608
|
key: "docker-doctor/clean-package-cache",
|
|
557
609
|
message: "Clean up package manager cache in the same RUN layer"
|
|
558
610
|
};
|
|
611
|
+
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");
|
|
559
612
|
const avoidDevDependencies = {
|
|
560
613
|
category: "Image Size",
|
|
561
614
|
check(instructions, file) {
|
|
562
615
|
const diagnostics = [];
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
616
|
+
const stages = [];
|
|
617
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
618
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
619
|
+
stages.push({
|
|
620
|
+
base: base?.toLowerCase() ?? "",
|
|
621
|
+
name: stage?.toLowerCase() ?? null,
|
|
622
|
+
runs: []
|
|
623
|
+
});
|
|
624
|
+
} else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
|
|
625
|
+
if (stages.length === 0) return diagnostics;
|
|
626
|
+
const auditedIndices = [];
|
|
627
|
+
let index = stages.length - 1;
|
|
628
|
+
while (index >= 0) {
|
|
629
|
+
auditedIndices.push(index);
|
|
630
|
+
const { base } = stages[index];
|
|
631
|
+
index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
|
|
632
|
+
}
|
|
633
|
+
const finalIndex = stages.length - 1;
|
|
634
|
+
for (const stageIndex of auditedIndices.toReversed()) {
|
|
635
|
+
const stage = stages[stageIndex];
|
|
636
|
+
for (const inst of stage.runs) {
|
|
637
|
+
if (!installsDevDependencies(inst.args)) continue;
|
|
638
|
+
const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
|
|
639
|
+
diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
|
|
575
640
|
}
|
|
576
641
|
}
|
|
577
642
|
return diagnostics;
|
|
578
643
|
},
|
|
579
644
|
defaultSeverity: "warning",
|
|
580
|
-
help: "For Node.js, run
|
|
645
|
+
help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
|
|
581
646
|
key: "docker-doctor/avoid-dev-dependencies",
|
|
582
|
-
message: "Avoid installing
|
|
647
|
+
message: "Avoid installing dev dependencies in the final stage"
|
|
583
648
|
};
|
|
584
649
|
const imageSizeRules = [
|
|
585
650
|
preferSlimBase,
|
|
@@ -608,7 +673,7 @@ const useMultiStage = {
|
|
|
608
673
|
defaultSeverity: "info",
|
|
609
674
|
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
610
675
|
key: "docker-doctor/use-multi-stage",
|
|
611
|
-
message: "
|
|
676
|
+
message: "Use multi-stage builds"
|
|
612
677
|
};
|
|
613
678
|
const orderLayers = {
|
|
614
679
|
category: "Performance",
|
|
@@ -652,10 +717,16 @@ const minimizeLayers = {
|
|
|
652
717
|
return diagnostics;
|
|
653
718
|
},
|
|
654
719
|
defaultSeverity: "info",
|
|
655
|
-
help: "Combine consecutive RUN instructions using
|
|
720
|
+
help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
|
|
656
721
|
key: "docker-doctor/minimize-layers",
|
|
657
722
|
message: "Minimize the number of image layers"
|
|
658
723
|
};
|
|
724
|
+
const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
|
|
725
|
+
const lastSlash = dockerfilePath.lastIndexOf("/");
|
|
726
|
+
const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
|
|
727
|
+
const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
|
|
728
|
+
return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
|
|
729
|
+
};
|
|
659
730
|
const useDockerignore = {
|
|
660
731
|
category: "Performance",
|
|
661
732
|
check(instructions, file, context) {
|
|
@@ -667,15 +738,13 @@ const useDockerignore = {
|
|
|
667
738
|
return src === "." || src === "./" || src === "*";
|
|
668
739
|
}
|
|
669
740
|
return false;
|
|
670
|
-
}) && context?.projectFiles)
|
|
671
|
-
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)];
|
|
672
|
-
}
|
|
741
|
+
}) && 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)];
|
|
673
742
|
return [];
|
|
674
743
|
},
|
|
675
744
|
defaultSeverity: "warning",
|
|
676
745
|
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
677
746
|
key: "docker-doctor/use-dockerignore",
|
|
678
|
-
message: "
|
|
747
|
+
message: "Add a .dockerignore file"
|
|
679
748
|
};
|
|
680
749
|
const performanceRules = [
|
|
681
750
|
useMultiStage,
|
|
@@ -694,25 +763,35 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
|
|
|
694
763
|
rule: ruleKey,
|
|
695
764
|
severity
|
|
696
765
|
});
|
|
766
|
+
const isRootUser = (value) => {
|
|
767
|
+
const [user] = value.split(":");
|
|
768
|
+
return user === "root" || user === "0";
|
|
769
|
+
};
|
|
697
770
|
const noRootUser = {
|
|
698
771
|
category: "Security",
|
|
699
772
|
check(instructions, file) {
|
|
773
|
+
const stageUser = /* @__PURE__ */ new Map();
|
|
774
|
+
let currentStage = null;
|
|
700
775
|
let lastUser = "root";
|
|
701
776
|
let lastUserLine = 1;
|
|
702
777
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
703
|
-
|
|
778
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
779
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
|
|
704
780
|
lastUserLine = inst.line;
|
|
781
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
782
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
705
783
|
} else if (inst.instruction === "USER") {
|
|
706
784
|
lastUser = inst.args.trim().toLowerCase();
|
|
707
785
|
lastUserLine = inst.line;
|
|
786
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
708
787
|
}
|
|
709
|
-
if (lastUser
|
|
788
|
+
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)];
|
|
710
789
|
return [];
|
|
711
790
|
},
|
|
712
791
|
defaultSeverity: "warning",
|
|
713
|
-
help: "Add a non-root user (e.g.,
|
|
792
|
+
help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
|
|
714
793
|
key: "docker-doctor/no-root-user",
|
|
715
|
-
message: "
|
|
794
|
+
message: "Run the container as a non-root user"
|
|
716
795
|
};
|
|
717
796
|
const noSecretsInEnv = {
|
|
718
797
|
category: "Security",
|
|
@@ -753,7 +832,7 @@ const noSecretsInEnv = {
|
|
|
753
832
|
defaultSeverity: "error",
|
|
754
833
|
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
755
834
|
key: "docker-doctor/no-secrets-in-env",
|
|
756
|
-
message: "
|
|
835
|
+
message: "Avoid storing secrets in ENV or ARG instructions"
|
|
757
836
|
};
|
|
758
837
|
const pinImageVersion = {
|
|
759
838
|
category: "Security",
|
|
@@ -761,8 +840,8 @@ const pinImageVersion = {
|
|
|
761
840
|
const diagnostics = [];
|
|
762
841
|
const stageAliases = collectStageAliases(instructions);
|
|
763
842
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
764
|
-
const imagePart = inst.args
|
|
765
|
-
if (!imagePart || imagePart
|
|
843
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
844
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
766
845
|
const ref = parseImageRef(imagePart);
|
|
767
846
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
768
847
|
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));
|
|
@@ -771,9 +850,9 @@ const pinImageVersion = {
|
|
|
771
850
|
return diagnostics;
|
|
772
851
|
},
|
|
773
852
|
defaultSeverity: "warning",
|
|
774
|
-
help: "Specify a concrete tag instead of
|
|
853
|
+
help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
|
|
775
854
|
key: "docker-doctor/pin-image-version",
|
|
776
|
-
message: "
|
|
855
|
+
message: "Pin base images to a specific tag or digest"
|
|
777
856
|
};
|
|
778
857
|
const noAddRemote = {
|
|
779
858
|
category: "Security",
|
|
@@ -787,7 +866,7 @@ const noAddRemote = {
|
|
|
787
866
|
return diagnostics;
|
|
788
867
|
},
|
|
789
868
|
defaultSeverity: "warning",
|
|
790
|
-
help: "Use
|
|
869
|
+
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.",
|
|
791
870
|
key: "docker-doctor/no-add-remote",
|
|
792
871
|
message: "Avoid using ADD with remote URLs"
|
|
793
872
|
};
|
|
@@ -810,15 +889,19 @@ const allComposeRules = [...composeRules];
|
|
|
810
889
|
const allRules = [...allDockerfileRules, ...allComposeRules];
|
|
811
890
|
const findRule = (key) => allRules.find((rule) => rule.key === key);
|
|
812
891
|
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region ../core/src/runners/resolve-severity.ts
|
|
894
|
+
const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[rule.key] ?? categoriesConfig?.[rule.category] ?? rule.defaultSeverity;
|
|
895
|
+
|
|
813
896
|
//#endregion
|
|
814
897
|
//#region ../core/src/runners/dockerfile-runner.ts
|
|
815
|
-
const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
|
|
898
|
+
const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
|
|
816
899
|
const diagnostics = [];
|
|
817
900
|
for (const rule of allDockerfileRules) {
|
|
818
|
-
const
|
|
819
|
-
if (
|
|
901
|
+
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
902
|
+
if (severity === "off") continue;
|
|
820
903
|
const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
|
|
821
|
-
if (
|
|
904
|
+
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
822
905
|
diagnostics.push(...ruleDiagnostics);
|
|
823
906
|
}
|
|
824
907
|
return diagnostics;
|
|
@@ -826,13 +909,13 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
|
|
|
826
909
|
|
|
827
910
|
//#endregion
|
|
828
911
|
//#region ../core/src/runners/compose-runner.ts
|
|
829
|
-
const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
912
|
+
const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
|
|
830
913
|
const diagnostics = [];
|
|
831
914
|
for (const rule of allComposeRules) {
|
|
832
|
-
const
|
|
833
|
-
if (
|
|
915
|
+
const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
|
|
916
|
+
if (severity === "off") continue;
|
|
834
917
|
const ruleDiagnostics = rule.check(composeContent, file);
|
|
835
|
-
if (
|
|
918
|
+
if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
|
|
836
919
|
diagnostics.push(...ruleDiagnostics);
|
|
837
920
|
}
|
|
838
921
|
return diagnostics;
|
|
@@ -900,6 +983,30 @@ const validateConfig = (input) => {
|
|
|
900
983
|
return result;
|
|
901
984
|
};
|
|
902
985
|
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region ../core/src/config/unknown-keys.ts
|
|
988
|
+
const KNOWN_CATEGORIES = [
|
|
989
|
+
"Best Practices",
|
|
990
|
+
"Compose",
|
|
991
|
+
"Image Size",
|
|
992
|
+
"Performance",
|
|
993
|
+
"Security"
|
|
994
|
+
];
|
|
995
|
+
const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
|
|
996
|
+
const collectUnknownConfigKeys = (raw) => {
|
|
997
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
998
|
+
categories: [],
|
|
999
|
+
rules: []
|
|
1000
|
+
};
|
|
1001
|
+
const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
|
|
1002
|
+
const knownCategories = new Set(KNOWN_CATEGORIES);
|
|
1003
|
+
const { categories, rules } = raw;
|
|
1004
|
+
return {
|
|
1005
|
+
categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
|
|
1006
|
+
rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
|
|
903
1010
|
//#endregion
|
|
904
1011
|
//#region ../core/src/config/loader.ts
|
|
905
1012
|
const fileExists = async (filePath) => {
|
|
@@ -929,7 +1036,12 @@ const importConfig = async (filePath) => {
|
|
|
929
1036
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
930
1037
|
}
|
|
931
1038
|
};
|
|
932
|
-
const
|
|
1039
|
+
const warnUnknownKeys = (raw, onWarning) => {
|
|
1040
|
+
const unknown = collectUnknownConfigKeys(raw);
|
|
1041
|
+
for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
|
|
1042
|
+
for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
|
|
1043
|
+
};
|
|
1044
|
+
const loadConfig = async (rootDir, customPath, onWarning) => {
|
|
933
1045
|
let configObject = null;
|
|
934
1046
|
if (customPath) {
|
|
935
1047
|
const fullPath = node_path.default.resolve(rootDir, customPath);
|
|
@@ -962,7 +1074,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
962
1074
|
}
|
|
963
1075
|
if (!configObject) return {};
|
|
964
1076
|
try {
|
|
965
|
-
|
|
1077
|
+
const config = validateConfig(configObject);
|
|
1078
|
+
if (onWarning) warnUnknownKeys(configObject, onWarning);
|
|
1079
|
+
return config;
|
|
966
1080
|
} catch (error) {
|
|
967
1081
|
const msg = error instanceof Error ? error.message : String(error);
|
|
968
1082
|
throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
|
|
@@ -1103,4 +1217,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1103
1217
|
return version;
|
|
1104
1218
|
}
|
|
1105
1219
|
});
|
|
1106
|
-
//# sourceMappingURL=src-
|
|
1220
|
+
//# sourceMappingURL=src-ILbJuJmE.cjs.map
|