From afd1181b9ea396d80b2ef6d298039e69aff77331 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 18 May 2026 21:37:31 +0200 Subject: [PATCH 01/18] feat(security-agent): move manual workflows into workers --- .../security-agent/SecurityAgentContext.tsx | 57 +- .../SecurityAgentPageClient.tsx | 48 +- apps/web/src/lib/config.server.ts | 6 + .../router/shared-handlers.test.ts | 309 +++++++++++ .../security-agent/router/shared-handlers.ts | 214 ++------ .../services/manual-analysis-client.test.ts | 52 ++ .../services/manual-analysis-client.ts | 64 +++ .../services/manual-dismiss-client.test.ts | 55 ++ .../services/manual-dismiss-client.ts | 88 +++ .../services/manual-sync-client.test.ts | 58 ++ .../services/manual-sync-client.ts | 78 +++ pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 2 +- .../src/callbacks/delivery.test.ts | 19 + .../src/callbacks/delivery.ts | 26 +- .../src/callbacks/queue-consumer.ts | 15 +- .../cloud-agent-next/src/callbacks/types.ts | 1 + .../src/persistence/CloudAgentSession.ts | 1 + .../src/persistence/schemas.ts | 1 + services/cloud-agent-next/src/server.ts | 2 +- services/cloud-agent-next/src/types.ts | 4 + .../session/callback-notification.test.ts | 28 + services/cloud-agent-next/wrangler.jsonc | 8 + services/security-auto-analysis/README.md | 13 +- .../src/auto-dismiss.test.ts | 216 ++++++++ .../src/auto-dismiss.ts | 182 +++++++ .../src/callbacks.test.ts | 347 ++++++++++++ .../security-auto-analysis/src/callbacks.ts | 416 ++++++++++++++ .../security-auto-analysis/src/consumer.ts | 5 +- .../src/db/queries.integration.test.ts | 256 +++++++++ .../src/db/queries.test.ts | 38 ++ .../security-auto-analysis/src/db/queries.ts | 199 ++++++- .../security-auto-analysis/src/dispatcher.ts | 8 +- .../security-auto-analysis/src/extraction.ts | 184 +++++++ .../security-auto-analysis/src/index.test.ts | 176 ++++++ services/security-auto-analysis/src/index.ts | 100 ++++ .../security-auto-analysis/src/launch.test.ts | 285 ++++++---- services/security-auto-analysis/src/launch.ts | 80 ++- .../src/manual-analysis.test.ts | 222 ++++++++ .../src/manual-analysis.ts | 192 +++++++ .../src/posthog.test.ts | 67 +++ .../security-auto-analysis/src/posthog.ts | 66 +++ .../src/session-result.ts | 55 ++ services/security-auto-analysis/src/token.ts | 14 + .../security-auto-analysis/src/types.test.ts | 25 +- services/security-auto-analysis/src/types.ts | 41 ++ .../worker-configuration.d.ts | 9 + .../security-auto-analysis/wrangler.jsonc | 56 ++ services/security-sync/README.md | 4 +- services/security-sync/package.json | 4 +- services/security-sync/src/dismiss.test.ts | 147 +++++ services/security-sync/src/dismiss.ts | 131 +++++ services/security-sync/src/index.test.ts | 293 ++++++++++ services/security-sync/src/index.ts | 345 ++++++++++-- services/security-sync/src/sync.test.ts | 101 ++++ services/security-sync/src/sync.ts | 512 +++++++++++++++--- services/security-sync/vitest.config.ts | 9 + .../security-sync/worker-configuration.d.ts | 9 +- services/security-sync/wrangler.jsonc | 22 + 59 files changed, 5541 insertions(+), 427 deletions(-) create mode 100644 apps/web/src/lib/security-agent/router/shared-handlers.test.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-analysis-client.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-dismiss-client.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-sync-client.test.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-sync-client.ts create mode 100644 services/security-auto-analysis/src/auto-dismiss.test.ts create mode 100644 services/security-auto-analysis/src/auto-dismiss.ts create mode 100644 services/security-auto-analysis/src/callbacks.test.ts create mode 100644 services/security-auto-analysis/src/callbacks.ts create mode 100644 services/security-auto-analysis/src/db/queries.integration.test.ts create mode 100644 services/security-auto-analysis/src/db/queries.test.ts create mode 100644 services/security-auto-analysis/src/extraction.ts create mode 100644 services/security-auto-analysis/src/index.test.ts create mode 100644 services/security-auto-analysis/src/manual-analysis.test.ts create mode 100644 services/security-auto-analysis/src/manual-analysis.ts create mode 100644 services/security-auto-analysis/src/posthog.test.ts create mode 100644 services/security-auto-analysis/src/posthog.ts create mode 100644 services/security-auto-analysis/src/session-result.ts create mode 100644 services/security-sync/src/dismiss.test.ts create mode 100644 services/security-sync/src/dismiss.ts create mode 100644 services/security-sync/src/index.test.ts create mode 100644 services/security-sync/src/sync.test.ts create mode 100644 services/security-sync/vitest.config.ts diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 805ab2b8b5..8c7a15381e 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -1,6 +1,14 @@ 'use client'; -import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'; +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -109,6 +117,8 @@ function getOptionalStringField(source: unknown, key: string): string | undefine return typeof value === 'string' ? value : undefined; } +const ACCEPTED_QUEUE_REFRESH_DELAYS_MS = [1000, 5000, 15000] as const; + type SecurityAgentProviderProps = { organizationId?: string; children: React.ReactNode; @@ -122,6 +132,27 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen const [startingAnalysisIds, setStartingAnalysisIds] = useState>(new Set()); const [gitHubError, setGitHubError] = useState(null); const toggleEnabledInFlightRef = useRef(false); + const acceptedQueueRefreshTimersRef = useRef([]); + + useEffect( + () => () => { + for (const timer of acceptedQueueRefreshTimersRef.current) { + window.clearTimeout(timer); + } + acceptedQueueRefreshTimersRef.current = []; + }, + [] + ); + + const refreshAcceptedQueueMutation = useCallback(() => { + void queryClient.invalidateQueries(); + for (const delay of ACCEPTED_QUEUE_REFRESH_DELAYS_MS) { + const timer = window.setTimeout(() => { + void queryClient.invalidateQueries(); + }, delay); + acceptedQueueRefreshTimersRef.current.push(timer); + } + }, [queryClient]); // Permission status query const { data: permissionData, isLoading: isLoadingPermission } = useQuery( @@ -160,8 +191,8 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.organizations.securityAgent.triggerSync.mutationOptions({ onSuccess: () => { setGitHubError(null); - toast.success('Sync completed successfully'); - void queryClient.invalidateQueries(); + toast.success('Sync queued'); + refreshAcceptedQueueMutation(); }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -182,7 +213,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.organizations.securityAgent.dismissFinding.mutationOptions({ onSuccess: () => { toast.success('Finding dismissed'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); }, onError: error => { toast.error('Failed to dismiss finding', { description: error.message }); @@ -205,9 +236,9 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen const { mutate: orgSetEnabledMutate, isPending: isOrgSetEnabledPending } = useMutation( trpc.organizations.securityAgent.setEnabled.mutationOptions({ onSuccess: async data => { - if ('syncResult' in data && data.syncResult) { + if ('initialSync' in data && data.initialSync) { toast.success('Security Agent enabled', { - description: `Initial sync completed: ${data.syncResult.synced} alerts synced${data.syncResult.errors > 0 ? `, ${data.syncResult.errors} errors` : ''}`, + description: 'Initial sync queued. Findings update as processing completes.', }); } else { toast.success('Security Agent setting updated'); @@ -229,7 +260,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen onSuccess: async (_data, variables) => { setGitHubError(null); toast.success('Analysis started'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); next.delete(variables.findingId); @@ -276,8 +307,8 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.securityAgent.triggerSync.mutationOptions({ onSuccess: () => { setGitHubError(null); - toast.success('Sync completed successfully'); - void queryClient.invalidateQueries(); + toast.success('Sync queued'); + refreshAcceptedQueueMutation(); }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -298,7 +329,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.securityAgent.dismissFinding.mutationOptions({ onSuccess: () => { toast.success('Finding dismissed'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); }, onError: error => { toast.error('Failed to dismiss finding', { description: error.message }); @@ -321,9 +352,9 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen const { mutate: personalSetEnabledMutate, isPending: isPersonalSetEnabledPending } = useMutation( trpc.securityAgent.setEnabled.mutationOptions({ onSuccess: async data => { - if ('syncResult' in data && data.syncResult) { + if ('initialSync' in data && data.initialSync) { toast.success('Security Agent enabled', { - description: `Initial sync completed: ${data.syncResult.synced} alerts synced${data.syncResult.errors > 0 ? `, ${data.syncResult.errors} errors` : ''}`, + description: 'Initial sync queued. Findings update as processing completes.', }); } else { toast.success('Security Agent setting updated'); @@ -345,7 +376,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen onSuccess: async (_data, variables) => { setGitHubError(null); toast.success('Analysis started'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); next.delete(variables.findingId); diff --git a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx index 5f3abbb1d1..343854aa45 100644 --- a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useMemo, useRef } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -28,6 +28,7 @@ type SecurityAgentPageClientProps = { }; const PAGE_SIZE = 20; +const ACCEPTED_QUEUE_REFRESH_DELAYS_MS = [1000, 5000, 15000] as const; function getOptionalStringField(source: unknown, key: string): string | undefined { if (typeof source !== 'object' || source === null) { @@ -57,10 +58,31 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli const [startingAnalysisIds, setStartingAnalysisIds] = useState>(new Set()); const [gitHubError, setGitHubError] = useState(null); const toggleEnabledInFlightRef = useRef(false); + const acceptedQueueRefreshTimersRef = useRef([]); const [sortBy, setSortBy] = useState<'severity_desc' | 'severity_asc' | 'sla_due_at_asc'>( 'severity_desc' ); + useEffect( + () => () => { + for (const timer of acceptedQueueRefreshTimersRef.current) { + window.clearTimeout(timer); + } + acceptedQueueRefreshTimersRef.current = []; + }, + [] + ); + + const refreshAcceptedQueueMutation = useCallback(() => { + void queryClient.invalidateQueries(); + for (const delay of ACCEPTED_QUEUE_REFRESH_DELAYS_MS) { + const timer = window.setTimeout(() => { + void queryClient.invalidateQueries(); + }, delay); + acceptedQueueRefreshTimersRef.current.push(timer); + } + }, [queryClient]); + const handleSortByChange = useCallback( (newSortBy: 'severity_desc' | 'severity_asc' | 'sla_due_at_asc') => { setSortBy(newSortBy); @@ -163,8 +185,8 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli trpc.organizations.securityAgent.triggerSync.mutationOptions({ onSuccess: () => { setGitHubError(null); // Clear any previous error on success - toast.success('Sync completed successfully'); - void queryClient.invalidateQueries(); + toast.success('Sync queued'); + refreshAcceptedQueueMutation(); }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -185,7 +207,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli trpc.organizations.securityAgent.dismissFinding.mutationOptions({ onSuccess: () => { toast.success('Finding dismissed'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); setDismissDialogOpen(false); setDetailDialogOpen(false); setSelectedFinding(null); @@ -211,9 +233,9 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli const { mutate: orgSetEnabledMutate, isPending: isOrgSetEnabledPending } = useMutation( trpc.organizations.securityAgent.setEnabled.mutationOptions({ onSuccess: async data => { - if ('syncResult' in data && data.syncResult) { + if ('initialSync' in data && data.initialSync) { toast.success('Security Agent enabled', { - description: `Initial sync completed: ${data.syncResult.synced} alerts synced${data.syncResult.errors > 0 ? `, ${data.syncResult.errors} errors` : ''}`, + description: 'Initial sync queued. Findings update as processing completes.', }); } else { toast.success('Security Agent setting updated'); @@ -235,8 +257,8 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli trpc.securityAgent.triggerSync.mutationOptions({ onSuccess: () => { setGitHubError(null); // Clear any previous error on success - toast.success('Sync completed successfully'); - void queryClient.invalidateQueries(); + toast.success('Sync queued'); + refreshAcceptedQueueMutation(); }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -257,7 +279,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli trpc.securityAgent.dismissFinding.mutationOptions({ onSuccess: () => { toast.success('Finding dismissed'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); setDismissDialogOpen(false); setDetailDialogOpen(false); setSelectedFinding(null); @@ -283,9 +305,9 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli const { mutate: personalSetEnabledMutate, isPending: isPersonalSetEnabledPending } = useMutation( trpc.securityAgent.setEnabled.mutationOptions({ onSuccess: async data => { - if ('syncResult' in data && data.syncResult) { + if ('initialSync' in data && data.initialSync) { toast.success('Security Agent enabled', { - description: `Initial sync completed: ${data.syncResult.synced} alerts synced${data.syncResult.errors > 0 ? `, ${data.syncResult.errors} errors` : ''}`, + description: 'Initial sync queued. Findings update as processing completes.', }); } else { toast.success('Security Agent setting updated'); @@ -308,7 +330,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli onSuccess: async (_data, variables) => { setGitHubError(null); // Clear any previous error on success toast.success('Analysis started'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); next.delete(variables.findingId); @@ -347,7 +369,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli onSuccess: async (_data, variables) => { setGitHubError(null); // Clear any previous error on success toast.success('Analysis started'); - void queryClient.invalidateQueries(); + refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); next.delete(variables.findingId); diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index 0c0a13a350..69174b72ef 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -360,6 +360,12 @@ export const MODEL_EVAL_INGEST_URL = getEnvVariable('MODEL_EVAL_INGEST_URL') || // Session ingest worker (public share proxy) export const SESSION_INGEST_WORKER_URL = getEnvVariable('SESSION_INGEST_WORKER_URL') || ''; +// Security Agent sync Worker command ingress +export const SECURITY_SYNC_WORKER_URL = getEnvVariable('SECURITY_SYNC_WORKER_URL') || ''; +// Security Agent auto-analysis Worker command ingress +export const SECURITY_AUTO_ANALYSIS_WORKER_URL = + getEnvVariable('SECURITY_AUTO_ANALYSIS_WORKER_URL') || ''; + // Google Web Risk API export const GOOGLE_WEB_RISK_API_KEY = getEnvVariable('GOOGLE_WEB_RISK_API_KEY'); 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 new file mode 100644 index 0000000000..bc56cee3e5 --- /dev/null +++ b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts @@ -0,0 +1,309 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { createSecurityAgentHandlers as createSecurityAgentHandlersType } from './shared-handlers'; +import type * as manualSyncClientModule from '../services/manual-sync-client'; +import type * as manualDismissClientModule from '../services/manual-dismiss-client'; +import type * as manualAnalysisClientModule from '../services/manual-analysis-client'; + +const mockSubmitManualSecuritySync = jest.fn() as jest.MockedFunction< + typeof manualSyncClientModule.submitManualSecuritySync +>; +const mockSubmitManualFindingDismissal = jest.fn() as jest.MockedFunction< + typeof manualDismissClientModule.submitManualFindingDismissal +>; +const mockSubmitManualAnalysisStart = jest.fn() as jest.MockedFunction< + typeof manualAnalysisClientModule.submitManualAnalysisStart +>; +const mockSyncDependabotAlertsForRepo = jest.fn(); +const mockSyncAllReposForOwner = jest.fn(); +const mockGetSecurityFindingById = jest.fn(); +const mockCanStartAnalysis = jest.fn(); +const mockTrackSecurityAgentSync = jest.fn(); +const mockLogSecurityAudit = jest.fn(); + +jest.mock('../services/manual-sync-client', () => ({ + submitManualSecuritySync: mockSubmitManualSecuritySync, +})); + +jest.mock('../services/manual-dismiss-client', () => ({ + submitManualFindingDismissal: mockSubmitManualFindingDismissal, +})); + +jest.mock('../services/manual-analysis-client', () => ({ + submitManualAnalysisStart: mockSubmitManualAnalysisStart, +})); + +jest.mock('../services/sync-service', () => ({ + syncDependabotAlertsForRepo: mockSyncDependabotAlertsForRepo, + syncAllReposForOwner: mockSyncAllReposForOwner, +})); + +jest.mock('../github/permissions', () => ({ + hasSecurityReviewPermissions: () => true, + getReauthorizeUrl: jest.fn(), +})); + +jest.mock('../posthog-tracking', () => ({ + trackSecurityAgentEnabled: jest.fn(), + trackSecurityAgentConfigSaved: jest.fn(), + trackSecurityAgentSync: mockTrackSecurityAgentSync, + trackSecurityAgentFindingDismissed: jest.fn(), +})); + +jest.mock('../services/audit-log-service', () => ({ + logSecurityAudit: mockLogSecurityAudit, + SecurityAuditLogAction: { + SyncTriggered: 'sync_triggered', + FindingDismissed: 'finding_dismissed', + }, +})); + +jest.mock('../db/security-config', () => ({ + getSecurityAgentConfigWithStatus: jest.fn(), + upsertSecurityAgentConfig: jest.fn(), + setSecurityAgentEnabled: jest.fn(), +})); + +jest.mock('../db/security-findings', () => ({ + listSecurityFindings: jest.fn(), + getSecurityFindingById: mockGetSecurityFindingById, + getSecurityFindingsSummary: jest.fn(), + updateSecurityFindingStatus: jest.fn(), + getLastSyncTime: jest.fn(), + getOrphanedRepositoriesWithFindingCounts: jest.fn(), + deleteFindingsByRepository: jest.fn(), +})); + +jest.mock('../db/dashboard-stats', () => ({ getDashboardStats: jest.fn() })); +jest.mock('../db/security-analysis', () => ({ + canStartAnalysis: mockCanStartAnalysis, + enqueueBacklogFindings: jest.fn(), +})); +jest.mock('../services/analysis-service', () => ({ startSecurityAnalysis: jest.fn() })); +jest.mock('../core/error-classification', () => ({ trpcCodeForAnalysisError: jest.fn() })); +jest.mock('../services/auto-dismiss-service', () => ({ + autoDismissEligibleFindings: jest.fn(), + countEligibleForAutoDismiss: jest.fn(), +})); +jest.mock('../github/dependabot-api', () => ({ dismissDependabotAlert: jest.fn() })); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + updateRepositoriesForIntegration: jest.fn(), +})); +jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ + fetchGitHubRepositories: jest.fn(), +})); +jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ + rethrowAsPaymentRequired: jest.fn(), +})); + +let createSecurityAgentHandlers: typeof createSecurityAgentHandlersType; + +beforeAll(async () => { + ({ createSecurityAgentHandlers } = await import('./shared-handlers')); +}); + +function createHandlers() { + return createSecurityAgentHandlers({ + resolveOwner: () => ({ + type: 'org', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + userId: 'user-123', + }), + resolveSecurityOwner: () => ({ + organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }), + resolveResourceId: () => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + verifyFindingOwnership: () => true, + getIntegration: async () => + ({ + id: 'integration-123', + integration_status: 'active', + platform_installation_id: 'installation-123', + repositories: [{ full_name: 'kilo/repo' }], + }) as never, + getGitHubToken: async () => null, + trackingExtras: () => ({}), + }); +} + +describe('setEnabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSubmitManualSecuritySync.mockResolvedValue({ + accepted: true, + runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + messageId: 'enable-sync-message-123', + }); + }); + + it('queues initial sync through Worker processing instead of running inline web sync', async () => { + const handlers = createHandlers(); + + const result = await handlers.setEnabled.handler({ + ctx: { + user: { + id: 'user-123', + google_user_email: 'owner@example.com', + google_user_name: 'Owner Example', + }, + } as never, + input: { + isEnabled: true, + repositorySelectionMode: 'all', + selectedRepositoryIds: [], + }, + }); + + expect(result).toEqual({ + success: true, + initialSync: { + accepted: true, + runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + messageId: 'enable-sync-message-123', + }, + }); + expect(mockSubmitManualSecuritySync).toHaveBeenCalledWith({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + }); + expect(mockSyncAllReposForOwner).not.toHaveBeenCalled(); + }); +}); + +describe('startAnalysis', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetSecurityFindingById.mockResolvedValue({ + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + repo_full_name: 'kilo/repo', + status: 'open', + } as never); + mockCanStartAnalysis.mockResolvedValue({ + allowed: true, + currentCount: 0, + limit: 3, + } as never); + mockSubmitManualAnalysisStart.mockResolvedValue({ accepted: true }); + }); + + it('returns accepted Worker orchestration instead of launching Cloud Agent inline', async () => { + const handlers = createHandlers(); + const result = await handlers.startAnalysis.handler({ + ctx: { + user: { + id: 'user-123', + google_user_email: 'owner@example.com', + google_user_name: 'Owner Example', + }, + } as never, + input: { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + analysisModel: 'analysis/model', + }, + }); + + expect(result).toEqual({ success: true, accepted: true }); + expect(mockSubmitManualAnalysisStart).toHaveBeenCalledWith({ + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actorUserId: 'user-123', + requestedModels: { + model: undefined, + triageModel: undefined, + analysisModel: 'analysis/model', + }, + retrySandboxOnly: undefined, + }); + }); +}); + +describe('triggerSync', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSubmitManualSecuritySync.mockResolvedValue({ + accepted: true, + runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + messageId: 'message-123', + }); + }); + + it('returns accepted async Worker correlation without running inline repository sync', async () => { + const handlers = createHandlers(); + + const result = await handlers.triggerSync.handler({ + ctx: { + user: { + id: 'user-123', + google_user_email: 'owner@example.com', + google_user_name: 'Owner Example', + }, + } as never, + input: { repoFullName: 'kilo/repo' }, + }); + + expect(result).toEqual({ + success: true, + accepted: true, + runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + messageId: 'message-123', + }); + expect(mockSubmitManualSecuritySync).toHaveBeenCalledWith({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + repoFullName: 'kilo/repo', + }); + expect(mockSyncDependabotAlertsForRepo).not.toHaveBeenCalled(); + }); +}); + +describe('dismissFinding', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetSecurityFindingById.mockResolvedValue({ + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + source: 'dependabot', + source_id: '42', + repo_full_name: 'kilo/repo', + status: 'open', + severity: 'high', + } as never); + mockSubmitManualFindingDismissal.mockResolvedValue({ + accepted: true, + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId: 'dismiss-message-123', + }); + }); + + it('returns accepted async Worker correlation without mutating dismissal inline', async () => { + const handlers = createHandlers(); + + const result = await handlers.dismissFinding.handler({ + ctx: { + user: { + id: 'user-123', + google_user_email: 'owner@example.com', + google_user_name: 'Owner Example', + }, + } as never, + input: { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + reason: 'not_used', + comment: 'No production usage', + }, + }); + + expect(result).toEqual({ + success: true, + accepted: true, + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId: 'dismiss-message-123', + }); + expect(mockSubmitManualFindingDismissal).toHaveBeenCalledWith({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }); + }); +}); 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 1477285b1e..287b66b068 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -14,7 +14,6 @@ import { listSecurityFindings, getSecurityFindingById, getSecurityFindingsSummary, - updateSecurityFindingStatus, getLastSyncTime as getLastSyncTimeDb, getOrphanedRepositoriesWithFindingCounts, deleteFindingsByRepository as deleteFindingsByRepositoryDb, @@ -28,18 +27,13 @@ import { hasSecurityReviewPermissions, getReauthorizeUrl, } from '@/lib/security-agent/github/permissions'; -import { - syncDependabotAlertsForRepo, - syncAllReposForOwner, -} from '@/lib/security-agent/services/sync-service'; -import { startSecurityAnalysis } from '@/lib/security-agent/services/analysis-service'; -import { trpcCodeForAnalysisError } from '@/lib/security-agent/core/error-classification'; +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'; import { autoDismissEligibleFindings, countEligibleForAutoDismiss, } from '@/lib/security-agent/services/auto-dismiss-service'; -import { dismissDependabotAlert } from '@/lib/security-agent/github/dependabot-api'; -import { rethrowAsPaymentRequired } from '@/lib/cloud-agent-next/cloud-agent-client'; import type { SecurityReviewOwner } from '@/lib/security-agent/core/types'; import type { SecurityFinding } from '@kilocode/db/schema'; import { @@ -448,11 +442,13 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps } if (repositoriesToSync.length > 0) { - const syncResult = await syncAllReposForOwner({ + const initialSync = await submitManualSecuritySync({ owner: securityOwner, - platformIntegrationId: integration.id, - installationId, - repositories: repositoriesToSync, + actor: { + id: ctx.user.id, + email: ctx.user.google_user_email, + name: ctx.user.google_user_name, + }, }); trackSecurityAgentEnabled({ @@ -462,8 +458,6 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps isEnabled: input.isEnabled, repositorySelectionMode: selectionMode, selectedRepoCount: repositoriesToSync.length, - syncedCount: syncResult.synced, - syncErrors: syncResult.errors, }); logSecurityAudit({ @@ -479,11 +473,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps return { success: true, - syncResult: { - synced: syncResult.synced, - errors: syncResult.errors, - reauthRequired: syncResult.reauthRequired, - }, + initialSync, }; } } @@ -707,10 +697,13 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); } - const result = await syncDependabotAlertsForRepo({ + const accepted = await submitManualSecuritySync({ owner: securityOwner, - platformIntegrationId: integration.id, - installationId, + actor: { + id: ctx.user.id, + email: ctx.user.google_user_email, + name: ctx.user.google_user_name, + }, repoFullName: input.repoFullName, }); @@ -720,8 +713,8 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps ...deps.trackingExtras(ctx, input), syncType: 'single_repo', repoCount: 1, - synced: result.synced, - errors: result.errors, + synced: 0, + errors: 0, }); logSecurityAudit({ @@ -735,16 +728,15 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps metadata: { syncType: 'single_repo', repoFullName: input.repoFullName, - synced: result.synced, - errors: result.errors, + runId: accepted.runId, + messageId: accepted.messageId, + status: 'accepted', }, }); return { success: true, - synced: result.synced, - errors: result.errors, - reauthRequired: result.reauthRequired, + ...accepted, }; } @@ -772,11 +764,13 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); } - const result = await syncAllReposForOwner({ + const accepted = await submitManualSecuritySync({ owner: securityOwner, - platformIntegrationId: integration.id, - installationId, - repositories: repositoriesToSync, + actor: { + id: ctx.user.id, + email: ctx.user.google_user_email, + name: ctx.user.google_user_name, + }, }); trackSecurityAgentSync({ @@ -785,8 +779,8 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps ...deps.trackingExtras(ctx, input), syncType: 'all_repos', repoCount: repositoriesToSync.length, - synced: result.synced, - errors: result.errors, + synced: 0, + errors: 0, }); logSecurityAudit({ @@ -800,16 +794,15 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps metadata: { syncType: 'all_repos', repoCount: repositoriesToSync.length, - synced: result.synced, - errors: result.errors, + runId: accepted.runId, + messageId: accepted.messageId, + status: 'accepted', }, }); return { success: true, - synced: result.synced, - errors: result.errors, - reauthRequired: result.reauthRequired, + ...accepted, }; }, }, @@ -863,38 +856,17 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); } - // Parse repo owner and name from full name - const [repoOwner, repoName] = finding.repo_full_name.split('/'); - if (!repoOwner || !repoName) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Invalid repository name format', - }); - } - - // Dismiss on GitHub if it's a Dependabot alert - if (finding.source === 'dependabot') { - const alertNumber = parseInt(finding.source_id, 10); - if (!isNaN(alertNumber)) { - await dismissDependabotAlert( - installationId, - repoOwner, - repoName, - alertNumber, - input.reason, - input.comment - ); - } else { - console.warn( - `Dependabot finding ${input.findingId} has non-numeric source_id "${finding.source_id}", skipping GitHub dismissal` - ); - } - } - - // Update local database - await updateSecurityFindingStatus(input.findingId, 'ignored', { - ignoredReason: input.reason, - ignoredBy: ctx.user.google_user_email, + const accepted = await submitManualFindingDismissal({ + owner: securityOwner, + actor: { + id: ctx.user.id, + email: ctx.user.google_user_email, + name: ctx.user.google_user_name, + }, + findingId: input.findingId, + installationId, + reason: input.reason, + comment: input.comment, }); trackSecurityAgentFindingDismissed({ @@ -907,20 +879,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps severity: finding.severity, }); - logSecurityAudit({ - owner: securityOwner, - actor_id: ctx.user.id, - actor_email: ctx.user.google_user_email, - actor_name: ctx.user.google_user_name, - action: SecurityAuditLogAction.FindingDismissed, - resource_type: 'security_finding', - resource_id: input.findingId, - before_state: { status: finding.status }, - after_state: { status: 'ignored', ignoredReason: input.reason }, - metadata: { source: finding.source, severity: finding.severity }, - }); - - return { success: true }; + return { success: true, ...accepted }; }, }, @@ -937,7 +896,6 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps input: StartAnalysisInput & TExtra; }) => { const input = rawInput; - const owner = deps.resolveOwner(ctx, input); const securityOwner = deps.resolveSecurityOwner(ctx, input); const finding = await getSecurityFindingById(input.findingId); @@ -967,75 +925,19 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); } - // Get GitHub token - const githubToken = await deps.getGitHubToken(ctx, input); - - if (!githubToken) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'GitHub integration required for analysis', - }); - } - - // Resolve triage/analysis models with legacy fallbacks - const config = await getSecurityAgentConfigWithStatus(owner); - const triageModel = - input.triageModel || - input.model || - config?.storedConfig.triage_model_slug || - config?.storedConfig.model_slug || - config?.config.triage_model_slug || - DEFAULT_SECURITY_AGENT_TRIAGE_MODEL; - const analysisModel = - input.analysisModel || - input.model || - config?.storedConfig.analysis_model_slug || - config?.storedConfig.model_slug || - config?.config.analysis_model_slug || - DEFAULT_SECURITY_AGENT_ANALYSIS_MODEL; - const analysisMode = config?.config.analysis_mode ?? 'auto'; - - try { - const result = await startSecurityAnalysis({ - findingId: input.findingId, - user: ctx.user, - githubRepo: finding.repo_full_name, - githubToken, - triageModel, - analysisModel, - analysisMode, - retrySandboxOnly: input.retrySandboxOnly, - organizationId: owner.type === 'org' ? owner.id : undefined, - }); - - if (!result.started) { - throw new TRPCError({ - code: trpcCodeForAnalysisError(result.errorCode), - message: result.error || 'Failed to start analysis', - }); - } - - logSecurityAudit({ - owner: securityOwner, - actor_id: ctx.user.id, - actor_email: ctx.user.google_user_email, - actor_name: ctx.user.google_user_name, - action: SecurityAuditLogAction.FindingAnalysisStarted, - resource_type: 'security_finding', - resource_id: input.findingId, - metadata: { - model: analysisModel, - triageModel, - analysisModel, - analysisMode, - triageOnly: result.triageOnly, - }, - }); + await submitManualAnalysisStart({ + findingId: input.findingId, + owner: securityOwner, + actorUserId: ctx.user.id, + requestedModels: { + model: input.model, + triageModel: input.triageModel, + analysisModel: input.analysisModel, + }, + retrySandboxOnly: input.retrySandboxOnly, + }); - return { success: true, triageOnly: result.triageOnly }; - } catch (error) { - rethrowAsPaymentRequired(error); - } + return { success: true, accepted: true }; }, }, diff --git a/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts b/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts new file mode 100644 index 0000000000..da98bc2e63 --- /dev/null +++ b/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts @@ -0,0 +1,52 @@ +import { submitManualAnalysisStart } from './manual-analysis-client'; + +jest.mock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal-secret', + SECURITY_AUTO_ANALYSIS_WORKER_URL: 'https://security-auto-analysis.test', +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('submitManualAnalysisStart', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('submits a durable manual analysis command and returns accepted state', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 202, + json: () => Promise.resolve({ success: true, accepted: true }), + }); + + await expect( + submitManualAnalysisStart({ + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actorUserId: 'user-123', + requestedModels: { analysisModel: 'analysis/model' }, + retrySandboxOnly: true, + }) + ).resolves.toEqual({ accepted: true }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://security-auto-analysis.test/internal/manual-analysis-start', + expect.objectContaining({ + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'test-internal-secret', + }, + body: JSON.stringify({ + schemaVersion: 1, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actorUserId: 'user-123', + requestedModels: { analysisModel: 'analysis/model' }, + retrySandboxOnly: true, + }), + }) + ); + }); +}); diff --git a/apps/web/src/lib/security-agent/services/manual-analysis-client.ts b/apps/web/src/lib/security-agent/services/manual-analysis-client.ts new file mode 100644 index 0000000000..975c496aeb --- /dev/null +++ b/apps/web/src/lib/security-agent/services/manual-analysis-client.ts @@ -0,0 +1,64 @@ +import 'server-only'; +import { INTERNAL_API_SECRET, SECURITY_AUTO_ANALYSIS_WORKER_URL } from '@/lib/config.server'; + +type ManualAnalysisOwner = + | { organizationId: string; userId?: never } + | { userId: string; organizationId?: never }; + +type ManualAnalysisStartParams = { + findingId: string; + owner: ManualAnalysisOwner; + actorUserId: string; + requestedModels?: { + model?: string; + triageModel?: string; + analysisModel?: string; + }; + retrySandboxOnly?: boolean; +}; + +type ManualAnalysisResponse = { + success?: boolean; + accepted?: boolean; + error?: string; +}; + +export async function submitManualAnalysisStart( + params: ManualAnalysisStartParams +): Promise<{ accepted: true }> { + if (!SECURITY_AUTO_ANALYSIS_WORKER_URL) { + throw new Error('SECURITY_AUTO_ANALYSIS_WORKER_URL is not configured'); + } + if (!INTERNAL_API_SECRET) { + throw new Error('INTERNAL_API_SECRET is not configured'); + } + + const response = await fetch( + `${SECURITY_AUTO_ANALYSIS_WORKER_URL}/internal/manual-analysis-start`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': INTERNAL_API_SECRET, + }, + body: JSON.stringify({ + schemaVersion: 1, + findingId: params.findingId, + owner: params.owner, + actorUserId: params.actorUserId, + requestedModels: params.requestedModels, + retrySandboxOnly: params.retrySandboxOnly, + }), + } + ); + const body = (await response.json()) as ManualAnalysisResponse; + if (!response.ok) { + throw new Error( + body.error ?? `Security analysis Worker request failed with ${response.status}` + ); + } + if (body.success !== true || body.accepted !== true) { + throw new Error('Security analysis Worker returned an invalid accepted response'); + } + return { accepted: true }; +} diff --git a/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts b/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts new file mode 100644 index 0000000000..4208729e1e --- /dev/null +++ b/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts @@ -0,0 +1,55 @@ +import { submitManualFindingDismissal } from './manual-dismiss-client'; + +jest.mock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal-secret', + SECURITY_SYNC_WORKER_URL: 'https://security-sync.test', +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('submitManualFindingDismissal', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('submits dismissal actor context and returns accepted correlation ids', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 202, + json: () => + Promise.resolve({ + success: true, + accepted: true, + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId: 'dismiss-message-123', + }), + }); + + await expect( + submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }) + ).resolves.toEqual({ + accepted: true, + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId: 'dismiss-message-123', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://security-sync.test/internal/dismiss-finding', + expect.objectContaining({ + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'test-internal-secret', + }, + }) + ); + }); +}); diff --git a/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts b/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts new file mode 100644 index 0000000000..48beb0461c --- /dev/null +++ b/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts @@ -0,0 +1,88 @@ +import 'server-only'; +import { INTERNAL_API_SECRET, SECURITY_SYNC_WORKER_URL } from '@/lib/config.server'; + +type ManualFindingDismissalOwner = + | { organizationId: string; userId?: never } + | { userId: string; organizationId?: never }; + +type ManualFindingDismissalActor = { + id: string; + email?: string | null; + name?: string | null; +}; + +type DismissReason = 'fix_started' | 'no_bandwidth' | 'tolerable_risk' | 'inaccurate' | 'not_used'; + +type SubmitManualFindingDismissalParams = { + owner: ManualFindingDismissalOwner; + actor: ManualFindingDismissalActor; + findingId: string; + installationId: string; + reason: DismissReason; + comment?: string; +}; + +type AcceptedManualFindingDismissal = { + accepted: true; + runId: string; + messageId: string; +}; + +type ManualFindingDismissalWorkerResponse = { + success?: boolean; + accepted?: boolean; + runId?: string; + messageId?: string; + error?: string; +}; + +export async function submitManualFindingDismissal( + params: SubmitManualFindingDismissalParams +): Promise { + if (!SECURITY_SYNC_WORKER_URL) { + throw new Error('SECURITY_SYNC_WORKER_URL is not configured'); + } + + if (!INTERNAL_API_SECRET) { + throw new Error('INTERNAL_API_SECRET is not configured'); + } + + const response = await fetch(`${SECURITY_SYNC_WORKER_URL}/internal/dismiss-finding`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': INTERNAL_API_SECRET, + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: params.owner, + actor: params.actor, + findingId: params.findingId, + installationId: params.installationId, + reason: params.reason, + comment: params.comment, + }), + }); + const body = (await response.json()) as ManualFindingDismissalWorkerResponse; + + if (!response.ok) { + throw new Error( + body.error ?? `Security dismissal Worker request failed with ${response.status}` + ); + } + + if ( + body.success !== true || + body.accepted !== true || + typeof body.runId !== 'string' || + typeof body.messageId !== 'string' + ) { + throw new Error('Security dismissal Worker returned an invalid accepted response'); + } + + return { + accepted: true, + runId: body.runId, + messageId: body.messageId, + }; +} diff --git a/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts b/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts new file mode 100644 index 0000000000..68a31d2f37 --- /dev/null +++ b/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts @@ -0,0 +1,58 @@ +import { submitManualSecuritySync } from './manual-sync-client'; + +jest.mock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal-secret', + SECURITY_SYNC_WORKER_URL: 'https://security-sync.test', +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('submitManualSecuritySync', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('submits actor and repository scope to the Worker and returns accepted correlation ids', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 202, + json: () => + Promise.resolve({ + success: true, + accepted: true, + runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + messageId: 'message-123', + }), + }); + + await expect( + submitManualSecuritySync({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + repoFullName: 'kilo/repo', + }) + ).resolves.toEqual({ + accepted: true, + runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + messageId: 'message-123', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://security-sync.test/internal/manual-sync', + expect.objectContaining({ + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'test-internal-secret', + }, + }) + ); + expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toEqual({ + schemaVersion: 1, + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + repoFullName: 'kilo/repo', + }); + }); +}); diff --git a/apps/web/src/lib/security-agent/services/manual-sync-client.ts b/apps/web/src/lib/security-agent/services/manual-sync-client.ts new file mode 100644 index 0000000000..2c5ae3b639 --- /dev/null +++ b/apps/web/src/lib/security-agent/services/manual-sync-client.ts @@ -0,0 +1,78 @@ +import 'server-only'; +import { INTERNAL_API_SECRET, SECURITY_SYNC_WORKER_URL } from '@/lib/config.server'; + +type ManualSecuritySyncOwner = + | { organizationId: string; userId?: never } + | { userId: string; organizationId?: never }; + +type ManualSecuritySyncActor = { + id: string; + email?: string | null; + name?: string | null; +}; + +type SubmitManualSecuritySyncParams = { + owner: ManualSecuritySyncOwner; + actor: ManualSecuritySyncActor; + repoFullName?: string; +}; + +type AcceptedManualSecuritySync = { + accepted: true; + runId: string; + messageId: string; +}; + +type ManualSecuritySyncWorkerResponse = { + success?: boolean; + accepted?: boolean; + runId?: string; + messageId?: string; + error?: string; +}; + +export async function submitManualSecuritySync( + params: SubmitManualSecuritySyncParams +): Promise { + if (!SECURITY_SYNC_WORKER_URL) { + throw new Error('SECURITY_SYNC_WORKER_URL is not configured'); + } + + if (!INTERNAL_API_SECRET) { + throw new Error('INTERNAL_API_SECRET is not configured'); + } + + const response = await fetch(`${SECURITY_SYNC_WORKER_URL}/internal/manual-sync`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': INTERNAL_API_SECRET, + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: params.owner, + actor: params.actor, + repoFullName: params.repoFullName, + }), + }); + const body = (await response.json()) as ManualSecuritySyncWorkerResponse; + + if (!response.ok) { + throw new Error(body.error ?? `Security sync Worker request failed with ${response.status}`); + } + + if ( + body.success !== true || + body.accepted !== true || + typeof body.runId !== 'string' || + typeof body.messageId !== 'string' + ) { + throw new Error('Security sync Worker returned an invalid accepted response'); + } + + return { + accepted: true, + runId: body.runId, + messageId: body.messageId, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf48d30cfc..000ddfb41c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2493,6 +2493,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) wrangler: specifier: 'catalog:' version: 4.90.1(@cloudflare/workers-types@4.20260511.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ab647390a7..fb003ef640 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,7 @@ catalog: wrangler: 4.90.1 zod: 4.4.3 -minimumReleaseAge: 6842 +minimumReleaseAge: 4320 minimumReleaseAgeExclude: - tsx - expo-dev-client diff --git a/services/cloud-agent-next/src/callbacks/delivery.test.ts b/services/cloud-agent-next/src/callbacks/delivery.test.ts index 6bfd09806b..84582554d5 100644 --- a/services/cloud-agent-next/src/callbacks/delivery.test.ts +++ b/services/cloud-agent-next/src/callbacks/delivery.test.ts @@ -117,6 +117,25 @@ describe('deliverCallbackJob', () => { }); describe('successful delivery', () => { + it('delivers Security Agent callbacks over direct service binding without public fetch fallback', async () => { + const publicFetch = vi.fn(); + globalThis.fetch = publicFetch; + const bindingFetch = vi.fn().mockResolvedValue(new Response('', { status: 202 })); + const target: CallbackTarget = { + url: 'https://security-auto-analysis/internal/security-analysis-callback/finding-123', + delivery: 'security-auto-analysis', + headers: { 'X-Internal-Secret': 'secret' }, + }; + + const result = await deliverCallbackJob(target, mockPayload, 1, { + securityAutoAnalysis: { fetch: bindingFetch }, + }); + + expect(result.type).toBe('success'); + expect(bindingFetch).toHaveBeenCalledTimes(1); + expect(publicFetch).not.toHaveBeenCalled(); + }); + it('should succeed on 200 response', async () => { globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); const target: CallbackTarget = { url: 'https://example.com/callback' }; diff --git a/services/cloud-agent-next/src/callbacks/delivery.ts b/services/cloud-agent-next/src/callbacks/delivery.ts index 40a1884cb9..08ac06ed12 100644 --- a/services/cloud-agent-next/src/callbacks/delivery.ts +++ b/services/cloud-agent-next/src/callbacks/delivery.ts @@ -17,9 +17,16 @@ export type DeliveryResult = | { type: 'retry'; delaySeconds: number } | { type: 'failed'; error: string }; +type CallbackDeliveryBindings = { + securityAutoAnalysis?: { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + }; +}; + async function deliverToTarget( target: CallbackTarget, - payload: ExecutionCallbackPayload + payload: ExecutionCallbackPayload, + bindings: CallbackDeliveryBindings ): Promise<{ ok: boolean; status?: number; error?: string }> { const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -27,12 +34,20 @@ async function deliverToTarget( }; try { - const response = await fetch(target.url, { + const requestInit: RequestInit = { method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), - }); + }; + const response = + target.delivery === 'security-auto-analysis' + ? await bindings.securityAutoAnalysis?.fetch(target.url, requestInit) + : await fetch(target.url, requestInit); + + if (!response) { + throw new Error('Security Auto Analysis callback binding is unavailable'); + } logger .withFields({ @@ -63,9 +78,10 @@ async function deliverToTarget( export async function deliverCallbackJob( target: CallbackTarget, payload: ExecutionCallbackPayload, - attempts: number + attempts: number, + bindings: CallbackDeliveryBindings = {} ): Promise { - const result = await deliverToTarget(target, payload); + const result = await deliverToTarget(target, payload, bindings); if (result.ok) { return { type: 'success' }; diff --git a/services/cloud-agent-next/src/callbacks/queue-consumer.ts b/services/cloud-agent-next/src/callbacks/queue-consumer.ts index ef0af2e055..bb521d1046 100644 --- a/services/cloud-agent-next/src/callbacks/queue-consumer.ts +++ b/services/cloud-agent-next/src/callbacks/queue-consumer.ts @@ -1,18 +1,25 @@ import type { CallbackJob } from './types.js'; import { deliverCallbackJob } from './delivery.js'; import { logger } from '../logger.js'; +import type { Env } from '../types.js'; -export function createCallbackQueueConsumer() { +export function createCallbackQueueConsumer(env: Pick) { return async function callbackQueueConsumer(batch: MessageBatch): Promise { for (const message of batch.messages) { - await processMessage(message); + await processMessage(message, env); } }; } -async function processMessage(message: Message): Promise { +async function processMessage( + message: Message, + env: Pick +): Promise { const job = message.body; - const result = await deliverCallbackJob(job.target, job.payload, message.attempts); + + const result = await deliverCallbackJob(job.target, job.payload, message.attempts, { + securityAutoAnalysis: env.SECURITY_AUTO_ANALYSIS, + }); switch (result.type) { case 'success': diff --git a/services/cloud-agent-next/src/callbacks/types.ts b/services/cloud-agent-next/src/callbacks/types.ts index 5172270fbb..ba55566504 100644 --- a/services/cloud-agent-next/src/callbacks/types.ts +++ b/services/cloud-agent-next/src/callbacks/types.ts @@ -1,6 +1,7 @@ export type CallbackTarget = { url: string; headers?: Record; + delivery?: 'http' | 'security-auto-analysis'; }; export type ExecutionCallbackPayload = { diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index e3f6ae6318..360b101363 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -356,6 +356,7 @@ export class CloudAgentSession extends DurableObject { error: err instanceof Error ? err.message : String(err), }) .error('Failed to enqueue callback job'); + throw err; } } diff --git a/services/cloud-agent-next/src/persistence/schemas.ts b/services/cloud-agent-next/src/persistence/schemas.ts index 7c70f2f54f..e367e27803 100644 --- a/services/cloud-agent-next/src/persistence/schemas.ts +++ b/services/cloud-agent-next/src/persistence/schemas.ts @@ -10,6 +10,7 @@ import type { SandboxId } from '../types.js'; export const CallbackTargetSchema = z.object({ url: z.string().url(), headers: z.record(z.string(), z.string()).optional(), + delivery: z.enum(['http', 'security-auto-analysis']).optional(), }); /** diff --git a/services/cloud-agent-next/src/server.ts b/services/cloud-agent-next/src/server.ts index 79e55ac461..7d07c0504b 100644 --- a/services/cloud-agent-next/src/server.ts +++ b/services/cloud-agent-next/src/server.ts @@ -334,7 +334,7 @@ export default { }, async queue(batch: MessageBatch, env: Env): Promise { if (batch.queue.startsWith('cloud-agent-next-callback-queue')) { - const consumer = createCallbackQueueConsumer(); + const consumer = createCallbackQueueConsumer(env); return consumer(batch as MessageBatch); } if (CLOUD_AGENT_REPORT_QUEUE_NAMES.has(batch.queue)) { diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index a38e5e8290..5f7f316315 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -206,6 +206,10 @@ export type Env = { GIT_TOKEN_SERVICE: GitTokenService; /** Service binding for dispatching push notifications */ NOTIFICATIONS: NotificationsBinding; + /** Direct HTTP service binding for Security Agent analysis callbacks */ + SECURITY_AUTO_ANALYSIS?: { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + }; /** GitHub Lite App slug for git commit attribution (e.g., 'kiloconnect-lite') */ GITHUB_LITE_APP_SLUG?: string; /** GitHub Lite App bot user ID for git commit email */ diff --git a/services/cloud-agent-next/test/integration/session/callback-notification.test.ts b/services/cloud-agent-next/test/integration/session/callback-notification.test.ts index c6ee61a46e..bcb6e7df18 100644 --- a/services/cloud-agent-next/test/integration/session/callback-notification.test.ts +++ b/services/cloud-agent-next/test/integration/session/callback-notification.test.ts @@ -143,6 +143,34 @@ describe('Callback notification with latest assistant message', () => { expect(job.target.url).toBe('https://example.com/callback'); }); + it('surfaces callback queue enqueue failures to terminal status updates', async () => { + const userId = 'user_cb_enqueue_failure'; + const sessionId = 'agent_cb_enqueue_failure'; + const executionId = 'exec_cb_enqueue_failure' as ExecutionId; + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + await runInDurableObject(stub, async (instance: CloudAgentSession) => { + injectCallbackQueue(instance, { + captured: [], + send: async () => { + throw new Error('callback queue unavailable'); + }, + }); + + await prepareSessionWithCallback(instance, sessionId, userId); + await instance.addExecution({ + executionId, + mode: 'followup', + streamingMode: 'websocket', + }); + await instance.updateExecutionStatus({ executionId, status: 'running' }); + await expect( + instance.updateExecutionStatus({ executionId, status: 'completed' }) + ).rejects.toThrow('callback queue unavailable'); + }); + }); + it('omits lastAssistantMessageText on failed callbacks', async () => { const userId = 'user_cb_2'; const sessionId = 'agent_cb_2'; diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index 6883ddea91..823a8cb71d 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -96,6 +96,10 @@ "service": "notifications", "entrypoint": "NotificationsService", }, + { + "binding": "SECURITY_AUTO_ANALYSIS", + "service": "security-auto-analysis", + }, ], "secrets_store_secrets": [ { @@ -299,6 +303,10 @@ "service": "notifications", "entrypoint": "NotificationsService", }, + { + "binding": "SECURITY_AUTO_ANALYSIS", + "service": "security-auto-analysis-dev", + }, ], "secrets_store_secrets": [ { diff --git a/services/security-auto-analysis/README.md b/services/security-auto-analysis/README.md index 6bc72cc564..22447aa80b 100644 --- a/services/security-auto-analysis/README.md +++ b/services/security-auto-analysis/README.md @@ -6,6 +6,8 @@ Cloudflare Worker that automatically triages and analyzes security findings via - `GET /health` — health check - `POST /internal/dispatch` — manual dispatch trigger (bearer-token auth) +- `POST /internal/manual-analysis-start` — manual command ingress; `MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED=false` pauses new Worker commands +- `POST /internal/security-analysis-callback/:findingId` — callback ingress; `SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED=false` pauses Worker callback acceptance - Cron trigger (`* * * * *`) — discovers owners with queued work and enqueues them ## Queue @@ -63,7 +65,7 @@ pnpm --filter cloudflare-security-auto-analysis exec wrangler queues list - `pending` is stale after 15 minutes - `running` is stale after 2 hours -> **Note:** There is no automated reconciliation cron for stale rows yet. Stuck rows must be identified and resolved manually using the diagnostic queries below. +> **Note:** The dispatcher reconciles stale rows before enqueueing due owners: stale `pending` rows return to `queued`, and stale `running` rows become terminal `failed` rows with `RUN_LOST`. Diagnostic queries below remain useful for verification and incident review. ### Failure codes @@ -197,7 +199,14 @@ Do not clear the block until credits are restored. After top-up, clear the block 1. Disable the Cloudflare scheduled trigger for `security-auto-analysis` 2. Pause the `security-auto-analysis-owner-queue` consumer -3. Verify queued backlog is no longer draining +3. Set `MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED=false` to reject new manual Worker launches +4. Set `SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED=false` only when routing callbacks back through compatibility ingress +5. Verify queued backlog is no longer draining + +**Callback routing:** + +- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=worker` targets direct service-binding delivery to this Worker. +- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=web` targets `${SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL}/api/internal/security-analysis-callback/:findingId` so rollout can return to the compatibility Next.js callback path without changing launch code. **Owner-scoped stop** (surgical): diff --git a/services/security-auto-analysis/src/auto-dismiss.test.ts b/services/security-auto-analysis/src/auto-dismiss.test.ts new file mode 100644 index 0000000000..bc4497a49c --- /dev/null +++ b/services/security-auto-analysis/src/auto-dismiss.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { maybeAutoDismissCompletedAnalysis } from './auto-dismiss.js'; + +describe('maybeAutoDismissCompletedAnalysis', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves Worker auto-dismiss state, Dependabot writeback, and audit trail', async () => { + let selectCount = 0; + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount += 1; + return selectCount === 1 + ? [{ config: { auto_dismiss_enabled: true } }] + : [{ installationId: 'installation-123' }]; + }, + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, + } as unknown as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + source: 'dependabot', + source_id: '42', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + correlationId: 'correlation-123', + sandboxAnalysis: { + isExploitable: false, + exploitabilityReasoning: 'Dependency is not reachable.', + usageLocations: [], + suggestedFix: 'Upgrade', + suggestedAction: 'dismiss', + summary: 'Not exploitable', + rawMarkdown: '# Not exploitable', + analysisAt: '2026-05-18T10:00:00.000Z', + }, + }, + }); + + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-sandbox', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(auditRows[0]).toMatchObject({ + action: 'security.finding.auto_dismissed', + resource_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + metadata: { correlationId: 'correlation-123', dismissSource: 'sandbox' }, + }); + }); + + it('auto-dismisses high-confidence triage decisions at the configured threshold', async () => { + let selectCount = 0; + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount += 1; + return selectCount === 1 + ? [ + { + config: { + auto_dismiss_enabled: true, + auto_dismiss_confidence_threshold: 'high', + }, + }, + ] + : [{ installationId: 'installation-123' }]; + }, + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, + } as unknown as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + source: 'dependabot', + source_id: '42', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + correlationId: 'correlation-456', + triage: { + needsSandboxAnalysis: false, + needsSandboxReasoning: 'No relevant runtime path.', + suggestedAction: 'dismiss', + confidence: 'high', + triageAt: '2026-05-18T09:59:00.000Z', + }, + }, + }); + + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-triage', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(auditRows[0]).toMatchObject({ + metadata: { + correlationId: 'correlation-456', + dismissSource: 'triage', + confidence: 'high', + }, + }); + }); + + it('keeps low-confidence triage findings open above their confidence threshold', async () => { + const updates: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + config: { auto_dismiss_enabled: true, auto_dismiss_confidence_threshold: 'medium' }, + }, + ], + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ values: async () => undefined }), + }; + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: {} as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + triage: { + needsSandboxAnalysis: false, + needsSandboxReasoning: 'Weak signal.', + suggestedAction: 'dismiss', + confidence: 'low', + triageAt: '2026-05-18T09:59:00.000Z', + }, + }, + }); + + expect(updates).toHaveLength(0); + }); +}); diff --git a/services/security-auto-analysis/src/auto-dismiss.ts b/services/security-auto-analysis/src/auto-dismiss.ts new file mode 100644 index 0000000000..07022c3eba --- /dev/null +++ b/services/security-auto-analysis/src/auto-dismiss.ts @@ -0,0 +1,182 @@ +import type { WorkerDb } from '@kilocode/db/client'; +import { platform_integrations, security_audit_log, security_findings } from '@kilocode/db/schema'; +import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { eq, sql } from 'drizzle-orm'; +import { getSecurityAgentConfigForOwner, type SecurityFindingRecord } from './db/queries.js'; +import { logger } from './logger.js'; +import type { QueueOwner, SecurityFindingAnalysis } from './types.js'; + +function findingOwner(finding: SecurityFindingRecord): QueueOwner | null { + if (finding.owned_by_organization_id) { + return { type: 'org', id: finding.owned_by_organization_id }; + } + if (finding.owned_by_user_id) { + return { type: 'user', id: finding.owned_by_user_id }; + } + return null; +} + +function meetsAutoDismissConfidenceThreshold( + threshold: 'high' | 'medium' | 'low', + confidence: 'high' | 'medium' | 'low' +): boolean { + return ( + threshold === 'low' || + (threshold === 'medium' && confidence !== 'low') || + (threshold === 'high' && confidence === 'high') + ); +} + +async function writeBackDependabotDismissal(params: { + db: WorkerDb; + env: CloudflareEnv; + finding: SecurityFindingRecord; + comment: string; +}): Promise { + if (params.finding.source !== 'dependabot' || !params.finding.platform_integration_id) { + return; + } + const alertNumber = Number.parseInt(params.finding.source_id, 10); + const [repoOwner, repoName] = params.finding.repo_full_name.split('/'); + if (!Number.isFinite(alertNumber) || !repoOwner || !repoName) return; + + const rows = await params.db + .select({ installationId: platform_integrations.platform_installation_id }) + .from(platform_integrations) + .where(eq(platform_integrations.id, params.finding.platform_integration_id)) + .limit(1); + const installationId = rows[0]?.installationId; + if (!installationId) return; + + try { + const token = await params.env.GIT_TOKEN_SERVICE.getToken(installationId); + const response = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/dependabot/alerts/${alertNumber}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'cloudflare-security-auto-analysis', + }, + body: JSON.stringify({ + state: 'dismissed', + dismissed_reason: 'not_used', + dismissed_comment: `[Kilo Code auto-dismiss] ${params.comment}`, + }), + } + ); + if (!response.ok) { + logger.warn('Dependabot auto-dismiss writeback failed', { + finding_id: params.finding.id, + status: response.status, + }); + } + } catch (error) { + logger.warn('Dependabot auto-dismiss writeback threw', { + finding_id: params.finding.id, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function maybeAutoDismissCompletedAnalysis(params: { + db: WorkerDb; + env: CloudflareEnv; + findingId: string; + finding: SecurityFindingRecord; + analysis: SecurityFindingAnalysis; +}): Promise { + const owner = findingOwner(params.finding); + if (!owner) return; + const config = await getSecurityAgentConfigForOwner(params.db, owner); + if (!config.auto_dismiss_enabled) return; + + const sandbox = params.analysis.sandboxAnalysis; + if (sandbox?.isExploitable === false) { + await params.db + .update(security_findings) + .set({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-sandbox', + updated_at: sql`now()`.mapWith(String), + }) + .where(eq(security_findings.id, params.findingId)); + + await writeBackDependabotDismissal({ + db: params.db, + env: params.env, + finding: params.finding, + comment: sandbox.exploitabilityReasoning, + }); + + await params.db.insert(security_audit_log).values({ + owned_by_organization_id: params.finding.owned_by_organization_id, + owned_by_user_id: params.finding.owned_by_user_id, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAutoDismissed, + resource_type: 'security_finding', + resource_id: params.findingId, + after_state: { status: 'ignored' }, + metadata: { + source: 'system', + trigger: 'auto_dismiss_policy', + dismissSource: 'sandbox', + correlationId: params.analysis.correlationId, + }, + }); + return; + } + + const triage = params.analysis.triage; + if ( + triage?.suggestedAction !== 'dismiss' || + !meetsAutoDismissConfidenceThreshold( + config.auto_dismiss_confidence_threshold, + triage.confidence + ) + ) { + return; + } + + await params.db + .update(security_findings) + .set({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-triage', + updated_at: sql`now()`.mapWith(String), + }) + .where(eq(security_findings.id, params.findingId)); + + await writeBackDependabotDismissal({ + db: params.db, + env: params.env, + finding: params.finding, + comment: triage.needsSandboxReasoning, + }); + + await params.db.insert(security_audit_log).values({ + owned_by_organization_id: params.finding.owned_by_organization_id, + owned_by_user_id: params.finding.owned_by_user_id, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAutoDismissed, + resource_type: 'security_finding', + resource_id: params.findingId, + after_state: { status: 'ignored' }, + metadata: { + source: 'system', + trigger: 'auto_dismiss_policy', + dismissSource: 'triage', + confidence: triage.confidence, + correlationId: params.analysis.correlationId, + }, + }); +} diff --git a/services/security-auto-analysis/src/callbacks.test.ts b/services/security-auto-analysis/src/callbacks.test.ts new file mode 100644 index 0000000000..224b457ccd --- /dev/null +++ b/services/security-auto-analysis/src/callbacks.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + classifyAnalysisCallback, + consumeAnalysisCallbackBatch, + finalizeCompletedAnalysisCallback, + finalizeFailedAnalysisCallback, + mapAnalysisCallbackFailure, + resolveCompletedCallbackMarkdown, + type SecurityAnalysisCallbackPayload, +} from './callbacks.js'; + +const failedPayload = { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'failed', + errorMessage: 'upstream 503', +} satisfies SecurityAnalysisCallbackPayload; + +describe('classifyAnalysisCallback', () => { + it('rejects stale session callbacks before terminalization', () => { + expect( + classifyAnalysisCallback( + { + session_id: 'agent-current', + cli_session_id: 'ses-current', + ignored_reason: null, + analysis_status: 'running', + }, + failedPayload + ) + ).toBe('stale-session'); + }); + + it('treats duplicate terminal callbacks as idempotent no-ops', () => { + expect( + classifyAnalysisCallback( + { + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: null, + analysis_status: 'failed', + }, + failedPayload + ) + ).toBe('already-terminal'); + }); + + it('marks superseded callbacks for queue release', () => { + expect( + classifyAnalysisCallback( + { + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: 'superseded:new-finding', + analysis_status: 'running', + }, + failedPayload + ) + ).toBe('superseded'); + }); +}); + +describe('resolveCompletedCallbackMarkdown', () => { + it('retries session-backed markdown resolution when callback text has not arrived', async () => { + let attempts = 0; + await expect( + resolveCompletedCallbackMarkdown({ + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + kiloSessionId: 'ses-123', + status: 'completed', + }, + fetchLatestAssistantText: async () => { + attempts += 1; + return attempts === 2 ? '# Delayed snapshot text' : null; + }, + sleep: async () => undefined, + }) + ).resolves.toBe('# Delayed snapshot text'); + expect(attempts).toBe(2); + }); +}); + +describe('finalizeCompletedAnalysisCallback', () => { + it('persists extracted sandbox analysis and terminal queue status for completed callbacks', async () => { + const updates: unknown[] = []; + const executes: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'running', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + analysis: { + analyzedAt: '2026-05-18T08:00:00.000Z', + analysisModel: 'analysis/model', + triageModel: 'triage/model', + triggeredByUserId: 'user-123', + correlationId: 'correlation-123', + }, + }, + ], + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: () => ({ + returning: async () => { + updates.push(values); + return [{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }]; + }, + }), + }), + }), + execute: async (statement: unknown) => { + executes.push(statement); + return { rows: [] }; + }, + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const autoDismissCalls: unknown[] = []; + const analyticsCalls: unknown[] = []; + + await expect( + finalizeCompletedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + kiloSessionId: 'ses-123', + status: 'completed', + lastAssistantMessageText: '# Completed analysis', + }, + extractSandboxAnalysis: async ({ rawMarkdown }) => ({ + isExploitable: false, + exploitabilityReasoning: 'No reachable usage', + usageLocations: [], + suggestedFix: 'Upgrade package', + suggestedAction: 'dismiss', + summary: 'Not exploitable.', + rawMarkdown, + analysisAt: '2026-05-18T08:05:00.000Z', + }), + maybeAutoDismissAnalysis: async params => { + autoDismissCalls.push(params); + }, + trackCompletedAnalysis: async params => { + analyticsCalls.push(params); + }, + }) + ).resolves.toEqual({ status: 'completed-finalized' }); + + expect(updates).toHaveLength(1); + expect(executes).toHaveLength(1); + expect(auditRows).toHaveLength(1); + expect(autoDismissCalls).toHaveLength(1); + expect(analyticsCalls).toHaveLength(1); + }); + + it('terminalizes completed callbacks when result markdown never becomes available', async () => { + const findingUpdates: unknown[] = []; + const queueTransitions: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'running', + }, + ], + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: () => ({ + returning: async () => { + findingUpdates.push(values); + return [{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }]; + }, + }), + }), + }), + execute: async (statement: unknown) => { + queueTransitions.push(statement); + return { rows: [] }; + }, + }; + + await expect( + finalizeCompletedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + kiloSessionId: 'ses-123', + status: 'completed', + }, + fetchLatestAssistantText: async () => null, + extractSandboxAnalysis: async () => { + throw new Error('missing callback markdown must skip extraction'); + }, + sleep: async () => undefined, + }) + ).resolves.toEqual({ status: 'result-missing' }); + + expect(findingUpdates[0]).toMatchObject({ analysis_status: 'failed' }); + expect(queueTransitions).toHaveLength(1); + }); +}); + +describe('consumeAnalysisCallbackBatch', () => { + const callbackBody = { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed' as const, + lastAssistantMessageText: '# Completed', + }, + }; + + it('acknowledges callback messages after durable finalization succeeds', async () => { + const ack = vi.fn(); + const retry = vi.fn(); + const finalizeCallback = vi.fn().mockResolvedValue({ status: 'completed-finalized' }); + + await consumeAnalysisCallbackBatch( + { messages: [{ body: callbackBody, ack, retry }] } as never, + {} as CloudflareEnv, + finalizeCallback + ); + + expect(finalizeCallback).toHaveBeenCalledTimes(1); + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); + + it('retries callback messages when durable finalization throws', async () => { + const ack = vi.fn(); + const retry = vi.fn(); + const finalizeCallback = vi.fn().mockRejectedValue(new Error('retry callback')); + + await consumeAnalysisCallbackBatch( + { messages: [{ body: callbackBody, ack, retry }] } as never, + {} as CloudflareEnv, + finalizeCallback + ); + + expect(ack).not.toHaveBeenCalled(); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); + +describe('finalizeFailedAnalysisCallback', () => { + it('writes terminal failed finding and queue state for retry-classified callbacks', async () => { + const findingUpdates: unknown[] = []; + const queueTransitions: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: null, + analysis_status: 'running', + }, + ], + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: () => ({ + returning: async () => { + findingUpdates.push(values); + return [{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }]; + }, + }), + }), + }), + execute: async (statement: unknown) => { + queueTransitions.push(statement); + return { rows: [] }; + }, + }; + + await expect( + finalizeFailedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: failedPayload, + }) + ).resolves.toEqual({ status: 'failed-finalized' }); + expect(findingUpdates[0]).toMatchObject({ + analysis_status: 'failed', + analysis_error: 'upstream 503', + }); + expect(queueTransitions).toHaveLength(1); + }); +}); + +describe('mapAnalysisCallbackFailure', () => { + it('maps interrupted callbacks to state guard rejection', () => { + expect( + mapAnalysisCallbackFailure({ status: 'interrupted', errorMessage: 'cancelled' }) + ).toEqual({ + errorMessage: 'Analysis interrupted: cancelled', + failureCode: 'STATE_GUARD_REJECTED', + }); + }); + + it('maps transient upstream failures to UPSTREAM_5XX', () => { + expect(mapAnalysisCallbackFailure(failedPayload)).toEqual({ + errorMessage: 'upstream 503', + failureCode: 'UPSTREAM_5XX', + }); + }); +}); diff --git a/services/security-auto-analysis/src/callbacks.ts b/services/security-auto-analysis/src/callbacks.ts new file mode 100644 index 0000000000..d3428db449 --- /dev/null +++ b/services/security-auto-analysis/src/callbacks.ts @@ -0,0 +1,416 @@ +import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; +import { security_audit_log } from '@kilocode/db/schema'; +import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { z } from 'zod'; +import { + clearAnalysisStatus, + getAnalysisActorById, + getSecurityFindingById, + setFindingCompleted, + setFindingFailed, + transitionAnalysisQueueFromCallback, +} from './db/queries.js'; +import { generateApiToken } from './token.js'; +import { extractSandboxAnalysis as runSandboxExtraction } from './extraction.js'; +import { fetchLatestAssistantText as fetchSessionAssistantText } from './session-result.js'; +import { maybeAutoDismissCompletedAnalysis } from './auto-dismiss.js'; +import { trackSecurityAnalysisCompleted } from './posthog.js'; +import type { + AutoAnalysisFailureCode, + SecurityFindingAnalysis, + SecurityFindingSandboxAnalysis, +} from './types.js'; + +export const SecurityAnalysisCallbackPayloadSchema = z.object({ + sessionId: z.string().min(1), + cloudAgentSessionId: z.string().min(1), + executionId: z.string().min(1), + status: z.enum(['completed', 'failed', 'interrupted']), + errorMessage: z.string().optional(), + kiloSessionId: z.string().optional(), + lastSeenBranch: z.string().optional(), + lastAssistantMessageText: z.string().optional(), +}); + +export type SecurityAnalysisCallbackPayload = z.infer; + +export const SecurityAnalysisCallbackMessageSchema = z.object({ + findingId: z.string().uuid(), + payload: SecurityAnalysisCallbackPayloadSchema, +}); + +export type SecurityAnalysisCallbackMessage = z.infer; + +type CallbackFindingState = Pick< + NonNullable>>, + 'session_id' | 'cli_session_id' | 'ignored_reason' | 'analysis_status' +>; + +export type CallbackDisposition = 'process' | 'stale-session' | 'superseded' | 'already-terminal'; + +export function classifyAnalysisCallback( + finding: CallbackFindingState, + payload: SecurityAnalysisCallbackPayload +): CallbackDisposition { + const sessionMismatch = + (payload.cloudAgentSessionId && + finding.session_id && + payload.cloudAgentSessionId !== finding.session_id) || + (payload.kiloSessionId && + finding.cli_session_id && + payload.kiloSessionId !== finding.cli_session_id); + if (sessionMismatch) return 'stale-session'; + if (finding.ignored_reason?.startsWith('superseded:')) return 'superseded'; + if (finding.analysis_status === 'completed' || finding.analysis_status === 'failed') { + return 'already-terminal'; + } + return 'process'; +} + +export function mapAnalysisCallbackFailure(params: { + status: 'failed' | 'interrupted'; + errorMessage?: string; +}): { errorMessage: string; failureCode: AutoAnalysisFailureCode } { + if (params.status === 'interrupted') { + return { + errorMessage: `Analysis interrupted: ${params.errorMessage ?? 'unknown reason'}`, + failureCode: 'STATE_GUARD_REJECTED', + }; + } + + const errorMessage = params.errorMessage ?? 'Analysis failed'; + const normalized = errorMessage.toLowerCase(); + if (normalized.includes('timeout') || normalized.includes('timed out')) { + return { errorMessage, failureCode: 'NETWORK_TIMEOUT' }; + } + if ( + normalized.includes('502') || + normalized.includes('503') || + normalized.includes('504') || + normalized.includes('upstream') || + normalized.includes('5xx') + ) { + return { errorMessage, failureCode: 'UPSTREAM_5XX' }; + } + return { errorMessage, failureCode: 'START_CALL_AMBIGUOUS' }; +} + +type ExtractSandboxAnalysis = (params: { + finding: NonNullable>>; + rawMarkdown: string; +}) => Promise; + +type MaybeAutoDismissAnalysis = (params: { + findingId: string; + analysis: SecurityFindingAnalysis; + finding: NonNullable>>; +}) => Promise; + +type TrackCompletedAnalysis = (params: { + findingId: string; + analysis: SecurityFindingAnalysis; + finding: NonNullable>>; +}) => Promise; + +const COMPLETED_CALLBACK_MAX_ATTEMPTS = 3; +const COMPLETED_CALLBACK_RETRY_DELAY_MS = 5000; + +type FetchLatestAssistantText = (params: { kiloSessionId: string }) => Promise; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function resolveCompletedCallbackMarkdown(params: { + payload: SecurityAnalysisCallbackPayload; + fetchLatestAssistantText?: FetchLatestAssistantText; + sleep?: (ms: number) => Promise; +}): Promise { + const callbackText = params.payload.lastAssistantMessageText?.trim(); + if (callbackText) return callbackText; + if (!params.payload.kiloSessionId || !params.fetchLatestAssistantText) return null; + + const delay = params.sleep ?? sleep; + for (let attempt = 1; attempt <= COMPLETED_CALLBACK_MAX_ATTEMPTS; attempt += 1) { + if (attempt > 1) await delay(COMPLETED_CALLBACK_RETRY_DELAY_MS); + const snapshotText = await params.fetchLatestAssistantText({ + kiloSessionId: params.payload.kiloSessionId, + }); + const normalized = snapshotText?.trim(); + if (normalized) return normalized; + } + return null; +} + +export async function finalizeCompletedAnalysisCallback(params: { + db: WorkerDb; + findingId: string; + payload: SecurityAnalysisCallbackPayload; + extractSandboxAnalysis: ExtractSandboxAnalysis; + maybeAutoDismissAnalysis?: MaybeAutoDismissAnalysis; + trackCompletedAnalysis?: TrackCompletedAnalysis; + fetchLatestAssistantText?: FetchLatestAssistantText; + sleep?: (ms: number) => Promise; +}): Promise<{ + status: 'missing' | CallbackDisposition | 'completed-finalized' | 'result-missing'; +}> { + const finding = await getSecurityFindingById(params.db, params.findingId); + if (!finding) return { status: 'missing' }; + + const disposition = classifyAnalysisCallback(finding, params.payload); + if (disposition === 'stale-session' || disposition === 'already-terminal') { + return { status: disposition }; + } + if (disposition === 'superseded') { + await transitionAnalysisQueueFromCallback(params.db, { + findingId: params.findingId, + toStatus: 'completed', + failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE', + errorMessage: null, + }); + await clearAnalysisStatus(params.db, params.findingId); + return { status: disposition }; + } + + const rawMarkdown = await resolveCompletedCallbackMarkdown({ + payload: params.payload, + fetchLatestAssistantText: params.fetchLatestAssistantText, + sleep: params.sleep, + }); + if (!rawMarkdown) { + const errorMessage = 'Analysis completed but callback result text was missing'; + if (!(await setFindingFailed(params.db, params.findingId, errorMessage))) { + await clearAnalysisStatus(params.db, params.findingId); + } + await transitionAnalysisQueueFromCallback(params.db, { + findingId: params.findingId, + toStatus: 'failed', + failureCode: 'START_CALL_AMBIGUOUS', + errorMessage, + }); + return { status: 'result-missing' }; + } + + const sandboxAnalysis = await params.extractSandboxAnalysis({ finding, rawMarkdown }); + const priorAnalysis = finding.analysis ?? undefined; + const completedAnalysis: SecurityFindingAnalysis = { + ...priorAnalysis, + sandboxAnalysis, + rawMarkdown, + analyzedAt: new Date().toISOString(), + modelUsed: + sandboxAnalysis.modelUsed ?? priorAnalysis?.analysisModel ?? priorAnalysis?.modelUsed, + analysisModel: priorAnalysis?.analysisModel, + triageModel: priorAnalysis?.triageModel, + triggeredByUserId: priorAnalysis?.triggeredByUserId, + correlationId: priorAnalysis?.correlationId, + }; + + if (!(await setFindingCompleted(params.db, params.findingId, completedAnalysis))) { + await clearAnalysisStatus(params.db, params.findingId); + return { status: 'superseded' }; + } + await transitionAnalysisQueueFromCallback(params.db, { + findingId: params.findingId, + toStatus: 'completed', + failureCode: null, + errorMessage: null, + }); + await params.db.insert(security_audit_log).values({ + owned_by_organization_id: finding.owned_by_organization_id, + owned_by_user_id: finding.owned_by_user_id, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAnalysisCompleted, + resource_type: 'security_finding', + resource_id: params.findingId, + metadata: { + source: 'system', + model: completedAnalysis.modelUsed, + triageModel: completedAnalysis.triageModel, + analysisModel: completedAnalysis.analysisModel, + correlationId: completedAnalysis.correlationId, + triggeredByUserId: completedAnalysis.triggeredByUserId, + }, + }); + await params.maybeAutoDismissAnalysis?.({ + findingId: params.findingId, + analysis: completedAnalysis, + finding, + }); + await params.trackCompletedAnalysis?.({ + findingId: params.findingId, + analysis: completedAnalysis, + finding, + }); + return { status: 'completed-finalized' }; +} + +export async function finalizeFailedAnalysisCallback(params: { + db: WorkerDb; + findingId: string; + payload: SecurityAnalysisCallbackPayload; +}): Promise<{ status: 'missing' | CallbackDisposition | 'failed-finalized' }> { + const finding = await getSecurityFindingById(params.db, params.findingId); + if (!finding) return { status: 'missing' }; + + const disposition = classifyAnalysisCallback(finding, params.payload); + if (disposition === 'stale-session' || disposition === 'already-terminal') { + return { status: disposition }; + } + if (disposition === 'superseded') { + await transitionAnalysisQueueFromCallback(params.db, { + findingId: params.findingId, + toStatus: 'completed', + failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE', + errorMessage: null, + }); + await clearAnalysisStatus(params.db, params.findingId); + return { status: disposition }; + } + + if (params.payload.status === 'completed') { + return { status: 'process' }; + } + + const failure = mapAnalysisCallbackFailure({ + status: params.payload.status === 'interrupted' ? 'interrupted' : 'failed', + errorMessage: params.payload.errorMessage, + }); + if (!(await setFindingFailed(params.db, params.findingId, failure.errorMessage))) { + await clearAnalysisStatus(params.db, params.findingId); + } + await transitionAnalysisQueueFromCallback(params.db, { + findingId: params.findingId, + toStatus: 'failed', + failureCode: failure.failureCode, + errorMessage: failure.errorMessage, + }); + return { status: 'failed-finalized' }; +} + +export async function finalizeFailedAnalysisCallbackFromEnv(params: { + env: CloudflareEnv; + findingId: string; + payload: SecurityAnalysisCallbackPayload; +}): Promise<{ status: 'missing' | CallbackDisposition | 'failed-finalized' }> { + const db = getWorkerDb(params.env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); + return finalizeFailedAnalysisCallback({ + db, + findingId: params.findingId, + payload: params.payload, + }); +} + +export async function finalizeCompletedAnalysisCallbackFromEnv(params: { + env: CloudflareEnv; + findingId: string; + payload: SecurityAnalysisCallbackPayload; +}): Promise<{ + status: 'missing' | CallbackDisposition | 'completed-finalized' | 'result-missing'; +}> { + const db = getWorkerDb(params.env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); + return finalizeCompletedAnalysisCallback({ + db, + findingId: params.findingId, + payload: params.payload, + fetchLatestAssistantText: async ({ kiloSessionId }) => { + const finding = await getSecurityFindingById(db, params.findingId); + const userId = finding?.analysis?.triggeredByUserId; + if (!userId) return null; + const nextAuthSecret = await params.env.NEXTAUTH_SECRET.get(); + return fetchSessionAssistantText({ + sessionId: kiloSessionId, + userId, + sessionIngestWorkerUrl: params.env.SESSION_INGEST_WORKER_URL, + nextAuthSecret, + }); + }, + extractSandboxAnalysis: async ({ finding, rawMarkdown }) => { + const triggeredByUserId = finding.analysis?.triggeredByUserId; + if (!triggeredByUserId) { + throw new Error('Cannot extract completed security analysis without triggeredByUserId'); + } + const actor = await getAnalysisActorById(db, triggeredByUserId); + if (!actor) { + throw new Error(`Analysis actor ${triggeredByUserId} is unavailable`); + } + const [nextAuthSecret] = await Promise.all([params.env.NEXTAUTH_SECRET.get()]); + const authToken = await generateApiToken(actor, nextAuthSecret, params.env.ENVIRONMENT); + return runSandboxExtraction({ + finding, + rawMarkdown, + authToken, + model: + finding.analysis?.analysisModel ?? + finding.analysis?.modelUsed ?? + 'anthropic/claude-opus-4.6', + backendBaseUrl: params.env.KILOCODE_BACKEND_BASE_URL, + organizationId: finding.owned_by_organization_id ?? undefined, + }); + }, + maybeAutoDismissAnalysis: async ({ findingId, finding, analysis }) => { + await maybeAutoDismissCompletedAnalysis({ + db, + env: params.env, + findingId, + finding, + analysis, + }); + }, + trackCompletedAnalysis: async ({ findingId, finding, analysis }) => { + await trackSecurityAnalysisCompleted({ + env: params.env, + findingId, + finding, + analysis, + }); + }, + }); +} + +export async function finalizeAnalysisCallbackFromEnv(params: { + env: CloudflareEnv; + findingId: string; + payload: SecurityAnalysisCallbackPayload; +}): Promise<{ + status: + | 'missing' + | CallbackDisposition + | 'failed-finalized' + | 'completed-finalized' + | 'result-missing'; +}> { + return params.payload.status === 'completed' + ? finalizeCompletedAnalysisCallbackFromEnv(params) + : finalizeFailedAnalysisCallbackFromEnv(params); +} + +export async function consumeAnalysisCallbackBatch( + batch: MessageBatch, + env: CloudflareEnv, + finalizeCallback = finalizeAnalysisCallbackFromEnv +): Promise { + for (const message of batch.messages) { + const parsed = SecurityAnalysisCallbackMessageSchema.safeParse(message.body); + if (!parsed.success) { + message.ack(); + continue; + } + try { + await finalizeCallback({ + env, + findingId: parsed.data.findingId, + payload: parsed.data.payload, + }); + message.ack(); + } catch (error) { + console.error('Security analysis callback finalization failed', { + findingId: parsed.data.findingId, + error: error instanceof Error ? error.message : String(error), + }); + message.retry(); + } + } +} diff --git a/services/security-auto-analysis/src/consumer.ts b/services/security-auto-analysis/src/consumer.ts index c016d8c8cb..5c04578342 100644 --- a/services/security-auto-analysis/src/consumer.ts +++ b/services/security-auto-analysis/src/consumer.ts @@ -16,6 +16,7 @@ import { AutoAnalysisOwnerMessageSchema, AUTO_ANALYSIS_MAX_ATTEMPTS, AUTO_ANALYSIS_OWNER_CAP, + resolveSecurityAgentModels, type ActorResolutionMode, type AutoAnalysisFailureCode, type ProcessCounters, @@ -479,13 +480,15 @@ async function processOwnerMessage(params: { continue; } + const models = resolveSecurityAgentModels(claim.config); const startResult = await startSecurityAnalysis({ db, env: params.env, findingId: finding.id, actorUser: actorResolution.user, githubToken, - model: claim.config.model_slug ?? 'anthropic/claude-opus-4.6', + triageModel: models.triageModel, + analysisModel: models.analysisModel, analysisMode: claim.config.analysis_mode, organizationId: launchOwner.type === 'org' ? launchOwner.id : undefined, nextAuthSecret, diff --git a/services/security-auto-analysis/src/db/queries.integration.test.ts b/services/security-auto-analysis/src/db/queries.integration.test.ts new file mode 100644 index 0000000000..14eb0ce96a --- /dev/null +++ b/services/security-auto-analysis/src/db/queries.integration.test.ts @@ -0,0 +1,256 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { randomUUID } from 'crypto'; +import { createDrizzleClient } from '@kilocode/db/client'; +import { + kilocode_users, + security_analysis_owner_state, + security_analysis_queue, + security_findings, +} from '@kilocode/db/schema'; +import { eq, inArray } from 'drizzle-orm'; +import { + discoverDueOwners, + ensureManualAnalysisQueueRow, + getSecurityFindingById, + reconcileStaleAnalysisQueueRows, +} from './queries.js'; + +const connectionString = + process.env.POSTGRES_URL ?? 'postgres://postgres:postgres@localhost:5432/postgres'; +const testUserId = `security-auto-analysis-db-${randomUUID()}`; +const testFindingIds: string[] = []; +let client: ReturnType; + +describe('security analysis durable database invariants', () => { + beforeAll(async () => { + client = createDrizzleClient({ connectionString, ssl: false }); + await client.db.insert(kilocode_users).values({ + id: testUserId, + google_user_email: `${testUserId}@example.com`, + google_user_name: 'Security Auto Analysis DB Test', + google_user_image_url: 'https://example.com/avatar.png', + stripe_customer_id: `cus_${randomUUID()}`, + }); + }); + + afterEach(async () => { + if (testFindingIds.length === 0) return; + const ids = testFindingIds.splice(0, testFindingIds.length); + await client.db + .delete(security_analysis_queue) + .where(inArray(security_analysis_queue.finding_id, ids)); + await client.db + .delete(security_analysis_owner_state) + .where(eq(security_analysis_owner_state.owned_by_user_id, testUserId)); + await client.db.delete(security_findings).where(inArray(security_findings.id, ids)); + }); + + afterAll(async () => { + await client.db.delete(kilocode_users).where(eq(kilocode_users.id, testUserId)); + await client.pool.end(); + }); + + it('enforces one manual queue row per finding against Postgres constraints', async () => { + const findingId = await insertFinding('manual-unique'); + const finding = await getSecurityFindingById(client.db as never, findingId); + expect(finding).not.toBeNull(); + if (!finding) return; + + await expect( + ensureManualAnalysisQueueRow(client.db as never, { + finding, + claimToken: 'claim-token-one', + jobId: 'manual-job-one', + }) + ).resolves.toBe(true); + await expect( + ensureManualAnalysisQueueRow(client.db as never, { + finding, + claimToken: 'claim-token-two', + jobId: 'manual-job-two', + }) + ).resolves.toBe(false); + + const queueRows = await client.db + .select({ queueStatus: security_analysis_queue.queue_status }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ queueStatus: 'pending' }]); + }); + + it('requeues stale pending rows and terminalizes stale running rows in real SQL', async () => { + const pendingFindingId = await insertFinding('stale-pending'); + const runningFindingId = await insertFinding('stale-running', 'running'); + await client.db.insert(security_analysis_queue).values([ + { + finding_id: pendingFindingId, + owned_by_user_id: testUserId, + queue_status: 'pending', + severity_rank: 1, + queued_at: '2026-05-18T08:00:00.000Z', + claimed_at: '2026-05-18T08:00:00.000Z', + claimed_by_job_id: 'pending-job', + claim_token: 'pending-claim', + updated_at: '2026-05-18T08:00:00.000Z', + }, + { + finding_id: runningFindingId, + owned_by_user_id: testUserId, + queue_status: 'running', + severity_rank: 1, + queued_at: '2026-05-18T06:00:00.000Z', + claimed_at: '2026-05-18T06:00:00.000Z', + claimed_by_job_id: 'running-job', + claim_token: 'running-claim', + updated_at: '2026-05-18T06:00:00.000Z', + }, + ]); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 1, + failedRunningCount: 1, + }); + + const queueRows = await client.db + .select({ + findingId: security_analysis_queue.finding_id, + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(inArray(security_analysis_queue.finding_id, [pendingFindingId, runningFindingId])); + expect(queueRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + findingId: pendingFindingId, + status: 'queued', + failureCode: null, + }), + expect.objectContaining({ + findingId: runningFindingId, + status: 'failed', + failureCode: 'RUN_LOST', + }), + ]) + ); + }); + + it('leaves fresh pending and running rows untouched in real SQL', async () => { + const currentTimestamp = new Date(Date.now() - 60_000).toISOString(); + const pendingFindingId = await insertFinding('fresh-pending'); + const runningFindingId = await insertFinding('fresh-running', 'running'); + await client.db.insert(security_analysis_queue).values([ + { + finding_id: pendingFindingId, + owned_by_user_id: testUserId, + queue_status: 'pending', + severity_rank: 1, + queued_at: currentTimestamp, + claimed_at: currentTimestamp, + claimed_by_job_id: 'fresh-pending-job', + claim_token: 'fresh-pending-claim', + updated_at: currentTimestamp, + }, + { + finding_id: runningFindingId, + owned_by_user_id: testUserId, + queue_status: 'running', + severity_rank: 1, + queued_at: currentTimestamp, + claimed_at: currentTimestamp, + claimed_by_job_id: 'fresh-running-job', + claim_token: 'fresh-running-claim', + updated_at: currentTimestamp, + }, + ]); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const queueRows = await client.db + .select({ + findingId: security_analysis_queue.finding_id, + status: security_analysis_queue.queue_status, + claimToken: security_analysis_queue.claim_token, + }) + .from(security_analysis_queue) + .where(inArray(security_analysis_queue.finding_id, [pendingFindingId, runningFindingId])); + expect(queueRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + findingId: pendingFindingId, + status: 'pending', + claimToken: 'fresh-pending-claim', + }), + expect.objectContaining({ + findingId: runningFindingId, + status: 'running', + claimToken: 'fresh-running-claim', + }), + ]) + ); + }); + + it('keeps owner block state intact while stale pending work is requeued', async () => { + const staleFindingId = await insertFinding('blocked-stale-pending'); + await client.db.insert(security_analysis_owner_state).values({ + owned_by_user_id: testUserId, + blocked_until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + block_reason: 'OPERATOR_PAUSE', + consecutive_actor_resolution_failures: 2, + }); + await client.db.insert(security_analysis_queue).values({ + finding_id: staleFindingId, + owned_by_user_id: testUserId, + queue_status: 'pending', + severity_rank: 1, + queued_at: '2026-05-18T08:00:00.000Z', + claimed_at: '2026-05-18T08:00:00.000Z', + claimed_by_job_id: 'blocked-pending-job', + claim_token: 'blocked-pending-claim', + updated_at: '2026-05-18T08:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 1, + failedRunningCount: 0, + }); + + const ownerStates = await client.db + .select({ + reason: security_analysis_owner_state.block_reason, + failures: security_analysis_owner_state.consecutive_actor_resolution_failures, + }) + .from(security_analysis_owner_state) + .where(eq(security_analysis_owner_state.owned_by_user_id, testUserId)); + expect(ownerStates).toEqual([{ reason: 'OPERATOR_PAUSE', failures: 2 }]); + await expect(discoverDueOwners(client.db as never, 10)).resolves.not.toContainEqual({ + type: 'user', + id: testUserId, + }); + }); +}); + +async function insertFinding( + suffix: string, + analysisStatus: string | null = 'pending' +): Promise { + const findingId = randomUUID(); + testFindingIds.push(findingId); + await client.db.insert(security_findings).values({ + id: findingId, + owned_by_user_id: testUserId, + repo_full_name: `kilo/${suffix}`, + source: 'dependabot', + source_id: suffix, + severity: 'high', + package_name: `package-${suffix}`, + package_ecosystem: 'npm', + title: `Finding ${suffix}`, + status: 'open', + analysis_status: analysisStatus, + }); + return findingId; +} diff --git a/services/security-auto-analysis/src/db/queries.test.ts b/services/security-auto-analysis/src/db/queries.test.ts new file mode 100644 index 0000000000..74b3cadc57 --- /dev/null +++ b/services/security-auto-analysis/src/db/queries.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveSecurityAgentModels } from '../types.js'; +import { parseSecurityConfig, reconcileStaleAnalysisQueueRows } from './queries.js'; + +describe('parseSecurityConfig', () => { + it('preserves legacy model fallback when phase-specific models are absent', () => { + const config = parseSecurityConfig({ model_slug: 'legacy/model' }); + + expect(resolveSecurityAgentModels(config)).toEqual({ + triageModel: 'legacy/model', + analysisModel: 'legacy/model', + }); + }); +}); + +describe('reconcileStaleAnalysisQueueRows', () => { + it('reports requeued stale pending rows and failed stale running rows', async () => { + const execute = vi + .fn() + .mockResolvedValueOnce({ rows: [{ id: 'pending-row' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'running-row' }, { id: 'running-row-2' }] }); + + await expect(reconcileStaleAnalysisQueueRows({ execute } as never)).resolves.toEqual({ + requeuedPendingCount: 1, + failedRunningCount: 2, + }); + expect(execute).toHaveBeenCalledTimes(2); + }); + + it('leaves fresh rows untouched when reconciliation queries return no stale rows', async () => { + const execute = vi.fn().mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); + + await expect(reconcileStaleAnalysisQueueRows({ execute } as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + }); +}); diff --git a/services/security-auto-analysis/src/db/queries.ts b/services/security-auto-analysis/src/db/queries.ts index 3a488453e5..f27244e023 100644 --- a/services/security-auto-analysis/src/db/queries.ts +++ b/services/security-auto-analysis/src/db/queries.ts @@ -38,7 +38,7 @@ type ClaimRowsForOwnerResult = { blocked: boolean; }; -function parseSecurityConfig(config: unknown): SecurityAgentConfig { +export function parseSecurityConfig(config: unknown): SecurityAgentConfig { let configValue: unknown = config; if (typeof configValue === 'string') { @@ -54,10 +54,21 @@ function parseSecurityConfig(config: unknown): SecurityAgentConfig { return DEFAULT_SECURITY_AGENT_CONFIG; } - return { + const resolvedConfig: SecurityAgentConfig = { ...DEFAULT_SECURITY_AGENT_CONFIG, ...parsed.data, }; + + if (parsed.data.model_slug !== undefined) { + if (parsed.data.triage_model_slug === undefined) { + resolvedConfig.triage_model_slug = undefined; + } + if (parsed.data.analysis_model_slug === undefined) { + resolvedConfig.analysis_model_slug = undefined; + } + } + + return resolvedConfig; } function ownerWhereQueue(owner: QueueOwner) { @@ -78,6 +89,26 @@ function ownerWhereOwnerState(owner: QueueOwner) { : eq(security_analysis_owner_state.owned_by_user_id, owner.id); } +export async function getSecurityAgentConfigForOwner( + db: WorkerDb, + owner: QueueOwner +): Promise { + const rows = await db + .select({ config: agent_configs.config }) + .from(agent_configs) + .where( + and( + eq(agent_configs.agent_type, 'security_scan'), + eq(agent_configs.platform, 'github'), + owner.type === 'org' + ? eq(agent_configs.owned_by_organization_id, owner.id) + : eq(agent_configs.owned_by_user_id, owner.id) + ) + ) + .limit(1); + return parseSecurityConfig(rows[0]?.config); +} + export async function discoverDueOwners(db: WorkerDb, limit: number): Promise { const rows = await db .selectDistinct({ @@ -320,6 +351,85 @@ export async function updateQueueFromPending( }; } +export async function countOwnerInflightAnalyses(db: WorkerDb, owner: QueueOwner): Promise { + const rows = await db + .select({ total: count() }) + .from(security_findings) + .where( + and( + ownerWhereFindings(owner), + inArray(security_findings.analysis_status, ['pending', 'running']) + ) + ); + return rows[0]?.total ?? 0; +} + +export async function ensureManualAnalysisQueueRow( + db: WorkerDb, + params: { finding: SecurityFindingRecord; claimToken: string; jobId: string } +): Promise { + const severityRank = + params.finding.severity === 'critical' + ? 0 + : params.finding.severity === 'high' + ? 1 + : params.finding.severity === 'medium' + ? 2 + : 3; + const rows = await db + .insert(security_analysis_queue) + .values({ + finding_id: params.finding.id, + owned_by_organization_id: params.finding.owned_by_organization_id, + owned_by_user_id: params.finding.owned_by_user_id, + queue_status: 'pending', + severity_rank: severityRank, + queued_at: sql`now()`.mapWith(String), + claimed_at: sql`now()`.mapWith(String), + claimed_by_job_id: params.jobId, + claim_token: params.claimToken, + updated_at: sql`now()`.mapWith(String), + }) + .onConflictDoNothing() + .returning({ id: security_analysis_queue.id }); + return rows.length > 0; +} + +export async function transitionManualAnalysisQueueFromStart( + db: WorkerDb, + params: { + findingId: string; + claimToken: string; + status: 'running' | 'completed' | 'failed'; + failureCode: string | null; + errorMessage: string | null; + } +): Promise { + await db.execute(sql` + UPDATE security_analysis_queue + SET + queue_status = ${params.status}, + failure_code = ${params.failureCode}, + last_error_redacted = ${params.errorMessage}, + updated_at = now() + WHERE finding_id = ${params.findingId}::uuid + AND claim_token = ${params.claimToken} + AND queue_status = 'pending' + `); +} + +export async function getAnalysisActorById( + db: WorkerDb, + userId: string +): Promise { + const rows = await db + .select({ id: kilocode_users.id, api_token_pepper: kilocode_users.api_token_pepper }) + .from(kilocode_users) + .where(and(eq(kilocode_users.id, userId), isNull(kilocode_users.blocked_reason))) + .limit(1); + return rows[0] ?? null; +} + export async function resolveAutoAnalysisActor( db: WorkerDb, owner: QueueOwner @@ -434,7 +544,10 @@ export async function getSecurityFindingById(db: WorkerDb, findingId: string) { const rows = await db .select({ id: security_findings.id, + platform_integration_id: security_findings.platform_integration_id, repo_full_name: security_findings.repo_full_name, + source: security_findings.source, + source_id: security_findings.source_id, created_at: security_findings.created_at, status: security_findings.status, severity: security_findings.severity, @@ -450,6 +563,11 @@ export async function getSecurityFindingById(db: WorkerDb, findingId: string) { manifest_path: security_findings.manifest_path, raw_data: security_findings.raw_data, analysis_status: security_findings.analysis_status, + analysis: security_findings.analysis, + analysis_started_at: security_findings.analysis_started_at, + session_id: security_findings.session_id, + cli_session_id: security_findings.cli_session_id, + ignored_reason: security_findings.ignored_reason, owned_by_organization_id: security_findings.owned_by_organization_id, owned_by_user_id: security_findings.owned_by_user_id, }) @@ -621,3 +739,80 @@ export async function clearAnalysisStatus(db: WorkerDb, findingId: string): Prom }) .where(eq(security_findings.id, findingId)); } + +export async function transitionAnalysisQueueFromCallback( + db: WorkerDb, + params: { + findingId: string; + toStatus: 'completed' | 'failed'; + failureCode: string | null; + errorMessage: string | null; + } +): Promise { + await db.execute(sql` + UPDATE security_analysis_queue + SET + queue_status = ${params.toStatus}, + failure_code = ${params.failureCode}, + last_error_redacted = ${params.errorMessage}, + updated_at = now() + WHERE finding_id = ${params.findingId}::uuid + AND queue_status IN ('running', 'pending') + `); +} + +export async function reconcileStaleAnalysisQueueRows(db: WorkerDb): Promise<{ + requeuedPendingCount: number; + failedRunningCount: number; +}> { + const requeuedPending = await db.execute<{ id: string }>(sql` + WITH requeued_rows AS ( + UPDATE security_analysis_queue + SET + queue_status = 'queued', + claim_token = NULL, + claimed_at = NULL, + claimed_by_job_id = NULL, + failure_code = NULL, + last_error_redacted = NULL, + next_retry_at = NULL, + updated_at = now() + WHERE queue_status = 'pending' + AND claimed_at <= now() - interval '15 minutes' + RETURNING finding_id + ) + UPDATE security_findings + SET + analysis_status = NULL, + updated_at = now() + WHERE id IN (SELECT finding_id FROM requeued_rows) + RETURNING id + `); + + const failedRunning = await db.execute<{ id: string }>(sql` + WITH failed_rows AS ( + UPDATE security_analysis_queue + SET + queue_status = 'failed', + failure_code = 'RUN_LOST', + last_error_redacted = 'Automated stale running reconciliation', + updated_at = now() + WHERE queue_status = 'running' + AND updated_at <= now() - interval '2 hours' + RETURNING finding_id + ) + UPDATE security_findings + SET + analysis_status = 'failed', + analysis_error = 'Analysis run lost during stale-row reconciliation', + analysis_completed_at = now(), + updated_at = now() + WHERE id IN (SELECT finding_id FROM failed_rows) + RETURNING id + `); + + return { + requeuedPendingCount: requeuedPending.rows.length, + failedRunningCount: failedRunning.rows.length, + }; +} diff --git a/services/security-auto-analysis/src/dispatcher.ts b/services/security-auto-analysis/src/dispatcher.ts index 1b112458a8..bb5f647240 100644 --- a/services/security-auto-analysis/src/dispatcher.ts +++ b/services/security-auto-analysis/src/dispatcher.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import { getWorkerDb } from '@kilocode/db/client'; -import { discoverDueOwners } from './db/queries.js'; +import { discoverDueOwners, reconcileStaleAnalysisQueueRows } from './db/queries.js'; import { logger } from './logger.js'; const DISPATCH_OWNER_LIMIT = 100; @@ -13,6 +13,12 @@ export async function dispatchDueOwners(env: CloudflareEnv): Promise<{ const dispatchId = randomUUID(); const db = getWorkerDb(env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); + const reconciliation = await reconcileStaleAnalysisQueueRows(db); + logger.info('Reconciled stale analysis queue rows before owner dispatch', { + requeued_pending_count: reconciliation.requeuedPendingCount, + failed_running_count: reconciliation.failedRunningCount, + }); + const owners = await discoverDueOwners(db, DISPATCH_OWNER_LIMIT); const messages = owners.map(owner => ({ diff --git a/services/security-auto-analysis/src/extraction.ts b/services/security-auto-analysis/src/extraction.ts new file mode 100644 index 0000000000..40dd11be0b --- /dev/null +++ b/services/security-auto-analysis/src/extraction.ts @@ -0,0 +1,184 @@ +import { z } from 'zod'; +import type { SecurityFindingRecord } from './db/queries.js'; +import { logger } from './logger.js'; +import type { SecurityFindingSandboxAnalysis } from './types.js'; + +const EXTRACTION_SERVICE_VERSION = '5.0.0'; +const EXTRACTION_SERVICE_USER_AGENT = `Kilo-Security-Extraction/${EXTRACTION_SERVICE_VERSION}`; + +const ExtractionResultSchema = z.object({ + isExploitable: z.union([z.boolean(), z.literal('unknown')]), + exploitabilityReasoning: z.string(), + usageLocations: z.array(z.string()), + suggestedFix: z.string(), + suggestedAction: z.enum(['dismiss', 'open_pr', 'manual_review', 'monitor']), + summary: z.string(), +}); + +const ExtractionResponseSchema = z.object({ + choices: z.array( + z.object({ + message: z.object({ + tool_calls: z + .array( + z.object({ + type: z.literal('function'), + function: z.object({ name: z.string(), arguments: z.string() }), + }) + ) + .optional(), + }), + }) + ), +}); + +function buildExtractionPrompt(finding: SecurityFindingRecord, rawMarkdown: string): string { + return `## Original Vulnerability Details + +**Package**: ${finding.package_name} (${finding.package_ecosystem}) +**Severity**: ${finding.severity} +**Dependency Scope**: ${finding.dependency_scope ?? 'unknown'} +**CVE**: ${finding.cve_id ?? 'N/A'} +**GHSA**: ${finding.ghsa_id ?? 'N/A'} +**Title**: ${finding.title} +**Vulnerable Versions**: ${finding.vulnerable_version_range ?? 'Unknown'} +**Patched Version**: ${finding.patched_version ?? 'No patch available'} + +## Raw Analysis Report + +${rawMarkdown} + +Please extract structured analysis and call submit_analysis_extraction.`; +} + +function fallbackExtraction(rawMarkdown: string, reason: string): SecurityFindingSandboxAnalysis { + return { + isExploitable: 'unknown', + exploitabilityReasoning: `Extraction failed: ${reason}. Please review the raw analysis.`, + usageLocations: [], + suggestedFix: 'Review the raw analysis for fix recommendations.', + suggestedAction: 'manual_review', + summary: 'Analysis completed but structured extraction failed. Review raw output.', + rawMarkdown, + analysisAt: new Date().toISOString(), + }; +} + +function parseExtractionResult( + argumentsJson: string, + rawMarkdown: string, + model: string +): SecurityFindingSandboxAnalysis | null { + let parsed: unknown; + try { + parsed = JSON.parse(argumentsJson); + } catch { + return null; + } + const result = ExtractionResultSchema.safeParse(parsed); + if (!result.success) return null; + return { + ...result.data, + rawMarkdown, + analysisAt: new Date().toISOString(), + modelUsed: model, + }; +} + +export async function extractSandboxAnalysis(params: { + finding: SecurityFindingRecord; + rawMarkdown: string; + authToken: string; + model: string; + backendBaseUrl: string; + organizationId?: string; +}): Promise { + const headers = new Headers({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.authToken}`, + 'X-KiloCode-Version': EXTRACTION_SERVICE_VERSION, + 'User-Agent': EXTRACTION_SERVICE_USER_AGENT, + }); + if (params.organizationId) headers.set('X-KiloCode-OrganizationId', params.organizationId); + + try { + const response = await fetch(`${params.backendBaseUrl}/api/openrouter/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: params.model, + messages: [ + { + role: 'system', + content: + 'Extract exploitability, usage locations, suggested fix, suggested action, and summary from the vulnerability analysis.', + }, + { role: 'user', content: buildExtractionPrompt(params.finding, params.rawMarkdown) }, + ], + tools: [ + { + type: 'function', + function: { + name: 'submit_analysis_extraction', + description: 'Submit structured security analysis extraction', + parameters: { + type: 'object', + properties: { + isExploitable: { + oneOf: [{ type: 'boolean' }, { type: 'string', enum: ['unknown'] }], + }, + exploitabilityReasoning: { type: 'string' }, + usageLocations: { type: 'array', items: { type: 'string' } }, + suggestedFix: { type: 'string' }, + suggestedAction: { + type: 'string', + enum: ['dismiss', 'open_pr', 'manual_review', 'monitor'], + }, + summary: { type: 'string' }, + }, + required: [ + 'isExploitable', + 'exploitabilityReasoning', + 'usageLocations', + 'suggestedFix', + 'suggestedAction', + 'summary', + ], + }, + }, + }, + ], + tool_choice: { type: 'function', function: { name: 'submit_analysis_extraction' } }, + stream: false, + }), + signal: AbortSignal.timeout(45_000), + }); + if (!response.ok) { + logger.error('Extraction request failed', { + status: response.status, + finding_id: params.finding.id, + }); + return fallbackExtraction(params.rawMarkdown, `API error: ${response.status}`); + } + const parsedResponse = ExtractionResponseSchema.safeParse(await response.json()); + const toolCall = parsedResponse.success + ? parsedResponse.data.choices[0]?.message.tool_calls?.[0] + : undefined; + if (!toolCall || toolCall.function.name !== 'submit_analysis_extraction') { + return fallbackExtraction(params.rawMarkdown, 'Tool call missing'); + } + return ( + parseExtractionResult(toolCall.function.arguments, params.rawMarkdown, params.model) ?? + fallbackExtraction(params.rawMarkdown, 'Tool call arguments invalid') + ); + } catch (error) { + logger.error('Extraction call threw', { + finding_id: params.finding.id, + error: error instanceof Error ? error.message : String(error), + }); + return fallbackExtraction( + params.rawMarkdown, + error instanceof Error ? error.message : 'Unknown extraction error' + ); + } +} diff --git a/services/security-auto-analysis/src/index.test.ts b/services/security-auto-analysis/src/index.test.ts new file mode 100644 index 0000000000..7d0ecd3c24 --- /dev/null +++ b/services/security-auto-analysis/src/index.test.ts @@ -0,0 +1,176 @@ +import { deriveCallbackToken } from '@kilocode/worker-utils'; +import { describe, expect, it } from 'vitest'; +import worker from './index.js'; + +const CALLBACK_SECRET = 'callback-token-secret'; +const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const OTHER_FINDING_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + +function callbackRequest(headers: Record = {}): Request { + return new Request( + `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}`, + { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify({ + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'completed', + lastAssistantMessageText: '# Completed', + }), + } + ); +} + +async function callbackTokenFor(findingId = FINDING_ID): Promise { + return deriveCallbackToken({ + secret: CALLBACK_SECRET, + scope: 'security-analysis-callback', + resourceParts: [findingId], + }); +} + +describe('security analysis callback ingress', () => { + it('rejects callback traffic without a scoped callback token', async () => { + const response = await worker.fetch(callbackRequest(), { + CALLBACK_TOKEN_SECRET: { get: async () => CALLBACK_SECRET }, + } as CloudflareEnv); + + expect(response.status).toBe(401); + }); + + it('rejects callback traffic with the raw internal secret alone', async () => { + const response = await worker.fetch(callbackRequest({ 'X-Internal-Secret': 'worker-secret' }), { + CALLBACK_TOKEN_SECRET: { get: async () => CALLBACK_SECRET }, + INTERNAL_API_SECRET: { get: async () => 'worker-secret' }, + } as CloudflareEnv); + + expect(response.status).toBe(401); + }); + + it('rejects callback traffic with a callback token scoped to another finding', async () => { + const response = await worker.fetch( + callbackRequest({ 'X-Callback-Token': await callbackTokenFor(OTHER_FINDING_ID) }), + { CALLBACK_TOKEN_SECRET: { get: async () => CALLBACK_SECRET } } as CloudflareEnv + ); + + expect(response.status).toBe(401); + }); + + it('rejects callbacks while Worker callback ingress routing is paused', async () => { + const response = await worker.fetch( + new Request( + `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}`, + { method: 'POST' } + ), + { SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED: 'false' } as CloudflareEnv + ); + + expect(response.status).toBe(503); + }); + + it('accepts authenticated callbacks by enqueuing durable Worker finalization', async () => { + const queued: MessageSendRequest[][] = []; + const response = await worker.fetch( + callbackRequest({ 'X-Callback-Token': await callbackTokenFor() }), + { + CALLBACK_TOKEN_SECRET: { get: async () => CALLBACK_SECRET }, + CALLBACK_QUEUE: { + sendBatch: async batch => { + queued.push(batch); + }, + }, + } as CloudflareEnv + ); + + expect(response.status).toBe(202); + expect(queued).toHaveLength(1); + expect(queued[0]?.[0]?.body).toMatchObject({ + findingId: FINDING_ID, + payload: { status: 'completed' }, + }); + }); + + it('accepts authenticated failed callbacks for durable Worker terminalization', async () => { + const queued: MessageSendRequest[][] = []; + const request = new Request( + `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Callback-Token': await callbackTokenFor(), + }, + body: JSON.stringify({ + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + status: 'failed', + errorMessage: 'sandbox failed', + }), + } + ); + const response = await worker.fetch(request, { + CALLBACK_TOKEN_SECRET: { get: async () => CALLBACK_SECRET }, + CALLBACK_QUEUE: { + sendBatch: async batch => { + queued.push(batch); + }, + }, + } as CloudflareEnv); + + expect(response.status).toBe(202); + expect(queued[0]?.[0]?.body).toMatchObject({ + findingId: FINDING_ID, + payload: { status: 'failed', errorMessage: 'sandbox failed' }, + }); + }); + + it('rejects manual analysis commands while Worker command routing is paused', async () => { + const response = await worker.fetch( + new Request('https://security-auto-analysis/internal/manual-analysis-start', { + method: 'POST', + }), + { MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED: 'false' } as CloudflareEnv + ); + + expect(response.status).toBe(503); + }); + + it('accepts manual analysis commands by enqueuing Worker-owned orchestration', async () => { + const queued: MessageSendRequest[][] = []; + const response = await worker.fetch( + new Request('https://security-auto-analysis/internal/manual-analysis-start', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'worker-secret', + }, + body: JSON.stringify({ + schemaVersion: 1, + findingId: FINDING_ID, + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actorUserId: 'user-123', + requestedModels: { analysisModel: 'analysis/model' }, + retrySandboxOnly: true, + }), + }), + { + INTERNAL_API_SECRET: { get: async () => 'worker-secret' }, + MANUAL_ANALYSIS_QUEUE: { + sendBatch: async batch => { + queued.push(batch); + }, + }, + } as CloudflareEnv + ); + + expect(response.status).toBe(202); + expect(queued[0]?.[0]?.body).toMatchObject({ + findingId: FINDING_ID, + actorUserId: 'user-123', + retrySandboxOnly: true, + }); + }); +}); diff --git a/services/security-auto-analysis/src/index.ts b/services/security-auto-analysis/src/index.ts index a20e37a6f5..6791639804 100644 --- a/services/security-auto-analysis/src/index.ts +++ b/services/security-auto-analysis/src/index.ts @@ -1,6 +1,12 @@ import { timingSafeEqual as nodeTSE } from 'crypto'; +import { verifyCallbackToken } from '@kilocode/worker-utils'; import { consumeOwnerBatch } from './consumer.js'; import { dispatchDueOwners } from './dispatcher.js'; +import { + consumeAnalysisCallbackBatch, + SecurityAnalysisCallbackPayloadSchema, +} from './callbacks.js'; +import { consumeManualAnalysisBatch, ManualAnalysisStartCommandSchema } from './manual-analysis.js'; async function sendBetterStackHeartbeat( heartbeatUrl: string | undefined, @@ -28,6 +34,10 @@ async function timingSafeEqual(a: string, b: string): Promise { return nodeTSE(new Uint8Array(digestA), new Uint8Array(digestB)); } +function workerRouteEnabled(value: string | undefined): boolean { + return value !== 'false'; +} + async function handleFetch(request: Request, env: CloudflareEnv): Promise { const url = new URL(request.url); @@ -54,6 +64,88 @@ async function handleFetch(request: Request, env: CloudflareEnv): Promise, env: CloudflareEnv): Promise { + if (batch.queue.startsWith('security-auto-analysis-callback-queue')) { + await consumeAnalysisCallbackBatch(batch, env); + return; + } + if (batch.queue.startsWith('security-manual-analysis-command-queue')) { + await consumeManualAnalysisBatch(batch, env); + return; + } await consumeOwnerBatch(batch, env); }, }; diff --git a/services/security-auto-analysis/src/launch.test.ts b/services/security-auto-analysis/src/launch.test.ts index a6100793f6..295f33aff1 100644 --- a/services/security-auto-analysis/src/launch.test.ts +++ b/services/security-auto-analysis/src/launch.test.ts @@ -1,131 +1,167 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveCallbackToken } from '@kilocode/worker-utils'; -import type { WorkerDb } from '@kilocode/db/client'; - -const { - mockGetSecurityFindingById, - mockTryAcquireAnalysisStartLease, - mockSetFindingPending, - mockSetFindingCompleted, - mockSetFindingFailed, - mockSetFindingRunning, - mockClearAnalysisStatus, - mockGenerateApiToken, - mockTriageSecurityFinding, -} = vi.hoisted(() => ({ - mockGetSecurityFindingById: vi.fn(), - mockTryAcquireAnalysisStartLease: vi.fn(), - mockSetFindingPending: vi.fn(), - mockSetFindingCompleted: vi.fn(), - mockSetFindingFailed: vi.fn(), - mockSetFindingRunning: vi.fn(), - mockClearAnalysisStatus: vi.fn(), - mockGenerateApiToken: vi.fn(), - mockTriageSecurityFinding: vi.fn(), -})); +import { + clearAnalysisStatus, + getSecurityFindingById, + setFindingCompleted, + setFindingFailed, + setFindingPending, + setFindingRunning, + tryAcquireAnalysisStartLease, +} from './db/queries.js'; +import { buildSecurityAnalysisCallbackTarget, startSecurityAnalysis } from './launch.js'; +import { generateApiToken } from './token.js'; +import { triageSecurityFinding } from './triage.js'; vi.mock('./db/queries.js', () => ({ - getSecurityFindingById: mockGetSecurityFindingById, - tryAcquireAnalysisStartLease: mockTryAcquireAnalysisStartLease, - setFindingPending: mockSetFindingPending, - setFindingCompleted: mockSetFindingCompleted, - setFindingFailed: mockSetFindingFailed, - setFindingRunning: mockSetFindingRunning, - clearAnalysisStatus: mockClearAnalysisStatus, -})); - -vi.mock('./token.js', () => ({ - generateApiToken: mockGenerateApiToken, + clearAnalysisStatus: vi.fn(), + getSecurityFindingById: vi.fn(), + setFindingCompleted: vi.fn(), + setFindingFailed: vi.fn(), + setFindingPending: vi.fn(), + setFindingRunning: vi.fn(), + tryAcquireAnalysisStartLease: vi.fn(), })); +vi.mock('./token.js', () => ({ generateApiToken: vi.fn() })); +vi.mock('./triage.js', () => ({ triageSecurityFinding: vi.fn() })); -vi.mock('./triage.js', () => ({ - triageSecurityFinding: mockTriageSecurityFinding, -})); +const CALLBACK_SECRET = 'test-callback-token-secret'; -import { startSecurityAnalysis } from './launch.js'; +const finding = { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo', + source: 'dependabot', + source_id: '42', + created_at: '2026-05-18T08:00:00.000Z', + status: 'open', + severity: 'high', + package_name: 'package-name', + package_ecosystem: 'npm', + dependency_scope: 'runtime', + cve_id: null, + ghsa_id: null, + title: 'Finding title', + description: 'Finding description', + vulnerable_version_range: '<1.0.0', + patched_version: '1.0.0', + manifest_path: 'package.json', + raw_data: null, + analysis_status: 'failed', + analysis_started_at: null, + session_id: null, + cli_session_id: null, + ignored_reason: null, + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, +}; -const INTERNAL_SECRET = 'test-internal-api-secret'; -const CALLBACK_SECRET = 'test-callback-token-secret'; -const FINDING_ID = 'finding-1'; +const existingTriage = { + needsSandboxAnalysis: true, + needsSandboxReasoning: 'Existing triage requests sandbox.', + suggestedAction: 'analyze_codebase' as const, + confidence: 'high' as const, + triageAt: '2026-05-18T08:00:00.000Z', +}; -function finding() { +function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) { return { - id: FINDING_ID, - status: 'open', - repo_full_name: 'owner/repo', - package_name: 'package', - package_ecosystem: 'npm', - severity: 'high', - dependency_scope: 'runtime', - cve_id: null, - ghsa_id: null, - title: 'Finding', - description: null, - vulnerable_version_range: null, - patched_version: null, - manifest_path: null, + db: {} as never, + env: { + ENVIRONMENT: 'development', + KILOCODE_BACKEND_BASE_URL: 'https://backend.test', + SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker', + SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', + CLOUD_AGENT_NEXT: { fetch: cloudAgentFetch }, + } as unknown as CloudflareEnv, + findingId: finding.id, + actorUser: { id: 'user-123', api_token_pepper: null }, + githubToken: 'github-token', + triageModel: 'triage/model', + analysisModel: 'analysis/model', + analysisMode: 'auto' as const, + organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + nextAuthSecret: 'next-auth-secret', + internalApiSecret: 'internal-api-secret', + callbackTokenSecret: CALLBACK_SECRET, + retrySandboxOnly, }; } -describe('security auto-analysis launch callback target', () => { +describe('buildSecurityAnalysisCallbackTarget', () => { + it('routes callback delivery directly to the Worker processing plane by default', () => { + expect( + buildSecurityAnalysisCallbackTarget( + { + SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker', + SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', + }, + finding.id, + 'callback-token' + ) + ).toEqual({ + url: `https://security-auto-analysis/internal/security-analysis-callback/${finding.id}`, + delivery: 'security-auto-analysis', + headers: { 'X-Callback-Token': 'callback-token' }, + }); + }); + + it('routes callback delivery through the compatibility web ingress when configured', () => { + expect( + buildSecurityAnalysisCallbackTarget( + { + SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'web', + SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai/', + }, + finding.id, + 'callback-token' + ) + ).toEqual({ + url: `https://app.kilo.ai/api/internal/security-analysis-callback/${finding.id}`, + headers: { 'X-Callback-Token': 'callback-token' }, + }); + }); +}); + +describe('startSecurityAnalysis retrySandboxOnly', () => { beforeEach(() => { vi.clearAllMocks(); - mockGetSecurityFindingById.mockResolvedValue(finding()); - mockTryAcquireAnalysisStartLease.mockResolvedValue(true); - mockSetFindingPending.mockResolvedValue(true); - mockSetFindingCompleted.mockResolvedValue(true); - mockSetFindingFailed.mockResolvedValue(true); - mockSetFindingRunning.mockResolvedValue(true); - mockClearAnalysisStatus.mockResolvedValue(undefined); - mockGenerateApiToken.mockResolvedValue('api-token'); - mockTriageSecurityFinding.mockResolvedValue({ - needsSandboxAnalysis: true, - needsSandboxReasoning: 'needs sandbox', - suggestedAction: 'analyze_codebase', - confidence: 'high', - triageAt: '2026-05-20T00:00:00.000Z', - }); + vi.mocked(tryAcquireAnalysisStartLease).mockResolvedValue(true); + vi.mocked(generateApiToken).mockResolvedValue('auth-token'); + vi.mocked(setFindingCompleted).mockResolvedValue(true); + vi.mocked(setFindingFailed).mockResolvedValue(true); + vi.mocked(setFindingPending).mockResolvedValue(true); + vi.mocked(setFindingRunning).mockResolvedValue(true); + vi.mocked(clearAnalysisStatus).mockResolvedValue(undefined); }); it('stores scoped callback token instead of raw internal API secret', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue(finding as never); + vi.mocked(triageSecurityFinding).mockResolvedValue(existingTriage); const requests: Request[] = []; const cloudAgentFetch = vi.fn(async (request: Request) => { requests.push(request); if (request.url.includes('/trpc/prepareSession')) { return Response.json({ - result: { data: { cloudAgentSessionId: 'cloud-session-1', kiloSessionId: 'kilo-1' } }, + result: { data: { cloudAgentSessionId: 'agent-session', kiloSessionId: 'ses-123' } }, }); } - return Response.json({ result: { data: { executionId: 'exec-1', status: 'running' } } }); + return Response.json({ result: { data: { executionId: 'exec-123', status: 'running' } } }); }); - const env = { - ENVIRONMENT: 'development', - KILOCODE_BACKEND_BASE_URL: 'https://api.test', - CLOUD_AGENT_NEXT: { fetch: cloudAgentFetch }, - } as unknown as CloudflareEnv; - - const result = await startSecurityAnalysis({ - db: {} as WorkerDb, - env, - findingId: FINDING_ID, - actorUser: { id: 'user-1', api_token_pepper: null }, - model: 'model-1', - analysisMode: 'deep', - nextAuthSecret: 'next-auth-secret', - internalApiSecret: INTERNAL_SECRET, - callbackTokenSecret: CALLBACK_SECRET, + + await expect(startSecurityAnalysis(createParams(false, cloudAgentFetch as never))).resolves.toEqual({ + started: true, + triageOnly: false, }); - expect(result).toEqual({ started: true, triageOnly: false }); const prepareBody = await requests[0]?.json(); const expectedCallbackToken = await deriveCallbackToken({ secret: CALLBACK_SECRET, scope: 'security-analysis-callback', - resourceParts: [FINDING_ID], + resourceParts: [finding.id], }); expect(prepareBody).toMatchObject({ callbackTarget: { - url: `https://api.test/api/internal/security-analysis-callback/${FINDING_ID}`, headers: { 'X-Callback-Token': expectedCallbackToken }, }, }); @@ -133,4 +169,67 @@ describe('security auto-analysis launch callback target', () => { callbackTarget: { headers: { 'X-Internal-Secret': expect.any(String) } }, }); }); + + it('reuses existing triage and launches sandbox without retriaging', async () => { + const previousAnalysis = { + triage: existingTriage, + analyzedAt: '2026-05-18T08:00:00.000Z', + correlationId: 'previous-correlation', + }; + vi.mocked(getSecurityFindingById).mockResolvedValue({ + ...finding, + analysis: previousAnalysis, + } as never); + const cloudAgentFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { data: { cloudAgentSessionId: 'agent-session', kiloSessionId: 'ses-123' } }, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ result: { data: { executionId: 'exec-123', status: 'running' } } }), + { status: 200 } + ) + ); + + await expect(startSecurityAnalysis(createParams(true, cloudAgentFetch as never))).resolves.toEqual({ + started: true, + triageOnly: false, + }); + + expect(triageSecurityFinding).not.toHaveBeenCalled(); + expect(setFindingPending).toHaveBeenNthCalledWith(1, {}, finding.id, previousAnalysis); + expect(setFindingPending).toHaveBeenNthCalledWith( + 2, + {}, + finding.id, + expect.objectContaining({ triage: existingTriage }) + ); + expect(setFindingRunning).toHaveBeenCalledWith({}, finding.id, 'agent-session', 'ses-123'); + }); + + it('falls back to full triage when sandbox-only retry has no prior triage', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); + vi.mocked(triageSecurityFinding).mockResolvedValue({ + ...existingTriage, + needsSandboxAnalysis: false, + suggestedAction: 'manual_review', + }); + + await expect(startSecurityAnalysis(createParams(true, vi.fn() as never))).resolves.toEqual({ + started: true, + triageOnly: true, + }); + + expect(triageSecurityFinding).toHaveBeenCalledTimes(1); + expect(setFindingPending).toHaveBeenCalledWith({}, finding.id, null); + expect(setFindingCompleted).toHaveBeenCalledTimes(1); + expect(setFindingFailed).not.toHaveBeenCalled(); + expect(clearAnalysisStatus).not.toHaveBeenCalled(); + }); }); diff --git a/services/security-auto-analysis/src/launch.ts b/services/security-auto-analysis/src/launch.ts index 2e29dd5202..6803a6071b 100644 --- a/services/security-auto-analysis/src/launch.ts +++ b/services/security-auto-analysis/src/launch.ts @@ -105,14 +105,43 @@ type StartSecurityAnalysisParams = { api_token_pepper: string | null; }; githubToken?: string; - model: string; + triageModel: string; + analysisModel: string; analysisMode: AnalysisMode; organizationId?: string; nextAuthSecret: string; internalApiSecret: string; callbackTokenSecret: string; + retrySandboxOnly?: boolean; }; +export function buildSecurityAnalysisCallbackTarget( + env: Pick< + CloudflareEnv, + 'SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE' | 'SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL' + >, + findingId: string, + callbackToken: string +): { + url: string; + delivery?: 'security-auto-analysis'; + headers: { 'X-Callback-Token': string }; +} { + if (env.SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE === 'web') { + const baseUrl = env.SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL.replace(/\/$/, ''); + return { + url: `${baseUrl}/api/internal/security-analysis-callback/${findingId}`, + headers: { 'X-Callback-Token': callbackToken }, + }; + } + + return { + url: `https://security-auto-analysis/internal/security-analysis-callback/${findingId}`, + delivery: 'security-auto-analysis', + headers: { 'X-Callback-Token': callbackToken }, + }; +} + export async function startSecurityAnalysis( params: StartSecurityAnalysisParams ): Promise<{ started: boolean; error?: string; triageOnly?: boolean }> { @@ -134,20 +163,30 @@ export async function startSecurityAnalysis( return { started: false, error: 'Analysis already in progress' }; } - await setFindingPending(params.db, params.findingId, null); + const existingTriage = params.retrySandboxOnly ? finding.analysis?.triage : undefined; + const skipTriage = params.retrySandboxOnly === true && existingTriage !== undefined; + + await setFindingPending( + params.db, + params.findingId, + skipTriage ? (finding.analysis ?? null) : null + ); try { const environment = params.env.ENVIRONMENT === 'production' ? 'production' : 'development'; const authToken = await generateApiToken(params.actorUser, params.nextAuthSecret, environment); - const triage = await triageSecurityFinding({ - finding, - authToken, - model: params.model, - backendBaseUrl: params.env.KILOCODE_BACKEND_BASE_URL, - organizationId: params.organizationId, - }); + const triage = skipTriage + ? existingTriage + : await triageSecurityFinding({ + finding, + authToken, + model: params.triageModel, + backendBaseUrl: params.env.KILOCODE_BACKEND_BASE_URL, + organizationId: params.organizationId, + }); const runSandbox = + skipTriage || params.analysisMode === 'deep' || (params.analysisMode === 'auto' && triage.needsSandboxAnalysis); @@ -155,7 +194,9 @@ export async function startSecurityAnalysis( const triageOnlyAnalysis: SecurityFindingAnalysis = { triage, analyzedAt: new Date().toISOString(), - modelUsed: params.model, + modelUsed: params.triageModel, + triageModel: params.triageModel, + analysisModel: params.analysisModel, triggeredByUserId: params.actorUser.id, correlationId, }; @@ -172,34 +213,35 @@ export async function startSecurityAnalysis( const partialAnalysis: SecurityFindingAnalysis = { triage, analyzedAt: new Date().toISOString(), - modelUsed: params.model, + modelUsed: params.analysisModel, + triageModel: params.triageModel, + analysisModel: params.analysisModel, triggeredByUserId: params.actorUser.id, correlationId, }; await setFindingPending(params.db, params.findingId, partialAnalysis); - const callbackUrl = `${params.env.KILOCODE_BACKEND_BASE_URL}/api/internal/security-analysis-callback/${params.findingId}`; const callbackToken = await deriveCallbackToken({ secret: params.callbackTokenSecret, scope: 'security-analysis-callback', resourceParts: [params.findingId], }); + const callbackTarget = buildSecurityAnalysisCallbackTarget( + params.env, + params.findingId, + callbackToken + ); const prepareInput = { prompt: buildAnalysisPrompt(finding), mode: 'code', - model: params.model, + model: params.analysisModel, githubRepo: finding.repo_full_name, githubToken: params.githubToken, kilocodeOrganizationId: params.organizationId, createdOnPlatform: 'security-agent', - callbackTarget: { - url: callbackUrl, - headers: { - 'X-Callback-Token': callbackToken, - }, - }, + callbackTarget, }; const prepareResponse = await params.env.CLOUD_AGENT_NEXT.fetch( diff --git a/services/security-auto-analysis/src/manual-analysis.test.ts b/services/security-auto-analysis/src/manual-analysis.test.ts new file mode 100644 index 0000000000..8e162f4b2b --- /dev/null +++ b/services/security-auto-analysis/src/manual-analysis.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ensureManualAnalysisQueueRow } from './db/queries.js'; +import { startSecurityAnalysis } from './launch.js'; +import { processManualAnalysisStart, type ManualAnalysisStartCommand } from './manual-analysis.js'; + +vi.mock('./launch.js', () => ({ + startSecurityAnalysis: vi.fn(), +})); + +const command: ManualAnalysisStartCommand = { + schemaVersion: 1, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actorUserId: 'user-123', +}; + +const finding = { + id: command.findingId, + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + repo_full_name: 'kilo/repo', + source: 'dependabot', + source_id: '42', + severity: 'high', +}; + +beforeEach(() => { + vi.mocked(startSecurityAnalysis).mockReset(); +}); + +describe('processManualAnalysisStart', () => { + it('rejects manual starts for findings owned by another tenant', async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + ...finding, + owned_by_organization_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + }, + ], + }), + }), + }), + }; + + await expect( + processManualAnalysisStart({ db: db as never, env: {} as CloudflareEnv, command }) + ).resolves.toEqual({ status: 'finding-missing' }); + expect(startSecurityAnalysis).not.toHaveBeenCalled(); + }); + + it('enforces owner cap before claiming a manual queue row', async () => { + let selectCount = 0; + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + return { from: () => ({ where: async () => [{ total: 3 }] }) }; + }, + }; + + await expect( + processManualAnalysisStart({ db: db as never, env: {} as CloudflareEnv, command }) + ).resolves.toEqual({ status: 'owner-cap' }); + }); + + it('persists actor-selected model context in Worker launch and audit metadata', async () => { + let selectCount = 0; + let insertCount = 0; + const auditRows: unknown[] = []; + const execute = vi.fn().mockResolvedValue({ rows: [] }); + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + if (selectCount === 2) { + return { from: () => ({ where: async () => [{ total: 0 }] }) }; + } + if (selectCount === 3) { + return { + from: () => ({ + where: () => ({ limit: async () => [{ id: 'user-123', api_token_pepper: null }] }), + }), + }; + } + return { + from: () => ({ + where: () => ({ + limit: async () => [ + { + config: { + analysis_mode: 'deep', + triage_model_slug: 'config/triage', + analysis_model_slug: 'config/analysis', + }, + }, + ], + }), + }), + }; + }, + insert: () => { + insertCount += 1; + if (insertCount === 1) { + return { + values: () => ({ + onConflictDoNothing: () => ({ returning: async () => [{ id: 'queue-row' }] }), + }), + }; + } + return { + values: async (values: unknown) => { + auditRows.push(values); + }, + }; + }, + execute, + }; + vi.mocked(startSecurityAnalysis).mockResolvedValue({ started: true, triageOnly: false }); + + await expect( + processManualAnalysisStart({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ + success: true, + token: 'github-token', + installationId: 'installation-123', + accountLogin: 'kilo', + appType: 'standard', + }), + }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-secret' }, + } as unknown as CloudflareEnv, + command: { + ...command, + requestedModels: { triageModel: 'request/triage', analysisModel: 'request/analysis' }, + retrySandboxOnly: true, + }, + }) + ).resolves.toEqual({ status: 'started' }); + + expect(startSecurityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + actorUser: { id: 'user-123', api_token_pepper: null }, + triageModel: 'request/triage', + analysisModel: 'request/analysis', + analysisMode: 'deep', + retrySandboxOnly: true, + }) + ); + expect(auditRows[0]).toMatchObject({ + actor_id: 'user-123', + metadata: { + model: 'request/analysis', + triageModel: 'request/triage', + analysisModel: 'request/analysis', + analysisMode: 'deep', + }, + }); + expect(execute).toHaveBeenCalledTimes(1); + }); +}); + +describe('ensureManualAnalysisQueueRow', () => { + it('records claimed pending manual queue state with owner and claim correlation', async () => { + const inserted: unknown[] = []; + const db = { + insert: () => ({ + values: (values: unknown) => ({ + onConflictDoNothing: () => ({ + returning: async () => { + inserted.push(values); + return [{ id: 'queue-row' }]; + }, + }), + }), + }), + }; + + await expect( + ensureManualAnalysisQueueRow(db as never, { + finding: finding as never, + claimToken: 'claim-token', + jobId: 'manual-job', + }) + ).resolves.toBe(true); + expect(inserted[0]).toMatchObject({ + finding_id: command.findingId, + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + queue_status: 'pending', + claim_token: 'claim-token', + claimed_by_job_id: 'manual-job', + }); + }); + + it('reports duplicate manual starts when the finding queue row already exists', async () => { + const db = { + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ returning: async () => [] }), + }), + }), + }; + + await expect( + ensureManualAnalysisQueueRow(db as never, { + finding: finding as never, + claimToken: 'claim-token', + jobId: 'manual-job', + }) + ).resolves.toBe(false); + }); +}); diff --git a/services/security-auto-analysis/src/manual-analysis.ts b/services/security-auto-analysis/src/manual-analysis.ts new file mode 100644 index 0000000000..6e502f8a40 --- /dev/null +++ b/services/security-auto-analysis/src/manual-analysis.ts @@ -0,0 +1,192 @@ +import { randomUUID } from 'crypto'; +import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; +import { security_audit_log } from '@kilocode/db/schema'; +import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { z } from 'zod'; +import { + countOwnerInflightAnalyses, + ensureManualAnalysisQueueRow, + getAnalysisActorById, + getSecurityAgentConfigForOwner, + getSecurityFindingById, + transitionManualAnalysisQueueFromStart, + type SecurityFindingRecord, +} from './db/queries.js'; +import { startSecurityAnalysis } from './launch.js'; +import { + resolveSecurityAgentModels, + SECURITY_ANALYSIS_OWNER_CAP, + type QueueOwner, +} from './types.js'; + +const ManualAnalysisOwnerSchema = z + .object({ + organizationId: z.string().uuid().optional(), + userId: z.string().min(1).optional(), + }) + .refine(owner => Boolean(owner.organizationId || owner.userId), { + message: 'organizationId or userId is required', + }); + +export const ManualAnalysisStartCommandSchema = z.object({ + schemaVersion: z.literal(1), + findingId: z.string().uuid(), + owner: ManualAnalysisOwnerSchema, + actorUserId: z.string().min(1), + requestedModels: z + .object({ + model: z.string().optional(), + triageModel: z.string().optional(), + analysisModel: z.string().optional(), + }) + .optional(), + retrySandboxOnly: z.boolean().optional(), +}); + +export type ManualAnalysisStartCommand = z.infer; + +function commandOwner(command: ManualAnalysisStartCommand): QueueOwner { + return command.owner.organizationId + ? { type: 'org', id: command.owner.organizationId } + : { type: 'user', id: command.owner.userId ?? command.actorUserId }; +} + +function findingMatchesOwner( + finding: Pick, + owner: QueueOwner +): boolean { + return owner.type === 'org' + ? finding.owned_by_organization_id === owner.id + : finding.owned_by_user_id === owner.id; +} + +export async function processManualAnalysisStart(params: { + db: WorkerDb; + env: CloudflareEnv; + command: ManualAnalysisStartCommand; +}): Promise<{ + status: + | 'started' + | 'duplicate' + | 'owner-cap' + | 'finding-missing' + | 'actor-missing' + | 'token-missing' + | 'failed'; +}> { + const owner = commandOwner(params.command); + const finding = await getSecurityFindingById(params.db, params.command.findingId); + if (!finding || !findingMatchesOwner(finding, owner)) { + return { status: 'finding-missing' }; + } + const inflight = await countOwnerInflightAnalyses(params.db, owner); + if (inflight >= SECURITY_ANALYSIS_OWNER_CAP) return { status: 'owner-cap' }; + const actor = await getAnalysisActorById(params.db, params.command.actorUserId); + if (!actor) return { status: 'actor-missing' }; + + const claimToken = randomUUID(); + const jobId = `manual:${claimToken}`; + if (!(await ensureManualAnalysisQueueRow(params.db, { finding, claimToken, jobId }))) { + return { status: 'duplicate' }; + } + + const tokenResult = await params.env.GIT_TOKEN_SERVICE.getTokenForRepo({ + githubRepo: finding.repo_full_name, + userId: actor.id, + orgId: owner.type === 'org' ? owner.id : undefined, + }); + if (!tokenResult.success) { + await transitionManualAnalysisQueueFromStart(params.db, { + findingId: finding.id, + claimToken, + status: 'failed', + failureCode: 'GITHUB_TOKEN_UNAVAILABLE', + errorMessage: tokenResult.reason, + }); + return { status: 'token-missing' }; + } + + const config = await getSecurityAgentConfigForOwner(params.db, owner); + const resolvedModels = resolveSecurityAgentModels(config); + const triageModel = + params.command.requestedModels?.triageModel ?? + params.command.requestedModels?.model ?? + resolvedModels.triageModel; + const analysisModel = + params.command.requestedModels?.analysisModel ?? + params.command.requestedModels?.model ?? + resolvedModels.analysisModel; + const [nextAuthSecret, internalApiSecret, callbackTokenSecret] = await Promise.all([ + params.env.NEXTAUTH_SECRET.get(), + params.env.INTERNAL_API_SECRET.get(), + params.env.CALLBACK_TOKEN_SECRET.get(), + ]); + const result = await startSecurityAnalysis({ + db: params.db, + env: params.env, + findingId: finding.id, + actorUser: actor, + githubToken: tokenResult.token, + triageModel, + analysisModel, + analysisMode: config.analysis_mode, + organizationId: owner.type === 'org' ? owner.id : undefined, + nextAuthSecret, + internalApiSecret, + callbackTokenSecret, + retrySandboxOnly: params.command.retrySandboxOnly, + }); + const queueStatus = result.started ? (result.triageOnly ? 'completed' : 'running') : 'failed'; + await transitionManualAnalysisQueueFromStart(params.db, { + findingId: finding.id, + claimToken, + status: queueStatus, + failureCode: result.started ? null : 'START_CALL_AMBIGUOUS', + errorMessage: result.error ?? null, + }); + if (!result.started) return { status: 'failed' }; + + await params.db.insert(security_audit_log).values({ + owned_by_organization_id: finding.owned_by_organization_id, + owned_by_user_id: finding.owned_by_user_id, + actor_id: actor.id, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAnalysisStarted, + resource_type: 'security_finding', + resource_id: finding.id, + metadata: { + source: 'user', + model: analysisModel, + triageModel, + analysisModel, + analysisMode: config.analysis_mode, + triageOnly: result.triageOnly ?? false, + }, + }); + return { status: 'started' }; +} + +export async function consumeManualAnalysisBatch( + batch: MessageBatch, + env: CloudflareEnv +): Promise { + const db = getWorkerDb(env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); + for (const message of batch.messages) { + const parsed = ManualAnalysisStartCommandSchema.safeParse(message.body); + if (!parsed.success) { + message.ack(); + continue; + } + try { + await processManualAnalysisStart({ db, env, command: parsed.data }); + message.ack(); + } catch (error) { + console.error('Manual security analysis start failed', { + findingId: parsed.data.findingId, + error: error instanceof Error ? error.message : String(error), + }); + message.retry(); + } + } +} diff --git a/services/security-auto-analysis/src/posthog.test.ts b/services/security-auto-analysis/src/posthog.test.ts new file mode 100644 index 0000000000..6651bda593 --- /dev/null +++ b/services/security-auto-analysis/src/posthog.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { trackSecurityAnalysisCompleted } from './posthog.js'; + +describe('trackSecurityAnalysisCompleted', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits the Worker completion analytics event with legacy event semantics', async () => { + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + await trackSecurityAnalysisCompleted({ + env: { NEXT_PUBLIC_POSTHOG_KEY: 'phc_test' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + analysis_started_at: '2026-05-18T10:00:00.000Z', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:05:00.000Z', + modelUsed: 'analysis/model', + triageModel: 'triage/model', + analysisModel: 'analysis/model', + triggeredByUserId: 'user-123', + triage: { + needsSandboxAnalysis: true, + needsSandboxReasoning: 'Reachability unknown', + suggestedAction: 'analyze_codebase', + confidence: 'medium', + triageAt: '2026-05-18T10:01:00.000Z', + }, + sandboxAnalysis: { + isExploitable: false, + exploitabilityReasoning: 'No usage', + usageLocations: [], + suggestedFix: 'Upgrade', + suggestedAction: 'dismiss', + summary: 'Safe', + rawMarkdown: '# Safe', + analysisAt: '2026-05-18T10:05:00.000Z', + }, + }, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [, init] = fetchSpy.mock.calls[0] ?? []; + const payload = JSON.parse(String(init?.body)); + expect(payload).toMatchObject({ + api_key: 'phc_test', + distinct_id: 'user-123', + event: 'security_agent_analysis_completed', + properties: { + userId: 'user-123', + organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + model: 'analysis/model', + triageModel: 'triage/model', + analysisModel: 'analysis/model', + triageOnly: false, + isExploitable: false, + feature: 'security-agent', + operation: 'analysis_completed', + }, + }); + }); +}); diff --git a/services/security-auto-analysis/src/posthog.ts b/services/security-auto-analysis/src/posthog.ts new file mode 100644 index 0000000000..755d82a250 --- /dev/null +++ b/services/security-auto-analysis/src/posthog.ts @@ -0,0 +1,66 @@ +import type { SecurityFindingRecord } from './db/queries.js'; +import type { SecurityFindingAnalysis } from './types.js'; + +const POSTHOG_CAPTURE_URL = 'https://us.i.posthog.com/i/v0/e/'; +const POSTHOG_TIMEOUT_MS = 5_000; + +type SecurityAnalysisCompletedAnalyticsEnv = Pick; + +export async function trackSecurityAnalysisCompleted(params: { + env: SecurityAnalysisCompletedAnalyticsEnv; + findingId: string; + finding: SecurityFindingRecord; + analysis: SecurityFindingAnalysis; +}): Promise { + const posthogKey = params.env.NEXT_PUBLIC_POSTHOG_KEY; + const triggeredByUserId = params.analysis.triggeredByUserId; + if (!posthogKey || !triggeredByUserId) return; + + const durationMs = params.finding.analysis_started_at + ? Math.max(0, Date.now() - new Date(params.finding.analysis_started_at).getTime()) + : 0; + const sandboxAnalysis = params.analysis.sandboxAnalysis; + const triage = params.analysis.triage; + + try { + const response = await fetch(POSTHOG_CAPTURE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(POSTHOG_TIMEOUT_MS), + body: JSON.stringify({ + api_key: posthogKey, + distinct_id: triggeredByUserId, + event: 'security_agent_analysis_completed', + properties: { + distinctId: triggeredByUserId, + userId: triggeredByUserId, + organizationId: params.finding.owned_by_organization_id ?? undefined, + findingId: params.findingId, + model: params.analysis.modelUsed ?? params.analysis.analysisModel ?? '', + triageModel: params.analysis.triageModel, + analysisModel: params.analysis.analysisModel, + triageOnly: !sandboxAnalysis, + needsSandboxAnalysis: triage?.needsSandboxAnalysis, + triageSuggestedAction: triage?.suggestedAction, + triageConfidence: triage?.confidence, + isExploitable: sandboxAnalysis?.isExploitable, + durationMs, + feature: 'security-agent', + operation: 'analysis_completed', + $lib: 'security-auto-analysis-worker', + }, + }), + }); + + await response.body?.cancel(); + if (!response.ok) { + console.warn('Security analysis completion PostHog capture failed', { + status: response.status, + }); + } + } catch (error) { + console.warn('Security analysis completion PostHog capture threw', { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/services/security-auto-analysis/src/session-result.ts b/services/security-auto-analysis/src/session-result.ts new file mode 100644 index 0000000000..1fad8c8096 --- /dev/null +++ b/services/security-auto-analysis/src/session-result.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { generateInternalServiceToken } from './token.js'; + +const SessionSnapshotSchema = z.object({ + info: z.unknown(), + messages: z.array( + z.looseObject({ + info: z.looseObject({ + id: z.string(), + role: z.string().optional(), + }), + parts: z.array( + z.looseObject({ + id: z.string(), + type: z.string().optional(), + text: z.string().optional(), + }) + ), + }) + ), +}); + +type SessionSnapshot = z.infer; + +function extractLastAssistantText(snapshot: SessionSnapshot): string | null { + for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) { + const message = snapshot.messages[index]; + if (message.info.role !== 'assistant') continue; + const text = message.parts + .filter(part => part.type === 'text' && typeof part.text === 'string') + .map(part => part.text) + .join(''); + if (text.length > 0) return text; + } + return null; +} + +export async function fetchLatestAssistantText(params: { + sessionId: string; + userId: string; + sessionIngestWorkerUrl: string; + nextAuthSecret: string; +}): Promise { + if (!params.sessionIngestWorkerUrl) return null; + const token = await generateInternalServiceToken(params.userId, params.nextAuthSecret); + const response = await fetch( + `${params.sessionIngestWorkerUrl}/api/session/${encodeURIComponent(params.sessionId)}/export`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Session ingest export failed with ${response.status}`); + } + return extractLastAssistantText(SessionSnapshotSchema.parse(await response.json())); +} diff --git a/services/security-auto-analysis/src/token.ts b/services/security-auto-analysis/src/token.ts index 45859e8e59..27ce5ce186 100644 --- a/services/security-auto-analysis/src/token.ts +++ b/services/security-auto-analysis/src/token.ts @@ -10,6 +10,20 @@ type TokenUser = { const JWT_TOKEN_VERSION = 3; +export async function generateInternalServiceToken( + userId: string, + secret: string +): Promise { + return signJwt( + { + kiloUserId: userId, + version: JWT_TOKEN_VERSION, + }, + secret, + { expiresIn: '1h' } + ); +} + export async function generateApiToken( user: TokenUser, secret: string, diff --git a/services/security-auto-analysis/src/types.test.ts b/services/security-auto-analysis/src/types.test.ts index ae457684d6..7f1a4f188b 100644 --- a/services/security-auto-analysis/src/types.test.ts +++ b/services/security-auto-analysis/src/types.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { AutoAnalysisOwnerMessageSchema, DEFAULT_SECURITY_AGENT_CONFIG } from './types.js'; +import { + AutoAnalysisOwnerMessageSchema, + DEFAULT_SECURITY_AGENT_CONFIG, + resolveSecurityAgentModels, +} from './types.js'; describe('AutoAnalysisOwnerMessageSchema', () => { it('accepts valid owner messages', () => { @@ -32,3 +36,22 @@ describe('DEFAULT_SECURITY_AGENT_CONFIG', () => { expect(DEFAULT_SECURITY_AGENT_CONFIG.auto_analysis_min_severity).toBe('high'); }); }); + +describe('resolveSecurityAgentModels', () => { + it('prefers explicit triage and analysis model slugs independently', () => { + expect( + resolveSecurityAgentModels({ + model_slug: 'legacy/model', + triage_model_slug: 'triage/model', + analysis_model_slug: 'analysis/model', + }) + ).toEqual({ triageModel: 'triage/model', analysisModel: 'analysis/model' }); + }); + + it('uses legacy model_slug as fallback for both Worker launch phases', () => { + expect(resolveSecurityAgentModels({ model_slug: 'legacy/model' })).toEqual({ + triageModel: 'legacy/model', + analysisModel: 'legacy/model', + }); + }); +}); diff --git a/services/security-auto-analysis/src/types.ts b/services/security-auto-analysis/src/types.ts index 10e16b4235..60a8dfa7e8 100644 --- a/services/security-auto-analysis/src/types.ts +++ b/services/security-auto-analysis/src/types.ts @@ -7,7 +7,11 @@ export const AUTO_ANALYSIS_MAX_ATTEMPTS = 5; export const SecurityAgentConfigSchema = z .object({ model_slug: z.string().optional(), + triage_model_slug: z.string().optional(), + analysis_model_slug: z.string().optional(), analysis_mode: z.enum(['auto', 'shallow', 'deep']).default('auto'), + auto_dismiss_enabled: z.boolean().default(false), + auto_dismiss_confidence_threshold: z.enum(['high', 'medium', 'low']).default('high'), auto_analysis_enabled: z.boolean().default(false), auto_analysis_min_severity: z.enum(['critical', 'high', 'medium', 'all']).default('high'), auto_analysis_include_existing: z.boolean().default(false), @@ -20,12 +24,33 @@ export type AutoAnalysisMinSeverity = SecurityAgentConfig['auto_analysis_min_sev export const DEFAULT_SECURITY_AGENT_CONFIG: SecurityAgentConfig = { model_slug: 'anthropic/claude-opus-4.6', + triage_model_slug: 'anthropic/claude-opus-4.6', + analysis_model_slug: 'anthropic/claude-opus-4.6', analysis_mode: 'auto', + auto_dismiss_enabled: false, + auto_dismiss_confidence_threshold: 'high', auto_analysis_enabled: false, auto_analysis_min_severity: 'high', auto_analysis_include_existing: false, }; +export function resolveSecurityAgentModels( + config: Pick +): { triageModel: string; analysisModel: string } { + return { + triageModel: + config.triage_model_slug ?? + config.model_slug ?? + DEFAULT_SECURITY_AGENT_CONFIG.triage_model_slug ?? + 'anthropic/claude-opus-4.6', + analysisModel: + config.analysis_model_slug ?? + config.model_slug ?? + DEFAULT_SECURITY_AGENT_CONFIG.analysis_model_slug ?? + 'anthropic/claude-opus-4.6', + }; +} + export const AutoAnalysisFailureCodeSchema = z.enum([ 'NETWORK_TIMEOUT', 'UPSTREAM_5XX', @@ -68,10 +93,26 @@ export type SecurityFindingTriage = { triageAt: string; }; +export type SecurityFindingSandboxAnalysis = { + isExploitable: boolean | 'unknown'; + exploitabilityReasoning: string; + usageLocations: string[]; + suggestedFix: string; + suggestedAction: 'dismiss' | 'open_pr' | 'manual_review' | 'monitor'; + summary: string; + rawMarkdown: string; + analysisAt: string; + modelUsed?: string; +}; + export type SecurityFindingAnalysis = { triage?: SecurityFindingTriage; + sandboxAnalysis?: SecurityFindingSandboxAnalysis; + rawMarkdown?: string; analyzedAt: string; modelUsed?: string; + triageModel?: string; + analysisModel?: string; triggeredByUserId?: string; correlationId?: string; }; diff --git a/services/security-auto-analysis/worker-configuration.d.ts b/services/security-auto-analysis/worker-configuration.d.ts index 96897f3556..295c254af0 100644 --- a/services/security-auto-analysis/worker-configuration.d.ts +++ b/services/security-auto-analysis/worker-configuration.d.ts @@ -46,6 +46,7 @@ declare type GitTokenService = { userId: string; orgId?: string; }): Promise; + getToken(installationId: string, appType?: 'standard' | 'lite'): Promise; }; declare type SecretBinding = { @@ -66,13 +67,21 @@ declare type ExecutionContext = { declare type CloudflareEnv = { HYPERDRIVE: Hyperdrive; OWNER_QUEUE: Queue; + CALLBACK_QUEUE: Queue; + MANUAL_ANALYSIS_QUEUE: Queue; CLOUD_AGENT_NEXT: { fetch(input: RequestInfo | URL, init?: RequestInit): Promise }; GIT_TOKEN_SERVICE: GitTokenService; NEXTAUTH_SECRET: SecretBinding; INTERNAL_API_SECRET: SecretBinding; CALLBACK_TOKEN_SECRET: SecretBinding; KILOCODE_BACKEND_BASE_URL: string; + SESSION_INGEST_WORKER_URL: string; ENVIRONMENT: string; + SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker' | 'web'; + SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: string; + SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED: string | undefined; + MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED: string | undefined; + NEXT_PUBLIC_POSTHOG_KEY: string | undefined; BETTERSTACK_HEARTBEAT_URL: string | undefined; }; diff --git a/services/security-auto-analysis/wrangler.jsonc b/services/security-auto-analysis/wrangler.jsonc index 941174e9ed..0d02df3ad4 100644 --- a/services/security-auto-analysis/wrangler.jsonc +++ b/services/security-auto-analysis/wrangler.jsonc @@ -17,6 +17,12 @@ "vars": { "ENVIRONMENT": "production", "KILOCODE_BACKEND_BASE_URL": "https://api.kilo.ai", + "SESSION_INGEST_WORKER_URL": "https://ingest.kilosessions.ai", + "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker", + "SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL": "https://app.kilo.ai", + "SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED": "true", + "MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED": "true", + "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", }, "triggers": { "crons": ["* * * * *"], @@ -34,6 +40,14 @@ "binding": "OWNER_QUEUE", "queue": "security-auto-analysis-owner-queue", }, + { + "binding": "CALLBACK_QUEUE", + "queue": "security-auto-analysis-callback-queue", + }, + { + "binding": "MANUAL_ANALYSIS_QUEUE", + "queue": "security-manual-analysis-command-queue", + }, ], "consumers": [ { @@ -43,6 +57,20 @@ "dead_letter_queue": "security-auto-analysis-owner-dlq", "max_concurrency": 50, }, + { + "queue": "security-auto-analysis-callback-queue", + "max_batch_size": 1, + "max_retries": 3, + "dead_letter_queue": "security-auto-analysis-callback-dlq", + "max_concurrency": 50, + }, + { + "queue": "security-manual-analysis-command-queue", + "max_batch_size": 1, + "max_retries": 3, + "dead_letter_queue": "security-manual-analysis-command-dlq", + "max_concurrency": 50, + }, ], }, "services": [ @@ -80,6 +108,12 @@ "vars": { "ENVIRONMENT": "development", "KILOCODE_BACKEND_BASE_URL": "http://localhost:3000", + "SESSION_INGEST_WORKER_URL": "http://localhost:8800", + "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker", + "SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL": "http://localhost:3000", + "SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED": "true", + "MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED": "true", + "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", }, "hyperdrive": [ { @@ -94,6 +128,14 @@ "binding": "OWNER_QUEUE", "queue": "security-auto-analysis-owner-queue-dev", }, + { + "binding": "CALLBACK_QUEUE", + "queue": "security-auto-analysis-callback-queue-dev", + }, + { + "binding": "MANUAL_ANALYSIS_QUEUE", + "queue": "security-manual-analysis-command-queue-dev", + }, ], "consumers": [ { @@ -103,6 +145,20 @@ "dead_letter_queue": "security-auto-analysis-owner-dlq-dev", "max_concurrency": 50, }, + { + "queue": "security-auto-analysis-callback-queue-dev", + "max_batch_size": 1, + "max_retries": 3, + "dead_letter_queue": "security-auto-analysis-callback-dlq-dev", + "max_concurrency": 50, + }, + { + "queue": "security-manual-analysis-command-queue-dev", + "max_batch_size": 1, + "max_retries": 3, + "dead_letter_queue": "security-manual-analysis-command-dlq-dev", + "max_concurrency": 50, + }, ], }, "services": [ diff --git a/services/security-sync/README.md b/services/security-sync/README.md index debdd0bd95..7800f552fc 100644 --- a/services/security-sync/README.md +++ b/services/security-sync/README.md @@ -5,6 +5,8 @@ Cloudflare Worker that syncs security alerts on a cron schedule, enqueuing one q ## Endpoints - `GET /health` - health check +- `POST /internal/manual-sync` - manual sync command ingress; `MANUAL_SYNC_COMMAND_ROUTING_ENABLED=false` pauses new Worker sync commands +- `POST /internal/dismiss-finding` - dismissal command ingress; `DISMISS_FINDING_COMMAND_ROUTING_ENABLED=false` pauses new Worker dismissal commands - Cron trigger (`0 */6 * * *`) — queries enabled owners from DB and enqueues sync messages ## Queue @@ -13,4 +15,4 @@ Cloudflare Worker that syncs security alerts on a cron schedule, enqueuing one q - Consumer queue: `security-sync-jobs` (`security-sync-jobs-dev` in dev) - DLQ: `security-sync-jobs-dlq` -The consumer calls `syncOwner` which fetches Dependabot alerts from GitHub, upserts findings into the database, and prunes stale repos from the config. +The consumer calls `syncOwner` which fetches Dependabot alerts from GitHub, upserts findings into the database, keeps automatic-analysis queue eligibility synchronized, and prunes stale repos from the config for both scheduled and manual sync paths. diff --git a/services/security-sync/package.json b/services/security-sync/package.json index 9cf161b887..9a7f95ab4f 100644 --- a/services/security-sync/package.json +++ b/services/security-sync/package.json @@ -8,7 +8,8 @@ "deploy:dev": "wrangler deploy --env dev", "typecheck": "tsgo --noEmit", "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/security-sync/src", - "types": "wrangler types --env-interface CloudflareEnv worker-configuration.d.ts" + "types": "wrangler types --env-interface CloudflareEnv worker-configuration.d.ts", + "test": "vitest run" }, "dependencies": { "@kilocode/db": "workspace:*", @@ -19,6 +20,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", + "vitest": "catalog:", "wrangler": "catalog:" } } diff --git a/services/security-sync/src/dismiss.test.ts b/services/security-sync/src/dismiss.test.ts new file mode 100644 index 0000000000..a543c5f9ea --- /dev/null +++ b/services/security-sync/src/dismiss.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { processSecurityFindingDismissal } from './dismiss.js'; +import type { SecurityDismissMessage } from './index.js'; + +const finding = { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + source: 'dependabot', + source_id: '42', + repo_full_name: 'kilo/repo', + status: 'open', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, +}; + +function createDb(selectedFinding = finding) { + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [selectedFinding], + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + + return { db: db as never, updates, auditRows }; +} + +function createMessage(): SecurityDismissMessage { + return { + schemaVersion: 1, + kind: 'dismiss', + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId: 'dismiss-message-123', + dispatchedAt: '2026-05-18T08:30:00.000Z', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }; +} + +describe('processSecurityFindingDismissal', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('updates local finding state and audit only after upstream Dependabot dismissal succeeds', async () => { + const { db, updates, auditRows } = createDb(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken: async () => 'github-token' } as GitTokenService, + message: createMessage(), + }) + ).resolves.toEqual({ dismissed: true, findingSource: 'dependabot' }); + + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'owner@example.com', + }); + expect(auditRows[0]).toMatchObject({ + actor_id: 'user-123', + action: 'security.finding.dismissed', + resource_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + after_state: { status: 'ignored', ignoredReason: 'not_used' }, + }); + }); + + it('preserves local state when upstream Dependabot dismissal fails transiently', async () => { + const { db, updates, auditRows } = createDb(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 })); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken: async () => 'github-token' } as GitTokenService, + message: createMessage(), + }) + ).rejects.toThrow('GitHub Dependabot dismissal failed with 503'); + + expect(updates).toHaveLength(0); + expect(auditRows).toHaveLength(0); + }); + + it('does not mutate local state when Dependabot source metadata is malformed', async () => { + const { db, updates, auditRows } = createDb({ ...finding, source_id: '42junk' }); + const getToken = vi.fn().mockResolvedValue('github-token'); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken } as unknown as GitTokenService, + message: createMessage(), + }) + ).resolves.toEqual({ dismissed: false, findingSource: 'dependabot' }); + + expect(getToken).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(updates).toHaveLength(0); + expect(auditRows).toHaveLength(0); + }); + + it('ignores dismissal commands for findings owned by another tenant', async () => { + const { db, updates, auditRows } = createDb({ + ...finding, + owned_by_organization_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + }); + const getToken = vi.fn().mockResolvedValue('github-token'); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken } as unknown as GitTokenService, + message: createMessage(), + }) + ).resolves.toEqual({ dismissed: false, findingSource: null }); + + expect(getToken).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(updates).toHaveLength(0); + expect(auditRows).toHaveLength(0); + }); +}); diff --git a/services/security-sync/src/dismiss.ts b/services/security-sync/src/dismiss.ts new file mode 100644 index 0000000000..fa5060f439 --- /dev/null +++ b/services/security-sync/src/dismiss.ts @@ -0,0 +1,131 @@ +import type { WorkerDb } from '@kilocode/db/client'; +import { security_audit_log, security_findings } from '@kilocode/db/schema'; +import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { eq, sql } from 'drizzle-orm'; +import type { SecurityDismissMessage } from './index.js'; + +type FindingDismissalResult = { + dismissed: boolean; + findingSource: string | null; +}; + +type FindingOwner = { + owned_by_organization_id: string | null; + owned_by_user_id: string | null; +}; + +function findingMatchesOwner( + finding: FindingOwner, + owner: SecurityDismissMessage['owner'] +): boolean { + if (owner.organizationId) { + return finding.owned_by_organization_id === owner.organizationId; + } + return Boolean(owner.userId && finding.owned_by_user_id === owner.userId); +} + +export async function processSecurityFindingDismissal(params: { + db: WorkerDb; + gitTokenService: GitTokenService; + message: SecurityDismissMessage; +}): Promise { + const rows = await params.db + .select({ + id: security_findings.id, + source: security_findings.source, + source_id: security_findings.source_id, + repo_full_name: security_findings.repo_full_name, + status: security_findings.status, + owned_by_organization_id: security_findings.owned_by_organization_id, + owned_by_user_id: security_findings.owned_by_user_id, + }) + .from(security_findings) + .where(eq(security_findings.id, params.message.findingId)) + .limit(1); + const finding = rows[0]; + + if (!finding || !findingMatchesOwner(finding, params.message.owner)) { + console.warn('Dismissal target finding unavailable for owner', { + runId: params.message.runId, + findingId: params.message.findingId, + }); + return { dismissed: false, findingSource: null }; + } + + if (finding.status === 'ignored') { + return { dismissed: false, findingSource: finding.source }; + } + + if (finding.source === 'dependabot') { + const alertNumber = /^\d+$/.test(finding.source_id) + ? Number.parseInt(finding.source_id, 10) + : Number.NaN; + const repoParts = finding.repo_full_name.split('/'); + const [repoOwner, repoName] = repoParts; + + if (!Number.isSafeInteger(alertNumber) || repoParts.length !== 2 || !repoOwner || !repoName) { + console.warn('Dependabot dismissal skipped because source metadata is invalid', { + runId: params.message.runId, + findingId: params.message.findingId, + }); + return { dismissed: false, findingSource: finding.source }; + } + + const token = await params.gitTokenService.getToken(params.message.installationId); + const response = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/dependabot/alerts/${alertNumber}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'cloudflare-security-sync', + }, + body: JSON.stringify({ + state: 'dismissed', + dismissed_reason: params.message.reason, + dismissed_comment: params.message.comment, + }), + } + ); + + if (!response.ok) { + throw new Error( + `GitHub Dependabot dismissal failed with ${response.status} for finding ${finding.id}` + ); + } + } + + await params.db + .update(security_findings) + .set({ + status: 'ignored', + ignored_reason: params.message.reason, + ignored_by: params.message.actor.email ?? params.message.actor.id, + updated_at: sql`now()`, + }) + .where(eq(security_findings.id, finding.id)); + + await params.db.insert(security_audit_log).values({ + owned_by_organization_id: params.message.owner.organizationId ?? null, + owned_by_user_id: params.message.owner.userId ?? null, + actor_id: params.message.actor.id, + actor_email: params.message.actor.email ?? null, + actor_name: params.message.actor.name ?? null, + action: SecurityAuditLogAction.FindingDismissed, + resource_type: 'security_finding', + resource_id: finding.id, + before_state: { status: finding.status }, + after_state: { status: 'ignored', ignoredReason: params.message.reason }, + metadata: { + source: finding.source, + runId: params.message.runId, + messageId: params.message.messageId, + trigger: 'worker_queue', + }, + }); + + return { dismissed: true, findingSource: finding.source }; +} diff --git a/services/security-sync/src/index.test.ts b/services/security-sync/src/index.test.ts new file mode 100644 index 0000000000..b514c71710 --- /dev/null +++ b/services/security-sync/src/index.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWorkerDb } from '@kilocode/db/client'; +import worker, { collectScheduledSyncOwners, type SecuritySyncQueueMessage } from './index.js'; +import { processSecurityFindingDismissal } from './dismiss.js'; +import { syncOwner } from './sync.js'; + +vi.mock('@kilocode/db/client', () => ({ getWorkerDb: vi.fn() })); +vi.mock('./dismiss.js', () => ({ processSecurityFindingDismissal: vi.fn() })); +vi.mock('./sync.js', () => ({ syncOwner: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('collectScheduledSyncOwners', () => { + it('skips owners whose automatic sync policy is disabled', () => { + const owners = collectScheduledSyncOwners([ + { + owned_by_organization_id: 'org-enabled', + owned_by_user_id: null, + config: { auto_sync_enabled: true }, + }, + { + owned_by_organization_id: 'org-disabled', + owned_by_user_id: null, + config: { auto_sync_enabled: false }, + }, + { + owned_by_organization_id: null, + owned_by_user_id: 'user-default-enabled', + config: {}, + }, + ]); + + expect(owners).toEqual([ + { + owner: { organizationId: 'org-enabled' }, + ownerKey: 'org:org-enabled', + }, + { + owner: { userId: 'user-default-enabled' }, + ownerKey: 'user:user-default-enabled', + }, + ]); + }); +}); + +describe('scheduled sync dispatch', () => { + it('enqueues enabled owners and processes the scheduled queue message', async () => { + const queuedBatches: MessageSendRequest[][] = []; + vi.mocked(getWorkerDb) + .mockReturnValueOnce({ + select: () => ({ + from: () => ({ + where: async () => [ + { + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + config: { auto_sync_enabled: true }, + }, + { + owned_by_organization_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_user_id: null, + config: { auto_sync_enabled: false }, + }, + ], + }), + }), + } as never) + .mockReturnValueOnce({} as never); + vi.mocked(syncOwner).mockResolvedValue({ synced: 1, errors: 0, staleRepos: 0 } as never); + + await worker.scheduled( + {} as ScheduledController, + { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + SYNC_QUEUE: { + sendBatch: async batch => { + queuedBatches.push(batch); + }, + }, + } as CloudflareEnv, + { waitUntil: vi.fn() } as unknown as ExecutionContext + ); + + const queuedMessage = queuedBatches[0]?.[0]?.body; + expect(queuedBatches).toHaveLength(1); + expect(queuedMessage).toMatchObject({ + trigger: 'scheduled', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + }); + + const ack = vi.fn(); + const retry = vi.fn(); + await worker.queue( + { messages: [{ body: queuedMessage, ack, retry }] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + GIT_TOKEN_SERVICE: {}, + } as CloudflareEnv + ); + + expect(syncOwner).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: 'scheduled', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + }) + ); + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); +}); + +describe('manual sync dispatch', () => { + it('accepts an authenticated repository command and enqueues worker processing', async () => { + const queuedBatches: MessageSendRequest[][] = []; + const response = await worker.fetch( + new Request('https://security-sync.test/internal/manual-sync', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'worker-secret', + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { + id: 'user-123', + email: 'owner@example.com', + name: 'Owner Example', + }, + repoFullName: 'kilo/repo', + }), + }), + { + INTERNAL_API_SECRET: { get: async () => 'worker-secret' }, + SYNC_QUEUE: { + sendBatch: async batch => { + queuedBatches.push(batch); + }, + }, + } as CloudflareEnv + ); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toMatchObject({ success: true, accepted: true }); + expect(queuedBatches).toHaveLength(1); + expect(queuedBatches[0]?.[0]?.body).toMatchObject({ + trigger: 'manual', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { + id: 'user-123', + email: 'owner@example.com', + name: 'Owner Example', + }, + repoFullName: 'kilo/repo', + }); + + vi.mocked(getWorkerDb).mockReturnValue({} as never); + vi.mocked(syncOwner).mockResolvedValue({ synced: 1, errors: 0, staleRepos: 0 } as never); + const ack = vi.fn(); + const retry = vi.fn(); + await worker.queue( + { messages: [{ body: queuedBatches[0]?.[0]?.body, ack, retry }] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + GIT_TOKEN_SERVICE: {}, + } as CloudflareEnv + ); + + expect(syncOwner).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: 'manual', + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + repoFullName: 'kilo/repo', + }) + ); + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); + + it('rejects migrated sync traffic when Worker command routing is paused', async () => { + const response = await worker.fetch( + new Request('https://security-sync.test/internal/manual-sync', { method: 'POST' }), + { MANUAL_SYNC_COMMAND_ROUTING_ENABLED: 'false' } as CloudflareEnv + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: 'Manual sync Worker routing is disabled', + }); + }); +}); + +describe('manual dismissal dispatch', () => { + it('accepts an authenticated dismissal command and enqueues actor-aware Worker processing', async () => { + const queuedBatches: MessageSendRequest[][] = []; + const response = await worker.fetch( + new Request('https://security-sync.test/internal/dismiss-finding', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'worker-secret', + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { + id: 'user-123', + email: 'owner@example.com', + name: 'Owner Example', + }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }), + }), + { + INTERNAL_API_SECRET: { get: async () => 'worker-secret' }, + SYNC_QUEUE: { + sendBatch: async batch => { + queuedBatches.push(batch); + }, + }, + } as CloudflareEnv + ); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toMatchObject({ success: true, accepted: true }); + expect(queuedBatches[0]?.[0]?.body).toMatchObject({ + kind: 'dismiss', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }); + }); + + it('rejects migrated dismissal traffic when Worker command routing is paused', async () => { + const response = await worker.fetch( + new Request('https://security-sync.test/internal/dismiss-finding', { method: 'POST' }), + { DISMISS_FINDING_COMMAND_ROUTING_ENABLED: 'false' } as CloudflareEnv + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: 'Finding dismissal Worker routing is disabled', + }); + }); + + it('retries queued dismissal messages when Worker processing throws', async () => { + vi.mocked(getWorkerDb).mockReturnValue({} as never); + vi.mocked(processSecurityFindingDismissal).mockRejectedValue(new Error('retry dismissal')); + const ack = vi.fn(); + const retry = vi.fn(); + + await worker.queue( + { + messages: [ + { + body: { + schemaVersion: 1, + kind: 'dismiss', + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId: 'dismiss-message-123', + dispatchedAt: '2026-05-18T08:30:00.000Z', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }, + ack, + retry, + }, + ], + } as never, + { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + GIT_TOKEN_SERVICE: {}, + } as CloudflareEnv + ); + + expect(ack).not.toHaveBeenCalled(); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/security-sync/src/index.ts b/services/security-sync/src/index.ts index d7e681a2c7..9630a5289b 100644 --- a/services/security-sync/src/index.ts +++ b/services/security-sync/src/index.ts @@ -1,34 +1,133 @@ +import { timingSafeEqual as nodeTimingSafeEqual } from 'crypto'; import { z } from 'zod'; import { getWorkerDb } from '@kilocode/db/client'; import { agent_configs } from '@kilocode/db/schema'; import { eq, and, isNotNull, or } from 'drizzle-orm'; import { syncOwner } from './sync'; +import { processSecurityFindingDismissal } from './dismiss'; + +const SecuritySyncOwnerSchema = z + .object({ + organizationId: z.string().uuid().optional(), + userId: z.string().uuid().optional(), + }) + .refine(value => Boolean(value.organizationId || value.userId), { + message: 'owner.organizationId or owner.userId is required', + }); + +const SecuritySyncActorSchema = z.object({ + id: z.string().min(1), + email: z.string().email().nullable().optional(), + name: z.string().min(1).nullable().optional(), +}); const SecuritySyncMessageSchema = z.object({ schemaVersion: z.literal(1), runId: z.string().uuid(), messageId: z.string().min(1), - owner: z - .object({ - organizationId: z.string().uuid().optional(), - userId: z.string().uuid().optional(), - }) - .refine(value => Boolean(value.organizationId || value.userId), { - message: 'owner.organizationId or owner.userId is required', - }), + trigger: z.enum(['scheduled', 'manual']), + owner: SecuritySyncOwnerSchema, ownerKey: z.string().min(1), chunkIndex: z.number().int().nonnegative(), chunkCount: z.number().int().positive(), dispatchedAt: z.string().datetime(), + actor: SecuritySyncActorSchema.optional(), + repoFullName: z.string().min(1).optional(), +}); + +const ManualSecuritySyncCommandSchema = z.object({ + schemaVersion: z.literal(1), + owner: SecuritySyncOwnerSchema, + actor: SecuritySyncActorSchema, + repoFullName: z.string().min(1).optional(), +}); + +const DependabotDismissReasonSchema = z.enum([ + 'fix_started', + 'no_bandwidth', + 'tolerable_risk', + 'inaccurate', + 'not_used', +]); + +const ManualFindingDismissalCommandSchema = z.object({ + schemaVersion: z.literal(1), + owner: SecuritySyncOwnerSchema, + actor: SecuritySyncActorSchema, + findingId: z.string().uuid(), + installationId: z.string().min(1), + reason: DependabotDismissReasonSchema, + comment: z.string().optional(), +}); + +const SecurityDismissMessageSchema = ManualFindingDismissalCommandSchema.extend({ + kind: z.literal('dismiss'), + runId: z.string().uuid(), + messageId: z.string().min(1), + dispatchedAt: z.string().datetime(), }); export type SecuritySyncMessage = z.infer; +export type SecurityDismissMessage = z.infer; +export type SecuritySyncQueueMessage = SecuritySyncMessage | SecurityDismissMessage; type OwnerEntry = { owner: { organizationId?: string; userId?: string }; ownerKey: string; }; +type ScheduledSyncOwnerRow = { + owned_by_organization_id: string | null; + owned_by_user_id: string | null; + config: unknown; +}; + +const ScheduledSecurityAgentConfigSchema = z + .object({ + auto_sync_enabled: z.boolean().default(true), + }) + .passthrough(); + +function isScheduledSyncEnabled(config: unknown): boolean { + const parsed = ScheduledSecurityAgentConfigSchema.safeParse(config ?? {}); + if (!parsed.success) { + console.warn('Invalid scheduled security agent config, skipping owner', { + error: parsed.error.message, + }); + return false; + } + + return parsed.data.auto_sync_enabled; +} + +export function collectScheduledSyncOwners(rows: ScheduledSyncOwnerRow[]): OwnerEntry[] { + const deduplicated = new Map(); + + for (const row of rows) { + if (!isScheduledSyncEnabled(row.config)) continue; + + if (row.owned_by_organization_id) { + const key = `org:${row.owned_by_organization_id}`; + if (!deduplicated.has(key)) { + deduplicated.set(key, { + owner: { organizationId: row.owned_by_organization_id }, + ownerKey: key, + }); + } + } else if (row.owned_by_user_id) { + const key = `user:${row.owned_by_user_id}`; + if (!deduplicated.has(key)) { + deduplicated.set(key, { + owner: { userId: row.owned_by_user_id }, + ownerKey: key, + }); + } + } + } + + return [...deduplicated.values()]; +} + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -36,29 +135,104 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +function workerRouteEnabled(value: string | undefined): boolean { + return value !== 'false'; +} + const QUEUE_SEND_BATCH_LIMIT = 100; +function createOwnerKey(owner: SecuritySyncMessage['owner']): string { + if (owner.organizationId) return `org:${owner.organizationId}`; + if (owner.userId) return `user:${owner.userId}`; + throw new Error('owner.organizationId or owner.userId is required'); +} + +async function timingSafeEqual(left: string, right: string): Promise { + const encoder = new TextEncoder(); + const [leftDigest, rightDigest] = await Promise.all([ + crypto.subtle.digest('SHA-256', encoder.encode(left)), + crypto.subtle.digest('SHA-256', encoder.encode(right)), + ]); + return nodeTimingSafeEqual(new Uint8Array(leftDigest), new Uint8Array(rightDigest)); +} + +async function enqueueManualSyncCommand( + queue: Queue, + command: z.infer +): Promise<{ runId: string; messageId: string }> { + const runId = crypto.randomUUID(); + const ownerKey = createOwnerKey(command.owner); + const messageId = `${runId}:${ownerKey}:manual`; + + await queue.sendBatch([ + { + body: { + schemaVersion: 1, + runId, + messageId, + trigger: 'manual', + owner: command.owner, + ownerKey, + chunkIndex: 0, + chunkCount: 1, + dispatchedAt: new Date().toISOString(), + actor: command.actor, + repoFullName: command.repoFullName, + }, + contentType: 'json', + }, + ]); + + return { runId, messageId }; +} + +async function enqueueDismissFindingCommand( + queue: Queue, + command: z.infer +): Promise<{ runId: string; messageId: string }> { + const runId = crypto.randomUUID(); + const messageId = `${runId}:${command.findingId}:dismiss`; + + await queue.sendBatch([ + { + body: { + ...command, + kind: 'dismiss', + runId, + messageId, + dispatchedAt: new Date().toISOString(), + }, + contentType: 'json', + }, + ]); + + return { runId, messageId }; +} + async function enqueueOwners( - queue: Queue, + queue: Queue, runId: string, dispatchedAt: string, owners: OwnerEntry[] ): Promise { if (owners.length === 0) return 0; - const messages: MessageSendRequest[] = owners.map(({ owner, ownerKey }) => ({ - body: { - schemaVersion: 1, - runId, - messageId: `${runId}:${ownerKey}:0`, - owner, - ownerKey, - chunkIndex: 0, - chunkCount: 1, - dispatchedAt, - }, - contentType: 'json', - })); + const messages: MessageSendRequest[] = owners.map( + ({ owner, ownerKey }) => ({ + body: { + schemaVersion: 1, + runId, + messageId: `${runId}:${ownerKey}:0`, + trigger: 'scheduled', + owner, + ownerKey, + chunkIndex: 0, + chunkCount: 1, + dispatchedAt, + }, + contentType: 'json', + }) + ); for (let i = 0; i < messages.length; i += QUEUE_SEND_BATCH_LIMIT) { await queue.sendBatch(messages.slice(i, i + QUEUE_SEND_BATCH_LIMIT)); @@ -88,8 +262,25 @@ function resolveOwner( return null; } +async function processSecurityDismissMessage( + message: Message, + env: CloudflareEnv +): Promise { + const parsed = SecurityDismissMessageSchema.safeParse(message.body); + if (!parsed.success) return false; + + const db = getWorkerDb(env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); + await processSecurityFindingDismissal({ + db, + gitTokenService: env.GIT_TOKEN_SERVICE, + message: parsed.data, + }); + message.ack(); + return true; +} + async function processSecuritySyncMessage( - message: Message, + message: Message, env: CloudflareEnv ): Promise { const parsed = SecuritySyncMessageSchema.safeParse(message.body); @@ -122,6 +313,9 @@ async function processSecuritySyncMessage( gitTokenService: env.GIT_TOKEN_SERVICE, owner, runId: body.runId, + trigger: body.trigger, + actor: body.actor, + repoFullName: body.repoFullName, }); console.info('Security sync completed for owner', { @@ -137,7 +331,7 @@ async function processSecuritySyncMessage( } export default { - async fetch(request: Request, _env: CloudflareEnv): Promise { + async fetch(request: Request, env: CloudflareEnv): Promise { const url = new URL(request.url); if (request.method === 'GET' && url.pathname === '/health') { @@ -148,6 +342,80 @@ export default { }); } + if (request.method === 'POST' && url.pathname === '/internal/manual-sync') { + if (!workerRouteEnabled(env.MANUAL_SYNC_COMMAND_ROUTING_ENABLED)) { + return jsonResponse( + { success: false, error: 'Manual sync Worker routing is disabled' }, + 503 + ); + } + const [internalSecret, authHeader] = await Promise.all([ + env.INTERNAL_API_SECRET.get(), + Promise.resolve(request.headers.get('x-internal-api-key')), + ]); + + if (!authHeader || !internalSecret || !(await timingSafeEqual(authHeader, internalSecret))) { + return jsonResponse({ success: false, error: 'Unauthorized' }, 401); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return jsonResponse({ success: false, error: 'Invalid JSON body' }, 400); + } + + const parsed = ManualSecuritySyncCommandSchema.safeParse(payload); + if (!parsed.success) { + return jsonResponse( + { success: false, error: 'Invalid manual sync command', issues: parsed.error.issues }, + 400 + ); + } + + const accepted = await enqueueManualSyncCommand(env.SYNC_QUEUE, parsed.data); + return jsonResponse({ success: true, accepted: true, ...accepted }, 202); + } + + if (request.method === 'POST' && url.pathname === '/internal/dismiss-finding') { + if (!workerRouteEnabled(env.DISMISS_FINDING_COMMAND_ROUTING_ENABLED)) { + return jsonResponse( + { success: false, error: 'Finding dismissal Worker routing is disabled' }, + 503 + ); + } + const [internalSecret, authHeader] = await Promise.all([ + env.INTERNAL_API_SECRET.get(), + Promise.resolve(request.headers.get('x-internal-api-key')), + ]); + + if (!authHeader || !internalSecret || !(await timingSafeEqual(authHeader, internalSecret))) { + return jsonResponse({ success: false, error: 'Unauthorized' }, 401); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return jsonResponse({ success: false, error: 'Invalid JSON body' }, 400); + } + + const parsed = ManualFindingDismissalCommandSchema.safeParse(payload); + if (!parsed.success) { + return jsonResponse( + { + success: false, + error: 'Invalid finding dismissal command', + issues: parsed.error.issues, + }, + 400 + ); + } + + const accepted = await enqueueDismissFindingCommand(env.SYNC_QUEUE, parsed.data); + return jsonResponse({ success: true, accepted: true, ...accepted }, 202); + } + return jsonResponse({ success: false, error: 'Not found' }, 404); }, @@ -161,6 +429,7 @@ export default { .select({ owned_by_organization_id: agent_configs.owned_by_organization_id, owned_by_user_id: agent_configs.owned_by_user_id, + config: agent_configs.config, }) .from(agent_configs) .where( @@ -175,28 +444,7 @@ export default { ) ); - const deduplicated = new Map(); - for (const row of rows) { - if (row.owned_by_organization_id) { - const key = `org:${row.owned_by_organization_id}`; - if (!deduplicated.has(key)) { - deduplicated.set(key, { - owner: { organizationId: row.owned_by_organization_id }, - ownerKey: key, - }); - } - } else if (row.owned_by_user_id) { - const key = `user:${row.owned_by_user_id}`; - if (!deduplicated.has(key)) { - deduplicated.set(key, { - owner: { userId: row.owned_by_user_id }, - ownerKey: key, - }); - } - } - } - - const owners = [...deduplicated.values()]; + const owners = collectScheduledSyncOwners(rows); const enqueuedMessages = await enqueueOwners( env.SYNC_QUEUE, runId, @@ -221,9 +469,12 @@ export default { } }, - async queue(batch: MessageBatch, env: CloudflareEnv): Promise { + async queue(batch: MessageBatch, env: CloudflareEnv): Promise { for (const message of batch.messages) { try { + if (await processSecurityDismissMessage(message, env)) { + continue; + } await processSecuritySyncMessage(message, env); } catch (error) { console.error('Security sync queue processing failed', { diff --git a/services/security-sync/src/sync.test.ts b/services/security-sync/src/sync.test.ts new file mode 100644 index 0000000000..15126e46b6 --- /dev/null +++ b/services/security-sync/src/sync.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + isFindingEligibleForAutoAnalysis, + selectRepositoriesForSync, + syncAutoAnalysisQueueForFinding, +} from './sync.js'; + +describe('selectRepositoriesForSync', () => { + it('allows a manual repository command to target an accessible repo outside configured sync selection', () => { + const repositories = selectRepositoriesForSync( + { + repositories: ['kilo/configured'], + repoNameToId: new Map([ + ['kilo/configured', 1], + ['kilo/requested', 2], + ]), + }, + 'kilo/requested' + ); + + expect(repositories).toEqual(['kilo/requested']); + }); +}); + +describe('Worker auto-analysis queue sync', () => { + it('matches automatic-analysis eligibility boundaries for newly synced findings', () => { + expect( + isFindingEligibleForAutoAnalysis({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + severity: 'high', + ownerAutoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'high', + }) + ).toEqual({ eligible: true, severityRank: 1 }); + + expect( + isFindingEligibleForAutoAnalysis({ + findingCreatedAt: '2026-05-18T08:00:00.000Z', + findingStatus: 'open', + severity: 'high', + ownerAutoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'high', + }) + ).toEqual({ eligible: false, severityRank: 1 }); + }); + + it('enqueues eligible findings for Worker-owned automatic analysis', async () => { + const inserted: unknown[] = []; + const tx = { + update: () => ({ + set: () => ({ + where: async () => undefined, + }), + }), + insert: () => ({ + values: (values: unknown) => ({ + onConflictDoNothing: () => ({ + returning: async () => { + inserted.push(values); + return [{ id: 'queue-row' }]; + }, + }), + }), + }), + }; + const db = { + transaction: async (callback: (transaction: typeof tx) => Promise) => callback(tx), + }; + + await expect( + syncAutoAnalysisQueueForFinding(db as never, { + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + findingCreatedAt: '2026-05-18T10:00:00.000Z', + previousStatus: null, + currentStatus: 'open', + severity: 'critical', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'high', + ownerAutoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + }) + ).resolves.toEqual({ + enqueueCount: 1, + eligibleCount: 1, + boundarySkipCount: 0, + unknownSeverityCount: 0, + }); + expect(inserted[0]).toMatchObject({ + finding_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + queue_status: 'queued', + severity_rank: 0, + }); + }); +}); diff --git a/services/security-sync/src/sync.ts b/services/security-sync/src/sync.ts index 4902816a0e..961a2620b0 100644 --- a/services/security-sync/src/sync.ts +++ b/services/security-sync/src/sync.ts @@ -13,6 +13,7 @@ import { platform_integrations, security_findings, security_analysis_queue, + security_analysis_owner_state, security_audit_log, } from '@kilocode/db/schema'; import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; @@ -92,6 +93,8 @@ type ParsedSecurityFinding = { dependency_scope: 'development' | 'runtime' | null; }; +type AutoAnalysisMinSeverity = 'critical' | 'high' | 'medium' | 'all'; + type SecurityAgentConfig = { sla_critical_days: number; sla_high_days: number; @@ -99,6 +102,9 @@ type SecurityAgentConfig = { sla_low_days: number; repository_selection_mode: 'all' | 'selected'; selected_repository_ids?: number[]; + auto_analysis_enabled: boolean; + auto_analysis_min_severity: AutoAnalysisMinSeverity; + auto_analysis_include_existing: boolean; }; const securityAgentConfigSchema = z.object({ @@ -108,6 +114,9 @@ const securityAgentConfigSchema = z.object({ sla_low_days: z.number(), repository_selection_mode: z.enum(['all', 'selected']), selected_repository_ids: z.array(z.number()).optional(), + auto_analysis_enabled: z.boolean(), + auto_analysis_min_severity: z.enum(['critical', 'high', 'medium', 'all']), + auto_analysis_include_existing: z.boolean(), }); const DEFAULT_SLA_CONFIG: SecurityAgentConfig = { @@ -116,6 +125,9 @@ const DEFAULT_SLA_CONFIG: SecurityAgentConfig = { sla_medium_days: 45, sla_low_days: 90, repository_selection_mode: 'all', + auto_analysis_enabled: false, + auto_analysis_min_severity: 'high', + auto_analysis_include_existing: false, }; type SecurityReviewOwner = @@ -157,6 +169,13 @@ function integrationOwnerFilter(owner: SecurityReviewOwner) { return eq(platform_integrations.owned_by_user_id, owner.userId); } +function analysisOwnerStateFilter(owner: SecurityReviewOwner) { + if (isOrgOwner(owner)) { + return eq(security_analysis_owner_state.owned_by_organization_id, owner.organizationId); + } + return eq(security_analysis_owner_state.owned_by_user_id, owner.userId); +} + type EnabledOwnerConfig = { owner: SecurityReviewOwner; platformIntegrationId: string; @@ -164,6 +183,7 @@ type EnabledOwnerConfig = { repositories: string[]; repoNameToId: Map; slaConfig: SecurityAgentConfig; + autoAnalysisEnabledAt: string | null; /** Number of selected_repository_ids that are no longer accessible via the installation. * Non-zero means the app lost access to a configured repo — freshness must not advance. */ missingSelectedRepoCount: number; @@ -262,6 +282,12 @@ export async function getOwnerConfig( }); } + const ownerStates = await db + .select({ autoAnalysisEnabledAt: security_analysis_owner_state.auto_analysis_enabled_at }) + .from(security_analysis_owner_state) + .where(analysisOwnerStateFilter(owner)) + .limit(1); + return { owner, platformIntegrationId: integration.id, @@ -269,6 +295,7 @@ export async function getOwnerConfig( repositories: selectedRepos, repoNameToId, slaConfig: { ...DEFAULT_SLA_CONFIG, ...securityConfig }, + autoAnalysisEnabledAt: ownerStates[0]?.autoAnalysisEnabledAt ?? null, missingSelectedRepoCount, }; } @@ -412,6 +439,25 @@ function calculateSlaDueAt(firstDetectedAt: string, slaDays: number): string { return date.toISOString(); } +const securityFindingStatusSchema = z.enum([ + SecurityFindingStatus.OPEN, + SecurityFindingStatus.FIXED, + SecurityFindingStatus.IGNORED, +]); + +const upsertSecurityFindingResultSchema = z.object({ + findingId: z.string().uuid(), + previousStatus: securityFindingStatusSchema.nullable(), + effectiveStatus: securityFindingStatusSchema, + findingCreatedAt: z + .union([z.string(), z.date()]) + .transform(value => + value instanceof Date ? value.toISOString() : new Date(value).toISOString() + ), +}); + +type UpsertSecurityFindingResult = z.infer; + async function upsertSecurityFinding( db: WorkerDb, params: { @@ -421,62 +467,355 @@ async function upsertSecurityFinding( repoFullName: string; slaDueAt: string; } -): Promise { +): Promise { const { finding, owner, platformIntegrationId, repoFullName, slaDueAt } = params; + const ownerOrganizationId = isOrgOwner(owner) ? owner.organizationId : null; + const ownerUserId = isOrgOwner(owner) ? null : owner.userId; + + const result = await db.execute>(sql` + WITH existing_match AS ( + SELECT ${security_findings.id} AS id, + ${security_findings.status} AS previous_status + FROM ${security_findings} + WHERE ${security_findings.repo_full_name} = ${repoFullName} + AND ${security_findings.source} = ${finding.source} + AND ${security_findings.source_id} = ${finding.source_id} + FOR UPDATE + ), + upserted AS ( + INSERT INTO ${security_findings} ( + ${sql.identifier(security_findings.owned_by_organization_id.name)}, + ${sql.identifier(security_findings.owned_by_user_id.name)}, + ${sql.identifier(security_findings.platform_integration_id.name)}, + ${sql.identifier(security_findings.repo_full_name.name)}, + ${sql.identifier(security_findings.source.name)}, + ${sql.identifier(security_findings.source_id.name)}, + ${sql.identifier(security_findings.severity.name)}, + ${sql.identifier(security_findings.ghsa_id.name)}, + ${sql.identifier(security_findings.cve_id.name)}, + ${sql.identifier(security_findings.package_name.name)}, + ${sql.identifier(security_findings.package_ecosystem.name)}, + ${sql.identifier(security_findings.vulnerable_version_range.name)}, + ${sql.identifier(security_findings.patched_version.name)}, + ${sql.identifier(security_findings.manifest_path.name)}, + ${sql.identifier(security_findings.title.name)}, + ${sql.identifier(security_findings.description.name)}, + ${sql.identifier(security_findings.status.name)}, + ${sql.identifier(security_findings.ignored_reason.name)}, + ${sql.identifier(security_findings.ignored_by.name)}, + ${sql.identifier(security_findings.fixed_at.name)}, + ${sql.identifier(security_findings.sla_due_at.name)}, + ${sql.identifier(security_findings.dependabot_html_url.name)}, + ${sql.identifier(security_findings.raw_data.name)}, + ${sql.identifier(security_findings.first_detected_at.name)}, + ${sql.identifier(security_findings.cwe_ids.name)}, + ${sql.identifier(security_findings.cvss_score.name)}, + ${sql.identifier(security_findings.dependency_scope.name)} + ) + SELECT + ${ownerOrganizationId}, + ${ownerUserId}, + ${platformIntegrationId}, + ${repoFullName}, + ${finding.source}, + ${finding.source_id}, + ${finding.severity}, + ${finding.ghsa_id}, + ${finding.cve_id}, + ${finding.package_name}, + ${finding.package_ecosystem}, + ${finding.vulnerable_version_range}, + ${finding.patched_version}, + ${finding.manifest_path}, + ${finding.title}, + ${finding.description}, + ${finding.status}, + ${finding.ignored_reason}, + ${finding.ignored_by}, + ${finding.fixed_at}, + ${slaDueAt}, + ${finding.dependabot_html_url}, + ${finding.raw_data}, + ${finding.first_detected_at}, + ${sql.param(finding.cwe_ids)}::text[], + ${finding.cvss_score?.toString() ?? null}, + ${finding.dependency_scope} + FROM (SELECT 1) AS input + LEFT JOIN existing_match ON true + ON CONFLICT (${sql.identifier(security_findings.repo_full_name.name)}, ${sql.identifier(security_findings.source.name)}, ${sql.identifier(security_findings.source_id.name)}) DO UPDATE + SET + ${sql.identifier(security_findings.severity.name)} = EXCLUDED.${sql.identifier(security_findings.severity.name)}, + ${sql.identifier(security_findings.ghsa_id.name)} = EXCLUDED.${sql.identifier(security_findings.ghsa_id.name)}, + ${sql.identifier(security_findings.cve_id.name)} = EXCLUDED.${sql.identifier(security_findings.cve_id.name)}, + ${sql.identifier(security_findings.vulnerable_version_range.name)} = EXCLUDED.${sql.identifier(security_findings.vulnerable_version_range.name)}, + ${sql.identifier(security_findings.patched_version.name)} = EXCLUDED.${sql.identifier(security_findings.patched_version.name)}, + ${sql.identifier(security_findings.title.name)} = EXCLUDED.${sql.identifier(security_findings.title.name)}, + ${sql.identifier(security_findings.description.name)} = EXCLUDED.${sql.identifier(security_findings.description.name)}, + ${sql.identifier(security_findings.status.name)} = CASE + WHEN ${security_findings.ignored_reason} LIKE 'superseded:%' THEN ${security_findings.status} + ELSE EXCLUDED.${sql.identifier(security_findings.status.name)} + END, + ${sql.identifier(security_findings.ignored_reason.name)} = CASE + WHEN ${security_findings.ignored_reason} LIKE 'superseded:%' THEN ${security_findings.ignored_reason} + ELSE EXCLUDED.${sql.identifier(security_findings.ignored_reason.name)} + END, + ${sql.identifier(security_findings.ignored_by.name)} = CASE + WHEN ${security_findings.ignored_reason} LIKE 'superseded:%' THEN ${security_findings.ignored_by} + ELSE EXCLUDED.${sql.identifier(security_findings.ignored_by.name)} + END, + ${sql.identifier(security_findings.fixed_at.name)} = EXCLUDED.${sql.identifier(security_findings.fixed_at.name)}, + ${sql.identifier(security_findings.sla_due_at.name)} = EXCLUDED.${sql.identifier(security_findings.sla_due_at.name)}, + ${sql.identifier(security_findings.dependabot_html_url.name)} = EXCLUDED.${sql.identifier(security_findings.dependabot_html_url.name)}, + ${sql.identifier(security_findings.raw_data.name)} = EXCLUDED.${sql.identifier(security_findings.raw_data.name)}, + ${sql.identifier(security_findings.cwe_ids.name)} = EXCLUDED.${sql.identifier(security_findings.cwe_ids.name)}, + ${sql.identifier(security_findings.cvss_score.name)} = EXCLUDED.${sql.identifier(security_findings.cvss_score.name)}, + ${sql.identifier(security_findings.dependency_scope.name)} = EXCLUDED.${sql.identifier(security_findings.dependency_scope.name)}, + ${sql.identifier(security_findings.last_synced_at.name)} = now(), + ${sql.identifier(security_findings.updated_at.name)} = now() + WHERE EXISTS (SELECT 1 FROM existing_match) + RETURNING + ${security_findings.id} AS id, + (xmax = 0) AS was_inserted, + ${security_findings.status} AS effective_status, + ${security_findings.created_at} AS created_at + ) + SELECT + upserted.id AS "findingId", + CASE + WHEN upserted.was_inserted THEN NULL::text + ELSE COALESCE(existing_match.previous_status, upserted.effective_status) + END AS "previousStatus", + upserted.effective_status AS "effectiveStatus", + upserted.created_at AS "findingCreatedAt" + FROM upserted + LEFT JOIN existing_match ON existing_match.id = upserted.id + LIMIT 1 + `); + + const upserted = result.rows[0]; + if (upserted) return upsertSecurityFindingResultSchema.parse(upserted); + + const fallback = await db.execute>(sql` + SELECT + ${security_findings.id} AS "findingId", + ${security_findings.status} AS "previousStatus", + ${security_findings.status} AS "effectiveStatus", + ${security_findings.created_at} AS "findingCreatedAt" + FROM ${security_findings} + WHERE ${security_findings.repo_full_name} = ${repoFullName} + AND ${security_findings.source} = ${finding.source} + AND ${security_findings.source_id} = ${finding.source_id} + LIMIT 1 + `); + const recovered = fallback.rows[0]; + if (!recovered) throw new Error('Failed to upsert security finding'); + return upsertSecurityFindingResultSchema.parse(recovered); +} + +type AutoAnalysisQueueSyncResult = { + enqueueCount: number; + eligibleCount: number; + boundarySkipCount: number; + unknownSeverityCount: number; +}; + +const AUTO_ANALYSIS_REOPEN_REQUEUE_CAP = 2; +const severityRankBySeverity = { + critical: 0, + high: 1, + medium: 2, + low: 3, +} satisfies Record; - // Fields that are updated on conflict (shared between insert and upsert). - // status, ignored_reason, and ignored_by are excluded here because the - // ON CONFLICT clause needs conditional logic to preserve superseded state. - const mutableFields = { - severity: finding.severity, - ghsa_id: finding.ghsa_id, - cve_id: finding.cve_id, - vulnerable_version_range: finding.vulnerable_version_range, - patched_version: finding.patched_version, - title: finding.title, - description: finding.description, - fixed_at: finding.fixed_at, - sla_due_at: slaDueAt, - dependabot_html_url: finding.dependabot_html_url, - raw_data: finding.raw_data, - cwe_ids: finding.cwe_ids, - cvss_score: finding.cvss_score?.toString() ?? null, - dependency_scope: finding.dependency_scope, +function minSeverityToMaxRank(minSeverity: AutoAnalysisMinSeverity): number { + switch (minSeverity) { + case 'critical': + return severityRankBySeverity.critical; + case 'high': + return severityRankBySeverity.high; + case 'medium': + return severityRankBySeverity.medium; + case 'all': + return severityRankBySeverity.low; + } +} + +export function isFindingEligibleForAutoAnalysis(params: { + findingCreatedAt: string; + findingStatus: string; + severity: string | null; + ownerAutoAnalysisEnabledAt: string | null; + isAgentEnabled: boolean; + autoAnalysisEnabled: boolean; + autoAnalysisMinSeverity: AutoAnalysisMinSeverity; + autoAnalysisIncludeExisting?: boolean; +}): { eligible: boolean; severityRank: number | null } { + const severityRank = securitySeveritySchema.safeParse(params.severity); + const normalizedSeverityRank = severityRank.success + ? severityRankBySeverity[severityRank.data] + : null; + if (!params.isAgentEnabled || !params.autoAnalysisEnabled) { + return { eligible: false, severityRank: normalizedSeverityRank }; + } + if (params.findingStatus !== SecurityFindingStatus.OPEN) { + return { eligible: false, severityRank: normalizedSeverityRank }; + } + if (!params.ownerAutoAnalysisEnabledAt) { + return { eligible: false, severityRank: normalizedSeverityRank }; + } + if ( + !params.autoAnalysisIncludeExisting && + Date.parse(params.findingCreatedAt) < Date.parse(params.ownerAutoAnalysisEnabledAt) + ) { + return { eligible: false, severityRank: normalizedSeverityRank }; + } + const effectiveRank = normalizedSeverityRank ?? severityRankBySeverity.low; + return { + eligible: effectiveRank <= minSeverityToMaxRank(params.autoAnalysisMinSeverity), + severityRank: effectiveRank, }; +} - await db - .insert(security_findings) - .values({ - owned_by_organization_id: isOrgOwner(owner) ? owner.organizationId : null, - owned_by_user_id: isOrgOwner(owner) ? null : owner.userId, - platform_integration_id: platformIntegrationId, - repo_full_name: repoFullName, - source: finding.source, - source_id: finding.source_id, - package_name: finding.package_name, - package_ecosystem: finding.package_ecosystem, - manifest_path: finding.manifest_path, - first_detected_at: finding.first_detected_at, - status: finding.status, - ignored_reason: finding.ignored_reason, - ignored_by: finding.ignored_by, - ...mutableFields, - }) - .onConflictDoUpdate({ - target: [ - security_findings.repo_full_name, - security_findings.source, - security_findings.source_id, - ], - set: { - ...mutableFields, - status: sql`CASE WHEN ${security_findings.ignored_reason} LIKE 'superseded:%' THEN ${security_findings.status} ELSE ${finding.status} END`, - ignored_reason: sql`CASE WHEN ${security_findings.ignored_reason} LIKE 'superseded:%' THEN ${security_findings.ignored_reason} ELSE ${finding.ignored_reason} END`, - ignored_by: sql`CASE WHEN ${security_findings.ignored_reason} LIKE 'superseded:%' THEN ${security_findings.ignored_by} ELSE ${finding.ignored_by} END`, - last_synced_at: sql`now()`, - updated_at: sql`now()`, - }, - }); +export async function syncAutoAnalysisQueueForFinding( + db: WorkerDb, + params: { + owner: SecurityReviewOwner; + findingId: string; + findingCreatedAt: string; + previousStatus: SecurityFindingStatus | null; + currentStatus: SecurityFindingStatus; + severity: string | null; + isAgentEnabled: boolean; + autoAnalysisEnabled: boolean; + autoAnalysisMinSeverity: AutoAnalysisMinSeverity; + ownerAutoAnalysisEnabledAt: string | null; + autoAnalysisIncludeExisting?: boolean; + } +): Promise { + const { eligible, severityRank } = isFindingEligibleForAutoAnalysis({ + findingCreatedAt: params.findingCreatedAt, + findingStatus: params.currentStatus, + severity: params.severity, + ownerAutoAnalysisEnabledAt: params.ownerAutoAnalysisEnabledAt, + isAgentEnabled: params.isAgentEnabled, + autoAnalysisEnabled: params.autoAnalysisEnabled, + autoAnalysisMinSeverity: params.autoAnalysisMinSeverity, + autoAnalysisIncludeExisting: params.autoAnalysisIncludeExisting, + }); + const boundarySkip = + !params.autoAnalysisIncludeExisting && + params.ownerAutoAnalysisEnabledAt != null && + Date.parse(params.findingCreatedAt) < Date.parse(params.ownerAutoAnalysisEnabledAt); + const unknownSeverityCount = severityRank == null ? 1 : 0; + let enqueueCount = 0; + const ownedByOrganizationId = isOrgOwner(params.owner) ? params.owner.organizationId : null; + const ownedByUserId = isOrgOwner(params.owner) ? null : params.owner.userId; + + await db.transaction(async tx => { + if (severityRank != null) { + await tx + .update(security_analysis_queue) + .set({ severity_rank: severityRank, updated_at: sql`now()` }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + eq(security_analysis_queue.queue_status, 'queued') + ) + ); + } + + if (!eligible) { + await tx + .update(security_analysis_queue) + .set({ + queue_status: 'completed', + failure_code: 'SKIPPED_NO_LONGER_ELIGIBLE', + claim_token: null, + claimed_at: null, + claimed_by_job_id: null, + updated_at: sql`now()`, + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + eq(security_analysis_queue.queue_status, 'queued') + ) + ); + } + + const reopened = + (params.previousStatus === SecurityFindingStatus.FIXED || + params.previousStatus === SecurityFindingStatus.IGNORED) && + params.currentStatus === SecurityFindingStatus.OPEN; + if (reopened && eligible) { + await tx + .update(security_analysis_queue) + .set({ + queue_status: 'queued', + queued_at: sql`now()`, + attempt_count: 0, + next_retry_at: null, + failure_code: null, + last_error_redacted: null, + claimed_at: null, + claimed_by_job_id: null, + claim_token: null, + reopen_requeue_count: sql`${security_analysis_queue.reopen_requeue_count} + 1`, + updated_at: sql`now()`, + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + or( + eq(security_analysis_queue.queue_status, 'completed'), + eq(security_analysis_queue.queue_status, 'failed') + ), + sql`${security_analysis_queue.reopen_requeue_count} < ${AUTO_ANALYSIS_REOPEN_REQUEUE_CAP}` + ) + ); + await tx + .update(security_analysis_queue) + .set({ + queue_status: 'failed', + failure_code: 'REOPEN_LOOP_GUARD', + updated_at: sql`now()`, + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + or( + eq(security_analysis_queue.queue_status, 'completed'), + eq(security_analysis_queue.queue_status, 'failed') + ), + sql`${security_analysis_queue.reopen_requeue_count} >= ${AUTO_ANALYSIS_REOPEN_REQUEUE_CAP}` + ) + ); + } + + if (eligible) { + const inserted = await tx + .insert(security_analysis_queue) + .values({ + finding_id: params.findingId, + owned_by_organization_id: ownedByOrganizationId, + owned_by_user_id: ownedByUserId, + queue_status: 'queued', + severity_rank: severityRank ?? severityRankBySeverity.low, + queued_at: sql`now()`, + updated_at: sql`now()`, + }) + .onConflictDoNothing() + .returning({ id: security_analysis_queue.id }); + enqueueCount = inserted.length; + } + }); + + return { + enqueueCount, + eligibleCount: eligible ? 1 : 0, + boundarySkipCount: boundarySkip ? 1 : 0, + unknownSeverityCount, + }; } type SupersedeResult = { count: number; supersededFindingIds: string[] }; @@ -593,20 +932,21 @@ async function writeAuditLog( db: WorkerDb, params: { owner: SecurityReviewOwner; + actor?: { id: string; email?: string | null; name?: string | null }; action: SecurityAuditLogAction; resource_type: string; resource_id: string; metadata: Record; } ): Promise { - const { owner, action, resource_type, resource_id, metadata } = params; + const { owner, actor, action, resource_type, resource_id, metadata } = params; await db.insert(security_audit_log).values({ owned_by_organization_id: isOrgOwner(owner) ? owner.organizationId : null, owned_by_user_id: isOrgOwner(owner) ? null : owner.userId, - actor_id: null, - actor_email: null, - actor_name: null, + actor_id: actor?.id ?? null, + actor_email: actor?.email ?? null, + actor_name: actor?.name ?? null, action, resource_type, resource_id, @@ -723,13 +1063,25 @@ async function pruneMissingSelectedRepos( console.warn(`Pruned ${removedCount} inaccessible repo ID(s) from config`); } +export function selectRepositoriesForSync( + config: Pick, + repoFullName?: string +): string[] { + if (!repoFullName) return config.repositories; + return config.repoNameToId.has(repoFullName) ? [repoFullName] : []; +} + export async function syncOwner(params: { db: WorkerDb; gitTokenService: GitTokenService; owner: SecurityReviewOwner; runId: string; + trigger?: 'scheduled' | 'manual'; + actor?: { id: string; email?: string | null; name?: string | null }; + repoFullName?: string; }): Promise { - const { db: database, gitTokenService, owner, runId } = params; + const { db: database, gitTokenService, owner, runId, actor, repoFullName } = params; + const trigger = params.trigger ?? 'scheduled'; const startTime = Date.now(); const config = await getOwnerConfig(database, owner); @@ -738,11 +1090,21 @@ export async function syncOwner(params: { return { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; } + const repositories = selectRepositoriesForSync(config, repoFullName); + if (repoFullName && repositories.length === 0) { + console.warn('Manual sync repository is not accessible for owner, skipping', { + runId, + owner, + repoFullName, + }); + return { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; + } + const totalResult: SyncResult = { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; let firstError: Error | null = null; let successfulRepos = 0; - for (const repoFullName of config.repositories) { + for (const repoFullName of repositories) { try { const repoResult = await syncRepo({ db: database, @@ -752,6 +1114,7 @@ export async function syncOwner(params: { platformIntegrationId: config.platformIntegrationId, repoFullName, slaConfig: config.slaConfig, + autoAnalysisEnabledAt: config.autoAnalysisEnabledAt, }); totalResult.synced += repoResult.synced; totalResult.errors += repoResult.errors; @@ -773,7 +1136,8 @@ export async function syncOwner(params: { throw firstError; } - // Prune stale repos + // Prune stale configured repositories regardless of trigger so manual and scheduled + // sync do not disagree about owner configuration after GitHub reports permanent loss. if (totalResult.staleRepos.length > 0) { try { await pruneStaleReposFromConfig(database, owner, totalResult.staleRepos, config.repoNameToId); @@ -784,7 +1148,8 @@ export async function syncOwner(params: { } } - // Prune selected repo IDs that silently vanished from the installation + // Prune selected repo IDs that silently vanished from the installation. Owner config + // inspection already loaded full accessible repositories for both sync scopes. if (config.missingSelectedRepoCount > 0) { try { const accessibleRepoIds = new Set(config.repoNameToId.values()); @@ -802,16 +1167,18 @@ export async function syncOwner(params: { try { await writeAuditLog(database, { owner, + actor, action: SecurityAuditLogAction.SyncCompleted, resource_type: 'agent_config', resource_id: ownerId, metadata: { - source: 'system', - trigger: 'worker_queue', + source: trigger === 'manual' ? 'user' : 'system', + trigger, runId, + repoFullName, synced: totalResult.synced, errors: totalResult.errors, - repoCount: config.repositories.length, + repoCount: repositories.length, }, }); } catch (error) { @@ -828,6 +1195,7 @@ export async function syncOwner(params: { // Missing selected repos (installation lost access) also block — the repo // was configured but silently dropped from the accessible list. if ( + !repoFullName && totalResult.errors === 0 && totalResult.staleRepos.length === 0 && config.missingSelectedRepoCount === 0 @@ -859,7 +1227,7 @@ export async function syncOwner(params: { const syncSummary = { runId, ownerId, - reposScanned: config.repositories.length, + reposScanned: repositories.length, findingsSynced: totalResult.synced, errors: totalResult.errors, skippedRepos: totalResult.skipped, @@ -885,6 +1253,7 @@ async function syncRepo(params: { platformIntegrationId: string; repoFullName: string; slaConfig: SecurityAgentConfig; + autoAnalysisEnabledAt: string | null; }): Promise { const { db: database, @@ -933,13 +1302,26 @@ async function syncRepo(params: { const slaDays = getSlaForSeverity(slaConfig, finding.severity); const slaDueAt = calculateSlaDueAt(finding.first_detected_at, slaDays); - await upsertSecurityFinding(database, { + const upserted = await upsertSecurityFinding(database, { finding, owner, platformIntegrationId, repoFullName, slaDueAt, }); + await syncAutoAnalysisQueueForFinding(database, { + owner, + findingId: upserted.findingId, + findingCreatedAt: upserted.findingCreatedAt, + previousStatus: upserted.previousStatus, + currentStatus: upserted.effectiveStatus, + severity: finding.severity, + isAgentEnabled: true, + autoAnalysisEnabled: slaConfig.auto_analysis_enabled, + autoAnalysisMinSeverity: slaConfig.auto_analysis_min_severity, + ownerAutoAnalysisEnabledAt: params.autoAnalysisEnabledAt, + autoAnalysisIncludeExisting: slaConfig.auto_analysis_include_existing, + }); result.synced++; } catch (error) { result.errors++; diff --git a/services/security-sync/vitest.config.ts b/services/security-sync/vitest.config.ts new file mode 100644 index 0000000000..7dd13254e7 --- /dev/null +++ b/services/security-sync/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/services/security-sync/worker-configuration.d.ts b/services/security-sync/worker-configuration.d.ts index 15b25b9512..42794a26b6 100644 --- a/services/security-sync/worker-configuration.d.ts +++ b/services/security-sync/worker-configuration.d.ts @@ -25,6 +25,10 @@ declare type GitTokenService = { getToken(installationId: string, appType?: 'standard' | 'lite'): Promise; }; +declare type SecretBinding = { + get(): Promise; +}; + declare type ScheduledController = { scheduledTime: number; cron: string; @@ -38,7 +42,10 @@ declare type ExecutionContext = { declare type CloudflareEnv = { SECURITY_SYNC_BETTERSTACK_HEARTBEAT_URL: string | undefined; - SYNC_QUEUE: Queue; + INTERNAL_API_SECRET: SecretBinding; + SYNC_QUEUE: Queue; HYPERDRIVE: Hyperdrive; GIT_TOKEN_SERVICE: GitTokenService; + MANUAL_SYNC_COMMAND_ROUTING_ENABLED: string | undefined; + DISMISS_FINDING_COMMAND_ROUTING_ENABLED: string | undefined; }; diff --git a/services/security-sync/wrangler.jsonc b/services/security-sync/wrangler.jsonc index f1bdfdc241..9284cb6128 100644 --- a/services/security-sync/wrangler.jsonc +++ b/services/security-sync/wrangler.jsonc @@ -14,6 +14,10 @@ "local_protocol": "http", "ip": "0.0.0.0", }, + "vars": { + "MANUAL_SYNC_COMMAND_ROUTING_ENABLED": "true", + "DISMISS_FINDING_COMMAND_ROUTING_ENABLED": "true", + }, "triggers": { "crons": ["0 */6 * * *"], }, @@ -48,9 +52,20 @@ "entrypoint": "GitTokenRPCEntrypoint", }, ], + "secrets_store_secrets": [ + { + "binding": "INTERNAL_API_SECRET", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "INTERNAL_API_SECRET_PROD", + }, + ], "env": { "dev": { "name": "cloudflare-security-sync-dev", + "vars": { + "MANUAL_SYNC_COMMAND_ROUTING_ENABLED": "true", + "DISMISS_FINDING_COMMAND_ROUTING_ENABLED": "true", + }, "triggers": { "crons": ["0 */6 * * *"], }, @@ -85,6 +100,13 @@ "entrypoint": "GitTokenRPCEntrypoint", }, ], + "secrets_store_secrets": [ + { + "binding": "INTERNAL_API_SECRET", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "INTERNAL_API_SECRET_DEV", + }, + ], }, }, } From e51e73a4bf5161f1705d6409880c6db887aaa767 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 18 May 2026 22:16:19 +0200 Subject: [PATCH 02/18] fix(security-agent): address reviewer feedback --- .../src/components/security-agent/SecurityAgentContext.tsx | 3 +++ .../src/components/security-agent/SecurityAgentPageClient.tsx | 3 +++ services/security-auto-analysis/src/callbacks.ts | 4 ---- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 8c7a15381e..71b7b40049 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -149,6 +149,9 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen for (const delay of ACCEPTED_QUEUE_REFRESH_DELAYS_MS) { const timer = window.setTimeout(() => { void queryClient.invalidateQueries(); + acceptedQueueRefreshTimersRef.current = acceptedQueueRefreshTimersRef.current.filter( + pendingTimer => pendingTimer !== timer + ); }, delay); acceptedQueueRefreshTimersRef.current.push(timer); } diff --git a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx index 343854aa45..6d46ad1799 100644 --- a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx @@ -78,6 +78,9 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli for (const delay of ACCEPTED_QUEUE_REFRESH_DELAYS_MS) { const timer = window.setTimeout(() => { void queryClient.invalidateQueries(); + acceptedQueueRefreshTimersRef.current = acceptedQueueRefreshTimersRef.current.filter( + pendingTimer => pendingTimer !== timer + ); }, delay); acceptedQueueRefreshTimersRef.current.push(timer); } diff --git a/services/security-auto-analysis/src/callbacks.ts b/services/security-auto-analysis/src/callbacks.ts index d3428db449..951fa12b96 100644 --- a/services/security-auto-analysis/src/callbacks.ts +++ b/services/security-auto-analysis/src/callbacks.ts @@ -270,10 +270,6 @@ export async function finalizeFailedAnalysisCallback(params: { return { status: disposition }; } - if (params.payload.status === 'completed') { - return { status: 'process' }; - } - const failure = mapAnalysisCallbackFailure({ status: params.payload.status === 'interrupted' ? 'interrupted' : 'failed', errorMessage: params.payload.errorMessage, From 732754481ec55402d4efd91c2eb8cd7ea2d99c06 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 18 May 2026 22:34:19 +0200 Subject: [PATCH 03/18] fix(security-agent): isolate DB integration tests --- services/security-auto-analysis/package.json | 3 ++- services/security-auto-analysis/vitest.config.ts | 1 + .../security-auto-analysis/vitest.integration.config.ts | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 services/security-auto-analysis/vitest.integration.config.ts diff --git a/services/security-auto-analysis/package.json b/services/security-auto-analysis/package.json index 71851cee9c..8902e7bc30 100644 --- a/services/security-auto-analysis/package.json +++ b/services/security-auto-analysis/package.json @@ -9,7 +9,8 @@ "typecheck": "tsgo --noEmit", "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/security-auto-analysis/src", "types": "wrangler types --env-interface CloudflareEnv worker-configuration.d.ts", - "test": "vitest run" + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { "@kilocode/db": "workspace:*", diff --git a/services/security-auto-analysis/vitest.config.ts b/services/security-auto-analysis/vitest.config.ts index 7dd13254e7..b1d5b43bb9 100644 --- a/services/security-auto-analysis/vitest.config.ts +++ b/services/security-auto-analysis/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + exclude: ['src/**/*.integration.test.ts'], }, }); diff --git a/services/security-auto-analysis/vitest.integration.config.ts b/services/security-auto-analysis/vitest.integration.config.ts new file mode 100644 index 0000000000..870fc0e5b6 --- /dev/null +++ b/services/security-auto-analysis/vitest.integration.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.integration.test.ts'], + }, +}); From d0bdf78baeafbb68a92a929ce27905fbe8afbf44 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 18 May 2026 23:13:36 +0200 Subject: [PATCH 04/18] refactor(security-agent): decouple analysis callbacks --- .../security-agent/SecurityAgentContext.tsx | 12 ++-------- .../src/callbacks/delivery.test.ts | 23 ++++++++++--------- .../src/callbacks/delivery.ts | 23 ++++--------------- .../src/callbacks/queue-consumer.ts | 14 ++++------- .../cloud-agent-next/src/callbacks/types.ts | 1 - .../src/persistence/schemas.ts | 1 - services/cloud-agent-next/src/server.ts | 4 ++-- services/cloud-agent-next/src/types.ts | 4 ---- services/cloud-agent-next/wrangler.jsonc | 8 ------- services/security-auto-analysis/README.md | 4 ++-- .../security-auto-analysis/src/launch.test.ts | 22 +++++++++++++++--- services/security-auto-analysis/src/launch.ts | 15 ++++++++---- .../worker-configuration.d.ts | 1 + .../security-auto-analysis/wrangler.jsonc | 6 +++-- services/security-sync/src/sync.test.ts | 12 ++++++++++ services/security-sync/src/sync.ts | 7 +++--- 16 files changed, 77 insertions(+), 80 deletions(-) diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 71b7b40049..1b6c572569 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -1,14 +1,6 @@ 'use client'; -import { - createContext, - useContext, - useState, - useCallback, - useEffect, - useMemo, - useRef, -} from 'react'; +import { createContext, use, useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -102,7 +94,7 @@ type SecurityAgentContextValue = { const SecurityAgentContext = createContext(null); export function useSecurityAgent() { - const ctx = useContext(SecurityAgentContext); + const ctx = use(SecurityAgentContext); if (!ctx) { throw new Error('useSecurityAgent must be used within a SecurityAgentProvider'); } diff --git a/services/cloud-agent-next/src/callbacks/delivery.test.ts b/services/cloud-agent-next/src/callbacks/delivery.test.ts index 84582554d5..fa4e5b38bc 100644 --- a/services/cloud-agent-next/src/callbacks/delivery.test.ts +++ b/services/cloud-agent-next/src/callbacks/delivery.test.ts @@ -117,23 +117,24 @@ describe('deliverCallbackJob', () => { }); describe('successful delivery', () => { - it('delivers Security Agent callbacks over direct service binding without public fetch fallback', async () => { - const publicFetch = vi.fn(); - globalThis.fetch = publicFetch; - const bindingFetch = vi.fn().mockResolvedValue(new Response('', { status: 202 })); + it('delivers Security Agent callbacks through the configured HTTP target', async () => { + const callbackFetch = vi.fn().mockResolvedValue(new Response('', { status: 202 })); + globalThis.fetch = callbackFetch; const target: CallbackTarget = { - url: 'https://security-auto-analysis/internal/security-analysis-callback/finding-123', - delivery: 'security-auto-analysis', + url: 'https://security-analysis.test/internal/security-analysis-callback/finding-123', headers: { 'X-Internal-Secret': 'secret' }, }; - const result = await deliverCallbackJob(target, mockPayload, 1, { - securityAutoAnalysis: { fetch: bindingFetch }, - }); + const result = await deliverCallbackJob(target, mockPayload, 1); expect(result.type).toBe('success'); - expect(bindingFetch).toHaveBeenCalledTimes(1); - expect(publicFetch).not.toHaveBeenCalled(); + expect(callbackFetch).toHaveBeenCalledWith( + 'https://security-analysis.test/internal/security-analysis-callback/finding-123', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockPayload), + }) + ); }); it('should succeed on 200 response', async () => { diff --git a/services/cloud-agent-next/src/callbacks/delivery.ts b/services/cloud-agent-next/src/callbacks/delivery.ts index 08ac06ed12..a355b05097 100644 --- a/services/cloud-agent-next/src/callbacks/delivery.ts +++ b/services/cloud-agent-next/src/callbacks/delivery.ts @@ -17,16 +17,9 @@ export type DeliveryResult = | { type: 'retry'; delaySeconds: number } | { type: 'failed'; error: string }; -type CallbackDeliveryBindings = { - securityAutoAnalysis?: { - fetch(input: RequestInfo | URL, init?: RequestInit): Promise; - }; -}; - async function deliverToTarget( target: CallbackTarget, - payload: ExecutionCallbackPayload, - bindings: CallbackDeliveryBindings + payload: ExecutionCallbackPayload ): Promise<{ ok: boolean; status?: number; error?: string }> { const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -40,14 +33,7 @@ async function deliverToTarget( body: JSON.stringify(payload), signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), }; - const response = - target.delivery === 'security-auto-analysis' - ? await bindings.securityAutoAnalysis?.fetch(target.url, requestInit) - : await fetch(target.url, requestInit); - - if (!response) { - throw new Error('Security Auto Analysis callback binding is unavailable'); - } + const response = await fetch(target.url, requestInit); logger .withFields({ @@ -78,10 +64,9 @@ async function deliverToTarget( export async function deliverCallbackJob( target: CallbackTarget, payload: ExecutionCallbackPayload, - attempts: number, - bindings: CallbackDeliveryBindings = {} + attempts: number ): Promise { - const result = await deliverToTarget(target, payload, bindings); + const result = await deliverToTarget(target, payload); if (result.ok) { return { type: 'success' }; diff --git a/services/cloud-agent-next/src/callbacks/queue-consumer.ts b/services/cloud-agent-next/src/callbacks/queue-consumer.ts index bb521d1046..9946794240 100644 --- a/services/cloud-agent-next/src/callbacks/queue-consumer.ts +++ b/services/cloud-agent-next/src/callbacks/queue-consumer.ts @@ -1,25 +1,19 @@ import type { CallbackJob } from './types.js'; import { deliverCallbackJob } from './delivery.js'; import { logger } from '../logger.js'; -import type { Env } from '../types.js'; -export function createCallbackQueueConsumer(env: Pick) { +export function createCallbackQueueConsumer() { return async function callbackQueueConsumer(batch: MessageBatch): Promise { for (const message of batch.messages) { - await processMessage(message, env); + await processMessage(message); } }; } -async function processMessage( - message: Message, - env: Pick -): Promise { +async function processMessage(message: Message): Promise { const job = message.body; - const result = await deliverCallbackJob(job.target, job.payload, message.attempts, { - securityAutoAnalysis: env.SECURITY_AUTO_ANALYSIS, - }); + const result = await deliverCallbackJob(job.target, job.payload, message.attempts); switch (result.type) { case 'success': diff --git a/services/cloud-agent-next/src/callbacks/types.ts b/services/cloud-agent-next/src/callbacks/types.ts index ba55566504..5172270fbb 100644 --- a/services/cloud-agent-next/src/callbacks/types.ts +++ b/services/cloud-agent-next/src/callbacks/types.ts @@ -1,7 +1,6 @@ export type CallbackTarget = { url: string; headers?: Record; - delivery?: 'http' | 'security-auto-analysis'; }; export type ExecutionCallbackPayload = { diff --git a/services/cloud-agent-next/src/persistence/schemas.ts b/services/cloud-agent-next/src/persistence/schemas.ts index e367e27803..7c70f2f54f 100644 --- a/services/cloud-agent-next/src/persistence/schemas.ts +++ b/services/cloud-agent-next/src/persistence/schemas.ts @@ -10,7 +10,6 @@ import type { SandboxId } from '../types.js'; export const CallbackTargetSchema = z.object({ url: z.string().url(), headers: z.record(z.string(), z.string()).optional(), - delivery: z.enum(['http', 'security-auto-analysis']).optional(), }); /** diff --git a/services/cloud-agent-next/src/server.ts b/services/cloud-agent-next/src/server.ts index 7d07c0504b..31a129859b 100644 --- a/services/cloud-agent-next/src/server.ts +++ b/services/cloud-agent-next/src/server.ts @@ -332,9 +332,9 @@ export default { return app.fetch(request, env, ctx); }, - async queue(batch: MessageBatch, env: Env): Promise { + async queue(batch: MessageBatch): Promise { if (batch.queue.startsWith('cloud-agent-next-callback-queue')) { - const consumer = createCallbackQueueConsumer(env); + const consumer = createCallbackQueueConsumer(); return consumer(batch as MessageBatch); } if (CLOUD_AGENT_REPORT_QUEUE_NAMES.has(batch.queue)) { diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index 5f7f316315..a38e5e8290 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -206,10 +206,6 @@ export type Env = { GIT_TOKEN_SERVICE: GitTokenService; /** Service binding for dispatching push notifications */ NOTIFICATIONS: NotificationsBinding; - /** Direct HTTP service binding for Security Agent analysis callbacks */ - SECURITY_AUTO_ANALYSIS?: { - fetch(input: RequestInfo | URL, init?: RequestInit): Promise; - }; /** GitHub Lite App slug for git commit attribution (e.g., 'kiloconnect-lite') */ GITHUB_LITE_APP_SLUG?: string; /** GitHub Lite App bot user ID for git commit email */ diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index 823a8cb71d..6883ddea91 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -96,10 +96,6 @@ "service": "notifications", "entrypoint": "NotificationsService", }, - { - "binding": "SECURITY_AUTO_ANALYSIS", - "service": "security-auto-analysis", - }, ], "secrets_store_secrets": [ { @@ -303,10 +299,6 @@ "service": "notifications", "entrypoint": "NotificationsService", }, - { - "binding": "SECURITY_AUTO_ANALYSIS", - "service": "security-auto-analysis-dev", - }, ], "secrets_store_secrets": [ { diff --git a/services/security-auto-analysis/README.md b/services/security-auto-analysis/README.md index 22447aa80b..2511550eb1 100644 --- a/services/security-auto-analysis/README.md +++ b/services/security-auto-analysis/README.md @@ -205,8 +205,8 @@ Do not clear the block until credits are restored. After top-up, clear the block **Callback routing:** -- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=worker` targets direct service-binding delivery to this Worker. -- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=web` targets `${SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL}/api/internal/security-analysis-callback/:findingId` so rollout can return to the compatibility Next.js callback path without changing launch code. +- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=worker` targets `${SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL}/internal/security-analysis-callback/:findingId`; base URL must be reachable from `cloud-agent-next`. +- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=web` targets `${SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL}/api/internal/security-analysis-callback/:findingId`; this is default callback path and keeps `cloud-agent-next` domain-blind. **Owner-scoped stop** (surgical): diff --git a/services/security-auto-analysis/src/launch.test.ts b/services/security-auto-analysis/src/launch.test.ts index 295f33aff1..b2a6319412 100644 --- a/services/security-auto-analysis/src/launch.test.ts +++ b/services/security-auto-analysis/src/launch.test.ts @@ -72,6 +72,7 @@ function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) KILOCODE_BACKEND_BASE_URL: 'https://backend.test', SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker', SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', + SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: 'https://security-analysis.test', CLOUD_AGENT_NEXT: { fetch: cloudAgentFetch }, } as unknown as CloudflareEnv, findingId: finding.id, @@ -89,19 +90,19 @@ function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) } describe('buildSecurityAnalysisCallbackTarget', () => { - it('routes callback delivery directly to the Worker processing plane by default', () => { + it('routes callback delivery to configured Worker HTTP ingress', () => { expect( buildSecurityAnalysisCallbackTarget( { SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker', SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', + SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: 'https://security-analysis.test/', }, finding.id, 'callback-token' ) ).toEqual({ - url: `https://security-auto-analysis/internal/security-analysis-callback/${finding.id}`, - delivery: 'security-auto-analysis', + url: `https://security-analysis.test/internal/security-analysis-callback/${finding.id}`, headers: { 'X-Callback-Token': 'callback-token' }, }); }); @@ -112,6 +113,7 @@ describe('buildSecurityAnalysisCallbackTarget', () => { { SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'web', SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai/', + SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: '', }, finding.id, 'callback-token' @@ -121,6 +123,20 @@ describe('buildSecurityAnalysisCallbackTarget', () => { headers: { 'X-Callback-Token': 'callback-token' }, }); }); + + it('requires a public Worker base URL for Worker callback routing', () => { + expect(() => + buildSecurityAnalysisCallbackTarget( + { + SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker', + SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', + SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: '', + }, + 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + 'callback-token' + ) + ).toThrow('SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL'); + }); }); describe('startSecurityAnalysis retrySandboxOnly', () => { diff --git a/services/security-auto-analysis/src/launch.ts b/services/security-auto-analysis/src/launch.ts index 6803a6071b..c90ef4fa9a 100644 --- a/services/security-auto-analysis/src/launch.ts +++ b/services/security-auto-analysis/src/launch.ts @@ -118,13 +118,14 @@ type StartSecurityAnalysisParams = { export function buildSecurityAnalysisCallbackTarget( env: Pick< CloudflareEnv, - 'SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE' | 'SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL' + | 'SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE' + | 'SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL' + | 'SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL' >, findingId: string, callbackToken: string ): { url: string; - delivery?: 'security-auto-analysis'; headers: { 'X-Callback-Token': string }; } { if (env.SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE === 'web') { @@ -135,9 +136,15 @@ export function buildSecurityAnalysisCallbackTarget( }; } + const baseUrl = env.SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL.replace(/\/$/, ''); + if (!baseUrl) { + throw new Error( + 'SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL is required for Worker callback routing' + ); + } + return { - url: `https://security-auto-analysis/internal/security-analysis-callback/${findingId}`, - delivery: 'security-auto-analysis', + url: `${baseUrl}/internal/security-analysis-callback/${findingId}`, headers: { 'X-Callback-Token': callbackToken }, }; } diff --git a/services/security-auto-analysis/worker-configuration.d.ts b/services/security-auto-analysis/worker-configuration.d.ts index 295c254af0..e49c60d370 100644 --- a/services/security-auto-analysis/worker-configuration.d.ts +++ b/services/security-auto-analysis/worker-configuration.d.ts @@ -79,6 +79,7 @@ declare type CloudflareEnv = { ENVIRONMENT: string; SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE: 'worker' | 'web'; SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: string; + SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: string; SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED: string | undefined; MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED: string | undefined; NEXT_PUBLIC_POSTHOG_KEY: string | undefined; diff --git a/services/security-auto-analysis/wrangler.jsonc b/services/security-auto-analysis/wrangler.jsonc index 0d02df3ad4..200da6397e 100644 --- a/services/security-auto-analysis/wrangler.jsonc +++ b/services/security-auto-analysis/wrangler.jsonc @@ -18,8 +18,9 @@ "ENVIRONMENT": "production", "KILOCODE_BACKEND_BASE_URL": "https://api.kilo.ai", "SESSION_INGEST_WORKER_URL": "https://ingest.kilosessions.ai", - "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker", + "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "web", "SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL": "https://app.kilo.ai", + "SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "", "SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED": "true", "MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED": "true", "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", @@ -109,8 +110,9 @@ "ENVIRONMENT": "development", "KILOCODE_BACKEND_BASE_URL": "http://localhost:3000", "SESSION_INGEST_WORKER_URL": "http://localhost:8800", - "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker", + "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "web", "SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL": "http://localhost:3000", + "SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "", "SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED": "true", "MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED": "true", "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", diff --git a/services/security-sync/src/sync.test.ts b/services/security-sync/src/sync.test.ts index 15126e46b6..ff6fbb950c 100644 --- a/services/security-sync/src/sync.test.ts +++ b/services/security-sync/src/sync.test.ts @@ -47,6 +47,18 @@ describe('Worker auto-analysis queue sync', () => { autoAnalysisMinSeverity: 'high', }) ).toEqual({ eligible: false, severityRank: 1 }); + + expect( + isFindingEligibleForAutoAnalysis({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + severity: 'unexpected', + ownerAutoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'all', + }) + ).toEqual({ eligible: false, severityRank: null }); }); it('enqueues eligible findings for Worker-owned automatic analysis', async () => { diff --git a/services/security-sync/src/sync.ts b/services/security-sync/src/sync.ts index 961a2620b0..bd68e313ca 100644 --- a/services/security-sync/src/sync.ts +++ b/services/security-sync/src/sync.ts @@ -669,10 +669,11 @@ export function isFindingEligibleForAutoAnalysis(params: { ) { return { eligible: false, severityRank: normalizedSeverityRank }; } - const effectiveRank = normalizedSeverityRank ?? severityRankBySeverity.low; return { - eligible: effectiveRank <= minSeverityToMaxRank(params.autoAnalysisMinSeverity), - severityRank: effectiveRank, + eligible: + normalizedSeverityRank !== null && + normalizedSeverityRank <= minSeverityToMaxRank(params.autoAnalysisMinSeverity), + severityRank: normalizedSeverityRank, }; } From ba7da3520d314194d163dd891fe1e063a3ddbc9d Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 19 May 2026 10:38:48 +0200 Subject: [PATCH 05/18] fix(security-agent): revive terminal manual analysis rows --- .../src/db/queries.integration.test.ts | 63 ++++++++++++++++++- .../security-auto-analysis/src/db/queries.ts | 25 +++++++- .../src/manual-analysis.test.ts | 24 +++++-- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/services/security-auto-analysis/src/db/queries.integration.test.ts b/services/security-auto-analysis/src/db/queries.integration.test.ts index 14eb0ce96a..d615f7ee2d 100644 --- a/services/security-auto-analysis/src/db/queries.integration.test.ts +++ b/services/security-auto-analysis/src/db/queries.integration.test.ts @@ -72,10 +72,69 @@ describe('security analysis durable database invariants', () => { ).resolves.toBe(false); const queueRows = await client.db - .select({ queueStatus: security_analysis_queue.queue_status }) + .select({ + queueStatus: security_analysis_queue.queue_status, + claimToken: security_analysis_queue.claim_token, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ queueStatus: 'pending', claimToken: 'claim-token-one' }]); + }); + + it('revives terminal manual queue rows so users can rerun analysis after completion', async () => { + const findingId = await insertFinding('manual-rerun'); + const finding = await getSecurityFindingById(client.db as never, findingId); + expect(finding).not.toBeNull(); + if (!finding) return; + + await expect( + ensureManualAnalysisQueueRow(client.db as never, { + finding, + claimToken: 'claim-token-first', + jobId: 'manual-job-first', + }) + ).resolves.toBe(true); + // Simulate the prior manual run reaching a terminal state with retry + // metadata that must be cleared on rerun. + await client.db + .update(security_analysis_queue) + .set({ + queue_status: 'failed', + failure_code: 'START_CALL_AMBIGUOUS', + last_error_redacted: 'prior failure', + attempt_count: 2, + }) + .where(eq(security_analysis_queue.finding_id, findingId)); + + await expect( + ensureManualAnalysisQueueRow(client.db as never, { + finding, + claimToken: 'claim-token-rerun', + jobId: 'manual-job-rerun', + }) + ).resolves.toBe(true); + + const queueRows = await client.db + .select({ + queueStatus: security_analysis_queue.queue_status, + claimToken: security_analysis_queue.claim_token, + claimedByJobId: security_analysis_queue.claimed_by_job_id, + failureCode: security_analysis_queue.failure_code, + lastErrorRedacted: security_analysis_queue.last_error_redacted, + attemptCount: security_analysis_queue.attempt_count, + }) .from(security_analysis_queue) .where(eq(security_analysis_queue.finding_id, findingId)); - expect(queueRows).toEqual([{ queueStatus: 'pending' }]); + expect(queueRows).toEqual([ + { + queueStatus: 'pending', + claimToken: 'claim-token-rerun', + claimedByJobId: 'manual-job-rerun', + failureCode: null, + lastErrorRedacted: null, + attemptCount: 0, + }, + ]); }); it('requeues stale pending rows and terminalizes stale running rows in real SQL', async () => { diff --git a/services/security-auto-analysis/src/db/queries.ts b/services/security-auto-analysis/src/db/queries.ts index f27244e023..57b24d2315 100644 --- a/services/security-auto-analysis/src/db/queries.ts +++ b/services/security-auto-analysis/src/db/queries.ts @@ -376,6 +376,10 @@ export async function ensureManualAnalysisQueueRow( : params.finding.severity === 'medium' ? 2 : 3; + // Insert a fresh manual-analysis claim, or revive a prior row that is in a + // terminal state (completed/failed) so users can rerun analysis after a + // previous attempt finished. Active rows (queued/pending/running) are left + // alone and the caller treats this as a duplicate. const rows = await db .insert(security_analysis_queue) .values({ @@ -390,7 +394,26 @@ export async function ensureManualAnalysisQueueRow( claim_token: params.claimToken, updated_at: sql`now()`.mapWith(String), }) - .onConflictDoNothing() + .onConflictDoUpdate({ + target: security_analysis_queue.finding_id, + set: { + queue_status: 'pending', + severity_rank: severityRank, + queued_at: sql`now()`, + claimed_at: sql`now()`, + claimed_by_job_id: params.jobId, + claim_token: params.claimToken, + attempt_count: 0, + next_retry_at: null, + failure_code: null, + last_error_redacted: null, + updated_at: sql`now()`, + }, + setWhere: or( + eq(security_analysis_queue.queue_status, 'completed'), + eq(security_analysis_queue.queue_status, 'failed') + ), + }) .returning({ id: security_analysis_queue.id }); return rows.length > 0; } diff --git a/services/security-auto-analysis/src/manual-analysis.test.ts b/services/security-auto-analysis/src/manual-analysis.test.ts index 8e162f4b2b..cda6550403 100644 --- a/services/security-auto-analysis/src/manual-analysis.test.ts +++ b/services/security-auto-analysis/src/manual-analysis.test.ts @@ -110,7 +110,7 @@ describe('processManualAnalysisStart', () => { if (insertCount === 1) { return { values: () => ({ - onConflictDoNothing: () => ({ returning: async () => [{ id: 'queue-row' }] }), + onConflictDoUpdate: () => ({ returning: async () => [{ id: 'queue-row' }] }), }), }; } @@ -173,12 +173,14 @@ describe('processManualAnalysisStart', () => { describe('ensureManualAnalysisQueueRow', () => { it('records claimed pending manual queue state with owner and claim correlation', async () => { const inserted: unknown[] = []; + const updateConfigs: unknown[] = []; const db = { insert: () => ({ values: (values: unknown) => ({ - onConflictDoNothing: () => ({ + onConflictDoUpdate: (config: unknown) => ({ returning: async () => { inserted.push(values); + updateConfigs.push(config); return [{ id: 'queue-row' }]; }, }), @@ -200,13 +202,27 @@ describe('ensureManualAnalysisQueueRow', () => { claim_token: 'claim-token', claimed_by_job_id: 'manual-job', }); + // Reviving terminal rows requires a setWhere clause that scopes the + // ON CONFLICT update to completed/failed rows. Active rows (queued/ + // pending/running) must remain untouched and surface as duplicates. + expect(updateConfigs[0]).toMatchObject({ + set: expect.objectContaining({ + queue_status: 'pending', + claim_token: 'claim-token', + claimed_by_job_id: 'manual-job', + attempt_count: 0, + failure_code: null, + last_error_redacted: null, + }), + setWhere: expect.anything(), + }); }); - it('reports duplicate manual starts when the finding queue row already exists', async () => { + it('reports duplicate manual starts when an active queue row already exists', async () => { const db = { insert: () => ({ values: () => ({ - onConflictDoNothing: () => ({ returning: async () => [] }), + onConflictDoUpdate: () => ({ returning: async () => [] }), }), }), }; From e285eb6fc1b12c8594281e94d90d666ba62e1810 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 19 May 2026 14:49:31 +0200 Subject: [PATCH 06/18] refactor(security-agent): centralize analysis start lifecycle --- ...alysis-start-lifecycle.integration.test.ts | 262 ++++++++++++++++++ .../src/analysis-start-lifecycle.ts | 177 ++++++++++++ .../src/consumer.test.ts | 227 +++++++++++++++ .../security-auto-analysis/src/consumer.ts | 89 +++--- .../security-auto-analysis/src/launch.test.ts | 80 +++++- services/security-auto-analysis/src/launch.ts | 83 +++--- .../src/manual-analysis.test.ts | 173 +++++++++++- .../src/manual-analysis.ts | 97 +++++-- 8 files changed, 1075 insertions(+), 113 deletions(-) create mode 100644 services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts create mode 100644 services/security-auto-analysis/src/analysis-start-lifecycle.ts create mode 100644 services/security-auto-analysis/src/consumer.test.ts diff --git a/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts b/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts new file mode 100644 index 0000000000..ad2b718c2b --- /dev/null +++ b/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts @@ -0,0 +1,262 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { randomUUID } from 'crypto'; +import { createDrizzleClient } from '@kilocode/db/client'; +import { kilocode_users, security_analysis_queue, security_findings } from '@kilocode/db/schema'; +import { eq, inArray } from 'drizzle-orm'; +import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; +import type { SecurityFindingAnalysis } from './types.js'; + +const connectionString = + process.env.POSTGRES_URL ?? 'postgres://postgres:postgres@localhost:5432/postgres'; +const testUserId = `security-analysis-start-lifecycle-${randomUUID()}`; +const testFindingIds: string[] = []; +let client: ReturnType; + +describe('analysis start lifecycle durable transitions', () => { + beforeAll(async () => { + client = createDrizzleClient({ connectionString, ssl: false }); + await client.db.insert(kilocode_users).values({ + id: testUserId, + google_user_email: `${testUserId}@example.com`, + google_user_name: 'Security Analysis Lifecycle Test', + google_user_image_url: 'https://example.com/avatar.png', + stripe_customer_id: `cus_${randomUUID()}`, + }); + }); + + afterEach(async () => { + if (testFindingIds.length === 0) return; + const ids = testFindingIds.splice(0, testFindingIds.length); + await client.db + .delete(security_analysis_queue) + .where(inArray(security_analysis_queue.finding_id, ids)); + await client.db.delete(security_findings).where(inArray(security_findings.id, ids)); + }); + + afterAll(async () => { + await client.db.delete(kilocode_users).where(eq(kilocode_users.id, testUserId)); + await client.pool.end(); + }); + + it('completes manual triage-only starts with queue and finding state settled together', async () => { + const findingId = await insertFinding('manual-triage-complete'); + await insertQueueClaim({ + findingId, + claimToken: 'manual-triage-claim', + jobId: 'manual-triage-job', + }); + const analysis = createAnalysis('manual-triage-complete'); + + await expect( + transitionAnalysisStartLifecycle(client.db as never, { + claim: { + source: 'manual', + findingId, + claimToken: 'manual-triage-claim', + }, + outcome: { + type: 'triage-only-completed', + analysis, + }, + }) + ).resolves.toEqual({ transitioned: true }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysis: security_findings.analysis, + }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([ + expect.objectContaining({ + analysisStatus: 'completed', + analysis: expect.objectContaining({ correlationId: analysis.correlationId }), + }), + ]); + + const queueRows = await client.db + .select({ status: security_analysis_queue.queue_status }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ status: 'completed' }]); + }); + + it('promotes scheduled sandbox starts to running without leaving the queue pending', async () => { + const findingId = await insertFinding('scheduled-sandbox-running'); + const queueRowId = await insertQueueClaim({ + findingId, + claimToken: 'scheduled-running-claim', + jobId: 'scheduled-running-job', + }); + + await expect( + transitionAnalysisStartLifecycle(client.db as never, { + claim: { + source: 'scheduled', + findingId, + queueRowId, + claimToken: 'scheduled-running-claim', + }, + outcome: { + type: 'sandbox-running', + cloudAgentSessionId: 'cloud-session-123', + kiloSessionId: 'kilo-session-123', + }, + }) + ).resolves.toEqual({ transitioned: true }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + sessionId: security_findings.session_id, + cliSessionId: security_findings.cli_session_id, + }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([ + { + analysisStatus: 'running', + sessionId: 'cloud-session-123', + cliSessionId: 'kilo-session-123', + }, + ]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + attemptCount: security_analysis_queue.attempt_count, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.id, queueRowId)); + expect(queueRows).toEqual([{ status: 'running', attemptCount: 1 }]); + }); + + it('requeues retryable scheduled start failures after running promotion without split state', async () => { + const findingId = await insertFinding('scheduled-retryable-failure', 'running'); + const queueRowId = await insertQueueClaim({ + findingId, + claimToken: 'scheduled-retry-claim', + jobId: 'scheduled-retry-job', + queueStatus: 'running', + }); + + await expect( + transitionAnalysisStartLifecycle(client.db as never, { + claim: { + source: 'scheduled', + findingId, + queueRowId, + claimToken: 'scheduled-retry-claim', + }, + outcome: { + type: 'start-failed', + errorMessage: 'prepareSession timed out', + queueStatus: 'queued', + failureCode: 'NETWORK_TIMEOUT', + incrementAttempt: true, + nextRetryAt: '2026-05-19T09:05:00.000Z', + }, + }) + ).resolves.toEqual({ transitioned: true }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysisError: security_findings.analysis_error, + }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([ + { + analysisStatus: 'failed', + analysisError: 'prepareSession timed out', + }, + ]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + attemptCount: security_analysis_queue.attempt_count, + failureCode: security_analysis_queue.failure_code, + nextRetryAt: security_analysis_queue.next_retry_at, + claimToken: security_analysis_queue.claim_token, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.id, queueRowId)); + expect(queueRows).toEqual([ + { + status: 'queued', + attemptCount: 1, + failureCode: 'NETWORK_TIMEOUT', + nextRetryAt: expect.any(String), + claimToken: null, + }, + ]); + }); +}); + +async function insertFinding( + suffix: string, + analysisStatus: 'pending' | 'running' = 'pending' +): Promise { + const findingId = randomUUID(); + testFindingIds.push(findingId); + await client.db.insert(security_findings).values({ + id: findingId, + owned_by_user_id: testUserId, + repo_full_name: `kilo/${suffix}`, + source: 'dependabot', + source_id: suffix, + severity: 'high', + package_name: `package-${suffix}`, + package_ecosystem: 'npm', + title: `Finding ${suffix}`, + status: 'open', + analysis_status: analysisStatus, + }); + return findingId; +} + +async function insertQueueClaim(params: { + findingId: string; + claimToken: string; + jobId: string; + queueStatus?: 'pending' | 'running'; +}): Promise { + const claimedAt = new Date().toISOString(); + const rows = await client.db + .insert(security_analysis_queue) + .values({ + finding_id: params.findingId, + owned_by_user_id: testUserId, + queue_status: params.queueStatus ?? 'pending', + severity_rank: 1, + queued_at: claimedAt, + claimed_at: claimedAt, + claimed_by_job_id: params.jobId, + claim_token: params.claimToken, + }) + .returning({ id: security_analysis_queue.id }); + const row = rows[0]; + if (!row) throw new Error('Expected queue row to be inserted'); + return row.id; +} + +function createAnalysis(suffix: string): SecurityFindingAnalysis { + return { + triage: { + needsSandboxAnalysis: false, + needsSandboxReasoning: `No sandbox needed for ${suffix}`, + suggestedAction: 'manual_review', + confidence: 'high', + triageAt: '2026-05-19T08:00:00.000Z', + }, + analyzedAt: '2026-05-19T08:01:00.000Z', + modelUsed: 'triage/model', + triageModel: 'triage/model', + analysisModel: 'analysis/model', + triggeredByUserId: testUserId, + correlationId: `correlation-${suffix}`, + }; +} diff --git a/services/security-auto-analysis/src/analysis-start-lifecycle.ts b/services/security-auto-analysis/src/analysis-start-lifecycle.ts new file mode 100644 index 0000000000..5cf8557a6e --- /dev/null +++ b/services/security-auto-analysis/src/analysis-start-lifecycle.ts @@ -0,0 +1,177 @@ +import type { WorkerDb } from '@kilocode/db/client'; +import { security_analysis_queue, security_findings } from '@kilocode/db/schema'; +import { and, eq, inArray, isNull, like, not, or, sql } from 'drizzle-orm'; +import type { AutoAnalysisFailureCode, SecurityFindingAnalysis } from './types.js'; + +export type AnalysisStartLifecycleClaim = + | { + source: 'manual'; + findingId: string; + claimToken: string; + } + | { + source: 'scheduled'; + findingId: string; + queueRowId: string; + claimToken: string; + }; + +export type AnalysisStartLifecycleOutcome = + | { + type: 'triage-only-completed'; + analysis: SecurityFindingAnalysis; + } + | { + type: 'sandbox-running'; + cloudAgentSessionId: string; + kiloSessionId: string; + } + | { + type: 'start-failed'; + errorMessage: string; + queueStatus: 'queued' | 'failed'; + failureCode: AutoAnalysisFailureCode; + incrementAttempt: boolean; + nextRetryAt: string | null; + }; + +class AnalysisStartQueueTransitionRejected extends Error { + constructor() { + super('Analysis start queue transition rejected'); + this.name = 'AnalysisStartQueueTransitionRejected'; + } +} + +export async function transitionAnalysisStartLifecycle( + db: WorkerDb, + params: { + claim: AnalysisStartLifecycleClaim; + outcome: AnalysisStartLifecycleOutcome; + } +): Promise<{ transitioned: boolean }> { + try { + return await db.transaction(async tx => { + const findingRows = await transitionFinding(tx, params.claim, params.outcome); + if (findingRows.length === 0) { + return { transitioned: false }; + } + + const queueRows = await tx + .update(security_analysis_queue) + .set({ + queue_status: queueStatusForOutcome(params.outcome), + attempt_count: shouldIncrementAttempt(params.claim, params.outcome) + ? sql`${security_analysis_queue.attempt_count} + 1` + : security_analysis_queue.attempt_count, + failure_code: params.outcome.type === 'start-failed' ? params.outcome.failureCode : null, + last_error_redacted: + params.outcome.type === 'start-failed' ? params.outcome.errorMessage : null, + next_retry_at: params.outcome.type === 'start-failed' ? params.outcome.nextRetryAt : null, + claimed_at: + params.outcome.type === 'start-failed' && params.outcome.queueStatus === 'queued' + ? null + : security_analysis_queue.claimed_at, + claimed_by_job_id: + params.outcome.type === 'start-failed' && params.outcome.queueStatus === 'queued' + ? null + : security_analysis_queue.claimed_by_job_id, + claim_token: + params.outcome.type === 'start-failed' && params.outcome.queueStatus === 'queued' + ? null + : security_analysis_queue.claim_token, + updated_at: sql`now()`.mapWith(String), + }) + .where( + and( + params.claim.source === 'scheduled' + ? eq(security_analysis_queue.id, params.claim.queueRowId) + : eq(security_analysis_queue.finding_id, params.claim.findingId), + eq(security_analysis_queue.claim_token, params.claim.claimToken), + params.outcome.type === 'start-failed' + ? inArray(security_analysis_queue.queue_status, ['pending', 'running']) + : eq(security_analysis_queue.queue_status, 'pending') + ) + ) + .returning({ id: security_analysis_queue.id }); + + if (queueRows.length === 0) { + throw new AnalysisStartQueueTransitionRejected(); + } + + return { transitioned: true }; + }); + } catch (error) { + if (error instanceof AnalysisStartQueueTransitionRejected) { + return { transitioned: false }; + } + throw error; + } +} + +function queueStatusForOutcome( + outcome: AnalysisStartLifecycleOutcome +): 'queued' | 'running' | 'completed' | 'failed' { + if (outcome.type === 'triage-only-completed') return 'completed'; + if (outcome.type === 'sandbox-running') return 'running'; + return outcome.queueStatus; +} + +function shouldIncrementAttempt( + claim: AnalysisStartLifecycleClaim, + outcome: AnalysisStartLifecycleOutcome +): boolean { + if (outcome.type === 'start-failed') return outcome.incrementAttempt; + return claim.source === 'scheduled'; +} + +function transitionFinding( + tx: Parameters[0]>[0], + claim: AnalysisStartLifecycleClaim, + outcome: AnalysisStartLifecycleOutcome +) { + const ignoredReasonGuard = or( + isNull(security_findings.ignored_reason), + not(like(security_findings.ignored_reason, 'superseded:%')) + ); + + if (outcome.type === 'triage-only-completed') { + return tx + .update(security_findings) + .set({ + analysis_status: 'completed', + analysis: sql`${JSON.stringify(outcome.analysis)}::jsonb`, + analysis_error: null, + analysis_completed_at: sql`now()`.mapWith(String), + updated_at: sql`now()`.mapWith(String), + }) + .where(and(eq(security_findings.id, claim.findingId), ignoredReasonGuard)) + .returning({ id: security_findings.id }); + } + + if (outcome.type === 'start-failed') { + return tx + .update(security_findings) + .set({ + analysis_status: 'failed', + analysis_error: outcome.errorMessage, + analysis_completed_at: sql`now()`.mapWith(String), + updated_at: sql`now()`.mapWith(String), + }) + .where(and(eq(security_findings.id, claim.findingId), ignoredReasonGuard)) + .returning({ id: security_findings.id }); + } + + return tx + .update(security_findings) + .set({ + analysis_status: 'running', + session_id: outcome.cloudAgentSessionId, + cli_session_id: outcome.kiloSessionId, + analysis_started_at: sql`coalesce(${security_findings.analysis_started_at}, now())`.mapWith( + String + ), + updated_at: sql`now()`.mapWith(String), + }) + .where(and(eq(security_findings.id, claim.findingId), ignoredReasonGuard)) + .returning({ id: security_findings.id }); +} diff --git a/services/security-auto-analysis/src/consumer.test.ts b/services/security-auto-analysis/src/consumer.test.ts new file mode 100644 index 0000000000..47a758751b --- /dev/null +++ b/services/security-auto-analysis/src/consumer.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWorkerDb } from '@kilocode/db/client'; +import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; +import { + claimRowsForOwner, + clearOwnerActorResolutionFailure, + getSecurityFindingById, + resolveAutoAnalysisActor, + updateQueueFromPending, +} from './db/queries.js'; +import { InsufficientCreditsError, startSecurityAnalysis } from './launch.js'; +import { consumeOwnerBatch } from './consumer.js'; + +vi.mock('@kilocode/db/client', () => ({ + getWorkerDb: vi.fn(), +})); +vi.mock('./analysis-start-lifecycle.js', () => ({ + transitionAnalysisStartLifecycle: vi.fn(), +})); +vi.mock('./db/queries.js', () => ({ + claimRowsForOwner: vi.fn(), + clearOwnerActorResolutionFailure: vi.fn(), + getSecurityFindingById: vi.fn(), + markOwnerActorResolutionFailure: vi.fn(), + markOwnerCreditFailure: vi.fn(), + resolveAutoAnalysisActor: vi.fn(), + updateQueueFromPending: vi.fn(), +})); +vi.mock('./launch.js', () => ({ + InsufficientCreditsError: class InsufficientCreditsError extends Error {}, + startSecurityAnalysis: vi.fn(), +})); + +const findingId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const queueRowId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const userId = 'user-123'; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getWorkerDb).mockReturnValue({} as never); + vi.mocked(claimRowsForOwner).mockResolvedValue({ + rows: [ + { + id: queueRowId, + finding_id: findingId, + claim_token: 'scheduled-claim-token', + attempt_count: 0, + owned_by_organization_id: null, + owned_by_user_id: userId, + }, + ], + config: { + analysis_mode: 'auto', + auto_analysis_enabled: true, + auto_analysis_min_severity: 'high', + auto_analysis_include_existing: true, + }, + isAgentEnabled: true, + autoAnalysisEnabledAt: '2026-05-19T08:00:00.000Z', + blocked: false, + } as never); + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + created_at: '2026-05-19T08:01:00.000Z', + status: 'open', + severity: 'high', + repo_full_name: 'kilo/repo', + } as never); + vi.mocked(resolveAutoAnalysisActor).mockResolvedValue({ + user: { id: userId, api_token_pepper: null }, + mode: 'owner', + }); + vi.mocked(clearOwnerActorResolutionFailure).mockResolvedValue(undefined); + vi.mocked(updateQueueFromPending).mockResolvedValue({ updated: true, attemptCount: 1 }); + vi.mocked(transitionAnalysisStartLifecycle).mockResolvedValue({ transitioned: true }); + vi.mocked(startSecurityAnalysis).mockResolvedValue({ started: true, triageOnly: false }); +}); + +describe('consumeOwnerBatch scheduled lifecycle handoff', () => { + it('passes the claimed queue row into launch and leaves running settlement to the lifecycle module', async () => { + const message = { + body: { + ownerType: 'user', + ownerId: userId, + dispatchId: 'dispatch-123', + enqueuedAt: '2026-05-19T08:00:00.000Z', + }, + attempts: 1, + ack: vi.fn(), + retry: vi.fn(), + }; + + await consumeOwnerBatch( + { queue: 'security-auto-analysis-owner', messages: [message] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://example' }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-api-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ success: true, token: 'github-token' }), + }, + } as unknown as CloudflareEnv + ); + + expect(startSecurityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + callbackTokenSecret: 'callback-token-secret', + lifecycleClaim: { + source: 'scheduled', + findingId, + queueRowId, + claimToken: 'scheduled-claim-token', + }, + }) + ); + expect(updateQueueFromPending).not.toHaveBeenCalled(); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); + + it('settles retryable scheduled start failures through lifecycle instead of queue-only updates', async () => { + vi.mocked(startSecurityAnalysis).mockResolvedValue({ + started: false, + error: 'prepareSession timed out', + failureNeedsLifecycleTransition: true, + }); + const message = { + body: { + ownerType: 'user', + ownerId: userId, + dispatchId: 'dispatch-456', + enqueuedAt: '2026-05-19T08:00:00.000Z', + }, + attempts: 1, + ack: vi.fn(), + retry: vi.fn(), + }; + + await consumeOwnerBatch( + { queue: 'security-auto-analysis-owner', messages: [message] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://example' }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-api-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ success: true, token: 'github-token' }), + }, + } as unknown as CloudflareEnv + ); + + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + claim: { + source: 'scheduled', + findingId, + queueRowId, + claimToken: 'scheduled-claim-token', + }, + outcome: expect.objectContaining({ + type: 'start-failed', + errorMessage: 'prepareSession timed out', + queueStatus: 'queued', + failureCode: 'NETWORK_TIMEOUT', + incrementAttempt: true, + }), + }) + ); + expect(updateQueueFromPending).not.toHaveBeenCalled(); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); + + it('requeues credit-gated scheduled starts through lifecycle after running promotion', async () => { + vi.mocked(startSecurityAnalysis).mockRejectedValue( + new InsufficientCreditsError('Insufficient credits') + ); + const message = { + body: { + ownerType: 'user', + ownerId: userId, + dispatchId: 'dispatch-credit', + enqueuedAt: '2026-05-19T08:00:00.000Z', + }, + attempts: 1, + ack: vi.fn(), + retry: vi.fn(), + }; + + await consumeOwnerBatch( + { queue: 'security-auto-analysis-owner', messages: [message] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://example' }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-api-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ success: true, token: 'github-token' }), + }, + } as unknown as CloudflareEnv + ); + + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + claim: { + source: 'scheduled', + findingId, + queueRowId, + claimToken: 'scheduled-claim-token', + }, + outcome: expect.objectContaining({ + type: 'start-failed', + errorMessage: 'Insufficient credits', + queueStatus: 'queued', + failureCode: 'INSUFFICIENT_CREDITS', + incrementAttempt: false, + }), + }) + ); + expect(updateQueueFromPending).not.toHaveBeenCalled(); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); +}); diff --git a/services/security-auto-analysis/src/consumer.ts b/services/security-auto-analysis/src/consumer.ts index 5c04578342..6f501ce804 100644 --- a/services/security-auto-analysis/src/consumer.ts +++ b/services/security-auto-analysis/src/consumer.ts @@ -10,6 +10,7 @@ import { updateQueueFromPending, type ClaimedQueueRow, } from './db/queries.js'; +import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; import { logger } from './logger.js'; import { InsufficientCreditsError, startSecurityAnalysis } from './launch.js'; import { @@ -494,42 +495,18 @@ async function processOwnerMessage(params: { nextAuthSecret, internalApiSecret, callbackTokenSecret, + lifecycleClaim: { + source: 'scheduled', + findingId: row.finding_id, + queueRowId: row.id, + claimToken: row.claim_token, + }, }); if (startResult.started) { if (startResult.triageOnly) { - await markQueuePendingState({ - db, - rowId: row.id, - claimToken: row.claim_token, - status: 'completed', - incrementAttempt: true, - logContext: { - jobId, - owner: launchOwner, - findingId: row.finding_id, - attemptCount: row.attempt_count + 1, - actorUserId: actorResolution.user.id, - actorResolutionMode: actorResolution.mode, - }, - }); counters.completed += 1; } else { - await markQueuePendingState({ - db, - rowId: row.id, - claimToken: row.claim_token, - status: 'running', - incrementAttempt: true, - logContext: { - jobId, - owner: launchOwner, - findingId: row.finding_id, - attemptCount: row.attempt_count + 1, - actorUserId: actorResolution.user.id, - actorResolutionMode: actorResolution.mode, - }, - }); counters.launched += 1; } continue; @@ -559,8 +536,31 @@ async function processOwnerMessage(params: { const nextAttemptCount = row.attempt_count + 1; const isRetryable = classification.class === 'retryable'; const terminal = !isRetryable || nextAttemptCount >= AUTO_ANALYSIS_MAX_ATTEMPTS; + const retryAt = terminal ? null : nextRetryAt(nextAttemptCount); - if (terminal) { + if (startResult.failureNeedsLifecycleTransition) { + await transitionAnalysisStartLifecycle(db, { + claim: { + source: 'scheduled', + findingId: row.finding_id, + queueRowId: row.id, + claimToken: row.claim_token, + }, + outcome: { + type: 'start-failed', + errorMessage: startResult.error ?? 'Security analysis start failed', + queueStatus: terminal ? 'failed' : 'queued', + failureCode: classification.code, + incrementAttempt: true, + nextRetryAt: retryAt?.toISOString() ?? null, + }, + }); + if (terminal) { + counters.failed += 1; + } else { + counters.requeued += 1; + } + } else if (terminal) { await markQueuePendingState({ db, rowId: row.id, @@ -588,7 +588,7 @@ async function processOwnerMessage(params: { failureCode: classification.code, errorMessage: startResult.error, incrementAttempt: true, - nextRetryAt: nextRetryAt(nextAttemptCount), + nextRetryAt: retryAt, logContext: { jobId, owner: launchOwner, @@ -607,19 +607,20 @@ async function processOwnerMessage(params: { if (classification.class === 'credit_gated') { await markOwnerCreditFailure(db, catchOwner); - await markQueuePendingState({ - db, - rowId: row.id, - claimToken: row.claim_token, - status: 'queued', - failureCode: classification.code, - errorMessage: error instanceof Error ? error.message : String(error), - nextRetryAt: new Date(Date.now() + 30 * 60 * 1000), - logContext: { - jobId, - owner: catchOwner, + await transitionAnalysisStartLifecycle(db, { + claim: { + source: 'scheduled', findingId: row.finding_id, - attemptCount: row.attempt_count, + queueRowId: row.id, + claimToken: row.claim_token, + }, + outcome: { + type: 'start-failed', + errorMessage: error instanceof Error ? error.message : String(error), + queueStatus: 'queued', + failureCode: classification.code, + incrementAttempt: false, + nextRetryAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }, }); diff --git a/services/security-auto-analysis/src/launch.test.ts b/services/security-auto-analysis/src/launch.test.ts index b2a6319412..5d4894f1cc 100644 --- a/services/security-auto-analysis/src/launch.test.ts +++ b/services/security-auto-analysis/src/launch.test.ts @@ -9,6 +9,7 @@ import { setFindingRunning, tryAcquireAnalysisStartLease, } from './db/queries.js'; +import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; import { buildSecurityAnalysisCallbackTarget, startSecurityAnalysis } from './launch.js'; import { generateApiToken } from './token.js'; import { triageSecurityFinding } from './triage.js'; @@ -22,6 +23,7 @@ vi.mock('./db/queries.js', () => ({ setFindingRunning: vi.fn(), tryAcquireAnalysisStartLease: vi.fn(), })); +vi.mock('./analysis-start-lifecycle.js', () => ({ transitionAnalysisStartLifecycle: vi.fn() })); vi.mock('./token.js', () => ({ generateApiToken: vi.fn() })); vi.mock('./triage.js', () => ({ triageSecurityFinding: vi.fn() })); @@ -86,6 +88,11 @@ function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) internalApiSecret: 'internal-api-secret', callbackTokenSecret: CALLBACK_SECRET, retrySandboxOnly, + lifecycleClaim: { + source: 'manual' as const, + findingId: finding.id, + claimToken: 'manual-claim-token', + }, }; } @@ -149,6 +156,7 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { vi.mocked(setFindingPending).mockResolvedValue(true); vi.mocked(setFindingRunning).mockResolvedValue(true); vi.mocked(clearAnalysisStatus).mockResolvedValue(undefined); + vi.mocked(transitionAnalysisStartLifecycle).mockResolvedValue({ transitioned: true }); }); it('stores scoped callback token instead of raw internal API secret', async () => { @@ -226,7 +234,18 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { finding.id, expect.objectContaining({ triage: existingTriage }) ); - expect(setFindingRunning).toHaveBeenCalledWith({}, finding.id, 'agent-session', 'ses-123'); + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + claim: expect.objectContaining({ source: 'manual', claimToken: 'manual-claim-token' }), + outcome: { + type: 'sandbox-running', + cloudAgentSessionId: 'agent-session', + kiloSessionId: 'ses-123', + }, + }) + ); + expect(setFindingRunning).not.toHaveBeenCalled(); }); it('falls back to full triage when sandbox-only retry has no prior triage', async () => { @@ -244,8 +263,65 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { expect(triageSecurityFinding).toHaveBeenCalledTimes(1); expect(setFindingPending).toHaveBeenCalledWith({}, finding.id, null); - expect(setFindingCompleted).toHaveBeenCalledTimes(1); + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + claim: expect.objectContaining({ source: 'manual', claimToken: 'manual-claim-token' }), + outcome: expect.objectContaining({ type: 'triage-only-completed' }), + }) + ); + expect(setFindingCompleted).not.toHaveBeenCalled(); expect(setFindingFailed).not.toHaveBeenCalled(); expect(clearAnalysisStatus).not.toHaveBeenCalled(); }); + + it('returns failed starts for lifecycle settlement instead of updating findings alone', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); + vi.mocked(triageSecurityFinding).mockResolvedValue(existingTriage); + const cloudAgentFetch = vi.fn().mockResolvedValue( + new Response('upstream unavailable', { + status: 503, + }) + ); + + await expect( + startSecurityAnalysis(createParams(false, cloudAgentFetch as never)) + ).resolves.toEqual({ + started: false, + error: 'upstream unavailable', + failureNeedsLifecycleTransition: true, + }); + + expect(setFindingFailed).not.toHaveBeenCalled(); + }); + + it('returns initiate failures for lifecycle settlement after the running transition', async () => { + vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); + vi.mocked(triageSecurityFinding).mockResolvedValue(existingTriage); + const cloudAgentFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { data: { cloudAgentSessionId: 'agent-session', kiloSessionId: 'ses-123' } }, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce(new Response('initiate unavailable', { status: 503 })); + + await expect( + startSecurityAnalysis(createParams(false, cloudAgentFetch as never)) + ).resolves.toEqual({ + started: false, + error: 'initiate unavailable', + failureNeedsLifecycleTransition: true, + }); + + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + {}, + expect.objectContaining({ outcome: expect.objectContaining({ type: 'sandbox-running' }) }) + ); + expect(setFindingFailed).not.toHaveBeenCalled(); + }); }); diff --git a/services/security-auto-analysis/src/launch.ts b/services/security-auto-analysis/src/launch.ts index c90ef4fa9a..a3ba0fb836 100644 --- a/services/security-auto-analysis/src/launch.ts +++ b/services/security-auto-analysis/src/launch.ts @@ -5,13 +5,14 @@ import { deriveCallbackToken } from '@kilocode/worker-utils'; import { clearAnalysisStatus, getSecurityFindingById, - setFindingCompleted, - setFindingFailed, setFindingPending, - setFindingRunning, tryAcquireAnalysisStartLease, type SecurityFindingRecord, } from './db/queries.js'; +import { + transitionAnalysisStartLifecycle, + type AnalysisStartLifecycleClaim, +} from './analysis-start-lifecycle.js'; import { logger } from './logger.js'; import { generateApiToken } from './token.js'; import { triageSecurityFinding } from './triage.js'; @@ -113,6 +114,7 @@ type StartSecurityAnalysisParams = { internalApiSecret: string; callbackTokenSecret: string; retrySandboxOnly?: boolean; + lifecycleClaim: AnalysisStartLifecycleClaim; }; export function buildSecurityAnalysisCallbackTarget( @@ -149,9 +151,16 @@ export function buildSecurityAnalysisCallbackTarget( }; } +export type StartSecurityAnalysisResult = { + started: boolean; + error?: string; + triageOnly?: boolean; + failureNeedsLifecycleTransition?: boolean; +}; + export async function startSecurityAnalysis( params: StartSecurityAnalysisParams -): Promise<{ started: boolean; error?: string; triageOnly?: boolean }> { +): Promise { const correlationId = randomUUID(); const finding = await getSecurityFindingById(params.db, params.findingId); @@ -207,10 +216,11 @@ export async function startSecurityAnalysis( triggeredByUserId: params.actorUser.id, correlationId, }; - const written = await setFindingCompleted(params.db, params.findingId, triageOnlyAnalysis); - if (!written) { - // Finding was superseded between lease acquisition and completion. - // Clear stale analysis_status so it doesn't count against the concurrency cap. + const transition = await transitionAnalysisStartLifecycle(params.db, { + claim: params.lifecycleClaim, + outcome: { type: 'triage-only-completed', analysis: triageOnlyAnalysis }, + }); + if (!transition.transitioned) { await clearAnalysisStatus(params.db, params.findingId); return { started: false, error: 'Finding was superseded during analysis' }; } @@ -265,28 +275,31 @@ export async function startSecurityAnalysis( if (!prepareResponse.ok) { const errorText = await prepareResponse.text(); - if (!(await setFindingFailed(params.db, params.findingId, errorText))) { - await clearAnalysisStatus(params.db, params.findingId); - } - return { started: false, error: errorText }; + return { + started: false, + error: errorText, + failureNeedsLifecycleTransition: true, + }; } const parsedPrepare = PrepareSessionResponseSchema.safeParse(await prepareResponse.json()); if (!parsedPrepare.success) { - if ( - !(await setFindingFailed( - params.db, - params.findingId, - 'Invalid prepareSession response shape' - )) - ) { - await clearAnalysisStatus(params.db, params.findingId); - } - return { started: false, error: 'Invalid prepareSession response shape' }; + return { + started: false, + error: 'Invalid prepareSession response shape', + failureNeedsLifecycleTransition: true, + }; } const { cloudAgentSessionId, kiloSessionId } = parsedPrepare.data.result.data; - await setFindingRunning(params.db, params.findingId, cloudAgentSessionId, kiloSessionId); + const runningTransition = await transitionAnalysisStartLifecycle(params.db, { + claim: params.lifecycleClaim, + outcome: { type: 'sandbox-running', cloudAgentSessionId, kiloSessionId }, + }); + if (!runningTransition.transitioned) { + await clearAnalysisStatus(params.db, params.findingId); + return { started: false, error: 'Finding was superseded during analysis' }; + } const initiateResponse = await params.env.CLOUD_AGENT_NEXT.fetch( new Request('https://cloud-agent-next/trpc/initiateFromKilocodeSessionV2', { @@ -301,9 +314,6 @@ export async function startSecurityAnalysis( if (!initiateResponse.ok) { const errorText = await initiateResponse.text(); - if (!(await setFindingFailed(params.db, params.findingId, errorText))) { - await clearAnalysisStatus(params.db, params.findingId); - } if (initiateResponse.status === 402) { throw new InsufficientCreditsError(errorText || 'Insufficient credits'); @@ -312,30 +322,22 @@ export async function startSecurityAnalysis( return { started: false, error: errorText, + failureNeedsLifecycleTransition: true, }; } const parsedInitiate = InitiateResponseSchema.safeParse(await initiateResponse.json()); if (!parsedInitiate.success) { - if ( - !(await setFindingFailed( - params.db, - params.findingId, - 'Invalid initiateFromKilocodeSessionV2 response shape' - )) - ) { - await clearAnalysisStatus(params.db, params.findingId); - } return { started: false, error: 'Invalid initiateFromKilocodeSessionV2 response shape', + failureNeedsLifecycleTransition: true, }; } return { started: true, triageOnly: false }; } catch (error) { if (error instanceof InsufficientCreditsError) { - // setFindingFailed already called at the throw site (line 231) throw error; } @@ -346,9 +348,10 @@ export async function startSecurityAnalysis( error: errorMessage, }); - if (!(await setFindingFailed(params.db, params.findingId, errorMessage))) { - await clearAnalysisStatus(params.db, params.findingId); - } - return { started: false, error: errorMessage }; + return { + started: false, + error: errorMessage, + failureNeedsLifecycleTransition: true, + }; } } diff --git a/services/security-auto-analysis/src/manual-analysis.test.ts b/services/security-auto-analysis/src/manual-analysis.test.ts index cda6550403..f90b6c867b 100644 --- a/services/security-auto-analysis/src/manual-analysis.test.ts +++ b/services/security-auto-analysis/src/manual-analysis.test.ts @@ -1,9 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; import { ensureManualAnalysisQueueRow } from './db/queries.js'; -import { startSecurityAnalysis } from './launch.js'; +import { InsufficientCreditsError, startSecurityAnalysis } from './launch.js'; import { processManualAnalysisStart, type ManualAnalysisStartCommand } from './manual-analysis.js'; +vi.mock('./analysis-start-lifecycle.js', () => ({ + transitionAnalysisStartLifecycle: vi.fn(), +})); vi.mock('./launch.js', () => ({ + InsufficientCreditsError: class InsufficientCreditsError extends Error {}, startSecurityAnalysis: vi.fn(), })); @@ -26,6 +31,8 @@ const finding = { beforeEach(() => { vi.mocked(startSecurityAnalysis).mockReset(); + vi.mocked(transitionAnalysisStartLifecycle).mockReset(); + vi.mocked(transitionAnalysisStartLifecycle).mockResolvedValue({ transitioned: true }); }); describe('processManualAnalysisStart', () => { @@ -139,6 +146,7 @@ describe('processManualAnalysisStart', () => { }, NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, INTERNAL_API_SECRET: { get: async () => 'internal-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, } as unknown as CloudflareEnv, command: { ...command, @@ -154,7 +162,13 @@ describe('processManualAnalysisStart', () => { triageModel: 'request/triage', analysisModel: 'request/analysis', analysisMode: 'deep', + callbackTokenSecret: 'callback-token-secret', retrySandboxOnly: true, + lifecycleClaim: expect.objectContaining({ + source: 'manual', + findingId: command.findingId, + claimToken: expect.any(String), + }), }) ); expect(auditRows[0]).toMatchObject({ @@ -166,7 +180,162 @@ describe('processManualAnalysisStart', () => { analysisMode: 'deep', }, }); - expect(execute).toHaveBeenCalledTimes(1); + expect(execute).not.toHaveBeenCalled(); + }); + + it('settles post-lease manual start failures through the lifecycle transition', async () => { + let selectCount = 0; + let insertCount = 0; + const execute = vi.fn().mockResolvedValue({ rows: [] }); + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + if (selectCount === 2) { + return { from: () => ({ where: async () => [{ total: 0 }] }) }; + } + if (selectCount === 3) { + return { + from: () => ({ + where: () => ({ limit: async () => [{ id: 'user-123', api_token_pepper: null }] }), + }), + }; + } + return { + from: () => ({ + where: () => ({ + limit: async () => [{ config: { analysis_mode: 'auto' } }], + }), + }), + }; + }, + insert: () => { + insertCount += 1; + return { + values: () => ({ + onConflictDoUpdate: () => ({ + returning: async () => [{ id: `queue-row-${insertCount}` }], + }), + }), + }; + }, + execute, + }; + vi.mocked(startSecurityAnalysis).mockResolvedValue({ + started: false, + error: 'prepareSession timed out', + failureNeedsLifecycleTransition: true, + }); + + await expect( + processManualAnalysisStart({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ + success: true, + token: 'github-token', + installationId: 'installation-123', + accountLogin: 'kilo', + appType: 'standard', + }), + }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, + } as unknown as CloudflareEnv, + command, + }) + ).resolves.toEqual({ status: 'failed' }); + + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + db, + expect.objectContaining({ + claim: expect.objectContaining({ source: 'manual', findingId: command.findingId }), + outcome: { + type: 'start-failed', + errorMessage: 'prepareSession timed out', + queueStatus: 'failed', + failureCode: 'START_CALL_AMBIGUOUS', + incrementAttempt: false, + nextRetryAt: null, + }, + }) + ); + expect(execute).not.toHaveBeenCalled(); + }); + + it('settles thrown manual credit failures before preserving queue retry behavior', async () => { + let selectCount = 0; + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + if (selectCount === 2) { + return { from: () => ({ where: async () => [{ total: 0 }] }) }; + } + if (selectCount === 3) { + return { + from: () => ({ + where: () => ({ limit: async () => [{ id: 'user-123', api_token_pepper: null }] }), + }), + }; + } + return { + from: () => ({ + where: () => ({ limit: async () => [{ config: { analysis_mode: 'auto' } }] }), + }), + }; + }, + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => ({ returning: async () => [{ id: 'queue-row-credit' }] }), + }), + }), + }; + vi.mocked(startSecurityAnalysis).mockRejectedValue( + new InsufficientCreditsError('Insufficient credits') + ); + + await expect( + processManualAnalysisStart({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ + success: true, + token: 'github-token', + installationId: 'installation-123', + accountLogin: 'kilo', + appType: 'standard', + }), + }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, + } as unknown as CloudflareEnv, + command, + }) + ).rejects.toThrow('Insufficient credits'); + + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + db, + expect.objectContaining({ + claim: expect.objectContaining({ source: 'manual', findingId: command.findingId }), + outcome: { + type: 'start-failed', + errorMessage: 'Insufficient credits', + queueStatus: 'failed', + failureCode: 'INSUFFICIENT_CREDITS', + incrementAttempt: false, + nextRetryAt: null, + }, + }) + ); }); }); diff --git a/services/security-auto-analysis/src/manual-analysis.ts b/services/security-auto-analysis/src/manual-analysis.ts index 6e502f8a40..785efbc240 100644 --- a/services/security-auto-analysis/src/manual-analysis.ts +++ b/services/security-auto-analysis/src/manual-analysis.ts @@ -12,7 +12,8 @@ import { transitionManualAnalysisQueueFromStart, type SecurityFindingRecord, } from './db/queries.js'; -import { startSecurityAnalysis } from './launch.js'; +import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; +import { InsufficientCreditsError, startSecurityAnalysis } from './launch.js'; import { resolveSecurityAgentModels, SECURITY_ANALYSIS_OWNER_CAP, @@ -121,30 +122,76 @@ export async function processManualAnalysisStart(params: { params.env.INTERNAL_API_SECRET.get(), params.env.CALLBACK_TOKEN_SECRET.get(), ]); - const result = await startSecurityAnalysis({ - db: params.db, - env: params.env, - findingId: finding.id, - actorUser: actor, - githubToken: tokenResult.token, - triageModel, - analysisModel, - analysisMode: config.analysis_mode, - organizationId: owner.type === 'org' ? owner.id : undefined, - nextAuthSecret, - internalApiSecret, - callbackTokenSecret, - retrySandboxOnly: params.command.retrySandboxOnly, - }); - const queueStatus = result.started ? (result.triageOnly ? 'completed' : 'running') : 'failed'; - await transitionManualAnalysisQueueFromStart(params.db, { - findingId: finding.id, - claimToken, - status: queueStatus, - failureCode: result.started ? null : 'START_CALL_AMBIGUOUS', - errorMessage: result.error ?? null, - }); - if (!result.started) return { status: 'failed' }; + let result: Awaited>; + try { + result = await startSecurityAnalysis({ + db: params.db, + env: params.env, + findingId: finding.id, + actorUser: actor, + githubToken: tokenResult.token, + triageModel, + analysisModel, + analysisMode: config.analysis_mode, + organizationId: owner.type === 'org' ? owner.id : undefined, + nextAuthSecret, + internalApiSecret, + callbackTokenSecret, + retrySandboxOnly: params.command.retrySandboxOnly, + lifecycleClaim: { + source: 'manual', + findingId: finding.id, + claimToken, + }, + }); + } catch (error) { + if (!(error instanceof InsufficientCreditsError)) throw error; + + await transitionAnalysisStartLifecycle(params.db, { + claim: { + source: 'manual', + findingId: finding.id, + claimToken, + }, + outcome: { + type: 'start-failed', + errorMessage: error.message, + queueStatus: 'failed', + failureCode: 'INSUFFICIENT_CREDITS', + incrementAttempt: false, + nextRetryAt: null, + }, + }); + throw error; + } + if (!result.started) { + if (result.failureNeedsLifecycleTransition) { + await transitionAnalysisStartLifecycle(params.db, { + claim: { + source: 'manual', + findingId: finding.id, + claimToken, + }, + outcome: { + type: 'start-failed', + errorMessage: result.error ?? 'Security analysis start failed', + queueStatus: 'failed', + failureCode: 'START_CALL_AMBIGUOUS', + incrementAttempt: false, + nextRetryAt: null, + }, + }); + } else { + await transitionManualAnalysisQueueFromStart(params.db, { + findingId: finding.id, + claimToken, + status: 'failed', + failureCode: 'START_CALL_AMBIGUOUS', + errorMessage: result.error ?? null, + }); + } + return { status: 'failed' }; + } await params.db.insert(security_audit_log).values({ owned_by_organization_id: finding.owned_by_organization_id, From 113662e297d716d4e17b3c925268e1ea56e5341b Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 19 May 2026 21:50:47 +0200 Subject: [PATCH 07/18] fix(security-agent): unify eligibility and dismissal parsing --- .../services/auto-dismiss-service.test.ts | 22 +++ .../services/auto-dismiss-service.ts | 25 ++- packages/worker-utils/package.json | 4 +- .../src/dependabot-dismissal-target.ts | 22 +++ .../src/security-auto-analysis-policy.test.ts | 164 ++++++++++++++++++ .../src/security-auto-analysis-policy.ts | 61 +++++++ pnpm-lock.yaml | 3 + .../src/consumer.test.ts | 59 +++++++ .../security-auto-analysis/src/consumer.ts | 49 ++---- services/security-sync/package.json | 1 + services/security-sync/src/dismiss.test.ts | 47 +++++ services/security-sync/src/dismiss.ts | 15 +- services/security-sync/src/sync.test.ts | 52 +++++- services/security-sync/src/sync.ts | 101 ++++------- 14 files changed, 495 insertions(+), 130 deletions(-) create mode 100644 packages/worker-utils/src/dependabot-dismissal-target.ts create mode 100644 packages/worker-utils/src/security-auto-analysis-policy.test.ts create mode 100644 packages/worker-utils/src/security-auto-analysis-policy.ts diff --git a/apps/web/src/lib/security-agent/services/auto-dismiss-service.test.ts b/apps/web/src/lib/security-agent/services/auto-dismiss-service.test.ts index b28fe32fcf..832eef0f1e 100644 --- a/apps/web/src/lib/security-agent/services/auto-dismiss-service.test.ts +++ b/apps/web/src/lib/security-agent/services/auto-dismiss-service.test.ts @@ -175,6 +175,16 @@ describe('writebackDependabotDismissal', () => { expect(mockDismissDependabotAlert).not.toHaveBeenCalled(); }); + it('skips partially numeric Dependabot alert IDs', async () => { + mockGetSecurityFindingById.mockResolvedValue(makeFinding({ source_id: '42junk' })); + mockGetIntegrationForOwner.mockResolvedValue(makeIntegration('inst-123')); + mockDismissDependabotAlert.mockResolvedValue(undefined); + + await writebackDependabotDismissal('finding-1', userOwner, 'reason'); + + expect(mockDismissDependabotAlert).not.toHaveBeenCalled(); + }); + it('skips when repo_full_name is invalid', async () => { mockGetSecurityFindingById.mockResolvedValue(makeFinding({ repo_full_name: 'no-slash' })); @@ -183,6 +193,18 @@ describe('writebackDependabotDismissal', () => { expect(mockDismissDependabotAlert).not.toHaveBeenCalled(); }); + it('skips repo_full_name values with extra path segments', async () => { + mockGetSecurityFindingById.mockResolvedValue( + makeFinding({ repo_full_name: 'acme/repo/extra' }) + ); + mockGetIntegrationForOwner.mockResolvedValue(makeIntegration('inst-123')); + mockDismissDependabotAlert.mockResolvedValue(undefined); + + await writebackDependabotDismissal('finding-1', userOwner, 'reason'); + + expect(mockDismissDependabotAlert).not.toHaveBeenCalled(); + }); + it('skips when no GitHub installation ID is available', async () => { mockGetSecurityFindingById.mockResolvedValue(makeFinding()); mockGetIntegrationForOwner.mockResolvedValue( diff --git a/apps/web/src/lib/security-agent/services/auto-dismiss-service.ts b/apps/web/src/lib/security-agent/services/auto-dismiss-service.ts index 0dd1c3ed14..37435c1558 100644 --- a/apps/web/src/lib/security-agent/services/auto-dismiss-service.ts +++ b/apps/web/src/lib/security-agent/services/auto-dismiss-service.ts @@ -23,6 +23,7 @@ import type { Owner } from '@/lib/code-reviews/core'; import type { SecurityFindingAnalysis, SecurityReviewOwner } from '../core/types'; import { sentryLogger } from '@/lib/utils.server'; import { logSecurityAudit, SecurityAuditLogAction } from './audit-log-service'; +import { parseDependabotDismissalTarget } from '@kilocode/worker-utils/dependabot-dismissal-target'; const log = sentryLogger('security-agent:auto-dismiss', 'info'); const logError = sentryLogger('security-agent:auto-dismiss', 'error'); @@ -73,17 +74,11 @@ export async function writebackDependabotDismissal( return; } - const alertNumber = parseInt(finding.source_id, 10); - if (isNaN(alertNumber)) { - return; - } - - const [repoOwner, repoName] = finding.repo_full_name.split('/'); - if (!repoOwner || !repoName) { - logError('Invalid repo_full_name for Dependabot writeback', { - findingId, - repoFullName: finding.repo_full_name, - }); + const target = parseDependabotDismissalTarget({ + sourceId: finding.source_id, + repoFullName: finding.repo_full_name, + }); + if (!target) { return; } @@ -96,14 +91,14 @@ export async function writebackDependabotDismissal( await dismissDependabotAlert( installationId, - repoOwner, - repoName, - alertNumber, + target.repoOwner, + target.repoName, + target.alertNumber, 'not_used', `[Kilo Code auto-dismiss] ${dismissedComment}` ); - log('Wrote back Dependabot dismissal', { findingId, alertNumber }); + log('Wrote back Dependabot dismissal', { findingId, alertNumber: target.alertNumber }); } /** diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index 685e0e8fb3..41c63ab970 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -16,7 +16,9 @@ "./git-url": "./src/git-url.ts", "./callback-token": "./src/callback-token.ts", "./kilo-model-id": "./src/kilo-model-id.ts", - "./cloud-agent-queue-report": "./src/cloud-agent-queue-report.ts" + "./cloud-agent-queue-report": "./src/cloud-agent-queue-report.ts", + "./security-auto-analysis-policy": "./src/security-auto-analysis-policy.ts", + "./dependabot-dismissal-target": "./src/dependabot-dismissal-target.ts" }, "scripts": { "test": "vitest run", diff --git a/packages/worker-utils/src/dependabot-dismissal-target.ts b/packages/worker-utils/src/dependabot-dismissal-target.ts new file mode 100644 index 0000000000..298af86293 --- /dev/null +++ b/packages/worker-utils/src/dependabot-dismissal-target.ts @@ -0,0 +1,22 @@ +export type DependabotDismissalTarget = { + alertNumber: number; + repoOwner: string; + repoName: string; +}; + +export function parseDependabotDismissalTarget(params: { + sourceId: string; + repoFullName: string; +}): DependabotDismissalTarget | null { + const alertNumber = /^\d+$/.test(params.sourceId) + ? Number.parseInt(params.sourceId, 10) + : Number.NaN; + const repoParts = params.repoFullName.split('/'); + const [repoOwner, repoName] = repoParts; + + if (!Number.isSafeInteger(alertNumber) || repoParts.length !== 2 || !repoOwner || !repoName) { + return null; + } + + return { alertNumber, repoOwner, repoName }; +} diff --git a/packages/worker-utils/src/security-auto-analysis-policy.test.ts b/packages/worker-utils/src/security-auto-analysis-policy.test.ts new file mode 100644 index 0000000000..2cb138176d --- /dev/null +++ b/packages/worker-utils/src/security-auto-analysis-policy.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { decideAutoAnalysisEligibility } from './security-auto-analysis-policy.js'; + +describe('decideAutoAnalysisEligibility', () => { + it('treats null severity as low-ranked eligible work at the all threshold', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + findingSeverity: null, + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'all', + autoAnalysisIncludeExisting: false, + }) + ).toEqual({ + eligible: true, + severityRank: 3, + severityWasUnknown: true, + boundarySkipped: false, + }); + }); + + it('rejects pre-enable findings while include-existing stays disabled', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T08:00:00.000Z', + findingStatus: 'open', + findingSeverity: 'high', + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'high', + autoAnalysisIncludeExisting: false, + }) + ).toEqual({ + eligible: false, + severityRank: 1, + severityWasUnknown: false, + boundarySkipped: true, + }); + }); + + it('rejects eligible-severity findings when automatic analysis config is disabled', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + findingSeverity: 'critical', + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: false, + autoAnalysisMinSeverity: 'critical', + autoAnalysisIncludeExisting: false, + }) + ).toEqual({ + eligible: false, + severityRank: 0, + severityWasUnknown: false, + boundarySkipped: false, + }); + }); + + it('rejects eligible-severity findings when the Security Agent config is disabled', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + findingSeverity: 'critical', + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: false, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'critical', + autoAnalysisIncludeExisting: false, + }) + ).toEqual({ + eligible: false, + severityRank: 0, + severityWasUnknown: false, + boundarySkipped: false, + }); + }); + + it('rejects closed findings even when severity and timestamps are eligible', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'fixed', + findingSeverity: 'critical', + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'critical', + autoAnalysisIncludeExisting: false, + }) + ).toEqual({ + eligible: false, + severityRank: 0, + severityWasUnknown: false, + boundarySkipped: false, + }); + }); + + it('requires an auto-analysis enable timestamp before work can launch', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + findingSeverity: 'critical', + autoAnalysisEnabledAt: null, + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'critical', + autoAnalysisIncludeExisting: true, + }) + ).toEqual({ + eligible: false, + severityRank: 0, + severityWasUnknown: false, + boundarySkipped: false, + }); + }); + + it('keeps low-ranked unknown severity below stricter thresholds', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T10:00:00.000Z', + findingStatus: 'open', + findingSeverity: 'unexpected', + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'medium', + autoAnalysisIncludeExisting: false, + }) + ).toEqual({ + eligible: false, + severityRank: 3, + severityWasUnknown: true, + boundarySkipped: false, + }); + }); + + it('keeps pre-enable findings eligible when include-existing is enabled', () => { + expect( + decideAutoAnalysisEligibility({ + findingCreatedAt: '2026-05-18T08:00:00.000Z', + findingStatus: 'open', + findingSeverity: 'high', + autoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'high', + autoAnalysisIncludeExisting: true, + }) + ).toEqual({ + eligible: true, + severityRank: 1, + severityWasUnknown: false, + boundarySkipped: false, + }); + }); +}); diff --git a/packages/worker-utils/src/security-auto-analysis-policy.ts b/packages/worker-utils/src/security-auto-analysis-policy.ts new file mode 100644 index 0000000000..cc19b32373 --- /dev/null +++ b/packages/worker-utils/src/security-auto-analysis-policy.ts @@ -0,0 +1,61 @@ +export type AutoAnalysisMinSeverity = 'critical' | 'high' | 'medium' | 'all'; +export type AutoAnalysisSeverityRank = 0 | 1 | 2 | 3; + +export type AutoAnalysisEligibilityParams = { + findingCreatedAt: string; + findingStatus: string; + findingSeverity: string | null; + autoAnalysisEnabledAt: string | null; + isAgentEnabled: boolean; + autoAnalysisEnabled: boolean; + autoAnalysisMinSeverity: AutoAnalysisMinSeverity; + autoAnalysisIncludeExisting?: boolean; +}; + +export type AutoAnalysisEligibilityDecision = { + eligible: boolean; + severityRank: AutoAnalysisSeverityRank; + severityWasUnknown: boolean; + boundarySkipped: boolean; +}; + +const LOW_SEVERITY_RANK = 3; + +function getSeverityRank(severity: string | null): AutoAnalysisSeverityRank | null { + if (severity === 'critical') return 0; + if (severity === 'high') return 1; + if (severity === 'medium') return 2; + if (severity === 'low') return LOW_SEVERITY_RANK; + return null; +} + +function getMaxSeverityRank(minSeverity: AutoAnalysisMinSeverity): AutoAnalysisSeverityRank { + if (minSeverity === 'critical') return 0; + if (minSeverity === 'high') return 1; + if (minSeverity === 'medium') return 2; + return LOW_SEVERITY_RANK; +} + +export function decideAutoAnalysisEligibility( + params: AutoAnalysisEligibilityParams +): AutoAnalysisEligibilityDecision { + const normalizedSeverityRank = getSeverityRank(params.findingSeverity); + const severityRank = normalizedSeverityRank ?? LOW_SEVERITY_RANK; + const boundarySkipped = + !params.autoAnalysisIncludeExisting && + params.autoAnalysisEnabledAt !== null && + Date.parse(params.findingCreatedAt) < Date.parse(params.autoAnalysisEnabledAt); + + return { + eligible: + params.isAgentEnabled && + params.autoAnalysisEnabled && + params.findingStatus === 'open' && + params.autoAnalysisEnabledAt !== null && + !boundarySkipped && + severityRank <= getMaxSeverityRank(params.autoAnalysisMinSeverity), + severityRank, + severityWasUnknown: normalizedSeverityRank === null, + boundarySkipped, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 000ddfb41c..8d3a1dd970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2477,6 +2477,9 @@ importers: '@kilocode/db': specifier: workspace:* version: link:../../packages/db + '@kilocode/worker-utils': + specifier: workspace:* + version: link:../../packages/worker-utils drizzle-orm: specifier: 0.45.2 version: 0.45.2(@cloudflare/workers-types@4.20260511.1)(@opentelemetry/api@1.9.1)(@types/pg@8.18.0)(bun-types@1.3.14)(kysely@0.29.2)(pg@8.20.0) diff --git a/services/security-auto-analysis/src/consumer.test.ts b/services/security-auto-analysis/src/consumer.test.ts index 47a758751b..f1757f34f5 100644 --- a/services/security-auto-analysis/src/consumer.test.ts +++ b/services/security-auto-analysis/src/consumer.test.ts @@ -224,4 +224,63 @@ describe('consumeOwnerBatch scheduled lifecycle handoff', () => { expect(message.ack).toHaveBeenCalledTimes(1); expect(message.retry).not.toHaveBeenCalled(); }); + + it('launches unknown severity at the all threshold to match sync-side queue eligibility', async () => { + vi.mocked(claimRowsForOwner).mockResolvedValue({ + rows: [ + { + id: queueRowId, + finding_id: findingId, + claim_token: 'scheduled-claim-token', + attempt_count: 0, + owned_by_organization_id: null, + owned_by_user_id: userId, + }, + ], + config: { + analysis_mode: 'auto', + auto_analysis_enabled: true, + auto_analysis_min_severity: 'all', + auto_analysis_include_existing: true, + }, + isAgentEnabled: true, + autoAnalysisEnabledAt: '2026-05-19T08:00:00.000Z', + blocked: false, + } as never); + vi.mocked(getSecurityFindingById).mockResolvedValue({ + id: findingId, + created_at: '2026-05-19T08:01:00.000Z', + status: 'open', + severity: 'unexpected', + repo_full_name: 'kilo/repo', + } as never); + const message = { + body: { + ownerType: 'user', + ownerId: userId, + dispatchId: 'dispatch-unknown-severity', + enqueuedAt: '2026-05-19T08:00:00.000Z', + }, + attempts: 1, + ack: vi.fn(), + retry: vi.fn(), + }; + + await consumeOwnerBatch( + { queue: 'security-auto-analysis-owner', messages: [message] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://example' }, + NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, + INTERNAL_API_SECRET: { get: async () => 'internal-api-secret' }, + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ success: true, token: 'github-token' }), + }, + } as unknown as CloudflareEnv + ); + + expect(startSecurityAnalysis).toHaveBeenCalledTimes(1); + expect(updateQueueFromPending).not.toHaveBeenCalled(); + expect(message.ack).toHaveBeenCalledTimes(1); + expect(message.retry).not.toHaveBeenCalled(); + }); }); diff --git a/services/security-auto-analysis/src/consumer.ts b/services/security-auto-analysis/src/consumer.ts index 6f501ce804..63eafbd896 100644 --- a/services/security-auto-analysis/src/consumer.ts +++ b/services/security-auto-analysis/src/consumer.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; +import { decideAutoAnalysisEligibility } from '@kilocode/worker-utils/security-auto-analysis-policy'; import { claimRowsForOwner, clearOwnerActorResolutionFailure, @@ -148,23 +149,6 @@ function ownerFromQueueRow(row: ClaimedQueueRow): QueueOwner | null { return null; } -function getSeverityRankForAutoAnalysis(severity: string | null): number | null { - if (severity === 'critical') return 0; - if (severity === 'high') return 1; - if (severity === 'medium') return 2; - if (severity === 'low') return 3; - return null; -} - -function maxSeverityRankForThreshold( - minSeverity: SecurityAgentConfig['auto_analysis_min_severity'] -): number { - if (minSeverity === 'critical') return 0; - if (minSeverity === 'high') return 1; - if (minSeverity === 'medium') return 2; - return 3; -} - function isEligibleForAutoLaunch(params: { findingCreatedAt: string; findingStatus: string; @@ -173,27 +157,16 @@ function isEligibleForAutoLaunch(params: { config: SecurityAgentConfig; isAgentEnabled: boolean; }): boolean { - if (!params.isAgentEnabled || !params.config.auto_analysis_enabled) { - return false; - } - if (params.findingStatus !== 'open') { - return false; - } - if (!params.autoAnalysisEnabledAt) { - return false; - } - if ( - !params.config.auto_analysis_include_existing && - Date.parse(params.findingCreatedAt) < Date.parse(params.autoAnalysisEnabledAt) - ) { - return false; - } - - // Treat null/unknown severity as low (rank 3) so these findings are not - // silently skipped. They still respect the severity threshold. - const severityRank = getSeverityRankForAutoAnalysis(params.findingSeverity) ?? 3; - - return severityRank <= maxSeverityRankForThreshold(params.config.auto_analysis_min_severity); + return decideAutoAnalysisEligibility({ + findingCreatedAt: params.findingCreatedAt, + findingStatus: params.findingStatus, + findingSeverity: params.findingSeverity, + autoAnalysisEnabledAt: params.autoAnalysisEnabledAt, + isAgentEnabled: params.isAgentEnabled, + autoAnalysisEnabled: params.config.auto_analysis_enabled, + autoAnalysisMinSeverity: params.config.auto_analysis_min_severity, + autoAnalysisIncludeExisting: params.config.auto_analysis_include_existing, + }).eligible; } function nextRetryAt(attemptCount: number): Date { diff --git a/services/security-sync/package.json b/services/security-sync/package.json index 9a7f95ab4f..53e1d6c669 100644 --- a/services/security-sync/package.json +++ b/services/security-sync/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@kilocode/db": "workspace:*", + "@kilocode/worker-utils": "workspace:*", "drizzle-orm": "catalog:", "zod": "catalog:" }, diff --git a/services/security-sync/src/dismiss.test.ts b/services/security-sync/src/dismiss.test.ts index a543c5f9ea..45da17e61e 100644 --- a/services/security-sync/src/dismiss.test.ts +++ b/services/security-sync/src/dismiss.test.ts @@ -122,6 +122,53 @@ describe('processSecurityFindingDismissal', () => { expect(auditRows).toHaveLength(0); }); + it('dismisses non-Dependabot findings locally without upstream writeback', async () => { + const { db, updates, auditRows } = createDb({ ...finding, source: 'pnpm_audit' }); + const getToken = vi.fn().mockResolvedValue('github-token'); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken } as unknown as GitTokenService, + message: createMessage(), + }) + ).resolves.toEqual({ dismissed: true, findingSource: 'pnpm_audit' }); + + expect(getToken).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'owner@example.com', + }); + expect(auditRows[0]).toMatchObject({ + action: 'security.finding.dismissed', + metadata: { source: 'pnpm_audit' }, + }); + }); + + it('leaves already ignored findings untouched', async () => { + const { db, updates, auditRows } = createDb({ ...finding, status: 'ignored' }); + const getToken = vi.fn().mockResolvedValue('github-token'); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken } as unknown as GitTokenService, + message: createMessage(), + }) + ).resolves.toEqual({ dismissed: false, findingSource: 'dependabot' }); + + expect(getToken).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(updates).toHaveLength(0); + expect(auditRows).toHaveLength(0); + }); + it('ignores dismissal commands for findings owned by another tenant', async () => { const { db, updates, auditRows } = createDb({ ...finding, diff --git a/services/security-sync/src/dismiss.ts b/services/security-sync/src/dismiss.ts index fa5060f439..f6c4422ac4 100644 --- a/services/security-sync/src/dismiss.ts +++ b/services/security-sync/src/dismiss.ts @@ -1,6 +1,7 @@ import type { WorkerDb } from '@kilocode/db/client'; import { security_audit_log, security_findings } from '@kilocode/db/schema'; import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { parseDependabotDismissalTarget } from '@kilocode/worker-utils/dependabot-dismissal-target'; import { eq, sql } from 'drizzle-orm'; import type { SecurityDismissMessage } from './index.js'; @@ -57,13 +58,12 @@ export async function processSecurityFindingDismissal(params: { } if (finding.source === 'dependabot') { - const alertNumber = /^\d+$/.test(finding.source_id) - ? Number.parseInt(finding.source_id, 10) - : Number.NaN; - const repoParts = finding.repo_full_name.split('/'); - const [repoOwner, repoName] = repoParts; + const target = parseDependabotDismissalTarget({ + sourceId: finding.source_id, + repoFullName: finding.repo_full_name, + }); - if (!Number.isSafeInteger(alertNumber) || repoParts.length !== 2 || !repoOwner || !repoName) { + if (!target) { console.warn('Dependabot dismissal skipped because source metadata is invalid', { runId: params.message.runId, findingId: params.message.findingId, @@ -73,7 +73,8 @@ export async function processSecurityFindingDismissal(params: { const token = await params.gitTokenService.getToken(params.message.installationId); const response = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/dependabot/alerts/${alertNumber}`, + `https://api.github.com/repos/${target.repoOwner}/${target.repoName}/dependabot/alerts/${target.alertNumber}`, + { method: 'PATCH', headers: { diff --git a/services/security-sync/src/sync.test.ts b/services/security-sync/src/sync.test.ts index ff6fbb950c..a15ad251b5 100644 --- a/services/security-sync/src/sync.test.ts +++ b/services/security-sync/src/sync.test.ts @@ -58,7 +58,7 @@ describe('Worker auto-analysis queue sync', () => { autoAnalysisEnabled: true, autoAnalysisMinSeverity: 'all', }) - ).toEqual({ eligible: false, severityRank: null }); + ).toEqual({ eligible: true, severityRank: 3 }); }); it('enqueues eligible findings for Worker-owned automatic analysis', async () => { @@ -110,4 +110,54 @@ describe('Worker auto-analysis queue sync', () => { severity_rank: 0, }); }); + + it('enqueues unknown severity at the all threshold using the durable low queue rank', async () => { + const inserted: unknown[] = []; + const tx = { + update: () => ({ + set: () => ({ + where: async () => undefined, + }), + }), + insert: () => ({ + values: (values: unknown) => ({ + onConflictDoNothing: () => ({ + returning: async () => { + inserted.push(values); + return [{ id: 'queue-row' }]; + }, + }), + }), + }), + }; + const db = { + transaction: async (callback: (transaction: typeof tx) => Promise) => callback(tx), + }; + + await expect( + syncAutoAnalysisQueueForFinding(db as never, { + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + findingId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + findingCreatedAt: '2026-05-18T10:00:00.000Z', + previousStatus: null, + currentStatus: 'open', + severity: 'unexpected', + isAgentEnabled: true, + autoAnalysisEnabled: true, + autoAnalysisMinSeverity: 'all', + ownerAutoAnalysisEnabledAt: '2026-05-18T09:00:00.000Z', + }) + ).resolves.toEqual({ + enqueueCount: 1, + eligibleCount: 1, + boundarySkipCount: 0, + unknownSeverityCount: 1, + }); + expect(inserted[0]).toMatchObject({ + finding_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + queue_status: 'queued', + severity_rank: 3, + }); + }); }); diff --git a/services/security-sync/src/sync.ts b/services/security-sync/src/sync.ts index bd68e313ca..bc98979395 100644 --- a/services/security-sync/src/sync.ts +++ b/services/security-sync/src/sync.ts @@ -17,6 +17,10 @@ import { security_audit_log, } from '@kilocode/db/schema'; import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { + decideAutoAnalysisEligibility, + type AutoAnalysisMinSeverity, +} from '@kilocode/worker-utils/security-auto-analysis-policy'; const SecurityFindingSource = { DEPENDABOT: 'dependabot' } as const; @@ -93,8 +97,6 @@ type ParsedSecurityFinding = { dependency_scope: 'development' | 'runtime' | null; }; -type AutoAnalysisMinSeverity = 'critical' | 'high' | 'medium' | 'all'; - type SecurityAgentConfig = { sla_critical_days: number; sla_high_days: number; @@ -620,26 +622,6 @@ type AutoAnalysisQueueSyncResult = { }; const AUTO_ANALYSIS_REOPEN_REQUEUE_CAP = 2; -const severityRankBySeverity = { - critical: 0, - high: 1, - medium: 2, - low: 3, -} satisfies Record; - -function minSeverityToMaxRank(minSeverity: AutoAnalysisMinSeverity): number { - switch (minSeverity) { - case 'critical': - return severityRankBySeverity.critical; - case 'high': - return severityRankBySeverity.high; - case 'medium': - return severityRankBySeverity.medium; - case 'all': - return severityRankBySeverity.low; - } -} - export function isFindingEligibleForAutoAnalysis(params: { findingCreatedAt: string; findingStatus: string; @@ -649,32 +631,19 @@ export function isFindingEligibleForAutoAnalysis(params: { autoAnalysisEnabled: boolean; autoAnalysisMinSeverity: AutoAnalysisMinSeverity; autoAnalysisIncludeExisting?: boolean; -}): { eligible: boolean; severityRank: number | null } { - const severityRank = securitySeveritySchema.safeParse(params.severity); - const normalizedSeverityRank = severityRank.success - ? severityRankBySeverity[severityRank.data] - : null; - if (!params.isAgentEnabled || !params.autoAnalysisEnabled) { - return { eligible: false, severityRank: normalizedSeverityRank }; - } - if (params.findingStatus !== SecurityFindingStatus.OPEN) { - return { eligible: false, severityRank: normalizedSeverityRank }; - } - if (!params.ownerAutoAnalysisEnabledAt) { - return { eligible: false, severityRank: normalizedSeverityRank }; - } - if ( - !params.autoAnalysisIncludeExisting && - Date.parse(params.findingCreatedAt) < Date.parse(params.ownerAutoAnalysisEnabledAt) - ) { - return { eligible: false, severityRank: normalizedSeverityRank }; - } - return { - eligible: - normalizedSeverityRank !== null && - normalizedSeverityRank <= minSeverityToMaxRank(params.autoAnalysisMinSeverity), - severityRank: normalizedSeverityRank, - }; +}): { eligible: boolean; severityRank: number } { + const decision = decideAutoAnalysisEligibility({ + findingCreatedAt: params.findingCreatedAt, + findingStatus: params.findingStatus, + findingSeverity: params.severity, + autoAnalysisEnabledAt: params.ownerAutoAnalysisEnabledAt, + isAgentEnabled: params.isAgentEnabled, + autoAnalysisEnabled: params.autoAnalysisEnabled, + autoAnalysisMinSeverity: params.autoAnalysisMinSeverity, + autoAnalysisIncludeExisting: params.autoAnalysisIncludeExisting, + }); + + return { eligible: decision.eligible, severityRank: decision.severityRank }; } export async function syncAutoAnalysisQueueForFinding( @@ -693,37 +662,33 @@ export async function syncAutoAnalysisQueueForFinding( autoAnalysisIncludeExisting?: boolean; } ): Promise { - const { eligible, severityRank } = isFindingEligibleForAutoAnalysis({ + const decision = decideAutoAnalysisEligibility({ findingCreatedAt: params.findingCreatedAt, findingStatus: params.currentStatus, - severity: params.severity, - ownerAutoAnalysisEnabledAt: params.ownerAutoAnalysisEnabledAt, + findingSeverity: params.severity, + autoAnalysisEnabledAt: params.ownerAutoAnalysisEnabledAt, isAgentEnabled: params.isAgentEnabled, autoAnalysisEnabled: params.autoAnalysisEnabled, autoAnalysisMinSeverity: params.autoAnalysisMinSeverity, autoAnalysisIncludeExisting: params.autoAnalysisIncludeExisting, }); - const boundarySkip = - !params.autoAnalysisIncludeExisting && - params.ownerAutoAnalysisEnabledAt != null && - Date.parse(params.findingCreatedAt) < Date.parse(params.ownerAutoAnalysisEnabledAt); - const unknownSeverityCount = severityRank == null ? 1 : 0; + const { eligible, severityRank } = decision; + const boundarySkip = decision.boundarySkipped; + const unknownSeverityCount = decision.severityWasUnknown ? 1 : 0; let enqueueCount = 0; const ownedByOrganizationId = isOrgOwner(params.owner) ? params.owner.organizationId : null; const ownedByUserId = isOrgOwner(params.owner) ? null : params.owner.userId; await db.transaction(async tx => { - if (severityRank != null) { - await tx - .update(security_analysis_queue) - .set({ severity_rank: severityRank, updated_at: sql`now()` }) - .where( - and( - eq(security_analysis_queue.finding_id, params.findingId), - eq(security_analysis_queue.queue_status, 'queued') - ) - ); - } + await tx + .update(security_analysis_queue) + .set({ severity_rank: severityRank, updated_at: sql`now()` }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + eq(security_analysis_queue.queue_status, 'queued') + ) + ); if (!eligible) { await tx @@ -801,7 +766,7 @@ export async function syncAutoAnalysisQueueForFinding( owned_by_organization_id: ownedByOrganizationId, owned_by_user_id: ownedByUserId, queue_status: 'queued', - severity_rank: severityRank ?? severityRankBySeverity.low, + severity_rank: severityRank, queued_at: sql`now()`, updated_at: sql`now()`, }) From 4fb5333815cc70490020311d5a1ae872267d4491 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 19 May 2026 21:51:06 +0200 Subject: [PATCH 08/18] fix(security-agent): settle callbacks and stale queue rows --- .../[findingId]/route.ts | 2 + services/security-auto-analysis/README.md | 6 +- ...alysis-start-lifecycle.integration.test.ts | 207 +++++++++++- .../src/analysis-start-lifecycle.ts | 147 +++++++++ .../src/auto-dismiss.test.ts | 301 ++++++++++++++++++ .../src/auto-dismiss.ts | 13 +- .../src/callbacks.test.ts | 213 ++++++++++++- .../security-auto-analysis/src/callbacks.ts | 101 +++--- .../src/db/queries.integration.test.ts | 222 +++++++++++++ .../src/db/queries.test.ts | 11 +- .../security-auto-analysis/src/db/queries.ts | 49 ++- .../security-auto-analysis/src/launch.test.ts | 150 ++++++++- services/security-auto-analysis/src/launch.ts | 8 + .../src/manual-analysis.test.ts | 96 ++++++ .../security-auto-analysis/wrangler.jsonc | 14 +- 15 files changed, 1469 insertions(+), 71 deletions(-) diff --git a/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts b/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts index 22a496acd9..684139d1c5 100644 --- a/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts +++ b/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts @@ -32,6 +32,8 @@ import { DEFAULT_SECURITY_AGENT_TRIAGE_MODEL, } from '@/lib/security-agent/core/constants'; +// Compatibility-only callback ingress retained for explicit rollback routing. +// Durable default ingress lives in the security-auto-analysis Worker. const log = sentryLogger('security-agent:callback', 'info'); const warn = sentryLogger('security-agent:callback', 'warning'); const logError = sentryLogger('security-agent:callback', 'error'); diff --git a/services/security-auto-analysis/README.md b/services/security-auto-analysis/README.md index 2511550eb1..10c482c669 100644 --- a/services/security-auto-analysis/README.md +++ b/services/security-auto-analysis/README.md @@ -65,7 +65,7 @@ pnpm --filter cloudflare-security-auto-analysis exec wrangler queues list - `pending` is stale after 15 minutes - `running` is stale after 2 hours -> **Note:** The dispatcher reconciles stale rows before enqueueing due owners: stale `pending` rows return to `queued`, and stale `running` rows become terminal `failed` rows with `RUN_LOST`. Diagnostic queries below remain useful for verification and incident review. +> **Note:** The dispatcher reconciles stale rows before enqueueing due owners: queue rows first heal to already-advanced finding states, remaining stale `pending` rows return to `queued`, and stale `running` rows become terminal `failed` rows with `RUN_LOST` only while the finding still reports `running`. Diagnostic queries below remain useful for verification and incident review. ### Failure codes @@ -205,8 +205,8 @@ Do not clear the block until credits are restored. After top-up, clear the block **Callback routing:** -- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=worker` targets `${SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL}/internal/security-analysis-callback/:findingId`; base URL must be reachable from `cloud-agent-next`. -- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=web` targets `${SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL}/api/internal/security-analysis-callback/:findingId`; this is default callback path and keeps `cloud-agent-next` domain-blind. +- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=worker` is the default and targets `${SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL}/internal/security-analysis-callback/:findingId`; base URL must be reachable from `cloud-agent-next`. Worker ingress validates, enqueues callback finalization, then returns `202`. +- `SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE=web` targets `${SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL}/api/internal/security-analysis-callback/:findingId`; this is compatibility-only rollback routing while legacy callback traffic drains, not the durable default. **Owner-scoped stop** (surgical): diff --git a/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts b/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts index ad2b718c2b..acb5467da9 100644 --- a/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts +++ b/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts @@ -3,7 +3,10 @@ import { randomUUID } from 'crypto'; import { createDrizzleClient } from '@kilocode/db/client'; import { kilocode_users, security_analysis_queue, security_findings } from '@kilocode/db/schema'; import { eq, inArray } from 'drizzle-orm'; -import { transitionAnalysisStartLifecycle } from './analysis-start-lifecycle.js'; +import { + transitionAnalysisCallbackLifecycle, + transitionAnalysisStartLifecycle, +} from './analysis-start-lifecycle.js'; import type { SecurityFindingAnalysis } from './types.js'; const connectionString = @@ -82,6 +85,208 @@ describe('analysis start lifecycle durable transitions', () => { expect(queueRows).toEqual([{ status: 'completed' }]); }); + it('terminalizes completed callbacks with queue and finding state settled together', async () => { + const findingId = await insertFinding('callback-completed', 'running'); + await insertQueueClaim({ + findingId, + claimToken: 'callback-completed-claim', + jobId: 'callback-completed-job', + queueStatus: 'running', + }); + const analysis = createAnalysis('callback-completed'); + + await expect( + transitionAnalysisCallbackLifecycle(client.db as never, { + findingId, + outcome: { + type: 'completed', + analysis, + }, + }) + ).resolves.toEqual({ status: 'completed' }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysis: security_findings.analysis, + }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([ + expect.objectContaining({ + analysisStatus: 'completed', + analysis: expect.objectContaining({ correlationId: analysis.correlationId }), + }), + ]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ status: 'completed', failureCode: null }]); + }); + + it('terminalizes failed callbacks with queue and finding failure state settled together', async () => { + const findingId = await insertFinding('callback-failed', 'running'); + await insertQueueClaim({ + findingId, + claimToken: 'callback-failed-claim', + jobId: 'callback-failed-job', + queueStatus: 'running', + }); + + await expect( + transitionAnalysisCallbackLifecycle(client.db as never, { + findingId, + outcome: { + type: 'failed', + errorMessage: 'upstream 503', + failureCode: 'UPSTREAM_5XX', + }, + }) + ).resolves.toEqual({ status: 'failed' }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysisError: security_findings.analysis_error, + }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([{ analysisStatus: 'failed', analysisError: 'upstream 503' }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + lastError: security_analysis_queue.last_error_redacted, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([ + { status: 'failed', failureCode: 'UPSTREAM_5XX', lastError: 'upstream 503' }, + ]); + }); + + it('clears superseded callback capacity while settling its queue row', async () => { + const findingId = await insertFinding('callback-superseded', 'running'); + await client.db + .update(security_findings) + .set({ ignored_reason: 'superseded:canonical-finding' }) + .where(eq(security_findings.id, findingId)); + await insertQueueClaim({ + findingId, + claimToken: 'callback-superseded-claim', + jobId: 'callback-superseded-job', + queueStatus: 'running', + }); + + await expect( + transitionAnalysisCallbackLifecycle(client.db as never, { + findingId, + outcome: { type: 'superseded' }, + }) + ).resolves.toEqual({ status: 'superseded' }); + + const findingRows = await client.db + .select({ analysisStatus: security_findings.analysis_status }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([{ analysisStatus: null }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ status: 'completed', failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE' }]); + }); + + it('settles completion races that find the callback superseded at terminal write time', async () => { + const findingId = await insertFinding('callback-superseded-completion-race', 'running'); + await client.db + .update(security_findings) + .set({ ignored_reason: 'superseded:replacement-finding' }) + .where(eq(security_findings.id, findingId)); + await insertQueueClaim({ + findingId, + claimToken: 'callback-superseded-completion-race-claim', + jobId: 'callback-superseded-completion-race-job', + queueStatus: 'running', + }); + + await expect( + transitionAnalysisCallbackLifecycle(client.db as never, { + findingId, + outcome: { + type: 'completed', + analysis: createAnalysis('callback-superseded-completion-race'), + }, + }) + ).resolves.toEqual({ status: 'superseded' }); + + const findingRows = await client.db + .select({ analysisStatus: security_findings.analysis_status }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([{ analysisStatus: null }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ status: 'completed', failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE' }]); + }); + + it('heals stale running queue state on retried already-terminal completed callbacks', async () => { + const findingId = await insertFinding('callback-partial-completion', 'running'); + await client.db + .update(security_findings) + .set({ analysis_status: 'completed' }) + .where(eq(security_findings.id, findingId)); + await insertQueueClaim({ + findingId, + claimToken: 'callback-partial-completion-claim', + jobId: 'callback-partial-completion-job', + queueStatus: 'running', + }); + + await expect( + transitionAnalysisCallbackLifecycle(client.db as never, { + findingId, + outcome: { + type: 'already-terminal', + findingStatus: 'completed', + failureCode: null, + errorMessage: null, + }, + }) + ).resolves.toEqual({ status: 'already-terminal' }); + + const findingRows = await client.db + .select({ analysisStatus: security_findings.analysis_status }) + .from(security_findings) + .where(eq(security_findings.id, findingId)); + expect(findingRows).toEqual([{ analysisStatus: 'completed' }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, findingId)); + expect(queueRows).toEqual([{ status: 'completed', failureCode: null }]); + }); + it('promotes scheduled sandbox starts to running without leaving the queue pending', async () => { const findingId = await insertFinding('scheduled-sandbox-running'); const queueRowId = await insertQueueClaim({ diff --git a/services/security-auto-analysis/src/analysis-start-lifecycle.ts b/services/security-auto-analysis/src/analysis-start-lifecycle.ts index 5cf8557a6e..724ac45de9 100644 --- a/services/security-auto-analysis/src/analysis-start-lifecycle.ts +++ b/services/security-auto-analysis/src/analysis-start-lifecycle.ts @@ -35,6 +35,26 @@ export type AnalysisStartLifecycleOutcome = nextRetryAt: string | null; }; +export type AnalysisCallbackLifecycleOutcome = + | { + type: 'completed'; + analysis: SecurityFindingAnalysis; + } + | { + type: 'failed'; + errorMessage: string; + failureCode: AutoAnalysisFailureCode; + } + | { + type: 'superseded'; + } + | { + type: 'already-terminal'; + findingStatus: 'completed' | 'failed'; + failureCode: AutoAnalysisFailureCode | null; + errorMessage: string | null; + }; + class AnalysisStartQueueTransitionRejected extends Error { constructor() { super('Analysis start queue transition rejected'); @@ -42,6 +62,133 @@ class AnalysisStartQueueTransitionRejected extends Error { } } +export async function transitionAnalysisCallbackLifecycle( + db: WorkerDb, + params: { + findingId: string; + outcome: AnalysisCallbackLifecycleOutcome; + } +): Promise<{ status: 'completed' | 'failed' | 'superseded' | 'already-terminal' }> { + return db.transaction(async tx => { + if (params.outcome.type === 'already-terminal') { + await tx + .update(security_analysis_queue) + .set({ + queue_status: params.outcome.findingStatus, + failure_code: + params.outcome.findingStatus === 'completed' ? null : params.outcome.failureCode, + last_error_redacted: + params.outcome.findingStatus === 'completed' ? null : params.outcome.errorMessage, + updated_at: sql`now()`.mapWith(String), + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + inArray(security_analysis_queue.queue_status, ['pending', 'running']) + ) + ); + return { status: 'already-terminal' }; + } + + if (params.outcome.type === 'superseded') { + await tx + .update(security_findings) + .set({ + analysis_status: null, + updated_at: sql`now()`.mapWith(String), + }) + .where(eq(security_findings.id, params.findingId)); + await tx + .update(security_analysis_queue) + .set({ + queue_status: 'completed', + failure_code: 'SKIPPED_NO_LONGER_ELIGIBLE', + last_error_redacted: null, + updated_at: sql`now()`.mapWith(String), + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + inArray(security_analysis_queue.queue_status, ['pending', 'running']) + ) + ); + return { status: 'superseded' }; + } + + const findingRows = await tx + .update(security_findings) + .set( + params.outcome.type === 'completed' + ? { + analysis_status: 'completed', + analysis: sql`${JSON.stringify(params.outcome.analysis)}::jsonb`, + analysis_error: null, + analysis_completed_at: sql`now()`.mapWith(String), + updated_at: sql`now()`.mapWith(String), + } + : { + analysis_status: 'failed', + analysis_error: params.outcome.errorMessage, + analysis_completed_at: sql`now()`.mapWith(String), + updated_at: sql`now()`.mapWith(String), + } + ) + .where( + and( + eq(security_findings.id, params.findingId), + or( + isNull(security_findings.ignored_reason), + not(like(security_findings.ignored_reason, 'superseded:%')) + ) + ) + ) + .returning({ id: security_findings.id }); + + if (findingRows.length === 0) { + await tx + .update(security_findings) + .set({ + analysis_status: null, + updated_at: sql`now()`.mapWith(String), + }) + .where(eq(security_findings.id, params.findingId)); + await tx + .update(security_analysis_queue) + .set({ + queue_status: 'completed', + failure_code: 'SKIPPED_NO_LONGER_ELIGIBLE', + last_error_redacted: null, + updated_at: sql`now()`.mapWith(String), + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + inArray(security_analysis_queue.queue_status, ['pending', 'running']) + ) + ); + return { status: 'superseded' }; + } + + await tx + .update(security_analysis_queue) + .set({ + queue_status: params.outcome.type === 'completed' ? 'completed' : 'failed', + failure_code: params.outcome.type === 'completed' ? null : params.outcome.failureCode, + last_error_redacted: + params.outcome.type === 'completed' ? null : params.outcome.errorMessage, + updated_at: sql`now()`.mapWith(String), + }) + .where( + and( + eq(security_analysis_queue.finding_id, params.findingId), + inArray(security_analysis_queue.queue_status, ['pending', 'running']) + ) + ); + + return { status: params.outcome.type }; + }); +} + export async function transitionAnalysisStartLifecycle( db: WorkerDb, params: { diff --git a/services/security-auto-analysis/src/auto-dismiss.test.ts b/services/security-auto-analysis/src/auto-dismiss.test.ts index bc4497a49c..d1b6de4fed 100644 --- a/services/security-auto-analysis/src/auto-dismiss.test.ts +++ b/services/security-auto-analysis/src/auto-dismiss.test.ts @@ -83,6 +83,307 @@ describe('maybeAutoDismissCompletedAnalysis', () => { }); }); + it('keeps automatic dismissal state and audit when upstream writeback fails', async () => { + let selectCount = 0; + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount += 1; + return selectCount === 1 + ? [{ config: { auto_dismiss_enabled: true } }] + : [{ installationId: 'installation-123' }]; + }, + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 503 })); + vi.stubGlobal('fetch', fetchSpy); + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, + } as unknown as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + source: 'dependabot', + source_id: '42', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + correlationId: 'correlation-writeback-503', + sandboxAnalysis: { + isExploitable: false, + exploitabilityReasoning: 'Dependency is not reachable.', + usageLocations: [], + suggestedFix: 'Upgrade', + suggestedAction: 'dismiss', + summary: 'Not exploitable', + rawMarkdown: '# Not exploitable', + analysisAt: '2026-05-18T10:00:00.000Z', + }, + }, + }); + + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-sandbox', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(auditRows[0]).toMatchObject({ + action: 'security.finding.auto_dismissed', + resource_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + metadata: { correlationId: 'correlation-writeback-503', dismissSource: 'sandbox' }, + }); + }); + + it('keeps automatic dismissal durable while skipping partially numeric Dependabot alert IDs', async () => { + let selectCount = 0; + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount += 1; + return selectCount === 1 + ? [{ config: { auto_dismiss_enabled: true } }] + : [{ installationId: 'installation-123' }]; + }, + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, + } as unknown as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + source: 'dependabot', + source_id: '42junk', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + correlationId: 'correlation-partial-source', + sandboxAnalysis: { + isExploitable: false, + exploitabilityReasoning: 'Dependency is not reachable.', + usageLocations: [], + suggestedFix: 'Upgrade', + suggestedAction: 'dismiss', + summary: 'Not exploitable', + rawMarkdown: '# Not exploitable', + analysisAt: '2026-05-18T10:00:00.000Z', + }, + }, + }); + + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-sandbox', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(auditRows[0]).toMatchObject({ + action: 'security.finding.auto_dismissed', + resource_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + metadata: { correlationId: 'correlation-partial-source', dismissSource: 'sandbox' }, + }); + }); + + it('keeps automatic dismissal durable while skipping malformed Dependabot repo names', async () => { + let selectCount = 0; + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount += 1; + return selectCount === 1 + ? [{ config: { auto_dismiss_enabled: true } }] + : [{ installationId: 'installation-123' }]; + }, + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, + } as unknown as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + source: 'dependabot', + source_id: '42', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo/extra', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + correlationId: 'correlation-invalid-repo', + sandboxAnalysis: { + isExploitable: false, + exploitabilityReasoning: 'Dependency is not reachable.', + usageLocations: [], + suggestedFix: 'Upgrade', + suggestedAction: 'dismiss', + summary: 'Not exploitable', + rawMarkdown: '# Not exploitable', + analysisAt: '2026-05-18T10:00:00.000Z', + }, + }, + }); + + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-sandbox', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(auditRows[0]).toMatchObject({ + action: 'security.finding.auto_dismissed', + resource_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + metadata: { correlationId: 'correlation-invalid-repo', dismissSource: 'sandbox' }, + }); + }); + + it('does not re-dismiss findings that are already ignored', async () => { + let selectCount = 0; + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount += 1; + return selectCount === 1 + ? [{ config: { auto_dismiss_enabled: true } }] + : [{ installationId: 'installation-123' }]; + }, + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + await maybeAutoDismissCompletedAnalysis({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, + } as unknown as CloudflareEnv, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + finding: { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + owned_by_user_id: null, + source: 'dependabot', + source_id: '42', + status: 'ignored', + platform_integration_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + repo_full_name: 'kilo/repo', + } as never, + analysis: { + analyzedAt: '2026-05-18T10:00:00.000Z', + correlationId: 'correlation-already-ignored', + sandboxAnalysis: { + isExploitable: false, + exploitabilityReasoning: 'Dependency is not reachable.', + usageLocations: [], + suggestedFix: 'Upgrade', + suggestedAction: 'dismiss', + summary: 'Not exploitable', + rawMarkdown: '# Not exploitable', + analysisAt: '2026-05-18T10:00:00.000Z', + }, + }, + }); + + expect(updates).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(auditRows).toHaveLength(0); + }); + it('auto-dismisses high-confidence triage decisions at the configured threshold', async () => { let selectCount = 0; const updates: unknown[] = []; diff --git a/services/security-auto-analysis/src/auto-dismiss.ts b/services/security-auto-analysis/src/auto-dismiss.ts index 07022c3eba..4de99d0d55 100644 --- a/services/security-auto-analysis/src/auto-dismiss.ts +++ b/services/security-auto-analysis/src/auto-dismiss.ts @@ -1,6 +1,7 @@ import type { WorkerDb } from '@kilocode/db/client'; import { platform_integrations, security_audit_log, security_findings } from '@kilocode/db/schema'; import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; +import { parseDependabotDismissalTarget } from '@kilocode/worker-utils/dependabot-dismissal-target'; import { eq, sql } from 'drizzle-orm'; import { getSecurityAgentConfigForOwner, type SecurityFindingRecord } from './db/queries.js'; import { logger } from './logger.js'; @@ -36,9 +37,11 @@ async function writeBackDependabotDismissal(params: { if (params.finding.source !== 'dependabot' || !params.finding.platform_integration_id) { return; } - const alertNumber = Number.parseInt(params.finding.source_id, 10); - const [repoOwner, repoName] = params.finding.repo_full_name.split('/'); - if (!Number.isFinite(alertNumber) || !repoOwner || !repoName) return; + const target = parseDependabotDismissalTarget({ + sourceId: params.finding.source_id, + repoFullName: params.finding.repo_full_name, + }); + if (!target) return; const rows = await params.db .select({ installationId: platform_integrations.platform_installation_id }) @@ -51,7 +54,7 @@ async function writeBackDependabotDismissal(params: { try { const token = await params.env.GIT_TOKEN_SERVICE.getToken(installationId); const response = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/dependabot/alerts/${alertNumber}`, + `https://api.github.com/repos/${target.repoOwner}/${target.repoName}/dependabot/alerts/${target.alertNumber}`, { method: 'PATCH', headers: { @@ -89,6 +92,8 @@ export async function maybeAutoDismissCompletedAnalysis(params: { finding: SecurityFindingRecord; analysis: SecurityFindingAnalysis; }): Promise { + if (params.finding.status === 'ignored') return; + const owner = findingOwner(params.finding); if (!owner) return; const config = await getSecurityAgentConfigForOwner(params.db, owner); diff --git a/services/security-auto-analysis/src/callbacks.test.ts b/services/security-auto-analysis/src/callbacks.test.ts index 224b457ccd..a5990e7191 100644 --- a/services/security-auto-analysis/src/callbacks.test.ts +++ b/services/security-auto-analysis/src/callbacks.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { transitionAnalysisCallbackLifecycle } from './analysis-start-lifecycle.js'; import { classifyAnalysisCallback, consumeAnalysisCallbackBatch, @@ -17,6 +18,15 @@ const failedPayload = { errorMessage: 'upstream 503', } satisfies SecurityAnalysisCallbackPayload; +vi.mock('./analysis-start-lifecycle.js', () => ({ + transitionAnalysisCallbackLifecycle: vi.fn(), +})); + +beforeEach(() => { + vi.mocked(transitionAnalysisCallbackLifecycle).mockReset(); + vi.mocked(transitionAnalysisCallbackLifecycle).mockResolvedValue({ status: 'completed' }); +}); + describe('classifyAnalysisCallback', () => { it('rejects stale session callbacks before terminalization', () => { expect( @@ -168,13 +178,113 @@ describe('finalizeCompletedAnalysisCallback', () => { }) ).resolves.toEqual({ status: 'completed-finalized' }); - expect(updates).toHaveLength(1); - expect(executes).toHaveLength(1); + expect(updates).toHaveLength(0); + expect(executes).toHaveLength(0); + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: expect.objectContaining({ + type: 'completed', + analysis: expect.objectContaining({ + rawMarkdown: '# Completed analysis', + }), + }), + }); expect(auditRows).toHaveLength(1); expect(autoDismissCalls).toHaveLength(1); expect(analyticsCalls).toHaveLength(1); }); + it('delegates already-terminal completed callback retries for queue healing', async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'completed', + }, + ], + }), + }), + }), + }; + const extractSandboxAnalysis = vi.fn(); + + await expect( + finalizeCompletedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + kiloSessionId: 'ses-123', + status: 'completed', + lastAssistantMessageText: '# Duplicate completion', + }, + extractSandboxAnalysis, + }) + ).resolves.toEqual({ status: 'already-terminal' }); + + expect(extractSandboxAnalysis).not.toHaveBeenCalled(); + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: { + type: 'already-terminal', + findingStatus: 'completed', + failureCode: null, + errorMessage: null, + }, + }); + }); + + it('delegates superseded completed callbacks to lifecycle settlement', async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: 'superseded:canonical-finding', + analysis_status: 'running', + }, + ], + }), + }), + }), + update: () => ({ set: () => ({ where: async () => undefined }) }), + execute: async () => ({ rows: [] }), + }; + const extractSandboxAnalysis = vi.fn(); + + await expect( + finalizeCompletedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + kiloSessionId: 'ses-123', + status: 'completed', + lastAssistantMessageText: '# Superseded completion', + }, + extractSandboxAnalysis, + }) + ).resolves.toEqual({ status: 'superseded' }); + + expect(extractSandboxAnalysis).not.toHaveBeenCalled(); + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: { type: 'superseded' }, + }); + }); + it('terminalizes completed callbacks when result markdown never becomes available', async () => { const findingUpdates: unknown[] = []; const queueTransitions: unknown[] = []; @@ -229,8 +339,16 @@ describe('finalizeCompletedAnalysisCallback', () => { }) ).resolves.toEqual({ status: 'result-missing' }); - expect(findingUpdates[0]).toMatchObject({ analysis_status: 'failed' }); - expect(queueTransitions).toHaveLength(1); + expect(findingUpdates).toHaveLength(0); + expect(queueTransitions).toHaveLength(0); + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: { + type: 'failed', + errorMessage: 'Analysis completed but callback result text was missing', + failureCode: 'START_CALL_AMBIGUOUS', + }, + }); }); }); @@ -279,6 +397,78 @@ describe('consumeAnalysisCallbackBatch', () => { }); describe('finalizeFailedAnalysisCallback', () => { + it('delegates already-terminal failed callback retries for queue healing', async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: null, + analysis_status: 'failed', + analysis_error: 'upstream 503', + }, + ], + }), + }), + }), + }; + + await expect( + finalizeFailedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: failedPayload, + }) + ).resolves.toEqual({ status: 'already-terminal' }); + + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: { + type: 'already-terminal', + findingStatus: 'failed', + failureCode: 'UPSTREAM_5XX', + errorMessage: 'upstream 503', + }, + }); + }); + + it('delegates superseded failed callbacks to lifecycle settlement', async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: 'superseded:canonical-finding', + analysis_status: 'running', + }, + ], + }), + }), + }), + update: () => ({ set: () => ({ where: async () => undefined }) }), + execute: async () => ({ rows: [] }), + }; + + await expect( + finalizeFailedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: failedPayload, + }) + ).resolves.toEqual({ status: 'superseded' }); + + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: { type: 'superseded' }, + }); + }); + it('writes terminal failed finding and queue state for retry-classified callbacks', async () => { const findingUpdates: unknown[] = []; const queueTransitions: unknown[] = []; @@ -320,11 +510,16 @@ describe('finalizeFailedAnalysisCallback', () => { payload: failedPayload, }) ).resolves.toEqual({ status: 'failed-finalized' }); - expect(findingUpdates[0]).toMatchObject({ - analysis_status: 'failed', - analysis_error: 'upstream 503', + expect(findingUpdates).toHaveLength(0); + expect(queueTransitions).toHaveLength(0); + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + outcome: { + type: 'failed', + errorMessage: 'upstream 503', + failureCode: 'UPSTREAM_5XX', + }, }); - expect(queueTransitions).toHaveLength(1); }); }); diff --git a/services/security-auto-analysis/src/callbacks.ts b/services/security-auto-analysis/src/callbacks.ts index 951fa12b96..955f0027ff 100644 --- a/services/security-auto-analysis/src/callbacks.ts +++ b/services/security-auto-analysis/src/callbacks.ts @@ -2,14 +2,8 @@ import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; import { security_audit_log } from '@kilocode/db/schema'; import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; import { z } from 'zod'; -import { - clearAnalysisStatus, - getAnalysisActorById, - getSecurityFindingById, - setFindingCompleted, - setFindingFailed, - transitionAnalysisQueueFromCallback, -} from './db/queries.js'; +import { getAnalysisActorById, getSecurityFindingById } from './db/queries.js'; +import { transitionAnalysisCallbackLifecycle } from './analysis-start-lifecycle.js'; import { generateApiToken } from './token.js'; import { extractSandboxAnalysis as runSandboxExtraction } from './extraction.js'; import { fetchLatestAssistantText as fetchSessionAssistantText } from './session-result.js'; @@ -158,17 +152,28 @@ export async function finalizeCompletedAnalysisCallback(params: { if (!finding) return { status: 'missing' }; const disposition = classifyAnalysisCallback(finding, params.payload); - if (disposition === 'stale-session' || disposition === 'already-terminal') { + if (disposition === 'stale-session') return { status: disposition }; + if (disposition === 'already-terminal') { + const findingStatus = finding.analysis_status === 'failed' ? 'failed' : 'completed'; + await transitionAnalysisCallbackLifecycle(params.db, { + findingId: params.findingId, + outcome: { + type: 'already-terminal', + findingStatus, + failureCode: findingStatus === 'failed' ? 'START_CALL_AMBIGUOUS' : null, + errorMessage: + findingStatus === 'failed' + ? 'Analysis completed but callback result text was missing' + : null, + }, + }); return { status: disposition }; } if (disposition === 'superseded') { - await transitionAnalysisQueueFromCallback(params.db, { + await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, - toStatus: 'completed', - failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE', - errorMessage: null, + outcome: { type: 'superseded' }, }); - await clearAnalysisStatus(params.db, params.findingId); return { status: disposition }; } @@ -179,14 +184,13 @@ export async function finalizeCompletedAnalysisCallback(params: { }); if (!rawMarkdown) { const errorMessage = 'Analysis completed but callback result text was missing'; - if (!(await setFindingFailed(params.db, params.findingId, errorMessage))) { - await clearAnalysisStatus(params.db, params.findingId); - } - await transitionAnalysisQueueFromCallback(params.db, { + await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, - toStatus: 'failed', - failureCode: 'START_CALL_AMBIGUOUS', - errorMessage, + outcome: { + type: 'failed', + errorMessage, + failureCode: 'START_CALL_AMBIGUOUS', + }, }); return { status: 'result-missing' }; } @@ -206,16 +210,14 @@ export async function finalizeCompletedAnalysisCallback(params: { correlationId: priorAnalysis?.correlationId, }; - if (!(await setFindingCompleted(params.db, params.findingId, completedAnalysis))) { - await clearAnalysisStatus(params.db, params.findingId); - return { status: 'superseded' }; - } - await transitionAnalysisQueueFromCallback(params.db, { + const lifecycleTransition = await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, - toStatus: 'completed', - failureCode: null, - errorMessage: null, + outcome: { + type: 'completed', + analysis: completedAnalysis, + }, }); + if (lifecycleTransition.status === 'superseded') return { status: 'superseded' }; await params.db.insert(security_audit_log).values({ owned_by_organization_id: finding.owned_by_organization_id, owned_by_user_id: finding.owned_by_user_id, @@ -256,17 +258,32 @@ export async function finalizeFailedAnalysisCallback(params: { if (!finding) return { status: 'missing' }; const disposition = classifyAnalysisCallback(finding, params.payload); - if (disposition === 'stale-session' || disposition === 'already-terminal') { + if (disposition === 'stale-session') return { status: disposition }; + if (disposition === 'already-terminal') { + const findingStatus = finding.analysis_status === 'completed' ? 'completed' : 'failed'; + const failure = + findingStatus === 'failed' + ? mapAnalysisCallbackFailure({ + status: params.payload.status === 'interrupted' ? 'interrupted' : 'failed', + errorMessage: params.payload.errorMessage, + }) + : null; + await transitionAnalysisCallbackLifecycle(params.db, { + findingId: params.findingId, + outcome: { + type: 'already-terminal', + findingStatus, + failureCode: failure?.failureCode ?? null, + errorMessage: failure?.errorMessage ?? null, + }, + }); return { status: disposition }; } if (disposition === 'superseded') { - await transitionAnalysisQueueFromCallback(params.db, { + await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, - toStatus: 'completed', - failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE', - errorMessage: null, + outcome: { type: 'superseded' }, }); - await clearAnalysisStatus(params.db, params.findingId); return { status: disposition }; } @@ -274,15 +291,15 @@ export async function finalizeFailedAnalysisCallback(params: { status: params.payload.status === 'interrupted' ? 'interrupted' : 'failed', errorMessage: params.payload.errorMessage, }); - if (!(await setFindingFailed(params.db, params.findingId, failure.errorMessage))) { - await clearAnalysisStatus(params.db, params.findingId); - } - await transitionAnalysisQueueFromCallback(params.db, { + const lifecycleTransition = await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, - toStatus: 'failed', - failureCode: failure.failureCode, - errorMessage: failure.errorMessage, + outcome: { + type: 'failed', + errorMessage: failure.errorMessage, + failureCode: failure.failureCode, + }, }); + if (lifecycleTransition.status === 'superseded') return { status: 'superseded' }; return { status: 'failed-finalized' }; } diff --git a/services/security-auto-analysis/src/db/queries.integration.test.ts b/services/security-auto-analysis/src/db/queries.integration.test.ts index d615f7ee2d..a0df77d3c1 100644 --- a/services/security-auto-analysis/src/db/queries.integration.test.ts +++ b/services/security-auto-analysis/src/db/queries.integration.test.ts @@ -194,6 +194,228 @@ describe('security analysis durable database invariants', () => { ); }); + it('heals stale running queue state without downgrading a completed finding', async () => { + const completedFindingId = await insertFinding('stale-running-completed', 'completed'); + await client.db.insert(security_analysis_queue).values({ + finding_id: completedFindingId, + owned_by_user_id: testUserId, + queue_status: 'running', + severity_rank: 1, + queued_at: '2026-05-18T06:00:00.000Z', + claimed_at: '2026-05-18T06:00:00.000Z', + claimed_by_job_id: 'completed-running-job', + claim_token: 'completed-running-claim', + updated_at: '2026-05-18T06:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysisError: security_findings.analysis_error, + }) + .from(security_findings) + .where(eq(security_findings.id, completedFindingId)); + expect(findingRows).toEqual([{ analysisStatus: 'completed', analysisError: null }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, completedFindingId)); + expect(queueRows).toEqual([{ status: 'completed', failureCode: null }]); + }); + + it('preserves a failed terminal finding while settling stale running queue state', async () => { + const failedFindingId = await insertFinding('stale-running-failed', 'failed'); + await client.db + .update(security_findings) + .set({ analysis_error: 'Callback failure already committed' }) + .where(eq(security_findings.id, failedFindingId)); + await client.db.insert(security_analysis_queue).values({ + finding_id: failedFindingId, + owned_by_user_id: testUserId, + queue_status: 'running', + severity_rank: 1, + queued_at: '2026-05-18T06:00:00.000Z', + claimed_at: '2026-05-18T06:00:00.000Z', + claimed_by_job_id: 'failed-running-job', + claim_token: 'failed-running-claim', + updated_at: '2026-05-18T06:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysisError: security_findings.analysis_error, + }) + .from(security_findings) + .where(eq(security_findings.id, failedFindingId)); + expect(findingRows).toEqual([ + { analysisStatus: 'failed', analysisError: 'Callback failure already committed' }, + ]); + + const queueRows = await client.db + .select({ status: security_analysis_queue.queue_status }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, failedFindingId)); + expect(queueRows).toEqual([{ status: 'failed' }]); + }); + + it('preserves a failed terminal finding while settling stale pending queue state', async () => { + const failedFindingId = await insertFinding('stale-pending-failed', 'failed'); + await client.db + .update(security_findings) + .set({ analysis_error: 'Start failure already committed' }) + .where(eq(security_findings.id, failedFindingId)); + await client.db.insert(security_analysis_queue).values({ + finding_id: failedFindingId, + owned_by_user_id: testUserId, + queue_status: 'pending', + severity_rank: 1, + queued_at: '2026-05-18T08:00:00.000Z', + claimed_at: '2026-05-18T08:00:00.000Z', + claimed_by_job_id: 'failed-pending-job', + claim_token: 'failed-pending-claim', + updated_at: '2026-05-18T08:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const findingRows = await client.db + .select({ + analysisStatus: security_findings.analysis_status, + analysisError: security_findings.analysis_error, + }) + .from(security_findings) + .where(eq(security_findings.id, failedFindingId)); + expect(findingRows).toEqual([ + { analysisStatus: 'failed', analysisError: 'Start failure already committed' }, + ]); + + const queueRows = await client.db + .select({ status: security_analysis_queue.queue_status }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, failedFindingId)); + expect(queueRows).toEqual([{ status: 'failed' }]); + }); + + it('promotes a stale pending queue row when launch already advanced the finding to running', async () => { + const runningFindingId = await insertFinding('stale-pending-running', 'running'); + await client.db.insert(security_analysis_queue).values({ + finding_id: runningFindingId, + owned_by_user_id: testUserId, + queue_status: 'pending', + severity_rank: 1, + queued_at: '2026-05-18T08:00:00.000Z', + claimed_at: '2026-05-18T08:00:00.000Z', + claimed_by_job_id: 'pending-running-job', + claim_token: 'pending-running-claim', + updated_at: '2026-05-18T08:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const findingRows = await client.db + .select({ analysisStatus: security_findings.analysis_status }) + .from(security_findings) + .where(eq(security_findings.id, runningFindingId)); + expect(findingRows).toEqual([{ analysisStatus: 'running' }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + claimToken: security_analysis_queue.claim_token, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, runningFindingId)); + expect(queueRows).toEqual([{ status: 'running', claimToken: 'pending-running-claim' }]); + }); + + it('heals a stale pending queue row when triage completion reached durable finding state', async () => { + const completedFindingId = await insertFinding('stale-pending-completed', 'completed'); + await client.db.insert(security_analysis_queue).values({ + finding_id: completedFindingId, + owned_by_user_id: testUserId, + queue_status: 'pending', + severity_rank: 1, + queued_at: '2026-05-18T08:00:00.000Z', + claimed_at: '2026-05-18T08:00:00.000Z', + claimed_by_job_id: 'pending-completed-job', + claim_token: 'pending-completed-claim', + updated_at: '2026-05-18T08:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const findingRows = await client.db + .select({ analysisStatus: security_findings.analysis_status }) + .from(security_findings) + .where(eq(security_findings.id, completedFindingId)); + expect(findingRows).toEqual([{ analysisStatus: 'completed' }]); + + const queueRows = await client.db + .select({ + status: security_analysis_queue.queue_status, + failureCode: security_analysis_queue.failure_code, + }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, completedFindingId)); + expect(queueRows).toEqual([{ status: 'completed', failureCode: null }]); + }); + + it('does not mark a stale running queue row lost when finding state has not reached running', async () => { + const pendingFindingId = await insertFinding('stale-running-pending', 'pending'); + await client.db.insert(security_analysis_queue).values({ + finding_id: pendingFindingId, + owned_by_user_id: testUserId, + queue_status: 'running', + severity_rank: 1, + queued_at: '2026-05-18T06:00:00.000Z', + claimed_at: '2026-05-18T06:00:00.000Z', + claimed_by_job_id: 'running-pending-job', + claim_token: 'running-pending-claim', + updated_at: '2026-05-18T06:00:00.000Z', + }); + + await expect(reconcileStaleAnalysisQueueRows(client.db as never)).resolves.toEqual({ + requeuedPendingCount: 0, + failedRunningCount: 0, + }); + + const findingRows = await client.db + .select({ analysisStatus: security_findings.analysis_status }) + .from(security_findings) + .where(eq(security_findings.id, pendingFindingId)); + expect(findingRows).toEqual([{ analysisStatus: 'pending' }]); + + const queueRows = await client.db + .select({ status: security_analysis_queue.queue_status }) + .from(security_analysis_queue) + .where(eq(security_analysis_queue.finding_id, pendingFindingId)); + expect(queueRows).toEqual([{ status: 'running' }]); + }); + it('leaves fresh pending and running rows untouched in real SQL', async () => { const currentTimestamp = new Date(Date.now() - 60_000).toISOString(); const pendingFindingId = await insertFinding('fresh-pending'); diff --git a/services/security-auto-analysis/src/db/queries.test.ts b/services/security-auto-analysis/src/db/queries.test.ts index 74b3cadc57..e7269bd625 100644 --- a/services/security-auto-analysis/src/db/queries.test.ts +++ b/services/security-auto-analysis/src/db/queries.test.ts @@ -17,18 +17,25 @@ describe('reconcileStaleAnalysisQueueRows', () => { it('reports requeued stale pending rows and failed stale running rows', async () => { const execute = vi .fn() + .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [{ id: 'pending-row' }] }) + .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [{ id: 'running-row' }, { id: 'running-row-2' }] }); await expect(reconcileStaleAnalysisQueueRows({ execute } as never)).resolves.toEqual({ requeuedPendingCount: 1, failedRunningCount: 2, }); - expect(execute).toHaveBeenCalledTimes(2); + expect(execute).toHaveBeenCalledTimes(4); }); it('leaves fresh rows untouched when reconciliation queries return no stale rows', async () => { - const execute = vi.fn().mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); + const execute = vi + .fn() + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); await expect(reconcileStaleAnalysisQueueRows({ execute } as never)).resolves.toEqual({ requeuedPendingCount: 0, diff --git a/services/security-auto-analysis/src/db/queries.ts b/services/security-auto-analysis/src/db/queries.ts index 57b24d2315..0a04b010dc 100644 --- a/services/security-auto-analysis/src/db/queries.ts +++ b/services/security-auto-analysis/src/db/queries.ts @@ -788,6 +788,26 @@ export async function reconcileStaleAnalysisQueueRows(db: WorkerDb): Promise<{ requeuedPendingCount: number; failedRunningCount: number; }> { + await db.execute(sql` + UPDATE security_analysis_queue + SET + queue_status = security_findings.analysis_status, + failure_code = CASE + WHEN security_findings.analysis_status = 'completed' THEN NULL + ELSE security_analysis_queue.failure_code + END, + last_error_redacted = CASE + WHEN security_findings.analysis_status = 'completed' THEN NULL + ELSE security_analysis_queue.last_error_redacted + END, + updated_at = now() + FROM security_findings + WHERE security_analysis_queue.finding_id = security_findings.id + AND security_analysis_queue.queue_status = 'pending' + AND security_analysis_queue.claimed_at <= now() - interval '15 minutes' + AND security_findings.analysis_status IN ('completed', 'failed', 'running') + `); + const requeuedPending = await db.execute<{ id: string }>(sql` WITH requeued_rows AS ( UPDATE security_analysis_queue @@ -812,6 +832,26 @@ export async function reconcileStaleAnalysisQueueRows(db: WorkerDb): Promise<{ RETURNING id `); + await db.execute(sql` + UPDATE security_analysis_queue + SET + queue_status = security_findings.analysis_status, + failure_code = CASE + WHEN security_findings.analysis_status = 'completed' THEN NULL + ELSE security_analysis_queue.failure_code + END, + last_error_redacted = CASE + WHEN security_findings.analysis_status = 'completed' THEN NULL + ELSE security_analysis_queue.last_error_redacted + END, + updated_at = now() + FROM security_findings + WHERE security_analysis_queue.finding_id = security_findings.id + AND security_analysis_queue.queue_status = 'running' + AND security_analysis_queue.updated_at <= now() - interval '2 hours' + AND security_findings.analysis_status IN ('completed', 'failed') + `); + const failedRunning = await db.execute<{ id: string }>(sql` WITH failed_rows AS ( UPDATE security_analysis_queue @@ -820,9 +860,12 @@ export async function reconcileStaleAnalysisQueueRows(db: WorkerDb): Promise<{ failure_code = 'RUN_LOST', last_error_redacted = 'Automated stale running reconciliation', updated_at = now() - WHERE queue_status = 'running' - AND updated_at <= now() - interval '2 hours' - RETURNING finding_id + FROM security_findings + WHERE security_analysis_queue.finding_id = security_findings.id + AND security_analysis_queue.queue_status = 'running' + AND security_analysis_queue.updated_at <= now() - interval '2 hours' + AND security_findings.analysis_status = 'running' + RETURNING security_analysis_queue.finding_id ) UPDATE security_findings SET diff --git a/services/security-auto-analysis/src/launch.test.ts b/services/security-auto-analysis/src/launch.test.ts index 5d4894f1cc..c8b8cfe3b3 100644 --- a/services/security-auto-analysis/src/launch.test.ts +++ b/services/security-auto-analysis/src/launch.test.ts @@ -1,7 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveCallbackToken } from '@kilocode/worker-utils'; import { clearAnalysisStatus, + getSecurityAgentConfigForOwner, getSecurityFindingById, setFindingCompleted, setFindingFailed, @@ -16,6 +18,7 @@ import { triageSecurityFinding } from './triage.js'; vi.mock('./db/queries.js', () => ({ clearAnalysisStatus: vi.fn(), + getSecurityAgentConfigForOwner: vi.fn(), getSecurityFindingById: vi.fn(), setFindingCompleted: vi.fn(), setFindingFailed: vi.fn(), @@ -28,6 +31,11 @@ vi.mock('./token.js', () => ({ generateApiToken: vi.fn() })); vi.mock('./triage.js', () => ({ triageSecurityFinding: vi.fn() })); const CALLBACK_SECRET = 'test-callback-token-secret'; +const workerConfig = readFileSync(new URL('../wrangler.jsonc', import.meta.url), 'utf8'); + +afterEach(() => { + vi.unstubAllGlobals(); +}); const finding = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', @@ -66,9 +74,36 @@ const existingTriage = { triageAt: '2026-05-18T08:00:00.000Z', }; -function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) { +function createAutoDismissDb() { + const updates: unknown[] = []; + const auditRows: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ installationId: 'installation-123' }], + }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + updates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + auditRows.push(values); + }, + }), + }; + return { db, updates, auditRows }; +} + +function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch, db: unknown = {}) { return { - db: {} as never, + db: db as never, env: { ENVIRONMENT: 'development', KILOCODE_BACKEND_BASE_URL: 'https://backend.test', @@ -76,6 +111,7 @@ function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL: 'https://app.kilo.ai', SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: 'https://security-analysis.test', CLOUD_AGENT_NEXT: { fetch: cloudAgentFetch }, + GIT_TOKEN_SERVICE: { getToken: async () => 'github-token' }, } as unknown as CloudflareEnv, findingId: finding.id, actorUser: { id: 'user-123', api_token_pepper: null }, @@ -97,6 +133,20 @@ function createParams(retrySandboxOnly: boolean, cloudAgentFetch: typeof fetch) } describe('buildSecurityAnalysisCallbackTarget', () => { + it('keeps deployment callback routing defaults on durable Worker ingress', () => { + expect(workerConfig.match(/"SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker"/g)).toHaveLength( + 2 + ); + expect(workerConfig).toContain('"pattern": "security-auto-analysis.kilosessions.ai"'); + expect(workerConfig).toContain('"custom_domain": true'); + expect(workerConfig).toContain( + '"SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "https://security-auto-analysis.kilosessions.ai"' + ); + expect(workerConfig).toContain( + '"SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "http://localhost:8797"' + ); + }); + it('routes callback delivery to configured Worker HTTP ingress', () => { expect( buildSecurityAnalysisCallbackTarget( @@ -151,6 +201,10 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { vi.clearAllMocks(); vi.mocked(tryAcquireAnalysisStartLease).mockResolvedValue(true); vi.mocked(generateApiToken).mockResolvedValue('auth-token'); + vi.mocked(getSecurityAgentConfigForOwner).mockResolvedValue({ + auto_dismiss_enabled: true, + auto_dismiss_confidence_threshold: 'high', + } as never); vi.mocked(setFindingCompleted).mockResolvedValue(true); vi.mocked(setFindingFailed).mockResolvedValue(true); vi.mocked(setFindingPending).mockResolvedValue(true); @@ -275,6 +329,96 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { expect(clearAnalysisStatus).not.toHaveBeenCalled(); }); + it('auto-dismisses triage-only dismiss recommendations after durable Worker completion', async () => { + const { db, updates, auditRows } = createAutoDismissDb(); + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); + vi.mocked(triageSecurityFinding).mockResolvedValue({ + ...existingTriage, + needsSandboxAnalysis: false, + needsSandboxReasoning: 'No relevant runtime path.', + suggestedAction: 'dismiss', + }); + + await expect(startSecurityAnalysis(createParams(false, vi.fn() as never, db))).resolves.toEqual( + { + started: true, + triageOnly: true, + } + ); + + expect(transitionAnalysisStartLifecycle).toHaveBeenCalledWith( + db, + expect.objectContaining({ + outcome: expect.objectContaining({ type: 'triage-only-completed' }), + }) + ); + expect(updates[0]).toMatchObject({ + status: 'ignored', + ignored_reason: 'not_used', + ignored_by: 'auto-triage', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(auditRows[0]).toMatchObject({ + action: 'security.finding.auto_dismissed', + resource_id: finding.id, + metadata: { dismissSource: 'triage', confidence: 'high' }, + }); + }); + + it('leaves triage-only dismiss recommendations open when auto-dismiss is disabled', async () => { + const { db, updates, auditRows } = createAutoDismissDb(); + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + vi.mocked(getSecurityAgentConfigForOwner).mockResolvedValue({ + auto_dismiss_enabled: false, + auto_dismiss_confidence_threshold: 'high', + } as never); + vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); + vi.mocked(triageSecurityFinding).mockResolvedValue({ + ...existingTriage, + needsSandboxAnalysis: false, + needsSandboxReasoning: 'No relevant runtime path.', + suggestedAction: 'dismiss', + }); + + await expect(startSecurityAnalysis(createParams(false, vi.fn() as never, db))).resolves.toEqual( + { + started: true, + triageOnly: true, + } + ); + + expect(updates).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(auditRows).toHaveLength(0); + }); + + it('leaves triage-only non-dismiss recommendations open after Worker completion', async () => { + const { db, updates, auditRows } = createAutoDismissDb(); + const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); + vi.mocked(triageSecurityFinding).mockResolvedValue({ + ...existingTriage, + needsSandboxAnalysis: false, + needsSandboxReasoning: 'Maintain manual review.', + suggestedAction: 'manual_review', + }); + + await expect(startSecurityAnalysis(createParams(false, vi.fn() as never, db))).resolves.toEqual( + { + started: true, + triageOnly: true, + } + ); + + expect(updates).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(auditRows).toHaveLength(0); + }); + it('returns failed starts for lifecycle settlement instead of updating findings alone', async () => { vi.mocked(getSecurityFindingById).mockResolvedValue({ ...finding, analysis: null } as never); vi.mocked(triageSecurityFinding).mockResolvedValue(existingTriage); diff --git a/services/security-auto-analysis/src/launch.ts b/services/security-auto-analysis/src/launch.ts index a3ba0fb836..d2737d11b4 100644 --- a/services/security-auto-analysis/src/launch.ts +++ b/services/security-auto-analysis/src/launch.ts @@ -16,6 +16,7 @@ import { import { logger } from './logger.js'; import { generateApiToken } from './token.js'; import { triageSecurityFinding } from './triage.js'; +import { maybeAutoDismissCompletedAnalysis } from './auto-dismiss.js'; import type { AnalysisMode, SecurityFindingAnalysis } from './types.js'; export class InsufficientCreditsError extends Error { @@ -224,6 +225,13 @@ export async function startSecurityAnalysis( await clearAnalysisStatus(params.db, params.findingId); return { started: false, error: 'Finding was superseded during analysis' }; } + await maybeAutoDismissCompletedAnalysis({ + db: params.db, + env: params.env, + findingId: params.findingId, + finding, + analysis: triageOnlyAnalysis, + }); return { started: true, triageOnly: true }; } diff --git a/services/security-auto-analysis/src/manual-analysis.test.ts b/services/security-auto-analysis/src/manual-analysis.test.ts index f90b6c867b..f24ceca498 100644 --- a/services/security-auto-analysis/src/manual-analysis.test.ts +++ b/services/security-auto-analysis/src/manual-analysis.test.ts @@ -75,6 +75,102 @@ describe('processManualAnalysisStart', () => { ).resolves.toEqual({ status: 'owner-cap' }); }); + it('rejects manual starts when the analysis actor is missing', async () => { + let selectCount = 0; + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + if (selectCount === 2) { + return { from: () => ({ where: async () => [{ total: 0 }] }) }; + } + return { from: () => ({ where: () => ({ limit: async () => [] }) }) }; + }, + }; + + await expect( + processManualAnalysisStart({ db: db as never, env: {} as CloudflareEnv, command }) + ).resolves.toEqual({ status: 'actor-missing' }); + expect(startSecurityAnalysis).not.toHaveBeenCalled(); + }); + + it('rejects duplicate manual starts after an active queue row wins admission', async () => { + let selectCount = 0; + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + if (selectCount === 2) { + return { from: () => ({ where: async () => [{ total: 0 }] }) }; + } + return { + from: () => ({ + where: () => ({ limit: async () => [{ id: 'user-123', api_token_pepper: null }] }), + }), + }; + }, + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => ({ returning: async () => [] }), + }), + }), + }; + + await expect( + processManualAnalysisStart({ db: db as never, env: {} as CloudflareEnv, command }) + ).resolves.toEqual({ status: 'duplicate' }); + expect(startSecurityAnalysis).not.toHaveBeenCalled(); + }); + + it('settles queued manual starts when the GitHub token is unavailable', async () => { + let selectCount = 0; + const execute = vi.fn().mockResolvedValue({ rows: [] }); + const db = { + select: () => { + selectCount += 1; + if (selectCount === 1) { + return { from: () => ({ where: () => ({ limit: async () => [finding] }) }) }; + } + if (selectCount === 2) { + return { from: () => ({ where: async () => [{ total: 0 }] }) }; + } + return { + from: () => ({ + where: () => ({ limit: async () => [{ id: 'user-123', api_token_pepper: null }] }), + }), + }; + }, + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => ({ returning: async () => [{ id: 'queue-row-token' }] }), + }), + }), + execute, + }; + + await expect( + processManualAnalysisStart({ + db: db as never, + env: { + GIT_TOKEN_SERVICE: { + getTokenForRepo: async () => ({ + success: false, + reason: 'token missing', + }), + }, + } as unknown as CloudflareEnv, + command, + }) + ).resolves.toEqual({ status: 'token-missing' }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(startSecurityAnalysis).not.toHaveBeenCalled(); + }); + it('persists actor-selected model context in Worker launch and audit metadata', async () => { let selectCount = 0; let insertCount = 0; diff --git a/services/security-auto-analysis/wrangler.jsonc b/services/security-auto-analysis/wrangler.jsonc index 200da6397e..249b3cff7d 100644 --- a/services/security-auto-analysis/wrangler.jsonc +++ b/services/security-auto-analysis/wrangler.jsonc @@ -14,13 +14,19 @@ "observability": { "enabled": true, }, + "routes": [ + { + "pattern": "security-auto-analysis.kilosessions.ai", + "custom_domain": true, + }, + ], "vars": { "ENVIRONMENT": "production", "KILOCODE_BACKEND_BASE_URL": "https://api.kilo.ai", "SESSION_INGEST_WORKER_URL": "https://ingest.kilosessions.ai", - "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "web", + "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker", "SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL": "https://app.kilo.ai", - "SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "", + "SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "https://security-auto-analysis.kilosessions.ai", "SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED": "true", "MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED": "true", "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", @@ -110,9 +116,9 @@ "ENVIRONMENT": "development", "KILOCODE_BACKEND_BASE_URL": "http://localhost:3000", "SESSION_INGEST_WORKER_URL": "http://localhost:8800", - "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "web", + "SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE": "worker", "SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL": "http://localhost:3000", - "SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "", + "SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL": "http://localhost:8797", "SECURITY_ANALYSIS_CALLBACK_WORKER_INGRESS_ENABLED": "true", "MANUAL_ANALYSIS_COMMAND_ROUTING_ENABLED": "true", "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", From 38fb912bacaa324e7d963149416d605187cd40e9 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 19 May 2026 21:51:25 +0200 Subject: [PATCH 09/18] refactor(security-agent): align web with worker orchestration --- .../security-agent/FindingDetailDialog.tsx | 4 + .../security-agent/SecurityAgentContext.tsx | 15 +- .../SecurityAgentPageClient.tsx | 9 +- .../security-agent/SecurityFindingRow.tsx | 3 +- .../manual-analysis-admission-copy.test.ts | 12 + .../manual-analysis-admission-copy.ts | 5 + .../db/security-analysis.test.ts | 102 +-- .../security-agent/db/security-analysis.ts | 274 +----- .../router/shared-handlers.test.ts | 15 +- .../security-agent/router/shared-handlers.ts | 4 +- .../services/analysis-service.test.ts | 20 +- .../services/manual-analysis-client.test.ts | 4 +- .../services/manual-analysis-client.ts | 4 +- .../services/sync-service.test.ts | 377 --------- .../security-agent/services/sync-service.ts | 782 ------------------ pnpm-lock.yaml | 2 +- services/cloud-agent-next/src/server.ts | 2 +- .../src/consumer.test.ts | 1 + .../security-auto-analysis/src/launch.test.ts | 4 +- services/security-sync/src/sync.test.ts | 210 ++++- services/security-sync/src/sync.ts | 146 +++- 21 files changed, 418 insertions(+), 1577 deletions(-) create mode 100644 apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts create mode 100644 apps/web/src/components/security-agent/manual-analysis-admission-copy.ts delete mode 100644 apps/web/src/lib/security-agent/services/sync-service.test.ts delete mode 100644 apps/web/src/lib/security-agent/services/sync-service.ts diff --git a/apps/web/src/components/security-agent/FindingDetailDialog.tsx b/apps/web/src/components/security-agent/FindingDetailDialog.tsx index c1c3170a5d..9c6665052f 100644 --- a/apps/web/src/components/security-agent/FindingDetailDialog.tsx +++ b/apps/web/src/components/security-agent/FindingDetailDialog.tsx @@ -29,6 +29,8 @@ import type { SecurityFinding } from '@kilocode/db/schema'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; +import { toast } from 'sonner'; +import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; type Severity = 'critical' | 'high' | 'medium' | 'low'; @@ -106,6 +108,7 @@ export function FindingDetailDialog({ const startOrgAnalysisMutation = useMutation( trpc.organizations.securityAgent.startAnalysis.mutationOptions({ onSuccess: async () => { + toast.success(manualAnalysisAdmissionCopy.successTitle); await queryClient.invalidateQueries(); }, }) @@ -115,6 +118,7 @@ export function FindingDetailDialog({ const startUserAnalysisMutation = useMutation( trpc.securityAgent.startAnalysis.mutationOptions({ onSuccess: async () => { + toast.success(manualAnalysisAdmissionCopy.successTitle); await queryClient.invalidateQueries(); }, }) diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 1b6c572569..b538014e2d 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -8,6 +8,7 @@ import type { SecurityFinding } from '@kilocode/db/schema'; import { isGitHubIntegrationError } from '@/lib/security-agent/core/error-display'; import type { DismissReason } from './DismissFindingDialog'; import type { SlaConfig } from './SecurityConfigForm'; +import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; type SecurityAgentContextValue = { organizationId: string | undefined; @@ -254,7 +255,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.organizations.securityAgent.startAnalysis.mutationOptions({ onSuccess: async (_data, variables) => { setGitHubError(null); - toast.success('Analysis started'); + toast.success(manualAnalysisAdmissionCopy.successTitle); refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); @@ -271,7 +272,10 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen 'The GitHub App may have been uninstalled. Please check your integrations.', }); } else { - toast.error('Failed to start analysis', { description: message, duration: 8000 }); + toast.error(manualAnalysisAdmissionCopy.failureTitle, { + description: message, + duration: 8000, + }); } void queryClient.invalidateQueries(); setStartingAnalysisIds(prev => { @@ -370,7 +374,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.securityAgent.startAnalysis.mutationOptions({ onSuccess: async (_data, variables) => { setGitHubError(null); - toast.success('Analysis started'); + toast.success(manualAnalysisAdmissionCopy.successTitle); refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); @@ -387,7 +391,10 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen 'The GitHub App may have been uninstalled. Please check your integrations.', }); } else { - toast.error('Failed to start analysis', { description: message, duration: 8000 }); + toast.error(manualAnalysisAdmissionCopy.failureTitle, { + description: message, + duration: 8000, + }); } void queryClient.invalidateQueries(); setStartingAnalysisIds(prev => { diff --git a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx index 6d46ad1799..2742da1d96 100644 --- a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx @@ -22,6 +22,7 @@ import { } from '@/lib/security-agent/core/schemas'; import Link from 'next/link'; import { isGitHubIntegrationError } from '@/lib/security-agent/core/error-display'; +import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; type SecurityAgentPageClientProps = { organizationId?: string; @@ -332,7 +333,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli trpc.organizations.securityAgent.startAnalysis.mutationOptions({ onSuccess: async (_data, variables) => { setGitHubError(null); // Clear any previous error on success - toast.success('Analysis started'); + toast.success(manualAnalysisAdmissionCopy.successTitle); refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); @@ -349,7 +350,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli 'The GitHub App may have been uninstalled. Please check your integrations.', }); } else { - toast.error('Failed to start analysis', { + toast.error(manualAnalysisAdmissionCopy.failureTitle, { description: message, duration: 8000, }); @@ -371,7 +372,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli trpc.securityAgent.startAnalysis.mutationOptions({ onSuccess: async (_data, variables) => { setGitHubError(null); // Clear any previous error on success - toast.success('Analysis started'); + toast.success(manualAnalysisAdmissionCopy.successTitle); refreshAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); @@ -388,7 +389,7 @@ export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageCli 'The GitHub App may have been uninstalled. Please check your integrations.', }); } else { - toast.error('Failed to start analysis', { + toast.error(manualAnalysisAdmissionCopy.failureTitle, { description: message, duration: 8000, }); diff --git a/apps/web/src/components/security-agent/SecurityFindingRow.tsx b/apps/web/src/components/security-agent/SecurityFindingRow.tsx index cbeb1d052d..da91d85ff5 100644 --- a/apps/web/src/components/security-agent/SecurityFindingRow.tsx +++ b/apps/web/src/components/security-agent/SecurityFindingRow.tsx @@ -19,6 +19,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import type { SecurityFinding } from '@kilocode/db/schema'; import { cn } from '@/lib/utils'; import { SeverityBadge } from './SeverityBadge'; +import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; type Outcome = { icon: typeof CheckCircle2; @@ -286,7 +287,7 @@ export function SecurityFindingRow({ ) : isStartingAnalysis ? ( ) : finding.analysis?.triage?.suggestedAction === 'manual_review' && finding.status === 'open' ? ( diff --git a/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts b/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts new file mode 100644 index 0000000000..1f77a685da --- /dev/null +++ b/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from '@jest/globals'; +import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; + +describe('manualAnalysisAdmissionCopy', () => { + test('describes manual analysis as queued admission', () => { + expect(manualAnalysisAdmissionCopy).toEqual({ + successTitle: 'Analysis queued', + failureTitle: 'Failed to queue analysis', + pendingLabel: 'Queueing', + }); + }); +}); diff --git a/apps/web/src/components/security-agent/manual-analysis-admission-copy.ts b/apps/web/src/components/security-agent/manual-analysis-admission-copy.ts new file mode 100644 index 0000000000..d745193a29 --- /dev/null +++ b/apps/web/src/components/security-agent/manual-analysis-admission-copy.ts @@ -0,0 +1,5 @@ +export const manualAnalysisAdmissionCopy = { + successTitle: 'Analysis queued', + failureTitle: 'Failed to queue analysis', + pendingLabel: 'Queueing', +}; diff --git a/apps/web/src/lib/security-agent/db/security-analysis.test.ts b/apps/web/src/lib/security-agent/db/security-analysis.test.ts index 29325302d4..b1f4751a7c 100644 --- a/apps/web/src/lib/security-agent/db/security-analysis.test.ts +++ b/apps/web/src/lib/security-agent/db/security-analysis.test.ts @@ -16,11 +16,9 @@ jest.mock('@/lib/drizzle', () => ({ jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })); let cleanupStaleAnalyses: typeof analysisDbModule.cleanupStaleAnalyses; -let isFindingEligibleForAutoAnalysis: typeof analysisDbModule.isFindingEligibleForAutoAnalysis; beforeAll(async () => { - ({ cleanupStaleAnalyses, isFindingEligibleForAutoAnalysis } = - await import('./security-analysis')); + ({ cleanupStaleAnalyses } = await import('./security-analysis')); }); beforeEach(() => { @@ -41,97 +39,13 @@ describe('cleanupStaleAnalyses', () => { }); }); -describe('isFindingEligibleForAutoAnalysis', () => { - const baseParams = { - findingCreatedAt: '2025-06-01T00:00:00Z', - findingStatus: 'open', - severity: 'high', - ownerAutoAnalysisEnabledAt: '2025-07-01T00:00:00Z', - isAgentEnabled: true, - autoAnalysisEnabled: true, - autoAnalysisMinSeverity: 'high' as const, - }; +describe('retired web sync queue policy surface', () => { + it('does not expose obsolete sync queue policy helpers', async () => { + const analysisDb = await import('./security-analysis'); - it('rejects findings created before auto_analysis_enabled_at by default', () => { - const result = isFindingEligibleForAutoAnalysis(baseParams); - expect(result.eligible).toBe(false); - }); - - it('accepts findings created after auto_analysis_enabled_at', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - findingCreatedAt: '2025-08-01T00:00:00Z', - }); - expect(result.eligible).toBe(true); - }); - - it('accepts pre-existing findings when autoAnalysisIncludeExisting is true', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - autoAnalysisIncludeExisting: true, - }); - expect(result.eligible).toBe(true); - }); - - it('still rejects non-open findings even with autoAnalysisIncludeExisting', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - findingStatus: 'fixed', - autoAnalysisIncludeExisting: true, - }); - expect(result.eligible).toBe(false); - }); - - it('still respects severity threshold with autoAnalysisIncludeExisting', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - severity: 'low', - autoAnalysisMinSeverity: 'high', - autoAnalysisIncludeExisting: true, - }); - expect(result.eligible).toBe(false); - }); - - it('rejects when agent is not enabled even with autoAnalysisIncludeExisting', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - isAgentEnabled: false, - autoAnalysisIncludeExisting: true, - }); - expect(result.eligible).toBe(false); - }); - - it('treats null severity as eligible with low rank when threshold is "all"', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - severity: null, - autoAnalysisMinSeverity: 'all', - findingCreatedAt: '2025-08-01T00:00:00Z', - }); - expect(result.eligible).toBe(true); - expect(result.severityRank).toBe(3); - }); - - it('rejects null severity when threshold is stricter than "all"', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - severity: null, - autoAnalysisMinSeverity: 'high', - findingCreatedAt: '2025-08-01T00:00:00Z', - }); - expect(result.eligible).toBe(false); - expect(result.severityRank).toBe(3); - }); - - it('treats null severity as eligible when threshold is medium', () => { - const result = isFindingEligibleForAutoAnalysis({ - ...baseParams, - severity: null, - autoAnalysisMinSeverity: 'medium', - findingCreatedAt: '2025-08-01T00:00:00Z', - }); - // low rank (3) > medium max rank (2), so not eligible - expect(result.eligible).toBe(false); - expect(result.severityRank).toBe(3); + expect('getOwnerAutoAnalysisEnabledAt' in analysisDb).toBe(false); + expect('isFindingEligibleForAutoAnalysis' in analysisDb).toBe(false); + expect('syncAutoAnalysisQueueForFinding' in analysisDb).toBe(false); + expect('dequeueSupersededFindings' in analysisDb).toBe(false); }); }); diff --git a/apps/web/src/lib/security-agent/db/security-analysis.ts b/apps/web/src/lib/security-agent/db/security-analysis.ts index c6c04954f7..1a62d0370e 100644 --- a/apps/web/src/lib/security-agent/db/security-analysis.ts +++ b/apps/web/src/lib/security-agent/db/security-analysis.ts @@ -5,11 +5,10 @@ import { security_analysis_owner_state, type SecurityFinding, } from '@kilocode/db/schema'; -import { eq, and, sql, count, isNotNull, desc, or, isNull, inArray, not, like } from 'drizzle-orm'; +import { eq, and, sql, count, isNotNull, desc, or, isNull, not, like } from 'drizzle-orm'; import { captureException } from '@sentry/nextjs'; import type { AutoAnalysisMinSeverity, - SecurityFindingStatus, SecuritySeverity, SecurityReviewOwner, SecurityFindingAnalysis, @@ -50,14 +49,6 @@ export type AutoAnalysisFailureCode = | 'START_CALL_AMBIGUOUS' | 'RUN_LOST'; -export type AutoAnalysisQueueSyncResult = { - enqueueCount: number; - eligibleCount: number; - boundarySkipCount: number; - unknownSeverityCount: number; -}; - -export const AUTO_ANALYSIS_REOPEN_REQUEUE_CAP = 2; export const AUTO_ANALYSIS_OWNER_CAP = 2; export const AUTO_ANALYSIS_MAX_ATTEMPTS = 5; @@ -81,50 +72,6 @@ function minSeverityToMaxRank(minSeverity: AutoAnalysisMinSeverity): number { } } -export function getSeverityRank(severity: string | null | undefined): number | null { - if (severity === 'critical') return severityRankBySeverity.critical; - if (severity === 'high') return severityRankBySeverity.high; - if (severity === 'medium') return severityRankBySeverity.medium; - if (severity === 'low') return severityRankBySeverity.low; - return null; -} - -export function isFindingEligibleForAutoAnalysis(params: { - findingCreatedAt: string; - findingStatus: string; - severity: string | null; - ownerAutoAnalysisEnabledAt: string | null; - isAgentEnabled: boolean; - autoAnalysisEnabled: boolean; - autoAnalysisMinSeverity: AutoAnalysisMinSeverity; - autoAnalysisIncludeExisting?: boolean; -}): { eligible: boolean; severityRank: number | null } { - const severityRank = getSeverityRank(params.severity); - - if (!params.isAgentEnabled || !params.autoAnalysisEnabled) { - return { eligible: false, severityRank }; - } - if (params.findingStatus !== 'open') { - return { eligible: false, severityRank }; - } - if (!params.ownerAutoAnalysisEnabledAt) { - return { eligible: false, severityRank }; - } - if ( - !params.autoAnalysisIncludeExisting && - Date.parse(params.findingCreatedAt) < Date.parse(params.ownerAutoAnalysisEnabledAt) - ) { - return { eligible: false, severityRank }; - } - // Treat null/unknown severity as eligible with lowest rank (low=3) so these - // findings are not silently skipped. They still respect the severity threshold - // — if the threshold is stricter than 'all' they will be filtered out by rank. - const effectiveRank = severityRank ?? severityRankBySeverity.low; - - const maxRank = minSeverityToMaxRank(params.autoAnalysisMinSeverity); - return { eligible: effectiveRank <= maxRank, severityRank: effectiveRank }; -} - /** * Update the analysis status of a finding. * Returns false if the finding was superseded (guard tripped, no rows updated). @@ -393,24 +340,6 @@ export async function countSecurityFindingsWithAnalysis( } } -export async function getOwnerAutoAnalysisEnabledAt( - owner: SecurityReviewOwner -): Promise { - const ownerConverted = toOwner(owner); - const ownerCondition = - ownerConverted.type === 'org' - ? eq(security_analysis_owner_state.owned_by_organization_id, ownerConverted.id) - : eq(security_analysis_owner_state.owned_by_user_id, ownerConverted.id); - - const [state] = await db - .select({ autoAnalysisEnabledAt: security_analysis_owner_state.auto_analysis_enabled_at }) - .from(security_analysis_owner_state) - .where(ownerCondition) - .limit(1); - - return state?.autoAnalysisEnabledAt ?? null; -} - export async function setOwnerAutoAnalysisEnabledAtNow(owner: SecurityReviewOwner): Promise { const ownerConverted = toOwner(owner); const ownerCondition = @@ -463,148 +392,6 @@ export async function resetOwnerAutoAnalysisEnabledAt(owner: SecurityReviewOwner .where(ownerCondition); } -export async function syncAutoAnalysisQueueForFinding(params: { - owner: SecurityReviewOwner; - findingId: string; - findingCreatedAt: string; - previousStatus: SecurityFindingStatus | null; - currentStatus: SecurityFindingStatus; - severity: string | null; - isAgentEnabled: boolean; - autoAnalysisEnabled: boolean; - autoAnalysisMinSeverity: AutoAnalysisMinSeverity; - ownerAutoAnalysisEnabledAt: string | null; - autoAnalysisIncludeExisting?: boolean; -}): Promise { - const ownerConverted = toOwner(params.owner); - const { eligible, severityRank } = isFindingEligibleForAutoAnalysis({ - findingCreatedAt: params.findingCreatedAt, - findingStatus: params.currentStatus, - severity: params.severity, - ownerAutoAnalysisEnabledAt: params.ownerAutoAnalysisEnabledAt, - isAgentEnabled: params.isAgentEnabled, - autoAnalysisEnabled: params.autoAnalysisEnabled, - autoAnalysisMinSeverity: params.autoAnalysisMinSeverity, - autoAnalysisIncludeExisting: params.autoAnalysisIncludeExisting, - }); - const isBoundarySkip = - !params.autoAnalysisIncludeExisting && - params.ownerAutoAnalysisEnabledAt != null && - Date.parse(params.findingCreatedAt) < Date.parse(params.ownerAutoAnalysisEnabledAt); - const unknownSeverityCount = severityRank == null ? 1 : 0; - let enqueueCount = 0; - - await db.transaction(async tx => { - if (severityRank != null) { - await tx - .update(security_analysis_queue) - .set({ - severity_rank: severityRank, - updated_at: sql`now()`, - }) - .where( - and( - eq(security_analysis_queue.finding_id, params.findingId), - eq(security_analysis_queue.queue_status, 'queued') - ) - ); - } - - if (!eligible) { - await tx - .update(security_analysis_queue) - .set({ - queue_status: 'completed', - failure_code: 'SKIPPED_NO_LONGER_ELIGIBLE', - claim_token: null, - claimed_at: null, - claimed_by_job_id: null, - updated_at: sql`now()`, - }) - .where( - and( - eq(security_analysis_queue.finding_id, params.findingId), - eq(security_analysis_queue.queue_status, 'queued') - ) - ); - } - - const isReopened = - (params.previousStatus === 'fixed' || params.previousStatus === 'ignored') && - params.currentStatus === 'open'; - - if (isReopened && eligible) { - await tx - .update(security_analysis_queue) - .set({ - queue_status: 'queued', - queued_at: sql`now()`, - attempt_count: 0, - next_retry_at: null, - failure_code: null, - last_error_redacted: null, - claimed_at: null, - claimed_by_job_id: null, - claim_token: null, - reopen_requeue_count: sql`${security_analysis_queue.reopen_requeue_count} + 1`, - updated_at: sql`now()`, - }) - .where( - and( - eq(security_analysis_queue.finding_id, params.findingId), - or( - eq(security_analysis_queue.queue_status, 'completed'), - eq(security_analysis_queue.queue_status, 'failed') - ), - sql`${security_analysis_queue.reopen_requeue_count} < ${AUTO_ANALYSIS_REOPEN_REQUEUE_CAP}` - ) - ); - - await tx - .update(security_analysis_queue) - .set({ - queue_status: 'failed', - failure_code: 'REOPEN_LOOP_GUARD', - updated_at: sql`now()`, - }) - .where( - and( - eq(security_analysis_queue.finding_id, params.findingId), - or( - eq(security_analysis_queue.queue_status, 'completed'), - eq(security_analysis_queue.queue_status, 'failed') - ), - sql`${security_analysis_queue.reopen_requeue_count} >= ${AUTO_ANALYSIS_REOPEN_REQUEUE_CAP}` - ) - ); - } - - if (eligible) { - const inserted = await tx - .insert(security_analysis_queue) - .values({ - finding_id: params.findingId, - owned_by_organization_id: ownerConverted.type === 'org' ? ownerConverted.id : null, - owned_by_user_id: ownerConverted.type === 'user' ? ownerConverted.id : null, - queue_status: 'queued', - severity_rank: severityRank ?? severityRankBySeverity.low, - queued_at: sql`now()`, - updated_at: sql`now()`, - }) - .onConflictDoNothing() - .returning({ id: security_analysis_queue.id }); - enqueueCount = inserted.length; - } - }); - - return { - enqueueCount, - eligibleCount: eligible ? 1 : 0, - boundarySkipCount: isBoundarySkip ? 1 : 0, - unknownSeverityCount, - }; -} - export async function tryAcquireAnalysisStartLease(findingId: string): Promise { const [lease] = await db .update(security_findings) @@ -654,7 +441,7 @@ export async function enqueueBacklogFindings(params: { : sql`${security_findings.owned_by_user_id} = ${ownerConverted.id}`; // Use a single INSERT ... SELECT to bulk-enqueue eligible findings. - // severity_rank maps null severity to low (3) to match isFindingEligibleForAutoAnalysis. + // severity_rank maps null severity to low (3). const result = await db.execute<{ id: string }>(sql` INSERT INTO ${security_analysis_queue} ( finding_id, @@ -704,63 +491,6 @@ export async function enqueueBacklogFindings(params: { return result.rows.length; } -/** - * Remove superseded findings from the auto-analysis queue so the worker - * doesn't analyze findings that are no longer open. - * - * Targets both `queued` and `pending` (claimed but not yet running) rows - * because the auto-analysis worker may claim rows between per-finding - * enqueue and this repo-level cleanup. - * - * Clears `analysis_status` for `pending` findings so they no longer count - * against the owner's concurrency cap. Already-running analyses are left - * alone — the callback route transitions their queue rows when the job - * reports back, releasing the concurrency slot at that point. - */ -export async function dequeueSupersededFindings(findingIds: string[]): Promise { - if (findingIds.length === 0) return 0; - - const result = await db - .update(security_analysis_queue) - .set({ - queue_status: 'completed', - failure_code: 'SKIPPED_NO_LONGER_ELIGIBLE', - claim_token: null, - claimed_at: null, - claimed_by_job_id: null, - updated_at: sql`now()`, - }) - .where( - and( - inArray(security_analysis_queue.finding_id, findingIds), - or( - eq(security_analysis_queue.queue_status, 'queued'), - eq(security_analysis_queue.queue_status, 'pending') - ) - ) - ) - .returning({ id: security_analysis_queue.id }); - - // Clear pending analysis_status so countRunningAnalyses no longer counts - // these superseded findings against the owner's concurrency cap. - // Running analyses are left alone — the callback route transitions their - // queue rows when the job completes, releasing the concurrency slot. - await db - .update(security_findings) - .set({ - analysis_status: null, - updated_at: sql`now()`, - }) - .where( - and( - inArray(security_findings.id, findingIds), - eq(security_findings.analysis_status, 'pending') - ) - ); - - return result.length; -} - export async function transitionAutoAnalysisQueueFromCallback(params: { findingId: string; toStatus: 'completed' | 'failed'; 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 bc56cee3e5..b914603eef 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 @@ -13,8 +13,6 @@ const mockSubmitManualFindingDismissal = jest.fn() as jest.MockedFunction< const mockSubmitManualAnalysisStart = jest.fn() as jest.MockedFunction< typeof manualAnalysisClientModule.submitManualAnalysisStart >; -const mockSyncDependabotAlertsForRepo = jest.fn(); -const mockSyncAllReposForOwner = jest.fn(); const mockGetSecurityFindingById = jest.fn(); const mockCanStartAnalysis = jest.fn(); const mockTrackSecurityAgentSync = jest.fn(); @@ -32,11 +30,6 @@ jest.mock('../services/manual-analysis-client', () => ({ submitManualAnalysisStart: mockSubmitManualAnalysisStart, })); -jest.mock('../services/sync-service', () => ({ - syncDependabotAlertsForRepo: mockSyncDependabotAlertsForRepo, - syncAllReposForOwner: mockSyncAllReposForOwner, -})); - jest.mock('../github/permissions', () => ({ hasSecurityReviewPermissions: () => true, getReauthorizeUrl: jest.fn(), @@ -165,7 +158,6 @@ describe('setEnabled', () => { owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, }); - expect(mockSyncAllReposForOwner).not.toHaveBeenCalled(); }); }); @@ -182,10 +174,10 @@ describe('startAnalysis', () => { currentCount: 0, limit: 3, } as never); - mockSubmitManualAnalysisStart.mockResolvedValue({ accepted: true }); + mockSubmitManualAnalysisStart.mockResolvedValue({ queued: true }); }); - it('returns accepted Worker orchestration instead of launching Cloud Agent inline', async () => { + it('returns queued Worker orchestration instead of claiming analysis started inline', async () => { const handlers = createHandlers(); const result = await handlers.startAnalysis.handler({ ctx: { @@ -201,7 +193,7 @@ describe('startAnalysis', () => { }, }); - expect(result).toEqual({ success: true, accepted: true }); + expect(result).toEqual({ success: true, queued: true }); expect(mockSubmitManualAnalysisStart).toHaveBeenCalledWith({ findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, @@ -251,7 +243,6 @@ describe('triggerSync', () => { actor: { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }, repoFullName: 'kilo/repo', }); - expect(mockSyncDependabotAlertsForRepo).not.toHaveBeenCalled(); }); }); 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 287b66b068..65aa1d6713 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -925,7 +925,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); } - await submitManualAnalysisStart({ + const queued = await submitManualAnalysisStart({ findingId: input.findingId, owner: securityOwner, actorUserId: ctx.user.id, @@ -937,7 +937,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps retrySandboxOnly: input.retrySandboxOnly, }); - return { success: true, accepted: true }; + return { success: true, ...queued }; }, }, diff --git a/apps/web/src/lib/security-agent/services/analysis-service.test.ts b/apps/web/src/lib/security-agent/services/analysis-service.test.ts index 0126555713..2f977ba1ec 100644 --- a/apps/web/src/lib/security-agent/services/analysis-service.test.ts +++ b/apps/web/src/lib/security-agent/services/analysis-service.test.ts @@ -39,19 +39,13 @@ const mockClearAnalysisStatus = jest.fn() as jest.MockedFunction< typeof securityAnalysisModule.clearAnalysisStatus >; -jest.mock('@/lib/security-agent/db/security-analysis', () => { - const actual: { isFindingEligibleForAutoAnalysis: unknown } = jest.requireActual( - '@/lib/security-agent/db/security-analysis' - ); - return { - updateAnalysisStatus: mockUpdateAnalysisStatus, - clearAnalysisStatus: mockClearAnalysisStatus, - tryAcquireAnalysisStartLease: mockTryAcquireAnalysisStartLease, - isFindingEligibleForAutoAnalysis: actual.isFindingEligibleForAutoAnalysis, - AUTO_ANALYSIS_MAX_ATTEMPTS: 5, - AUTO_ANALYSIS_OWNER_CAP: 2, - }; -}); +jest.mock('@/lib/security-agent/db/security-analysis', () => ({ + updateAnalysisStatus: mockUpdateAnalysisStatus, + clearAnalysisStatus: mockClearAnalysisStatus, + tryAcquireAnalysisStartLease: mockTryAcquireAnalysisStartLease, + AUTO_ANALYSIS_MAX_ATTEMPTS: 5, + AUTO_ANALYSIS_OWNER_CAP: 2, +})); jest.mock('@/lib/config.server', () => ({ CALLBACK_TOKEN_SECRET: 'test-callback-token-secret', diff --git a/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts b/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts index da98bc2e63..ba90a0396a 100644 --- a/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts +++ b/apps/web/src/lib/security-agent/services/manual-analysis-client.test.ts @@ -13,7 +13,7 @@ describe('submitManualAnalysisStart', () => { mockFetch.mockReset(); }); - it('submits a durable manual analysis command and returns accepted state', async () => { + it('submits a durable manual analysis command and returns queued state', async () => { mockFetch.mockResolvedValue({ ok: true, status: 202, @@ -28,7 +28,7 @@ describe('submitManualAnalysisStart', () => { requestedModels: { analysisModel: 'analysis/model' }, retrySandboxOnly: true, }) - ).resolves.toEqual({ accepted: true }); + ).resolves.toEqual({ queued: true }); expect(mockFetch).toHaveBeenCalledWith( 'https://security-auto-analysis.test/internal/manual-analysis-start', diff --git a/apps/web/src/lib/security-agent/services/manual-analysis-client.ts b/apps/web/src/lib/security-agent/services/manual-analysis-client.ts index 975c496aeb..5ece737695 100644 --- a/apps/web/src/lib/security-agent/services/manual-analysis-client.ts +++ b/apps/web/src/lib/security-agent/services/manual-analysis-client.ts @@ -25,7 +25,7 @@ type ManualAnalysisResponse = { export async function submitManualAnalysisStart( params: ManualAnalysisStartParams -): Promise<{ accepted: true }> { +): Promise<{ queued: true }> { if (!SECURITY_AUTO_ANALYSIS_WORKER_URL) { throw new Error('SECURITY_AUTO_ANALYSIS_WORKER_URL is not configured'); } @@ -60,5 +60,5 @@ export async function submitManualAnalysisStart( if (body.success !== true || body.accepted !== true) { throw new Error('Security analysis Worker returned an invalid accepted response'); } - return { accepted: true }; + return { queued: true }; } diff --git a/apps/web/src/lib/security-agent/services/sync-service.test.ts b/apps/web/src/lib/security-agent/services/sync-service.test.ts deleted file mode 100644 index e3f0c2c778..0000000000 --- a/apps/web/src/lib/security-agent/services/sync-service.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import type * as dependabotApiModule from '../github/dependabot-api'; -import type * as parserModule from '../parsers/dependabot-parser'; -import type * as findingsDbModule from '../db/security-findings'; -import type * as configDbModule from '../db/security-config'; -import type * as analysisDbModule from '../db/security-analysis'; -import type { - syncAllReposForOwner as syncAllReposForOwnerType, - syncDependabotAlertsForRepo as syncDependabotAlertsForRepoType, -} from './sync-service'; - -const mockFetchAllDependabotAlerts = jest.fn() as jest.MockedFunction< - typeof dependabotApiModule.fetchAllDependabotAlerts ->; -const mockParseDependabotAlerts = jest.fn() as jest.MockedFunction< - typeof parserModule.parseDependabotAlerts ->; -const mockUpsertSecurityFinding = jest.fn() as jest.MockedFunction< - typeof findingsDbModule.upsertSecurityFinding ->; -const mockGetSecurityAgentConfigWithStatus = jest.fn() as jest.MockedFunction< - typeof configDbModule.getSecurityAgentConfigWithStatus ->; -const mockGetSecurityAgentConfig = jest.fn() as jest.MockedFunction< - typeof configDbModule.getSecurityAgentConfig ->; -const mockGetOwnerAutoAnalysisEnabledAt = jest.fn() as jest.MockedFunction< - typeof analysisDbModule.getOwnerAutoAnalysisEnabledAt ->; -const mockSyncAutoAnalysisQueueForFinding = jest.fn() as jest.MockedFunction< - typeof analysisDbModule.syncAutoAnalysisQueueForFinding ->; -const mockSupersedeDuplicateFindings = jest.fn() as jest.MockedFunction< - typeof findingsDbModule.supersedeDuplicateFindings ->; -const mockDequeueSupersededFindings = jest.fn() as jest.MockedFunction< - typeof analysisDbModule.dequeueSupersededFindings ->; -const mockSyncLogger = jest.fn(); -const mockCaptureException = jest.fn(); -const mockErrorExceptInTest = jest.fn(); -const mockWarnExceptInTest = jest.fn(); -let mockIntegrationAuthInvalidAt: string | null = null; -const mockDbLimit = jest.fn(async () => [{ authInvalidAt: mockIntegrationAuthInvalidAt }]); -const mockDbWhereSelect = jest.fn((_condition: unknown) => ({ limit: mockDbLimit })); -const mockDbFrom = jest.fn((_table: unknown) => ({ where: mockDbWhereSelect })); -const mockDbSelect = jest.fn((_selection?: unknown) => ({ from: mockDbFrom })); -const mockDbUpdateWhere = jest.fn(async (_condition: unknown) => undefined); -const mockDbSet = jest.fn((_values: unknown) => ({ where: mockDbUpdateWhere })); -const mockDbUpdate = jest.fn((_table: unknown) => ({ set: mockDbSet })); - -jest.mock('../github/dependabot-api', () => ({ - fetchAllDependabotAlerts: mockFetchAllDependabotAlerts, -})); - -jest.mock('../parsers/dependabot-parser', () => ({ - parseDependabotAlerts: mockParseDependabotAlerts, -})); - -jest.mock('../db/security-findings', () => ({ - upsertSecurityFinding: mockUpsertSecurityFinding, - supersedeDuplicateFindings: mockSupersedeDuplicateFindings, -})); - -jest.mock('../db/security-config', () => ({ - getSecurityAgentConfigWithStatus: mockGetSecurityAgentConfigWithStatus, - getSecurityAgentConfig: mockGetSecurityAgentConfig, -})); - -jest.mock('../db/security-analysis', () => ({ - getOwnerAutoAnalysisEnabledAt: mockGetOwnerAutoAnalysisEnabledAt, - syncAutoAnalysisQueueForFinding: mockSyncAutoAnalysisQueueForFinding, - dequeueSupersededFindings: mockDequeueSupersededFindings, -})); - -jest.mock('@/lib/drizzle', () => ({ - db: { - select: mockDbSelect, - update: mockDbUpdate, - }, -})); -jest.mock('@kilocode/db/schema', () => ({ - platform_integrations: { - id: 'platform_integrations.id', - auth_invalid_at: 'platform_integrations.auth_invalid_at', - }, - agent_configs: { - agent_type: 'agent_configs.agent_type', - platform: 'agent_configs.platform', - owned_by_organization_id: 'agent_configs.owned_by_organization_id', - owned_by_user_id: 'agent_configs.owned_by_user_id', - runtime_state: 'agent_configs.runtime_state', - }, -})); -jest.mock('drizzle-orm', () => ({ - and: jest.fn(() => 'and'), - eq: jest.fn(() => 'eq'), - isNotNull: jest.fn(() => 'isNotNull'), - sql: jest.fn(() => 'sql'), -})); -jest.mock('../github/permissions', () => ({ hasSecurityReviewPermissions: () => true })); -jest.mock('@sentry/nextjs', () => ({ captureException: mockCaptureException })); -jest.mock('@/lib/utils.server', () => ({ - sentryLogger: () => mockSyncLogger, - errorExceptInTest: mockErrorExceptInTest, - warnExceptInTest: mockWarnExceptInTest, -})); -jest.mock('./audit-log-service', () => ({ - logSecurityAudit: jest.fn(), - SecurityAuditLogAction: { SyncCompleted: 'sync_completed' }, -})); -jest.mock('../posthog-tracking', () => ({ trackSecurityAgentFullSync: jest.fn() })); - -let syncDependabotAlertsForRepo: typeof syncDependabotAlertsForRepoType; -let syncAllReposForOwner: typeof syncAllReposForOwnerType; - -beforeAll(async () => { - ({ syncAllReposForOwner, syncDependabotAlertsForRepo } = await import('./sync-service')); -}); - -describe('sync-service queue enqueue wiring', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockIntegrationAuthInvalidAt = null; - mockFetchAllDependabotAlerts.mockResolvedValue({ status: 'success', alerts: [] }); - mockParseDependabotAlerts.mockReturnValue([ - { - source: 'dependabot', - source_id: '101', - severity: 'high', - ghsa_id: 'GHSA-1', - cve_id: null, - package_name: 'lodash', - package_ecosystem: 'npm', - vulnerable_version_range: '<4.17.21', - patched_version: '4.17.21', - manifest_path: 'package.json', - title: 'test finding', - description: 'desc', - status: 'open', - ignored_reason: null, - ignored_by: null, - fixed_at: null, - dependabot_html_url: null, - first_detected_at: '2026-01-01T00:00:00.000Z', - raw_data: {} as never, - cwe_ids: null, - cvss_score: null, - dependency_scope: 'runtime', - }, - ]); - const config: Awaited> = { - sla_critical_days: 15, - sla_high_days: 30, - sla_medium_days: 45, - sla_low_days: 90, - auto_sync_enabled: true, - repository_selection_mode: 'all', - model_slug: 'anthropic/claude-opus-4.6', - analysis_mode: 'auto', - auto_dismiss_enabled: false, - auto_dismiss_confidence_threshold: 'high', - auto_analysis_enabled: true, - auto_analysis_min_severity: 'high', - auto_analysis_include_existing: false, - }; - const configWithStatus: Awaited> = { - isEnabled: true, - config, - storedConfig: config, - }; - mockGetSecurityAgentConfigWithStatus.mockResolvedValue(configWithStatus); - mockGetSecurityAgentConfig.mockResolvedValue(config); - mockGetOwnerAutoAnalysisEnabledAt.mockResolvedValue('2026-01-01T00:00:00.000Z'); - mockUpsertSecurityFinding.mockResolvedValue({ - findingId: 'finding-1', - wasInserted: true, - previousStatus: null, - effectiveStatus: 'open', - findingCreatedAt: '2026-01-01T00:00:00.000Z', - }); - mockSyncAutoAnalysisQueueForFinding.mockResolvedValue({ - enqueueCount: 1, - eligibleCount: 1, - boundarySkipCount: 0, - unknownSeverityCount: 0, - }); - mockSupersedeDuplicateFindings.mockResolvedValue({ - count: 0, - supersededFindingIds: [], - }); - mockDequeueSupersededFindings.mockResolvedValue(0); - }); - - it('passes upsert metadata into auto-analysis queue sync', async () => { - await syncDependabotAlertsForRepo({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repoFullName: 'acme/repo', - }); - - expect(mockSyncAutoAnalysisQueueForFinding).toHaveBeenCalledWith( - expect.objectContaining({ - findingId: 'finding-1', - previousStatus: null, - currentStatus: 'open', - findingCreatedAt: '2026-01-01T00:00:00.000Z', - autoAnalysisEnabled: true, - isAgentEnabled: true, - }) - ); - }); - - it('uses effectiveStatus (not payload status) so superseded findings are not re-queued', async () => { - mockUpsertSecurityFinding.mockResolvedValue({ - findingId: 'finding-superseded', - wasInserted: false, - previousStatus: 'ignored', - effectiveStatus: 'ignored', - findingCreatedAt: '2026-01-01T00:00:00.000Z', - }); - - await syncDependabotAlertsForRepo({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repoFullName: 'acme/repo', - }); - - expect(mockSyncAutoAnalysisQueueForFinding).toHaveBeenCalledWith( - expect.objectContaining({ - findingId: 'finding-superseded', - previousStatus: 'ignored', - currentStatus: 'ignored', - }) - ); - }); - - it('logs queue enqueue observability fields for each sync', async () => { - await syncDependabotAlertsForRepo({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repoFullName: 'acme/repo', - }); - - expect(mockSyncLogger).toHaveBeenCalledWith( - 'Repo sync complete', - expect.objectContaining({ - enqueue_count_per_sync: 1, - eligible_count_per_sync: 1, - boundary_skip_count: 0, - unknown_severity_count: 0, - }) - ); - }); - - it('handles all auth_invalid repos without throwing, freshness advancement, or Sentry capture', async () => { - mockFetchAllDependabotAlerts.mockResolvedValue({ status: 'auth_invalid' }); - - await expect( - syncAllReposForOwner({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repositories: ['acme/widgets', 'acme/api', 'acme/web'], - }) - ).resolves.toEqual( - expect.objectContaining({ - errors: 0, - authInvalid: 1, - authInvalidRepos: ['acme/widgets'], - reauthRequired: true, - }) - ); - - expect(mockFetchAllDependabotAlerts).toHaveBeenCalledTimes(1); - expect(mockCaptureException).not.toHaveBeenCalled(); - expect(mockDbSet.mock.calls.map(call => call[0])).not.toContainEqual( - expect.objectContaining({ runtime_state: expect.anything() }) - ); - }); - - it('does not advance freshness for mixed success and auth_invalid repos', async () => { - mockFetchAllDependabotAlerts - .mockResolvedValueOnce({ status: 'success', alerts: [] }) - .mockResolvedValueOnce({ status: 'auth_invalid' }); - mockParseDependabotAlerts.mockReturnValue([]); - - const result = await syncAllReposForOwner({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repositories: ['acme/widgets', 'acme/api', 'acme/web'], - }); - - expect(result).toEqual( - expect.objectContaining({ - errors: 0, - authInvalid: 1, - reauthRequired: true, - }) - ); - expect(mockFetchAllDependabotAlerts).toHaveBeenCalledTimes(2); - expect(mockDbSet.mock.calls.map(call => call[0])).not.toContainEqual( - expect.objectContaining({ runtime_state: expect.anything() }) - ); - }); - - it('marks the installation auth-invalid after a 401-derived fetch result', async () => { - mockFetchAllDependabotAlerts.mockResolvedValue({ status: 'auth_invalid' }); - - await syncAllReposForOwner({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repositories: ['acme/widgets'], - }); - - expect(mockDbSet).toHaveBeenCalledWith( - expect.objectContaining({ - auth_invalid_at: expect.any(String), - auth_invalid_reason: 'github_dependabot_401', - }) - ); - }); - - it('refreshes expired auth-invalid state after GitHub still returns 401', async () => { - mockIntegrationAuthInvalidAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - mockFetchAllDependabotAlerts.mockResolvedValue({ status: 'auth_invalid' }); - - const result = await syncAllReposForOwner({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repositories: ['acme/widgets'], - }); - - expect(result).toEqual( - expect.objectContaining({ - authInvalid: 1, - authInvalidRepos: ['acme/widgets'], - reauthRequired: true, - }) - ); - expect(mockFetchAllDependabotAlerts).toHaveBeenCalledTimes(1); - expect(mockDbSet).toHaveBeenCalledWith( - expect.objectContaining({ - auth_invalid_at: expect.any(String), - auth_invalid_reason: 'github_dependabot_401', - }) - ); - }); - - it('short-circuits recent auth-invalid installations without GitHub calls', async () => { - mockIntegrationAuthInvalidAt = new Date().toISOString(); - - const result = await syncAllReposForOwner({ - owner: { userId: 'user-1' }, - platformIntegrationId: 'integration-1', - installationId: 'inst-1', - repositories: ['acme/widgets', 'acme/api'], - }); - - expect(result).toEqual( - expect.objectContaining({ - authInvalid: 2, - authInvalidRepos: ['acme/widgets', 'acme/api'], - reauthRequired: true, - }) - ); - expect(mockFetchAllDependabotAlerts).not.toHaveBeenCalled(); - expect(mockDbUpdate).not.toHaveBeenCalled(); - expect(mockCaptureException).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/lib/security-agent/services/sync-service.ts b/apps/web/src/lib/security-agent/services/sync-service.ts deleted file mode 100644 index 4b0bac12dc..0000000000 --- a/apps/web/src/lib/security-agent/services/sync-service.ts +++ /dev/null @@ -1,782 +0,0 @@ -import { captureException } from '@sentry/nextjs'; -import { trackSecurityAgentFullSync } from '../posthog-tracking'; -import { db } from '@/lib/drizzle'; -import { platform_integrations, agent_configs } from '@kilocode/db/schema'; -import { eq, and, isNotNull, sql } from 'drizzle-orm'; -import { fetchAllDependabotAlerts } from '../github/dependabot-api'; -import { hasSecurityReviewPermissions } from '../github/permissions'; -import { parseDependabotAlerts } from '../parsers/dependabot-parser'; -import { upsertSecurityFinding, supersedeDuplicateFindings } from '../db/security-findings'; -import { getSecurityAgentConfig, getSecurityAgentConfigWithStatus } from '../db/security-config'; -import { - getOwnerAutoAnalysisEnabledAt, - syncAutoAnalysisQueueForFinding, - dequeueSupersededFindings, - type AutoAnalysisQueueSyncResult, -} from '../db/security-analysis'; -import { upsertAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; -import { - getSlaForSeverity, - calculateSlaDueAt, - type SecurityReviewOwner, - type SyncResult, -} from '../core/types'; -import type { Owner } from '@/lib/code-reviews/core'; -import { errorExceptInTest, sentryLogger, warnExceptInTest } from '@/lib/utils.server'; -import { logSecurityAuditAndWait, SecurityAuditLogAction } from './audit-log-service'; - -const log = sentryLogger('security-agent:sync', 'info'); -const warn = sentryLogger('security-agent:sync', 'warning'); -const logError = sentryLogger('security-agent:sync', 'error'); - -const AUTH_INVALID_SHORT_CIRCUIT_MS = 60 * 60 * 1000; -const AUTH_INVALID_WRITE_THROTTLE_MS = AUTH_INVALID_SHORT_CIRCUIT_MS; - -function createEmptySyncResult(): SyncResult { - return { - synced: 0, - created: 0, - updated: 0, - errors: 0, - skipped: 0, - authInvalid: 0, - authInvalidRepos: [], - reauthRequired: false, - staleRepos: [], - }; -} - -function isRecentTimestamp(value: string | null | undefined, windowMs: number): boolean { - if (!value) return false; - const timestamp = new Date(value).getTime(); - return Number.isFinite(timestamp) && Date.now() - timestamp < windowMs; -} - -function createAuthInvalidSyncResult(repositories: string[]): SyncResult { - return { - ...createEmptySyncResult(), - authInvalid: repositories.length, - authInvalidRepos: [...repositories], - reauthRequired: true, - }; -} - -async function getIntegrationAuthInvalidAt(platformIntegrationId: string): Promise { - const [integration] = await db - .select({ authInvalidAt: platform_integrations.auth_invalid_at }) - .from(platform_integrations) - .where(eq(platform_integrations.id, platformIntegrationId)) - .limit(1); - - return integration?.authInvalidAt ?? null; -} - -async function markIntegrationAuthInvalid( - platformIntegrationId: string, - reason: string -): Promise { - try { - const now = new Date().toISOString(); - await db - .update(platform_integrations) - .set({ - auth_invalid_at: now, - auth_invalid_reason: reason, - updated_at: now, - }) - .where( - and( - eq(platform_integrations.id, platformIntegrationId), - sql`(${platform_integrations.auth_invalid_at} IS NULL OR ${platform_integrations.auth_invalid_at} < now() - ${AUTH_INVALID_WRITE_THROTTLE_MS} * interval '1 millisecond')` - ) - ); - } catch (error) { - logError('Failed to mark GitHub integration auth invalid', { error, platformIntegrationId }); - } -} - -async function clearIntegrationAuthInvalid(platformIntegrationId: string): Promise { - try { - const now = new Date().toISOString(); - await db - .update(platform_integrations) - .set({ - auth_invalid_at: null, - auth_invalid_reason: null, - updated_at: now, - }) - .where( - and( - eq(platform_integrations.id, platformIntegrationId), - isNotNull(platform_integrations.auth_invalid_at) - ) - ); - } catch (error) { - logError('Failed to clear GitHub integration auth invalid state', { - error, - platformIntegrationId, - }); - captureException(error, { - tags: { operation: 'clearIntegrationAuthInvalid' }, - extra: { platformIntegrationId }, - }); - } -} - -export async function updateLastSyncedAt(owner: SecurityReviewOwner): Promise { - try { - const { type, id } = toAgentConfigOwner(owner); - const ownerCondition = - type === 'org' - ? eq(agent_configs.owned_by_organization_id, id) - : eq(agent_configs.owned_by_user_id, id); - - await db - .update(agent_configs) - .set({ - runtime_state: sql`jsonb_set( - COALESCE(${agent_configs.runtime_state}, '{}'::jsonb), - '{last_synced_at}', - to_jsonb(now()) - )`, - }) - .where( - and( - eq(agent_configs.agent_type, 'security_scan'), - eq(agent_configs.platform, 'github'), - ownerCondition - ) - ); - } catch (error) { - logError('Failed to update last_synced_at in runtime_state', { error }); - captureException(error, { - tags: { operation: 'updateLastSyncedAt' }, - }); - } -} - -function toAgentConfigOwner(owner: SecurityReviewOwner): Owner { - if (owner.organizationId) { - return { type: 'org', id: owner.organizationId, userId: 'system' }; - } - if (owner.userId) { - return { type: 'user', id: owner.userId, userId: owner.userId }; - } - throw new Error('Invalid owner: must have either organizationId or userId'); -} - -export async function syncDependabotAlertsForRepo(params: { - owner: SecurityReviewOwner; - platformIntegrationId: string; - installationId: string; - repoFullName: string; -}): Promise { - const { owner, platformIntegrationId, installationId, repoFullName } = params; - const repoStartTime = performance.now(); - - log(`Starting sync for ${repoFullName}`, { installationId }); - - const result = createEmptySyncResult(); - const queueSyncTotals: AutoAnalysisQueueSyncResult = { - enqueueCount: 0, - eligibleCount: 0, - boundarySkipCount: 0, - unknownSeverityCount: 0, - }; - - try { - const authInvalidAt = await getIntegrationAuthInvalidAt(platformIntegrationId); - if (isRecentTimestamp(authInvalidAt, AUTH_INVALID_SHORT_CIRCUIT_MS)) { - warnExceptInTest('Skipping security sync because GitHub installation needs reauthorization', { - platformIntegrationId, - repoFullName, - authInvalidAt, - }); - return createAuthInvalidSyncResult([repoFullName]); - } - - const [repoOwner, repoName] = repoFullName.split('/'); - if (!repoOwner || !repoName) { - throw new Error(`Invalid repo full name: ${repoFullName}`); - } - - const fetchResult = await fetchAllDependabotAlerts(installationId, repoOwner, repoName); - - if (fetchResult.status === 'repo_not_found') { - warn(`Repository ${repoFullName} no longer exists, marking as stale`); - result.staleRepos.push(repoFullName); - return result; - } - - if (fetchResult.status === 'alerts_disabled') { - warn(`Dependabot alerts disabled for ${repoFullName}, skipping`); - result.skipped = 1; - return result; - } - - if (fetchResult.status === 'access_blocked') { - warn(`Repository ${repoFullName} access blocked, marking as stale`); - result.staleRepos.push(repoFullName); - return result; - } - - if (fetchResult.status === 'auth_invalid') { - warnExceptInTest('GitHub installation needs reauthorization; skipping repo sync', { - platformIntegrationId, - installationId, - repoFullName, - }); - await markIntegrationAuthInvalid(platformIntegrationId, 'github_dependabot_401'); - result.authInvalid = 1; - result.authInvalidRepos.push(repoFullName); - result.reauthRequired = true; - return result; - } - - await clearIntegrationAuthInvalid(platformIntegrationId); - - const alerts = fetchResult.alerts; - log(`Fetched ${alerts.length} alerts from GitHub for ${repoFullName}`); - - const findings = parseDependabotAlerts(alerts, repoFullName); - log(`Parsed ${findings.length} findings for ${repoFullName}`); - - const configOwner = toAgentConfigOwner(owner); - const configWithStatus = await getSecurityAgentConfigWithStatus(configOwner); - const config = configWithStatus?.config ?? (await getSecurityAgentConfig(configOwner)); - const isAgentEnabled = configWithStatus?.isEnabled ?? false; - const ownerAutoAnalysisEnabledAt = await getOwnerAutoAnalysisEnabledAt(owner); - - for (const finding of findings) { - try { - const slaDays = getSlaForSeverity(config, finding.severity); - const slaDueAt = calculateSlaDueAt(finding.first_detected_at, slaDays); - - const upsertResult = await upsertSecurityFinding({ - ...finding, - owner, - platformIntegrationId, - repoFullName, - slaDueAt, - }); - - result.synced++; - if (upsertResult.wasInserted) { - result.created++; - } else { - result.updated++; - } - - try { - const queueSyncResult = await syncAutoAnalysisQueueForFinding({ - owner, - findingId: upsertResult.findingId, - findingCreatedAt: upsertResult.findingCreatedAt, - previousStatus: upsertResult.previousStatus, - currentStatus: upsertResult.effectiveStatus, - severity: finding.severity, - isAgentEnabled, - autoAnalysisEnabled: config.auto_analysis_enabled, - autoAnalysisMinSeverity: config.auto_analysis_min_severity, - ownerAutoAnalysisEnabledAt, - autoAnalysisIncludeExisting: config.auto_analysis_include_existing, - }); - queueSyncTotals.enqueueCount += queueSyncResult.enqueueCount; - queueSyncTotals.eligibleCount += queueSyncResult.eligibleCount; - queueSyncTotals.boundarySkipCount += queueSyncResult.boundarySkipCount; - queueSyncTotals.unknownSeverityCount += queueSyncResult.unknownSeverityCount; - } catch (error) { - logError(`Error syncing auto-analysis queue for ${repoFullName}`, { - error, - alertNumber: finding.source_id, - findingId: upsertResult.findingId, - }); - captureException(error, { - tags: { operation: 'syncDependabotAlertsForRepo', step: 'syncAutoAnalysisQueue' }, - extra: { - repoFullName, - alertNumber: finding.source_id, - findingId: upsertResult.findingId, - }, - }); - } - } catch (error) { - result.errors++; - logError(`Error upserting finding for ${repoFullName}`, { - error, - alertNumber: finding.source_id, - }); - captureException(error, { - tags: { operation: 'syncDependabotAlertsForRepo', step: 'upsertFinding' }, - extra: { repoFullName, alertNumber: finding.source_id }, - }); - } - } - - try { - const { count: supersededCount, supersededFindingIds } = - await supersedeDuplicateFindings(repoFullName); - if (supersededCount > 0) { - log(`Superseded ${supersededCount} duplicate finding(s) for ${repoFullName}`); - const dequeued = await dequeueSupersededFindings(supersededFindingIds); - if (dequeued > 0) { - log(`Dequeued ${dequeued} superseded finding(s) from auto-analysis queue`); - } - } - } catch (error) { - logError(`Error superseding duplicate findings for ${repoFullName}`, { error }); - captureException(error, { - tags: { operation: 'syncDependabotAlertsForRepo', step: 'supersedeDuplicates' }, - extra: { repoFullName }, - }); - } - - const repoDurationMs = Math.round(performance.now() - repoStartTime); - log(`Repo sync complete`, { - repo: repoFullName, - durationMs: repoDurationMs, - alertsSynced: result.synced, - errors: result.errors, - enqueue_count_per_sync: queueSyncTotals.enqueueCount, - eligible_count_per_sync: queueSyncTotals.eligibleCount, - boundary_skip_count: queueSyncTotals.boundarySkipCount, - unknown_severity_count: queueSyncTotals.unknownSeverityCount, - }); - - return result; - } catch (error) { - const repoDurationMs = Math.round(performance.now() - repoStartTime); - errorExceptInTest(`Error syncing ${repoFullName}`, { durationMs: repoDurationMs, error }); - captureException(error, { - tags: { operation: 'syncDependabotAlertsForRepo' }, - extra: { repoFullName }, - }); - throw error; - } -} - -/** - * Sync all repos for an owner. Throws the first error if every repo fails. - * Stale repos (GitHub 404) are returned for pruning. - */ -export async function syncAllReposForOwner(params: { - owner: SecurityReviewOwner; - platformIntegrationId: string; - installationId: string; - repositories: string[]; - missingSelectedRepoCount?: number; -}): Promise { - const { - owner, - platformIntegrationId, - installationId, - repositories, - missingSelectedRepoCount = 0, - } = params; - const syncStartTime = performance.now(); - - const totalResult = createEmptySyncResult(); - - const authInvalidAt = await getIntegrationAuthInvalidAt(platformIntegrationId); - if (isRecentTimestamp(authInvalidAt, AUTH_INVALID_SHORT_CIRCUIT_MS)) { - warnExceptInTest('Skipping security sync because GitHub installation needs reauthorization', { - platformIntegrationId, - repositoryCount: repositories.length, - authInvalidAt, - }); - return createAuthInvalidSyncResult(repositories); - } - - let firstError: Error | null = null; - let successfulRepos = 0; - - for (const repoFullName of repositories) { - try { - const result = await syncDependabotAlertsForRepo({ - owner, - platformIntegrationId, - installationId, - repoFullName, - }); - - totalResult.synced += result.synced; - totalResult.created += result.created; - totalResult.updated += result.updated; - totalResult.errors += result.errors; - totalResult.skipped += result.skipped; - totalResult.authInvalid += result.authInvalid; - totalResult.authInvalidRepos.push(...result.authInvalidRepos); - totalResult.reauthRequired = totalResult.reauthRequired || result.reauthRequired; - totalResult.staleRepos.push(...result.staleRepos); - successfulRepos++; - - if (result.reauthRequired) { - break; - } - } catch (error) { - totalResult.errors++; - errorExceptInTest(`Failed to sync ${repoFullName}`, { error }); - if (!firstError && error instanceof Error) { - firstError = error; - } - } - } - - if (successfulRepos === 0 && firstError) { - throw firstError; - } - - // Only advance owner-level freshness when every repo was actually synced. - // Stale repos (deleted/transferred/access-blocked) block the update because - // they were selected for sync but never refreshed. Skipped repos - // (Dependabot permanently disabled) do NOT block — that's a permanent - // repo-level setting, and blocking here would leave the timestamp stuck. - // Missing selected repos (installation lost access) also block — the repo - // was configured but silently dropped from the accessible list. - if ( - totalResult.errors === 0 && - totalResult.authInvalid === 0 && - totalResult.staleRepos.length === 0 && - missingSelectedRepoCount === 0 - ) { - await updateLastSyncedAt(owner); - } - - const totalDurationMs = Math.round(performance.now() - syncStartTime); - if ( - totalResult.synced === 0 && - totalResult.errors === 0 && - totalResult.skipped === 0 && - totalResult.authInvalid === 0 - ) { - warn('Sync completed with zero findings processed across all repos', { - reposScanned: repositories.length, - missingSelectedRepos: missingSelectedRepoCount, - durationMs: totalDurationMs, - }); - } else { - log('Sync cycle summary', { - reposScanned: repositories.length, - findingsSynced: totalResult.synced, - findingsCreated: totalResult.created, - findingsUpdated: totalResult.updated, - errors: totalResult.errors, - skippedRepos: totalResult.skipped, - authInvalidRepos: totalResult.authInvalid, - reauthRequired: totalResult.reauthRequired, - missingSelectedRepos: missingSelectedRepoCount, - durationMs: totalDurationMs, - }); - } - - return totalResult; -} - -type EnabledSecurityReviewConfig = { - owner: SecurityReviewOwner; - platformIntegrationId: string; - installationId: string; - repositories: string[]; - /** Maps repo full_name to its numeric ID for pruning stale repos from selected_repository_ids */ - repoNameToId: Map; - /** Number of selected_repository_ids that are no longer accessible via the installation. - * Non-zero means the app lost access to a configured repo — freshness must not advance. */ - missingSelectedRepoCount: number; -}; - -export async function getEnabledSecurityReviewConfigs(): Promise { - const configs = await db - .select() - .from(agent_configs) - .where(and(eq(agent_configs.agent_type, 'security_scan'), eq(agent_configs.is_enabled, true))); - - const results: EnabledSecurityReviewConfig[] = []; - - for (const config of configs) { - const orgId = config.owned_by_organization_id; - const userId = config.owned_by_user_id; - - if (!orgId && !userId) { - log(`Config ${config.id} has no owner, skipping`); - continue; - } - - const ownerCondition = orgId - ? eq(platform_integrations.owned_by_organization_id, orgId) - : eq(platform_integrations.owned_by_user_id, userId as string); - - const [integration] = await db - .select() - .from(platform_integrations) - .where( - and( - ownerCondition, - eq(platform_integrations.platform, 'github'), - isNotNull(platform_integrations.platform_installation_id) - ) - ) - .limit(1); - - if (!integration || !integration.platform_installation_id) { - log(`No GitHub integration found for config ${config.id}, skipping`); - continue; - } - - if (!hasSecurityReviewPermissions(integration)) { - log(`Integration ${integration.id} missing vulnerability_alerts permission, skipping`); - continue; - } - - const allRepositories = (integration.repositories || []).filter( - (r): r is { id: number; full_name: string; name: string; private: boolean } => - typeof r.id === 'number' && typeof r.full_name === 'string' && r.full_name.length > 0 - ); - - if (allRepositories.length === 0) { - log(`No repositories found for integration ${integration.id}, skipping`); - continue; - } - - const repoNameToId = new Map(allRepositories.map(r => [r.full_name, r.id])); - - const securityConfig = config.config as { - repository_selection_mode?: 'all' | 'selected'; - selected_repository_ids?: number[]; - }; - - let selectedRepos: string[]; - let missingSelectedRepoCount = 0; - if (securityConfig.repository_selection_mode === 'selected') { - const selectedIds = new Set(securityConfig.selected_repository_ids ?? []); - if (selectedIds.size > 0) { - const accessibleIds = new Set(allRepositories.map(r => r.id)); - selectedRepos = allRepositories.filter(r => selectedIds.has(r.id)).map(r => r.full_name); - missingSelectedRepoCount = [...selectedIds].filter(id => !accessibleIds.has(id)).length; - } else { - // Mode is 'selected' but no repos are configured — don't fall through to 'all' - selectedRepos = []; - } - } else { - selectedRepos = allRepositories.map(r => r.full_name); - } - - const owner: SecurityReviewOwner = orgId - ? { organizationId: orgId } - : { userId: userId as string }; - - if (selectedRepos.length === 0 && missingSelectedRepoCount === 0) { - log(`No selected repositories for config ${config.id}, skipping`); - continue; - } - - if (missingSelectedRepoCount > 0) { - warn( - `${missingSelectedRepoCount} selected repo(s) no longer accessible for config ${config.id}`, - { owner } - ); - } - - results.push({ - owner, - platformIntegrationId: integration.id, - installationId: integration.platform_installation_id, - repositories: selectedRepos, - repoNameToId, - missingSelectedRepoCount, - }); - } - - return results; -} - -const SECURITY_SCAN_AGENT_TYPE = 'security_scan'; -const SECURITY_SCAN_PLATFORM = 'github'; - -/** Remove stale repos from selected_repository_ids when using 'selected' mode. */ -async function pruneStaleReposFromConfig( - owner: SecurityReviewOwner, - staleRepoNames: string[], - repoNameToId: Map -): Promise { - if (staleRepoNames.length === 0) return; - - const staleIds = new Set( - staleRepoNames.map(name => repoNameToId.get(name)).filter((id): id is number => id != null) - ); - if (staleIds.size === 0) return; - - const agentOwner = toAgentConfigOwner(owner); - const configWithStatus = await getSecurityAgentConfigWithStatus(agentOwner); - if (!configWithStatus) return; - - const { config, isEnabled } = configWithStatus; - - if ( - config.repository_selection_mode !== 'selected' || - !config.selected_repository_ids || - config.selected_repository_ids.length === 0 - ) { - return; - } - - const prunedIds = config.selected_repository_ids.filter(id => !staleIds.has(id)); - if (prunedIds.length === config.selected_repository_ids.length) return; - - const prunedRepoNames = staleRepoNames.filter(name => repoNameToId.has(name)); - const removedCount = config.selected_repository_ids.length - prunedIds.length; - warn( - `Pruning ${removedCount} stale repo(s) from security config: ${prunedRepoNames.join(', ')}`, - { owner } - ); - - await upsertAgentConfigForOwner({ - owner: agentOwner, - agentType: SECURITY_SCAN_AGENT_TYPE, - platform: SECURITY_SCAN_PLATFORM, - config: { ...config, selected_repository_ids: prunedIds }, - isEnabled, - createdBy: 'system-sync-prune', - }); -} - -/** Remove selected_repository_ids that are no longer accessible via the GitHub installation. - * Unlike pruneStaleReposFromConfig (which prunes by repo name after sync), this handles - * repos that silently vanished from the installation and were never synced at all. */ -async function pruneMissingSelectedRepos( - owner: SecurityReviewOwner, - accessibleRepoIds: Set -): Promise { - const agentOwner = toAgentConfigOwner(owner); - const configWithStatus = await getSecurityAgentConfigWithStatus(agentOwner); - if (!configWithStatus) return; - - const { config, isEnabled } = configWithStatus; - - if ( - config.repository_selection_mode !== 'selected' || - !config.selected_repository_ids || - config.selected_repository_ids.length === 0 - ) { - return; - } - - const prunedIds = config.selected_repository_ids.filter(id => accessibleRepoIds.has(id)); - if (prunedIds.length === config.selected_repository_ids.length) return; - - const removedCount = config.selected_repository_ids.length - prunedIds.length; - warn(`Pruning ${removedCount} inaccessible repo ID(s) from security config`, { owner }); - - await upsertAgentConfigForOwner({ - owner: agentOwner, - agentType: SECURITY_SCAN_AGENT_TYPE, - platform: SECURITY_SCAN_PLATFORM, - config: { ...config, selected_repository_ids: prunedIds }, - isEnabled, - createdBy: 'system-sync-prune', - }); -} - -export async function runFullSync(): Promise<{ - totalSynced: number; - totalErrors: number; - configsProcessed: number; -}> { - log('Starting full security alerts sync...'); - const startTime = performance.now(); - - const configs = await getEnabledSecurityReviewConfigs(); - log(`Found ${configs.length} enabled configurations`); - - let totalSynced = 0; - let totalErrors = 0; - - for (const config of configs) { - try { - const result = await syncAllReposForOwner(config); - totalSynced += result.synced; - totalErrors += result.errors; - - if (result.staleRepos.length > 0) { - try { - await pruneStaleReposFromConfig(config.owner, result.staleRepos, config.repoNameToId); - } catch (pruneError) { - logError('Failed to prune stale repos from config', { - error: pruneError, - staleRepos: result.staleRepos, - owner: config.owner, - }); - captureException(pruneError, { - tags: { operation: 'runFullSync', step: 'pruneStaleRepos' }, - extra: { owner: config.owner, staleRepos: result.staleRepos }, - }); - } - } - - if (config.missingSelectedRepoCount > 0) { - try { - const accessibleRepoIds = new Set(config.repoNameToId.values()); - await pruneMissingSelectedRepos(config.owner, accessibleRepoIds); - } catch (pruneError) { - logError('Failed to prune missing selected repos from config', { - error: pruneError, - missingCount: config.missingSelectedRepoCount, - owner: config.owner, - }); - captureException(pruneError, { - tags: { operation: 'runFullSync', step: 'pruneMissingSelectedRepos' }, - extra: { owner: config.owner, missingCount: config.missingSelectedRepoCount }, - }); - } - } - - const ownerId = - 'organizationId' in config.owner - ? (config.owner.organizationId ?? 'unknown') - : (config.owner.userId ?? 'unknown'); - await logSecurityAuditAndWait( - { - owner: config.owner, - actor_id: null, - actor_email: null, - actor_name: null, - action: SecurityAuditLogAction.SyncCompleted, - resource_type: 'agent_config', - resource_id: ownerId, - metadata: { - source: 'system', - trigger: 'cron', - synced: result.synced, - errors: result.errors, - repoCount: config.repositories.length, - }, - }, - 1500 - ); - } catch (error) { - totalErrors++; - captureException(error, { - tags: { operation: 'runFullSync' }, - extra: { owner: config.owner }, - }); - } - } - - const duration = Math.round(performance.now() - startTime); - log( - `Full sync completed in ${duration}ms: ${totalSynced} alerts synced, ${totalErrors} errors, ${configs.length} configs processed` - ); - - trackSecurityAgentFullSync({ - distinctId: 'system-cron', - configsProcessed: configs.length, - totalSynced, - totalErrors, - durationMs: duration, - }); - - return { - totalSynced, - totalErrors, - configsProcessed: configs.length, - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d3a1dd970..f18c5b5b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2498,7 +2498,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) wrangler: specifier: 'catalog:' version: 4.90.1(@cloudflare/workers-types@4.20260511.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) diff --git a/services/cloud-agent-next/src/server.ts b/services/cloud-agent-next/src/server.ts index 31a129859b..79e55ac461 100644 --- a/services/cloud-agent-next/src/server.ts +++ b/services/cloud-agent-next/src/server.ts @@ -332,7 +332,7 @@ export default { return app.fetch(request, env, ctx); }, - async queue(batch: MessageBatch): Promise { + async queue(batch: MessageBatch, env: Env): Promise { if (batch.queue.startsWith('cloud-agent-next-callback-queue')) { const consumer = createCallbackQueueConsumer(); return consumer(batch as MessageBatch); diff --git a/services/security-auto-analysis/src/consumer.test.ts b/services/security-auto-analysis/src/consumer.test.ts index f1757f34f5..e13359be66 100644 --- a/services/security-auto-analysis/src/consumer.test.ts +++ b/services/security-auto-analysis/src/consumer.test.ts @@ -272,6 +272,7 @@ describe('consumeOwnerBatch scheduled lifecycle handoff', () => { HYPERDRIVE: { connectionString: 'postgres://example' }, NEXTAUTH_SECRET: { get: async () => 'next-auth-secret' }, INTERNAL_API_SECRET: { get: async () => 'internal-api-secret' }, + CALLBACK_TOKEN_SECRET: { get: async () => 'callback-token-secret' }, GIT_TOKEN_SERVICE: { getTokenForRepo: async () => ({ success: true, token: 'github-token' }), }, diff --git a/services/security-auto-analysis/src/launch.test.ts b/services/security-auto-analysis/src/launch.test.ts index c8b8cfe3b3..d2965c0a62 100644 --- a/services/security-auto-analysis/src/launch.test.ts +++ b/services/security-auto-analysis/src/launch.test.ts @@ -207,8 +207,8 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { } as never); vi.mocked(setFindingCompleted).mockResolvedValue(true); vi.mocked(setFindingFailed).mockResolvedValue(true); - vi.mocked(setFindingPending).mockResolvedValue(true); - vi.mocked(setFindingRunning).mockResolvedValue(true); + vi.mocked(setFindingPending).mockResolvedValue(undefined); + vi.mocked(setFindingRunning).mockResolvedValue(undefined); vi.mocked(clearAnalysisStatus).mockResolvedValue(undefined); vi.mocked(transitionAnalysisStartLifecycle).mockResolvedValue({ transitioned: true }); }); diff --git a/services/security-sync/src/sync.test.ts b/services/security-sync/src/sync.test.ts index a15ad251b5..8de3fb2949 100644 --- a/services/security-sync/src/sync.test.ts +++ b/services/security-sync/src/sync.test.ts @@ -1,10 +1,75 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + fetchAllDependabotAlerts, isFindingEligibleForAutoAnalysis, selectRepositoriesForSync, syncAutoAnalysisQueueForFinding, + syncOwner, } from './sync.js'; +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +type FakeDbOptions = { + authInvalidAt?: string | null; + repositories?: string[]; +}; + +function createFakeDb(options: FakeDbOptions = {}) { + const repositories = options.repositories ?? ['acme/widgets']; + const sets: Array> = []; + let selectCount = 0; + + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + selectCount++; + if (selectCount === 1) { + return [{ id: 'agent-config', config: {}, is_enabled: true }]; + } + if (selectCount === 2) { + return [ + { + id: 'integration-1', + platform_installation_id: 'installation-1', + permissions: { vulnerability_alerts: 'read' }, + repositories: repositories.map((full_name, index) => ({ id: index + 1, full_name })), + authInvalidAt: options.authInvalidAt ?? null, + }, + ]; + } + return []; + }, + }), + }), + }), + update: () => ({ + set: (values: Record) => { + sets.push(values); + return { where: async () => undefined }; + }, + }), + insert: () => ({ values: async () => undefined }), + execute: async () => ({ rows: [] }), + }; + + return { db, sets }; +} + +function createGitTokenService() { + return { getToken: vi.fn(async () => 'github-token') }; +} + +function stubFetch(response: Response | (() => Response)) { + const fetchStub = vi.fn(async () => (typeof response === 'function' ? response() : response)); + vi.stubGlobal('fetch', fetchStub); + return fetchStub; +} + describe('selectRepositoriesForSync', () => { it('allows a manual repository command to target an accessible repo outside configured sync selection', () => { const repositories = selectRepositoriesForSync( @@ -22,6 +87,149 @@ describe('selectRepositoriesForSync', () => { }); }); +describe('Worker GitHub auth-invalid sync', () => { + it('classifies a direct GitHub 401 as auth_invalid', async () => { + stubFetch(new Response('Bad credentials', { status: 401 })); + + await expect(fetchAllDependabotAlerts('github-token', 'acme', 'widgets')).resolves.toEqual({ + status: 'auth_invalid', + }); + }); + + it('persists the first GitHub 401 and stops syncing remaining repos', async () => { + const { db, sets } = createFakeDb({ repositories: ['acme/widgets', 'acme/api'] }); + const gitTokenService = createGitTokenService(); + const fetchStub = stubFetch(new Response('Bad credentials', { status: 401 })); + + await expect( + syncOwner({ + db: db as never, + gitTokenService, + owner: { userId: 'user-1' }, + runId: 'run-1', + }) + ).resolves.toMatchObject({ + authInvalid: 1, + authInvalidRepos: ['acme/widgets'], + reauthRequired: true, + errors: 0, + }); + + expect(fetchStub).toHaveBeenCalledTimes(1); + expect(gitTokenService.getToken).toHaveBeenCalledTimes(1); + expect(sets).toContainEqual( + expect.objectContaining({ auth_invalid_reason: 'github_dependabot_401' }) + ); + expect(sets).not.toContainEqual(expect.objectContaining({ runtime_state: expect.anything() })); + }); + + it('short-circuits a recent invalid marker before token minting or GitHub fetch', async () => { + const { db } = createFakeDb({ + authInvalidAt: new Date().toISOString(), + repositories: ['acme/widgets', 'acme/api'], + }); + const gitTokenService = createGitTokenService(); + const fetchStub = stubFetch(new Response('unexpected')); + + await expect( + syncOwner({ + db: db as never, + gitTokenService, + owner: { userId: 'user-1' }, + runId: 'run-1', + }) + ).resolves.toMatchObject({ + authInvalid: 2, + authInvalidRepos: ['acme/widgets', 'acme/api'], + reauthRequired: true, + }); + + expect(gitTokenService.getToken).not.toHaveBeenCalled(); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('refreshes an expired marker after GitHub still returns 401', async () => { + const { db, sets } = createFakeDb({ + authInvalidAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + }); + const gitTokenService = createGitTokenService(); + const fetchStub = stubFetch(new Response('Bad credentials', { status: 401 })); + + await expect( + syncOwner({ + db: db as never, + gitTokenService, + owner: { userId: 'user-1' }, + runId: 'run-1', + }) + ).resolves.toMatchObject({ authInvalid: 1, reauthRequired: true }); + + expect(fetchStub).toHaveBeenCalledTimes(1); + expect(sets).toContainEqual( + expect.objectContaining({ auth_invalid_reason: 'github_dependabot_401' }) + ); + }); + + it('clears invalid state after success and advances full-sync freshness', async () => { + const { db, sets } = createFakeDb({ + authInvalidAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + }); + const gitTokenService = createGitTokenService(); + stubFetch(new Response(JSON.stringify([]), { status: 200 })); + + await expect( + syncOwner({ + db: db as never, + gitTokenService, + owner: { userId: 'user-1' }, + runId: 'run-1', + }) + ).resolves.toMatchObject({ authInvalid: 0, reauthRequired: false }); + + expect(sets).toContainEqual( + expect.objectContaining({ auth_invalid_at: null, auth_invalid_reason: null }) + ); + expect(sets).toContainEqual(expect.objectContaining({ runtime_state: expect.anything() })); + }); + + it('does not advance freshness after mixed success then GitHub 401', async () => { + const { db, sets } = createFakeDb({ repositories: ['acme/widgets', 'acme/api'] }); + const gitTokenService = createGitTokenService(); + const fetchStub = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce(new Response('Bad credentials', { status: 401 })); + vi.stubGlobal('fetch', fetchStub); + + await expect( + syncOwner({ + db: db as never, + gitTokenService, + owner: { userId: 'user-1' }, + runId: 'run-1', + }) + ).resolves.toMatchObject({ authInvalid: 1, reauthRequired: true }); + + expect(fetchStub).toHaveBeenCalledTimes(2); + expect(sets).not.toContainEqual(expect.objectContaining({ runtime_state: expect.anything() })); + }); + + it('throws non-401 GitHub errors', async () => { + const { db } = createFakeDb(); + const gitTokenService = createGitTokenService(); + stubFetch(new Response('Service unavailable', { status: 500 })); + + await expect( + syncOwner({ + db: db as never, + gitTokenService, + owner: { userId: 'user-1' }, + runId: 'run-1', + }) + ).rejects.toThrow('GitHub API error 500 for acme/widgets: Service unavailable'); + }); +}); + describe('Worker auto-analysis queue sync', () => { it('matches automatic-analysis eligibility boundaries for newly synced findings', () => { expect( diff --git a/services/security-sync/src/sync.ts b/services/security-sync/src/sync.ts index bc98979395..47fec084cc 100644 --- a/services/security-sync/src/sync.ts +++ b/services/security-sync/src/sync.ts @@ -24,6 +24,9 @@ import { const SecurityFindingSource = { DEPENDABOT: 'dependabot' } as const; +const AUTH_INVALID_SHORT_CIRCUIT_MS = 60 * 60 * 1000; +const AUTH_INVALID_WRITE_THROTTLE_MS = AUTH_INVALID_SHORT_CIRCUIT_MS; + const SecurityFindingStatus = { OPEN: 'open', FIXED: 'fixed', @@ -141,6 +144,10 @@ type SyncResult = { errors: number; /** Repos where Dependabot alerts are permanently disabled (safe to skip) */ skipped: number; + /** Repos where the GitHub installation requires reauthorization */ + authInvalid: number; + authInvalidRepos: string[]; + reauthRequired: boolean; /** Repos that returned 404 or are access-blocked (deleted/transferred/inaccessible) */ staleRepos: string[]; }; @@ -149,7 +156,29 @@ type FetchAlertsResult = | { status: 'success'; alerts: DependabotAlertRaw[] } | { status: 'repo_not_found' } | { status: 'alerts_disabled' } - | { status: 'access_blocked' }; + | { status: 'access_blocked' } + | { status: 'auth_invalid' }; + +function createEmptySyncResult(): SyncResult { + return { + synced: 0, + errors: 0, + skipped: 0, + authInvalid: 0, + authInvalidRepos: [], + reauthRequired: false, + staleRepos: [], + }; +} + +function createAuthInvalidSyncResult(repositories: string[]): SyncResult { + return { + ...createEmptySyncResult(), + authInvalid: repositories.length, + authInvalidRepos: [...repositories], + reauthRequired: true, + }; +} function isOrgOwner( owner: SecurityReviewOwner @@ -186,6 +215,7 @@ type EnabledOwnerConfig = { repoNameToId: Map; slaConfig: SecurityAgentConfig; autoAnalysisEnabledAt: string | null; + authInvalidAt: string | null; /** Number of selected_repository_ids that are no longer accessible via the installation. * Non-zero means the app lost access to a configured repo — freshness must not advance. */ missingSelectedRepoCount: number; @@ -223,6 +253,7 @@ export async function getOwnerConfig( platform_installation_id: platform_integrations.platform_installation_id, permissions: platform_integrations.permissions, repositories: platform_integrations.repositories, + authInvalidAt: platform_integrations.auth_invalid_at, }) .from(platform_integrations) .where( @@ -298,11 +329,69 @@ export async function getOwnerConfig( repoNameToId, slaConfig: { ...DEFAULT_SLA_CONFIG, ...securityConfig }, autoAnalysisEnabledAt: ownerStates[0]?.autoAnalysisEnabledAt ?? null, + authInvalidAt: integration.authInvalidAt, missingSelectedRepoCount, }; } -async function fetchAllDependabotAlerts( +function isRecentTimestamp(value: string | null | undefined, windowMs: number): boolean { + if (!value) return false; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) && Date.now() - timestamp < windowMs; +} + +async function markIntegrationAuthInvalid( + db: WorkerDb, + platformIntegrationId: string +): Promise { + try { + const now = new Date().toISOString(); + await db + .update(platform_integrations) + .set({ + auth_invalid_at: now, + auth_invalid_reason: 'github_dependabot_401', + updated_at: now, + }) + .where( + and( + eq(platform_integrations.id, platformIntegrationId), + sql`(${platform_integrations.auth_invalid_at} IS NULL OR ${platform_integrations.auth_invalid_at} < now() - ${AUTH_INVALID_WRITE_THROTTLE_MS} * interval '1 millisecond')` + ) + ); + } catch (error) { + console.error('Failed to mark GitHub integration auth invalid', { + error: error instanceof Error ? error.message : String(error), + platformIntegrationId, + }); + } +} + +async function clearIntegrationAuthInvalid(db: WorkerDb, platformIntegrationId: string): Promise { + try { + const now = new Date().toISOString(); + await db + .update(platform_integrations) + .set({ + auth_invalid_at: null, + auth_invalid_reason: null, + updated_at: now, + }) + .where( + and( + eq(platform_integrations.id, platformIntegrationId), + isNotNull(platform_integrations.auth_invalid_at) + ) + ); + } catch (error) { + console.error('Failed to clear GitHub integration auth invalid state', { + error: error instanceof Error ? error.message : String(error), + platformIntegrationId, + }); + } +} + +export async function fetchAllDependabotAlerts( token: string, repoOwner: string, repoName: string @@ -321,6 +410,10 @@ async function fetchAllDependabotAlerts( }, }); + if (response.status === 401) { + return { status: 'auth_invalid' }; + } + if (response.status === 404) { return { status: 'repo_not_found' }; } @@ -1053,7 +1146,7 @@ export async function syncOwner(params: { const config = await getOwnerConfig(database, owner); if (!config) { console.info(`No enabled config for owner, skipping`, { runId, owner }); - return { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; + return createEmptySyncResult(); } const repositories = selectRepositoriesForSync(config, repoFullName); @@ -1063,10 +1156,20 @@ export async function syncOwner(params: { owner, repoFullName, }); - return { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; + return createEmptySyncResult(); } - const totalResult: SyncResult = { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; + if (isRecentTimestamp(config.authInvalidAt, AUTH_INVALID_SHORT_CIRCUIT_MS)) { + console.warn('Skipping security sync because GitHub installation needs reauthorization', { + runId, + owner, + repositoryCount: repositories.length, + authInvalidAt: config.authInvalidAt, + }); + return createAuthInvalidSyncResult(repositories); + } + + const totalResult = createEmptySyncResult(); let firstError: Error | null = null; let successfulRepos = 0; @@ -1085,8 +1188,15 @@ export async function syncOwner(params: { totalResult.synced += repoResult.synced; totalResult.errors += repoResult.errors; totalResult.skipped += repoResult.skipped; + totalResult.authInvalid += repoResult.authInvalid; + totalResult.authInvalidRepos.push(...repoResult.authInvalidRepos); + totalResult.reauthRequired = totalResult.reauthRequired || repoResult.reauthRequired; totalResult.staleRepos.push(...repoResult.staleRepos); successfulRepos++; + + if (repoResult.reauthRequired) { + break; + } } catch (error) { totalResult.errors++; console.error(`Failed to sync ${repoFullName}`, { @@ -1144,6 +1254,8 @@ export async function syncOwner(params: { repoFullName, synced: totalResult.synced, errors: totalResult.errors, + authInvalidRepos: totalResult.authInvalidRepos, + reauthRequired: totalResult.reauthRequired, repoCount: repositories.length, }, }); @@ -1163,6 +1275,7 @@ export async function syncOwner(params: { if ( !repoFullName && totalResult.errors === 0 && + totalResult.authInvalid === 0 && totalResult.staleRepos.length === 0 && config.missingSelectedRepoCount === 0 ) { @@ -1197,12 +1310,19 @@ export async function syncOwner(params: { findingsSynced: totalResult.synced, errors: totalResult.errors, skippedRepos: totalResult.skipped, + authInvalidRepos: totalResult.authInvalidRepos, + reauthRequired: totalResult.reauthRequired, staleRepos: totalResult.staleRepos, missingSelectedRepos: config.missingSelectedRepoCount, durationMs: Date.now() - startTime, }; - if (totalResult.synced === 0 && totalResult.errors === 0 && totalResult.skipped === 0) { + if ( + totalResult.synced === 0 && + totalResult.errors === 0 && + totalResult.skipped === 0 && + totalResult.authInvalid === 0 + ) { console.warn('Sync completed with zero findings processed across all repos', syncSummary); } else { console.info('Sync cycle summary', syncSummary); @@ -1231,7 +1351,7 @@ async function syncRepo(params: { slaConfig, } = params; const token = await gitTokenService.getToken(installationId); - const result: SyncResult = { synced: 0, errors: 0, skipped: 0, staleRepos: [] }; + const result = createEmptySyncResult(); const [repoOwner, repoName] = repoFullName.split('/'); if (!repoOwner || !repoName) { @@ -1240,6 +1360,16 @@ async function syncRepo(params: { const fetchResult = await fetchAllDependabotAlerts(token, repoOwner, repoName); + if (fetchResult.status === 'auth_invalid') { + console.warn('GitHub installation needs reauthorization; skipping repo sync', { + platformIntegrationId, + installationId, + repoFullName, + }); + await markIntegrationAuthInvalid(database, platformIntegrationId); + return createAuthInvalidSyncResult([repoFullName]); + } + if (fetchResult.status === 'repo_not_found') { console.warn(`Repository ${repoFullName} no longer exists, marking as stale`); result.staleRepos.push(repoFullName); @@ -1258,6 +1388,8 @@ async function syncRepo(params: { return result; } + await clearIntegrationAuthInvalid(database, platformIntegrationId); + const findings = fetchResult.alerts.map(alert => parseDependabotAlert(alert)); console.info(`Fetched ${fetchResult.alerts.length} alerts, parsed ${findings.length} findings`, { repo: repoFullName, From bb10b36191076c952251b3a6a8c9a65e1a15ace8 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 19 May 2026 22:16:49 +0200 Subject: [PATCH 10/18] chore(workspace): restore main release age policy --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb003ef640..ab647390a7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,7 @@ catalog: wrangler: 4.90.1 zod: 4.4.3 -minimumReleaseAge: 4320 +minimumReleaseAge: 6842 minimumReleaseAgeExclude: - tsx - expo-dev-client From e0cf0842fa083788ab77be134c018c0affb88bfb Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:00:38 +0000 Subject: [PATCH 11/18] refactor(security-sync): wrap finding dismissal in a database transaction Ensures that both the security finding status update and the audit log insertion are performed atomically, preventing inconsistent states if the audit log insertion fails. --- services/security-sync/src/dismiss.ts | 54 ++++++++++++++------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/services/security-sync/src/dismiss.ts b/services/security-sync/src/dismiss.ts index f6c4422ac4..21d1169ba3 100644 --- a/services/security-sync/src/dismiss.ts +++ b/services/security-sync/src/dismiss.ts @@ -99,33 +99,35 @@ export async function processSecurityFindingDismissal(params: { } } - await params.db - .update(security_findings) - .set({ - status: 'ignored', - ignored_reason: params.message.reason, - ignored_by: params.message.actor.email ?? params.message.actor.id, - updated_at: sql`now()`, - }) - .where(eq(security_findings.id, finding.id)); + await params.db.transaction(async tx => { + await tx + .update(security_findings) + .set({ + status: 'ignored', + ignored_reason: params.message.reason, + ignored_by: params.message.actor.email ?? params.message.actor.id, + updated_at: sql`now()`, + }) + .where(eq(security_findings.id, finding.id)); - await params.db.insert(security_audit_log).values({ - owned_by_organization_id: params.message.owner.organizationId ?? null, - owned_by_user_id: params.message.owner.userId ?? null, - actor_id: params.message.actor.id, - actor_email: params.message.actor.email ?? null, - actor_name: params.message.actor.name ?? null, - action: SecurityAuditLogAction.FindingDismissed, - resource_type: 'security_finding', - resource_id: finding.id, - before_state: { status: finding.status }, - after_state: { status: 'ignored', ignoredReason: params.message.reason }, - metadata: { - source: finding.source, - runId: params.message.runId, - messageId: params.message.messageId, - trigger: 'worker_queue', - }, + await tx.insert(security_audit_log).values({ + owned_by_organization_id: params.message.owner.organizationId ?? null, + owned_by_user_id: params.message.owner.userId ?? null, + actor_id: params.message.actor.id, + actor_email: params.message.actor.email ?? null, + actor_name: params.message.actor.name ?? null, + action: SecurityAuditLogAction.FindingDismissed, + resource_type: 'security_finding', + resource_id: finding.id, + before_state: { status: finding.status }, + after_state: { status: 'ignored', ignoredReason: params.message.reason }, + metadata: { + source: finding.source, + runId: params.message.runId, + messageId: params.message.messageId, + trigger: 'worker_queue', + }, + }); }); return { dismissed: true, findingSource: finding.source }; From e3db40eff86a20b40fc5774c3b1caa1a73730b2d Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 1 Jun 2026 12:51:09 +0200 Subject: [PATCH 12/18] fix(security-agent): harden analysis callback attempts --- .../[findingId]/route.test.ts | 29 +- .../[findingId]/route.ts | 54 +- .../SecurityAgentPageClient.tsx | 893 ------------------ .../src/components/security-agent/index.ts | 1 - .../security-agent/db/security-analysis.ts | 4 + .../router/shared-handlers.test.ts | 1 - .../security-agent/router/shared-handlers.ts | 1 - .../organization-security-agent-router.ts | 3 - apps/web/src/routers/security-agent-router.ts | 4 - ...alysis-start-lifecycle.integration.test.ts | 5 + .../src/analysis-start-lifecycle.ts | 34 +- .../src/callbacks.test.ts | 163 ++++ .../security-auto-analysis/src/callbacks.ts | 83 +- .../security-auto-analysis/src/db/queries.ts | 132 +-- .../security-auto-analysis/src/index.test.ts | 14 +- services/security-auto-analysis/src/index.ts | 8 +- .../security-auto-analysis/src/launch.test.ts | 38 +- services/security-auto-analysis/src/launch.ts | 13 +- services/security-sync/src/dismiss.test.ts | 58 +- services/security-sync/src/sync.test.ts | 5 +- services/security-sync/src/sync.ts | 5 +- 21 files changed, 457 insertions(+), 1091 deletions(-) delete mode 100644 apps/web/src/components/security-agent/SecurityAgentPageClient.tsx diff --git a/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.test.ts b/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.test.ts index b3c8a7a4e6..4bde8c35d7 100644 --- a/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.test.ts +++ b/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.test.ts @@ -105,10 +105,18 @@ jest.mock('@/lib/utils.server', () => ({ })); jest.mock('@/lib/drizzle', () => { + let selectedTable: unknown; const chain = { - from: jest.fn().mockReturnThis(), + from: jest.fn((table: unknown) => { + selectedTable = table; + return chain; + }), where: jest.fn().mockReturnThis(), - limit: jest.fn(() => mockDbSelect()), + limit: jest.fn(() => + (selectedTable as { __name?: string } | undefined)?.__name === 'security_analysis_queue' + ? Promise.resolve([{ claimToken: 'attempt-token-123' }]) + : mockDbSelect() + ), }; return { db: { @@ -119,16 +127,25 @@ jest.mock('@/lib/drizzle', () => { jest.mock('@kilocode/db/schema', () => ({ kilocode_users: { id: 'id' }, + security_analysis_queue: { + __name: 'security_analysis_queue', + claim_token: 'claim_token', + finding_id: 'finding_id', + queue_status: 'queue_status', + }, })); jest.mock('drizzle-orm', () => ({ + and: jest.fn(), eq: jest.fn(), + inArray: jest.fn(), })); // --- Helpers --- const CALLBACK_SECRET = 'test-callback-token-secret'; const FINDING_ID = 'finding-abc-123'; +const ATTEMPT_TOKEN = 'attempt-token-123'; let defaultCallbackToken: string; function makeRequest( @@ -137,6 +154,9 @@ function makeRequest( callbackToken: string | null = defaultCallbackToken ): NextRequest { return { + nextUrl: new URL( + `https://app.kilo.ai/api/internal/security-analysis-callback/${findingId}?attempt=${ATTEMPT_TOKEN}` + ), headers: { get: (name: string) => { if (name === 'X-Callback-Token') return callbackToken; @@ -248,7 +268,7 @@ beforeEach(async () => { defaultCallbackToken = await deriveCallbackToken({ secret: CALLBACK_SECRET, scope: 'security-analysis-callback', - resourceParts: [FINDING_ID], + resourceParts: [FINDING_ID, ATTEMPT_TOKEN], }); mockUpdateAnalysisStatus.mockResolvedValue(true); mockTransitionAutoAnalysisQueueFromCallback.mockResolvedValue(undefined); @@ -292,7 +312,7 @@ describe('POST /api/internal/security-analysis-callback/[findingId]', () => { const callbackToken = await deriveCallbackToken({ secret: CALLBACK_SECRET, scope: 'security-analysis-callback', - resourceParts: ['different-finding'], + resourceParts: ['different-finding', ATTEMPT_TOKEN], }); const req = makeRequest(FINDING_ID, completedPayload, callbackToken); const response = await POST(req, makeParams(FINDING_ID)); @@ -366,6 +386,7 @@ describe('POST /api/internal/security-analysis-callback/[findingId]', () => { expect(body.message).toBe('Superseded finding ignored'); expect(mockTransitionAutoAnalysisQueueFromCallback).toHaveBeenCalledWith({ findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, toStatus: 'completed', failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE', }); diff --git a/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts b/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts index 684139d1c5..31a22c34bc 100644 --- a/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts +++ b/apps/web/src/app/api/internal/security-analysis-callback/[findingId]/route.ts @@ -17,8 +17,8 @@ import { fetchSessionSnapshot } from '@/lib/session-ingest-client'; import { trackSecurityAgentAnalysisCompleted } from '@/lib/security-agent/posthog-tracking'; import { generateApiToken } from '@/lib/tokens'; import { db } from '@/lib/drizzle'; -import { kilocode_users } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; +import { kilocode_users, security_analysis_queue } from '@kilocode/db/schema'; +import { and, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { verifyCallbackToken } from '@kilocode/worker-utils/callback-token'; import { logExceptInTest, sentryLogger } from '@/lib/utils.server'; @@ -84,6 +84,10 @@ export async function POST( ) { try { const { findingId } = await params; + const attemptToken = req.nextUrl.searchParams.get('attempt'); + if (!attemptToken) { + return NextResponse.json({ error: 'Missing callback attempt token' }, { status: 400 }); + } const callbackToken = req.headers.get('X-Callback-Token'); const validCallbackToken = !!CALLBACK_TOKEN_SECRET && @@ -91,7 +95,7 @@ export async function POST( token: callbackToken, secret: CALLBACK_TOKEN_SECRET, scope: 'security-analysis-callback', - resourceParts: [findingId], + resourceParts: [findingId, attemptToken], })); if (!validCallbackToken) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -119,6 +123,28 @@ export async function POST( return NextResponse.json({ error: 'Finding not found' }, { status: 404 }); } + const [activeAttempt] = await db + .select({ claimToken: security_analysis_queue.claim_token }) + .from(security_analysis_queue) + .where( + and( + eq(security_analysis_queue.finding_id, findingId), + inArray(security_analysis_queue.queue_status, ['pending', 'running']) + ) + ) + .limit(1); + if ( + (finding.analysis_status === 'pending' || finding.analysis_status === 'running') && + activeAttempt?.claimToken !== attemptToken + ) { + warn('Ignoring stale auto-analysis callback due to attempt mismatch', { + findingId, + callbackAttemptToken: attemptToken, + activeAttemptToken: activeAttempt?.claimToken ?? null, + }); + return NextResponse.json({ success: true, message: 'Stale callback ignored' }); + } + const sessionMismatch = (payload.cloudAgentSessionId && finding.session_id && @@ -160,6 +186,7 @@ export async function POST( }); await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'completed', failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE', }); @@ -186,9 +213,9 @@ export async function POST( after(async () => { try { if (payload.status === 'completed') { - await handleAnalysisCompleted(findingId, payload, finding); + await handleAnalysisCompleted(findingId, attemptToken, payload, finding); } else if (payload.status === 'failed' || payload.status === 'interrupted') { - await handleAnalysisFailed(findingId, payload, finding); + await handleAnalysisFailed(findingId, attemptToken, payload, finding); } else { const unknownStatus = payload.status as string; logError('Unknown callback status received, marking as failed', { @@ -204,6 +231,7 @@ export async function POST( } await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'STATE_GUARD_REJECTED', errorMessage: `Unknown callback status: ${unknownStatus}`, @@ -257,6 +285,7 @@ function readAnalysisContext(analysis: SecurityFindingAnalysis | null | undefine async function handleAnalysisCompleted( findingId: string, + attemptToken: string, payload: ExecutionCallbackPayload, finding: Awaited> & {} ) { @@ -283,6 +312,7 @@ async function handleAnalysisCompleted( } await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'STATE_GUARD_REJECTED', errorMessage: 'Cannot process callback — triggeredByUserId missing from analysis context', @@ -302,6 +332,7 @@ async function handleAnalysisCompleted( } await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'STATE_GUARD_REJECTED', errorMessage: 'Callback missing kiloSessionId — cannot retrieve analysis result', @@ -367,6 +398,7 @@ async function handleAnalysisCompleted( } await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'START_CALL_AMBIGUOUS', errorMessage: 'Analysis completed but result could not be retrieved from ingest service', @@ -406,6 +438,7 @@ async function handleAnalysisCompleted( } await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'STATE_GUARD_REJECTED', errorMessage: `User ${triggeredByUserId} not found — cannot run Tier 3 extraction`, @@ -451,6 +484,7 @@ async function handleAnalysisCompleted( }); await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'START_CALL_AMBIGUOUS', errorMessage: error instanceof Error ? error.message : String(error), @@ -460,10 +494,15 @@ async function handleAnalysisCompleted( const updatedFinding = await getSecurityFindingById(findingId); if (updatedFinding?.analysis_status === 'completed') { - await transitionAutoAnalysisQueueFromCallback({ findingId, toStatus: 'completed' }); + await transitionAutoAnalysisQueueFromCallback({ + findingId, + attemptToken, + toStatus: 'completed', + }); } else if (updatedFinding?.analysis_status === 'failed') { await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'START_CALL_AMBIGUOUS', errorMessage: updatedFinding.analysis_error ?? undefined, @@ -471,6 +510,7 @@ async function handleAnalysisCompleted( } else { await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: 'STATE_GUARD_REJECTED', errorMessage: `Unexpected post-finalize state: ${updatedFinding?.analysis_status ?? 'finding_not_found'}`, @@ -480,6 +520,7 @@ async function handleAnalysisCompleted( async function handleAnalysisFailed( findingId: string, + attemptToken: string, payload: ExecutionCallbackPayload, finding: Awaited> & {} ) { @@ -523,6 +564,7 @@ async function handleAnalysisFailed( } await transitionAutoAnalysisQueueFromCallback({ findingId, + attemptToken, toStatus: 'failed', failureCode: callbackFailure.failureCode, errorMessage, diff --git a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx b/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx deleted file mode 100644 index 2742da1d96..0000000000 --- a/apps/web/src/components/security-agent/SecurityAgentPageClient.tsx +++ /dev/null @@ -1,893 +0,0 @@ -'use client'; - -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { SecurityFindingsCard } from './SecurityFindingsCard'; -import { FindingDetailDialog } from './FindingDetailDialog'; -import { DismissFindingDialog, type DismissReason } from './DismissFindingDialog'; -import { SecurityConfigForm, type SlaConfig } from './SecurityConfigForm'; -import { ClearFindingsCard } from './ClearFindingsCard'; -import { useTRPC } from '@/lib/trpc/utils'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { AlertTriangle, ExternalLink, ListChecks, Settings2, RefreshCw } from 'lucide-react'; -import { toast } from 'sonner'; -import type { SecurityFinding } from '@kilocode/db/schema'; -import { - SecurityFindingStatusSchema, - SecuritySeveritySchema, - OutcomeFilterSchema, -} from '@/lib/security-agent/core/schemas'; -import Link from 'next/link'; -import { isGitHubIntegrationError } from '@/lib/security-agent/core/error-display'; -import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; - -type SecurityAgentPageClientProps = { - organizationId?: string; -}; - -const PAGE_SIZE = 20; -const ACCEPTED_QUEUE_REFRESH_DELAYS_MS = [1000, 5000, 15000] as const; - -function getOptionalStringField(source: unknown, key: string): string | undefined { - if (typeof source !== 'object' || source === null) { - return undefined; - } - - const value = Reflect.get(source, key); - return typeof value === 'string' ? value : undefined; -} - -export function SecurityAgentPageClient({ organizationId }: SecurityAgentPageClientProps) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - // Default to config tab if security reviews is disabled - const [activeTab, setActiveTab] = useState(null); - const [page, setPage] = useState(1); - const [filters, setFilters] = useState<{ - status?: string; - severity?: string; - repoFullName?: string; - outcomeFilter?: string; - }>({ status: 'open' }); - const [selectedFinding, setSelectedFinding] = useState(null); - const [detailDialogOpen, setDetailDialogOpen] = useState(false); - const [dismissDialogOpen, setDismissDialogOpen] = useState(false); - const [startingAnalysisIds, setStartingAnalysisIds] = useState>(new Set()); - const [gitHubError, setGitHubError] = useState(null); - const toggleEnabledInFlightRef = useRef(false); - const acceptedQueueRefreshTimersRef = useRef([]); - const [sortBy, setSortBy] = useState<'severity_desc' | 'severity_asc' | 'sla_due_at_asc'>( - 'severity_desc' - ); - - useEffect( - () => () => { - for (const timer of acceptedQueueRefreshTimersRef.current) { - window.clearTimeout(timer); - } - acceptedQueueRefreshTimersRef.current = []; - }, - [] - ); - - const refreshAcceptedQueueMutation = useCallback(() => { - void queryClient.invalidateQueries(); - for (const delay of ACCEPTED_QUEUE_REFRESH_DELAYS_MS) { - const timer = window.setTimeout(() => { - void queryClient.invalidateQueries(); - acceptedQueueRefreshTimersRef.current = acceptedQueueRefreshTimersRef.current.filter( - pendingTimer => pendingTimer !== timer - ); - }, delay); - acceptedQueueRefreshTimersRef.current.push(timer); - } - }, [queryClient]); - - const handleSortByChange = useCallback( - (newSortBy: 'severity_desc' | 'severity_asc' | 'sla_due_at_asc') => { - setSortBy(newSortBy); - setPage(1); - }, - [] - ); - - // Determine which router to use based on organizationId - const isOrg = !!organizationId; - - // Permission status query - const { data: permissionData, isLoading: isLoadingPermission } = useQuery( - isOrg - ? trpc.organizations.securityAgent.getPermissionStatus.queryOptions({ organizationId }) - : trpc.securityAgent.getPermissionStatus.queryOptions() - ); - - // Config query - const { data: configData, refetch: refetchConfig } = useQuery( - isOrg - ? trpc.organizations.securityAgent.getConfig.queryOptions({ organizationId }) - : trpc.securityAgent.getConfig.queryOptions() - ); - - // Stats query - const { data: statsData } = useQuery( - isOrg - ? trpc.organizations.securityAgent.getStats.queryOptions({ organizationId }) - : trpc.securityAgent.getStats.queryOptions() - ); - - // Repositories query - const { data: reposData } = useQuery( - isOrg - ? trpc.organizations.securityAgent.getRepositories.queryOptions({ organizationId }) - : trpc.securityAgent.getRepositories.queryOptions() - ); - - // Derived query params — computed once and shared by both org/personal branches. - // Parse filter strings through their Zod schemas to get correctly-typed values - // without unsafe `as` casts. - const findingsQueryParams = useMemo(() => { - const parsedStatus = SecurityFindingStatusSchema.safeParse(filters.status); - const parsedSeverity = SecuritySeveritySchema.safeParse(filters.severity); - const parsedOutcome = OutcomeFilterSchema.safeParse(filters.outcomeFilter); - return { - status: parsedStatus.success ? parsedStatus.data : undefined, - severity: parsedSeverity.success ? parsedSeverity.data : undefined, - outcomeFilter: parsedOutcome.success ? parsedOutcome.data : undefined, - sortBy, - repoFullName: filters.repoFullName, - limit: PAGE_SIZE, - offset: (page - 1) * PAGE_SIZE, - }; - }, [filters, sortBy, page]); - - // Findings query - polls when there are active analysis jobs - const { data: findingsData, isLoading: isLoadingFindings } = useQuery({ - ...(isOrg - ? trpc.organizations.securityAgent.listFindings.queryOptions({ - organizationId, - ...findingsQueryParams, - }) - : trpc.securityAgent.listFindings.queryOptions(findingsQueryParams)), - refetchInterval: query => { - const result = query.state.data; - if (!result) return false; - // Poll every 5s if any analysis is running (globally or on current page) - const hasActiveAnalysis = - (result.runningCount ?? 0) > 0 || - result.findings.some( - f => f.analysis_status === 'pending' || f.analysis_status === 'running' - ); - return hasActiveAnalysis ? 5000 : false; - }, - }); - - // Last sync time query - const { data: lastSyncData } = useQuery( - isOrg - ? trpc.organizations.securityAgent.getLastSyncTime.queryOptions({ - organizationId, - repoFullName: filters.repoFullName, - }) - : trpc.securityAgent.getLastSyncTime.queryOptions({ - repoFullName: filters.repoFullName, - }) - ); - - // Orphaned repositories query (repos with findings but no longer in GitHub integration) - const { data: orphanedReposData } = useQuery( - isOrg - ? trpc.organizations.securityAgent.getOrphanedRepositories.queryOptions({ organizationId }) - : trpc.securityAgent.getOrphanedRepositories.queryOptions() - ); - - // Organization mutations - const { mutate: orgSyncMutate, isPending: isOrgSyncPending } = useMutation( - trpc.organizations.securityAgent.triggerSync.mutationOptions({ - onSuccess: () => { - setGitHubError(null); // Clear any previous error on success - toast.success('Sync queued'); - refreshAcceptedQueueMutation(); - }, - onError: error => { - const message = error instanceof Error ? error.message : String(error); - if (isGitHubIntegrationError(error)) { - setGitHubError(message); - toast.error('GitHub Integration Error', { - description: - 'The GitHub App may have been uninstalled. Please check your integrations.', - }); - } else { - toast.error('Sync failed', { description: message }); - } - }, - }) - ); - - const { mutate: orgDismissMutate, isPending: isOrgDismissPending } = useMutation( - trpc.organizations.securityAgent.dismissFinding.mutationOptions({ - onSuccess: () => { - toast.success('Finding dismissed'); - refreshAcceptedQueueMutation(); - setDismissDialogOpen(false); - setDetailDialogOpen(false); - setSelectedFinding(null); - }, - onError: error => { - toast.error('Failed to dismiss finding', { description: error.message }); - }, - }) - ); - - const { mutate: orgSaveConfigMutate, isPending: isOrgSaveConfigPending } = useMutation( - trpc.organizations.securityAgent.saveConfig.mutationOptions({ - onSuccess: async () => { - toast.success('Configuration saved'); - await refetchConfig(); - }, - onError: error => { - toast.error('Failed to save configuration', { description: error.message }); - }, - }) - ); - - const { mutate: orgSetEnabledMutate, isPending: isOrgSetEnabledPending } = useMutation( - trpc.organizations.securityAgent.setEnabled.mutationOptions({ - onSuccess: async data => { - if ('initialSync' in data && data.initialSync) { - toast.success('Security Agent enabled', { - description: 'Initial sync queued. Findings update as processing completes.', - }); - } else { - toast.success('Security Agent setting updated'); - } - await refetchConfig(); - void queryClient.invalidateQueries(); - }, - onError: error => { - toast.error('Failed to toggle Security Agent', { description: error.message }); - }, - onSettled: () => { - toggleEnabledInFlightRef.current = false; - }, - }) - ); - - // Personal mutations - const { mutate: personalSyncMutate, isPending: isPersonalSyncPending } = useMutation( - trpc.securityAgent.triggerSync.mutationOptions({ - onSuccess: () => { - setGitHubError(null); // Clear any previous error on success - toast.success('Sync queued'); - refreshAcceptedQueueMutation(); - }, - onError: error => { - const message = error instanceof Error ? error.message : String(error); - if (isGitHubIntegrationError(error)) { - setGitHubError(message); - toast.error('GitHub Integration Error', { - description: - 'The GitHub App may have been uninstalled. Please check your integrations.', - }); - } else { - toast.error('Sync failed', { description: message }); - } - }, - }) - ); - - const { mutate: personalDismissMutate, isPending: isPersonalDismissPending } = useMutation( - trpc.securityAgent.dismissFinding.mutationOptions({ - onSuccess: () => { - toast.success('Finding dismissed'); - refreshAcceptedQueueMutation(); - setDismissDialogOpen(false); - setDetailDialogOpen(false); - setSelectedFinding(null); - }, - onError: error => { - toast.error('Failed to dismiss finding', { description: error.message }); - }, - }) - ); - - const { mutate: personalSaveConfigMutate, isPending: isPersonalSaveConfigPending } = useMutation( - trpc.securityAgent.saveConfig.mutationOptions({ - onSuccess: async () => { - toast.success('Configuration saved'); - await refetchConfig(); - }, - onError: error => { - toast.error('Failed to save configuration', { description: error.message }); - }, - }) - ); - - const { mutate: personalSetEnabledMutate, isPending: isPersonalSetEnabledPending } = useMutation( - trpc.securityAgent.setEnabled.mutationOptions({ - onSuccess: async data => { - if ('initialSync' in data && data.initialSync) { - toast.success('Security Agent enabled', { - description: 'Initial sync queued. Findings update as processing completes.', - }); - } else { - toast.success('Security Agent setting updated'); - } - await refetchConfig(); - void queryClient.invalidateQueries(); - }, - onError: error => { - toast.error('Failed to toggle Security Agent', { description: error.message }); - }, - onSettled: () => { - toggleEnabledInFlightRef.current = false; - }, - }) - ); - - // Analysis mutations - Organization - const { mutate: orgStartAnalysisMutate, isPending: _isOrgStartAnalysisPending } = useMutation( - trpc.organizations.securityAgent.startAnalysis.mutationOptions({ - onSuccess: async (_data, variables) => { - setGitHubError(null); // Clear any previous error on success - toast.success(manualAnalysisAdmissionCopy.successTitle); - refreshAcceptedQueueMutation(); - setStartingAnalysisIds(prev => { - const next = new Set(prev); - next.delete(variables.findingId); - return next; - }); - }, - onError: (error, variables) => { - const message = error instanceof Error ? error.message : String(error); - if (isGitHubIntegrationError(error)) { - setGitHubError(message); - toast.error('GitHub Integration Error', { - description: - 'The GitHub App may have been uninstalled. Please check your integrations.', - }); - } else { - toast.error(manualAnalysisAdmissionCopy.failureTitle, { - description: message, - duration: 8000, - }); - } - // Refetch so the row picks up analysis_status = 'failed' and shows "Retry" - void queryClient.invalidateQueries(); - setStartingAnalysisIds(prev => { - const next = new Set(prev); - next.delete(variables.findingId); - return next; - }); - }, - }) - ); - - // Analysis mutations - Personal - const { mutate: personalStartAnalysisMutate, isPending: _isPersonalStartAnalysisPending } = - useMutation( - trpc.securityAgent.startAnalysis.mutationOptions({ - onSuccess: async (_data, variables) => { - setGitHubError(null); // Clear any previous error on success - toast.success(manualAnalysisAdmissionCopy.successTitle); - refreshAcceptedQueueMutation(); - setStartingAnalysisIds(prev => { - const next = new Set(prev); - next.delete(variables.findingId); - return next; - }); - }, - onError: (error, variables) => { - const message = error instanceof Error ? error.message : String(error); - if (isGitHubIntegrationError(error)) { - setGitHubError(message); - toast.error('GitHub Integration Error', { - description: - 'The GitHub App may have been uninstalled. Please check your integrations.', - }); - } else { - toast.error(manualAnalysisAdmissionCopy.failureTitle, { - description: message, - duration: 8000, - }); - } - // Refetch so the row picks up analysis_status = 'failed' and shows "Retry" - void queryClient.invalidateQueries(); - setStartingAnalysisIds(prev => { - const next = new Set(prev); - next.delete(variables.findingId); - return next; - }); - }, - }) - ); - - // Delete findings mutations - Organization - const { mutate: orgDeleteFindingsMutate, isPending: isOrgDeleteFindingsPending } = useMutation( - trpc.organizations.securityAgent.deleteFindingsByRepository.mutationOptions({ - onSuccess: data => { - toast.success('Findings deleted', { - description: `${data.deletedCount} findings were permanently deleted`, - }); - void queryClient.invalidateQueries(); - }, - onError: error => { - toast.error('Failed to delete findings', { description: error.message }); - }, - }) - ); - - // Delete findings mutations - Personal - const { mutate: personalDeleteFindingsMutate, isPending: isPersonalDeleteFindingsPending } = - useMutation( - trpc.securityAgent.deleteFindingsByRepository.mutationOptions({ - onSuccess: data => { - toast.success('Findings deleted', { - description: `${data.deletedCount} findings were permanently deleted`, - }); - void queryClient.invalidateQueries(); - }, - onError: error => { - toast.error('Failed to delete findings', { description: error.message }); - }, - }) - ); - - // Refresh installation mutation (unified - works for both org and personal) - const { mutate: refreshMutate, isPending: isRefreshPending } = useMutation( - trpc.githubApps.refreshInstallation.mutationOptions({ - onSuccess: () => { - toast.success('Permissions refreshed', { - description: 'GitHub App permissions have been updated from GitHub.', - }); - void queryClient.invalidateQueries(); - }, - onError: (error: { message: string }) => { - toast.error('Failed to refresh permissions', { description: error.message }); - }, - }) - ); - - // Handlers - const handleSync = useCallback( - (repoFullName?: string) => { - if (isOrg && organizationId) { - orgSyncMutate({ - organizationId, - repoFullName, - }); - } else if (!isOrg) { - personalSyncMutate({ - repoFullName, - }); - } - }, - [isOrg, organizationId, orgSyncMutate, personalSyncMutate] - ); - - const handleDismiss = useCallback( - (reason: DismissReason, comment?: string) => { - if (!selectedFinding) return; - - if (isOrg && organizationId) { - orgDismissMutate({ - organizationId, - findingId: selectedFinding.id, - reason, - comment, - }); - } else if (!isOrg) { - personalDismissMutate({ - findingId: selectedFinding.id, - reason, - comment, - }); - } - }, - [isOrg, organizationId, selectedFinding, orgDismissMutate, personalDismissMutate] - ); - - const handleSaveConfig = useCallback( - ( - config: SlaConfig & { - repositorySelectionMode: 'all' | 'selected'; - selectedRepositoryIds: number[]; - triageModelSlug: string; - analysisModelSlug: string; - modelSlug?: string; - analysisMode: 'auto' | 'shallow' | 'deep'; - autoDismissEnabled: boolean; - autoDismissConfidenceThreshold: 'high' | 'medium' | 'low'; - autoAnalysisEnabled: boolean; - autoAnalysisMinSeverity: 'critical' | 'high' | 'medium' | 'all'; - autoAnalysisIncludeExisting: boolean; - } - ) => { - const modelConfigPayload = { - triageModelSlug: config.triageModelSlug, - analysisModelSlug: config.analysisModelSlug, - modelSlug: config.modelSlug, - }; - - if (isOrg && organizationId) { - orgSaveConfigMutate({ - organizationId, - slaCriticalDays: config.critical, - slaHighDays: config.high, - slaMediumDays: config.medium, - slaLowDays: config.low, - repositorySelectionMode: config.repositorySelectionMode, - selectedRepositoryIds: config.selectedRepositoryIds, - analysisMode: config.analysisMode, - autoDismissEnabled: config.autoDismissEnabled, - autoDismissConfidenceThreshold: config.autoDismissConfidenceThreshold, - autoAnalysisEnabled: config.autoAnalysisEnabled, - autoAnalysisMinSeverity: config.autoAnalysisMinSeverity, - autoAnalysisIncludeExisting: config.autoAnalysisIncludeExisting, - ...modelConfigPayload, - }); - } else if (!isOrg) { - personalSaveConfigMutate({ - slaCriticalDays: config.critical, - slaHighDays: config.high, - slaMediumDays: config.medium, - slaLowDays: config.low, - repositorySelectionMode: config.repositorySelectionMode, - selectedRepositoryIds: config.selectedRepositoryIds, - analysisMode: config.analysisMode, - autoDismissEnabled: config.autoDismissEnabled, - autoDismissConfidenceThreshold: config.autoDismissConfidenceThreshold, - autoAnalysisEnabled: config.autoAnalysisEnabled, - autoAnalysisMinSeverity: config.autoAnalysisMinSeverity, - autoAnalysisIncludeExisting: config.autoAnalysisIncludeExisting, - ...modelConfigPayload, - }); - } - }, - [isOrg, organizationId, orgSaveConfigMutate, personalSaveConfigMutate] - ); - - const handleToggleEnabled = useCallback( - ( - enabled: boolean, - repositorySelection: { - repositorySelectionMode: 'all' | 'selected'; - selectedRepositoryIds: number[]; - } - ) => { - if (toggleEnabledInFlightRef.current) return; - toggleEnabledInFlightRef.current = true; - - if (isOrg && organizationId) { - orgSetEnabledMutate({ - organizationId, - isEnabled: enabled, - repositorySelectionMode: repositorySelection.repositorySelectionMode, - selectedRepositoryIds: repositorySelection.selectedRepositoryIds, - }); - } else if (!isOrg) { - personalSetEnabledMutate({ - isEnabled: enabled, - repositorySelectionMode: repositorySelection.repositorySelectionMode, - selectedRepositoryIds: repositorySelection.selectedRepositoryIds, - }); - } else { - toggleEnabledInFlightRef.current = false; - } - }, - [isOrg, organizationId, orgSetEnabledMutate, personalSetEnabledMutate] - ); - - const handleFindingClick = useCallback((finding: SecurityFinding) => { - setSelectedFinding(finding); - setDetailDialogOpen(true); - }, []); - - const handleOpenDismissDialog = useCallback(() => { - setDetailDialogOpen(false); - setDismissDialogOpen(true); - }, []); - - const handleStartAnalysis = useCallback( - (findingId: string, { retrySandboxOnly }: { retrySandboxOnly?: boolean } = {}) => { - setStartingAnalysisIds(prev => new Set(prev).add(findingId)); - if (isOrg && organizationId) { - orgStartAnalysisMutate({ organizationId, findingId, retrySandboxOnly }); - } else { - personalStartAnalysisMutate({ findingId, retrySandboxOnly }); - } - }, - [isOrg, organizationId, orgStartAnalysisMutate, personalStartAnalysisMutate] - ); - - const handleDeleteFindings = useCallback( - (repoFullName: string) => { - if (isOrg && organizationId) { - orgDeleteFindingsMutate({ organizationId, repoFullName }); - } else { - personalDeleteFindingsMutate({ repoFullName }); - } - }, - [isOrg, organizationId, orgDeleteFindingsMutate, personalDeleteFindingsMutate] - ); - - const handleRefreshPermissions = useCallback(() => { - if (isOrg && organizationId) { - refreshMutate({ organizationId }); - } else { - refreshMutate(undefined); - } - }, [isOrg, organizationId, refreshMutate]); - - // Check integration and permissions - const hasIntegration = permissionData?.hasIntegration ?? false; - const hasPermission = permissionData?.hasPermissions ?? false; - const reauthorizeUrl = permissionData?.reauthorizeUrl; - - // Get config data - const isEnabled = configData?.isEnabled ?? false; - - // Set default tab based on whether security reviews is enabled - // Only set once when configData is first loaded - // If no integration, always show findings tab (config tab will be hidden) - const effectiveTab = activeTab ?? (hasIntegration && !isEnabled ? 'config' : 'findings'); - const slaConfig = { - critical: configData?.slaCriticalDays ?? 15, - high: configData?.slaHighDays ?? 30, - medium: configData?.slaMediumDays ?? 45, - low: configData?.slaLowDays ?? 90, - } satisfies SlaConfig; - - // Get stats data - already flat from the API - const stats = statsData ?? { - total: 0, - critical: 0, - high: 0, - medium: 0, - low: 0, - open: 0, - fixed: 0, - ignored: 0, - }; - - // Get findings data - const findings = findingsData?.findings ?? []; - const totalCount = findingsData?.totalCount ?? 0; - const serverRunningCount = findingsData?.runningCount ?? 0; - const concurrencyLimit = findingsData?.concurrencyLimit ?? 3; - - // Compute effective running count that includes optimistic additions from - // in-flight startAnalysis mutations (whose results haven't been reflected in - // the server data yet). This ensures the capacity badge updates immediately - // when the user clicks "Analyze". - const runningCount = useMemo(() => { - if (startingAnalysisIds.size === 0) return serverRunningCount; - - let optimisticAdditional = 0; - for (const id of startingAnalysisIds) { - const finding = findings.find(f => f.id === id); - // Skip findings not on the current page — serverRunningCount is global - // and will already include them once the server processes the mutation. - // Previously `!finding` counted as additional, which double-counted when - // the user changed page/filter while the mutation was in-flight. - if (!finding) continue; - // Only count as additional if the server data doesn't already reflect - // this analysis (i.e. the finding's status hasn't moved to pending/running yet). - if (finding.analysis_status !== 'pending' && finding.analysis_status !== 'running') { - optimisticAdditional++; - } - } - return serverRunningCount + optimisticAdditional; - }, [serverRunningCount, startingAnalysisIds, findings]); - - // Get repositories - filter to only show selected repositories in the findings tab - const allRepositories = reposData ?? []; - const repositorySelectionMode = configData?.repositorySelectionMode ?? 'selected'; - const selectedRepositoryIds = configData?.selectedRepositoryIds ?? []; - const triageModelSlug = getOptionalStringField(configData, 'triageModelSlug'); - const analysisModelSlug = getOptionalStringField(configData, 'analysisModelSlug'); - - // For the findings tab, only show repositories that are selected for security reviews - const filteredRepositories = - repositorySelectionMode === 'all' - ? allRepositories - : allRepositories.filter(repo => selectedRepositoryIds.includes(repo.id)); - - // Mutation states - const isSyncing = isOrg ? isOrgSyncPending : isPersonalSyncPending; - const isDismissing = isOrg ? isOrgDismissPending : isPersonalDismissPending; - const isSavingConfig = isOrg ? isOrgSaveConfigPending : isPersonalSaveConfigPending; - const isTogglingEnabled = isOrg ? isOrgSetEnabledPending : isPersonalSetEnabledPending; - const isDeletingFindings = isOrg ? isOrgDeleteFindingsPending : isPersonalDeleteFindingsPending; - const isRefreshing = isRefreshPending; - - // Orphaned repositories data - const orphanedRepositories = orphanedReposData ?? []; - - // GitHub App installed but missing permissions - show reauthorize prompt - const showPermissionRequired = hasIntegration && !hasPermission && !isLoadingPermission; - - return ( -
- {/* Header */} -
-
-

Security Agent

- Beta -
-

- Monitor and manage Dependabot security alerts for your repositories -

- - Learn how to use it - - -
- - {/* Additional Permissions Required Alert */} - {showPermissionRequired && ( - - - Additional Permissions Required - -

- Security Agent requires the vulnerability_alerts permission to access - Dependabot alerts. Please re-authorize the GitHub App to grant this permission. -

-
- {reauthorizeUrl && ( - - )} - -
-

- Already approved the new permissions in GitHub? Click "Refresh Permissions" - to update. -

-
-
- )} - - {/* GitHub Integration Error Alert */} - {gitHubError && ( - - - GitHub Integration Error - -

Failed to access GitHub: {gitHubError}

-

- This usually happens when the GitHub App has been uninstalled or permissions have - changed. Please reinstall the GitHub App to continue running security analyses. -

- - - -
-
- )} - - - {hasIntegration && ( - - - - Findings - - - - Config - - - )} - - {/* Findings Tab */} - - setActiveTab('config')} - lastSyncTime={lastSyncData?.lastSyncTime} - onStartAnalysis={handleStartAnalysis} - startingAnalysisIds={startingAnalysisIds} - sortBy={sortBy} - onSortByChange={handleSortByChange} - runningCount={runningCount} - concurrencyLimit={concurrencyLimit} - /> - - - {/* Config Tab - only available when GitHub integration exists */} - {hasIntegration && ( - - - {/* Clear Orphaned Findings Card - only shown when there are orphaned repos */} - - - )} - - - {/* Finding Detail Dialog */} - - - {/* Dismiss Finding Dialog */} - -
- ); -} diff --git a/apps/web/src/components/security-agent/index.ts b/apps/web/src/components/security-agent/index.ts index afca72f28a..ab30910ffc 100644 --- a/apps/web/src/components/security-agent/index.ts +++ b/apps/web/src/components/security-agent/index.ts @@ -6,7 +6,6 @@ export { FindingDetailDialog } from './FindingDetailDialog'; export { DismissFindingDialog, type DismissReason } from './DismissFindingDialog'; export { SecurityConfigForm, type SlaConfig } from './SecurityConfigForm'; export { RepositoryFilter } from './RepositoryFilter'; -export { SecurityAgentPageClient } from './SecurityAgentPageClient'; export { MarkdownProse } from './MarkdownProse'; export { FindingStatusBadge } from './FindingStatusBadge'; export { ClearFindingsCard } from './ClearFindingsCard'; diff --git a/apps/web/src/lib/security-agent/db/security-analysis.ts b/apps/web/src/lib/security-agent/db/security-analysis.ts index 1a62d0370e..a59ee7d19d 100644 --- a/apps/web/src/lib/security-agent/db/security-analysis.ts +++ b/apps/web/src/lib/security-agent/db/security-analysis.ts @@ -493,6 +493,7 @@ export async function enqueueBacklogFindings(params: { export async function transitionAutoAnalysisQueueFromCallback(params: { findingId: string; + attemptToken?: string; toStatus: 'completed' | 'failed'; failureCode?: AutoAnalysisFailureCode; errorMessage?: string; @@ -515,6 +516,9 @@ export async function transitionAutoAnalysisQueueFromCallback(params: { .where( and( eq(security_analysis_queue.finding_id, params.findingId), + params.attemptToken + ? eq(security_analysis_queue.claim_token, params.attemptToken) + : undefined, or( eq(security_analysis_queue.queue_status, 'running'), eq(security_analysis_queue.queue_status, 'pending') 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 b914603eef..ee290dfb67 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 @@ -113,7 +113,6 @@ function createHandlers() { platform_installation_id: 'installation-123', repositories: [{ full_name: 'kilo/repo' }], }) as never, - getGitHubToken: async () => null, trackingExtras: () => ({}), }); } 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 65aa1d6713..9f0611d179 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -92,7 +92,6 @@ type SecurityAgentDeps = { resolveResourceId: (ctx: TRPCContext, input: TExtra) => string; verifyFindingOwnership: (finding: SecurityFinding, ctx: TRPCContext, input: TExtra) => boolean; getIntegration: (ctx: TRPCContext, input: TExtra) => Promise; - getGitHubToken: (ctx: TRPCContext, input: TExtra) => Promise; trackingExtras: (ctx: TRPCContext, input: TExtra) => Record; }; diff --git a/apps/web/src/routers/organizations/organization-security-agent-router.ts b/apps/web/src/routers/organizations/organization-security-agent-router.ts index e784fe7be1..8cdab445ce 100644 --- a/apps/web/src/routers/organizations/organization-security-agent-router.ts +++ b/apps/web/src/routers/organizations/organization-security-agent-router.ts @@ -7,7 +7,6 @@ import { } from './utils'; import { getIntegrationForOrganization } from '@/lib/integrations/db/platform-integrations'; -import { getGitHubTokenForOrganization } from '@/lib/cloud-agent/github-integration-helpers'; import { createSecurityAgentHandlers } from '@/lib/security-agent/router/shared-handlers'; const handlers = createSecurityAgentHandlers<{ organizationId: string }>({ @@ -24,8 +23,6 @@ const handlers = createSecurityAgentHandlers<{ organizationId: string }>({ finding.owned_by_organization_id === input.organizationId, getIntegration: async (_ctx, input) => await getIntegrationForOrganization(input.organizationId, 'github'), - getGitHubToken: async (_ctx, input) => - (await getGitHubTokenForOrganization(input.organizationId)) ?? null, trackingExtras: (_ctx, input) => ({ organizationId: input.organizationId, }), diff --git a/apps/web/src/routers/security-agent-router.ts b/apps/web/src/routers/security-agent-router.ts index ed0ba94ff7..60c8fe7b86 100644 --- a/apps/web/src/routers/security-agent-router.ts +++ b/apps/web/src/routers/security-agent-router.ts @@ -1,6 +1,5 @@ import { createTRPCRouter, baseProcedure } from '@/lib/trpc/init'; import { getIntegrationForOwner } from '@/lib/integrations/db/platform-integrations'; -import { getGitHubTokenForUser } from '@/lib/cloud-agent/github-integration-helpers'; import { createSecurityAgentHandlers } from '@/lib/security-agent/router/shared-handlers'; const handlers = createSecurityAgentHandlers({ @@ -18,9 +17,6 @@ const handlers = createSecurityAgentHandlers({ const owner = { type: 'user' as const, id: ctx.user.id, userId: ctx.user.id }; return await getIntegrationForOwner(owner, 'github'); }, - getGitHubToken: async ctx => { - return (await getGitHubTokenForUser(ctx.user.id)) ?? null; - }, trackingExtras: () => ({}), }); diff --git a/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts b/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts index acb5467da9..6839fc2b59 100644 --- a/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts +++ b/services/security-auto-analysis/src/analysis-start-lifecycle.integration.test.ts @@ -98,6 +98,7 @@ describe('analysis start lifecycle durable transitions', () => { await expect( transitionAnalysisCallbackLifecycle(client.db as never, { findingId, + attemptToken: 'callback-completed-claim', outcome: { type: 'completed', analysis, @@ -141,6 +142,7 @@ describe('analysis start lifecycle durable transitions', () => { await expect( transitionAnalysisCallbackLifecycle(client.db as never, { findingId, + attemptToken: 'callback-failed-claim', outcome: { type: 'failed', errorMessage: 'upstream 503', @@ -187,6 +189,7 @@ describe('analysis start lifecycle durable transitions', () => { await expect( transitionAnalysisCallbackLifecycle(client.db as never, { findingId, + attemptToken: 'callback-superseded-claim', outcome: { type: 'superseded' }, }) ).resolves.toEqual({ status: 'superseded' }); @@ -223,6 +226,7 @@ describe('analysis start lifecycle durable transitions', () => { await expect( transitionAnalysisCallbackLifecycle(client.db as never, { findingId, + attemptToken: 'callback-superseded-completion-race-claim', outcome: { type: 'completed', analysis: createAnalysis('callback-superseded-completion-race'), @@ -262,6 +266,7 @@ describe('analysis start lifecycle durable transitions', () => { await expect( transitionAnalysisCallbackLifecycle(client.db as never, { findingId, + attemptToken: 'callback-partial-completion-claim', outcome: { type: 'already-terminal', findingStatus: 'completed', diff --git a/services/security-auto-analysis/src/analysis-start-lifecycle.ts b/services/security-auto-analysis/src/analysis-start-lifecycle.ts index 724ac45de9..a2538d6359 100644 --- a/services/security-auto-analysis/src/analysis-start-lifecycle.ts +++ b/services/security-auto-analysis/src/analysis-start-lifecycle.ts @@ -66,10 +66,26 @@ export async function transitionAnalysisCallbackLifecycle( db: WorkerDb, params: { findingId: string; + attemptToken: string; outcome: AnalysisCallbackLifecycleOutcome; } -): Promise<{ status: 'completed' | 'failed' | 'superseded' | 'already-terminal' }> { +): Promise<{ + status: 'completed' | 'failed' | 'superseded' | 'already-terminal' | 'stale-attempt'; +}> { return db.transaction(async tx => { + const activeAttemptRows = await tx.execute<{ id: string }>(sql` + SELECT id + FROM security_analysis_queue + WHERE finding_id = ${params.findingId}::uuid + AND claim_token = ${params.attemptToken} + AND queue_status IN ('pending', 'running') + FOR UPDATE + `); + const activeAttemptId = activeAttemptRows.rows[0]?.id; + if (!activeAttemptId) { + return { status: 'stale-attempt' }; + } + if (params.outcome.type === 'already-terminal') { await tx .update(security_analysis_queue) @@ -83,8 +99,8 @@ export async function transitionAnalysisCallbackLifecycle( }) .where( and( - eq(security_analysis_queue.finding_id, params.findingId), - inArray(security_analysis_queue.queue_status, ['pending', 'running']) + eq(security_analysis_queue.id, activeAttemptId), + eq(security_analysis_queue.claim_token, params.attemptToken) ) ); return { status: 'already-terminal' }; @@ -108,8 +124,8 @@ export async function transitionAnalysisCallbackLifecycle( }) .where( and( - eq(security_analysis_queue.finding_id, params.findingId), - inArray(security_analysis_queue.queue_status, ['pending', 'running']) + eq(security_analysis_queue.id, activeAttemptId), + eq(security_analysis_queue.claim_token, params.attemptToken) ) ); return { status: 'superseded' }; @@ -162,8 +178,8 @@ export async function transitionAnalysisCallbackLifecycle( }) .where( and( - eq(security_analysis_queue.finding_id, params.findingId), - inArray(security_analysis_queue.queue_status, ['pending', 'running']) + eq(security_analysis_queue.id, activeAttemptId), + eq(security_analysis_queue.claim_token, params.attemptToken) ) ); return { status: 'superseded' }; @@ -180,8 +196,8 @@ export async function transitionAnalysisCallbackLifecycle( }) .where( and( - eq(security_analysis_queue.finding_id, params.findingId), - inArray(security_analysis_queue.queue_status, ['pending', 'running']) + eq(security_analysis_queue.id, activeAttemptId), + eq(security_analysis_queue.claim_token, params.attemptToken) ) ); diff --git a/services/security-auto-analysis/src/callbacks.test.ts b/services/security-auto-analysis/src/callbacks.test.ts index a5990e7191..051a9bc8cf 100644 --- a/services/security-auto-analysis/src/callbacks.test.ts +++ b/services/security-auto-analysis/src/callbacks.test.ts @@ -17,6 +17,7 @@ const failedPayload = { status: 'failed', errorMessage: 'upstream 503', } satisfies SecurityAnalysisCallbackPayload; +const ATTEMPT_TOKEN = 'attempt-token-123'; vi.mock('./analysis-start-lifecycle.js', () => ({ transitionAnalysisCallbackLifecycle: vi.fn(), @@ -69,6 +70,36 @@ describe('classifyAnalysisCallback', () => { ) ).toBe('superseded'); }); + + it('rejects callbacks from an older active attempt', () => { + expect( + classifyAnalysisCallback( + { + session_id: null, + cli_session_id: null, + ignored_reason: null, + analysis_status: 'pending', + }, + failedPayload, + { expected: 'old-attempt', active: ATTEMPT_TOKEN } + ) + ).toBe('stale-attempt'); + }); + + it('rejects callbacks when the active attempt has already disappeared', () => { + expect( + classifyAnalysisCallback( + { + session_id: null, + cli_session_id: null, + ignored_reason: null, + analysis_status: 'running', + }, + failedPayload, + { expected: ATTEMPT_TOKEN, active: null } + ) + ).toBe('stale-attempt'); + }); }); describe('resolveCompletedCallbackMarkdown', () => { @@ -110,6 +141,7 @@ describe('finalizeCompletedAnalysisCallback', () => { cli_session_id: 'ses-123', ignored_reason: null, analysis_status: 'running', + claimToken: ATTEMPT_TOKEN, owned_by_organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', owned_by_user_id: null, analysis: { @@ -151,6 +183,7 @@ describe('finalizeCompletedAnalysisCallback', () => { finalizeCompletedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: { sessionId: 'session-123', cloudAgentSessionId: 'agent-123', @@ -182,6 +215,7 @@ describe('finalizeCompletedAnalysisCallback', () => { expect(executes).toHaveLength(0); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: expect.objectContaining({ type: 'completed', analysis: expect.objectContaining({ @@ -217,6 +251,7 @@ describe('finalizeCompletedAnalysisCallback', () => { finalizeCompletedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: { sessionId: 'session-123', cloudAgentSessionId: 'agent-123', @@ -232,6 +267,7 @@ describe('finalizeCompletedAnalysisCallback', () => { expect(extractSandboxAnalysis).not.toHaveBeenCalled(); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: { type: 'already-terminal', findingStatus: 'completed', @@ -252,6 +288,7 @@ describe('finalizeCompletedAnalysisCallback', () => { cli_session_id: 'ses-123', ignored_reason: 'superseded:canonical-finding', analysis_status: 'running', + claimToken: ATTEMPT_TOKEN, }, ], }), @@ -266,6 +303,7 @@ describe('finalizeCompletedAnalysisCallback', () => { finalizeCompletedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: { sessionId: 'session-123', cloudAgentSessionId: 'agent-123', @@ -281,6 +319,7 @@ describe('finalizeCompletedAnalysisCallback', () => { expect(extractSandboxAnalysis).not.toHaveBeenCalled(); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: { type: 'superseded' }, }); }); @@ -299,6 +338,7 @@ describe('finalizeCompletedAnalysisCallback', () => { cli_session_id: 'ses-123', ignored_reason: null, analysis_status: 'running', + claimToken: ATTEMPT_TOKEN, }, ], }), @@ -324,6 +364,7 @@ describe('finalizeCompletedAnalysisCallback', () => { finalizeCompletedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: { sessionId: 'session-123', cloudAgentSessionId: 'agent-123', @@ -343,6 +384,7 @@ describe('finalizeCompletedAnalysisCallback', () => { expect(queueTransitions).toHaveLength(0); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: { type: 'failed', errorMessage: 'Analysis completed but callback result text was missing', @@ -350,11 +392,61 @@ describe('finalizeCompletedAnalysisCallback', () => { }, }); }); + + it('rejects completed callbacks before extraction when no active attempt exists', async () => { + const extractSandboxAnalysis = vi.fn(); + const db = { + select: vi + .fn() + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: 'ses-123', + ignored_reason: null, + analysis_status: 'running', + }, + ], + }), + }), + }) + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [], + }), + }), + }), + }; + + await expect( + finalizeCompletedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, + payload: { + sessionId: 'session-123', + cloudAgentSessionId: 'agent-123', + executionId: 'exec-123', + kiloSessionId: 'ses-123', + status: 'completed', + lastAssistantMessageText: '# Completed analysis', + }, + extractSandboxAnalysis, + }) + ).resolves.toEqual({ status: 'stale-attempt' }); + + expect(extractSandboxAnalysis).not.toHaveBeenCalled(); + expect(transitionAnalysisCallbackLifecycle).not.toHaveBeenCalled(); + }); }); describe('consumeAnalysisCallbackBatch', () => { const callbackBody = { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: { sessionId: 'session-123', cloudAgentSessionId: 'agent-123', @@ -380,6 +472,31 @@ describe('consumeAnalysisCallbackBatch', () => { expect(retry).not.toHaveBeenCalled(); }); + it('accepts legacy callback messages without an attempt token', async () => { + const ack = vi.fn(); + const retry = vi.fn(); + const finalizeCallback = vi.fn().mockResolvedValue({ status: 'completed-finalized' }); + const legacyBody = { + findingId: callbackBody.findingId, + payload: callbackBody.payload, + }; + + await consumeAnalysisCallbackBatch( + { messages: [{ body: legacyBody, ack, retry }] } as never, + {} as CloudflareEnv, + finalizeCallback + ); + + expect(finalizeCallback).toHaveBeenCalledWith({ + env: {}, + findingId: callbackBody.findingId, + attemptToken: undefined, + payload: callbackBody.payload, + }); + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); + it('retries callback messages when durable finalization throws', async () => { const ack = vi.fn(); const retry = vi.fn(); @@ -420,12 +537,14 @@ describe('finalizeFailedAnalysisCallback', () => { finalizeFailedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: failedPayload, }) ).resolves.toEqual({ status: 'already-terminal' }); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: { type: 'already-terminal', findingStatus: 'failed', @@ -446,6 +565,7 @@ describe('finalizeFailedAnalysisCallback', () => { cli_session_id: null, ignored_reason: 'superseded:canonical-finding', analysis_status: 'running', + claimToken: ATTEMPT_TOKEN, }, ], }), @@ -459,12 +579,14 @@ describe('finalizeFailedAnalysisCallback', () => { finalizeFailedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: failedPayload, }) ).resolves.toEqual({ status: 'superseded' }); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: { type: 'superseded' }, }); }); @@ -482,6 +604,7 @@ describe('finalizeFailedAnalysisCallback', () => { cli_session_id: null, ignored_reason: null, analysis_status: 'running', + claimToken: ATTEMPT_TOKEN, }, ], }), @@ -507,6 +630,7 @@ describe('finalizeFailedAnalysisCallback', () => { finalizeFailedAnalysisCallback({ db: db as never, findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, payload: failedPayload, }) ).resolves.toEqual({ status: 'failed-finalized' }); @@ -514,6 +638,45 @@ describe('finalizeFailedAnalysisCallback', () => { expect(queueTransitions).toHaveLength(0); expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, + outcome: { + type: 'failed', + errorMessage: 'upstream 503', + failureCode: 'UPSTREAM_5XX', + }, + }); + }); + + it('resolves the active claim token for legacy failed callback messages', async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + session_id: 'agent-123', + cli_session_id: null, + ignored_reason: null, + analysis_status: 'running', + claimToken: ATTEMPT_TOKEN, + }, + ], + }), + }), + }), + }; + + await expect( + finalizeFailedAnalysisCallback({ + db: db as never, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + payload: failedPayload, + }) + ).resolves.toEqual({ status: 'failed-finalized' }); + + expect(transitionAnalysisCallbackLifecycle).toHaveBeenCalledWith(db, { + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + attemptToken: ATTEMPT_TOKEN, outcome: { type: 'failed', errorMessage: 'upstream 503', diff --git a/services/security-auto-analysis/src/callbacks.ts b/services/security-auto-analysis/src/callbacks.ts index 955f0027ff..c74f804723 100644 --- a/services/security-auto-analysis/src/callbacks.ts +++ b/services/security-auto-analysis/src/callbacks.ts @@ -2,7 +2,11 @@ import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; import { security_audit_log } from '@kilocode/db/schema'; import { SecurityAuditLogAction } from '@kilocode/db/schema-types'; import { z } from 'zod'; -import { getAnalysisActorById, getSecurityFindingById } from './db/queries.js'; +import { + getActiveAnalysisAttemptToken, + getAnalysisActorById, + getSecurityFindingById, +} from './db/queries.js'; import { transitionAnalysisCallbackLifecycle } from './analysis-start-lifecycle.js'; import { generateApiToken } from './token.js'; import { extractSandboxAnalysis as runSandboxExtraction } from './extraction.js'; @@ -30,6 +34,7 @@ export type SecurityAnalysisCallbackPayload = z.infer; -export type CallbackDisposition = 'process' | 'stale-session' | 'superseded' | 'already-terminal'; +export type CallbackDisposition = + | 'process' + | 'stale-session' + | 'stale-attempt' + | 'superseded' + | 'already-terminal'; export function classifyAnalysisCallback( finding: CallbackFindingState, - payload: SecurityAnalysisCallbackPayload + payload: SecurityAnalysisCallbackPayload, + attempt?: { expected: string; active: string | null | undefined } ): CallbackDisposition { + if ( + attempt && + attempt.active !== attempt.expected && + (finding.analysis_status === 'pending' || finding.analysis_status === 'running') + ) { + return 'stale-attempt'; + } const sessionMismatch = (payload.cloudAgentSessionId && finding.session_id && @@ -139,6 +157,7 @@ export async function resolveCompletedCallbackMarkdown(params: { export async function finalizeCompletedAnalysisCallback(params: { db: WorkerDb; findingId: string; + attemptToken?: string; payload: SecurityAnalysisCallbackPayload; extractSandboxAnalysis: ExtractSandboxAnalysis; maybeAutoDismissAnalysis?: MaybeAutoDismissAnalysis; @@ -150,13 +169,29 @@ export async function finalizeCompletedAnalysisCallback(params: { }> { const finding = await getSecurityFindingById(params.db, params.findingId); if (!finding) return { status: 'missing' }; + const activeAttemptToken = await getActiveAnalysisAttemptToken(params.db, params.findingId); + const attemptToken = params.attemptToken ?? activeAttemptToken ?? undefined; + if ( + !attemptToken && + (finding.analysis_status === 'pending' || finding.analysis_status === 'running') + ) { + return { status: 'stale-attempt' }; + } - const disposition = classifyAnalysisCallback(finding, params.payload); + const disposition = attemptToken + ? classifyAnalysisCallback(finding, params.payload, { + expected: attemptToken, + active: activeAttemptToken, + }) + : classifyAnalysisCallback(finding, params.payload); if (disposition === 'stale-session') return { status: disposition }; + if (disposition === 'stale-attempt') return { status: disposition }; if (disposition === 'already-terminal') { const findingStatus = finding.analysis_status === 'failed' ? 'failed' : 'completed'; + if (!attemptToken) return { status: disposition }; await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'already-terminal', findingStatus, @@ -170,12 +205,15 @@ export async function finalizeCompletedAnalysisCallback(params: { return { status: disposition }; } if (disposition === 'superseded') { + if (!attemptToken) return { status: disposition }; await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'superseded' }, }); return { status: disposition }; } + if (!attemptToken) return { status: 'stale-attempt' }; const rawMarkdown = await resolveCompletedCallbackMarkdown({ payload: params.payload, @@ -184,14 +222,17 @@ export async function finalizeCompletedAnalysisCallback(params: { }); if (!rawMarkdown) { const errorMessage = 'Analysis completed but callback result text was missing'; - await transitionAnalysisCallbackLifecycle(params.db, { + const lifecycleTransition = await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'failed', errorMessage, failureCode: 'START_CALL_AMBIGUOUS', }, }); + if (lifecycleTransition.status === 'stale-attempt') return { status: 'stale-attempt' }; + if (lifecycleTransition.status === 'superseded') return { status: 'superseded' }; return { status: 'result-missing' }; } @@ -212,12 +253,14 @@ export async function finalizeCompletedAnalysisCallback(params: { const lifecycleTransition = await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'completed', analysis: completedAnalysis, }, }); if (lifecycleTransition.status === 'superseded') return { status: 'superseded' }; + if (lifecycleTransition.status === 'stale-attempt') return { status: 'stale-attempt' }; await params.db.insert(security_audit_log).values({ owned_by_organization_id: finding.owned_by_organization_id, owned_by_user_id: finding.owned_by_user_id, @@ -252,13 +295,28 @@ export async function finalizeCompletedAnalysisCallback(params: { export async function finalizeFailedAnalysisCallback(params: { db: WorkerDb; findingId: string; + attemptToken?: string; payload: SecurityAnalysisCallbackPayload; }): Promise<{ status: 'missing' | CallbackDisposition | 'failed-finalized' }> { const finding = await getSecurityFindingById(params.db, params.findingId); if (!finding) return { status: 'missing' }; + const activeAttemptToken = await getActiveAnalysisAttemptToken(params.db, params.findingId); + const attemptToken = params.attemptToken ?? activeAttemptToken ?? undefined; + if ( + !attemptToken && + (finding.analysis_status === 'pending' || finding.analysis_status === 'running') + ) { + return { status: 'stale-attempt' }; + } - const disposition = classifyAnalysisCallback(finding, params.payload); + const disposition = attemptToken + ? classifyAnalysisCallback(finding, params.payload, { + expected: attemptToken, + active: activeAttemptToken, + }) + : classifyAnalysisCallback(finding, params.payload); if (disposition === 'stale-session') return { status: disposition }; + if (disposition === 'stale-attempt') return { status: disposition }; if (disposition === 'already-terminal') { const findingStatus = finding.analysis_status === 'completed' ? 'completed' : 'failed'; const failure = @@ -268,8 +326,10 @@ export async function finalizeFailedAnalysisCallback(params: { errorMessage: params.payload.errorMessage, }) : null; + if (!attemptToken) return { status: disposition }; await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'already-terminal', findingStatus, @@ -280,12 +340,15 @@ export async function finalizeFailedAnalysisCallback(params: { return { status: disposition }; } if (disposition === 'superseded') { + if (!attemptToken) return { status: disposition }; await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'superseded' }, }); return { status: disposition }; } + if (!attemptToken) return { status: 'stale-attempt' }; const failure = mapAnalysisCallbackFailure({ status: params.payload.status === 'interrupted' ? 'interrupted' : 'failed', @@ -293,6 +356,7 @@ export async function finalizeFailedAnalysisCallback(params: { }); const lifecycleTransition = await transitionAnalysisCallbackLifecycle(params.db, { findingId: params.findingId, + attemptToken, outcome: { type: 'failed', errorMessage: failure.errorMessage, @@ -300,18 +364,21 @@ export async function finalizeFailedAnalysisCallback(params: { }, }); if (lifecycleTransition.status === 'superseded') return { status: 'superseded' }; + if (lifecycleTransition.status === 'stale-attempt') return { status: 'stale-attempt' }; return { status: 'failed-finalized' }; } export async function finalizeFailedAnalysisCallbackFromEnv(params: { env: CloudflareEnv; findingId: string; + attemptToken?: string; payload: SecurityAnalysisCallbackPayload; }): Promise<{ status: 'missing' | CallbackDisposition | 'failed-finalized' }> { const db = getWorkerDb(params.env.HYPERDRIVE.connectionString, { statement_timeout: 30_000 }); return finalizeFailedAnalysisCallback({ db, findingId: params.findingId, + attemptToken: params.attemptToken, payload: params.payload, }); } @@ -319,6 +386,7 @@ export async function finalizeFailedAnalysisCallbackFromEnv(params: { export async function finalizeCompletedAnalysisCallbackFromEnv(params: { env: CloudflareEnv; findingId: string; + attemptToken?: string; payload: SecurityAnalysisCallbackPayload; }): Promise<{ status: 'missing' | CallbackDisposition | 'completed-finalized' | 'result-missing'; @@ -327,6 +395,7 @@ export async function finalizeCompletedAnalysisCallbackFromEnv(params: { return finalizeCompletedAnalysisCallback({ db, findingId: params.findingId, + attemptToken: params.attemptToken, payload: params.payload, fetchLatestAssistantText: async ({ kiloSessionId }) => { const finding = await getSecurityFindingById(db, params.findingId); @@ -386,6 +455,7 @@ export async function finalizeCompletedAnalysisCallbackFromEnv(params: { export async function finalizeAnalysisCallbackFromEnv(params: { env: CloudflareEnv; findingId: string; + attemptToken?: string; payload: SecurityAnalysisCallbackPayload; }): Promise<{ status: @@ -415,6 +485,7 @@ export async function consumeAnalysisCallbackBatch( await finalizeCallback({ env, findingId: parsed.data.findingId, + attemptToken: parsed.data.attemptToken, payload: parsed.data.payload, }); message.ack(); diff --git a/services/security-auto-analysis/src/db/queries.ts b/services/security-auto-analysis/src/db/queries.ts index 0a04b010dc..4407840d86 100644 --- a/services/security-auto-analysis/src/db/queries.ts +++ b/services/security-auto-analysis/src/db/queries.ts @@ -603,6 +603,24 @@ export async function getSecurityFindingById(db: WorkerDb, findingId: string) { export type SecurityFindingRecord = NonNullable>>; +export async function getActiveAnalysisAttemptToken( + db: WorkerDb, + findingId: string +): Promise { + const rows = await db + .select({ claimToken: security_analysis_queue.claim_token }) + .from(security_analysis_queue) + .where( + and( + eq(security_analysis_queue.finding_id, findingId), + inArray(security_analysis_queue.queue_status, ['pending', 'running']) + ) + ) + .limit(1); + + return rows[0]?.claimToken ?? null; +} + export async function tryAcquireAnalysisStartLease( db: WorkerDb, findingId: string @@ -656,99 +674,6 @@ export async function setFindingPending( ); } -export async function setFindingRunning( - db: WorkerDb, - findingId: string, - cloudAgentSessionId: string, - kiloSessionId: string -): Promise { - await db - .update(security_findings) - .set({ - analysis_status: 'running', - session_id: cloudAgentSessionId, - cli_session_id: kiloSessionId, - analysis_started_at: sql`coalesce(${security_findings.analysis_started_at}, now())`.mapWith( - String - ), - updated_at: sql`now()`.mapWith(String), - }) - .where( - and( - eq(security_findings.id, findingId), - or( - isNull(security_findings.ignored_reason), - not(like(security_findings.ignored_reason, 'superseded:%')) - ) - ) - ); -} - -/** - * Mark a finding's analysis as completed. - * Returns false if the finding was superseded (guard tripped, no rows updated). - * The caller should clear analysis_status when this returns false. - */ -export async function setFindingCompleted( - db: WorkerDb, - findingId: string, - analysis: SecurityFindingAnalysis -): Promise { - const rows = await db - .update(security_findings) - .set({ - analysis_status: 'completed', - analysis: sql`${JSON.stringify(analysis)}::jsonb`, - analysis_error: null, - analysis_completed_at: sql`now()`.mapWith(String), - updated_at: sql`now()`.mapWith(String), - }) - .where( - and( - eq(security_findings.id, findingId), - or( - isNull(security_findings.ignored_reason), - not(like(security_findings.ignored_reason, 'superseded:%')) - ) - ) - ) - .returning({ id: security_findings.id }); - - return rows.length > 0; -} - -/** - * Mark a finding's analysis as failed. - * Returns false if the finding was superseded (guard tripped, no rows updated). - * The caller should clear analysis_status when this returns false. - */ -export async function setFindingFailed( - db: WorkerDb, - findingId: string, - errorMessage: string -): Promise { - const rows = await db - .update(security_findings) - .set({ - analysis_status: 'failed', - analysis_error: errorMessage, - analysis_completed_at: sql`now()`.mapWith(String), - updated_at: sql`now()`.mapWith(String), - }) - .where( - and( - eq(security_findings.id, findingId), - or( - isNull(security_findings.ignored_reason), - not(like(security_findings.ignored_reason, 'superseded:%')) - ) - ) - ) - .returning({ id: security_findings.id }); - - return rows.length > 0; -} - /** * Clear analysis_status so a superseded finding no longer counts against * the owner's concurrency cap in countRunningAnalyses(). @@ -763,27 +688,6 @@ export async function clearAnalysisStatus(db: WorkerDb, findingId: string): Prom .where(eq(security_findings.id, findingId)); } -export async function transitionAnalysisQueueFromCallback( - db: WorkerDb, - params: { - findingId: string; - toStatus: 'completed' | 'failed'; - failureCode: string | null; - errorMessage: string | null; - } -): Promise { - await db.execute(sql` - UPDATE security_analysis_queue - SET - queue_status = ${params.toStatus}, - failure_code = ${params.failureCode}, - last_error_redacted = ${params.errorMessage}, - updated_at = now() - WHERE finding_id = ${params.findingId}::uuid - AND queue_status IN ('running', 'pending') - `); -} - export async function reconcileStaleAnalysisQueueRows(db: WorkerDb): Promise<{ requeuedPendingCount: number; failedRunningCount: number; diff --git a/services/security-auto-analysis/src/index.test.ts b/services/security-auto-analysis/src/index.test.ts index 7d0ecd3c24..2d929c29d2 100644 --- a/services/security-auto-analysis/src/index.test.ts +++ b/services/security-auto-analysis/src/index.test.ts @@ -5,10 +5,11 @@ import worker from './index.js'; const CALLBACK_SECRET = 'callback-token-secret'; const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const OTHER_FINDING_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const ATTEMPT_TOKEN = 'attempt-token-123'; function callbackRequest(headers: Record = {}): Request { return new Request( - `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}`, + `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}?attempt=${ATTEMPT_TOKEN}`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, @@ -23,11 +24,14 @@ function callbackRequest(headers: Record = {}): Request { ); } -async function callbackTokenFor(findingId = FINDING_ID): Promise { +async function callbackTokenFor( + findingId = FINDING_ID, + attemptToken = ATTEMPT_TOKEN +): Promise { return deriveCallbackToken({ secret: CALLBACK_SECRET, scope: 'security-analysis-callback', - resourceParts: [findingId], + resourceParts: [findingId, attemptToken], }); } @@ -88,6 +92,7 @@ describe('security analysis callback ingress', () => { expect(queued).toHaveLength(1); expect(queued[0]?.[0]?.body).toMatchObject({ findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, payload: { status: 'completed' }, }); }); @@ -95,7 +100,7 @@ describe('security analysis callback ingress', () => { it('accepts authenticated failed callbacks for durable Worker terminalization', async () => { const queued: MessageSendRequest[][] = []; const request = new Request( - `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}`, + `https://security-auto-analysis/internal/security-analysis-callback/${FINDING_ID}?attempt=${ATTEMPT_TOKEN}`, { method: 'POST', headers: { @@ -123,6 +128,7 @@ describe('security analysis callback ingress', () => { expect(response.status).toBe(202); expect(queued[0]?.[0]?.body).toMatchObject({ findingId: FINDING_ID, + attemptToken: ATTEMPT_TOKEN, payload: { status: 'failed', errorMessage: 'sandbox failed' }, }); }); diff --git a/services/security-auto-analysis/src/index.ts b/services/security-auto-analysis/src/index.ts index 6791639804..e730bd55cc 100644 --- a/services/security-auto-analysis/src/index.ts +++ b/services/security-auto-analysis/src/index.ts @@ -108,6 +108,10 @@ async function handleFetch(request: Request, env: CloudflareEnv): Promise ({ clearAnalysisStatus: vi.fn(), getSecurityAgentConfigForOwner: vi.fn(), getSecurityFindingById: vi.fn(), - setFindingCompleted: vi.fn(), - setFindingFailed: vi.fn(), setFindingPending: vi.fn(), - setFindingRunning: vi.fn(), tryAcquireAnalysisStartLease: vi.fn(), })); vi.mock('./analysis-start-lifecycle.js', () => ({ transitionAnalysisStartLifecycle: vi.fn() })); @@ -156,10 +150,11 @@ describe('buildSecurityAnalysisCallbackTarget', () => { SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: 'https://security-analysis.test/', }, finding.id, - 'callback-token' + 'callback-token', + 'attempt-token' ) ).toEqual({ - url: `https://security-analysis.test/internal/security-analysis-callback/${finding.id}`, + url: `https://security-analysis.test/internal/security-analysis-callback/${finding.id}?attempt=attempt-token`, headers: { 'X-Callback-Token': 'callback-token' }, }); }); @@ -173,10 +168,11 @@ describe('buildSecurityAnalysisCallbackTarget', () => { SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: '', }, finding.id, - 'callback-token' + 'callback-token', + 'attempt-token' ) ).toEqual({ - url: `https://app.kilo.ai/api/internal/security-analysis-callback/${finding.id}`, + url: `https://app.kilo.ai/api/internal/security-analysis-callback/${finding.id}?attempt=attempt-token`, headers: { 'X-Callback-Token': 'callback-token' }, }); }); @@ -190,7 +186,8 @@ describe('buildSecurityAnalysisCallbackTarget', () => { SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL: '', }, 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - 'callback-token' + 'callback-token', + 'attempt-token' ) ).toThrow('SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL'); }); @@ -205,10 +202,7 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { auto_dismiss_enabled: true, auto_dismiss_confidence_threshold: 'high', } as never); - vi.mocked(setFindingCompleted).mockResolvedValue(true); - vi.mocked(setFindingFailed).mockResolvedValue(true); vi.mocked(setFindingPending).mockResolvedValue(undefined); - vi.mocked(setFindingRunning).mockResolvedValue(undefined); vi.mocked(clearAnalysisStatus).mockResolvedValue(undefined); vi.mocked(transitionAnalysisStartLifecycle).mockResolvedValue({ transitioned: true }); }); @@ -227,7 +221,9 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { return Response.json({ result: { data: { executionId: 'exec-123', status: 'running' } } }); }); - await expect(startSecurityAnalysis(createParams(false, cloudAgentFetch as never))).resolves.toEqual({ + await expect( + startSecurityAnalysis(createParams(false, cloudAgentFetch as never)) + ).resolves.toEqual({ started: true, triageOnly: false, }); @@ -236,7 +232,7 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { const expectedCallbackToken = await deriveCallbackToken({ secret: CALLBACK_SECRET, scope: 'security-analysis-callback', - resourceParts: [finding.id], + resourceParts: [finding.id, 'manual-claim-token'], }); expect(prepareBody).toMatchObject({ callbackTarget: { @@ -275,7 +271,9 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { ) ); - await expect(startSecurityAnalysis(createParams(true, cloudAgentFetch as never))).resolves.toEqual({ + await expect( + startSecurityAnalysis(createParams(true, cloudAgentFetch as never)) + ).resolves.toEqual({ started: true, triageOnly: false, }); @@ -299,7 +297,6 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { }, }) ); - expect(setFindingRunning).not.toHaveBeenCalled(); }); it('falls back to full triage when sandbox-only retry has no prior triage', async () => { @@ -324,8 +321,6 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { outcome: expect.objectContaining({ type: 'triage-only-completed' }), }) ); - expect(setFindingCompleted).not.toHaveBeenCalled(); - expect(setFindingFailed).not.toHaveBeenCalled(); expect(clearAnalysisStatus).not.toHaveBeenCalled(); }); @@ -435,8 +430,6 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { error: 'upstream unavailable', failureNeedsLifecycleTransition: true, }); - - expect(setFindingFailed).not.toHaveBeenCalled(); }); it('returns initiate failures for lifecycle settlement after the running transition', async () => { @@ -466,6 +459,5 @@ describe('startSecurityAnalysis retrySandboxOnly', () => { {}, expect.objectContaining({ outcome: expect.objectContaining({ type: 'sandbox-running' }) }) ); - expect(setFindingFailed).not.toHaveBeenCalled(); }); }); diff --git a/services/security-auto-analysis/src/launch.ts b/services/security-auto-analysis/src/launch.ts index d2737d11b4..5ee1778e00 100644 --- a/services/security-auto-analysis/src/launch.ts +++ b/services/security-auto-analysis/src/launch.ts @@ -126,15 +126,17 @@ export function buildSecurityAnalysisCallbackTarget( | 'SECURITY_ANALYSIS_CALLBACK_WORKER_BASE_URL' >, findingId: string, - callbackToken: string + callbackToken: string, + attemptToken: string ): { url: string; headers: { 'X-Callback-Token': string }; } { + const encodedAttemptToken = encodeURIComponent(attemptToken); if (env.SECURITY_ANALYSIS_CALLBACK_ROUTING_MODE === 'web') { const baseUrl = env.SECURITY_ANALYSIS_CALLBACK_WEB_BASE_URL.replace(/\/$/, ''); return { - url: `${baseUrl}/api/internal/security-analysis-callback/${findingId}`, + url: `${baseUrl}/api/internal/security-analysis-callback/${findingId}?attempt=${encodedAttemptToken}`, headers: { 'X-Callback-Token': callbackToken }, }; } @@ -147,7 +149,7 @@ export function buildSecurityAnalysisCallbackTarget( } return { - url: `${baseUrl}/internal/security-analysis-callback/${findingId}`, + url: `${baseUrl}/internal/security-analysis-callback/${findingId}?attempt=${encodedAttemptToken}`, headers: { 'X-Callback-Token': callbackToken }, }; } @@ -250,12 +252,13 @@ export async function startSecurityAnalysis( const callbackToken = await deriveCallbackToken({ secret: params.callbackTokenSecret, scope: 'security-analysis-callback', - resourceParts: [params.findingId], + resourceParts: [params.findingId, params.lifecycleClaim.claimToken], }); const callbackTarget = buildSecurityAnalysisCallbackTarget( params.env, params.findingId, - callbackToken + callbackToken, + params.lifecycleClaim.claimToken ); const prepareInput = { diff --git a/services/security-sync/src/dismiss.test.ts b/services/security-sync/src/dismiss.test.ts index 45da17e61e..d7f35222a3 100644 --- a/services/security-sync/src/dismiss.test.ts +++ b/services/security-sync/src/dismiss.test.ts @@ -12,9 +12,28 @@ const finding = { owned_by_user_id: null, }; -function createDb(selectedFinding = finding) { +function createDb(selectedFinding = finding, options: { failAuditInsert?: boolean } = {}) { const updates: unknown[] = []; const auditRows: unknown[] = []; + function createOperations(targetUpdates: unknown[], targetAuditRows: unknown[]) { + return { + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + targetUpdates.push(values); + }, + }), + }), + insert: () => ({ + values: async (values: unknown) => { + if (options.failAuditInsert) { + throw new Error('audit insert failed'); + } + targetAuditRows.push(values); + }, + }), + }; + } const db = { select: () => ({ from: () => ({ @@ -23,18 +42,15 @@ function createDb(selectedFinding = finding) { }), }), }), - update: () => ({ - set: (values: unknown) => ({ - where: async () => { - updates.push(values); - }, - }), - }), - insert: () => ({ - values: async (values: unknown) => { - auditRows.push(values); - }, - }), + ...createOperations(updates, auditRows), + transaction: async (callback: (tx: unknown) => Promise) => { + const stagedUpdates: unknown[] = []; + const stagedAuditRows: unknown[] = []; + const result = await callback(createOperations(stagedUpdates, stagedAuditRows)); + updates.push(...stagedUpdates); + auditRows.push(...stagedAuditRows); + return result; + }, }; return { db: db as never, updates, auditRows }; @@ -102,6 +118,22 @@ describe('processSecurityFindingDismissal', () => { expect(auditRows).toHaveLength(0); }); + it('rolls back the local finding update when audit insert fails', async () => { + const { db, updates, auditRows } = createDb(finding, { failAuditInsert: true }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); + + await expect( + processSecurityFindingDismissal({ + db, + gitTokenService: { getToken: async () => 'github-token' } as GitTokenService, + message: createMessage(), + }) + ).rejects.toThrow('audit insert failed'); + + expect(updates).toHaveLength(0); + expect(auditRows).toHaveLength(0); + }); + it('does not mutate local state when Dependabot source metadata is malformed', async () => { const { db, updates, auditRows } = createDb({ ...finding, source_id: '42junk' }); const getToken = vi.fn().mockResolvedValue('github-token'); diff --git a/services/security-sync/src/sync.test.ts b/services/security-sync/src/sync.test.ts index 8de3fb2949..dfb0fd61b4 100644 --- a/services/security-sync/src/sync.test.ts +++ b/services/security-sync/src/sync.test.ts @@ -37,7 +37,10 @@ function createFakeDb(options: FakeDbOptions = {}) { id: 'integration-1', platform_installation_id: 'installation-1', permissions: { vulnerability_alerts: 'read' }, - repositories: repositories.map((full_name, index) => ({ id: index + 1, full_name })), + repositories: repositories.map((full_name, index) => ({ + id: index + 1, + full_name, + })), authInvalidAt: options.authInvalidAt ?? null, }, ]; diff --git a/services/security-sync/src/sync.ts b/services/security-sync/src/sync.ts index 47fec084cc..a7f8fc9db7 100644 --- a/services/security-sync/src/sync.ts +++ b/services/security-sync/src/sync.ts @@ -367,7 +367,10 @@ async function markIntegrationAuthInvalid( } } -async function clearIntegrationAuthInvalid(db: WorkerDb, platformIntegrationId: string): Promise { +async function clearIntegrationAuthInvalid( + db: WorkerDb, + platformIntegrationId: string +): Promise { try { const now = new Date().toISOString(); await db From 334d73837f89fdd875921f980eccaf4463344ace Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 1 Jun 2026 13:01:18 +0200 Subject: [PATCH 13/18] chore: address code review feedback --- .../manual-analysis-admission-copy.test.ts | 8 ++--- .../src/security-auto-analysis-policy.ts | 31 +++++++++++++------ .../security-auto-analysis/src/db/queries.ts | 1 + services/security-auto-analysis/src/index.ts | 8 ++--- services/security-sync/src/index.ts | 8 ++--- 5 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts b/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts index 1f77a685da..9e79e39247 100644 --- a/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts +++ b/apps/web/src/components/security-agent/manual-analysis-admission-copy.test.ts @@ -3,10 +3,8 @@ import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; describe('manualAnalysisAdmissionCopy', () => { test('describes manual analysis as queued admission', () => { - expect(manualAnalysisAdmissionCopy).toEqual({ - successTitle: 'Analysis queued', - failureTitle: 'Failed to queue analysis', - pendingLabel: 'Queueing', - }); + expect(manualAnalysisAdmissionCopy.successTitle).toMatch(/queued/i); + expect(manualAnalysisAdmissionCopy.failureTitle).toMatch(/failed to queue/i); + expect(manualAnalysisAdmissionCopy.pendingLabel).toMatch(/queue/i); }); }); diff --git a/packages/worker-utils/src/security-auto-analysis-policy.ts b/packages/worker-utils/src/security-auto-analysis-policy.ts index cc19b32373..43f154ade4 100644 --- a/packages/worker-utils/src/security-auto-analysis-policy.ts +++ b/packages/worker-utils/src/security-auto-analysis-policy.ts @@ -21,19 +21,32 @@ export type AutoAnalysisEligibilityDecision = { const LOW_SEVERITY_RANK = 3; +const SEVERITY_RANKS = { + critical: 0, + high: 1, + medium: 2, + low: LOW_SEVERITY_RANK, +} as const satisfies Record; + +type KnownSeverity = keyof typeof SEVERITY_RANKS; + +const MIN_SEVERITY_MAX_RANKS = { + critical: SEVERITY_RANKS.critical, + high: SEVERITY_RANKS.high, + medium: SEVERITY_RANKS.medium, + all: LOW_SEVERITY_RANK, +} as const satisfies Record; + +function isKnownSeverity(severity: string): severity is KnownSeverity { + return severity in SEVERITY_RANKS; +} + function getSeverityRank(severity: string | null): AutoAnalysisSeverityRank | null { - if (severity === 'critical') return 0; - if (severity === 'high') return 1; - if (severity === 'medium') return 2; - if (severity === 'low') return LOW_SEVERITY_RANK; - return null; + return severity && isKnownSeverity(severity) ? SEVERITY_RANKS[severity] : null; } function getMaxSeverityRank(minSeverity: AutoAnalysisMinSeverity): AutoAnalysisSeverityRank { - if (minSeverity === 'critical') return 0; - if (minSeverity === 'high') return 1; - if (minSeverity === 'medium') return 2; - return LOW_SEVERITY_RANK; + return MIN_SEVERITY_MAX_RANKS[minSeverity]; } export function decideAutoAnalysisEligibility( diff --git a/services/security-auto-analysis/src/db/queries.ts b/services/security-auto-analysis/src/db/queries.ts index 4407840d86..099ec434c5 100644 --- a/services/security-auto-analysis/src/db/queries.ts +++ b/services/security-auto-analysis/src/db/queries.ts @@ -59,6 +59,7 @@ export function parseSecurityConfig(config: unknown): SecurityAgentConfig { ...parsed.data, }; + // Preserve legacy unified model fallback instead of masking it with split model defaults. if (parsed.data.model_slug !== undefined) { if (parsed.data.triage_model_slug === undefined) { resolvedConfig.triage_model_slug = undefined; diff --git a/services/security-auto-analysis/src/index.ts b/services/security-auto-analysis/src/index.ts index e730bd55cc..1504c18e02 100644 --- a/services/security-auto-analysis/src/index.ts +++ b/services/security-auto-analysis/src/index.ts @@ -34,10 +34,6 @@ async function timingSafeEqual(a: string, b: string): Promise { return nodeTSE(new Uint8Array(digestA), new Uint8Array(digestB)); } -function workerRouteEnabled(value: string | undefined): boolean { - return value !== 'false'; -} - async function handleFetch(request: Request, env: CloudflareEnv): Promise { const url = new URL(request.url); @@ -65,7 +61,7 @@ async function handleFetch(request: Request, env: CloudflareEnv): Promise Date: Mon, 1 Jun 2026 13:11:14 +0200 Subject: [PATCH 14/18] test(cloud-agent-next): remove stale callback failure expectation --- .../session/legacy-callback-enqueue.test.ts | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/services/cloud-agent-next/test/integration/session/legacy-callback-enqueue.test.ts b/services/cloud-agent-next/test/integration/session/legacy-callback-enqueue.test.ts index fac02e6bdb..7579869552 100644 --- a/services/cloud-agent-next/test/integration/session/legacy-callback-enqueue.test.ts +++ b/services/cloud-agent-next/test/integration/session/legacy-callback-enqueue.test.ts @@ -65,51 +65,4 @@ describe('legacy execution callback enqueue', () => { status: 'completed', }); }); - - it('handles callback queue send failures without failing execution completion', async () => { - const userId = 'user_legacy_callback_failure'; - const sessionId = 'agent_legacy_callback_failure'; - const stub = env.CLOUD_AGENT_SESSION.get( - env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`) - ); - - const result = await runInDurableObject(stub, async instance => { - let attempted = false; - installCallbackQueue(instance, async () => { - attempted = true; - throw new Error('queue unavailable'); - }); - - await registerReadySession(instance, { - sessionId, - userId, - prompt: 'prepared prompt', - mode: 'code', - model: 'test-model', - kiloSessionId: '55555555-5555-4555-8555-555555555555', - kilocodeToken: 'token-callback-failure', - callbackTarget: { url: 'https://example.com/callback' }, - }); - await instance.addExecution({ - executionId: 'exc_legacy_callback_failure', - mode: 'code', - streamingMode: 'websocket', - ingestToken: 'exc_legacy_callback_failure', - messageId: 'msg_018f1e2d3c4bCallFailAbCd', - }); - - await instance.updateExecutionStatus({ - executionId: 'exc_legacy_callback_failure', - status: 'running', - }); - const update = await instance.updateExecutionStatus({ - executionId: 'exc_legacy_callback_failure', - status: 'completed', - }); - - return { attempted, updateOk: update.ok }; - }); - - expect(result).toEqual({ attempted: true, updateOk: true }); - }); }); From 2cf2a1bc2e9007563e587e8dd2e8a3da5d9449b2 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 1 Jun 2026 17:34:16 +0200 Subject: [PATCH 15/18] fix(security-agent): address worker review feedback --- .../security-agent/SecurityAgentContext.tsx | 139 ++++++++++++++---- .../src/security-auto-analysis-policy.ts | 9 +- services/security-sync/src/index.test.ts | 103 +++++++++++++ services/security-sync/src/index.ts | 2 +- 4 files changed, 220 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index b538014e2d..85ed4d895a 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -110,7 +110,24 @@ function getOptionalStringField(source: unknown, key: string): string | undefine return typeof value === 'string' ? value : undefined; } -const ACCEPTED_QUEUE_REFRESH_DELAYS_MS = [1000, 5000, 15000] as const; +const ACCEPTED_QUEUE_POLL_INTERVAL_MS = 3000; +const ACCEPTED_QUEUE_POLL_TIMEOUT_MS = 18000; + +function listFindingsDataHasActiveAnalysis(data: unknown): boolean { + if (typeof data !== 'object' || data === null) return false; + + const runningCount = Reflect.get(data, 'runningCount'); + if (typeof runningCount === 'number' && runningCount > 0) return true; + + const findings = Reflect.get(data, 'findings'); + if (!Array.isArray(findings)) return false; + + return findings.some(finding => { + if (typeof finding !== 'object' || finding === null) return false; + const analysisStatus = Reflect.get(finding, 'analysis_status'); + return analysisStatus === 'pending' || analysisStatus === 'running'; + }); +} type SecurityAgentProviderProps = { organizationId?: string; @@ -125,30 +142,96 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen const [startingAnalysisIds, setStartingAnalysisIds] = useState>(new Set()); const [gitHubError, setGitHubError] = useState(null); const toggleEnabledInFlightRef = useRef(false); - const acceptedQueueRefreshTimersRef = useRef([]); + const acceptedQueuePollRef = useRef<{ intervalId: number; timeoutId: number } | null>(null); + + const clearAcceptedQueuePoll = useCallback(() => { + const activePoll = acceptedQueuePollRef.current; + if (!activePoll) return; + window.clearInterval(activePoll.intervalId); + window.clearTimeout(activePoll.timeoutId); + acceptedQueuePollRef.current = null; + }, []); + + useEffect(() => clearAcceptedQueuePoll, [clearAcceptedQueuePoll]); + + const invalidateAcceptedQueueQueries = useCallback(() => { + if (isOrg && organizationId) { + const ownerInput = { organizationId }; + void Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.listFindings.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getFinding.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getAnalysis.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getStats.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getDashboardStats.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getLastSyncTime.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getRepositories.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getOrphanedRepositories.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.securityAgent.getAutoDismissEligible.queryKey(ownerInput), + }), + ]); + return; + } - useEffect( - () => () => { - for (const timer of acceptedQueueRefreshTimersRef.current) { - window.clearTimeout(timer); + void Promise.all([ + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.listFindings.queryKey() }), + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getFinding.queryKey() }), + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getAnalysis.queryKey() }), + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getStats.queryKey() }), + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getDashboardStats.queryKey() }), + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getLastSyncTime.queryKey() }), + queryClient.invalidateQueries({ queryKey: trpc.securityAgent.getRepositories.queryKey() }), + queryClient.invalidateQueries({ + queryKey: trpc.securityAgent.getOrphanedRepositories.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.securityAgent.getAutoDismissEligible.queryKey(), + }), + ]); + }, [isOrg, organizationId, queryClient, trpc]); + + const cachedListFindingsHasActiveAnalysis = useCallback(() => { + const queryKey = isOrg + ? trpc.organizations.securityAgent.listFindings.queryKey( + organizationId ? { organizationId } : undefined + ) + : trpc.securityAgent.listFindings.queryKey(); + + return queryClient + .getQueriesData({ queryKey }) + .some(([, data]) => listFindingsDataHasActiveAnalysis(data)); + }, [isOrg, organizationId, queryClient, trpc]); + + const pollAcceptedQueueMutation = useCallback(() => { + clearAcceptedQueuePoll(); + invalidateAcceptedQueueQueries(); + + const intervalId = window.setInterval(() => { + invalidateAcceptedQueueQueries(); + if (cachedListFindingsHasActiveAnalysis()) { + clearAcceptedQueuePoll(); } - acceptedQueueRefreshTimersRef.current = []; - }, - [] - ); + }, ACCEPTED_QUEUE_POLL_INTERVAL_MS); - const refreshAcceptedQueueMutation = useCallback(() => { - void queryClient.invalidateQueries(); - for (const delay of ACCEPTED_QUEUE_REFRESH_DELAYS_MS) { - const timer = window.setTimeout(() => { - void queryClient.invalidateQueries(); - acceptedQueueRefreshTimersRef.current = acceptedQueueRefreshTimersRef.current.filter( - pendingTimer => pendingTimer !== timer - ); - }, delay); - acceptedQueueRefreshTimersRef.current.push(timer); - } - }, [queryClient]); + const timeoutId = window.setTimeout(clearAcceptedQueuePoll, ACCEPTED_QUEUE_POLL_TIMEOUT_MS); + acceptedQueuePollRef.current = { intervalId, timeoutId }; + }, [cachedListFindingsHasActiveAnalysis, clearAcceptedQueuePoll, invalidateAcceptedQueueQueries]); // Permission status query const { data: permissionData, isLoading: isLoadingPermission } = useQuery( @@ -188,7 +271,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen onSuccess: () => { setGitHubError(null); toast.success('Sync queued'); - refreshAcceptedQueueMutation(); + pollAcceptedQueueMutation(); }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -209,7 +292,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.organizations.securityAgent.dismissFinding.mutationOptions({ onSuccess: () => { toast.success('Finding dismissed'); - refreshAcceptedQueueMutation(); + pollAcceptedQueueMutation(); }, onError: error => { toast.error('Failed to dismiss finding', { description: error.message }); @@ -256,7 +339,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen onSuccess: async (_data, variables) => { setGitHubError(null); toast.success(manualAnalysisAdmissionCopy.successTitle); - refreshAcceptedQueueMutation(); + pollAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); next.delete(variables.findingId); @@ -307,7 +390,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen onSuccess: () => { setGitHubError(null); toast.success('Sync queued'); - refreshAcceptedQueueMutation(); + pollAcceptedQueueMutation(); }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -328,7 +411,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen trpc.securityAgent.dismissFinding.mutationOptions({ onSuccess: () => { toast.success('Finding dismissed'); - refreshAcceptedQueueMutation(); + pollAcceptedQueueMutation(); }, onError: error => { toast.error('Failed to dismiss finding', { description: error.message }); @@ -375,7 +458,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen onSuccess: async (_data, variables) => { setGitHubError(null); toast.success(manualAnalysisAdmissionCopy.successTitle); - refreshAcceptedQueueMutation(); + pollAcceptedQueueMutation(); setStartingAnalysisIds(prev => { const next = new Set(prev); next.delete(variables.findingId); diff --git a/packages/worker-utils/src/security-auto-analysis-policy.ts b/packages/worker-utils/src/security-auto-analysis-policy.ts index 43f154ade4..4d718e2ae8 100644 --- a/packages/worker-utils/src/security-auto-analysis-policy.ts +++ b/packages/worker-utils/src/security-auto-analysis-policy.ts @@ -19,13 +19,14 @@ export type AutoAnalysisEligibilityDecision = { boundarySkipped: boolean; }; -const LOW_SEVERITY_RANK = 3; +const LOWEST_SEVERITY_RANK = 3; +const ALL_SEVERITIES_MAX_RANK = LOWEST_SEVERITY_RANK; const SEVERITY_RANKS = { critical: 0, high: 1, medium: 2, - low: LOW_SEVERITY_RANK, + low: LOWEST_SEVERITY_RANK, } as const satisfies Record; type KnownSeverity = keyof typeof SEVERITY_RANKS; @@ -34,7 +35,7 @@ const MIN_SEVERITY_MAX_RANKS = { critical: SEVERITY_RANKS.critical, high: SEVERITY_RANKS.high, medium: SEVERITY_RANKS.medium, - all: LOW_SEVERITY_RANK, + all: ALL_SEVERITIES_MAX_RANK, } as const satisfies Record; function isKnownSeverity(severity: string): severity is KnownSeverity { @@ -53,7 +54,7 @@ export function decideAutoAnalysisEligibility( params: AutoAnalysisEligibilityParams ): AutoAnalysisEligibilityDecision { const normalizedSeverityRank = getSeverityRank(params.findingSeverity); - const severityRank = normalizedSeverityRank ?? LOW_SEVERITY_RANK; + const severityRank = normalizedSeverityRank ?? LOWEST_SEVERITY_RANK; const boundarySkipped = !params.autoAnalysisIncludeExisting && params.autoAnalysisEnabledAt !== null && diff --git a/services/security-sync/src/index.test.ts b/services/security-sync/src/index.test.ts index b514c71710..ad53909cd0 100644 --- a/services/security-sync/src/index.test.ts +++ b/services/security-sync/src/index.test.ts @@ -179,6 +179,67 @@ describe('manual sync dispatch', () => { expect(retry).not.toHaveBeenCalled(); }); + it('accepts legacy OAuth user IDs in manual sync commands', async () => { + const queuedBatches: MessageSendRequest[][] = []; + const legacyUserId = 'oauth:google:1234567890'; + const response = await worker.fetch( + new Request('https://security-sync.test/internal/manual-sync', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'worker-secret', + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: { userId: legacyUserId }, + actor: { + id: legacyUserId, + email: 'owner@example.com', + name: 'Owner Example', + }, + }), + }), + { + INTERNAL_API_SECRET: { get: async () => 'worker-secret' }, + SYNC_QUEUE: { + sendBatch: async batch => { + queuedBatches.push(batch); + }, + }, + } as CloudflareEnv + ); + + expect(response.status).toBe(202); + expect(queuedBatches[0]?.[0]?.body).toMatchObject({ + trigger: 'manual', + owner: { userId: legacyUserId }, + ownerKey: `user:${legacyUserId}`, + actor: { + id: legacyUserId, + }, + }); + + vi.mocked(getWorkerDb).mockReturnValue({} as never); + vi.mocked(syncOwner).mockResolvedValue({ synced: 1, errors: 0, staleRepos: 0 } as never); + const ack = vi.fn(); + const retry = vi.fn(); + await worker.queue( + { messages: [{ body: queuedBatches[0]?.[0]?.body, ack, retry }] } as never, + { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + GIT_TOKEN_SERVICE: {}, + } as CloudflareEnv + ); + + expect(syncOwner).toHaveBeenCalledWith( + expect.objectContaining({ + owner: { userId: legacyUserId }, + }) + ); + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); + it('rejects migrated sync traffic when Worker command routing is paused', async () => { const response = await worker.fetch( new Request('https://security-sync.test/internal/manual-sync', { method: 'POST' }), @@ -240,6 +301,48 @@ describe('manual dismissal dispatch', () => { }); }); + it('accepts legacy OAuth user IDs in dismissal commands', async () => { + const queuedBatches: MessageSendRequest[][] = []; + const legacyUserId = 'oauth:google:1234567890'; + const response = await worker.fetch( + new Request('https://security-sync.test/internal/dismiss-finding', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'worker-secret', + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: { userId: legacyUserId }, + actor: { + id: legacyUserId, + email: 'owner@example.com', + name: 'Owner Example', + }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + comment: 'No production usage', + }), + }), + { + INTERNAL_API_SECRET: { get: async () => 'worker-secret' }, + SYNC_QUEUE: { + sendBatch: async batch => { + queuedBatches.push(batch); + }, + }, + } as CloudflareEnv + ); + + expect(response.status).toBe(202); + expect(queuedBatches[0]?.[0]?.body).toMatchObject({ + kind: 'dismiss', + owner: { userId: legacyUserId }, + actor: { id: legacyUserId, email: 'owner@example.com', name: 'Owner Example' }, + }); + }); + it('rejects migrated dismissal traffic when Worker command routing is paused', async () => { const response = await worker.fetch( new Request('https://security-sync.test/internal/dismiss-finding', { method: 'POST' }), diff --git a/services/security-sync/src/index.ts b/services/security-sync/src/index.ts index 0361670ef7..af7232a802 100644 --- a/services/security-sync/src/index.ts +++ b/services/security-sync/src/index.ts @@ -9,7 +9,7 @@ import { processSecurityFindingDismissal } from './dismiss'; const SecuritySyncOwnerSchema = z .object({ organizationId: z.string().uuid().optional(), - userId: z.string().uuid().optional(), + userId: z.string().min(1).optional(), }) .refine(value => Boolean(value.organizationId || value.userId), { message: 'owner.organizationId or owner.userId is required', From 42c95e63c5a029f8a2fb7f99af36baa23ec07efd Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Mon, 1 Jun 2026 19:37:50 +0200 Subject: [PATCH 16/18] fix(security-agent): keep accepted poll through completion --- .../components/security-agent/SecurityAgentContext.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 85ed4d895a..0bbbe51060 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -143,6 +143,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen const [gitHubError, setGitHubError] = useState(null); const toggleEnabledInFlightRef = useRef(false); const acceptedQueuePollRef = useRef<{ intervalId: number; timeoutId: number } | null>(null); + const acceptedQueuePollHasSeenActiveRef = useRef(false); const clearAcceptedQueuePoll = useCallback(() => { const activePoll = acceptedQueuePollRef.current; @@ -220,11 +221,17 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen const pollAcceptedQueueMutation = useCallback(() => { clearAcceptedQueuePoll(); + acceptedQueuePollHasSeenActiveRef.current = false; invalidateAcceptedQueueQueries(); const intervalId = window.setInterval(() => { invalidateAcceptedQueueQueries(); - if (cachedListFindingsHasActiveAnalysis()) { + const hasActiveAnalysis = cachedListFindingsHasActiveAnalysis(); + if (hasActiveAnalysis) { + acceptedQueuePollHasSeenActiveRef.current = true; + return; + } + if (acceptedQueuePollHasSeenActiveRef.current) { clearAcceptedQueuePoll(); } }, ACCEPTED_QUEUE_POLL_INTERVAL_MS); From dd68e11324267ae347e50e5b1ef2c4ce7ec5ae93 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 2 Jun 2026 16:19:31 +0200 Subject: [PATCH 17/18] chore(security): add local dev worker group --- apps/web/.env.development.local.example | 6 ++++++ dev/local/cli.ts | 4 ++-- dev/local/services.ts | 23 +++++++++++++++++++++++ services/security-sync/wrangler.jsonc | 8 +++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/web/.env.development.local.example b/apps/web/.env.development.local.example index 3aa640eff5..d3c858c6ca 100644 --- a/apps/web/.env.development.local.example +++ b/apps/web/.env.development.local.example @@ -16,6 +16,12 @@ AUTO_FIX_URL=http://localhost:8792 # @url cloudflare-auto-triage-infra AUTO_TRIAGE_URL=http://localhost:8791 +# @url cloudflare-security-sync +SECURITY_SYNC_WORKER_URL=http://localhost:8812 + +# @url cloudflare-security-auto-analysis +SECURITY_AUTO_ANALYSIS_WORKER_URL=http://localhost:8797 + # @url cloudflare-app-builder APP_BUILDER_URL=http://localhost:8790 diff --git a/dev/local/cli.ts b/dev/local/cli.ts index 6dad97e1dd..3bf8638c26 100644 --- a/dev/local/cli.ts +++ b/dev/local/cli.ts @@ -592,8 +592,8 @@ Usage: dev:env --check Validate env vars (CI mode) dev:env -y Sync without confirmation -Targets: app, app-builder, agents, mobile, all, or any service/group name -Multiple targets can be specified: dev:start kiloclaw agents`); +Targets: app, app-builder, agents, security-agent, mobile, all, or any service/group name +Multiple targets can be specified: dev:start kiloclaw security-agent`); } // --------------------------------------------------------------------------- diff --git a/dev/local/services.ts b/dev/local/services.ts index 7c859d5a9f..7c64cbb0aa 100644 --- a/dev/local/services.ts +++ b/dev/local/services.ts @@ -40,6 +40,12 @@ const groups: ServiceGroup[] = [ sectionBreakBefore: true, }, { id: 'auto-fix', label: 'Auto Fix', alwaysOn: false, groupDependsOn: ['cloud-agent'] }, + { + id: 'security-agent', + label: 'Security Agent', + alwaysOn: false, + groupDependsOn: ['cloud-agent'], + }, { id: 'deploy', label: 'Deploy', alwaysOn: false }, { id: 'observability', label: 'Observability', alwaysOn: false }, { id: 'mobile', label: 'Mobile', alwaysOn: false, sectionBreakBefore: true }, @@ -135,6 +141,23 @@ const serviceMeta: Record = { dependsOn: ['cloud-agent-next', 'nextjs'], dir: 'services/auto-fix-infra', }, + // security-agent + 'cloudflare-security-sync': { + group: 'security-agent', + dependsOn: ['postgres', 'cloudflare-git-token-service'], + dir: 'services/security-sync', + }, + 'cloudflare-security-auto-analysis': { + group: 'security-agent', + dependsOn: [ + 'postgres', + 'nextjs', + 'cloud-agent-next', + 'cloudflare-git-token-service', + 'cloudflare-session-ingest', + ], + dir: 'services/security-auto-analysis', + }, // deploy 'cloudflare-deploy-builder': { group: 'deploy', diff --git a/services/security-sync/wrangler.jsonc b/services/security-sync/wrangler.jsonc index 9284cb6128..ddddbc6324 100644 --- a/services/security-sync/wrangler.jsonc +++ b/services/security-sync/wrangler.jsonc @@ -9,8 +9,14 @@ "observability": { "enabled": true, }, + "routes": [ + { + "pattern": "security-sync.kilosessions.ai", + "custom_domain": true, + }, + ], "dev": { - "port": 8796, + "port": 8812, "local_protocol": "http", "ip": "0.0.0.0", }, From fd5341525380a6e03a741de6ba1d2d6b463cc04a Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 2 Jun 2026 19:44:17 +0200 Subject: [PATCH 18/18] fix(security-sync): accept nullable dependabot fields --- services/security-sync/src/sync.test.ts | 54 +++++++++++++++++++++++++ services/security-sync/src/sync.ts | 4 +- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/services/security-sync/src/sync.test.ts b/services/security-sync/src/sync.test.ts index dfb0fd61b4..54b9778e60 100644 --- a/services/security-sync/src/sync.test.ts +++ b/services/security-sync/src/sync.test.ts @@ -73,6 +73,41 @@ function stubFetch(response: Response | (() => Response)) { return fetchStub; } +function createDependabotAlert(overrides: Record = {}) { + return { + number: 23, + state: 'open', + dependency: { + package: { ecosystem: 'npm', name: 'lodash' }, + manifest_path: 'package.json', + scope: 'runtime', + }, + security_advisory: { + ghsa_id: 'GHSA-1234-5678-90ab', + cve_id: null, + summary: 'Prototype pollution in lodash', + description: 'A vulnerable lodash version allows prototype pollution.', + severity: 'high', + cvss: { score: 7.5, vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H' }, + cwes: [{ cwe_id: 'CWE-1321', name: 'Improperly Controlled Modification' }], + }, + security_vulnerability: { + vulnerable_version_range: '< 4.17.21', + first_patched_version: { identifier: '4.17.21' }, + }, + created_at: '2026-05-18T10:00:00Z', + updated_at: '2026-05-18T10:00:00Z', + fixed_at: null, + dismissed_at: null, + dismissed_by: null, + dismissed_reason: null, + dismissed_comment: null, + html_url: 'https://github.com/acme/widgets/security/dependabot/23', + url: 'https://api.github.com/repos/acme/widgets/dependabot/alerts/23', + ...overrides, + }; +} + describe('selectRepositoriesForSync', () => { it('allows a manual repository command to target an accessible repo outside configured sync selection', () => { const repositories = selectRepositoriesForSync( @@ -91,6 +126,25 @@ describe('selectRepositoriesForSync', () => { }); describe('Worker GitHub auth-invalid sync', () => { + it('accepts Dependabot alerts with nullable advisory fields', async () => { + const alert = createDependabotAlert({ + security_advisory: { + ...createDependabotAlert().security_advisory, + cvss: { score: 7.5, vector_string: null }, + }, + security_vulnerability: { + vulnerable_version_range: '< 4.17.21', + first_patched_version: null, + }, + }); + stubFetch(new Response(JSON.stringify([alert]), { status: 200 })); + + await expect(fetchAllDependabotAlerts('github-token', 'acme', 'widgets')).resolves.toEqual({ + status: 'success', + alerts: [alert], + }); + }); + it('classifies a direct GitHub 401 as auth_invalid', async () => { stubFetch(new Response('Bad credentials', { status: 401 })); diff --git a/services/security-sync/src/sync.ts b/services/security-sync/src/sync.ts index a7f8fc9db7..873bcab676 100644 --- a/services/security-sync/src/sync.ts +++ b/services/security-sync/src/sync.ts @@ -54,12 +54,12 @@ const dependabotAlertRawSchema = z.object({ summary: z.string(), description: z.string(), severity: securitySeveritySchema, - cvss: z.object({ score: z.number(), vector_string: z.string() }).optional(), + cvss: z.object({ score: z.number(), vector_string: z.string().nullable() }).optional(), cwes: z.array(z.object({ cwe_id: z.string(), name: z.string() })).optional(), }), security_vulnerability: z.object({ vulnerable_version_range: z.string(), - first_patched_version: z.object({ identifier: z.string() }).optional(), + first_patched_version: z.object({ identifier: z.string() }).nullable().optional(), }), created_at: z.string().datetime(), updated_at: z.string().datetime(),