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.
@@ -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
@@ -0,0 +1,4 @@
1
+ export type { ImageRef, ImageResult } from "./types.js";
2
+ export { parseCompose, parseImageRef } from "./composeParser.js";
3
+ export { resolveImage } from "./resolver.js";
4
+ export { formatJson, formatTable } from "./output.js";
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
+ }
@@ -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
+ }