@docker-doctor/cli 0.2.1 → 0.3.1
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 +36 -5
- package/dist/cli.cjs +851 -62
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +802 -15
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +6 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -17
- package/dist/index.d.mts +11 -17
- package/dist/index.mjs +6 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{src-O8FXMq8O.cjs → src-38C8Tk2b.cjs} +295 -121
- package/dist/src-38C8Tk2b.cjs.map +1 -0
- package/dist/{src-DvNFg6Nz.mjs → src-BOzcDwm-.mjs} +295 -119
- package/dist/src-BOzcDwm-.mjs.map +1 -0
- package/package.json +5 -3
- package/skill/docker-doctor/SKILL.md +60 -0
- package/skill/docker-doctor/references/explain.md +72 -0
- package/dist/src-DvNFg6Nz.mjs.map +0 -1
- package/dist/src-O8FXMq8O.cjs.map +0 -1
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
|
-
import * as Data from "effect/Data";
|
|
6
|
-
import * as Schema from "effect/Schema";
|
|
7
5
|
|
|
8
6
|
//#region package.json
|
|
9
7
|
var package_default = {
|
|
10
8
|
name: "@docker-doctor/cli",
|
|
11
|
-
version: "0.
|
|
9
|
+
version: "0.3.1",
|
|
12
10
|
description: "Static analysis for Dockerfile and Docker Compose files",
|
|
13
11
|
keywords: [
|
|
14
12
|
"best-practices",
|
|
@@ -33,7 +31,11 @@ var package_default = {
|
|
|
33
31
|
"directory": "packages/docker-doctor"
|
|
34
32
|
},
|
|
35
33
|
bin: { "docker-doctor": "dist/cli.mjs" },
|
|
36
|
-
files: [
|
|
34
|
+
files: [
|
|
35
|
+
"dist",
|
|
36
|
+
"skill",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
37
39
|
type: "module",
|
|
38
40
|
sideEffects: false,
|
|
39
41
|
exports: { ".": {
|
|
@@ -53,13 +55,14 @@ var package_default = {
|
|
|
53
55
|
scripts: {
|
|
54
56
|
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsdown",
|
|
55
57
|
"dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch",
|
|
58
|
+
"test": "bun test",
|
|
56
59
|
"typecheck": "tsc --noEmit",
|
|
57
|
-
"clean": "git clean -xdf .turbo node_modules dist"
|
|
60
|
+
"clean": "git clean -xdf .turbo node_modules dist skill"
|
|
58
61
|
},
|
|
59
62
|
dependencies: {
|
|
63
|
+
"agent-install": "0.0.8",
|
|
60
64
|
"chalk": "^5.4.1",
|
|
61
65
|
"commander": "^15.0.0",
|
|
62
|
-
"effect": "4.0.0-beta.70",
|
|
63
66
|
"yaml": "^2.7.0"
|
|
64
67
|
},
|
|
65
68
|
devDependencies: {
|
|
@@ -103,76 +106,122 @@ const discoverProject = async (rootDir) => {
|
|
|
103
106
|
|
|
104
107
|
//#endregion
|
|
105
108
|
//#region ../core/src/parsers/dockerfile-parser.ts
|
|
109
|
+
const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
110
|
+
"ADD",
|
|
111
|
+
"ARG",
|
|
112
|
+
"CMD",
|
|
113
|
+
"COPY",
|
|
114
|
+
"ENTRYPOINT",
|
|
115
|
+
"ENV",
|
|
116
|
+
"EXPOSE",
|
|
117
|
+
"FROM",
|
|
118
|
+
"HEALTHCHECK",
|
|
119
|
+
"LABEL",
|
|
120
|
+
"MAINTAINER",
|
|
121
|
+
"ONBUILD",
|
|
122
|
+
"RUN",
|
|
123
|
+
"SHELL",
|
|
124
|
+
"STOPSIGNAL",
|
|
125
|
+
"USER",
|
|
126
|
+
"VOLUME",
|
|
127
|
+
"WORKDIR"
|
|
128
|
+
]);
|
|
129
|
+
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
130
|
+
const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
|
|
131
|
+
const createParserState = () => ({
|
|
132
|
+
currentArgs: "",
|
|
133
|
+
currentInstruction: "",
|
|
134
|
+
heredocQueue: [],
|
|
135
|
+
instructions: [],
|
|
136
|
+
rawAccumulator: [],
|
|
137
|
+
startLine: 0
|
|
138
|
+
});
|
|
139
|
+
const closeInstruction = (state) => {
|
|
140
|
+
if (state.currentInstruction) state.instructions.push({
|
|
141
|
+
args: state.currentArgs,
|
|
142
|
+
instruction: state.currentInstruction,
|
|
143
|
+
line: state.startLine,
|
|
144
|
+
raw: state.rawAccumulator.join("\n")
|
|
145
|
+
});
|
|
146
|
+
state.currentInstruction = "";
|
|
147
|
+
state.currentArgs = "";
|
|
148
|
+
state.rawAccumulator = [];
|
|
149
|
+
};
|
|
150
|
+
const matchInstructionKeyword = (lineContent) => {
|
|
151
|
+
const match = lineContent.match(INSTRUCTION_LINE_RE);
|
|
152
|
+
const matchedWord = match?.groups?.inst.toUpperCase();
|
|
153
|
+
if (matchedWord && DOCKERFILE_KEYWORDS.has(matchedWord)) return {
|
|
154
|
+
args: match?.groups?.args ?? "",
|
|
155
|
+
instruction: matchedWord
|
|
156
|
+
};
|
|
157
|
+
const word = lineContent.trim().toUpperCase();
|
|
158
|
+
if (DOCKERFILE_KEYWORDS.has(word)) return {
|
|
159
|
+
args: "",
|
|
160
|
+
instruction: word
|
|
161
|
+
};
|
|
162
|
+
return null;
|
|
163
|
+
};
|
|
164
|
+
const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
|
|
165
|
+
const processHeredocLine = (state, trimmed) => {
|
|
166
|
+
if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
|
|
167
|
+
else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
|
|
168
|
+
if (state.heredocQueue.length === 0) closeInstruction(state);
|
|
169
|
+
};
|
|
170
|
+
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
171
|
+
let lineContent = trimmed;
|
|
172
|
+
if (lineContent.startsWith("#")) return;
|
|
173
|
+
const hasContinuation = lineContent.endsWith("\\");
|
|
174
|
+
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
175
|
+
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
176
|
+
else {
|
|
177
|
+
state.startLine = lineNum;
|
|
178
|
+
const matched = matchInstructionKeyword(lineContent);
|
|
179
|
+
if (matched) {
|
|
180
|
+
state.currentInstruction = matched.instruction;
|
|
181
|
+
state.currentArgs = matched.args;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
185
|
+
if (state.heredocQueue.length > 0) return;
|
|
186
|
+
if (!hasContinuation) closeInstruction(state);
|
|
187
|
+
};
|
|
106
188
|
const parseDockerfile = (content) => {
|
|
107
|
-
const
|
|
189
|
+
const state = createParserState();
|
|
108
190
|
const lines = content.split(/\r?\n/u);
|
|
109
|
-
let currentInstruction = "";
|
|
110
|
-
let currentArgs = "";
|
|
111
|
-
let startLine = 0;
|
|
112
|
-
let rawAccumulator = [];
|
|
113
191
|
for (const [i, rawLine] of lines.entries()) {
|
|
114
192
|
const trimmed = rawLine.trim();
|
|
115
193
|
const lineNum = i + 1;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
122
|
-
if (currentInstruction) currentArgs += (currentArgs ? " " : "") + lineContent;
|
|
123
|
-
else {
|
|
124
|
-
startLine = lineNum;
|
|
125
|
-
const match = lineContent.match(/^(?<inst>[A-Z]+)\s+(?<args>.*)$/iu);
|
|
126
|
-
if (match?.groups) {
|
|
127
|
-
currentInstruction = match.groups.inst.toUpperCase();
|
|
128
|
-
currentArgs = match.groups.args;
|
|
129
|
-
} else {
|
|
130
|
-
const word = lineContent.trim().toUpperCase();
|
|
131
|
-
if ([
|
|
132
|
-
"RUN",
|
|
133
|
-
"CMD",
|
|
134
|
-
"ENTRYPOINT",
|
|
135
|
-
"EXPOSE",
|
|
136
|
-
"USER",
|
|
137
|
-
"WORKDIR"
|
|
138
|
-
].includes(word)) {
|
|
139
|
-
currentInstruction = word;
|
|
140
|
-
currentArgs = "";
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (!hasContinuation) {
|
|
145
|
-
if (currentInstruction) instructions.push({
|
|
146
|
-
args: currentArgs,
|
|
147
|
-
instruction: currentInstruction,
|
|
148
|
-
line: startLine,
|
|
149
|
-
raw: rawAccumulator.join("\n")
|
|
150
|
-
});
|
|
151
|
-
currentInstruction = "";
|
|
152
|
-
currentArgs = "";
|
|
153
|
-
rawAccumulator = [];
|
|
154
|
-
}
|
|
194
|
+
const insideHeredoc = state.heredocQueue.length > 0;
|
|
195
|
+
if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
|
|
196
|
+
state.rawAccumulator.push(rawLine);
|
|
197
|
+
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
198
|
+
else processInstructionLine(state, trimmed, lineNum);
|
|
155
199
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
instruction: currentInstruction,
|
|
159
|
-
line: startLine,
|
|
160
|
-
raw: rawAccumulator.join("\n")
|
|
161
|
-
});
|
|
162
|
-
return instructions;
|
|
200
|
+
closeInstruction(state);
|
|
201
|
+
return state.instructions;
|
|
163
202
|
};
|
|
164
203
|
|
|
165
204
|
//#endregion
|
|
166
205
|
//#region ../core/src/errors/config-error.ts
|
|
167
|
-
var ConfigError = class extends
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
206
|
+
var ConfigError = class extends Error {
|
|
207
|
+
_tag = "ConfigError";
|
|
208
|
+
constructor(options) {
|
|
209
|
+
super(options.message);
|
|
210
|
+
this.name = "ConfigError";
|
|
211
|
+
}
|
|
212
|
+
};
|
|
172
213
|
|
|
173
214
|
//#endregion
|
|
174
215
|
//#region ../core/src/errors/parse-error.ts
|
|
175
|
-
var ParseError = class extends
|
|
216
|
+
var ParseError = class extends Error {
|
|
217
|
+
_tag = "ParseError";
|
|
218
|
+
file;
|
|
219
|
+
constructor(options) {
|
|
220
|
+
super(options.message);
|
|
221
|
+
this.name = "ParseError";
|
|
222
|
+
this.file = options.file;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
176
225
|
|
|
177
226
|
//#endregion
|
|
178
227
|
//#region ../core/src/parsers/compose-parser.ts
|
|
@@ -215,7 +264,8 @@ const preferCopyOverAdd = {
|
|
|
215
264
|
check(instructions, file) {
|
|
216
265
|
const diagnostics = [];
|
|
217
266
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
218
|
-
const
|
|
267
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
268
|
+
if (!src) continue;
|
|
219
269
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
220
270
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
221
271
|
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
|
|
@@ -448,6 +498,54 @@ const composeRules = [
|
|
|
448
498
|
useDependsOnCondition
|
|
449
499
|
];
|
|
450
500
|
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
503
|
+
const parseImageRef = (ref) => {
|
|
504
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
505
|
+
isVariable: true,
|
|
506
|
+
name: ref
|
|
507
|
+
};
|
|
508
|
+
let remainder = ref;
|
|
509
|
+
let digest;
|
|
510
|
+
const atIndex = remainder.indexOf("@");
|
|
511
|
+
if (atIndex !== -1) {
|
|
512
|
+
digest = remainder.slice(atIndex + 1);
|
|
513
|
+
remainder = remainder.slice(0, atIndex);
|
|
514
|
+
}
|
|
515
|
+
let tag;
|
|
516
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
517
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
518
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
519
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
520
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
521
|
+
}
|
|
522
|
+
let registry;
|
|
523
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
524
|
+
if (firstSlashIndex !== -1) {
|
|
525
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
526
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
527
|
+
registry = firstSegment;
|
|
528
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
digest,
|
|
533
|
+
isVariable: false,
|
|
534
|
+
name: remainder,
|
|
535
|
+
registry,
|
|
536
|
+
tag
|
|
537
|
+
};
|
|
538
|
+
};
|
|
539
|
+
const collectStageAliases = (instructions) => {
|
|
540
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
541
|
+
for (const inst of instructions) {
|
|
542
|
+
if (inst.instruction !== "FROM") continue;
|
|
543
|
+
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
544
|
+
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
545
|
+
}
|
|
546
|
+
return aliases;
|
|
547
|
+
};
|
|
548
|
+
|
|
451
549
|
//#endregion
|
|
452
550
|
//#region ../core/src/rules/image-size.ts
|
|
453
551
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -462,17 +560,16 @@ const preferSlimBase = {
|
|
|
462
560
|
category: "Image Size",
|
|
463
561
|
check(instructions, file) {
|
|
464
562
|
const diagnostics = [];
|
|
563
|
+
const stageAliases = collectStageAliases(instructions);
|
|
465
564
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
466
565
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
467
566
|
if (!imagePart || imagePart === "scratch") continue;
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (!isSlim && !isSha && !isStageReference) 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));
|
|
475
|
-
}
|
|
567
|
+
const ref = parseImageRef(imagePart);
|
|
568
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
569
|
+
if (ref.digest) continue;
|
|
570
|
+
if (!ref.tag) continue;
|
|
571
|
+
const tag = ref.tag.toLowerCase();
|
|
572
|
+
if (!(tag.includes("alpine") || tag.includes("slim") || tag.includes("distroless"))) 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));
|
|
476
573
|
}
|
|
477
574
|
return diagnostics;
|
|
478
575
|
},
|
|
@@ -557,10 +654,12 @@ const orderLayers = {
|
|
|
557
654
|
const diagnostics = [];
|
|
558
655
|
let copyAllLine = -1;
|
|
559
656
|
for (const inst of instructions) {
|
|
657
|
+
if (inst.instruction === "FROM") copyAllLine = -1;
|
|
560
658
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
561
659
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
562
660
|
if (!src) continue;
|
|
563
|
-
|
|
661
|
+
const normalized = src.replace(/^\.\//u, "");
|
|
662
|
+
if ((normalized === "." || normalized === "" || normalized === "*" || normalized === "src" || normalized.startsWith("src/")) && copyAllLine === -1) copyAllLine = inst.line;
|
|
564
663
|
}
|
|
565
664
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
566
665
|
const args = inst.args.toLowerCase();
|
|
@@ -600,7 +699,9 @@ const useDockerignore = {
|
|
|
600
699
|
check(instructions, file, context) {
|
|
601
700
|
if (instructions.some((inst) => {
|
|
602
701
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
603
|
-
|
|
702
|
+
if (/(?:^|\s)--from=/u.test(inst.args)) return false;
|
|
703
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
704
|
+
if (!src) return false;
|
|
604
705
|
return src === "." || src === "./" || src === "*";
|
|
605
706
|
}
|
|
606
707
|
return false;
|
|
@@ -636,7 +737,10 @@ const noRootUser = {
|
|
|
636
737
|
check(instructions, file) {
|
|
637
738
|
let lastUser = "root";
|
|
638
739
|
let lastUserLine = 1;
|
|
639
|
-
for (const inst of instructions) if (inst.instruction === "
|
|
740
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
741
|
+
lastUser = "root";
|
|
742
|
+
lastUserLine = inst.line;
|
|
743
|
+
} else if (inst.instruction === "USER") {
|
|
640
744
|
lastUser = inst.args.trim().toLowerCase();
|
|
641
745
|
lastUserLine = inst.line;
|
|
642
746
|
}
|
|
@@ -653,12 +757,12 @@ const noSecretsInEnv = {
|
|
|
653
757
|
check(instructions, file) {
|
|
654
758
|
const diagnostics = [];
|
|
655
759
|
const secretKeywords = [
|
|
656
|
-
/password/iu,
|
|
657
|
-
/secret/iu,
|
|
658
|
-
/token/iu,
|
|
659
|
-
/api_key/iu,
|
|
660
|
-
/private_key/iu,
|
|
661
|
-
/auth/iu
|
|
760
|
+
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
761
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
762
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
763
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
764
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
765
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
662
766
|
];
|
|
663
767
|
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
664
768
|
const args = inst.args.trim();
|
|
@@ -693,15 +797,14 @@ const pinImageVersion = {
|
|
|
693
797
|
category: "Security",
|
|
694
798
|
check(instructions, file) {
|
|
695
799
|
const diagnostics = [];
|
|
800
|
+
const stageAliases = collectStageAliases(instructions);
|
|
696
801
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
697
802
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
698
803
|
if (!imagePart || imagePart === "scratch") continue;
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
else if (
|
|
703
|
-
if (imagePart.slice(colonIndex + 1) === "latest") diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' uses the mutable 'latest' tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
704
|
-
}
|
|
804
|
+
const ref = parseImageRef(imagePart);
|
|
805
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
806
|
+
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));
|
|
807
|
+
else if (ref.tag === "latest" && !ref.digest) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' uses the mutable 'latest' tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
705
808
|
}
|
|
706
809
|
return diagnostics;
|
|
707
810
|
},
|
|
@@ -715,7 +818,8 @@ const noAddRemote = {
|
|
|
715
818
|
check(instructions, file) {
|
|
716
819
|
const diagnostics = [];
|
|
717
820
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
718
|
-
const
|
|
821
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
822
|
+
if (!src) continue;
|
|
719
823
|
if (src.startsWith("http://") || src.startsWith("https://")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction uses a remote URL '${src}'. Remote files added via ADD cannot be cleaned up in later layers, increasing image size.`, this.help, inst.line));
|
|
720
824
|
}
|
|
721
825
|
return diagnostics;
|
|
@@ -774,23 +878,65 @@ const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
|
774
878
|
|
|
775
879
|
//#endregion
|
|
776
880
|
//#region ../core/src/schemas/config.ts
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
]
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
});
|
|
881
|
+
const RULE_SEVERITIES = [
|
|
882
|
+
"error",
|
|
883
|
+
"warning",
|
|
884
|
+
"info",
|
|
885
|
+
"off"
|
|
886
|
+
];
|
|
887
|
+
const RULE_CATEGORIES = [
|
|
888
|
+
"Best Practices",
|
|
889
|
+
"Compose",
|
|
890
|
+
"Image Size",
|
|
891
|
+
"Performance",
|
|
892
|
+
"Security"
|
|
893
|
+
];
|
|
894
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
895
|
+
const isRuleSeverity = (value) => typeof value === "string" && RULE_SEVERITIES.includes(value);
|
|
896
|
+
const validateRules = (value) => {
|
|
897
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "rules" must be an object, got ${typeof value}`);
|
|
898
|
+
for (const [key, severity] of Object.entries(value)) if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for rule "${key}"`);
|
|
899
|
+
return value;
|
|
900
|
+
};
|
|
901
|
+
const validateCategories = (value) => {
|
|
902
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "categories" must be an object, got ${typeof value}`);
|
|
903
|
+
const result = {};
|
|
904
|
+
for (const [key, severity] of Object.entries(value)) {
|
|
905
|
+
if (!RULE_CATEGORIES.includes(key)) continue;
|
|
906
|
+
if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for category "${key}"`);
|
|
907
|
+
result[key] = severity;
|
|
908
|
+
}
|
|
909
|
+
return result;
|
|
910
|
+
};
|
|
911
|
+
const validateIgnore = (value) => {
|
|
912
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "ignore" must be an object, got ${typeof value}`);
|
|
913
|
+
const result = {};
|
|
914
|
+
if ("files" in value && value.files !== void 0) {
|
|
915
|
+
if (!Array.isArray(value.files) || !value.files.every((item) => typeof item === "string")) throw new Error("Invalid config: \"ignore.files\" must be an array of strings");
|
|
916
|
+
result.files = value.files;
|
|
917
|
+
}
|
|
918
|
+
return result;
|
|
919
|
+
};
|
|
920
|
+
const describeInvalidTopLevel = (input) => {
|
|
921
|
+
if (input === null) return "null";
|
|
922
|
+
if (Array.isArray(input)) return "array";
|
|
923
|
+
return typeof input;
|
|
924
|
+
};
|
|
925
|
+
/**
|
|
926
|
+
* Validates and normalizes a raw config object, throwing on invalid input.
|
|
927
|
+
*
|
|
928
|
+
* Mirrors the legacy schema's behavior exactly, including silently
|
|
929
|
+
* dropping unknown top-level (and nested) keys rather than throwing
|
|
930
|
+
* or preserving them.
|
|
931
|
+
*/
|
|
932
|
+
const validateConfig = (input) => {
|
|
933
|
+
if (!isPlainObject(input)) throw new Error(`Invalid config: expected an object, got ${describeInvalidTopLevel(input)}`);
|
|
934
|
+
const result = {};
|
|
935
|
+
if ("rules" in input && input.rules !== void 0) result.rules = validateRules(input.rules);
|
|
936
|
+
if ("categories" in input && input.categories !== void 0) result.categories = validateCategories(input.categories);
|
|
937
|
+
if ("ignore" in input && input.ignore !== void 0) result.ignore = validateIgnore(input.ignore);
|
|
938
|
+
return result;
|
|
939
|
+
};
|
|
794
940
|
|
|
795
941
|
//#endregion
|
|
796
942
|
//#region ../core/src/config/loader.ts
|
|
@@ -802,13 +948,16 @@ const fileExists = async (filePath) => {
|
|
|
802
948
|
return false;
|
|
803
949
|
}
|
|
804
950
|
};
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
return JSON.parse(content);
|
|
951
|
+
const parseConfigFile = async (filePath, format, parse) => {
|
|
952
|
+
try {
|
|
953
|
+
return parse(await fs.readFile(filePath, "utf-8"));
|
|
809
954
|
} catch (error) {
|
|
810
|
-
throw new ConfigError({ message: `Failed to parse config
|
|
955
|
+
throw new ConfigError({ message: `Failed to parse config ${format}: ${error instanceof Error ? error.message : String(error)}` });
|
|
811
956
|
}
|
|
957
|
+
};
|
|
958
|
+
const importConfig = async (filePath) => {
|
|
959
|
+
if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
|
|
960
|
+
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", parse);
|
|
812
961
|
try {
|
|
813
962
|
const configModule = await import(filePath);
|
|
814
963
|
return configModule.default || configModule;
|
|
@@ -828,7 +977,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
828
977
|
"docker-doctor.config.js",
|
|
829
978
|
"docker-doctor.config.mjs",
|
|
830
979
|
"docker-doctor.config.cjs",
|
|
831
|
-
"docker-doctor.config.json"
|
|
980
|
+
"docker-doctor.config.json",
|
|
981
|
+
"docker-doctor.config.yaml",
|
|
982
|
+
"docker-doctor.config.yml"
|
|
832
983
|
]) {
|
|
833
984
|
const fullPath = path.join(rootDir, cand);
|
|
834
985
|
if (await fileExists(fullPath)) {
|
|
@@ -847,7 +998,7 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
847
998
|
}
|
|
848
999
|
if (!configObject) return {};
|
|
849
1000
|
try {
|
|
850
|
-
return
|
|
1001
|
+
return validateConfig(configObject);
|
|
851
1002
|
} catch (error) {
|
|
852
1003
|
throw new ConfigError({ message: `Invalid configuration format: ${error instanceof Error ? error.message : String(error)}` });
|
|
853
1004
|
}
|
|
@@ -855,24 +1006,48 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
855
1006
|
|
|
856
1007
|
//#endregion
|
|
857
1008
|
//#region ../core/src/scoring.ts
|
|
1009
|
+
const SCORE_BUCKETS = [
|
|
1010
|
+
{
|
|
1011
|
+
emoji: "🏆",
|
|
1012
|
+
label: "Excellent",
|
|
1013
|
+
min: 90
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
emoji: "✅",
|
|
1017
|
+
label: "Good",
|
|
1018
|
+
min: 75
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
emoji: "⚠️",
|
|
1022
|
+
label: "Needs Work",
|
|
1023
|
+
min: 50
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
emoji: "🚨",
|
|
1027
|
+
label: "Critical",
|
|
1028
|
+
min: 0
|
|
1029
|
+
}
|
|
1030
|
+
];
|
|
1031
|
+
const getScoreBucket = (score) => {
|
|
1032
|
+
for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
|
|
1033
|
+
return SCORE_BUCKETS.at(-1);
|
|
1034
|
+
};
|
|
858
1035
|
const calculateScore = (diagnostics) => {
|
|
859
1036
|
let penalty = 0;
|
|
860
1037
|
for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
|
|
861
1038
|
else if (diag.severity === "warning") penalty += 4;
|
|
862
1039
|
else if (diag.severity === "info") penalty += 1;
|
|
863
|
-
const score = Math.
|
|
864
|
-
|
|
865
|
-
if (score >= 90) label = "Excellent 🏆";
|
|
866
|
-
else if (score >= 75) label = "Good ✅";
|
|
867
|
-
else if (score >= 50) label = "Needs Work ⚠️";
|
|
1040
|
+
const score = Math.round(100 * Math.exp(-penalty / 70));
|
|
1041
|
+
const bucket = getScoreBucket(score);
|
|
868
1042
|
return {
|
|
869
|
-
label
|
|
1043
|
+
label: `${bucket.label} ${bucket.emoji}`,
|
|
870
1044
|
score
|
|
871
1045
|
};
|
|
872
1046
|
};
|
|
873
1047
|
|
|
874
1048
|
//#endregion
|
|
875
1049
|
//#region ../core/src/report.ts
|
|
1050
|
+
const REPORT_SCHEMA_VERSION = 2;
|
|
876
1051
|
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
877
1052
|
diagnostics: diagnostics.map((d) => ({
|
|
878
1053
|
column: d.column,
|
|
@@ -885,10 +1060,11 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
885
1060
|
})),
|
|
886
1061
|
label,
|
|
887
1062
|
project,
|
|
1063
|
+
schemaVersion: 2,
|
|
888
1064
|
score,
|
|
889
1065
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
890
1066
|
});
|
|
891
1067
|
|
|
892
1068
|
//#endregion
|
|
893
1069
|
export { runDockerfileRules as a, parseCompose as c, package_default 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 };
|
|
894
|
-
//# sourceMappingURL=src-
|
|
1070
|
+
//# sourceMappingURL=src-BOzcDwm-.mjs.map
|