@dockerforge/core 0.2.0 → 0.2.2
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/package.json +1 -1
- package/src/engine/digestPinning.js +195 -0
- package/src/engine/index.js +27 -1
- package/src/index.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dockerforge/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DockerForge engine: analyse a local project and generate production-grade Dockerfiles, .dockerignore, and Compose, and lint Dockerfiles. Offline, no network.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Docker Forge",
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const DOCKER_HUB_REGISTRIES = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io']);
|
|
6
|
+
const MANIFEST_ACCEPT = [
|
|
7
|
+
'application/vnd.oci.image.index.v1+json',
|
|
8
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
9
|
+
'application/vnd.oci.image.manifest.v1+json',
|
|
10
|
+
'application/vnd.docker.distribution.manifest.v2+json',
|
|
11
|
+
].join(', ');
|
|
12
|
+
|
|
13
|
+
function hasRegistry(firstSegment) {
|
|
14
|
+
return firstSegment.includes('.') || firstSegment.includes(':') || firstSegment === 'localhost';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseImageReference(imageRef) {
|
|
18
|
+
const original = String(imageRef || '').trim();
|
|
19
|
+
if (!original) throw new Error('Cannot digest-pin an empty image reference');
|
|
20
|
+
if (original.includes('@sha256:')) {
|
|
21
|
+
throw new Error(`Image is already digest-pinned: ${original}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parts = original.split('/');
|
|
25
|
+
let registry = 'docker.io';
|
|
26
|
+
let repositoryParts = parts;
|
|
27
|
+
|
|
28
|
+
if (parts.length > 1 && hasRegistry(parts[0])) {
|
|
29
|
+
registry = parts[0];
|
|
30
|
+
repositoryParts = parts.slice(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!DOCKER_HUB_REGISTRIES.has(registry)) {
|
|
34
|
+
throw new Error(`Digest pinning currently supports Docker Hub images only: ${original}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const last = repositoryParts[repositoryParts.length - 1];
|
|
38
|
+
const tagSeparator = last.lastIndexOf(':');
|
|
39
|
+
if (tagSeparator === -1) {
|
|
40
|
+
throw new Error(`Cannot digest-pin an image without an explicit tag: ${original}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tag = last.slice(tagSeparator + 1);
|
|
44
|
+
const name = last.slice(0, tagSeparator);
|
|
45
|
+
if (!tag || !name) {
|
|
46
|
+
throw new Error(`Cannot digest-pin an image without an explicit tag: ${original}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const repoParts = [...repositoryParts.slice(0, -1), name];
|
|
50
|
+
const repository = repoParts.length === 1 ? `library/${repoParts[0]}` : repoParts.join('/');
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
original,
|
|
54
|
+
registry: 'docker.io',
|
|
55
|
+
repository,
|
|
56
|
+
tag,
|
|
57
|
+
registryType: 'docker-hub',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readErrorBody(response) {
|
|
62
|
+
if (typeof response.text !== 'function') return '';
|
|
63
|
+
try {
|
|
64
|
+
return await response.text();
|
|
65
|
+
} catch {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveDockerHubDigest(imageRef, options = {}) {
|
|
71
|
+
const parsed = parseImageReference(imageRef);
|
|
72
|
+
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
73
|
+
if (typeof fetchImpl !== 'function') {
|
|
74
|
+
throw new Error('Digest pinning requires fetch support in this Node.js runtime');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tokenUrl = new URL('https://auth.docker.io/token');
|
|
78
|
+
tokenUrl.searchParams.set('service', 'registry.docker.io');
|
|
79
|
+
tokenUrl.searchParams.set('scope', `repository:${parsed.repository}:pull`);
|
|
80
|
+
|
|
81
|
+
const tokenResponse = await fetchImpl(tokenUrl.toString());
|
|
82
|
+
if (!tokenResponse.ok) {
|
|
83
|
+
const body = await readErrorBody(tokenResponse);
|
|
84
|
+
throw new Error(`Failed to request Docker Hub token for ${imageRef}: ${tokenResponse.status}${body ? ` ${body}` : ''}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tokenPayload = await tokenResponse.json();
|
|
88
|
+
const token = tokenPayload.token || tokenPayload.access_token;
|
|
89
|
+
if (!token) throw new Error(`Docker Hub token response did not include a token for ${imageRef}`);
|
|
90
|
+
|
|
91
|
+
const manifestUrl = `https://registry-1.docker.io/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}`;
|
|
92
|
+
const manifestResponse = await fetchImpl(manifestUrl, {
|
|
93
|
+
headers: {
|
|
94
|
+
accept: MANIFEST_ACCEPT,
|
|
95
|
+
authorization: `Bearer ${token}`,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!manifestResponse.ok) {
|
|
100
|
+
const body = await readErrorBody(manifestResponse);
|
|
101
|
+
throw new Error(`Failed to resolve digest for ${imageRef}: ${manifestResponse.status}${body ? ` ${body}` : ''}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body = Buffer.from(await manifestResponse.arrayBuffer());
|
|
105
|
+
const headerDigest = manifestResponse.headers && typeof manifestResponse.headers.get === 'function'
|
|
106
|
+
? manifestResponse.headers.get('docker-content-digest')
|
|
107
|
+
: null;
|
|
108
|
+
const digest = headerDigest || `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
|
|
109
|
+
|
|
110
|
+
if (!/^sha256:[a-f0-9]{64}$/i.test(digest)) {
|
|
111
|
+
throw new Error(`Registry returned an invalid digest for ${imageRef}: ${digest}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
original: imageRef,
|
|
116
|
+
pinned: `${imageRef}@${digest}`,
|
|
117
|
+
digest,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseFromLine(line) {
|
|
122
|
+
const match = line.match(/^(\s*FROM\s+)(.+?)(\s*)$/i);
|
|
123
|
+
if (!match) return null;
|
|
124
|
+
|
|
125
|
+
const prefix = match[1];
|
|
126
|
+
const rest = match[2].trim();
|
|
127
|
+
const trailing = match[3] || '';
|
|
128
|
+
const tokens = rest.split(/\s+/);
|
|
129
|
+
let index = 0;
|
|
130
|
+
const flags = [];
|
|
131
|
+
|
|
132
|
+
while (tokens[index] && tokens[index].startsWith('--')) {
|
|
133
|
+
flags.push(tokens[index]);
|
|
134
|
+
index += 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const image = tokens[index];
|
|
138
|
+
if (!image) return null;
|
|
139
|
+
|
|
140
|
+
const suffix = tokens.slice(index + 1).join(' ');
|
|
141
|
+
return { prefix, flags, image, suffix, trailing };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isStageReference(image, stageNames) {
|
|
145
|
+
return stageNames.has(image);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function collectStageNames(lines) {
|
|
149
|
+
const names = new Set();
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
const parsed = parseFromLine(line);
|
|
152
|
+
if (!parsed || !parsed.suffix) continue;
|
|
153
|
+
const aliasMatch = parsed.suffix.match(/^AS\s+(\S+)$/i);
|
|
154
|
+
if (aliasMatch) names.add(aliasMatch[1]);
|
|
155
|
+
}
|
|
156
|
+
return names;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function pinDockerfileDigests(dockerfile, options = {}) {
|
|
160
|
+
const resolveDigest = options.resolveDigest || resolveDockerHubDigest;
|
|
161
|
+
const lines = String(dockerfile || '').split('\n');
|
|
162
|
+
const stageNames = collectStageNames(lines);
|
|
163
|
+
const pinnedImages = [];
|
|
164
|
+
|
|
165
|
+
const rewritten = [];
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const parsed = parseFromLine(line);
|
|
168
|
+
if (!parsed || isStageReference(parsed.image, stageNames) || parsed.image.includes('@sha256:')) {
|
|
169
|
+
rewritten.push(line);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const resolved = await resolveDigest(parsed.image);
|
|
174
|
+
pinnedImages.push(resolved);
|
|
175
|
+
|
|
176
|
+
const chunks = [
|
|
177
|
+
parsed.prefix.trimEnd(),
|
|
178
|
+
...parsed.flags,
|
|
179
|
+
resolved.pinned,
|
|
180
|
+
parsed.suffix,
|
|
181
|
+
].filter(Boolean);
|
|
182
|
+
rewritten.push(`${chunks.join(' ')}${parsed.trailing}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
dockerfile: rewritten.join('\n'),
|
|
187
|
+
pinnedImages,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
parseImageReference,
|
|
193
|
+
pinDockerfileDigests,
|
|
194
|
+
resolveDockerHubDigest,
|
|
195
|
+
};
|
package/src/engine/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const { optimise } = require('./optimisation/optimiser');
|
|
|
7
7
|
const { securityPass } = require('./security/security');
|
|
8
8
|
const { buildExplanation } = require('./explanation/explainer');
|
|
9
9
|
const { generateCompose } = require('./generation/composeGenerator');
|
|
10
|
+
const { pinDockerfileDigests } = require('./digestPinning');
|
|
10
11
|
|
|
11
12
|
function clampScore(score) {
|
|
12
13
|
return Math.max(0, Math.min(1, Number(score.toFixed(2))));
|
|
@@ -168,11 +169,36 @@ async function runDockerfileEngine(input = {}) {
|
|
|
168
169
|
result = optimise(result, primaryService);
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
let digestPinningNote = null;
|
|
173
|
+
if (input.pinDigests === true) {
|
|
174
|
+
const pinned = await pinDockerfileDigests(result.dockerfile, {
|
|
175
|
+
resolveDigest: input.digestResolver,
|
|
176
|
+
});
|
|
177
|
+
result = {
|
|
178
|
+
...result,
|
|
179
|
+
dockerfile: pinned.dockerfile,
|
|
180
|
+
validationDockerfile: result.validationDockerfile
|
|
181
|
+
? (await pinDockerfileDigests(result.validationDockerfile, {
|
|
182
|
+
resolveDigest: input.digestResolver,
|
|
183
|
+
})).dockerfile
|
|
184
|
+
: result.validationDockerfile,
|
|
185
|
+
};
|
|
186
|
+
digestPinningNote = pinned.pinnedImages.length > 0
|
|
187
|
+
? `Digest-pinned ${pinned.pinnedImages.length} Docker Hub base image reference${pinned.pinnedImages.length === 1 ? '' : 's'} using live registry data; use an update process to refresh pinned digests.`
|
|
188
|
+
: 'Digest pinning was requested, but no Docker Hub base image references needed rewriting.';
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
const securityNotes = input.security === false ? [] : securityPass(result, primaryService);
|
|
172
192
|
const explanation = buildExplanation(primaryService, result, securityNotes);
|
|
173
|
-
const improvements = [
|
|
193
|
+
const improvements = [
|
|
194
|
+
...securityNotes,
|
|
195
|
+
...(digestPinningNote ? [digestPinningNote] : []),
|
|
196
|
+
...(result.improvements || []),
|
|
197
|
+
...(composeResult.improvements || []),
|
|
198
|
+
];
|
|
174
199
|
const assumptions = collectAssumptions(analysisResult);
|
|
175
200
|
const warnings = buildWarnings({ analysisResult, result, securityNotes });
|
|
201
|
+
if (digestPinningNote) warnings.push(digestPinningNote);
|
|
176
202
|
const confidence = applyValidationEvidence(
|
|
177
203
|
scoreConfidence({ analysisResult, warnings }),
|
|
178
204
|
input.validation
|
package/src/index.js
CHANGED
|
@@ -41,17 +41,17 @@ async function ingestLocal(targetPath) {
|
|
|
41
41
|
/**
|
|
42
42
|
* Run the offline engine pipeline. Only the documented OFFLINE input fields are accepted;
|
|
43
43
|
* remote-ingest fields (gitUrl/zipPath/fileTree/workDir/pat) are deliberately not forwarded.
|
|
44
|
-
* @param {{projectPath: string, hints?: object, optimise?: boolean, security?: boolean, validation?: object|null}} input
|
|
44
|
+
* @param {{projectPath: string, hints?: object, optimise?: boolean, security?: boolean, validation?: object|null, pinDigests?: boolean, digestResolver?: Function}} input
|
|
45
45
|
* @returns {Promise<object>} EngineResult (see contract Section 2.2)
|
|
46
46
|
*/
|
|
47
47
|
async function runDockerfileEngine(input = {}) {
|
|
48
|
-
const { projectPath, hints, optimise, security, validation } = input;
|
|
48
|
+
const { projectPath, hints, optimise, security, validation, pinDigests, digestResolver } = input;
|
|
49
49
|
if (!projectPath) {
|
|
50
50
|
throw new errors.IngestError(
|
|
51
51
|
'projectPath is required: @dockerforge/core is offline. Remote ingestion (git URL/zip) lives in the cloud adapter, not core.'
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
|
-
return engine.runDockerfileEngine({ projectPath, hints, optimise, security, validation });
|
|
54
|
+
return engine.runDockerfileEngine({ projectPath, hints, optimise, security, validation, pinDigests, digestResolver });
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
module.exports = {
|