@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
|
@@ -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
|
|
@@ -174,6 +174,79 @@ const parseCompose = (content, filepath) => {
|
|
|
174
174
|
}
|
|
175
175
|
};
|
|
176
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
|
+
|
|
177
250
|
//#endregion
|
|
178
251
|
//#region ../core/src/rules/best-practices.ts
|
|
179
252
|
const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -193,7 +266,7 @@ const requireHealthcheck = {
|
|
|
193
266
|
return [];
|
|
194
267
|
},
|
|
195
268
|
defaultSeverity: "info",
|
|
196
|
-
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.",
|
|
197
270
|
key: "docker-doctor/require-healthcheck",
|
|
198
271
|
message: "Add a HEALTHCHECK instruction"
|
|
199
272
|
};
|
|
@@ -219,14 +292,11 @@ const useExecForm = {
|
|
|
219
292
|
category: "Best Practices",
|
|
220
293
|
check(instructions, file) {
|
|
221
294
|
const diagnostics = [];
|
|
222
|
-
for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
|
|
223
|
-
const args = inst.args.trim();
|
|
224
|
-
if (!args.startsWith("[") || !args.endsWith("]")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
|
|
225
|
-
}
|
|
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));
|
|
226
296
|
return diagnostics;
|
|
227
297
|
},
|
|
228
298
|
defaultSeverity: "warning",
|
|
229
|
-
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
299
|
+
help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
|
|
230
300
|
key: "docker-doctor/use-exec-form",
|
|
231
301
|
message: "Use exec form for CMD and ENTRYPOINT"
|
|
232
302
|
};
|
|
@@ -237,7 +307,7 @@ const requireLabels = {
|
|
|
237
307
|
return [];
|
|
238
308
|
},
|
|
239
309
|
defaultSeverity: "info",
|
|
240
|
-
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.",
|
|
241
311
|
key: "docker-doctor/require-labels",
|
|
242
312
|
message: "Add LABEL metadata to images"
|
|
243
313
|
};
|
|
@@ -254,22 +324,47 @@ const combineAptUpdateInstall = {
|
|
|
254
324
|
return diagnostics;
|
|
255
325
|
},
|
|
256
326
|
defaultSeverity: "warning",
|
|
257
|
-
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/*`).",
|
|
258
328
|
key: "docker-doctor/combine-apt-update-install",
|
|
259
329
|
message: "Combine apt-get update and apt-get install"
|
|
260
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
|
+
};
|
|
261
337
|
const usePipefail = {
|
|
262
338
|
category: "Best Practices",
|
|
263
339
|
check(instructions, file) {
|
|
264
340
|
const diagnostics = [];
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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));
|
|
268
363
|
}
|
|
269
364
|
return diagnostics;
|
|
270
365
|
},
|
|
271
366
|
defaultSeverity: "warning",
|
|
272
|
-
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.",
|
|
273
368
|
key: "docker-doctor/use-pipefail",
|
|
274
369
|
message: "Use pipefail to catch pipeline command failures"
|
|
275
370
|
};
|
|
@@ -284,7 +379,7 @@ const absoluteWorkdir = {
|
|
|
284
379
|
return diagnostics;
|
|
285
380
|
},
|
|
286
381
|
defaultSeverity: "warning",
|
|
287
|
-
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`).",
|
|
288
383
|
key: "docker-doctor/absolute-workdir",
|
|
289
384
|
message: "Use absolute paths for WORKDIR"
|
|
290
385
|
};
|
|
@@ -296,7 +391,7 @@ const avoidRunCd = {
|
|
|
296
391
|
return diagnostics;
|
|
297
392
|
},
|
|
298
393
|
defaultSeverity: "info",
|
|
299
|
-
help: "Use the WORKDIR instruction instead of
|
|
394
|
+
help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
|
|
300
395
|
key: "docker-doctor/avoid-run-cd",
|
|
301
396
|
message: "Avoid changing directories with cd in RUN"
|
|
302
397
|
};
|
|
@@ -331,7 +426,7 @@ const useraddNoLogInit = {
|
|
|
331
426
|
return diagnostics;
|
|
332
427
|
},
|
|
333
428
|
defaultSeverity: "warning",
|
|
334
|
-
help: "Pass
|
|
429
|
+
help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
|
|
335
430
|
key: "docker-doctor/useradd-no-log-init",
|
|
336
431
|
message: "Use --no-log-init with useradd"
|
|
337
432
|
};
|
|
@@ -364,9 +459,9 @@ const noVersionKey = {
|
|
|
364
459
|
return [];
|
|
365
460
|
},
|
|
366
461
|
defaultSeverity: "warning",
|
|
367
|
-
help: "The
|
|
462
|
+
help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
|
|
368
463
|
key: "docker-doctor/no-version-key",
|
|
369
|
-
message: "Remove the
|
|
464
|
+
message: "Remove the `version` key from the Compose file"
|
|
370
465
|
};
|
|
371
466
|
const requireResourceLimits = {
|
|
372
467
|
category: "Compose",
|
|
@@ -384,7 +479,7 @@ const requireResourceLimits = {
|
|
|
384
479
|
return diagnostics;
|
|
385
480
|
},
|
|
386
481
|
defaultSeverity: "warning",
|
|
387
|
-
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.",
|
|
388
483
|
key: "docker-doctor/require-resource-limits",
|
|
389
484
|
message: "Define resource limits for services"
|
|
390
485
|
};
|
|
@@ -405,7 +500,7 @@ const requireRestartPolicy = {
|
|
|
405
500
|
return diagnostics;
|
|
406
501
|
},
|
|
407
502
|
defaultSeverity: "warning",
|
|
408
|
-
help: "Define
|
|
503
|
+
help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
|
|
409
504
|
key: "docker-doctor/require-restart-policy",
|
|
410
505
|
message: "Set restart policy for services"
|
|
411
506
|
};
|
|
@@ -425,7 +520,7 @@ const useDependsOnCondition = {
|
|
|
425
520
|
return diagnostics;
|
|
426
521
|
},
|
|
427
522
|
defaultSeverity: "info",
|
|
428
|
-
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.",
|
|
429
524
|
key: "docker-doctor/use-depends-on-condition",
|
|
430
525
|
message: "Use long-form depends_on with healthcheck conditions"
|
|
431
526
|
};
|
|
@@ -436,54 +531,6 @@ const composeRules = [
|
|
|
436
531
|
useDependsOnCondition
|
|
437
532
|
];
|
|
438
533
|
|
|
439
|
-
//#endregion
|
|
440
|
-
//#region ../core/src/parsers/image-ref.ts
|
|
441
|
-
const parseImageRef = (ref) => {
|
|
442
|
-
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
443
|
-
isVariable: true,
|
|
444
|
-
name: ref
|
|
445
|
-
};
|
|
446
|
-
let remainder = ref;
|
|
447
|
-
let digest;
|
|
448
|
-
const atIndex = remainder.indexOf("@");
|
|
449
|
-
if (atIndex !== -1) {
|
|
450
|
-
digest = remainder.slice(atIndex + 1);
|
|
451
|
-
remainder = remainder.slice(0, atIndex);
|
|
452
|
-
}
|
|
453
|
-
let tag;
|
|
454
|
-
const lastColonIndex = remainder.lastIndexOf(":");
|
|
455
|
-
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
456
|
-
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
457
|
-
tag = remainder.slice(lastColonIndex + 1);
|
|
458
|
-
remainder = remainder.slice(0, lastColonIndex);
|
|
459
|
-
}
|
|
460
|
-
let registry;
|
|
461
|
-
const firstSlashIndex = remainder.indexOf("/");
|
|
462
|
-
if (firstSlashIndex !== -1) {
|
|
463
|
-
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
464
|
-
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
465
|
-
registry = firstSegment;
|
|
466
|
-
remainder = remainder.slice(firstSlashIndex + 1);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
return {
|
|
470
|
-
digest,
|
|
471
|
-
isVariable: false,
|
|
472
|
-
name: remainder,
|
|
473
|
-
registry,
|
|
474
|
-
tag
|
|
475
|
-
};
|
|
476
|
-
};
|
|
477
|
-
const collectStageAliases = (instructions) => {
|
|
478
|
-
const aliases = /* @__PURE__ */ new Set();
|
|
479
|
-
for (const inst of instructions) {
|
|
480
|
-
if (inst.instruction !== "FROM") continue;
|
|
481
|
-
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
482
|
-
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
483
|
-
}
|
|
484
|
-
return aliases;
|
|
485
|
-
};
|
|
486
|
-
|
|
487
534
|
//#endregion
|
|
488
535
|
//#region ../core/src/rules/image-size.ts
|
|
489
536
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -500,8 +547,8 @@ const preferSlimBase = {
|
|
|
500
547
|
const diagnostics = [];
|
|
501
548
|
const stageAliases = collectStageAliases(instructions);
|
|
502
549
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
503
|
-
const imagePart = inst.args
|
|
504
|
-
if (!imagePart || imagePart
|
|
550
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
551
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
505
552
|
const ref = parseImageRef(imagePart);
|
|
506
553
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
507
554
|
if (ref.digest) continue;
|
|
@@ -512,9 +559,9 @@ const preferSlimBase = {
|
|
|
512
559
|
return diagnostics;
|
|
513
560
|
},
|
|
514
561
|
defaultSeverity: "info",
|
|
515
|
-
help: "Prefer tags with
|
|
562
|
+
help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
|
|
516
563
|
key: "docker-doctor/prefer-slim-base",
|
|
517
|
-
message: "
|
|
564
|
+
message: "Prefer slim, alpine, or distroless base images"
|
|
518
565
|
};
|
|
519
566
|
const cleanPackageCache = {
|
|
520
567
|
category: "Image Size",
|
|
@@ -528,34 +575,47 @@ const cleanPackageCache = {
|
|
|
528
575
|
return diagnostics;
|
|
529
576
|
},
|
|
530
577
|
defaultSeverity: "warning",
|
|
531
|
-
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`.",
|
|
532
579
|
key: "docker-doctor/clean-package-cache",
|
|
533
580
|
message: "Clean up package manager cache in the same RUN layer"
|
|
534
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");
|
|
535
583
|
const avoidDevDependencies = {
|
|
536
584
|
category: "Image Size",
|
|
537
585
|
check(instructions, file) {
|
|
538
586
|
const diagnostics = [];
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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));
|
|
551
611
|
}
|
|
552
612
|
}
|
|
553
613
|
return diagnostics;
|
|
554
614
|
},
|
|
555
615
|
defaultSeverity: "warning",
|
|
556
|
-
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.",
|
|
557
617
|
key: "docker-doctor/avoid-dev-dependencies",
|
|
558
|
-
message: "Avoid installing
|
|
618
|
+
message: "Avoid installing dev dependencies in the final stage"
|
|
559
619
|
};
|
|
560
620
|
const imageSizeRules = [
|
|
561
621
|
preferSlimBase,
|
|
@@ -584,7 +644,7 @@ const useMultiStage = {
|
|
|
584
644
|
defaultSeverity: "info",
|
|
585
645
|
help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
|
|
586
646
|
key: "docker-doctor/use-multi-stage",
|
|
587
|
-
message: "
|
|
647
|
+
message: "Use multi-stage builds"
|
|
588
648
|
};
|
|
589
649
|
const orderLayers = {
|
|
590
650
|
category: "Performance",
|
|
@@ -628,7 +688,7 @@ const minimizeLayers = {
|
|
|
628
688
|
return diagnostics;
|
|
629
689
|
},
|
|
630
690
|
defaultSeverity: "info",
|
|
631
|
-
help: "Combine consecutive RUN instructions using
|
|
691
|
+
help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
|
|
632
692
|
key: "docker-doctor/minimize-layers",
|
|
633
693
|
message: "Minimize the number of image layers"
|
|
634
694
|
};
|
|
@@ -655,7 +715,7 @@ const useDockerignore = {
|
|
|
655
715
|
defaultSeverity: "warning",
|
|
656
716
|
help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
|
|
657
717
|
key: "docker-doctor/use-dockerignore",
|
|
658
|
-
message: "
|
|
718
|
+
message: "Add a .dockerignore file"
|
|
659
719
|
};
|
|
660
720
|
const performanceRules = [
|
|
661
721
|
useMultiStage,
|
|
@@ -681,22 +741,28 @@ const isRootUser = (value) => {
|
|
|
681
741
|
const noRootUser = {
|
|
682
742
|
category: "Security",
|
|
683
743
|
check(instructions, file) {
|
|
744
|
+
const stageUser = /* @__PURE__ */ new Map();
|
|
745
|
+
let currentStage = null;
|
|
684
746
|
let lastUser = "root";
|
|
685
747
|
let lastUserLine = 1;
|
|
686
748
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
687
|
-
|
|
749
|
+
const { base, stage } = parseFromArgs(inst.args);
|
|
750
|
+
lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
|
|
688
751
|
lastUserLine = inst.line;
|
|
752
|
+
currentStage = stage?.toLowerCase() ?? null;
|
|
753
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
689
754
|
} else if (inst.instruction === "USER") {
|
|
690
755
|
lastUser = inst.args.trim().toLowerCase();
|
|
691
756
|
lastUserLine = inst.line;
|
|
757
|
+
if (currentStage) stageUser.set(currentStage, lastUser);
|
|
692
758
|
}
|
|
693
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)];
|
|
694
760
|
return [];
|
|
695
761
|
},
|
|
696
762
|
defaultSeverity: "warning",
|
|
697
|
-
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.",
|
|
698
764
|
key: "docker-doctor/no-root-user",
|
|
699
|
-
message: "
|
|
765
|
+
message: "Run the container as a non-root user"
|
|
700
766
|
};
|
|
701
767
|
const noSecretsInEnv = {
|
|
702
768
|
category: "Security",
|
|
@@ -737,7 +803,7 @@ const noSecretsInEnv = {
|
|
|
737
803
|
defaultSeverity: "error",
|
|
738
804
|
help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
|
|
739
805
|
key: "docker-doctor/no-secrets-in-env",
|
|
740
|
-
message: "
|
|
806
|
+
message: "Avoid storing secrets in ENV or ARG instructions"
|
|
741
807
|
};
|
|
742
808
|
const pinImageVersion = {
|
|
743
809
|
category: "Security",
|
|
@@ -745,8 +811,8 @@ const pinImageVersion = {
|
|
|
745
811
|
const diagnostics = [];
|
|
746
812
|
const stageAliases = collectStageAliases(instructions);
|
|
747
813
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
748
|
-
const imagePart = inst.args
|
|
749
|
-
if (!imagePart || imagePart
|
|
814
|
+
const imagePart = parseFromArgs(inst.args).base;
|
|
815
|
+
if (!imagePart || isScratch(imagePart)) continue;
|
|
750
816
|
const ref = parseImageRef(imagePart);
|
|
751
817
|
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
752
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));
|
|
@@ -755,9 +821,9 @@ const pinImageVersion = {
|
|
|
755
821
|
return diagnostics;
|
|
756
822
|
},
|
|
757
823
|
defaultSeverity: "warning",
|
|
758
|
-
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`).",
|
|
759
825
|
key: "docker-doctor/pin-image-version",
|
|
760
|
-
message: "
|
|
826
|
+
message: "Pin base images to a specific tag or digest"
|
|
761
827
|
};
|
|
762
828
|
const noAddRemote = {
|
|
763
829
|
category: "Security",
|
|
@@ -771,7 +837,7 @@ const noAddRemote = {
|
|
|
771
837
|
return diagnostics;
|
|
772
838
|
},
|
|
773
839
|
defaultSeverity: "warning",
|
|
774
|
-
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.",
|
|
775
841
|
key: "docker-doctor/no-add-remote",
|
|
776
842
|
message: "Avoid using ADD with remote URLs"
|
|
777
843
|
};
|
|
@@ -888,6 +954,30 @@ const validateConfig = (input) => {
|
|
|
888
954
|
return result;
|
|
889
955
|
};
|
|
890
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
|
+
|
|
891
981
|
//#endregion
|
|
892
982
|
//#region ../core/src/config/loader.ts
|
|
893
983
|
const fileExists = async (filePath) => {
|
|
@@ -917,7 +1007,12 @@ const importConfig = async (filePath) => {
|
|
|
917
1007
|
throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
|
|
918
1008
|
}
|
|
919
1009
|
};
|
|
920
|
-
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) => {
|
|
921
1016
|
let configObject = null;
|
|
922
1017
|
if (customPath) {
|
|
923
1018
|
const fullPath = path.resolve(rootDir, customPath);
|
|
@@ -950,7 +1045,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
950
1045
|
}
|
|
951
1046
|
if (!configObject) return {};
|
|
952
1047
|
try {
|
|
953
|
-
|
|
1048
|
+
const config = validateConfig(configObject);
|
|
1049
|
+
if (onWarning) warnUnknownKeys(configObject, onWarning);
|
|
1050
|
+
return config;
|
|
954
1051
|
} catch (error) {
|
|
955
1052
|
const msg = error instanceof Error ? error.message : String(error);
|
|
956
1053
|
throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
|
|
@@ -1020,4 +1117,4 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
1020
1117
|
|
|
1021
1118
|
//#endregion
|
|
1022
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 };
|
|
1023
|
-
//# sourceMappingURL=src-
|
|
1120
|
+
//# sourceMappingURL=src-TK9DRoRo.mjs.map
|