@docker-doctor/cli 0.4.2 → 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 +7 -0
- package/dist/cli.cjs +20 -12
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +20 -12
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-909bBBPN.cjs → src-ILbJuJmE.cjs} +200 -103
- package/dist/src-ILbJuJmE.cjs.map +1 -0
- package/dist/{src-BuUGk4ND.mjs → src-TK9DRoRo.mjs} +200 -103
- package/dist/src-TK9DRoRo.mjs.map +1 -0
- package/package.json +1 -1
- package/dist/src-909bBBPN.cjs.map +0 -1
- package/dist/src-BuUGk4ND.mjs.map +0 -1
package/dist/index.d.mts
CHANGED
|
@@ -60,7 +60,7 @@ declare const findRule: (key: string) => RuleDefinition | undefined;
|
|
|
60
60
|
declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorConfig;
|
|
61
61
|
//#endregion
|
|
62
62
|
//#region ../core/src/config/loader.d.ts
|
|
63
|
-
declare const loadConfig: (rootDir: string, customPath?: string) => Promise<DockerDoctorConfig>;
|
|
63
|
+
declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
|
|
64
64
|
//#endregion
|
|
65
65
|
//#region ../core/src/scoring.d.ts
|
|
66
66
|
declare const calculateScore: (diagnostics: Diagnostic[]) => {
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as runDockerfileRules, c as parseCompose, d as version$1, i as runComposeRules, l as parseDockerfile, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as discoverProject } from "./src-
|
|
2
|
+
import { a as runDockerfileRules, c as parseCompose, d as version$1, i as runComposeRules, l as parseDockerfile, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as discoverProject } from "./src-TK9DRoRo.mjs";
|
|
3
3
|
|
|
4
4
|
//#region ../core/src/config/define-config.ts
|
|
5
5
|
const defineConfig = (config) => config;
|
|
@@ -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
|
|
@@ -203,6 +203,79 @@ const parseCompose = (content, filepath) => {
|
|
|
203
203
|
}
|
|
204
204
|
};
|
|
205
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
|
+
|
|
206
279
|
//#endregion
|
|
207
280
|
//#region ../core/src/rules/best-practices.ts
|
|
208
281
|
const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -222,7 +295,7 @@ const requireHealthcheck = {
|
|
|
222
295
|
return [];
|
|
223
296
|
},
|
|
224
297
|
defaultSeverity: "info",
|
|
225
|
-
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.",
|
|
226
299
|
key: "docker-doctor/require-healthcheck",
|
|
227
300
|
message: "Add a HEALTHCHECK instruction"
|
|
228
301
|
};
|
|
@@ -248,14 +321,11 @@ const useExecForm = {
|
|
|
248
321
|
category: "Best Practices",
|
|
249
322
|
check(instructions, file) {
|
|
250
323
|
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
|
-
}
|
|
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));
|
|
255
325
|
return diagnostics;
|
|
256
326
|
},
|
|
257
327
|
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.",
|
|
328
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
259
329
|
key: "docker-doctor/use-exec-form",
|
|
260
330
|
message: "Use exec form for CMD and ENTRYPOINT"
|
|
261
331
|
};
|
|
@@ -266,7 +336,7 @@ const requireLabels = {
|
|
|
266
336
|
return [];
|
|
267
337
|
},
|
|
268
338
|
defaultSeverity: "info",
|
|
269
|
-
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.",
|
|
270
340
|
key: "docker-doctor/require-labels",
|
|
271
341
|
message: "Add LABEL metadata to images"
|
|
272
342
|
};
|
|
@@ -283,22 +353,47 @@ const combineAptUpdateInstall = {
|
|
|
283
353
|
return diagnostics;
|
|
284
354
|
},
|
|
285
355
|
defaultSeverity: "warning",
|
|
286
|
-
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/*`).",
|
|
287
357
|
key: "docker-doctor/combine-apt-update-install",
|
|
288
358
|
message: "Combine apt-get update and apt-get install"
|
|
289
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
|
+
};
|
|
290
366
|
const usePipefail = {
|
|
291
367
|
category: "Best Practices",
|
|
292
368
|
check(instructions, file) {
|
|
293
369
|
const diagnostics = [];
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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));
|
|
297
392
|
}
|
|
298
393
|
return diagnostics;
|
|
299
394
|
},
|
|
300
395
|
defaultSeverity: "warning",
|
|
301
|
-
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.",
|
|
302
397
|
key: "docker-doctor/use-pipefail",
|
|
303
398
|
message: "Use pipefail to catch pipeline command failures"
|
|
304
399
|
};
|
|
@@ -313,7 +408,7 @@ const absoluteWorkdir = {
|
|
|
313
408
|
return diagnostics;
|
|
314
409
|
},
|
|
315
410
|
defaultSeverity: "warning",
|
|
316
|
-
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`).",
|
|
317
412
|
key: "docker-doctor/absolute-workdir",
|
|
318
413
|
message: "Use absolute paths for WORKDIR"
|
|
319
414
|
};
|
|
@@ -325,7 +420,7 @@ const avoidRunCd = {
|
|
|
325
420
|
return diagnostics;
|
|
326
421
|
},
|
|
327
422
|
defaultSeverity: "info",
|
|
328
|
-
help: "Use the WORKDIR instruction instead of
|
|
423
|
+
help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
|
|
329
424
|
key: "docker-doctor/avoid-run-cd",
|
|
330
425
|
message: "Avoid changing directories with cd in RUN"
|
|
331
426
|
};
|
|
@@ -360,7 +455,7 @@ const useraddNoLogInit = {
|
|
|
360
455
|
return diagnostics;
|
|
361
456
|
},
|
|
362
457
|
defaultSeverity: "warning",
|
|
363
|
-
help: "Pass
|
|
458
|
+
help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
364
459
|
key: "docker-doctor/useradd-no-log-init",
|
|
365
460
|
message: "Use --no-log-init with useradd"
|
|
366
461
|
};
|
|
@@ -393,9 +488,9 @@ const noVersionKey = {
|
|
|
393
488
|
return [];
|
|
394
489
|
},
|
|
395
490
|
defaultSeverity: "warning",
|
|
396
|
-
help: "The
|
|
491
|
+
help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
|
|
397
492
|
key: "docker-doctor/no-version-key",
|
|
398
|
-
message: "Remove the
|
|
493
|
+
message: "Remove the `version` key from the Compose file"
|
|
399
494
|
};
|
|
400
495
|
const requireResourceLimits = {
|
|
401
496
|
category: "Compose",
|
|
@@ -413,7 +508,7 @@ const requireResourceLimits = {
|
|
|
413
508
|
return diagnostics;
|
|
414
509
|
},
|
|
415
510
|
defaultSeverity: "warning",
|
|
416
|
-
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.",
|
|
417
512
|
key: "docker-doctor/require-resource-limits",
|
|
418
513
|
message: "Define resource limits for services"
|
|
419
514
|
};
|
|
@@ -434,7 +529,7 @@ const requireRestartPolicy = {
|
|
|
434
529
|
return diagnostics;
|
|
435
530
|
},
|
|
436
531
|
defaultSeverity: "warning",
|
|
437
|
-
help: "Define
|
|
532
|
+
help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
|
|
438
533
|
key: "docker-doctor/require-restart-policy",
|
|
439
534
|
message: "Set restart policy for services"
|
|
440
535
|
};
|
|
@@ -454,7 +549,7 @@ const useDependsOnCondition = {
|
|
|
454
549
|
return diagnostics;
|
|
455
550
|
},
|
|
456
551
|
defaultSeverity: "info",
|
|
457
|
-
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.",
|
|
458
553
|
key: "docker-doctor/use-depends-on-condition",
|
|
459
554
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
460
555
|
};
|
|
@@ -465,54 +560,6 @@ const composeRules = [
|
|
|
465
560
|
useDependsOnCondition
|
|
466
561
|
];
|
|
467
562
|
|
|
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
563
|
//#endregion
|
|
517
564
|
//#region ../core/src/rules/image-size.ts
|
|
518
565
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -529,8 +576,8 @@ const preferSlimBase = {
|
|
|
529
576
|
const diagnostics = [];
|
|
530
577
|
const stageAliases = collectStageAliases(instructions);
|
|
531
578
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
532
|
-
const imagePart = inst.args
|
|
533
|
-
if (!imagePart || imagePart
|
|
579
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
580
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
534
581
|
const ref = parseImageRef(imagePart);
|
|
535
582
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
536
583
|
if (ref.digest) continue;
|
|
@@ -541,9 +588,9 @@ const preferSlimBase = {
|
|
|
541
588
|
return diagnostics;
|
|
542
589
|
},
|
|
543
590
|
defaultSeverity: "info",
|
|
544
|
-
help: "Prefer tags with
|
|
591
|
+
help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
|
|
545
592
|
key: "docker-doctor/prefer-slim-base",
|
|
546
|
-
message: "
|
|
593
|
+
message: "Prefer slim, alpine, or distroless base images"
|
|
547
594
|
};
|
|
548
595
|
const cleanPackageCache = {
|
|
549
596
|
category: "Image Size",
|
|
@@ -557,34 +604,47 @@ const cleanPackageCache = {
|
|
|
557
604
|
return diagnostics;
|
|
558
605
|
},
|
|
559
606
|
defaultSeverity: "warning",
|
|
560
|
-
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`.",
|
|
561
608
|
key: "docker-doctor/clean-package-cache",
|
|
562
609
|
message: "Clean up package manager cache in the same RUN layer"
|
|
563
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");
|
|
564
612
|
const avoidDevDependencies = {
|
|
565
613
|
category: "Image Size",
|
|
566
614
|
check(instructions, file) {
|
|
567
615
|
const diagnostics = [];
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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));
|
|
580
640
|
}
|
|
581
641
|
}
|
|
582
642
|
return diagnostics;
|
|
583
643
|
},
|
|
584
644
|
defaultSeverity: "warning",
|
|
585
|
-
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.",
|
|
586
646
|
key: "docker-doctor/avoid-dev-dependencies",
|
|
587
|
-
message: "Avoid installing
|
|
647
|
+
message: "Avoid installing dev dependencies in the final stage"
|
|
588
648
|
};
|
|
589
649
|
const imageSizeRules = [
|
|
590
650
|
preferSlimBase,
|
|
@@ -613,7 +673,7 @@ const useMultiStage = {
|
|
|
613
673
|
defaultSeverity: "info",
|
|
614
674
|
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
615
675
|
key: "docker-doctor/use-multi-stage",
|
|
616
|
-
message: "
|
|
676
|
+
message: "Use multi-stage builds"
|
|
617
677
|
};
|
|
618
678
|
const orderLayers = {
|
|
619
679
|
category: "Performance",
|
|
@@ -657,7 +717,7 @@ const minimizeLayers = {
|
|
|
657
717
|
return diagnostics;
|
|
658
718
|
},
|
|
659
719
|
defaultSeverity: "info",
|
|
660
|
-
help: "Combine consecutive RUN instructions using
|
|
720
|
+
help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
|
|
661
721
|
key: "docker-doctor/minimize-layers",
|
|
662
722
|
message: "Minimize the number of image layers"
|
|
663
723
|
};
|
|
@@ -684,7 +744,7 @@ const useDockerignore = {
|
|
|
684
744
|
defaultSeverity: "warning",
|
|
685
745
|
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
686
746
|
key: "docker-doctor/use-dockerignore",
|
|
687
|
-
message: "
|
|
747
|
+
message: "Add a .dockerignore file"
|
|
688
748
|
};
|
|
689
749
|
const performanceRules = [
|
|
690
750
|
useMultiStage,
|
|
@@ -710,22 +770,28 @@ const isRootUser = (value) => {
|
|
|
710
770
|
const noRootUser = {
|
|
711
771
|
category: "Security",
|
|
712
772
|
check(instructions, file) {
|
|
773
|
+
const stageUser = /* @__PURE__ */ new Map();
|
|
774
|
+
let currentStage = null;
|
|
713
775
|
let lastUser = "root";
|
|
714
776
|
let lastUserLine = 1;
|
|
715
777
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
716
|
-
|
|
778
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
779
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
|
|
717
780
|
lastUserLine = inst.line;
|
|
781
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
782
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
718
783
|
} else if (inst.instruction === "USER") {
|
|
719
784
|
lastUser = inst.args.trim().toLowerCase();
|
|
720
785
|
lastUserLine = inst.line;
|
|
786
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
721
787
|
}
|
|
722
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)];
|
|
723
789
|
return [];
|
|
724
790
|
},
|
|
725
791
|
defaultSeverity: "warning",
|
|
726
|
-
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.",
|
|
727
793
|
key: "docker-doctor/no-root-user",
|
|
728
|
-
message: "
|
|
794
|
+
message: "Run the container as a non-root user"
|
|
729
795
|
};
|
|
730
796
|
const noSecretsInEnv = {
|
|
731
797
|
category: "Security",
|
|
@@ -766,7 +832,7 @@ const noSecretsInEnv = {
|
|
|
766
832
|
defaultSeverity: "error",
|
|
767
833
|
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
768
834
|
key: "docker-doctor/no-secrets-in-env",
|
|
769
|
-
message: "
|
|
835
|
+
message: "Avoid storing secrets in ENV or ARG instructions"
|
|
770
836
|
};
|
|
771
837
|
const pinImageVersion = {
|
|
772
838
|
category: "Security",
|
|
@@ -774,8 +840,8 @@ const pinImageVersion = {
|
|
|
774
840
|
const diagnostics = [];
|
|
775
841
|
const stageAliases = collectStageAliases(instructions);
|
|
776
842
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
777
|
-
const imagePart = inst.args
|
|
778
|
-
if (!imagePart || imagePart
|
|
843
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
844
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
779
845
|
const ref = parseImageRef(imagePart);
|
|
780
846
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
781
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));
|
|
@@ -784,9 +850,9 @@ const pinImageVersion = {
|
|
|
784
850
|
return diagnostics;
|
|
785
851
|
},
|
|
786
852
|
defaultSeverity: "warning",
|
|
787
|
-
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`).",
|
|
788
854
|
key: "docker-doctor/pin-image-version",
|
|
789
|
-
message: "
|
|
855
|
+
message: "Pin base images to a specific tag or digest"
|
|
790
856
|
};
|
|
791
857
|
const noAddRemote = {
|
|
792
858
|
category: "Security",
|
|
@@ -800,7 +866,7 @@ const noAddRemote = {
|
|
|
800
866
|
return diagnostics;
|
|
801
867
|
},
|
|
802
868
|
defaultSeverity: "warning",
|
|
803
|
-
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.",
|
|
804
870
|
key: "docker-doctor/no-add-remote",
|
|
805
871
|
message: "Avoid using ADD with remote URLs"
|
|
806
872
|
};
|
|
@@ -917,6 +983,30 @@ const validateConfig = (input) => {
|
|
|
917
983
|
return result;
|
|
918
984
|
};
|
|
919
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
|
+
|
|
920
1010
|
//#endregion
|
|
921
1011
|
//#region ../core/src/config/loader.ts
|
|
922
1012
|
const fileExists = async (filePath) => {
|
|
@@ -946,7 +1036,12 @@ const importConfig = async (filePath) => {
|
|
|
946
1036
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
947
1037
|
}
|
|
948
1038
|
};
|
|
949
|
-
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) => {
|
|
950
1045
|
let configObject = null;
|
|
951
1046
|
if (customPath) {
|
|
952
1047
|
const fullPath = node_path.default.resolve(rootDir, customPath);
|
|
@@ -979,7 +1074,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
979
1074
|
}
|
|
980
1075
|
if (!configObject) return {};
|
|
981
1076
|
try {
|
|
982
|
-
|
|
1077
|
+
const config = validateConfig(configObject);
|
|
1078
|
+
if (onWarning) warnUnknownKeys(configObject, onWarning);
|
|
1079
|
+
return config;
|
|
983
1080
|
} catch (error) {
|
|
984
1081
|
const msg = error instanceof Error ? error.message : String(error);
|
|
985
1082
|
throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
|
|
@@ -1120,4 +1217,4 @@ Object.defineProperty(exports, 'version', {
|
|
|
1120
1217
|
return version;
|
|
1121
1218
|
}
|
|
1122
1219
|
});
|
|
1123
|
-
//# sourceMappingURL=src-
|
|
1220
|
+
//# sourceMappingURL=src-ILbJuJmE.cjs.map
|