Skip to content

Commit 0035ea2

Browse files
fix(security-agent): handle invalid GitHub installations (#3440)
1 parent 5320a9b commit 0035ea2

13 files changed

Lines changed: 24247 additions & 44 deletions

File tree

apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ function makeIntegration(overrides: Partial<PlatformIntegration> = {}): Platform
265265
repository_access: null,
266266
repositories: null,
267267
repositories_synced_at: null,
268+
auth_invalid_at: null,
269+
auth_invalid_reason: null,
268270
metadata: null,
269271
kilo_requester_user_id: null,
270272
platform_requester_account_id: null,

apps/web/src/lib/bot/platforms/github.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ function createIntegration(overrides: Partial<PlatformIntegration> = {}): Platfo
116116
repository_access: 'all',
117117
repositories: null,
118118
repositories_synced_at: null,
119+
auth_invalid_at: null,
120+
auth_invalid_reason: null,
119121
metadata: null,
120122
kilo_requester_user_id: null,
121123
platform_requester_account_id: null,

apps/web/src/lib/integrations/db/platform-integrations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ export async function upsertPlatformIntegration(data: {
127127
repository_access: sql`EXCLUDED.repository_access`,
128128
integration_status: sql`EXCLUDED.integration_status`,
129129
repositories: sql`EXCLUDED.repositories`,
130+
auth_invalid_at: null,
131+
auth_invalid_reason: null,
130132
updated_at: sql`now()`,
131133
},
132134
});
@@ -145,6 +147,8 @@ export async function updateIntegrationRepositories(
145147
.set({
146148
repositories,
147149
repositories_synced_at: new Date().toISOString(),
150+
auth_invalid_at: null,
151+
auth_invalid_reason: null,
148152
updated_at: new Date().toISOString(),
149153
})
150154
.where(
@@ -167,6 +171,8 @@ export async function updateRepositoriesForIntegration(
167171
.set({
168172
repositories,
169173
repositories_synced_at: new Date().toISOString(),
174+
auth_invalid_at: null,
175+
auth_invalid_reason: null,
170176
updated_at: new Date().toISOString(),
171177
})
172178
.where(eq(platform_integrations.id, integrationId));
@@ -416,6 +422,8 @@ export async function autoCompleteInstallation({
416422
scopes: installationData.events,
417423
installed_at: new Date(installationData.created_at).toISOString(),
418424
metadata: completedMetadata,
425+
auth_invalid_at: null,
426+
auth_invalid_reason: null,
419427
updated_at: new Date().toISOString(),
420428
})
421429
.where(eq(platform_integrations.id, integrationId));
@@ -578,6 +586,8 @@ export async function upsertPlatformIntegrationForOwner(
578586
integration_status: INTEGRATION_STATUS.ACTIVE,
579587
repositories: data.repositories || null,
580588
github_app_type: data.githubAppType || existing.github_app_type,
589+
auth_invalid_at: null,
590+
auth_invalid_reason: null,
581591
updated_at: new Date().toISOString(),
582592
})
583593
.where(eq(platform_integrations.id, existing.id));

apps/web/src/lib/security-agent/core/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ export type SyncResult = {
146146
errors: number;
147147
/** Repos where Dependabot alerts are permanently disabled (safe to skip) */
148148
skipped: number;
149+
/** Repos where the GitHub App installation auth is invalid and needs reauthorization */
150+
authInvalid: number;
151+
/** Names of repos skipped because the installation needs reauthorization */
152+
authInvalidRepos: string[];
153+
/** True when the GitHub App installation needs user reauthorization */
154+
reauthRequired: boolean;
149155
/** Repos that returned 404 or are access-blocked (deleted/transferred/inaccessible) */
150156
staleRepos: string[];
151157
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
2+
3+
const mockPaginate = jest.fn() as jest.MockedFunction<(...args: unknown[]) => Promise<unknown[]>>;
4+
const mockWarnExceptInTest = jest.fn();
5+
const mockErrorExceptInTest = jest.fn();
6+
const mockSentryLog = jest.fn();
7+
const mockSentryLogger = jest.fn(() => mockSentryLog);
8+
9+
jest.mock('@octokit/rest', () => ({
10+
Octokit: jest.fn().mockImplementation(() => ({
11+
paginate: mockPaginate,
12+
rest: {
13+
dependabot: {
14+
listAlertsForRepo: jest.fn(),
15+
},
16+
},
17+
})),
18+
}));
19+
20+
jest.mock('@/lib/integrations/platforms/github/adapter', () => ({
21+
generateGitHubInstallationToken: jest.fn(async () => ({ token: 'token' })),
22+
}));
23+
24+
jest.mock('@/lib/utils.server', () => ({
25+
sentryLogger: mockSentryLogger,
26+
warnExceptInTest: mockWarnExceptInTest,
27+
errorExceptInTest: mockErrorExceptInTest,
28+
}));
29+
30+
describe('dependabot-api', () => {
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
});
34+
35+
it('classifies 401 as auth_invalid and preserves existing skip classifications', async () => {
36+
const { classifyFetchAlertsError } = await import('./dependabot-api');
37+
38+
expect(classifyFetchAlertsError(401, 'Bad credentials')).toBe('auth_invalid');
39+
expect(classifyFetchAlertsError(404, 'Not Found')).toBe('repo_not_found');
40+
expect(classifyFetchAlertsError(451, 'Unavailable For Legal Reasons')).toBe('access_blocked');
41+
expect(classifyFetchAlertsError(403, 'Repository access blocked')).toBe('access_blocked');
42+
expect(classifyFetchAlertsError(422, 'Dependabot alerts are disabled')).toBe('alerts_disabled');
43+
expect(classifyFetchAlertsError(403, 'Dependabot alerts are not available')).toBe(
44+
'alerts_disabled'
45+
);
46+
});
47+
48+
it('returns auth_invalid for 401 without throwing or Sentry logging', async () => {
49+
const { fetchAllDependabotAlerts } = await import('./dependabot-api');
50+
mockPaginate.mockRejectedValueOnce({ status: 401, message: 'Bad credentials' });
51+
52+
await expect(fetchAllDependabotAlerts('inst-1', 'acme', 'widgets')).resolves.toEqual({
53+
status: 'auth_invalid',
54+
});
55+
56+
expect(mockWarnExceptInTest).toHaveBeenCalledWith(
57+
'GitHub App installation auth invalid for acme/widgets, skipping',
58+
expect.objectContaining({ status: 401, message: 'Bad credentials' })
59+
);
60+
expect(mockErrorExceptInTest).not.toHaveBeenCalled();
61+
expect(mockSentryLog).toHaveBeenCalledWith('Fetching alerts for acme/widgets', {
62+
installationId: 'inst-1',
63+
});
64+
expect(mockSentryLog).toHaveBeenCalledTimes(1);
65+
});
66+
});

apps/web/src/lib/security-agent/github/dependabot-api.ts

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77
import { Octokit } from '@octokit/rest';
88
import { generateGitHubInstallationToken } from '@/lib/integrations/platforms/github/adapter';
99
import type { DependabotAlertRaw, DependabotAlertState } from '../core/types';
10-
import { sentryLogger } from '@/lib/utils.server';
10+
import { errorExceptInTest, sentryLogger, warnExceptInTest } from '@/lib/utils.server';
1111

1212
const log = sentryLogger('security-agent:dependabot-api', 'info');
1313
const warn = sentryLogger('security-agent:dependabot-api', 'warning');
14-
const logError = sentryLogger('security-agent:dependabot-api', 'error');
1514

1615
/**
1716
* Dependabot alert from GitHub API
@@ -109,9 +108,14 @@ export type FetchAlertsResult =
109108
| { status: 'success'; alerts: DependabotAlertRaw[] }
110109
| { status: 'repo_not_found' }
111110
| { status: 'alerts_disabled' }
112-
| { status: 'access_blocked' };
111+
| { status: 'access_blocked' }
112+
| { status: 'auth_invalid' };
113113

114-
type FetchAlertsSkipStatus = 'repo_not_found' | 'alerts_disabled' | 'access_blocked';
114+
type FetchAlertsSkipStatus =
115+
| 'repo_not_found'
116+
| 'alerts_disabled'
117+
| 'access_blocked'
118+
| 'auth_invalid';
115119

116120
// Permanent repo-level settings — safe to skip without blocking freshness.
117121
const DEPENDABOT_DISABLED_HINTS = [
@@ -134,10 +138,14 @@ function matchesAnyHint(message: string | undefined, hints: readonly string[]):
134138
return hints.some(hint => normalized.includes(hint));
135139
}
136140

137-
function classifyFetchAlertsError(
141+
export function classifyFetchAlertsError(
138142
httpStatus?: number,
139143
message?: string
140144
): FetchAlertsSkipStatus | null {
145+
if (httpStatus === 401) {
146+
return 'auth_invalid';
147+
}
148+
141149
if (httpStatus === 404) {
142150
return 'repo_not_found';
143151
}
@@ -217,6 +225,14 @@ export async function fetchAllDependabotAlerts(
217225
const message = (error as { message?: string }).message;
218226
const skipStatus = classifyFetchAlertsError(httpStatus, message);
219227

228+
if (skipStatus === 'auth_invalid') {
229+
warnExceptInTest(`GitHub App installation auth invalid for ${owner}/${repo}, skipping`, {
230+
status: httpStatus,
231+
message,
232+
});
233+
return { status: 'auth_invalid' };
234+
}
235+
220236
if (skipStatus === 'alerts_disabled') {
221237
warn(`Dependabot alerts are disabled for ${owner}/${repo}, skipping`, {
222238
status: httpStatus,
@@ -240,7 +256,7 @@ export async function fetchAllDependabotAlerts(
240256
return { status: 'repo_not_found' };
241257
}
242258

243-
logError(`Error fetching alerts for ${owner}/${repo}`, {
259+
errorExceptInTest(`Error fetching alerts for ${owner}/${repo}`, {
244260
status: httpStatus,
245261
message,
246262
durationMs: apiDurationMs,
@@ -258,19 +274,42 @@ export async function fetchOpenDependabotAlerts(
258274
installationId: string,
259275
owner: string,
260276
repo: string
261-
): Promise<DependabotAlertRaw[]> {
277+
): Promise<FetchAlertsResult> {
262278
const tokenData = await generateGitHubInstallationToken(installationId);
263279
const octokit = new Octokit({ auth: tokenData.token });
264280

265-
// Use Octokit's paginate helper which handles cursor-based pagination automatically
266-
const data = await octokit.paginate(octokit.rest.dependabot.listAlertsForRepo, {
267-
owner,
268-
repo,
269-
state: 'open',
270-
per_page: 100,
271-
});
281+
try {
282+
// Use Octokit's paginate helper which handles cursor-based pagination automatically
283+
const data = await octokit.paginate(octokit.rest.dependabot.listAlertsForRepo, {
284+
owner,
285+
repo,
286+
state: 'open',
287+
per_page: 100,
288+
});
272289

273-
return data.map(alert => toInternalAlert(alert as unknown as GitHubDependabotAlert));
290+
return {
291+
status: 'success',
292+
alerts: data.map(alert => toInternalAlert(alert as unknown as GitHubDependabotAlert)),
293+
};
294+
} catch (error) {
295+
const httpStatus = (error as { status?: number }).status;
296+
const message = (error as { message?: string }).message;
297+
const skipStatus = classifyFetchAlertsError(httpStatus, message);
298+
299+
if (skipStatus === 'auth_invalid') {
300+
warnExceptInTest(`GitHub App installation auth invalid for ${owner}/${repo}, skipping`, {
301+
status: httpStatus,
302+
message,
303+
});
304+
return { status: 'auth_invalid' };
305+
}
306+
307+
if (skipStatus) {
308+
return { status: skipStatus };
309+
}
310+
311+
throw error;
312+
}
274313
}
275314

276315
/**
@@ -294,8 +333,16 @@ export async function fetchDependabotAlert(
294333

295334
return toInternalAlert(data as unknown as GitHubDependabotAlert);
296335
} catch (error) {
336+
const status = (error as { status?: number }).status;
337+
if (status === 401) {
338+
warnExceptInTest(`GitHub App installation auth invalid for ${owner}/${repo}, skipping`, {
339+
status,
340+
});
341+
return null;
342+
}
343+
297344
// Return null if alert not found
298-
if ((error as { status?: number }).status === 404) {
345+
if (status === 404) {
299346
return null;
300347
}
301348
throw error;
@@ -365,8 +412,15 @@ export async function isDependabotEnabled(
365412
});
366413
return true;
367414
} catch (error) {
368-
// 403 or 404 typically means Dependabot is not enabled or no access
415+
// 401 means the GitHub App installation needs reauthorization.
416+
// 403 or 404 typically means Dependabot is not enabled or no access.
369417
const status = (error as { status?: number }).status;
418+
if (status === 401) {
419+
warnExceptInTest(`GitHub App installation auth invalid for ${owner}/${repo}, skipping`, {
420+
status,
421+
});
422+
return false;
423+
}
370424
if (status === 403 || status === 404) {
371425
return false;
372426
}

apps/web/src/lib/security-agent/router/shared-handlers.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,19 +128,26 @@ export function createSecurityAgentHandlers<TExtra = {}>(deps: SecurityAgentDeps
128128
hasIntegration: false,
129129
hasPermissions: false,
130130
reauthorizeUrl: null,
131+
authInvalidAt: integration?.auth_invalid_at ?? null,
132+
authInvalidReason: integration?.auth_invalid_reason ?? null,
131133
};
132134
}
133135

134136
const hasPermissions = hasSecurityReviewPermissions(integration);
137+
// UI reauthorization state is intentionally time-invariant: once GitHub returns
138+
// auth-invalid, keep prompting until a sync or install-refresh path clears the flag.
139+
const hasEffectivePermissions = hasPermissions && !integration.auth_invalid_at;
135140

136141
return {
137142
hasIntegration: true,
138-
hasPermissions,
139-
reauthorizeUrl: hasPermissions
143+
hasPermissions: hasEffectivePermissions,
144+
reauthorizeUrl: hasEffectivePermissions
140145
? null
141146
: integration.platform_installation_id
142147
? getReauthorizeUrl(integration.platform_installation_id)
143148
: null,
149+
authInvalidAt: integration.auth_invalid_at,
150+
authInvalidReason: integration.auth_invalid_reason,
144151
};
145152
},
146153

@@ -475,6 +482,7 @@ export function createSecurityAgentHandlers<TExtra = {}>(deps: SecurityAgentDeps
475482
syncResult: {
476483
synced: syncResult.synced,
477484
errors: syncResult.errors,
485+
reauthRequired: syncResult.reauthRequired,
478486
},
479487
};
480488
}
@@ -736,6 +744,7 @@ export function createSecurityAgentHandlers<TExtra = {}>(deps: SecurityAgentDeps
736744
success: true,
737745
synced: result.synced,
738746
errors: result.errors,
747+
reauthRequired: result.reauthRequired,
739748
};
740749
}
741750

@@ -800,6 +809,7 @@ export function createSecurityAgentHandlers<TExtra = {}>(deps: SecurityAgentDeps
800809
success: true,
801810
synced: result.synced,
802811
errors: result.errors,
812+
reauthRequired: result.reauthRequired,
803813
};
804814
},
805815
},

0 commit comments

Comments
 (0)