Skip to content

Commit 71fe6a2

Browse files
lroolleclaude
andcommitted
fix: resolve GitHub bot permissions validation security issues
- Fix bot permission validation flaw: replace read-only repos.get() with proper write permission checks - Standardize bot detection: use GitHub API userData.type consistently across files - Add comprehensive test coverage for bot permission scenarios - Remove unnecessary code comments for cleaner code Resolves BugBot security findings: - Prevents read-only bots from bypassing write permission validation - Eliminates inconsistent bot detection methods - Ensures proper authorization validation for GitHub Apps 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6acfc46 commit 71fe6a2

2 files changed

Lines changed: 303 additions & 10 deletions

File tree

src/github/validation/permissions.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ import type { Octokit } from "@octokit/rest";
88
* @param context - The GitHub context
99
* @returns true if the actor has write permissions, false otherwise
1010
*/
11+
async function getBotActorType(
12+
octokit: Octokit,
13+
actor: string,
14+
): Promise<string | null> {
15+
try {
16+
const { data: userData } = await octokit.users.getByUsername({
17+
username: actor,
18+
});
19+
return userData.type;
20+
} catch (error) {
21+
core.warning(`Failed to get user data for ${actor}: ${error}`);
22+
return null;
23+
}
24+
}
25+
1126
export async function checkWritePermissions(
1227
octokit: Octokit,
1328
context: ParsedGitHubContext,
@@ -17,26 +32,60 @@ export async function checkWritePermissions(
1732
try {
1833
core.info(`Checking permissions for actor: ${actor}`);
1934

20-
// For GitHub Apps (like claude-yolo[bot]), check if we can perform write operations
21-
if (actor.endsWith("[bot]")) {
22-
core.info(`GitHub App detected: ${actor}, checking app installation`);
35+
const actorType = await getBotActorType(octokit, actor);
36+
37+
if (actorType === "Bot") {
38+
core.info(`GitHub App detected: ${actor}, checking write permissions`);
2339

2440
try {
25-
// Try to get the repository to verify the app has access
26-
await octokit.repos.get({
41+
const response = await octokit.repos.getCollaboratorPermissionLevel({
2742
owner: repository.owner,
2843
repo: repository.repo,
44+
username: actor,
2945
});
3046

31-
core.info(`App has repository access: ${actor}`);
32-
return true;
47+
const permissionLevel = response.data.permission;
48+
core.info(`App permission level: ${permissionLevel}`);
49+
50+
if (permissionLevel === "admin" || permissionLevel === "write") {
51+
core.info(`App has write access: ${permissionLevel}`);
52+
return true;
53+
} else {
54+
core.warning(`App has insufficient permissions: ${permissionLevel}`);
55+
return false;
56+
}
3357
} catch (error) {
34-
core.warning(`App lacks repository access: ${error}`);
35-
return false;
58+
core.warning(
59+
`Could not check collaborator permissions for bot, checking app installation: ${error}`,
60+
);
61+
62+
try {
63+
const installation = await octokit.apps.getRepoInstallation({
64+
owner: repository.owner,
65+
repo: repository.repo,
66+
});
67+
68+
core.info(`App installation found: ${installation.data.id}`);
69+
70+
const permissions = installation.data.permissions;
71+
if (
72+
permissions &&
73+
(permissions.contents === "write" ||
74+
permissions.contents === "admin")
75+
) {
76+
core.info(`App has write permissions via installation`);
77+
return true;
78+
} else {
79+
core.warning(`App lacks write permissions in installation`);
80+
return false;
81+
}
82+
} catch (installationError) {
83+
core.warning(`App lacks repository access: ${installationError}`);
84+
return false;
85+
}
3686
}
3787
}
3888

39-
// For human users, check permissions using the collaborator endpoint
4089
const response = await octokit.repos.getCollaboratorPermissionLevel({
4190
owner: repository.owner,
4291
repo: repository.repo,
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test";
2+
import { checkWritePermissions } from "../src/github/validation/permissions";
3+
import type { ParsedGitHubContext } from "../src/github/context";
4+
5+
describe("checkWritePermissions", () => {
6+
let coreSpy: any;
7+
8+
beforeEach(() => {
9+
coreSpy = {
10+
info: spyOn(console, "log").mockImplementation(() => {}),
11+
warning: spyOn(console, "warn").mockImplementation(() => {}),
12+
error: spyOn(console, "error").mockImplementation(() => {}),
13+
};
14+
});
15+
16+
afterEach(() => {
17+
coreSpy.info.mockRestore();
18+
coreSpy.warning.mockRestore();
19+
coreSpy.error.mockRestore();
20+
});
21+
22+
const createContext = (actor: string = "test-user"): ParsedGitHubContext => ({
23+
runId: "1234567890",
24+
eventName: "issue_comment",
25+
eventAction: "created",
26+
repository: {
27+
full_name: "test-owner/test-repo",
28+
owner: "test-owner",
29+
repo: "test-repo",
30+
},
31+
actor,
32+
payload: {
33+
action: "created",
34+
issue: {
35+
number: 1,
36+
title: "Test Issue",
37+
body: "Test body",
38+
user: { login: actor },
39+
},
40+
comment: {
41+
id: 123,
42+
body: "@claude test",
43+
user: { login: actor },
44+
html_url:
45+
"https://github.com/test-owner/test-repo/issues/1#issuecomment-123",
46+
},
47+
} as any,
48+
entityNumber: 1,
49+
isPR: false,
50+
inputs: {
51+
triggerPhrase: "@claude",
52+
assigneeTrigger: "",
53+
labelTrigger: "",
54+
allowedTools: [],
55+
disallowedTools: [],
56+
customInstructions: "",
57+
directPrompt: "",
58+
branchPrefix: "claude/",
59+
useStickyComment: false,
60+
},
61+
});
62+
63+
test("should grant write permissions to human users with write access", async () => {
64+
const mockOctokit = {
65+
users: {
66+
getByUsername: async () => ({
67+
data: { type: "User" },
68+
}),
69+
},
70+
repos: {
71+
getCollaboratorPermissionLevel: async () => ({
72+
data: { permission: "write" },
73+
}),
74+
},
75+
} as any;
76+
77+
const context = createContext("human-user");
78+
const result = await checkWritePermissions(mockOctokit, context);
79+
80+
expect(result).toBe(true);
81+
});
82+
83+
test("should deny write permissions to human users with read access", async () => {
84+
const mockOctokit = {
85+
users: {
86+
getByUsername: async () => ({
87+
data: { type: "User" },
88+
}),
89+
},
90+
repos: {
91+
getCollaboratorPermissionLevel: async () => ({
92+
data: { permission: "read" },
93+
}),
94+
},
95+
} as any;
96+
97+
const context = createContext("human-user");
98+
const result = await checkWritePermissions(mockOctokit, context);
99+
100+
expect(result).toBe(false);
101+
});
102+
103+
test("should grant write permissions to bots with write access via collaborator check", async () => {
104+
const mockOctokit = {
105+
users: {
106+
getByUsername: async () => ({
107+
data: { type: "Bot" },
108+
}),
109+
},
110+
repos: {
111+
getCollaboratorPermissionLevel: async () => ({
112+
data: { permission: "write" },
113+
}),
114+
},
115+
} as any;
116+
117+
const context = createContext("claude-bot");
118+
const result = await checkWritePermissions(mockOctokit, context);
119+
120+
expect(result).toBe(true);
121+
});
122+
123+
test("should deny write permissions to bots with read access via collaborator check", async () => {
124+
const mockOctokit = {
125+
users: {
126+
getByUsername: async () => ({
127+
data: { type: "Bot" },
128+
}),
129+
},
130+
repos: {
131+
getCollaboratorPermissionLevel: async () => ({
132+
data: { permission: "read" },
133+
}),
134+
},
135+
} as any;
136+
137+
const context = createContext("claude-bot");
138+
const result = await checkWritePermissions(mockOctokit, context);
139+
140+
expect(result).toBe(false);
141+
});
142+
143+
test("should fallback to app installation check for bots when collaborator check fails", async () => {
144+
const mockOctokit = {
145+
users: {
146+
getByUsername: async () => ({
147+
data: { type: "Bot" },
148+
}),
149+
},
150+
repos: {
151+
getCollaboratorPermissionLevel: async () => {
152+
throw new Error("Not found");
153+
},
154+
},
155+
apps: {
156+
getRepoInstallation: async () => ({
157+
data: {
158+
id: 123,
159+
permissions: { contents: "write" },
160+
},
161+
}),
162+
},
163+
} as any;
164+
165+
const context = createContext("claude-bot");
166+
const result = await checkWritePermissions(mockOctokit, context);
167+
168+
expect(result).toBe(true);
169+
});
170+
171+
test("should deny write permissions to bots with read-only installation", async () => {
172+
const mockOctokit = {
173+
users: {
174+
getByUsername: async () => ({
175+
data: { type: "Bot" },
176+
}),
177+
},
178+
repos: {
179+
getCollaboratorPermissionLevel: async () => {
180+
throw new Error("Not found");
181+
},
182+
},
183+
apps: {
184+
getRepoInstallation: async () => ({
185+
data: {
186+
id: 123,
187+
permissions: { contents: "read" },
188+
},
189+
}),
190+
},
191+
} as any;
192+
193+
const context = createContext("claude-bot");
194+
const result = await checkWritePermissions(mockOctokit, context);
195+
196+
expect(result).toBe(false);
197+
});
198+
199+
test("should deny write permissions to bots with no installation", async () => {
200+
const mockOctokit = {
201+
users: {
202+
getByUsername: async () => ({
203+
data: { type: "Bot" },
204+
}),
205+
},
206+
repos: {
207+
getCollaboratorPermissionLevel: async () => {
208+
throw new Error("Not found");
209+
},
210+
},
211+
apps: {
212+
getRepoInstallation: async () => {
213+
throw new Error("Installation not found");
214+
},
215+
},
216+
} as any;
217+
218+
const context = createContext("claude-bot");
219+
const result = await checkWritePermissions(mockOctokit, context);
220+
221+
expect(result).toBe(false);
222+
});
223+
224+
test("should handle API errors gracefully", async () => {
225+
const mockOctokit = {
226+
users: {
227+
getByUsername: async () => {
228+
throw new Error("API Error");
229+
},
230+
},
231+
repos: {
232+
getCollaboratorPermissionLevel: async () => {
233+
throw new Error("API Error");
234+
},
235+
},
236+
} as any;
237+
238+
const context = createContext("test-user");
239+
240+
await expect(checkWritePermissions(mockOctokit, context)).rejects.toThrow(
241+
"Failed to check permissions for test-user:",
242+
);
243+
});
244+
});

0 commit comments

Comments
 (0)