@intentius/chant 0.8.0 → 0.8.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.
@@ -1 +1 @@
1
- {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/audit/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,6DAA6D;IAC7D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AASD,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEhD,UAAU,UAAU;IAClB,IAAI,EAAE,QAAQ,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,YAAY,CAAC;CACvB;AAQD,qBAAa,UAAW,SAAQ,KAAK;CAAG;AAExC,UAAU,UAAU;IAClB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAqBpD;AAmHD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CA6B5H;AAID;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1E,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAqB7B;AAeD,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAsB/D;AAkBD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EACb,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1D,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1E,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAwB7B"}
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/audit/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,6DAA6D;IAC7D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AASD,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEhD,UAAU,UAAU;IAClB,IAAI,EAAE,QAAQ,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,YAAY,CAAC;CACvB;AAQD,qBAAa,UAAW,SAAQ,KAAK;CAAG;AAExC,UAAU,UAAU;IAClB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAqBpD;AAuKD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CA6B5H;AAID;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1E,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAsB7B;AAeD,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAsB/D;AAkBD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EACb,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1D,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1E,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAwB7B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -35,6 +35,13 @@ function gitTreeMock(files: Record<string, string>, sizes: Record<string, number
35
35
  const tree = Object.keys(files).map((path) => ({ path, type: "blob", size: sizes[path] ?? files[path].length }));
36
36
  return new Response(JSON.stringify({ tree }), { status: 200 });
37
37
  }
38
+ // GitHub content comes from the raw CDN first (plain text, no base64).
39
+ const rawm = u.match(/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/[^/]+\/(.+)$/);
40
+ if (rawm) {
41
+ const path = decodeURIComponent(rawm[1]);
42
+ if (files[path] === undefined) return new Response("not found", { status: 404 });
43
+ return new Response(files[path], { status: 200 });
44
+ }
38
45
  const cm = u.match(/\/contents\/(.+?)\?/);
39
46
  if (cm) {
40
47
  const path = decodeURIComponent(cm[1]);
@@ -99,6 +106,49 @@ describe("fetchRepoFiles", () => {
99
106
  expect(auth).toContain("Bearer secret");
100
107
  });
101
108
 
109
+ test("always sends a User-Agent, even without a token (GitHub 403s otherwise)", async () => {
110
+ const { impl, calls } = gitTreeMock({ ".github/workflows/ci.yml": CI_YAML });
111
+ await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: impl });
112
+ const uas = calls.map((c) => (c.init?.headers as Record<string, string>)?.["User-Agent"]);
113
+ expect(uas.every((ua) => typeof ua === "string" && ua.length > 0)).toBe(true);
114
+ });
115
+
116
+ test("github: reads content from the raw CDN (no contents-API burst)", async () => {
117
+ const { impl, calls } = gitTreeMock({ ".github/workflows/ci.yml": CI_YAML });
118
+ const files = await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: impl });
119
+ expect(files[0].content).toContain("permissions: write-all");
120
+ // Content came from raw.githubusercontent.com — the contents API was not hit.
121
+ expect(calls.some((c) => c.url.includes("raw.githubusercontent.com"))).toBe(true);
122
+ expect(calls.some((c) => c.url.includes("/contents/"))).toBe(false);
123
+ });
124
+
125
+ test("does not send the auth token cross-host to the raw CDN", async () => {
126
+ const { impl, calls } = gitTreeMock({ ".github/workflows/ci.yml": CI_YAML });
127
+ await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: impl, token: "secret" });
128
+ const rawCall = calls.find((c) => c.url.includes("raw.githubusercontent.com"));
129
+ expect((rawCall?.init?.headers as Record<string, string>)?.Authorization).toBeUndefined();
130
+ });
131
+
132
+ test("encodes path segments with spaces (would 403 unencoded)", async () => {
133
+ const spaced = ".changes/v1.16/NEW FEATURES.yaml";
134
+ const { impl, calls } = gitTreeMock({ [spaced]: "kind: Changelog\n" });
135
+ const files = await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: impl });
136
+ expect(files.map((f) => f.path)).toContain(spaced);
137
+ expect(calls.some((c) => c.url.includes("NEW%20FEATURES.yaml"))).toBe(true);
138
+ });
139
+
140
+ test("a single file's failure does not abort the whole walk", async () => {
141
+ // raw 404s for the bad path → contents API also 404s → that file is skipped,
142
+ // not fatal; the good file still comes back.
143
+ const { impl } = gitTreeMock({ ".github/workflows/ci.yml": CI_YAML, "k8s/deploy.yaml": "kind: Deployment\n" });
144
+ const broken = (async (url: string | URL | Request, init?: RequestInit) => {
145
+ if (String(url).includes("deploy.yaml")) return new Response("forbidden", { status: 403 });
146
+ return impl(url, init);
147
+ }) as unknown as typeof fetch;
148
+ const files = await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: broken });
149
+ expect(files.map((f) => f.path)).toEqual([".github/workflows/ci.yml"]);
150
+ });
151
+
102
152
  test("skips a file over the per-file size cap (reported by the tree)", async () => {
103
153
  const { impl } = gitTreeMock({ ".github/workflows/ci.yml": CI_YAML }, { ".github/workflows/ci.yml": 10_000_000 });
104
154
  const files = await fetchRepoFiles("https://github.com/acme/widgets", { fetchImpl: impl, maxBytesPerFile: 1024 });
@@ -80,11 +80,16 @@ export function parseRepoUrl(url: string): ParsedRepo {
80
80
  return { host, owner: parts[0], repo: parts[1].replace(/\.git$/, "") };
81
81
  }
82
82
 
83
+ /** GitHub (and good manners) require a User-Agent — workerd's fetch sends none,
84
+ * so a missing UA 403s every request. Always set one, with or without a token. */
85
+ const USER_AGENT = "chant-audit (+https://github.com/intentius/chant)";
86
+
83
87
  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
+ const base = { "User-Agent": USER_AGENT };
89
+ if (!token) return base;
90
+ if (kind === "github") return { ...base, Authorization: `Bearer ${token}` };
91
+ if (kind === "forgejo") return { ...base, Authorization: `token ${token}` };
92
+ return { ...base, "PRIVATE-TOKEN": token };
88
93
  }
89
94
 
90
95
  function timeoutSignal(ms: number): AbortSignal | undefined {
@@ -173,22 +178,69 @@ async function listTree(host: HostConfig, owner: string, repo: string, ref: stri
173
178
  return tree.filter((e) => e.type === "blob").map((e) => ({ path: e.path, type: "blob", size: e.size }));
174
179
  }
175
180
 
176
- /** Fetch one file's content per host (base64 contents API, or GitLab raw). */
181
+ /**
182
+ * Resilient GET → text. Returns undefined on any error or non-ok status, so one
183
+ * file's failure (a bad path, a 403 secondary rate-limit, a timeout) never
184
+ * aborts the whole walk. `redirect: "manual"` keeps the SSRF posture — a 3xx is
185
+ * skipped, never followed.
186
+ */
187
+ async function fetchText(url: string, headers: Record<string, string>, doFetch: typeof fetch, ms: number): Promise<string | undefined> {
188
+ let res: Response;
189
+ try {
190
+ res = await doFetch(url, { headers, redirect: "manual", signal: timeoutSignal(ms) });
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ if (!res.ok) return undefined;
195
+ try {
196
+ return await res.text();
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Fetch one file's content. GitHub reads from the raw CDN first — a burst of
204
+ * contents-API calls trips GitHub's *secondary* rate limit on large repos
205
+ * (dozens of files), whereas raw.githubusercontent.com isn't subject to it and
206
+ * needs no auth for public repos. The contents API is the fallback for private
207
+ * repos (raw 404s there without auth). GitLab uses its raw endpoint; Forgejo
208
+ * uses the contents API.
209
+ */
177
210
  async function fetchFileContent(host: HostConfig, owner: string, repo: string, path: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<string | undefined> {
211
+ // Encode each segment (keep the slashes) — paths can contain spaces and other
212
+ // characters that make an unencoded URL malformed and 403 (e.g. GitHub's
213
+ // changelog fragments like ".changes/v1.16/NEW FEATURES-….yaml").
214
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
215
+
178
216
  if (host.kind === "gitlab") {
179
217
  const url = `${host.api}/projects/${projectId(owner, repo)}/repository/files/${encodeURIComponent(path)}/raw?ref=${encodeURIComponent(ref)}`;
180
- let res: Response;
181
- try {
182
- res = await doFetch(url, { headers, redirect: "manual", signal: timeoutSignal(ms) });
183
- } catch {
184
- return undefined;
185
- }
186
- if (!res.ok) return undefined;
187
- return await res.text();
218
+ return fetchText(url, headers, doFetch, ms);
219
+ }
220
+
221
+ if (host.kind === "github") {
222
+ // No token sent cross-host to the CDN; public repos need none.
223
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/${encodedPath}`;
224
+ const raw = await fetchText(rawUrl, { "User-Agent": USER_AGENT }, doFetch, ms);
225
+ if (raw !== undefined) return raw;
226
+ // Private repo (raw 404s without auth) — fall through to the contents API.
227
+ }
228
+
229
+ // Contents API: base64 JSON. Forgejo always; GitHub private-repo fallback.
230
+ const url = `${host.api}/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`;
231
+ let res: Response;
232
+ try {
233
+ res = await doFetch(url, { headers, redirect: "manual", signal: timeoutSignal(ms) });
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ if (!res.ok) return undefined;
238
+ let file: ContentsEntry | null;
239
+ try {
240
+ file = (await res.json()) as ContentsEntry | null;
241
+ } catch {
242
+ return undefined;
188
243
  }
189
- const url = `${host.api}/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`;
190
- const { body } = await getJsonAt(url, headers, doFetch, ms);
191
- const file = body as ContentsEntry | null;
192
244
  if (!file?.content) return undefined;
193
245
  return Buffer.from(file.content, (file.encoding as BufferEncoding) ?? "base64").toString("utf-8");
194
246
  }
@@ -251,6 +303,7 @@ export async function resolveActionSha(
251
303
  const res = await doFetch(url, {
252
304
  headers: {
253
305
  Accept: "application/vnd.github+json",
306
+ "User-Agent": USER_AGENT,
254
307
  ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
255
308
  },
256
309
  redirect: "manual",
@@ -347,16 +400,16 @@ export async function resolveImageDigest(
347
400
  const manifestUrl = `https://${parsed.registry}/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}`;
348
401
 
349
402
  try {
350
- let res = await doFetch(manifestUrl, { headers: { Accept: accept }, redirect: "manual", signal: timeoutSignal(ms) });
403
+ let res = await doFetch(manifestUrl, { headers: { Accept: accept, "User-Agent": USER_AGENT }, redirect: "manual", signal: timeoutSignal(ms) });
351
404
  if (res.status === 401) {
352
405
  const tokenUrl = tokenUrlFromChallenge(res.headers.get("www-authenticate") ?? "");
353
406
  if (!tokenUrl) return undefined;
354
- const tokRes = await doFetch(tokenUrl, { redirect: "manual", signal: timeoutSignal(ms) });
407
+ const tokRes = await doFetch(tokenUrl, { headers: { "User-Agent": USER_AGENT }, redirect: "manual", signal: timeoutSignal(ms) });
355
408
  if (!tokRes.ok) return undefined;
356
409
  const tok = (await tokRes.json()) as { token?: string; access_token?: string };
357
410
  const bearer = tok.token ?? tok.access_token;
358
411
  if (!bearer) return undefined;
359
- res = await doFetch(manifestUrl, { headers: { Accept: accept, Authorization: `Bearer ${bearer}` }, redirect: "manual", signal: timeoutSignal(ms) });
412
+ res = await doFetch(manifestUrl, { headers: { Accept: accept, "User-Agent": USER_AGENT, Authorization: `Bearer ${bearer}` }, redirect: "manual", signal: timeoutSignal(ms) });
360
413
  }
361
414
  if (!res.ok) return undefined;
362
415
  const digest = res.headers.get("docker-content-digest");