code-provenance 0.1.0
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 +99 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +72 -0
- package/dist/cli.js.map +1 -0
- package/dist/composeParser.d.ts +9 -0
- package/dist/composeParser.js +72 -0
- package/dist/composeParser.js.map +1 -0
- package/dist/composeParser.test.d.ts +1 -0
- package/dist/composeParser.test.js +65 -0
- package/dist/composeParser.test.js.map +1 -0
- package/dist/github.d.ts +37 -0
- package/dist/github.js +270 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/output.d.ts +9 -0
- package/dist/output.js +39 -0
- package/dist/output.js.map +1 -0
- package/dist/registry.d.ts +9 -0
- package/dist/registry.js +123 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolver.d.ts +5 -0
- package/dist/resolver.js +165 -0
- package/dist/resolver.js.map +1 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/run.sh +25 -0
- package/src/cli.ts +87 -0
- package/src/composeParser.test.ts +76 -0
- package/src/composeParser.ts +79 -0
- package/src/github.ts +339 -0
- package/src/index.ts +4 -0
- package/src/output.ts +57 -0
- package/src/registry.ts +153 -0
- package/src/resolver.ts +192 -0
- package/src/types.ts +21 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import type { ImageRef } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a Docker image string into an ImageRef.
|
|
6
|
+
*/
|
|
7
|
+
export function parseImageRef(imageString: string): ImageRef {
|
|
8
|
+
const raw = imageString;
|
|
9
|
+
let tag: string;
|
|
10
|
+
let namePart: string;
|
|
11
|
+
|
|
12
|
+
// Handle digest references (image@sha256:...)
|
|
13
|
+
if (imageString.includes("@")) {
|
|
14
|
+
const atIdx = imageString.indexOf("@");
|
|
15
|
+
namePart = imageString.slice(0, atIdx);
|
|
16
|
+
tag = imageString.slice(atIdx + 1);
|
|
17
|
+
} else {
|
|
18
|
+
const lastSegment = imageString.split("/").pop()!;
|
|
19
|
+
if (lastSegment.includes(":")) {
|
|
20
|
+
const colonPos = imageString.lastIndexOf(":");
|
|
21
|
+
tag = imageString.slice(colonPos + 1);
|
|
22
|
+
namePart = imageString.slice(0, colonPos);
|
|
23
|
+
} else {
|
|
24
|
+
tag = "latest";
|
|
25
|
+
namePart = imageString;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Determine registry
|
|
30
|
+
const parts = namePart.split("/");
|
|
31
|
+
let registry: string;
|
|
32
|
+
let remaining: string[];
|
|
33
|
+
|
|
34
|
+
if (parts.length >= 2 && (parts[0].includes(".") || parts[0].includes(":"))) {
|
|
35
|
+
registry = parts[0];
|
|
36
|
+
remaining = parts.slice(1);
|
|
37
|
+
} else {
|
|
38
|
+
registry = "docker.io";
|
|
39
|
+
remaining = parts;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Determine namespace and name
|
|
43
|
+
let namespace: string;
|
|
44
|
+
let name: string;
|
|
45
|
+
|
|
46
|
+
if (remaining.length === 1) {
|
|
47
|
+
namespace = "library";
|
|
48
|
+
name = remaining[0];
|
|
49
|
+
} else if (remaining.length === 2) {
|
|
50
|
+
namespace = remaining[0];
|
|
51
|
+
name = remaining[1];
|
|
52
|
+
} else {
|
|
53
|
+
namespace = remaining[0];
|
|
54
|
+
name = remaining.slice(1).join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { registry, namespace, name, tag, raw };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse docker-compose YAML and return list of [serviceName, imageString] pairs.
|
|
62
|
+
*/
|
|
63
|
+
export function parseCompose(yamlContent: string): [string, string][] {
|
|
64
|
+
const data = YAML.parse(yamlContent);
|
|
65
|
+
const services = data?.services ?? {};
|
|
66
|
+
const results: [string, string][] = [];
|
|
67
|
+
|
|
68
|
+
for (const [serviceName, serviceConfig] of Object.entries(services)) {
|
|
69
|
+
if (
|
|
70
|
+
serviceConfig !== null &&
|
|
71
|
+
typeof serviceConfig === "object" &&
|
|
72
|
+
"image" in (serviceConfig as Record<string, unknown>)
|
|
73
|
+
) {
|
|
74
|
+
results.push([serviceName, (serviceConfig as Record<string, unknown>).image as string]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return results;
|
|
79
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
function githubHeaders(): Record<string, string> {
|
|
2
|
+
const headers: Record<string, string> = {
|
|
3
|
+
Accept: "application/vnd.github+json",
|
|
4
|
+
};
|
|
5
|
+
const token = process.env.GITHUB_TOKEN;
|
|
6
|
+
if (token) {
|
|
7
|
+
headers.Authorization = `Bearer ${token}`;
|
|
8
|
+
}
|
|
9
|
+
return headers;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeTag(tag: string): string {
|
|
13
|
+
return tag.replace(/^v+/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPrefixMatch(imageTag: string, gitTag: string): boolean {
|
|
17
|
+
const normImage = normalizeTag(imageTag);
|
|
18
|
+
const normGit = normalizeTag(gitTag);
|
|
19
|
+
return normGit.startsWith(normImage + ".");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseVersionTuple(tag: string): number[] | null {
|
|
23
|
+
let norm = normalizeTag(tag);
|
|
24
|
+
// Strip pre-release suffixes like -rc1, -beta2
|
|
25
|
+
norm = norm.split(/[-+]/)[0];
|
|
26
|
+
const parts = norm.split(".");
|
|
27
|
+
try {
|
|
28
|
+
const nums = parts.map((p) => {
|
|
29
|
+
const n = parseInt(p, 10);
|
|
30
|
+
if (isNaN(n)) throw new Error();
|
|
31
|
+
return n;
|
|
32
|
+
});
|
|
33
|
+
return nums;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareVersions(a: number[], b: number[]): number {
|
|
40
|
+
const len = Math.max(a.length, b.length);
|
|
41
|
+
for (let i = 0; i < len; i++) {
|
|
42
|
+
const av = a[i] ?? 0;
|
|
43
|
+
const bv = b[i] ?? 0;
|
|
44
|
+
if (av !== bv) return av - bv;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse the Link header for pagination.
|
|
51
|
+
* Returns the "next" URL or null.
|
|
52
|
+
*/
|
|
53
|
+
function parseNextLink(linkHeader: string | null): string | null {
|
|
54
|
+
if (!linkHeader) return null;
|
|
55
|
+
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
|
|
56
|
+
return match ? match[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GitTag {
|
|
60
|
+
name: string;
|
|
61
|
+
commit: { sha: string };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve an image tag to a commit SHA by matching against git tags.
|
|
66
|
+
* Tries exact match first, then prefix match (e.g., v2.10 -> highest v2.10.x).
|
|
67
|
+
* Returns [commit_sha, is_exact_match] or null.
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveTagToCommit(
|
|
70
|
+
owner: string,
|
|
71
|
+
repo: string,
|
|
72
|
+
tag: string
|
|
73
|
+
): Promise<[string, boolean] | null> {
|
|
74
|
+
const headers = githubHeaders();
|
|
75
|
+
let url: string | null =
|
|
76
|
+
`https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`;
|
|
77
|
+
|
|
78
|
+
const prefixCandidates: [number[], string][] = [];
|
|
79
|
+
|
|
80
|
+
while (url) {
|
|
81
|
+
const resp = await fetch(url, {
|
|
82
|
+
headers,
|
|
83
|
+
signal: AbortSignal.timeout(10000),
|
|
84
|
+
});
|
|
85
|
+
if (!resp.ok) return null;
|
|
86
|
+
|
|
87
|
+
const gitTags: GitTag[] = await resp.json() as GitTag[];
|
|
88
|
+
|
|
89
|
+
for (const gitTag of gitTags) {
|
|
90
|
+
const name = gitTag.name;
|
|
91
|
+
// Exact match (with/without v prefix)
|
|
92
|
+
if (
|
|
93
|
+
name === tag ||
|
|
94
|
+
name === `v${tag}` ||
|
|
95
|
+
normalizeTag(name) === normalizeTag(tag)
|
|
96
|
+
) {
|
|
97
|
+
return [gitTag.commit.sha, true];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Collect prefix match candidates
|
|
101
|
+
if (isPrefixMatch(tag, name)) {
|
|
102
|
+
const version = parseVersionTuple(name);
|
|
103
|
+
if (version !== null) {
|
|
104
|
+
prefixCandidates.push([version, gitTag.commit.sha]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
url = parseNextLink(resp.headers.get("link"));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Return the highest version among prefix matches
|
|
113
|
+
if (prefixCandidates.length > 0) {
|
|
114
|
+
prefixCandidates.sort((a, b) => compareVersions(b[0], a[0]));
|
|
115
|
+
return [prefixCandidates[0][1], false];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the commit SHA of the latest GitHub release.
|
|
123
|
+
* Returns [commit_sha, tag_name] or null.
|
|
124
|
+
*/
|
|
125
|
+
export async function getLatestReleaseCommit(
|
|
126
|
+
owner: string,
|
|
127
|
+
repo: string
|
|
128
|
+
): Promise<[string, string] | null> {
|
|
129
|
+
const headers = githubHeaders();
|
|
130
|
+
try {
|
|
131
|
+
const resp = await fetch(
|
|
132
|
+
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
|
|
133
|
+
{ headers, signal: AbortSignal.timeout(10000) }
|
|
134
|
+
);
|
|
135
|
+
if (!resp.ok) return null;
|
|
136
|
+
const data = (await resp.json()) as { tag_name?: string };
|
|
137
|
+
const tagName = data.tag_name;
|
|
138
|
+
if (!tagName) return null;
|
|
139
|
+
|
|
140
|
+
const tagResult = await resolveTagToCommit(owner, repo, tagName);
|
|
141
|
+
if (tagResult) {
|
|
142
|
+
return [tagResult[0], tagName];
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get the latest commit SHA on the default branch.
|
|
152
|
+
*/
|
|
153
|
+
export async function getLatestCommit(
|
|
154
|
+
owner: string,
|
|
155
|
+
repo: string
|
|
156
|
+
): Promise<string | null> {
|
|
157
|
+
const headers = githubHeaders();
|
|
158
|
+
try {
|
|
159
|
+
const resp = await fetch(
|
|
160
|
+
`https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`,
|
|
161
|
+
{ headers, signal: AbortSignal.timeout(10000) }
|
|
162
|
+
);
|
|
163
|
+
if (!resp.ok) return null;
|
|
164
|
+
const commits = (await resp.json()) as { sha: string }[];
|
|
165
|
+
if (commits.length > 0) return commits[0].sha;
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a GitHub repo exists.
|
|
174
|
+
*/
|
|
175
|
+
export async function checkGithubRepoExists(
|
|
176
|
+
owner: string,
|
|
177
|
+
repo: string
|
|
178
|
+
): Promise<boolean> {
|
|
179
|
+
const headers = githubHeaders();
|
|
180
|
+
try {
|
|
181
|
+
const resp = await fetch(
|
|
182
|
+
`https://api.github.com/repos/${owner}/${repo}`,
|
|
183
|
+
{ headers, signal: AbortSignal.timeout(10000) }
|
|
184
|
+
);
|
|
185
|
+
return resp.status === 200;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface PackageVersionResult {
|
|
192
|
+
repo: string;
|
|
193
|
+
commit: string | null;
|
|
194
|
+
tags: string[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Find a GHCR package version by digest or tag via the GitHub Packages API.
|
|
199
|
+
*/
|
|
200
|
+
async function findGhcrPackageVersion(
|
|
201
|
+
owner: string,
|
|
202
|
+
packageName: string,
|
|
203
|
+
options: { matchDigest?: string; matchTag?: string }
|
|
204
|
+
): Promise<PackageVersionResult | null> {
|
|
205
|
+
const headers = githubHeaders();
|
|
206
|
+
if (!headers.Authorization) return null;
|
|
207
|
+
|
|
208
|
+
for (const entityType of ["orgs", "users"]) {
|
|
209
|
+
const pkgBase = `https://api.github.com/${entityType}/${owner}/packages/container/${packageName}`;
|
|
210
|
+
|
|
211
|
+
// Get package metadata for source repo
|
|
212
|
+
let fullName: string | undefined;
|
|
213
|
+
try {
|
|
214
|
+
const pkgResp = await fetch(pkgBase, {
|
|
215
|
+
headers,
|
|
216
|
+
signal: AbortSignal.timeout(10000),
|
|
217
|
+
});
|
|
218
|
+
if (pkgResp.status === 403) return null;
|
|
219
|
+
if (!pkgResp.ok) continue;
|
|
220
|
+
const pkgData = await pkgResp.json();
|
|
221
|
+
fullName = (pkgData as any)?.repository?.full_name;
|
|
222
|
+
if (!fullName) continue;
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Search versions
|
|
228
|
+
let versionsUrl: string | null = `${pkgBase}/versions?per_page=50`;
|
|
229
|
+
try {
|
|
230
|
+
while (versionsUrl) {
|
|
231
|
+
const resp = await fetch(versionsUrl, {
|
|
232
|
+
headers,
|
|
233
|
+
signal: AbortSignal.timeout(10000),
|
|
234
|
+
});
|
|
235
|
+
if (!resp.ok) break;
|
|
236
|
+
|
|
237
|
+
const versions = (await resp.json()) as any[];
|
|
238
|
+
|
|
239
|
+
for (const version of versions) {
|
|
240
|
+
const name: string = version.name ?? "";
|
|
241
|
+
const metadata = version.metadata?.container ?? {};
|
|
242
|
+
const tags: string[] = metadata.tags ?? [];
|
|
243
|
+
|
|
244
|
+
// Match by digest (version name is the digest)
|
|
245
|
+
if (options.matchDigest && name !== options.matchDigest) {
|
|
246
|
+
if (!options.matchTag) continue;
|
|
247
|
+
}
|
|
248
|
+
// Match by tag
|
|
249
|
+
if (options.matchTag && !tags.includes(options.matchTag)) continue;
|
|
250
|
+
|
|
251
|
+
// Found matching version - resolve tags to a commit
|
|
252
|
+
const [repoOwner, repoName] = fullName!.split("/", 2);
|
|
253
|
+
const resolvableTags = tags.filter((t) => t !== "latest");
|
|
254
|
+
for (const t of resolvableTags) {
|
|
255
|
+
const tagResult = await resolveTagToCommit(repoOwner, repoName, t);
|
|
256
|
+
if (tagResult) {
|
|
257
|
+
return { repo: fullName!, commit: tagResult[0], tags };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { repo: fullName!, commit: null, tags };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
versionsUrl = parseNextLink(resp.headers.get("link"));
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Find the commit for a GHCR image by its digest.
|
|
276
|
+
*/
|
|
277
|
+
export async function resolveGhcrDigestViaPackages(
|
|
278
|
+
owner: string,
|
|
279
|
+
packageName: string,
|
|
280
|
+
digest: string
|
|
281
|
+
): Promise<PackageVersionResult | null> {
|
|
282
|
+
return findGhcrPackageVersion(owner, packageName, { matchDigest: digest });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Find the commit for a GHCR image's :latest tag.
|
|
287
|
+
*/
|
|
288
|
+
export async function resolveGhcrLatestViaPackages(
|
|
289
|
+
owner: string,
|
|
290
|
+
packageName: string
|
|
291
|
+
): Promise<PackageVersionResult | null> {
|
|
292
|
+
return findGhcrPackageVersion(owner, packageName, { matchTag: "latest" });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Try to find the GitHub repo for a Docker Hub image.
|
|
297
|
+
*/
|
|
298
|
+
export async function inferRepoFromDockerhub(
|
|
299
|
+
namespace: string,
|
|
300
|
+
name: string
|
|
301
|
+
): Promise<[string, string] | null> {
|
|
302
|
+
// For official images (library/X), try the image name as org/repo directly
|
|
303
|
+
if (namespace === "library") {
|
|
304
|
+
if (await checkGithubRepoExists(name, name)) {
|
|
305
|
+
return [name, name];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// For namespaced images, try namespace/name on GitHub
|
|
310
|
+
if (namespace !== "library") {
|
|
311
|
+
if (await checkGithubRepoExists(namespace, name)) {
|
|
312
|
+
return [namespace, name];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Fall back to scraping Docker Hub description for GitHub links
|
|
317
|
+
try {
|
|
318
|
+
const resp = await fetch(
|
|
319
|
+
`https://hub.docker.com/v2/repositories/${namespace}/${name}`,
|
|
320
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
321
|
+
);
|
|
322
|
+
if (!resp.ok) return null;
|
|
323
|
+
|
|
324
|
+
const data = (await resp.json()) as {
|
|
325
|
+
full_description?: string;
|
|
326
|
+
description?: string;
|
|
327
|
+
};
|
|
328
|
+
const text =
|
|
329
|
+
(data.full_description || "") + " " + (data.description || "");
|
|
330
|
+
const match = text.match(/https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/);
|
|
331
|
+
if (match) {
|
|
332
|
+
return [match[1], match[2]];
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// ignore
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return null;
|
|
339
|
+
}
|
package/src/index.ts
ADDED
package/src/output.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ImageResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format results as a JSON array.
|
|
5
|
+
*/
|
|
6
|
+
export function formatJson(results: ImageResult[]): string {
|
|
7
|
+
return JSON.stringify(results, null, 2);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format results as a box-drawing table.
|
|
12
|
+
*/
|
|
13
|
+
export function formatTable(results: ImageResult[]): string {
|
|
14
|
+
const columns = ["SERVICE", "IMAGE", "REPO", "COMMIT", "STATUS", "CONFIDENCE"];
|
|
15
|
+
|
|
16
|
+
// Build rows
|
|
17
|
+
const rows: string[][] = results.map((r) => [
|
|
18
|
+
r.service,
|
|
19
|
+
r.image,
|
|
20
|
+
r.repo ? r.repo.replace("https://", "") : "-",
|
|
21
|
+
r.commit ? r.commit.slice(0, 12) : "-",
|
|
22
|
+
r.status,
|
|
23
|
+
r.confidence || "-",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Calculate column widths
|
|
27
|
+
const widths = columns.map((col, i) => {
|
|
28
|
+
const dataMax = rows.reduce((max, row) => Math.max(max, row[i].length), 0);
|
|
29
|
+
return Math.max(col.length, dataMax);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const pad = (s: string, w: number) => s + " ".repeat(w - s.length);
|
|
33
|
+
|
|
34
|
+
// Build lines
|
|
35
|
+
const topBorder =
|
|
36
|
+
"┌" + widths.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
|
|
37
|
+
const headerSep =
|
|
38
|
+
"├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
|
|
39
|
+
const bottomBorder =
|
|
40
|
+
"└" + widths.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
|
|
41
|
+
|
|
42
|
+
const headerRow =
|
|
43
|
+
"│" +
|
|
44
|
+
columns.map((col, i) => " " + pad(col, widths[i]) + " ").join("│") +
|
|
45
|
+
"│";
|
|
46
|
+
|
|
47
|
+
const dataRows = rows.map(
|
|
48
|
+
(row) =>
|
|
49
|
+
"│" +
|
|
50
|
+
row.map((cell, i) => " " + pad(cell, widths[i]) + " ").join("│") +
|
|
51
|
+
"│"
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return [topBorder, headerRow, headerSep, ...dataRows, bottomBorder].join(
|
|
55
|
+
"\n"
|
|
56
|
+
);
|
|
57
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ImageRef } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get an anonymous pull token from an OCI registry.
|
|
5
|
+
*/
|
|
6
|
+
export async function getRegistryToken(
|
|
7
|
+
registry: string,
|
|
8
|
+
repoPath: string
|
|
9
|
+
): Promise<string | null> {
|
|
10
|
+
let url: string;
|
|
11
|
+
|
|
12
|
+
if (registry === "ghcr.io") {
|
|
13
|
+
url = `https://ghcr.io/token?scope=repository:${encodeURIComponent(repoPath)}:pull`;
|
|
14
|
+
} else if (registry === "docker.io") {
|
|
15
|
+
url =
|
|
16
|
+
`https://auth.docker.io/token?service=registry.docker.io` +
|
|
17
|
+
`&scope=repository:${encodeURIComponent(repoPath)}:pull`;
|
|
18
|
+
} else {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
24
|
+
if (!resp.ok) return null;
|
|
25
|
+
const data = (await resp.json()) as { token?: string };
|
|
26
|
+
return data.token ?? null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function registryBaseUrl(registry: string): string {
|
|
33
|
+
if (registry === "docker.io") return "https://registry-1.docker.io";
|
|
34
|
+
return `https://${registry}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MANIFEST_ACCEPT = [
|
|
38
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
39
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
40
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
41
|
+
"application/vnd.oci.image.index.v1+json",
|
|
42
|
+
].join(", ");
|
|
43
|
+
|
|
44
|
+
const INDEX_MEDIA_TYPES = new Set([
|
|
45
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
46
|
+
"application/vnd.oci.image.index.v1+json",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
interface ManifestPlatform {
|
|
50
|
+
os?: string;
|
|
51
|
+
architecture?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ManifestEntry {
|
|
55
|
+
digest: string;
|
|
56
|
+
platform?: ManifestPlatform;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a manifest reference to a config blob digest, handling multi-arch indexes.
|
|
61
|
+
*/
|
|
62
|
+
async function resolveManifestToConfigDigest(
|
|
63
|
+
baseUrl: string,
|
|
64
|
+
repoPath: string,
|
|
65
|
+
reference: string,
|
|
66
|
+
token: string
|
|
67
|
+
): Promise<string | null> {
|
|
68
|
+
try {
|
|
69
|
+
const resp = await fetch(
|
|
70
|
+
`${baseUrl}/v2/${repoPath}/manifests/${reference}`,
|
|
71
|
+
{
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${token}`,
|
|
74
|
+
Accept: MANIFEST_ACCEPT,
|
|
75
|
+
},
|
|
76
|
+
signal: AbortSignal.timeout(10000),
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
if (!resp.ok) return null;
|
|
80
|
+
const data = await resp.json();
|
|
81
|
+
const mediaType: string = data.mediaType ?? "";
|
|
82
|
+
|
|
83
|
+
// If it's an index/manifest list, pick amd64/linux
|
|
84
|
+
if (INDEX_MEDIA_TYPES.has(mediaType)) {
|
|
85
|
+
const manifests: ManifestEntry[] = data.manifests ?? [];
|
|
86
|
+
let platformDigest: string | null = null;
|
|
87
|
+
|
|
88
|
+
// Try amd64/linux first
|
|
89
|
+
for (const m of manifests) {
|
|
90
|
+
const platform = m.platform ?? {};
|
|
91
|
+
if (platform.os === "unknown") continue;
|
|
92
|
+
if (platform.architecture === "amd64" && platform.os === "linux") {
|
|
93
|
+
platformDigest = m.digest;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fall back to first non-attestation manifest
|
|
99
|
+
if (!platformDigest) {
|
|
100
|
+
for (const m of manifests) {
|
|
101
|
+
if (m.platform?.os !== "unknown") {
|
|
102
|
+
platformDigest = m.digest;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!platformDigest) return null;
|
|
109
|
+
return resolveManifestToConfigDigest(baseUrl, repoPath, platformDigest, token);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Single manifest - extract config digest
|
|
113
|
+
return data.config?.digest ?? null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fetch OCI labels from an image's config blob without pulling the image.
|
|
121
|
+
*/
|
|
122
|
+
export async function fetchOciLabels(
|
|
123
|
+
ref: ImageRef
|
|
124
|
+
): Promise<Record<string, string>> {
|
|
125
|
+
const repoPath = `${ref.namespace}/${ref.name}`;
|
|
126
|
+
const token = await getRegistryToken(ref.registry, repoPath);
|
|
127
|
+
if (!token) return {};
|
|
128
|
+
|
|
129
|
+
const baseUrl = registryBaseUrl(ref.registry);
|
|
130
|
+
const configDigest = await resolveManifestToConfigDigest(
|
|
131
|
+
baseUrl,
|
|
132
|
+
repoPath,
|
|
133
|
+
ref.tag,
|
|
134
|
+
token
|
|
135
|
+
);
|
|
136
|
+
if (!configDigest) return {};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const resp = await fetch(`${baseUrl}/v2/${repoPath}/blobs/${configDigest}`, {
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${token}`,
|
|
142
|
+
Accept: "application/vnd.docker.container.image.v1+json",
|
|
143
|
+
},
|
|
144
|
+
redirect: "follow",
|
|
145
|
+
signal: AbortSignal.timeout(10000),
|
|
146
|
+
});
|
|
147
|
+
if (!resp.ok) return {};
|
|
148
|
+
const data = await resp.json();
|
|
149
|
+
return data.config?.Labels ?? {};
|
|
150
|
+
} catch {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|