@cldmv/gh-broker 0.7.0 → 0.9.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.
Files changed (107) hide show
  1. package/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. package/tsconfig.json +0 -21
@@ -1,106 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- vi.mock("node:fs", () => ({
4
- existsSync: vi.fn(() => true),
5
- readFileSync: vi.fn(),
6
- writeFileSync: vi.fn(),
7
- statSync: vi.fn(() => ({ size: 8 })),
8
- rmSync: vi.fn()
9
- }));
10
-
11
- import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
12
- import { readSecretsFile, removeSecretsFile, referencedCredNames, detectSecretsProvider } from "../deploy/lib/secret-store.mjs";
13
-
14
- const mockRead = vi.mocked(readFileSync);
15
- const mockExists = vi.mocked(existsSync);
16
- const mockWrite = vi.mocked(writeFileSync);
17
- const mockRm = vi.mocked(rmSync);
18
-
19
- describe("readSecretsFile", () => {
20
- beforeEach(() => mockRead.mockReset());
21
-
22
- it("keeps only non-empty string values (drops numbers/objects/empties)", () => {
23
- mockRead.mockReturnValue(JSON.stringify({ a: "tok", b: 123, c: "", d: { x: 1 }, e: "ok" }) as any);
24
- expect(readSecretsFile("/x")).toEqual({ a: "tok", e: "ok" });
25
- });
26
-
27
- it("returns {} for a non-object or unparseable file", () => {
28
- mockRead.mockReturnValue("[1,2,3]" as any);
29
- expect(readSecretsFile("/x")).toEqual({});
30
- mockRead.mockReturnValue("not json" as any);
31
- expect(readSecretsFile("/x")).toEqual({});
32
- });
33
- });
34
-
35
- describe("removeSecretsFile", () => {
36
- beforeEach(() => {
37
- mockExists.mockReset().mockReturnValue(true);
38
- mockWrite.mockReset();
39
- mockRm.mockReset();
40
- });
41
-
42
- it("zero-overwrites then unlinks an existing file", () => {
43
- removeSecretsFile("/x/secrets.json");
44
- expect(mockWrite).toHaveBeenCalledTimes(1);
45
- const buf = mockWrite.mock.calls[0][1] as Buffer;
46
- expect(Buffer.isBuffer(buf)).toBe(true);
47
- expect(buf.every((b) => b === 0)).toBe(true);
48
- expect(mockRm).toHaveBeenCalledWith("/x/secrets.json", { force: true });
49
- });
50
-
51
- it("is a no-op when the file is absent", () => {
52
- mockExists.mockReturnValue(false);
53
- removeSecretsFile("/x/secrets.json");
54
- expect(mockWrite).not.toHaveBeenCalled();
55
- expect(mockRm).not.toHaveBeenCalled();
56
- });
57
- });
58
-
59
- describe("referencedCredNames", () => {
60
- it("collects distinct cred names from owners + app, ignoring non-cred refs", () => {
61
- const cfg = {
62
- owners: { o1: { read: "cred:a", write: "cred:b" }, o2: { read: "cred:a", write: "cred:c" } },
63
- app: { privateKey: "cred:k" }
64
- };
65
- expect(referencedCredNames(cfg).sort()).toEqual(["a", "b", "c", "k"]);
66
- });
67
-
68
- it("returns [] with no owners", () => {
69
- expect(referencedCredNames({})).toEqual([]);
70
- });
71
-
72
- it("rejects an unsafe cred name (path separator / traversal)", () => {
73
- expect(() => referencedCredNames({ owners: { o: { read: "cred:../x", write: "cred:ok" } } })).toThrow(/unsafe credential name/);
74
- expect(() => referencedCredNames({ owners: { o: { read: "cred:a/b", write: "cred:ok" } } })).toThrow(/unsafe credential name/);
75
- });
76
- });
77
-
78
- describe("detectSecretsProvider", () => {
79
- it("trusts the provider recorded in the deployed config, whatever is on disk", () => {
80
- expect(detectSecretsProvider({ configured: "file", credNames: ["a"], fileTokenNames: [] })).toBe("file");
81
- expect(detectSecretsProvider({ configured: "systemd-creds", credNames: [], fileTokenNames: ["a"] })).toBe("systemd-creds");
82
- });
83
-
84
- it("ignores an unrecognised configured value and falls back to what is on disk", () => {
85
- expect(detectSecretsProvider({ configured: "vault", credNames: ["a"] })).toBe("systemd-creds");
86
- expect(detectSecretsProvider({ configured: 42, fileTokenNames: ["a"] })).toBe("file");
87
- });
88
-
89
- it("infers systemd-creds from encrypted .cred files when nothing is recorded", () => {
90
- expect(detectSecretsProvider({ credNames: ["cldmv-read", "cldmv-write"] })).toBe("systemd-creds");
91
- });
92
-
93
- it("infers file from tokens in the secrets file when nothing is recorded", () => {
94
- expect(detectSecretsProvider({ fileTokenNames: ["cldmv-read"] })).toBe("file");
95
- });
96
-
97
- it("returns undefined on a first install, so the installer asks", () => {
98
- expect(detectSecretsProvider({})).toBeUndefined();
99
- expect(detectSecretsProvider()).toBeUndefined();
100
- expect(detectSecretsProvider({ credNames: [], fileTokenNames: [] })).toBeUndefined();
101
- });
102
-
103
- it("returns undefined when both backends hold credentials and nothing is recorded (ambiguous)", () => {
104
- expect(detectSecretsProvider({ credNames: ["a"], fileTokenNames: ["a"] })).toBeUndefined();
105
- });
106
- });
@@ -1,155 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
-
3
- // secrets.ts reads the JSON secrets file via node:fs.readFileSync.
4
- vi.mock("node:fs", () => ({ readFileSync: vi.fn() }));
5
-
6
- import { readFileSync } from "node:fs";
7
- import { loadSecretStore } from "../src/secrets.ts";
8
-
9
- const mockRead = vi.mocked(readFileSync);
10
- const SECRETS = "/x/secrets.json";
11
-
12
- function config(owners: Record<string, unknown> = { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } }, extra: Record<string, unknown> = {}): any {
13
- return { listen: "unix:/x", upstream: "https://x", tools: [], owners, secrets: { provider: "file", path: SECRETS }, ...extra };
14
- }
15
-
16
- /**
17
- * Serve the secrets file body (object → JSON, or a raw string). fileStore reads
18
- * exactly one file, so this returns the body unconditionally.
19
- */
20
- function secretsFile(obj: Record<string, unknown> | string) {
21
- const body = typeof obj === "string" ? obj : JSON.stringify(obj);
22
- mockRead.mockReset();
23
- mockRead.mockReturnValue(body as any);
24
- }
25
-
26
- describe("loadSecretStore (file provider)", () => {
27
- beforeEach(() => mockRead.mockReset());
28
-
29
- it("resolves cred refs from the secrets file", () => {
30
- secretsFile({ "cldmv-read": "readtok", "cldmv-write": "writetok" });
31
- const store = loadSecretStore(config());
32
- expect(store.resolve("cred:cldmv-read")).toBe("readtok");
33
- expect(store.resolve("cred:cldmv-write")).toBe("writetok");
34
- });
35
-
36
- it("reads the secrets file once, not per credential", () => {
37
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
38
- const store = loadSecretStore(config());
39
- expect(mockRead).toHaveBeenCalledTimes(1);
40
- store.resolve("cred:cldmv-read");
41
- store.resolve("cred:cldmv-read");
42
- expect(mockRead).toHaveBeenCalledTimes(1);
43
- });
44
-
45
- it("throws when the secrets file is unreadable", () => {
46
- mockRead.mockReset();
47
- // Only the fileStore read throws; later reads (e.g. vitest error formatting)
48
- // must not, or they'd mask the wrapped message under test.
49
- mockRead.mockImplementationOnce(() => {
50
- throw new Error("ENOENT");
51
- });
52
- expect(() => loadSecretStore(config())).toThrow(/cannot read\/parse secrets file/);
53
- });
54
-
55
- it("throws when the file is not a JSON object", () => {
56
- secretsFile("[1,2,3]");
57
- expect(() => loadSecretStore(config())).toThrow(/must be a JSON object/);
58
- });
59
-
60
- it("throws (eager) when a referenced credential is missing in the file", () => {
61
- secretsFile({ "cldmv-write": "w" });
62
- expect(() => loadSecretStore(config())).toThrow(/credential "cldmv-read" missing or empty/);
63
- });
64
-
65
- it("throws when a referenced credential is empty", () => {
66
- secretsFile({ "cldmv-read": "", "cldmv-write": "w" });
67
- expect(() => loadSecretStore(config())).toThrow(/credential "cldmv-read" missing or empty/);
68
- });
69
-
70
- it("strips a single trailing newline (matches the systemd-creds provider)", () => {
71
- secretsFile({ "cldmv-read": "readtok\n", "cldmv-write": "writetok\r\n" });
72
- const store = loadSecretStore(config());
73
- expect(store.resolve("cred:cldmv-read")).toBe("readtok");
74
- expect(store.resolve("cred:cldmv-write")).toBe("writetok");
75
- });
76
-
77
- it("rejects a credential that is only a newline (empty after normalization)", () => {
78
- secretsFile({ "cldmv-read": "\n", "cldmv-write": "w" });
79
- expect(() => loadSecretStore(config())).toThrow(/credential "cldmv-read" missing or empty/);
80
- });
81
-
82
- it("rejects an owner ref that is not cred:<name>", () => {
83
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
84
- expect(() => loadSecretStore(config({ cldmv: { read: "plain-ref", write: "cred:cldmv-write" } }))).toThrow(/must be "cred:<name>"/);
85
- });
86
-
87
- it("rejects a malformed ref at resolve time", () => {
88
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
89
- const store = loadSecretStore(config());
90
- expect(() => store.resolve("not-a-cred-ref")).toThrow(/bad credential ref/);
91
- });
92
-
93
- it("rejects an owner cred name with a path separator (traversal guard)", () => {
94
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
95
- expect(() => loadSecretStore(config({ cldmv: { read: "cred:../../etc/shadow", write: "cred:cldmv-write" } }))).toThrow(/unsafe credential name/);
96
- });
97
-
98
- it("rejects an unsafe cred name at resolve time", () => {
99
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
100
- const store = loadSecretStore(config());
101
- expect(() => store.resolve("cred:..")).toThrow(/unsafe credential name/);
102
- expect(() => store.resolve("cred:a/b")).toThrow(/unsafe credential name/);
103
- });
104
-
105
- it("rejects an unknown provider", () => {
106
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
107
- expect(() => loadSecretStore(config(undefined, { secrets: { provider: "vault", path: SECRETS } }))).toThrow(/unknown provider/);
108
- });
109
-
110
- it("rejects a non-cred config.app.privateKey (would inline a secret in the config)", () => {
111
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
112
- expect(() => loadSecretStore(config(undefined, { app: { appId: 1, privateKey: "-----BEGIN PRIVATE KEY-----" } }))).toThrow(
113
- /config.app.privateKey must be a "cred:<name>" ref/
114
- );
115
- });
116
- });
117
-
118
- describe("loadSecretStore (systemd-creds provider, the default)", () => {
119
- const CREDS = "/run/creds";
120
- const original = process.env.CREDENTIALS_DIRECTORY;
121
- function scConfig(): any {
122
- return {
123
- listen: "unix:/x",
124
- upstream: "https://x",
125
- tools: [],
126
- owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
127
- secrets: { provider: "systemd-creds" }
128
- };
129
- }
130
-
131
- beforeEach(() => {
132
- mockRead.mockReset();
133
- process.env.CREDENTIALS_DIRECTORY = CREDS;
134
- });
135
- afterEach(() => {
136
- if (original === undefined) delete process.env.CREDENTIALS_DIRECTORY;
137
- else process.env.CREDENTIALS_DIRECTORY = original;
138
- });
139
-
140
- it("throws when $CREDENTIALS_DIRECTORY is unset", () => {
141
- delete process.env.CREDENTIALS_DIRECTORY;
142
- expect(() => loadSecretStore(scConfig())).toThrow(/\$CREDENTIALS_DIRECTORY unset/);
143
- });
144
-
145
- it("resolves creds from $CREDENTIALS_DIRECTORY, stripping a trailing newline", () => {
146
- mockRead.mockImplementation((p: any) => {
147
- if (String(p).endsWith("cldmv-read")) return "rtok\n" as any;
148
- if (String(p).endsWith("cldmv-write")) return "wtok" as any;
149
- throw new Error(`ENOENT: ${p}`);
150
- });
151
- const store = loadSecretStore(scConfig());
152
- expect(store.resolve("cred:cldmv-read")).toBe("rtok");
153
- expect(store.resolve("cred:cldmv-write")).toBe("wtok");
154
- });
155
- });
@@ -1,328 +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("flags max-alerts when a type fills its alertLimit", async () => {
176
- const octokit = octokitWith(async (route) => ({ data: route.includes("dependabot") ? [dependabot(1, { repository: { full_name: "CLDMV/a" } }), dependabot(2, { repository: { full_name: "CLDMV/a" } })] : [], headers: {} }));
177
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", alertLimit: 2 });
178
- expect(out.truncated).toBe(true);
179
- expect(out.truncatedReason).toBe("max-alerts");
180
- expect(out.alerts).toHaveLength(2);
181
- });
182
- });
183
-
184
- describe("reportSecurityAlerts — per-repo fallback", () => {
185
- const orgDenied = (route: string) => route.startsWith("GET /orgs/{owner}/") && !route.endsWith("/repos");
186
-
187
- it("falls back to a per-repo sweep for a type whose org endpoint is 403/404, keeping the types that worked", async () => {
188
- const octokit = octokitWith(async (route, params) => {
189
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
190
- if (route === "GET /orgs/{owner}/code-scanning/alerts") throw status(403, "Resource not accessible by personal access token");
191
- if (route === "GET /orgs/{owner}/secret-scanning/alerts") throw status(404, "Not Found");
192
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b", archived: true }, { name: "c" }], headers: {} };
193
- if (route === "GET /repos/{owner}/{repo}/code-scanning/alerts") return { data: params.repo === "a" ? [codeql(4)] : [], headers: {} };
194
- if (route === "GET /repos/{owner}/{repo}/secret-scanning/alerts") return { data: params.repo === "c" ? [secret(5)] : [], headers: {} };
195
- throw new Error(`unexpected route ${route}`);
196
- });
197
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
198
- expect(out.scope).toEqual({ dependabot: "org", codeql: "per-repo", secrets: "per-repo" });
199
- expect(out.scanned).toEqual({ repos: 2 });
200
- 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"]);
201
- expect(out.disabled).toBeUndefined();
202
- });
203
-
204
- it("counts repos where a type is switched off instead of failing", async () => {
205
- const octokit = octokitWith(async (route, params) => {
206
- if (orgDenied(route)) throw status(404);
207
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }, { name: "c" }], headers: {} };
208
- if (route === "GET /repos/{owner}/{repo}/dependabot/alerts") return { data: params.repo === "a" ? [dependabot(1)] : [], headers: {} };
209
- if (route === "GET /repos/{owner}/{repo}/code-scanning/alerts") throw status(403, "Code Security must be enabled for this repository to use code scanning.");
210
- if (route === "GET /repos/{owner}/{repo}/secret-scanning/alerts") {
211
- if (params.repo === "b") throw status(404, "Secret scanning is disabled on this repository.");
212
- return { data: [], headers: {} };
213
- }
214
- throw new Error(`unexpected route ${route}`);
215
- });
216
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
217
- expect(out.disabled).toEqual({ codeql: 3, secrets: 1 });
218
- expect(out.alerts).toHaveLength(1);
219
- expect(out.counts).toEqual({ dependabot: { high: 1 }, codeql: {}, secrets: {} });
220
- expect(out.truncated).toBeUndefined();
221
- });
222
-
223
- it("goes straight to per-repo (no org calls) when a repo is named", async () => {
224
- const octokit = octokitWith(async (route, params) => {
225
- expect(params).toMatchObject({ owner: "cldmv", repo: "gh-broker", state: "open" });
226
- if (route === "GET /repos/{owner}/{repo}/dependabot/alerts") return { data: [dependabot(8)], headers: {} };
227
- return { data: [], headers: {} };
228
- });
229
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "gh-broker" });
230
- expect(out.scanned).toEqual({ repos: 1 });
231
- expect(out.scope).toEqual({ dependabot: "per-repo", codeql: "per-repo", secrets: "per-repo" });
232
- expect(out.alerts[0]).toMatchObject({ repo: "cldmv/gh-broker", number: 8 });
233
- const routes = octokit.request.mock.calls.map((c: any[]) => c[0]);
234
- expect(routes.some((r: string) => r.startsWith("GET /orgs/"))).toBe(false);
235
- });
236
-
237
- it("flags max-repos when the owner has more repos than maxRepos, and sweeps only maxRepos", async () => {
238
- const octokit = octokitWith(async (route, params) => {
239
- if (orgDenied(route)) throw status(404);
240
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }, { name: "c" }], headers: {} };
241
- expect(["a", "b"]).toContain(params.repo);
242
- return { data: [], headers: {} };
243
- });
244
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"], maxRepos: 2 });
245
- expect(out.scanned).toEqual({ repos: 2 });
246
- expect(out.truncated).toBe(true);
247
- expect(out.truncatedReason).toBe("max-repos");
248
- });
249
-
250
- it("does not flag max-repos when the owner has exactly maxRepos repos", async () => {
251
- const octokit = octokitWith(async (route) => {
252
- if (orgDenied(route)) throw status(404);
253
- if (route === "GET /orgs/{owner}/repos") return { data: [{ name: "a" }, { name: "b" }], headers: {} };
254
- return { data: [], headers: {} };
255
- });
256
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", types: ["dependabot"], maxRepos: 2 });
257
- expect(out.truncated).toBeUndefined();
258
- });
259
-
260
- it("flags max-alerts when one repo fills its per-type alertLimit", async () => {
261
- const octokit = octokitWith(async () => ({ data: [dependabot(1), dependabot(2)], headers: {} }));
262
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r", types: ["dependabot"], alertLimit: 2 });
263
- expect(out.truncatedReason).toBe("max-alerts");
264
- });
265
- });
266
-
267
- describe("reportSecurityAlerts — limits and errors", () => {
268
- it("returns partial results flagged max-api-calls when the call budget runs out", async () => {
269
- const octokit = octokitWith(async (route) => {
270
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
271
- return { data: [], headers: {} };
272
- });
273
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv", maxApiCalls: 1 });
274
- expect(out.truncated).toBe(true);
275
- expect(out.truncatedReason).toBe("max-api-calls");
276
- expect(out.alerts).toHaveLength(1);
277
- // types never reached are not reported as having zero alerts
278
- expect(out.counts).toEqual({ dependabot: { high: 1 } });
279
- expect(out.scope).toEqual({ dependabot: "org" });
280
- });
281
-
282
- it("returns partial results with retryAfterSeconds when rate limited mid-scan", async () => {
283
- const octokit = octokitWith(async (route) => {
284
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dependabot(1, { repository: { full_name: "CLDMV/a" } })], headers: {} };
285
- throw Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "retry-after": "30" } } });
286
- });
287
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
288
- expect(out.truncated).toBe(true);
289
- expect(out.truncatedReason).toBe("rate-limited");
290
- expect(out.retryAfterSeconds).toBe(30);
291
- expect(out.alerts).toHaveLength(1);
292
- });
293
-
294
- it("omits retryAfterSeconds when GitHub sent none", async () => {
295
- const octokit = octokitWith(async () => { throw status(429, "Too Many Requests"); });
296
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
297
- expect(out.truncatedReason).toBe("rate-limited");
298
- expect(out).not.toHaveProperty("retryAfterSeconds");
299
- });
300
-
301
- it("stops proactively at the rate-limit floor", async () => {
302
- const octokit = octokitWith(async () => ({ data: [dependabot(1)], headers: { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "50" } }));
303
- const out = await reportSecurityAlerts(octokit, { owner: "cldmv" });
304
- expect(out.truncatedReason).toBe("rate-limited");
305
- });
306
-
307
- it("propagates an unexpected error from an org endpoint", async () => {
308
- const octokit = octokitWith(async () => { throw status(500, "server exploded"); });
309
- await expect(reportSecurityAlerts(octokit, { owner: "cldmv" })).rejects.toThrow("server exploded");
310
- });
311
-
312
- it("propagates an unexpected error from a per-repo endpoint", async () => {
313
- const octokit = octokitWith(async () => { throw status(500, "server exploded"); });
314
- await expect(reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r" })).rejects.toThrow("server exploded");
315
- });
316
-
317
- it("clamps the caps to their hard maximums", async () => {
318
- const octokit = octokitWith(async () => ({ data: [], headers: {} }));
319
- await reportSecurityAlerts(octokit, { owner: "cldmv", repo: "r", types: ["codeql"], alertLimit: 99999, maxApiCalls: 99999, maxRepos: 99999 });
320
- expect(octokit.request).toHaveBeenCalledTimes(1);
321
- });
322
-
323
- it("advertises a valid schema", () => {
324
- expect(REPORT_SECURITY_ALERTS_SCHEMA.required).toEqual(["owner"]);
325
- expect(REPORT_SECURITY_ALERTS_SCHEMA.additionalProperties).toBe(false);
326
- expect(REPORT_SECURITY_ALERTS_SCHEMA.properties.types.items.enum).toEqual(["dependabot", "codeql", "secrets"]);
327
- });
328
- });
@@ -1,20 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { startServer } from "../src/server.ts";
3
-
4
- // startServer owns a long-lived net.Server it never hands back, so a successful
5
- // listen can't be cleanly torn down from a unit test. We exercise the safe,
6
- // non-listening paths only: the unsupported-scheme guard (synchronous throw) and
7
- // a listen that fails (bind into a non-existent directory) → promise rejects.
8
- const fakeRouter: any = { call: () => Promise.resolve({ content: [] }) };
9
- const catalog: any[] = [];
10
-
11
- describe("startServer", () => {
12
- it("throws synchronously for a non-unix listen scheme", () => {
13
- expect(() => startServer({ listen: "tcp:0.0.0.0:9000" } as any, fakeRouter, catalog)).toThrow(/unsupported listen/);
14
- });
15
-
16
- it("rejects when the socket cannot be bound", async () => {
17
- const config: any = { listen: "unix:/no/such/dir/gh-broker.sock" };
18
- await expect(startServer(config, fakeRouter, catalog)).rejects.toBeTruthy();
19
- });
20
- });