@cldmv/gh-broker 0.8.0 → 0.10.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 +48 -13
- package/config.example.json +86 -3
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -18
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +100 -41
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +5171 -1686
- package/dist/index.mjs.map +4 -4
- package/package.json +14 -3
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { reportSecurityAlerts, REPORT_SECURITY_ALERTS_SCHEMA } from "../src/verbs/report_security_alerts.ts";
|
|
3
|
-
|
|
4
|
-
type Handler = (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>;
|
|
5
|
-
|
|
6
|
-
function octokitWith(handler: Handler): any {
|
|
7
|
-
return { request: vi.fn(handler) };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const status = (code: number, message = "x") => Object.assign(new Error(message), { status: code });
|
|
11
|
-
|
|
12
|
-
const dependabot = (n: number, over: Record<string, unknown> = {}) => ({
|
|
13
|
-
number: n,
|
|
14
|
-
html_url: `https://github.com/CLDMV/r/security/dependabot/${n}`,
|
|
15
|
-
created_at: "2026-10-02T20:10:30Z",
|
|
16
|
-
dependency: { package: { ecosystem: "npm", name: "hono" }, manifest_path: "package-lock.json", scope: "runtime" },
|
|
17
|
-
security_advisory: { ghsa_id: "GHSA-aaaa", cve_id: "CVE-2026-1", summary: "bad thing", severity: "medium" },
|
|
18
|
-
security_vulnerability: { severity: "high", vulnerable_version_range: "< 4.13.7", first_patched_version: { identifier: "4.13.7" } },
|
|
19
|
-
...over
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
const codeql = (n: number, over: Record<string, unknown> = {}) => ({
|
|
23
|
-
number: n,
|
|
24
|
-
html_url: `https://github.com/CLDMV/r/security/code-scanning/${n}`,
|
|
25
|
-
created_at: "2026-10-01T00:00:00Z",
|
|
26
|
-
rule: { id: "js/xss", severity: "error", security_severity_level: "critical", description: "XSS" },
|
|
27
|
-
tool: { name: "CodeQL" },
|
|
28
|
-
most_recent_instance: { location: { path: "src/a.ts", start_line: 12 } },
|
|
29
|
-
...over
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const secret = (n: number, over: Record<string, unknown> = {}) => ({
|
|
33
|
-
number: n,
|
|
34
|
-
html_url: `https://github.com/CLDMV/r/security/secret-scanning/${n}`,
|
|
35
|
-
created_at: "2026-09-30T00:00:00Z",
|
|
36
|
-
secret_type: "github_personal_access_token",
|
|
37
|
-
secret_type_display_name: "GitHub Personal Access Token",
|
|
38
|
-
validity: "active",
|
|
39
|
-
secret: "ghp_SHOULD_NEVER_BE_RETURNED",
|
|
40
|
-
...over
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("reportSecurityAlerts — org-level reads", () => {
|
|
44
|
-
it("reads the three org endpoints with state=open and normalizes each alert type", async () => {
|
|
45
|
-
const octokit = octokitWith(async (route, params) => {
|
|
46
|
-
expect(params).toMatchObject({ owner: "cldmv", state: "open" });
|
|
47
|
-
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
|
|
48
|
-
if (route === "GET /orgs/{owner}/code-scanning/alerts") return { data: [codeql(2, { repository: { full_name: "CLDMV/b" } })], headers: {} };
|
|
49
|
-
if (route === "GET /orgs/{owner}/secret-scanning/alerts") return { data: [secret(3, { repository: { full_name: "CLDMV/c" } })], headers: {} };
|
|
50
|
-
throw new Error(`unexpected route ${route}`);
|
|
51
|
-
});
|
|
52
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
53
|
-
expect(out.scope).toEqual({ dependabot: "org", codeql: "org", secrets: "org" });
|
|
54
|
-
expect(out.scanned).toEqual({});
|
|
55
|
-
expect(octokit.request).toHaveBeenCalledTimes(3);
|
|
56
|
-
expect(out.alerts).toEqual([
|
|
57
|
-
{
|
|
58
|
-
repo: "CLDMV/b",
|
|
59
|
-
type: "codeql",
|
|
60
|
-
number: 2,
|
|
61
|
-
severity: "critical",
|
|
62
|
-
summary: "XSS",
|
|
63
|
-
url: "https://github.com/CLDMV/r/security/code-scanning/2",
|
|
64
|
-
createdAt: "2026-10-01T00:00:00Z",
|
|
65
|
-
rule: "js/xss",
|
|
66
|
-
tool: "CodeQL",
|
|
67
|
-
location: "src/a.ts:12"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
repo: "CLDMV/a",
|
|
71
|
-
type: "dependabot",
|
|
72
|
-
number: 1,
|
|
73
|
-
severity: "high",
|
|
74
|
-
summary: "bad thing",
|
|
75
|
-
url: "https://github.com/CLDMV/r/security/dependabot/1",
|
|
76
|
-
createdAt: "2026-10-02T20:10:30Z",
|
|
77
|
-
package: "hono",
|
|
78
|
-
ecosystem: "npm",
|
|
79
|
-
manifest: "package-lock.json",
|
|
80
|
-
scope: "runtime",
|
|
81
|
-
ghsa: "GHSA-aaaa",
|
|
82
|
-
cve: "CVE-2026-1",
|
|
83
|
-
vulnerableRange: "< 4.13.7",
|
|
84
|
-
patchedVersion: "4.13.7"
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
repo: "CLDMV/c",
|
|
88
|
-
type: "secrets",
|
|
89
|
-
number: 3,
|
|
90
|
-
severity: "open",
|
|
91
|
-
summary: "GitHub Personal Access Token",
|
|
92
|
-
url: "https://github.com/CLDMV/r/security/secret-scanning/3",
|
|
93
|
-
createdAt: "2026-09-30T00:00:00Z",
|
|
94
|
-
secretType: "github_personal_access_token",
|
|
95
|
-
validity: "active"
|
|
96
|
-
}
|
|
97
|
-
]);
|
|
98
|
-
expect(out.counts).toEqual({ dependabot: { high: 1 }, codeql: { critical: 1 }, secrets: { open: 1 } });
|
|
99
|
-
expect(out.truncated).toBeUndefined();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("never returns the secret value", async () => {
|
|
103
|
-
const octokit = octokitWith(async (route) => ({ data: route.includes("secret-scanning") ? [secret(1, { repository: { full_name: "CLDMV/c" } })] : [], headers: {} }));
|
|
104
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["secrets"] });
|
|
105
|
-
expect(JSON.stringify(out)).not.toContain("ghp_SHOULD_NEVER_BE_RETURNED");
|
|
106
|
-
expect(out.alerts[0]).not.toHaveProperty("secret");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("only reads the requested types", async () => {
|
|
110
|
-
const octokit = octokitWith(async () => ({ data: [], headers: {} }));
|
|
111
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"] });
|
|
112
|
-
expect(octokit.request).toHaveBeenCalledTimes(1);
|
|
113
|
-
expect(octokit.request.mock.calls[0]![0]).toBe("GET /orgs/{owner}/dependabot/alerts");
|
|
114
|
-
expect(out.scope).toEqual({ dependabot: "org" });
|
|
115
|
-
expect(out.counts).toEqual({ dependabot: {} });
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("treats an empty types array as all three", async () => {
|
|
119
|
-
const octokit = octokitWith(async () => ({ data: [], headers: {} }));
|
|
120
|
-
await reportSecurityAlerts(octokit, { owner: "cldmv", types: [] });
|
|
121
|
-
expect(octokit.request).toHaveBeenCalledTimes(3);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("follows the Dependabot cursor across pages in org mode", async () => {
|
|
125
|
-
const next = '<https://api.github.com/x?after=CUR>; rel="next"';
|
|
126
|
-
const octokit = octokitWith(async (route, params) => {
|
|
127
|
-
if (route !== "GET /orgs/{owner}/dependabot/alerts") return { data: [], headers: {} };
|
|
128
|
-
if (!params.after) return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: { link: next } };
|
|
129
|
-
return { data: [dependabot(2, { repository: { full_name: "CLDMV/a" } })], headers: {} };
|
|
130
|
-
});
|
|
131
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"] });
|
|
132
|
-
expect(out.alerts.map((a) => a.number)).toEqual([1, 2]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("pages the code-scanning org endpoint with page numbers", async () => {
|
|
136
|
-
const full = Array.from({ length: 100 }, (_, i) => codeql(i + 1, { repository: { full_name: "CLDMV/a" } }));
|
|
137
|
-
const octokit = octokitWith(async (_route, params) => ({ data: params.page === 1 ? full : [codeql(101, { repository: { full_name: "CLDMV/a" } })], headers: {} }));
|
|
138
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["codeql"] });
|
|
139
|
-
expect(out.alerts).toHaveLength(101);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("sorts by severity (critical first), then repo, then number", async () => {
|
|
143
|
-
const octokit = octokitWith(async (route) => {
|
|
144
|
-
if (route !== "GET /orgs/{owner}/dependabot/alerts") return { data: [], headers: {} };
|
|
145
|
-
const sev = (s: string) => ({ security_vulnerability: { severity: s } });
|
|
146
|
-
return {
|
|
147
|
-
data: [
|
|
148
|
-
dependabot(5, { repository: { full_name: "CLDMV/z" }, ...sev("low") }),
|
|
149
|
-
dependabot(9, { repository: { full_name: "CLDMV/b" }, ...sev("high") }),
|
|
150
|
-
dependabot(2, { repository: { full_name: "CLDMV/b" }, ...sev("high") }),
|
|
151
|
-
dependabot(1, { repository: { full_name: "CLDMV/a" }, ...sev("critical") }),
|
|
152
|
-
dependabot(3, { repository: { full_name: "CLDMV/a" }, ...sev("weird") })
|
|
153
|
-
],
|
|
154
|
-
headers: {}
|
|
155
|
-
};
|
|
156
|
-
});
|
|
157
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"] });
|
|
158
|
-
expect(out.alerts.map((a) => `${a.repo}#${a.number}`)).toEqual(["CLDMV/a#1", "CLDMV/b#2", "CLDMV/b#9", "CLDMV/z#5", "CLDMV/a#3"]);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("falls back to unknown severity and omits absent optional fields", async () => {
|
|
162
|
-
const bare = { number: 7, html_url: "u", created_at: "t", repository: { full_name: "CLDMV/a" } };
|
|
163
|
-
const octokit = octokitWith(async (route) => {
|
|
164
|
-
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [bare], headers: {} };
|
|
165
|
-
if (route === "GET /orgs/{owner}/code-scanning/alerts") return { data: [{ ...bare, rule: {}, most_recent_instance: { location: { path: "x.ts" } } }], headers: {} };
|
|
166
|
-
return { data: [{ ...bare }], headers: {} };
|
|
167
|
-
});
|
|
168
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
169
|
-
const dep = out.alerts.find((a) => a.type === "dependabot")!;
|
|
170
|
-
expect(dep).toEqual({ repo: "CLDMV/a", type: "dependabot", number: 7, severity: "unknown", summary: "", url: "u", createdAt: "t" });
|
|
171
|
-
expect(out.alerts.find((a) => a.type === "codeql")).toMatchObject({ severity: "unknown", location: "x.ts" });
|
|
172
|
-
expect(out.alerts.find((a) => a.type === "secrets")).toMatchObject({ severity: "open", summary: "" });
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("leaves the location off a code scanning alert whose instance has no path", async () => {
|
|
176
|
-
const bare = { number: 7, html_url: "u", created_at: "t", repository: { full_name: "CLDMV/a" } };
|
|
177
|
-
const octokit = octokitWith(async (route) => ({
|
|
178
|
-
data: route === "GET /orgs/{owner}/code-scanning/alerts" ? [{ ...bare, most_recent_instance: { location: {} } }, { ...bare, number: 8 }] : [],
|
|
179
|
-
headers: {}
|
|
180
|
-
}));
|
|
181
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
182
|
-
for (const a of out.alerts.filter((x) => x.type === "codeql")) expect(a).not.toHaveProperty("location");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("flags max-alerts when a type fills its alertLimit", async () => {
|
|
186
|
-
const octokit = octokitWith(async (route) => ({ data: route.includes("dependabot") ? [dependabot(1, { repository: { full_name: "CLDMV/a" } }), dependabot(2, { repository: { full_name: "CLDMV/a" } })] : [], headers: {} }));
|
|
187
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", alertLimit: 2 });
|
|
188
|
-
expect(out.truncated).toBe(true);
|
|
189
|
-
expect(out.truncatedReason).toBe("max-alerts");
|
|
190
|
-
expect(out.alerts).toHaveLength(2);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe("reportSecurityAlerts — per-repo fallback", () => {
|
|
195
|
-
const orgDenied = (route: string) => route.startsWith("GET /orgs/{owner}/") && !route.endsWith("/repos");
|
|
196
|
-
|
|
197
|
-
it("falls back to a per-repo sweep for a type whose org endpoint is 403/404, keeping the types that worked", async () => {
|
|
198
|
-
const octokit = octokitWith(async (route, params) => {
|
|
199
|
-
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
|
|
200
|
-
if (route === "GET /orgs/{owner}/code-scanning/alerts") throw status(403, "Resource not accessible by personal access token");
|
|
201
|
-
if (route === "GET /orgs/{owner}/secret-scanning/alerts") throw status(404, "Not Found");
|
|
202
|
-
if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b", archived: true }, { name: "c" }], headers: {} };
|
|
203
|
-
if (route === "GET /repos/{owner}/{repo}/code-scanning/alerts") return { data: params.repo === "a" ? [codeql(4)] : [], headers: {} };
|
|
204
|
-
if (route === "GET /repos/{owner}/{repo}/secret-scanning/alerts") return { data: params.repo === "c" ? [secret(5)] : [], headers: {} };
|
|
205
|
-
throw new Error(`unexpected route ${route}`);
|
|
206
|
-
});
|
|
207
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
208
|
-
expect(out.scope).toEqual({ dependabot: "org", codeql: "per-repo", secrets: "per-repo" });
|
|
209
|
-
expect(out.scanned).toEqual({ repos: 2 });
|
|
210
|
-
expect(out.alerts.map((a) => `${a.type}:${a.repo}#${a.number}`).sort()).toEqual(["codeql:cldmv/a#4", "dependabot:CLDMV/a#1", "secrets:cldmv/c#5"]);
|
|
211
|
-
expect(out.disabled).toBeUndefined();
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("counts repos where a type is switched off instead of failing", async () => {
|
|
215
|
-
const octokit = octokitWith(async (route, params) => {
|
|
216
|
-
if (orgDenied(route)) throw status(404);
|
|
217
|
-
if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }, { name: "c" }], headers: {} };
|
|
218
|
-
if (route === "GET /repos/{owner}/{repo}/dependabot/alerts") return { data: params.repo === "a" ? [dependabot(1)] : [], headers: {} };
|
|
219
|
-
if (route === "GET /repos/{owner}/{repo}/code-scanning/alerts") throw status(403, "Code Security must be enabled for this repository to use code scanning.");
|
|
220
|
-
if (route === "GET /repos/{owner}/{repo}/secret-scanning/alerts") {
|
|
221
|
-
if (params.repo === "b") throw status(404, "Secret scanning is disabled on this repository.");
|
|
222
|
-
return { data: [], headers: {} };
|
|
223
|
-
}
|
|
224
|
-
throw new Error(`unexpected route ${route}`);
|
|
225
|
-
});
|
|
226
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
227
|
-
expect(out.disabled).toEqual({ codeql: 3, secrets: 1 });
|
|
228
|
-
expect(out.alerts).toHaveLength(1);
|
|
229
|
-
expect(out.counts).toEqual({ dependabot: { high: 1 }, codeql: {}, secrets: {} });
|
|
230
|
-
expect(out.truncated).toBeUndefined();
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("goes straight to per-repo (no org calls) when a repo is named", async () => {
|
|
234
|
-
const octokit = octokitWith(async (route, params) => {
|
|
235
|
-
expect(params).toMatchObject({ owner: "cldmv", repo: "gh-broker", state: "open" });
|
|
236
|
-
if (route === "GET /repos/{owner}/{repo}/dependabot/alerts") return { data: [dependabot(8)], headers: {} };
|
|
237
|
-
return { data: [], headers: {} };
|
|
238
|
-
});
|
|
239
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "gh-broker" });
|
|
240
|
-
expect(out.scanned).toEqual({ repos: 1 });
|
|
241
|
-
expect(out.scope).toEqual({ dependabot: "per-repo", codeql: "per-repo", secrets: "per-repo" });
|
|
242
|
-
expect(out.alerts[0]).toMatchObject({ repo: "cldmv/gh-broker", number: 8 });
|
|
243
|
-
const routes = octokit.request.mock.calls.map((c: any[]) => c[0]);
|
|
244
|
-
expect(routes.some((r: string) => r.startsWith("GET /orgs/"))).toBe(false);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("flags max-repos when the owner has more repos than maxRepos, and sweeps only maxRepos", async () => {
|
|
248
|
-
const octokit = octokitWith(async (route, params) => {
|
|
249
|
-
if (orgDenied(route)) throw status(404);
|
|
250
|
-
if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }, { name: "c" }], headers: {} };
|
|
251
|
-
expect(["a", "b"]).toContain(params.repo);
|
|
252
|
-
return { data: [], headers: {} };
|
|
253
|
-
});
|
|
254
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"], maxRepos: 2 });
|
|
255
|
-
expect(out.scanned).toEqual({ repos: 2 });
|
|
256
|
-
expect(out.truncated).toBe(true);
|
|
257
|
-
expect(out.truncatedReason).toBe("max-repos");
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("does not flag max-repos when the owner has exactly maxRepos repos", async () => {
|
|
261
|
-
const octokit = octokitWith(async (route) => {
|
|
262
|
-
if (orgDenied(route)) throw status(404);
|
|
263
|
-
if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }], headers: {} };
|
|
264
|
-
return { data: [], headers: {} };
|
|
265
|
-
});
|
|
266
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"], maxRepos: 2 });
|
|
267
|
-
expect(out.truncated).toBeUndefined();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("flags max-alerts when one repo fills its per-type alertLimit", async () => {
|
|
271
|
-
const octokit = octokitWith(async () => ({ data: [dependabot(1), dependabot(2)], headers: {} }));
|
|
272
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r", types: ["dependabot"], alertLimit: 2 });
|
|
273
|
-
expect(out.truncatedReason).toBe("max-alerts");
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
describe("reportSecurityAlerts — limits and errors", () => {
|
|
278
|
-
it("returns partial results flagged max-api-calls when the call budget runs out", async () => {
|
|
279
|
-
const octokit = octokitWith(async (route) => {
|
|
280
|
-
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
|
|
281
|
-
return { data: [], headers: {} };
|
|
282
|
-
});
|
|
283
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv", maxApiCalls: 1 });
|
|
284
|
-
expect(out.truncated).toBe(true);
|
|
285
|
-
expect(out.truncatedReason).toBe("max-api-calls");
|
|
286
|
-
expect(out.alerts).toHaveLength(1);
|
|
287
|
-
// types never reached are not reported as having zero alerts
|
|
288
|
-
expect(out.counts).toEqual({ dependabot: { high: 1 } });
|
|
289
|
-
expect(out.scope).toEqual({ dependabot: "org" });
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("returns partial results with retryAfterSeconds when rate limited mid-scan", async () => {
|
|
293
|
-
const octokit = octokitWith(async (route) => {
|
|
294
|
-
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
|
|
295
|
-
throw Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "retry-after": "30" } } });
|
|
296
|
-
});
|
|
297
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
298
|
-
expect(out.truncated).toBe(true);
|
|
299
|
-
expect(out.truncatedReason).toBe("rate-limited");
|
|
300
|
-
expect(out.retryAfterSeconds).toBe(30);
|
|
301
|
-
expect(out.alerts).toHaveLength(1);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("omits retryAfterSeconds when GitHub sent none", async () => {
|
|
305
|
-
const octokit = octokitWith(async () => { throw status(429, "Too Many Requests"); });
|
|
306
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
307
|
-
expect(out.truncatedReason).toBe("rate-limited");
|
|
308
|
-
expect(out).not.toHaveProperty("retryAfterSeconds");
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it("stops proactively at the rate-limit floor", async () => {
|
|
312
|
-
const octokit = octokitWith(async () => ({ data: [dependabot(1)], headers: { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "50" } }));
|
|
313
|
-
const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
|
|
314
|
-
expect(out.truncatedReason).toBe("rate-limited");
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it("propagates an unexpected error from an org endpoint", async () => {
|
|
318
|
-
const octokit = octokitWith(async () => { throw status(500, "server exploded"); });
|
|
319
|
-
await expect(reportSecurityAlerts(octokit, { owner: "cldmv" })).rejects.toThrow("server exploded");
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("propagates an unexpected error from a per-repo endpoint", async () => {
|
|
323
|
-
const octokit = octokitWith(async () => { throw status(500, "server exploded"); });
|
|
324
|
-
await expect(reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r" })).rejects.toThrow("server exploded");
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("clamps the caps to their hard maximums", async () => {
|
|
328
|
-
const octokit = octokitWith(async () => ({ data: [], headers: {} }));
|
|
329
|
-
await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r", types: ["codeql"], alertLimit: 99999, maxApiCalls: 99999, maxRepos: 99999 });
|
|
330
|
-
expect(octokit.request).toHaveBeenCalledTimes(1);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("advertises a valid schema", () => {
|
|
334
|
-
expect(REPORT_SECURITY_ALERTS_SCHEMA.required).toEqual(["owner"]);
|
|
335
|
-
expect(REPORT_SECURITY_ALERTS_SCHEMA.additionalProperties).toBe(false);
|
|
336
|
-
expect(REPORT_SECURITY_ALERTS_SCHEMA.properties.types.items.enum).toEqual(["dependabot", "codeql", "secrets"]);
|
|
337
|
-
});
|
|
338
|
-
});
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import { connect, type Socket } from "node:net";
|
|
3
|
-
import { mkdirSync, rmSync, statSync, writeFileSync, readFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
-
import { startServer } from "../src/server.ts";
|
|
7
|
-
|
|
8
|
-
// Real node:fs, except readFileSync is a spy so the /etc/group lookup can be forced to fail.
|
|
9
|
-
vi.mock("node:fs", async (importOriginal) => {
|
|
10
|
-
const actual = await importOriginal<typeof import("node:fs")>();
|
|
11
|
-
return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
// Real node:net, except createServer remembers the server-side sockets so a test can inject events on them.
|
|
15
|
-
const seen = vi.hoisted(() => ({ sockets: [] as any[] }));
|
|
16
|
-
vi.mock("node:net", async (importOriginal) => {
|
|
17
|
-
const actual = await importOriginal<typeof import("node:net")>();
|
|
18
|
-
return {
|
|
19
|
-
...actual,
|
|
20
|
-
createServer: (listener: (s: any) => void) =>
|
|
21
|
-
actual.createServer((s) => {
|
|
22
|
-
seen.sockets.push(s);
|
|
23
|
-
listener(s);
|
|
24
|
-
})
|
|
25
|
-
};
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const root = join(process.cwd(), "tmp", "tests", "server");
|
|
29
|
-
const fakeRouter: any = { call: vi.fn(async (name: string, args: unknown) => ({ content: [{ type: "text", text: `${name}:${JSON.stringify(args)}` }] })) };
|
|
30
|
-
const catalog: any[] = [{ name: "get_me", description: "me", inputSchema: { type: "object" } }];
|
|
31
|
-
const sockets: Socket[] = [];
|
|
32
|
-
|
|
33
|
-
function sockPath(name: string): string {
|
|
34
|
-
mkdirSync(root, { recursive: true });
|
|
35
|
-
rmSync(join(root, name), { force: true }); // a socket left behind by an earlier run
|
|
36
|
-
return join(root, name);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Open a client connection and return a request/response helper over newline-delimited JSON-RPC. */
|
|
40
|
-
async function client(path: string) {
|
|
41
|
-
const sock: Socket = await new Promise((resolve, reject) => {
|
|
42
|
-
const s = connect(path, () => resolve(s));
|
|
43
|
-
s.once("error", reject);
|
|
44
|
-
});
|
|
45
|
-
sockets.push(sock);
|
|
46
|
-
let buf = "";
|
|
47
|
-
const waiting = new Map<number, (m: any) => void>();
|
|
48
|
-
sock.on("data", (d) => {
|
|
49
|
-
buf += d.toString("utf8");
|
|
50
|
-
let nl;
|
|
51
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
52
|
-
const msg = JSON.parse(buf.slice(0, nl));
|
|
53
|
-
buf = buf.slice(nl + 1);
|
|
54
|
-
waiting.get(msg.id)?.(msg);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
let id = 0;
|
|
58
|
-
return {
|
|
59
|
-
sock,
|
|
60
|
-
request: (method: string, params: unknown) =>
|
|
61
|
-
new Promise<any>((resolve) => {
|
|
62
|
-
const i = ++id;
|
|
63
|
-
waiting.set(i, resolve);
|
|
64
|
-
sock.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n");
|
|
65
|
-
})
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
afterEach(() => {
|
|
70
|
-
vi.restoreAllMocks();
|
|
71
|
-
for (const s of sockets.splice(0)) s.destroy();
|
|
72
|
-
delete process.env.GH_BROKER_SOCKET_GROUP;
|
|
73
|
-
vi.mocked(readFileSync).mockClear();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe("startServer", () => {
|
|
77
|
-
it("throws synchronously for a non-unix listen scheme", () => {
|
|
78
|
-
expect(() => startServer({ listen: "tcp:0.0.0.0:9000" } as any, fakeRouter, catalog)).toThrow(/unsupported listen/);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("rejects when the socket cannot be bound", async () => {
|
|
82
|
-
const config: any = { listen: "unix:/no/such/dir/gh-broker.sock" };
|
|
83
|
-
await expect(startServer(config, fakeRouter, catalog)).rejects.toBeTruthy();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("serves tools/list and tools/call over the socket, then drops the connection cleanly", async () => {
|
|
87
|
-
const path = sockPath("serve.sock");
|
|
88
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
89
|
-
expect(statSync(path).mode & 0o777).toBe(0o660);
|
|
90
|
-
|
|
91
|
-
const c = await client(path);
|
|
92
|
-
const init = await c.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } });
|
|
93
|
-
expect(init.result.serverInfo.name).toBe("gh-broker");
|
|
94
|
-
const listed = await c.request("tools/list", {});
|
|
95
|
-
expect(listed.result.tools).toEqual(catalog);
|
|
96
|
-
const called = await c.request("tools/call", { name: "get_me", arguments: { a: 1 } });
|
|
97
|
-
expect(called.result.content[0].text).toBe('get_me:{"a":1}');
|
|
98
|
-
// arguments omitted → router receives {}
|
|
99
|
-
const noArgs = await c.request("tools/call", { name: "get_me" });
|
|
100
|
-
expect(noArgs.result.content[0].text).toBe("get_me:{}");
|
|
101
|
-
|
|
102
|
-
c.sock.destroy();
|
|
103
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("replaces a stale socket file left by a previous run", async () => {
|
|
107
|
-
const path = sockPath("stale.sock");
|
|
108
|
-
writeFileSync(path, "");
|
|
109
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
110
|
-
const c = await client(path);
|
|
111
|
-
expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("applies the configured socket group when it resolves", async () => {
|
|
115
|
-
const path = sockPath("group.sock");
|
|
116
|
-
// the process's own primary group always resolves and is chown-able by the owner
|
|
117
|
-
const gid = process.getgid!();
|
|
118
|
-
const groupLine = `testgrp:x:${gid}:\n`;
|
|
119
|
-
vi.mocked(readFileSync).mockImplementationOnce(((p: any, ...rest: any[]) => (p === "/etc/group" ? groupLine : undefined)) as any);
|
|
120
|
-
process.env.GH_BROKER_SOCKET_GROUP = "testgrp";
|
|
121
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
122
|
-
expect(statSync(path).gid).toBe(gid);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("still listens when the group is unknown or /etc/group is unreadable", async () => {
|
|
126
|
-
process.env.GH_BROKER_SOCKET_GROUP = "no-such-group-xyz";
|
|
127
|
-
await startServer({ listen: `unix:${sockPath("nogroup.sock")}` } as any, fakeRouter, catalog);
|
|
128
|
-
|
|
129
|
-
vi.mocked(readFileSync).mockImplementationOnce((() => {
|
|
130
|
-
throw new Error("EACCES");
|
|
131
|
-
}) as any);
|
|
132
|
-
await startServer({ listen: `unix:${sockPath("unreadable.sock")}` } as any, fakeRouter, catalog);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("tolerates a failed chown (group the process cannot assign)", async () => {
|
|
136
|
-
const path = sockPath("chownfail.sock");
|
|
137
|
-
// gid 0 (root) is not assignable by an unprivileged owner → chownSync throws, which is swallowed
|
|
138
|
-
vi.mocked(readFileSync).mockImplementationOnce((() => "rootish:x:0:\n") as any);
|
|
139
|
-
process.env.GH_BROKER_SOCKET_GROUP = "rootish";
|
|
140
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
141
|
-
const c = await client(path);
|
|
142
|
-
expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("survives garbage from a client without taking the server down", async () => {
|
|
146
|
-
const path = sockPath("garbage.sock");
|
|
147
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
148
|
-
const bad = await client(path);
|
|
149
|
-
bad.sock.write("not json\n");
|
|
150
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
151
|
-
bad.sock.destroy();
|
|
152
|
-
const good = await client(path);
|
|
153
|
-
expect((await good.request("tools/list", {})).result.tools).toEqual(catalog);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("ignores an error on a client socket", async () => {
|
|
157
|
-
const path = sockPath("hangup.sock");
|
|
158
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
159
|
-
const c = await client(path);
|
|
160
|
-
await c.request("tools/list", {});
|
|
161
|
-
const serverSide = seen.sockets.at(-1);
|
|
162
|
-
expect(() => serverSide.emit("error", new Error("ECONNRESET"))).not.toThrow();
|
|
163
|
-
expect((await c.request("tools/list", {})).result.tools).toEqual(catalog);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("tolerates a failing MCP close when the client goes away", async () => {
|
|
167
|
-
const path = sockPath("closefail.sock");
|
|
168
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
169
|
-
const close = vi.spyOn(McpServer.prototype, "close").mockRejectedValue(new Error("close failed"));
|
|
170
|
-
const c = await client(path);
|
|
171
|
-
await c.request("tools/list", {});
|
|
172
|
-
c.sock.destroy();
|
|
173
|
-
await vi.waitFor(() => expect(close).toHaveBeenCalled());
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("logs and drops a connection whose MCP transport fails to start", async () => {
|
|
177
|
-
const path = sockPath("connectfail.sock");
|
|
178
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
179
|
-
vi.spyOn(McpServer.prototype, "connect").mockRejectedValue(new Error("transport broke"));
|
|
180
|
-
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
181
|
-
const c = await client(path);
|
|
182
|
-
await new Promise<void>((resolve) => c.sock.once("close", () => resolve()));
|
|
183
|
-
expect(err).toHaveBeenCalledWith("gh-broker: connection error: transport broke");
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("chowns with uid -1 when process.getuid is unavailable", async () => {
|
|
187
|
-
const orig = process.getuid;
|
|
188
|
-
const path = sockPath("nouid.sock");
|
|
189
|
-
const gid = process.getgid!();
|
|
190
|
-
vi.mocked(readFileSync).mockImplementationOnce(((p: any) => (p === "/etc/group" ? `testgrp:x:${gid}:\n` : undefined)) as any);
|
|
191
|
-
process.env.GH_BROKER_SOCKET_GROUP = "testgrp";
|
|
192
|
-
(process as any).getuid = undefined;
|
|
193
|
-
try {
|
|
194
|
-
await startServer({ listen: `unix:${path}` } as any, fakeRouter, catalog);
|
|
195
|
-
} finally {
|
|
196
|
-
process.getuid = orig;
|
|
197
|
-
}
|
|
198
|
-
expect(statSync(path).mode & 0o777).toBe(0o660);
|
|
199
|
-
});
|
|
200
|
-
});
|