@intentius/chant 0.13.0 → 0.13.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.
- package/dist/audit/fetch.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/fetch.test.ts +112 -8
- package/src/audit/fetch.ts +53 -8
|
@@ -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;
|
|
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;AAoND;;;;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
package/src/audit/fetch.test.ts
CHANGED
|
@@ -54,14 +54,51 @@ function gitTreeMock(files: Record<string, string>, sizes: Record<string, number
|
|
|
54
54
|
return { impl, calls };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* GitLab mock for authenticated search-based discovery. `searchMap` maps search
|
|
59
|
+
* term → array of blob paths the search API returns. `files` maps path → content.
|
|
60
|
+
* Pass `token` to fetchRepoFiles when using this mock so the code takes the
|
|
61
|
+
* search path (unauthenticated calls fall back to BFS).
|
|
62
|
+
*/
|
|
63
|
+
function gitlabMock(searchMap: Record<string, string[]>, files: Record<string, string>) {
|
|
64
|
+
const impl = (async (url: string | URL | Request) => {
|
|
65
|
+
const u = String(url);
|
|
66
|
+
if (u.includes("/search")) {
|
|
67
|
+
const params = new URL(u).searchParams;
|
|
68
|
+
const term = params.get("search") ?? "";
|
|
69
|
+
const page = Number(params.get("page") ?? "1");
|
|
70
|
+
const paths = page === 1 ? (searchMap[term] ?? []) : [];
|
|
71
|
+
return new Response(JSON.stringify(paths.map((p) => ({ path: p }))), { status: 200 });
|
|
72
|
+
}
|
|
73
|
+
const rm = u.match(/\/repository\/files\/(.+?)\/raw\?/);
|
|
74
|
+
if (rm) {
|
|
75
|
+
const path = decodeURIComponent(rm[1]);
|
|
76
|
+
if (files[path] === undefined) return new Response("not found", { status: 404 });
|
|
77
|
+
return new Response(files[path], { status: 200 });
|
|
78
|
+
}
|
|
79
|
+
if (/\/projects\/[^/]+(\?|$)/.test(u)) return new Response(JSON.stringify({ default_branch: "main" }), { status: 200 });
|
|
80
|
+
return new Response("not found", { status: 404 });
|
|
81
|
+
}) as unknown as typeof fetch;
|
|
82
|
+
return { impl };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* GitLab mock for the unauthenticated BFS fallback. `dirEntries` maps directory
|
|
87
|
+
* path (empty = root) to entries returned by the non-recursive tree API. `files`
|
|
88
|
+
* maps path → content for the raw endpoint.
|
|
89
|
+
*/
|
|
90
|
+
function gitlabBfsMock(
|
|
91
|
+
dirEntries: Record<string, Array<{ path: string; type: "blob" | "tree" }>>,
|
|
92
|
+
files: Record<string, string>,
|
|
93
|
+
) {
|
|
59
94
|
const impl = (async (url: string | URL | Request) => {
|
|
60
95
|
const u = String(url);
|
|
61
96
|
if (u.includes("/repository/tree")) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
97
|
+
const params = new URL(u).searchParams;
|
|
98
|
+
const dir = params.get("path") ?? "";
|
|
99
|
+
const page = Number(params.get("page") ?? "1");
|
|
100
|
+
const entries = dirEntries[dir] ?? [];
|
|
101
|
+
return new Response(JSON.stringify(page === 1 ? entries : []), { status: 200 });
|
|
65
102
|
}
|
|
66
103
|
const rm = u.match(/\/repository\/files\/(.+?)\/raw\?/);
|
|
67
104
|
if (rm) {
|
|
@@ -174,13 +211,80 @@ describe("fetchRepoFiles", () => {
|
|
|
174
211
|
await expect(fetchRepoFiles("https://evil.example.com/o/r")).rejects.toThrow(/Host not allowed/);
|
|
175
212
|
});
|
|
176
213
|
|
|
177
|
-
test("gitlab:
|
|
178
|
-
const { impl } = gitlabMock(
|
|
179
|
-
|
|
214
|
+
test("gitlab: authenticated search finds .gitlab-ci.yml via stages: term", async () => {
|
|
215
|
+
const { impl } = gitlabMock(
|
|
216
|
+
{ "stages:": [".gitlab-ci.yml"] },
|
|
217
|
+
{ ".gitlab-ci.yml": "stages:\n - build\n" },
|
|
218
|
+
);
|
|
219
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl, token: "tok" });
|
|
180
220
|
expect(files.map((f) => f.path)).toEqual([".gitlab-ci.yml"]);
|
|
181
221
|
expect(files[0].content).toContain("stages:");
|
|
182
222
|
});
|
|
183
223
|
|
|
224
|
+
test("gitlab: search finds CI at non-canonical paths (include targets) (#520)", async () => {
|
|
225
|
+
// A .gitlab-ci.yml with `include:` referencing other CI files — the included
|
|
226
|
+
// file also has `stages:` so the search finds it without knowing the path.
|
|
227
|
+
const { impl } = gitlabMock(
|
|
228
|
+
{ "stages:": [".gitlab-ci.yml", ".gitlab/ci/build.gitlab-ci.yml"] },
|
|
229
|
+
{
|
|
230
|
+
".gitlab-ci.yml": "stages:\n - build\ninclude:\n - .gitlab/ci/build.gitlab-ci.yml\n",
|
|
231
|
+
".gitlab/ci/build.gitlab-ci.yml": "stages:\n - build\nscript:\n - make\n",
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl, token: "tok" });
|
|
235
|
+
expect(files.map((f) => f.path)).toContain(".gitlab/ci/build.gitlab-ci.yml");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("gitlab: search finds IaC files by content, unaffected by repo size (#518)", async () => {
|
|
239
|
+
// Previously, gitlab-org/gitlab returned 0 files because the recursive tree
|
|
240
|
+
// API listed thousands of directories before any blobs. Search-based discovery
|
|
241
|
+
// is independent of directory count — it goes directly to content.
|
|
242
|
+
const { impl } = gitlabMock(
|
|
243
|
+
{
|
|
244
|
+
"AWSTemplateFormatVersion": ["infra/stack.json"],
|
|
245
|
+
"stages:": [".gitlab-ci.yml"],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"infra/stack.json": '{"AWSTemplateFormatVersion":"2010-09-09","Resources":{}}',
|
|
249
|
+
".gitlab-ci.yml": "stages:\n - deploy\n",
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/monorepo", { fetchImpl: impl, token: "tok" });
|
|
253
|
+
const paths = files.map((f) => f.path);
|
|
254
|
+
expect(paths).toContain(".gitlab-ci.yml");
|
|
255
|
+
expect(paths).toContain("infra/stack.json");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("gitlab: search deduplicates paths found by multiple terms", async () => {
|
|
259
|
+
// A Helm Chart.yaml matches both "apiVersion: v2" and the broader "apiVersion".
|
|
260
|
+
const { impl } = gitlabMock(
|
|
261
|
+
{
|
|
262
|
+
"apiVersion: v2": ["helm/Chart.yaml"],
|
|
263
|
+
"apiVersion": ["helm/Chart.yaml", "k8s/deploy.yaml"],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"helm/Chart.yaml": "apiVersion: v2\nname: myapp\nversion: 1.0.0\n",
|
|
267
|
+
"k8s/deploy.yaml": "apiVersion: apps/v1\nkind: Deployment\n",
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl, token: "tok" });
|
|
271
|
+
const paths = files.map((f) => f.path);
|
|
272
|
+
expect(paths.filter((p) => p === "helm/Chart.yaml")).toHaveLength(1);
|
|
273
|
+
expect(paths).toContain("k8s/deploy.yaml");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("gitlab: unauthenticated falls back to BFS tree walk", async () => {
|
|
277
|
+
// Without a token, GitLab search returns 401. The BFS fallback walks
|
|
278
|
+
// directories non-recursively so root blobs appear on the first request.
|
|
279
|
+
const { impl } = gitlabBfsMock(
|
|
280
|
+
{ "": [{ path: ".gitlab-ci.yml", type: "blob" }] },
|
|
281
|
+
{ ".gitlab-ci.yml": "stages:\n - build\n" },
|
|
282
|
+
);
|
|
283
|
+
// No token → BFS path
|
|
284
|
+
const files = await fetchRepoFiles("https://gitlab.com/acme/widgets", { fetchImpl: impl });
|
|
285
|
+
expect(files.map((f) => f.path)).toContain(".gitlab-ci.yml");
|
|
286
|
+
});
|
|
287
|
+
|
|
184
288
|
test("forgejo (codeberg): gitea tree + contents", async () => {
|
|
185
289
|
const { impl } = gitTreeMock({ ".forgejo/workflows/ci.yml": CI_YAML });
|
|
186
290
|
const files = await fetchRepoFiles("https://codeberg.org/acme/widgets", { fetchImpl: impl });
|
package/src/audit/fetch.ts
CHANGED
|
@@ -157,16 +157,61 @@ interface TreeEntry {
|
|
|
157
157
|
/** List every blob path in the repo (recursive), with size where the host reports it. */
|
|
158
158
|
async function listTree(host: HostConfig, owner: string, repo: string, ref: string, doFetch: typeof fetch, headers: Record<string, string>, ms: number): Promise<TreeEntry[]> {
|
|
159
159
|
if (host.kind === "gitlab") {
|
|
160
|
+
// GitLab search API requires authentication even for public projects. When a
|
|
161
|
+
// token is present, use content search — one query per lexicon finds only
|
|
162
|
+
// relevant files regardless of repo size, and catches non-canonical CI paths
|
|
163
|
+
// that path-based detection misses (#518, #520). Without a token, fall back
|
|
164
|
+
// to the non-recursive BFS which works for public repos.
|
|
165
|
+
if ("PRIVATE-TOKEN" in headers) {
|
|
166
|
+
const out: TreeEntry[] = [];
|
|
167
|
+
const seen = new Set<string>();
|
|
168
|
+
const addPath = (p: string) => { if (typeof p === "string" && !seen.has(p)) { seen.add(p); out.push({ path: p, type: "blob" }); } };
|
|
169
|
+
// Ordered most → least selective so the seen-set dedup reduces noise from
|
|
170
|
+
// broader terms (e.g. `apiVersion` won't re-add files already found by
|
|
171
|
+
// the more specific `apiVersion: v2`).
|
|
172
|
+
const SEARCH_TERMS = [
|
|
173
|
+
"AWSTemplateFormatVersion", // CloudFormation
|
|
174
|
+
"deploymentTemplate", // Azure ARM ($schema substring)
|
|
175
|
+
"cnrm.cloud.google.com", // GCP Config Connector
|
|
176
|
+
"apiVersion: v2", // Helm Chart.yaml
|
|
177
|
+
"stages:", // GitLab CI — root file + non-canonical includes (#520)
|
|
178
|
+
"FROM ", // Dockerfiles (content-based; space avoids false matches)
|
|
179
|
+
"services:", // Docker Compose
|
|
180
|
+
"apiVersion", // k8s manifests (broad; runs last)
|
|
181
|
+
];
|
|
182
|
+
for (const term of SEARCH_TERMS) {
|
|
183
|
+
for (let page = 1; page <= 3; page++) {
|
|
184
|
+
const url = `${host.api}/projects/${projectId(owner, repo)}/search?scope=blobs&search=${encodeURIComponent(term)}&per_page=100&page=${page}&ref=${encodeURIComponent(ref)}`;
|
|
185
|
+
const { body } = await getJsonAt(url, headers, doFetch, ms);
|
|
186
|
+
if (!Array.isArray(body) || body.length === 0) break;
|
|
187
|
+
for (const e of body as Array<{ path: string }>) addPath(e.path);
|
|
188
|
+
if (body.length < 100) break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
// Unauthenticated fallback: non-recursive BFS. GitLab's recursive tree API
|
|
194
|
+
// lists ALL directories before any blobs for large repos (#518), so we walk
|
|
195
|
+
// directories breadth-first to ensure root blobs appear on the first request.
|
|
160
196
|
const out: TreeEntry[] = [];
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
197
|
+
const queue: string[] = [""]; // "" = repo root
|
|
198
|
+
const MAX_DIRS = 30;
|
|
199
|
+
const MAX_BLOBS = 200;
|
|
200
|
+
let dirs = 0;
|
|
201
|
+
while (queue.length > 0 && dirs < MAX_DIRS && out.length < MAX_BLOBS) {
|
|
202
|
+
const dir = queue.shift()!;
|
|
203
|
+
dirs++;
|
|
204
|
+
const pathParam = dir ? `&path=${encodeURIComponent(dir)}` : "";
|
|
205
|
+
for (let page = 1; page <= 5; page++) {
|
|
206
|
+
const url = `${host.api}/projects/${projectId(owner, repo)}/repository/tree?per_page=100&page=${page}&ref=${encodeURIComponent(ref)}${pathParam}`;
|
|
207
|
+
const { body } = await getJsonAt(url, headers, doFetch, ms);
|
|
208
|
+
if (!Array.isArray(body) || body.length === 0) break;
|
|
209
|
+
for (const e of body as Array<{ path: string; type: string }>) {
|
|
210
|
+
if (e.type === "blob") out.push({ path: e.path, type: "blob" });
|
|
211
|
+
else if (e.type === "tree") queue.push(e.path);
|
|
212
|
+
}
|
|
213
|
+
if (body.length < 100) break;
|
|
168
214
|
}
|
|
169
|
-
if (body.length < 100) break;
|
|
170
215
|
}
|
|
171
216
|
return out;
|
|
172
217
|
}
|