diff --git a/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx b/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx index b4468097ec3..da28c794ac5 100644 --- a/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx +++ b/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx @@ -20,12 +20,14 @@ export type RepositoryMultiSelectProps = { repositories: Repository[]; selectedIds: TId[]; onSelectionChange: (selectedIds: TId[]) => void; + renderRepositoryAccessory?: (repository: Repository) => React.ReactNode; }; export function RepositoryMultiSelect({ repositories, selectedIds, onSelectionChange, + renderRepositoryAccessory, }: RepositoryMultiSelectProps) { const [searchQuery, setSearchQuery] = useState(''); @@ -116,7 +118,7 @@ export function RepositoryMultiSelect({ /> ); diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index b524d404267..9d2b59dcd76 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -14,6 +14,7 @@ import type { SecurityFinding } from '@kilocode/db/schema'; import type { SecurityRemediationAdmissionRejectionReason } from '@kilocode/worker-utils/security-remediation-policy'; import { getSecurityCommandFailureMessage } from '@kilocode/app-shared/security-agent'; import type { SecurityAgentUiInteraction } from '@/lib/security-agent/core/schemas'; +import type { DependabotAlertsAvailability } from '@/lib/security-agent/core/types'; import { isGitHubIntegrationError } from '@/lib/security-agent/core/error-display'; import type { DismissReason } from './DismissFindingDialog'; import { getRemediationUnavailableCopy } from './remediation-unavailable-copy'; @@ -76,8 +77,20 @@ type SecurityAgentContextValue = { refetchConfig: () => Promise; // Repositories - allRepositories: Array<{ id: number; fullName: string; name: string; private: boolean }>; - filteredRepositories: Array<{ id: number; fullName: string; name: string; private: boolean }>; + allRepositories: Array<{ + id: number; + fullName: string; + name: string; + private: boolean; + dependabotAlerts: DependabotAlertsAvailability; + }>; + filteredRepositories: Array<{ + id: number; + fullName: string; + name: string; + private: boolean; + dependabotAlerts: DependabotAlertsAvailability; + }>; // Mutation handlers trackUiInteraction: (interaction: SecurityAgentUiInteraction) => void; diff --git a/apps/web/src/components/security-agent/SecurityConfigSections.tsx b/apps/web/src/components/security-agent/SecurityConfigSections.tsx index 53826c53f4c..37514f76347 100644 --- a/apps/web/src/components/security-agent/SecurityConfigSections.tsx +++ b/apps/web/src/components/security-agent/SecurityConfigSections.tsx @@ -14,6 +14,8 @@ import { } from 'lucide-react'; import { RepositoryMultiSelect } from '@/components/code-reviews/RepositoryMultiSelect'; import { ModelCombobox } from '@/components/shared/ModelCombobox'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -334,6 +336,21 @@ export function RepositorySection({ repositories: SecurityRepository[]; isLoading?: boolean; }) { + const dependabotAlertsByRepositoryId = new Map( + repositories.map(repository => [repository.id, repository.dependabotAlerts]) + ); + const disabledRepositories = repositories.filter( + repository => repository.dependabotAlerts === 'disabled' + ); + const selectedRepositoryIds = new Set(state.selectedRepositoryIds); + const selectedDisabledRepositories = + state.repositorySelectionMode === 'all' + ? disabledRepositories + : disabledRepositories.filter(repository => selectedRepositoryIds.has(repository.id)); + const displayedDisabledRepositories = selectedDisabledRepositories.slice(0, 5); + const additionalDisabledRepositoryCount = + selectedDisabledRepositories.length - displayedDisabledRepositories.length; + return ( + {selectedDisabledRepositories.length > 0 && ( + + + )} {state.repositorySelectionMode === 'selected' && (
@@ -384,6 +422,20 @@ export function RepositorySection({ onSelectionChange={selectedRepositoryIds => setState(current => ({ ...current, selectedRepositoryIds })) } + renderRepositoryAccessory={repository => { + if (dependabotAlertsByRepositoryId.get(repository.id) !== 'disabled') { + return null; + } + + return ( + + Dependabot alerts off + + ); + }} />
)} diff --git a/apps/web/src/components/security-agent/security-config-types.ts b/apps/web/src/components/security-agent/security-config-types.ts index 51a9e55fd98..24a5c5056fc 100644 --- a/apps/web/src/components/security-agent/security-config-types.ts +++ b/apps/web/src/components/security-agent/security-config-types.ts @@ -1,4 +1,5 @@ import type { Repository } from '@/components/code-reviews/RepositoryMultiSelect'; +import type { DependabotAlertsAvailability } from '@/lib/security-agent/core/types'; export type SlaConfig = { critical: number; @@ -19,6 +20,7 @@ export type SecurityRepository = { fullName: string; name: string; private: boolean; + dependabotAlerts: DependabotAlertsAvailability; }; export type SecurityConfigFormState = { diff --git a/apps/web/src/lib/security-agent/core/types.ts b/apps/web/src/lib/security-agent/core/types.ts index 176181a284e..20dcc461e50 100644 --- a/apps/web/src/lib/security-agent/core/types.ts +++ b/apps/web/src/lib/security-agent/core/types.ts @@ -46,6 +46,7 @@ export type SecurityFindingAnalysisStatus = (typeof SecurityFindingAnalysisStatus)[keyof typeof SecurityFindingAnalysisStatus]; export type AnalysisMode = 'auto' | 'shallow' | 'deep'; +export type DependabotAlertsAvailability = 'enabled' | 'disabled' | 'unknown'; export type AutoAnalysisMinSeverity = 'critical' | 'high' | 'medium' | 'all'; export type AutoRemediationMinSeverity = 'critical' | 'high' | 'medium' | 'all'; diff --git a/apps/web/src/lib/security-agent/github/dependabot-api.test.ts b/apps/web/src/lib/security-agent/github/dependabot-api.test.ts index baaac392daf..8ede1a17348 100644 --- a/apps/web/src/lib/security-agent/github/dependabot-api.test.ts +++ b/apps/web/src/lib/security-agent/github/dependabot-api.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; const mockPaginate = jest.fn() as jest.MockedFunction<(...args: unknown[]) => Promise>; +const mockListAlertsForRepo = jest.fn() as jest.MockedFunction< + (...args: unknown[]) => Promise +>; +const mockGenerateGitHubInstallationToken = jest.fn(async () => ({ token: 'token' })); const mockWarnExceptInTest = jest.fn(); const mockErrorExceptInTest = jest.fn(); const mockSentryLog = jest.fn(); @@ -11,14 +15,14 @@ jest.mock('@octokit/rest', () => ({ paginate: mockPaginate, rest: { dependabot: { - listAlertsForRepo: jest.fn(), + listAlertsForRepo: mockListAlertsForRepo, }, }, })), })); jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ - generateGitHubInstallationToken: jest.fn(async () => ({ token: 'token' })), + generateGitHubInstallationToken: mockGenerateGitHubInstallationToken, })); jest.mock('@/lib/utils.server', () => ({ @@ -63,4 +67,77 @@ describe('dependabot-api', () => { }); expect(mockSentryLog).toHaveBeenCalledTimes(1); }); + + it('reports enabled only when the Dependabot alerts endpoint succeeds', async () => { + const { checkDependabotAlertsAvailability } = await import('./dependabot-api'); + mockListAlertsForRepo.mockResolvedValueOnce({ data: [] }); + mockListAlertsForRepo.mockRejectedValueOnce({ + status: 422, + message: 'Dependabot alerts are disabled for this repository', + }); + mockListAlertsForRepo.mockRejectedValueOnce({ status: 403, message: 'Forbidden' }); + + await expect( + checkDependabotAlertsAvailability('inst-1', 'standard', [ + { id: 1, fullName: 'acme/enabled' }, + { id: 2, fullName: 'acme/disabled' }, + { id: 3, fullName: 'acme/unknown' }, + ]) + ).resolves.toEqual([ + { id: 1, status: 'enabled' }, + { id: 2, status: 'disabled' }, + { id: 3, status: 'unknown' }, + ]); + + expect(mockListAlertsForRepo).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'enabled', + state: 'open', + per_page: 1, + }); + }); + + it('reuses a recent repository result without another GitHub request', async () => { + const { checkDependabotAlertsAvailability } = await import('./dependabot-api'); + mockListAlertsForRepo.mockResolvedValue({ data: [] }); + const repositories = [{ id: 1, fullName: 'cache-test/widgets' }]; + + await expect( + checkDependabotAlertsAvailability('cache-installation', 'standard', repositories) + ).resolves.toEqual([{ id: 1, status: 'enabled' }]); + await expect( + checkDependabotAlertsAvailability('cache-installation', 'standard', repositories) + ).resolves.toEqual([{ id: 1, status: 'enabled' }]); + + expect(mockGenerateGitHubInstallationToken).toHaveBeenCalledTimes(1); + expect(mockListAlertsForRepo).toHaveBeenCalledTimes(1); + }); + + it('keeps every repository unknown when GitHub authentication fails', async () => { + const { checkDependabotAlertsAvailability } = await import('./dependabot-api'); + mockGenerateGitHubInstallationToken.mockRejectedValueOnce(new Error('auth unavailable')); + + await expect( + checkDependabotAlertsAvailability('auth-failure-installation', 'standard', [ + { id: 1, fullName: 'auth-failure/widgets' }, + ]) + ).resolves.toEqual([{ id: 1, status: 'unknown' }]); + + expect(mockListAlertsForRepo).not.toHaveBeenCalled(); + }); + + it('isolates malformed repository names instead of failing the whole check', async () => { + const { checkDependabotAlertsAvailability } = await import('./dependabot-api'); + mockListAlertsForRepo.mockResolvedValueOnce({ data: [] }); + + await expect( + checkDependabotAlertsAvailability('malformed-installation', 'standard', [ + { id: 1, fullName: undefined as never }, + { id: 2, fullName: 'acme/valid' }, + ]) + ).resolves.toEqual([ + { id: 1, status: 'unknown' }, + { id: 2, status: 'enabled' }, + ]); + }); }); diff --git a/apps/web/src/lib/security-agent/github/dependabot-api.ts b/apps/web/src/lib/security-agent/github/dependabot-api.ts index 6762f60650f..234f1eb88b2 100644 --- a/apps/web/src/lib/security-agent/github/dependabot-api.ts +++ b/apps/web/src/lib/security-agent/github/dependabot-api.ts @@ -5,8 +5,16 @@ */ import { Octokit } from '@octokit/rest'; -import { generateGitHubInstallationToken } from '@/lib/integrations/platforms/github/adapter'; -import type { DependabotAlertRaw, DependabotAlertState } from '../core/types'; +import pLimit from 'p-limit'; +import { + generateGitHubInstallationToken, + type GitHubAppType, +} from '@/lib/integrations/platforms/github/adapter'; +import type { + DependabotAlertRaw, + DependabotAlertsAvailability, + DependabotAlertState, +} from '../core/types'; import { errorExceptInTest, sentryLogger, warnExceptInTest } from '@/lib/utils.server'; const log = sentryLogger('security-agent:dependabot-api', 'info'); @@ -117,6 +125,11 @@ type FetchAlertsSkipStatus = | 'access_blocked' | 'auth_invalid'; +export type RepositoryDependabotAlertsAvailability = { + id: number; + status: DependabotAlertsAvailability; +}; + // Permanent repo-level settings — safe to skip without blocking freshness. const DEPENDABOT_DISABLED_HINTS = [ 'dependabot alerts are disabled', @@ -129,6 +142,14 @@ const DEPENDABOT_DISABLED_HINTS = [ // advancement so the owner doesn't look fully synced. const ACCESS_BLOCKED_HINTS = ['repository access blocked'] as const; +const DEPENDABOT_AVAILABILITY_CACHE_TTL_MS = 30 * 60_000; +const DEPENDABOT_AVAILABILITY_CACHE_MAX_ENTRIES = 10_000; +const dependabotAvailabilityCache = new Map< + string, + { status: DependabotAlertsAvailability; expiresAtMs: number } +>(); +const dependabotAvailabilityInFlight = new Map>(); + function normalizeErrorMessage(message?: string): string { return (message ?? '').toLowerCase(); } @@ -169,6 +190,171 @@ export function classifyFetchAlertsError( return null; } +/** + * Checks whether GitHub's Dependabot alerts API is available for each repository. + * A successful request is enough to prove alerts are enabled, even when no alerts exist. + * Ambiguous permission, authentication, and transient failures remain unknown so the UI + * never labels a repository as disabled without a definitive GitHub response. + */ +export async function checkDependabotAlertsAvailability( + installationId: string, + appType: GitHubAppType, + repositories: ReadonlyArray<{ id: number; fullName: string }> +): Promise { + if (repositories.length === 0) return []; + + const now = Date.now(); + sweepDependabotAvailabilityCache(now); + const cachedResults = new Map(); + const repositoriesToCheck = repositories.filter(repository => { + const cacheKey = dependabotAvailabilityCacheKey(installationId, appType, repository.fullName); + if (!cacheKey) return true; + const cached = dependabotAvailabilityCache.get(cacheKey); + if (!cached) return true; + cachedResults.set(repository.id, cached.status); + return false; + }); + + if (repositoriesToCheck.length === 0) { + return repositories.map(repository => ({ + id: repository.id, + status: cachedResults.get(repository.id) ?? 'unknown', + })); + } + + let token: string; + try { + token = (await generateGitHubInstallationToken(installationId, appType)).token; + } catch { + warnExceptInTest('Unable to authenticate while checking Dependabot alerts availability'); + return repositories.map(repository => ({ + id: repository.id, + status: cachedResults.get(repository.id) ?? 'unknown', + })); + } + + const octokit = new Octokit({ auth: token }); + const limit = pLimit(5); + const checkedResults = await Promise.all( + repositoriesToCheck.map(repository => + limit(async (): Promise => { + try { + const status = await checkRepositoryDependabotAlertsAvailability({ + installationId, + appType, + repositoryFullName: repository.fullName, + octokit, + }); + return { id: repository.id, status }; + } catch { + return { id: repository.id, status: 'unknown' }; + } + }) + ) + ); + const checkedResultsById = new Map(checkedResults.map(result => [result.id, result.status])); + const results = repositories.map(repository => ({ + id: repository.id, + status: cachedResults.get(repository.id) ?? checkedResultsById.get(repository.id) ?? 'unknown', + })); + + const unknownCount = results.filter(result => result.status === 'unknown').length; + if (unknownCount > 0) { + warnExceptInTest('Unable to determine Dependabot alerts availability for some repositories', { + repositoryCount: unknownCount, + }); + } + + return results; +} + +async function checkRepositoryDependabotAlertsAvailability(input: { + installationId: string; + appType: GitHubAppType; + repositoryFullName: string; + octokit: Octokit; +}): Promise { + const cacheKey = dependabotAvailabilityCacheKey( + input.installationId, + input.appType, + input.repositoryFullName + ); + if (!cacheKey) return 'unknown'; + const inFlight = dependabotAvailabilityInFlight.get(cacheKey); + if (inFlight) return await inFlight; + + const promise = fetchRepositoryDependabotAlertsAvailability( + input.repositoryFullName, + input.octokit + ); + dependabotAvailabilityInFlight.set(cacheKey, promise); + + try { + const status = await promise; + if (status !== 'unknown') { + dependabotAvailabilityCache.set(cacheKey, { + status, + expiresAtMs: Date.now() + DEPENDABOT_AVAILABILITY_CACHE_TTL_MS, + }); + sweepDependabotAvailabilityCache(Date.now()); + } + return status; + } finally { + dependabotAvailabilityInFlight.delete(cacheKey); + } +} + +async function fetchRepositoryDependabotAlertsAvailability( + repositoryFullName: string, + octokit: Octokit +): Promise { + const [owner, repo, ...extraParts] = repositoryFullName.split('/'); + if (!owner || !repo || extraParts.length > 0) return 'unknown'; + + try { + await octokit.rest.dependabot.listAlertsForRepo({ + owner, + repo, + state: 'open', + per_page: 1, + }); + return 'enabled'; + } catch (error) { + const httpStatus = (error as { status?: number }).status; + const message = (error as { message?: string }).message; + return classifyFetchAlertsError(httpStatus, message) === 'alerts_disabled' + ? 'disabled' + : 'unknown'; + } +} + +function dependabotAvailabilityCacheKey( + installationId: string, + appType: GitHubAppType, + repositoryFullName: unknown +): string | null { + if (typeof repositoryFullName !== 'string') return null; + const normalizedRepositoryFullName = repositoryFullName.trim().toLowerCase(); + if (!normalizedRepositoryFullName) return null; + return JSON.stringify([ + installationId.trim().toLowerCase(), + appType, + normalizedRepositoryFullName, + ]); +} + +function sweepDependabotAvailabilityCache(now: number): void { + for (const [key, cached] of dependabotAvailabilityCache) { + if (cached.expiresAtMs <= now) dependabotAvailabilityCache.delete(key); + } + + while (dependabotAvailabilityCache.size > DEPENDABOT_AVAILABILITY_CACHE_MAX_ENTRIES) { + const oldestKey = dependabotAvailabilityCache.keys().next().value; + if (typeof oldestKey !== 'string') break; + dependabotAvailabilityCache.delete(oldestKey); + } +} + /** * Fetch ALL Dependabot alerts for a repository (including fixed/dismissed) * This is used for full sync to capture complete history. diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts index 479d0fec673..115e9730645 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts @@ -40,6 +40,10 @@ const mockLogSecurityAudit = jest.fn(); const mockCreateSecurityAuditLog = jest.fn(); const mockUpsertSecurityAgentConfig = jest.fn(); const mockSetSecurityAgentEnabled = jest.fn(); +const mockCheckDependabotAlertsAvailability = + jest.fn< + (installationId: string, appType: string, repositories: unknown[]) => Promise + >(); const mockAutoDismissEligibleFindings = jest.fn< ( @@ -66,6 +70,9 @@ jest.mock('../github/permissions', () => ({ hasSecurityReviewPermissions: () => true, getReauthorizeUrl: jest.fn(), })); +jest.mock('../github/dependabot-api', () => ({ + checkDependabotAlertsAvailability: mockCheckDependabotAlertsAvailability, +})); jest.mock('../posthog-tracking', () => ({ trackSecurityAgentEnabled: jest.fn(), trackSecurityAgentConfigSaved: jest.fn(), @@ -134,6 +141,7 @@ beforeEach(() => { mockGetSecurityAgentConfigWithStatus.mockResolvedValue(null); mockGetRemediationAttemptHistory.mockResolvedValue([]); mockEnqueueBacklogFindings.mockResolvedValue(0); + mockCheckDependabotAlertsAvailability.mockResolvedValue([]); }); function createHandlers() { @@ -151,7 +159,7 @@ function createHandlers() { id: 'integration-123', integration_status: 'active', platform_installation_id: 'installation-123', - repositories: [{ id: 1, full_name: 'kilo/repo' }], + repositories: [{ id: 1, full_name: 'kilo/repo', name: 'repo', private: true }], }) as never, trackingExtras: () => ({}), }); @@ -260,6 +268,43 @@ describe('trackUiInteraction', () => { }); }); +describe('getRepositories', () => { + it('includes Dependabot alerts availability for each repository', async () => { + mockCheckDependabotAlertsAvailability.mockResolvedValueOnce([{ id: 1, status: 'disabled' }]); + + await expect(createHandlers().getRepositories({ ctx: context, input: {} })).resolves.toEqual([ + { + id: 1, + fullName: 'kilo/repo', + name: 'repo', + private: true, + dependabotAlerts: 'disabled', + }, + ]); + + expect(mockCheckDependabotAlertsAvailability).toHaveBeenCalledWith( + 'installation-123', + 'standard', + [ + { + id: 1, + fullName: 'kilo/repo', + name: 'repo', + private: true, + }, + ] + ); + }); + + it('keeps repository selection available when the availability check fails', async () => { + mockCheckDependabotAlertsAvailability.mockRejectedValueOnce(new Error('GitHub unavailable')); + + await expect(createHandlers().getRepositories({ ctx: context, input: {} })).resolves.toEqual([ + expect.objectContaining({ id: 1, dependabotAlerts: 'unknown' }), + ]); + }); +}); + describe('getConfig', () => { it('marks new owners without config as setup state', async () => { await expect(createHandlers().getConfig({ ctx: context, input: {} })).resolves.toMatchObject({ diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.ts b/apps/web/src/lib/security-agent/router/shared-handlers.ts index 1bbb0cfe101..890bb365050 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -46,6 +46,7 @@ import { hasSecurityReviewPermissions, getReauthorizeUrl, } from '@/lib/security-agent/github/permissions'; +import { checkDependabotAlertsAvailability } from '@/lib/security-agent/github/dependabot-api'; import { submitManualSecuritySync } from '@/lib/security-agent/services/manual-sync-client'; import { submitManualFindingDismissal } from '@/lib/security-agent/services/manual-dismiss-client'; import { submitManualAnalysisStart } from '@/lib/security-agent/services/manual-analysis-client'; @@ -846,12 +847,43 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps repos = fetchedRepos; } - return repos.map(repo => ({ + const repositoryDetails = repos.map(repo => ({ id: repo.id, fullName: repo.full_name, name: repo.name, private: repo.private, })); + const installationId = integration.platform_installation_id; + if (!installationId || !hasSecurityReviewPermissions(integration)) { + return repositoryDetails.map(repository => ({ + ...repository, + dependabotAlerts: 'unknown' as const, + })); + } + + const appType = integration.github_app_type || 'standard'; + let availability: Awaited>; + try { + availability = await checkDependabotAlertsAvailability( + installationId, + appType, + repositoryDetails + ); + } catch (error) { + console.error('Failed to check Dependabot alerts availability', { + error: error instanceof Error ? error.message : String(error), + }); + return repositoryDetails.map(repository => ({ + ...repository, + dependabotAlerts: 'unknown' as const, + })); + } + const availabilityById = new Map(availability.map(result => [result.id, result.status])); + + return repositoryDetails.map(repository => ({ + ...repository, + dependabotAlerts: availabilityById.get(repository.id) ?? ('unknown' as const), + })); }, // -----------------------------------------------------------------------