@intentius/chant 0.4.0 → 0.6.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,182 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { fetchCiFiles, parseRepoUrl, resolveActionSha, resolveImageDigest, parseImageRef, FetchError } from "./fetch";
3
+
4
+ const b64 = (s: string) => Buffer.from(s, "utf-8").toString("base64");
5
+ const CI_YAML = "name: CI\non:\n push:\npermissions: write-all\njobs:\n build:\n runs-on: ubuntu-latest\n";
6
+
7
+ interface Route {
8
+ match: string;
9
+ make: () => Response;
10
+ }
11
+
12
+ function fakeFetch(routes: Route[]) {
13
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
14
+ const impl = (async (url: string | URL | Request, init?: RequestInit) => {
15
+ calls.push({ url: String(url), init });
16
+ for (const r of routes) {
17
+ if (String(url).includes(r.match)) return r.make();
18
+ }
19
+ return new Response("not found", { status: 404 });
20
+ }) as unknown as typeof fetch;
21
+ return { impl, calls };
22
+ }
23
+
24
+ const githubRoutes = (size = 100): Route[] => [
25
+ {
26
+ match: "/contents/.github/workflows/ci.yml",
27
+ make: () => new Response(JSON.stringify({ name: "ci.yml", path: ".github/workflows/ci.yml", type: "file", content: b64(CI_YAML), encoding: "base64" }), { status: 200 }),
28
+ },
29
+ {
30
+ match: "/contents/.github/workflows",
31
+ make: () => new Response(JSON.stringify([{ name: "ci.yml", path: ".github/workflows/ci.yml", type: "file", size }]), { status: 200 }),
32
+ },
33
+ ];
34
+
35
+ describe("parseRepoUrl", () => {
36
+ test("rejects non-https", () => {
37
+ expect(() => parseRepoUrl("http://github.com/o/r")).toThrow(FetchError);
38
+ });
39
+ test("rejects non-allowlisted host", () => {
40
+ expect(() => parseRepoUrl("https://evil.example.com/o/r")).toThrow(/Host not allowed/);
41
+ });
42
+ test("parses owner/repo and strips .git", () => {
43
+ const p = parseRepoUrl("https://github.com/acme/widgets.git");
44
+ expect(p.owner).toBe("acme");
45
+ expect(p.repo).toBe("widgets");
46
+ expect(p.host.kind).toBe("github");
47
+ });
48
+ });
49
+
50
+ describe("fetchCiFiles", () => {
51
+ test("fetches and decodes github workflow files", async () => {
52
+ const { impl } = fakeFetch(githubRoutes());
53
+ const files = await fetchCiFiles("https://github.com/acme/widgets", { fetchImpl: impl });
54
+ expect(files).toHaveLength(1);
55
+ expect(files[0].path).toBe(".github/workflows/ci.yml");
56
+ expect(files[0].lexicon).toBe("github");
57
+ expect(files[0].content).toContain("permissions: write-all");
58
+ });
59
+
60
+ test("sends an auth token when provided", async () => {
61
+ const { impl, calls } = fakeFetch(githubRoutes());
62
+ await fetchCiFiles("https://github.com/acme/widgets", { fetchImpl: impl, token: "secret" });
63
+ const auth = calls.map((c) => (c.init?.headers as Record<string, string>)?.Authorization);
64
+ expect(auth).toContain("Bearer secret");
65
+ });
66
+
67
+ test("skips a file over the per-file size cap", async () => {
68
+ const { impl } = fakeFetch(githubRoutes(10_000_000));
69
+ const files = await fetchCiFiles("https://github.com/acme/widgets", { fetchImpl: impl, maxBytesPerFile: 1024 });
70
+ expect(files).toHaveLength(0);
71
+ });
72
+
73
+ test("refuses to follow a redirect", async () => {
74
+ const { impl } = fakeFetch([{ match: "/contents/", make: () => new Response(null, { status: 302 }) }]);
75
+ await expect(fetchCiFiles("https://github.com/acme/widgets", { fetchImpl: impl })).rejects.toThrow(/redirect/i);
76
+ });
77
+
78
+ test("throws on a non-allowlisted host before any fetch", async () => {
79
+ await expect(fetchCiFiles("https://evil.example.com/o/r")).rejects.toThrow(/Host not allowed/);
80
+ });
81
+
82
+ test("returns the gitlab pipeline file", async () => {
83
+ const { impl } = fakeFetch([
84
+ { match: "/repository/files/", make: () => new Response("stages:\n - build\n", { status: 200 }) },
85
+ ]);
86
+ const files = await fetchCiFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl });
87
+ expect(files).toHaveLength(1);
88
+ expect(files[0].path).toBe(".gitlab-ci.yml");
89
+ expect(files[0].lexicon).toBe("gitlab");
90
+ });
91
+
92
+ test("forgejo (codeberg) reads .forgejo/workflows", async () => {
93
+ const { impl } = fakeFetch([
94
+ {
95
+ match: "/contents/.forgejo/workflows/ci.yml",
96
+ make: () => new Response(JSON.stringify({ name: "ci.yml", path: ".forgejo/workflows/ci.yml", type: "file", content: b64(CI_YAML), encoding: "base64" }), { status: 200 }),
97
+ },
98
+ {
99
+ match: "/contents/.forgejo/workflows",
100
+ make: () => new Response(JSON.stringify([{ name: "ci.yml", path: ".forgejo/workflows/ci.yml", type: "file", size: 100 }]), { status: 200 }),
101
+ },
102
+ ]);
103
+ const files = await fetchCiFiles("https://codeberg.org/acme/widgets", { fetchImpl: impl });
104
+ expect(files).toHaveLength(1);
105
+ expect(files[0].lexicon).toBe("forgejo");
106
+ expect(files[0].path).toBe(".forgejo/workflows/ci.yml");
107
+ });
108
+
109
+ test("a repo with no CI files returns []", async () => {
110
+ const { impl } = fakeFetch([]); // everything 404s
111
+ const files = await fetchCiFiles("https://github.com/acme/empty", { fetchImpl: impl });
112
+ expect(files).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe("resolveActionSha", () => {
117
+ const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
118
+
119
+ test("resolves an action ref to a commit SHA via the GitHub API", async () => {
120
+ const { impl, calls } = fakeFetch([
121
+ { match: "/repos/actions/checkout/commits/v4", make: () => new Response(JSON.stringify({ sha: SHA }), { status: 200 }) },
122
+ ]);
123
+ const sha = await resolveActionSha("actions/checkout", "v4", { fetchImpl: impl });
124
+ expect(sha).toBe(SHA);
125
+ expect(calls[0].url).toContain("api.github.com/repos/actions/checkout/commits/v4");
126
+ });
127
+
128
+ test("returns undefined on a failed lookup", async () => {
129
+ const { impl } = fakeFetch([]); // 404
130
+ expect(await resolveActionSha("acme/missing", "v1", { fetchImpl: impl })).toBeUndefined();
131
+ });
132
+
133
+ test("rejects a non-SHA response", async () => {
134
+ const { impl } = fakeFetch([{ match: "/commits/", make: () => new Response(JSON.stringify({ sha: "not-a-sha" }), { status: 200 }) }]);
135
+ expect(await resolveActionSha("acme/action", "v1", { fetchImpl: impl })).toBeUndefined();
136
+ });
137
+ });
138
+
139
+ describe("parseImageRef", () => {
140
+ test("bare Docker Hub official image", () => {
141
+ expect(parseImageRef("node:20")).toEqual({ registry: "registry-1.docker.io", repository: "library/node", tag: "20" });
142
+ });
143
+ test("Docker Hub org image, default tag", () => {
144
+ expect(parseImageRef("acme/app")).toEqual({ registry: "registry-1.docker.io", repository: "acme/app", tag: "latest" });
145
+ });
146
+ test("ghcr image with registry host", () => {
147
+ expect(parseImageRef("ghcr.io/owner/img:1.2")).toEqual({ registry: "ghcr.io", repository: "owner/img", tag: "1.2" });
148
+ });
149
+ test("already-digested ref returns undefined", () => {
150
+ expect(parseImageRef("node@sha256:" + "a".repeat(64))).toBeUndefined();
151
+ });
152
+ });
153
+
154
+ describe("resolveImageDigest", () => {
155
+ const DIGEST = "sha256:" + "c".repeat(64);
156
+
157
+ test("resolves via the registry v2 bearer-token challenge", async () => {
158
+ const challenge = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/node:pull"';
159
+ const impl = (async (url: string | URL | Request, init?: RequestInit) => {
160
+ const u = String(url);
161
+ if (u.includes("auth.docker.io/token")) return new Response(JSON.stringify({ token: "t" }), { status: 200 });
162
+ if (u.includes("/v2/library/node/manifests/20")) {
163
+ const hasAuth = Boolean((init?.headers as Record<string, string>)?.Authorization);
164
+ return hasAuth
165
+ ? new Response(null, { status: 200, headers: { "docker-content-digest": DIGEST } })
166
+ : new Response(null, { status: 401, headers: { "www-authenticate": challenge } });
167
+ }
168
+ return new Response("nf", { status: 404 });
169
+ }) as unknown as typeof fetch;
170
+ expect(await resolveImageDigest("node:20", { fetchImpl: impl })).toBe(DIGEST);
171
+ });
172
+
173
+ test("skips a non-allowlisted registry (SSRF guard)", async () => {
174
+ let called = false;
175
+ const impl = (async () => {
176
+ called = true;
177
+ return new Response(null, { status: 200 });
178
+ }) as unknown as typeof fetch;
179
+ expect(await resolveImageDigest("evil.internal/x:1", { fetchImpl: impl })).toBeUndefined();
180
+ expect(called).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Remote fetch — pull a repo's CI files from a git host so the auditor can run
3
+ * on a URL, not just a local path. This is the ONLY audit module that touches
4
+ * the network; the core stays pure.
5
+ *
6
+ * SSRF posture: only an allowlisted set of hosts is accepted; request URLs are
7
+ * built from the parsed owner/repo (never a user-controlled host); redirects
8
+ * are refused; and file count / size / total bytes / time are all capped.
9
+ */
10
+
11
+ import type { AuditInput, AuditLexicon } from "./core";
12
+
13
+ export interface FetchOptions {
14
+ /** Branch/tag/sha; defaults to the repo's default branch. */
15
+ ref?: string;
16
+ /** Server-side token (lifts rate limits). Never surfaced to callers. */
17
+ token?: string;
18
+ /** Max number of CI files to fetch (default 50). */
19
+ maxFiles?: number;
20
+ /** Max bytes for a single file; larger files are skipped (default 256 KiB). */
21
+ maxBytesPerFile?: number;
22
+ /** Max total bytes across all files; exceeding throws (default 2 MiB). */
23
+ maxTotalBytes?: number;
24
+ /** Per-request timeout in ms (default 10000). */
25
+ timeoutMs?: number;
26
+ /** Injectable fetch for testing. Defaults to the global fetch. */
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+
30
+ const DEFAULTS = {
31
+ maxFiles: 50,
32
+ maxBytesPerFile: 256 * 1024,
33
+ maxTotalBytes: 2 * 1024 * 1024,
34
+ timeoutMs: 10_000,
35
+ };
36
+
37
+ type HostKind = "github" | "forgejo" | "gitlab";
38
+
39
+ interface HostConfig {
40
+ kind: HostKind;
41
+ api: string;
42
+ lexicon: AuditLexicon;
43
+ }
44
+
45
+ const ALLOWED_HOSTS: Record<string, HostConfig> = {
46
+ "github.com": { kind: "github", api: "https://api.github.com", lexicon: "github" },
47
+ "codeberg.org": { kind: "forgejo", api: "https://codeberg.org/api/v1", lexicon: "forgejo" },
48
+ "gitlab.com": { kind: "gitlab", api: "https://gitlab.com/api/v4", lexicon: "gitlab" },
49
+ };
50
+
51
+ export class FetchError extends Error {}
52
+
53
+ interface ParsedRepo {
54
+ host: HostConfig;
55
+ owner: string;
56
+ repo: string;
57
+ }
58
+
59
+ /** Parse and validate a repo URL against the host allowlist (SSRF guard). */
60
+ export function parseRepoUrl(url: string): ParsedRepo {
61
+ let u: URL;
62
+ try {
63
+ u = new URL(url);
64
+ } catch {
65
+ throw new FetchError(`Invalid URL: ${url}`);
66
+ }
67
+ if (u.protocol !== "https:") {
68
+ throw new FetchError(`Only https:// URLs are allowed (got ${u.protocol}).`);
69
+ }
70
+ const host = ALLOWED_HOSTS[u.hostname];
71
+ if (!host) {
72
+ throw new FetchError(
73
+ `Host not allowed: ${u.hostname}. Allowed: ${Object.keys(ALLOWED_HOSTS).join(", ")}.`,
74
+ );
75
+ }
76
+ const parts = u.pathname.replace(/^\/+/, "").split("/");
77
+ if (parts.length < 2 || !parts[0] || !parts[1]) {
78
+ throw new FetchError(`URL must be https://${u.hostname}/<owner>/<repo>.`);
79
+ }
80
+ return { host, owner: parts[0], repo: parts[1].replace(/\.git$/, "") };
81
+ }
82
+
83
+ function authHeaders(kind: HostKind, token?: string): Record<string, string> {
84
+ if (!token) return {};
85
+ if (kind === "github") return { Authorization: `Bearer ${token}` };
86
+ if (kind === "forgejo") return { Authorization: `token ${token}` };
87
+ return { "PRIVATE-TOKEN": token };
88
+ }
89
+
90
+ function isYaml(name: string): boolean {
91
+ return name.endsWith(".yml") || name.endsWith(".yaml");
92
+ }
93
+
94
+ function timeoutSignal(ms: number): AbortSignal | undefined {
95
+ return typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
96
+ ? AbortSignal.timeout(ms)
97
+ : undefined;
98
+ }
99
+
100
+ /** Gitea/GitHub contents-API entry (directory listing or single file). */
101
+ interface ContentsEntry {
102
+ name: string;
103
+ path: string;
104
+ type: string;
105
+ size?: number;
106
+ content?: string;
107
+ encoding?: string;
108
+ }
109
+
110
+ export async function fetchCiFiles(url: string, opts: FetchOptions = {}): Promise<AuditInput[]> {
111
+ const { host, owner, repo } = parseRepoUrl(url);
112
+ const doFetch = opts.fetchImpl ?? fetch;
113
+ const cfg = {
114
+ maxFiles: opts.maxFiles ?? DEFAULTS.maxFiles,
115
+ maxBytesPerFile: opts.maxBytesPerFile ?? DEFAULTS.maxBytesPerFile,
116
+ maxTotalBytes: opts.maxTotalBytes ?? DEFAULTS.maxTotalBytes,
117
+ timeoutMs: opts.timeoutMs ?? DEFAULTS.timeoutMs,
118
+ };
119
+ const headers = authHeaders(host.kind, opts.token);
120
+
121
+ async function getJson(apiUrl: string): Promise<{ status: number; body: unknown }> {
122
+ let res: Response;
123
+ try {
124
+ res = await doFetch(apiUrl, { headers, redirect: "error", signal: timeoutSignal(cfg.timeoutMs) });
125
+ } catch (err) {
126
+ throw new FetchError(`Request failed: ${err instanceof Error ? err.message : String(err)}`);
127
+ }
128
+ if (res.status >= 300 && res.status < 400) {
129
+ throw new FetchError(`Refusing to follow redirect from ${apiUrl}`);
130
+ }
131
+ if (res.status === 404) return { status: 404, body: null };
132
+ if (!res.ok) throw new FetchError(`${apiUrl} returned ${res.status}`);
133
+ return { status: res.status, body: await res.json() };
134
+ }
135
+
136
+ if (host.kind === "gitlab") {
137
+ return fetchGitlab(host, owner, repo, opts.ref, cfg, doFetch, headers);
138
+ }
139
+
140
+ // GitHub / Forgejo share the Gitea-style contents API.
141
+ const dirs = host.kind === "forgejo" ? [".forgejo/workflows", ".github/workflows"] : [".github/workflows"];
142
+ const ref = opts.ref ? `?ref=${encodeURIComponent(opts.ref)}` : "";
143
+
144
+ const candidates: ContentsEntry[] = [];
145
+ const seen = new Set<string>();
146
+ for (const dir of dirs) {
147
+ const { body } = await getJson(`${host.api}/repos/${owner}/${repo}/contents/${dir}${ref}`);
148
+ if (!Array.isArray(body)) continue;
149
+ for (const entry of body as ContentsEntry[]) {
150
+ if (entry.type === "file" && isYaml(entry.name) && !seen.has(entry.path)) {
151
+ seen.add(entry.path);
152
+ candidates.push(entry);
153
+ }
154
+ }
155
+ }
156
+
157
+ const inputs: AuditInput[] = [];
158
+ let total = 0;
159
+ for (const entry of candidates) {
160
+ if (inputs.length >= cfg.maxFiles) break;
161
+ if ((entry.size ?? 0) > cfg.maxBytesPerFile) continue; // skip oversize
162
+ const { body } = await getJson(`${host.api}/repos/${owner}/${repo}/contents/${entry.path}${ref}`);
163
+ const file = body as ContentsEntry | null;
164
+ if (!file?.content) continue;
165
+ const content = Buffer.from(file.content, (file.encoding as BufferEncoding) ?? "base64").toString("utf-8");
166
+ if (content.length > cfg.maxBytesPerFile) continue;
167
+ total += content.length;
168
+ if (total > cfg.maxTotalBytes) throw new FetchError("Repository CI files exceed the total size cap.");
169
+ inputs.push({ path: entry.path, content, lexicon: host.lexicon });
170
+ }
171
+ return inputs;
172
+ }
173
+
174
+ const SHA40 = /^[0-9a-f]{40}$/;
175
+
176
+ /**
177
+ * Resolve an action ref (e.g. action="actions/checkout", ref="v4") to its
178
+ * commit SHA via the GitHub API. Returns undefined on any failure — pinning
179
+ * degrades gracefully to guidance. Actions are GitHub-hosted slugs, so this
180
+ * queries api.github.com regardless of the audited repo's host.
181
+ */
182
+ export async function resolveActionSha(
183
+ action: string,
184
+ ref: string,
185
+ opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number } = {},
186
+ ): Promise<string | undefined> {
187
+ const parts = action.split("/");
188
+ if (parts.length < 2 || !parts[0] || !parts[1]) return undefined;
189
+ const [owner, repo] = parts;
190
+ const doFetch = opts.fetchImpl ?? fetch;
191
+ const url = `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
192
+ try {
193
+ const res = await doFetch(url, {
194
+ headers: {
195
+ Accept: "application/vnd.github+json",
196
+ ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
197
+ },
198
+ redirect: "error",
199
+ signal: timeoutSignal(opts.timeoutMs ?? DEFAULTS.timeoutMs),
200
+ });
201
+ if (!res.ok) return undefined;
202
+ const body = (await res.json()) as { sha?: string };
203
+ return body.sha && SHA40.test(body.sha) ? body.sha : undefined;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
210
+
211
+ /** Public registries we'll talk to. The image ref is untrusted (SSRF guard). */
212
+ const ALLOWED_REGISTRIES = new Set([
213
+ "registry-1.docker.io",
214
+ "ghcr.io",
215
+ "quay.io",
216
+ "gcr.io",
217
+ "public.ecr.aws",
218
+ "mcr.microsoft.com",
219
+ "registry.gitlab.com",
220
+ ]);
221
+
222
+ interface ImageRef {
223
+ registry: string;
224
+ repository: string;
225
+ tag: string;
226
+ }
227
+
228
+ /** Parse a Docker/OCI image reference. Returns undefined if already digested. */
229
+ export function parseImageRef(ref: string): ImageRef | undefined {
230
+ if (ref.includes("@")) return undefined; // already digest-pinned
231
+ let registry = "registry-1.docker.io";
232
+ let rest = ref;
233
+ let repoPrefix = "";
234
+ const firstSlash = ref.indexOf("/");
235
+ const firstPart = firstSlash === -1 ? "" : ref.slice(0, firstSlash);
236
+ if (firstPart && (firstPart.includes(".") || firstPart.includes(":") || firstPart === "localhost")) {
237
+ registry = firstPart;
238
+ rest = ref.slice(firstSlash + 1);
239
+ } else if (firstSlash === -1) {
240
+ repoPrefix = "library/"; // bare Docker Hub official image
241
+ }
242
+ let tag = "latest";
243
+ const lastColon = rest.lastIndexOf(":");
244
+ const lastSlash = rest.lastIndexOf("/");
245
+ if (lastColon > lastSlash) {
246
+ tag = rest.slice(lastColon + 1);
247
+ rest = rest.slice(0, lastColon);
248
+ }
249
+ if (!rest) return undefined;
250
+ return { registry, repository: repoPrefix + rest, tag };
251
+ }
252
+
253
+ /** Parse a `Bearer realm=...,service=...,scope=...` challenge into a token URL. */
254
+ function tokenUrlFromChallenge(header: string): string | undefined {
255
+ const m = /Bearer\s+(.*)/i.exec(header);
256
+ if (!m) return undefined;
257
+ const params: Record<string, string> = {};
258
+ for (const part of m[1].split(",")) {
259
+ const kv = /(\w+)="([^"]*)"/.exec(part.trim());
260
+ if (kv) params[kv[1]] = kv[2];
261
+ }
262
+ if (!params.realm) return undefined;
263
+ const url = new URL(params.realm);
264
+ if (params.service) url.searchParams.set("service", params.service);
265
+ if (params.scope) url.searchParams.set("scope", params.scope);
266
+ return url.toString();
267
+ }
268
+
269
+ /**
270
+ * Resolve a container image `name:tag` to its `sha256:...` digest via the OCI
271
+ * registry v2 API (anonymous bearer-token challenge). Returns undefined on any
272
+ * failure or for a non-allowlisted registry. The image ref is untrusted, so we
273
+ * only ever contact allowlisted public registries (SSRF guard).
274
+ */
275
+ export async function resolveImageDigest(
276
+ image: string,
277
+ opts: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
278
+ ): Promise<string | undefined> {
279
+ const parsed = parseImageRef(image);
280
+ if (!parsed || !ALLOWED_REGISTRIES.has(parsed.registry)) return undefined;
281
+ const doFetch = opts.fetchImpl ?? fetch;
282
+ const ms = opts.timeoutMs ?? DEFAULTS.timeoutMs;
283
+ const accept = [
284
+ "application/vnd.oci.image.index.v1+json",
285
+ "application/vnd.oci.image.manifest.v1+json",
286
+ "application/vnd.docker.distribution.manifest.list.v2+json",
287
+ "application/vnd.docker.distribution.manifest.v2+json",
288
+ ].join(", ");
289
+ const manifestUrl = `https://${parsed.registry}/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}`;
290
+
291
+ try {
292
+ let res = await doFetch(manifestUrl, { headers: { Accept: accept }, redirect: "error", signal: timeoutSignal(ms) });
293
+ if (res.status === 401) {
294
+ const tokenUrl = tokenUrlFromChallenge(res.headers.get("www-authenticate") ?? "");
295
+ if (!tokenUrl) return undefined;
296
+ const tokRes = await doFetch(tokenUrl, { redirect: "error", signal: timeoutSignal(ms) });
297
+ if (!tokRes.ok) return undefined;
298
+ const tok = (await tokRes.json()) as { token?: string; access_token?: string };
299
+ const bearer = tok.token ?? tok.access_token;
300
+ if (!bearer) return undefined;
301
+ res = await doFetch(manifestUrl, { headers: { Accept: accept, Authorization: `Bearer ${bearer}` }, redirect: "error", signal: timeoutSignal(ms) });
302
+ }
303
+ if (!res.ok) return undefined;
304
+ const digest = res.headers.get("docker-content-digest");
305
+ return digest && DIGEST_RE.test(digest) ? digest : undefined;
306
+ } catch {
307
+ return undefined;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Resolve the audited repo's current commit SHA (best-effort) for the report
313
+ * snapshot, so findings are anchored to an exact commit. Returns undefined on
314
+ * any failure.
315
+ */
316
+ export async function resolveRepoCommit(
317
+ url: string,
318
+ opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number } = {},
319
+ ): Promise<string | undefined> {
320
+ let parsed: ParsedRepo;
321
+ try {
322
+ parsed = parseRepoUrl(url);
323
+ } catch {
324
+ return undefined;
325
+ }
326
+ const { host, owner, repo } = parsed;
327
+ const doFetch = opts.fetchImpl ?? fetch;
328
+ const headers = authHeaders(host.kind, opts.token);
329
+ const apiUrl =
330
+ host.kind === "gitlab"
331
+ ? `${host.api}/projects/${encodeURIComponent(`${owner}/${repo}`)}/repository/commits?per_page=1`
332
+ : `${host.api}/repos/${owner}/${repo}/commits?per_page=1&limit=1`;
333
+ try {
334
+ const res = await doFetch(apiUrl, { headers, redirect: "error", signal: timeoutSignal(opts.timeoutMs ?? DEFAULTS.timeoutMs) });
335
+ if (!res.ok) return undefined;
336
+ const body = (await res.json()) as Array<{ sha?: string; id?: string }>;
337
+ if (!Array.isArray(body) || body.length === 0) return undefined;
338
+ const sha = body[0].sha ?? body[0].id;
339
+ return typeof sha === "string" ? sha : undefined;
340
+ } catch {
341
+ return undefined;
342
+ }
343
+ }
344
+
345
+ async function fetchGitlab(
346
+ host: HostConfig,
347
+ owner: string,
348
+ repo: string,
349
+ ref: string | undefined,
350
+ cfg: { maxBytesPerFile: number; maxTotalBytes: number; timeoutMs: number },
351
+ doFetch: typeof fetch,
352
+ headers: Record<string, string>,
353
+ ): Promise<AuditInput[]> {
354
+ const projectId = encodeURIComponent(`${owner}/${repo}`);
355
+ const refQ = `?ref=${encodeURIComponent(ref ?? "HEAD")}`;
356
+ const apiUrl = `${host.api}/projects/${projectId}/repository/files/${encodeURIComponent(".gitlab-ci.yml")}/raw${refQ}`;
357
+ let res: Response;
358
+ try {
359
+ res = await doFetch(apiUrl, { headers, redirect: "error", signal: timeoutSignal(cfg.timeoutMs) });
360
+ } catch (err) {
361
+ throw new FetchError(`Request failed: ${err instanceof Error ? err.message : String(err)}`);
362
+ }
363
+ if (res.status >= 300 && res.status < 400) throw new FetchError(`Refusing to follow redirect from ${apiUrl}`);
364
+ if (res.status === 404) return [];
365
+ if (!res.ok) throw new FetchError(`${apiUrl} returned ${res.status}`);
366
+ const content = await res.text();
367
+ if (content.length > cfg.maxBytesPerFile || content.length > cfg.maxTotalBytes) {
368
+ throw new FetchError("Pipeline file exceeds the size cap.");
369
+ }
370
+ return [{ path: ".gitlab-ci.yml", content, lexicon: "gitlab" }];
371
+ }