diff --git a/package.json b/package.json index b7e286b..ca6a378 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,10 @@ "better-sqlite3" ], "overrides": { - "postcss": "8.5.15", - "ip-address": "10.2.0", - "vite": "8.0.13", - "esbuild": "0.28.0", + "postcss": "^8.5.15", + "ip-address": "^10.2.0", + "vite": "^8.0.13", + "esbuild": ">=0.28.0", "qs": ">=6.15.2" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43e9091..68b9aa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - postcss: 8.5.15 - ip-address: 10.2.0 - vite: 8.0.13 - esbuild: 0.28.0 + postcss: ^8.5.15 + ip-address: ^10.2.0 + vite: ^8.0.13 + esbuild: '>=0.28.0' qs: '>=6.15.2' importers: @@ -1595,7 +1595,7 @@ packages: resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: msw: ^2.4.9 - vite: 8.0.13 + vite: ^8.0.13 peerDependenciesMeta: msw: optional: true @@ -2637,7 +2637,7 @@ packages: peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.18 - esbuild: 0.28.0 + esbuild: '>=0.28.0' jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -2689,7 +2689,7 @@ packages: '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' - vite: 8.0.13 + vite: ^8.0.13 peerDependenciesMeta: '@edge-runtime/vm': optional: true diff --git a/src/app/api/projects/[id]/escalate/route.ts b/src/app/api/projects/[id]/escalate/route.ts index cabd7ec..df998e7 100644 --- a/src/app/api/projects/[id]/escalate/route.ts +++ b/src/app/api/projects/[id]/escalate/route.ts @@ -9,6 +9,7 @@ import { join } from 'path' import { execFile, exec } from 'child_process' import { promisify } from 'util' import { logger } from '@/lib/logger' +import { decideDevServerGuard, isHexopsSelf, isTracked } from '@/lib/process-manager' const execFileAsync = promisify(execFile) const execAsync = promisify(exec) @@ -58,6 +59,17 @@ export async function POST( return NextResponse.json({ error: 'Project not found' }, { status: 404 }) } + // #109: an escalation rewrites overrides and reinstalls. Refuse if the target + // is hexops itself — churning node_modules would kill the server serving this + // request mid-install. (Orchestrated stop/restart for other projects: TODO.) + const guard = decideDevServerGuard({ isSelf: isHexopsSelf(project), isTracked: isTracked(id) }) + if (guard.action === 'block-self') { + return NextResponse.json( + { error: guard.reason, devServerGuard: { action: guard.action, reason: guard.reason } }, + { status: 409 }, + ) + } + const body: EscalateRequestBody = await request.json() const { package: pkg, action, reason, overrideVersion, targetVersion, expiresAt, emergency } = body diff --git a/src/app/api/projects/[id]/override-remove/route.ts b/src/app/api/projects/[id]/override-remove/route.ts index 1af1cfe..09015f1 100644 --- a/src/app/api/projects/[id]/override-remove/route.ts +++ b/src/app/api/projects/[id]/override-remove/route.ts @@ -7,6 +7,7 @@ import { promisify } from 'util'; import { detectPackageManager } from '@/lib/patch-scanner'; import { invalidateProjectCache } from '@/lib/patch-storage'; import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; +import { runWithDevServerGuard, decideDevServerGuard, isHexopsSelf, isTracked } from '@/lib/process-manager'; const execAsync = promisify(exec); @@ -34,6 +35,15 @@ export async function POST( return NextResponse.json({ error: 'Project not found' }, { status: 404 }); } + // #109: refuse to churn node_modules out from under hexops's own dev server. + const guard = decideDevServerGuard({ isSelf: isHexopsSelf(project), isTracked: isTracked(id) }); + if (guard.action === 'block-self') { + return NextResponse.json( + { success: false, error: guard.reason, devServerGuard: { action: guard.action, reason: guard.reason } }, + { status: 409 }, + ); + } + const pkgJsonPath = join(project.path, 'package.json'); if (!existsSync(pkgJsonPath)) { return NextResponse.json({ error: 'package.json not found' }, { status: 400 }); @@ -92,18 +102,30 @@ export async function POST( ? 'yarn install' : 'npm install --legacy-peer-deps'; - let installOutput = ''; - try { - const result = await execAsync(installCmd, { cwd: project.path, timeout: 120000 }); - installOutput = (result.stdout || '') + (result.stderr || ''); - } catch (err) { - const e = err as { stdout?: string; stderr?: string }; - installOutput = (e.stdout || '') + (e.stderr || ''); - } + // #109: stop a running dev server, reinstall, then restart it. + const guardOutcome = await runWithDevServerGuard(project, async () => { + try { + const result = await execAsync(installCmd, { cwd: project.path, timeout: 120000 }); + return (result.stdout || '') + (result.stderr || ''); + } catch (err) { + const e = err as { stdout?: string; stderr?: string }; + return (e.stdout || '') + (e.stderr || ''); + } + }, { clearBuildDir: true }); invalidateProjectCache(id); - return NextResponse.json({ success: true, removed: pkgName, output: installOutput }); + return NextResponse.json({ + success: true, + removed: pkgName, + output: guardOutcome.result ?? '', + devServerGuard: { + action: guardOutcome.decision, + stopped: guardOutcome.stopped, + restarted: guardOutcome.restarted, + ...(guardOutcome.restartError ? { restartError: guardOutcome.restartError } : {}), + }, + }); } catch (error) { console.error('Error removing override:', error); return NextResponse.json({ error: 'Failed to remove override' }, { status: 500 }); diff --git a/src/app/api/projects/[id]/plugins/[pluginId]/route.ts b/src/app/api/projects/[id]/plugins/[pluginId]/route.ts new file mode 100644 index 0000000..d20bab6 --- /dev/null +++ b/src/app/api/projects/[id]/plugins/[pluginId]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProjects, getCategories, saveConfig } from '@/lib/config'; +import { getPlugin } from '@/lib/security/plugins'; +import { setProjectPluginConfig } from '@/lib/security/plugins/config'; +import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; + +interface ToggleRequest { + enabled: boolean; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; pluginId: string }> } +) { + if (!AUTO_APPLY_ENABLED) { + return NextResponse.json( + { success: false, error: 'Auto-apply is disabled in HexOps. Re-enable AUTO_APPLY_ENABLED to apply updates.' }, + { status: 409 }, + ); + } + + const { id, pluginId } = await params; + + const plugin = getPlugin(pluginId); + if (!plugin) { + return NextResponse.json({ error: `Unknown plugin: ${pluginId}` }, { status: 404 }); + } + + const projects = getProjects(); + const projectIndex = projects.findIndex(p => p.id === id); + if (projectIndex === -1) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const body = (await request.json().catch(() => null)) as ToggleRequest | null; + if (!body || typeof body.enabled !== 'boolean') { + return NextResponse.json({ error: 'Body must be { enabled: boolean }' }, { status: 400 }); + } + + // Mutate via the existing setProjectPluginConfig helper (T3). Writer captures + // the index update + saves the whole config (mirrors the holds-route pattern). + await setProjectPluginConfig( + projects[projectIndex], + pluginId, + { enabled: body.enabled }, + (next) => { + projects[projectIndex] = next; + saveConfig({ projects, categories: getCategories() }); + }, + ); + + return NextResponse.json({ + success: true, + project: id, + pluginId, + enabled: body.enabled, + }); +} diff --git a/src/app/api/projects/[id]/security-scan/route.ts b/src/app/api/projects/[id]/security-scan/route.ts index 20f6cd4..893f9c5 100644 --- a/src/app/api/projects/[id]/security-scan/route.ts +++ b/src/app/api/projects/[id]/security-scan/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { getProject } from '@/lib/config'; -import { scanProject } from '@/lib/security/runner'; +import { scanProject, scanProjectWithSources } from '@/lib/security/runner'; +import { SOURCES } from '@/lib/security/sources'; import { logger } from '@/lib/logger'; export async function POST( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; @@ -12,7 +13,17 @@ export async function POST( if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); try { - const result = await scanProject(project); + const url = new URL(req.url); + const filter = url.searchParams.get('sources'); + const subset = filter + ? SOURCES.filter(s => filter.split(',').map(x => x.trim()).includes(s.id)) + : SOURCES; + if (subset.length === 0) { + return NextResponse.json({ error: `No sources matched: ${filter}` }, { status: 400 }); + } + const result = subset.length === SOURCES.length + ? await scanProject(project) + : await scanProjectWithSources(project, subset); return NextResponse.json(result); } catch (err) { const message = err instanceof Error ? err.message : 'Security scan failed'; diff --git a/src/app/api/projects/[id]/security/exceptions/[exceptionId]/revoke/route.ts b/src/app/api/projects/[id]/security/exceptions/[exceptionId]/revoke/route.ts new file mode 100644 index 0000000..41af9e9 --- /dev/null +++ b/src/app/api/projects/[id]/security/exceptions/[exceptionId]/revoke/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProject } from '@/lib/config'; +import { revokeException } from '@/lib/security/exceptions'; +import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; +import { logger } from '@/lib/logger'; + +interface RevokeBody { + revokeReason?: string; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string; exceptionId: string }> }, +) { + if (!AUTO_APPLY_ENABLED) { + return NextResponse.json( + { success: false, error: 'Auto-apply is disabled in HexOps.' }, + { status: 409 }, + ); + } + const { id, exceptionId } = await params; + if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + + const body = (await req.json().catch(() => ({}))) as RevokeBody; + const exc = revokeException({ + projectId: id, + exceptionId, + revokeReason: body.revokeReason, + }); + if (!exc) return NextResponse.json({ error: 'Exception not found' }, { status: 404 }); + + logger.info( + 'security', + 'exception_revoked', + `Exception ${exc.id} revoked`, + { + projectId: id, + meta: { + exceptionId: exc.id, + parentPackage: exc.parentPackage, + revokeReason: body.revokeReason, + }, + }, + ); + + return NextResponse.json({ success: true, exception: exc }); +} diff --git a/src/app/api/projects/[id]/security/exceptions/[exceptionId]/route.ts b/src/app/api/projects/[id]/security/exceptions/[exceptionId]/route.ts new file mode 100644 index 0000000..74c0d6c --- /dev/null +++ b/src/app/api/projects/[id]/security/exceptions/[exceptionId]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProject } from '@/lib/config'; +import { updateException, type ExceptionClassification } from '@/lib/security/exceptions'; +import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; +import { logger } from '@/lib/logger'; + +const VALID_CLASSIFICATIONS: ExceptionClassification[] = [ + 'risk-accepted', + 'false-positive', + 'compensating-control', + 'deferred', + 'unfixable', + 'deviation', +]; + +interface PatchBody { + classification?: ExceptionClassification; + reason?: string; + notes?: string | null; // null = clear + expiresAt?: string | null; // null = clear +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string; exceptionId: string }> }, +) { + if (!AUTO_APPLY_ENABLED) { + return NextResponse.json( + { success: false, error: 'Auto-apply is disabled in HexOps.' }, + { status: 409 }, + ); + } + const { id, exceptionId } = await params; + if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + + const body = (await req.json().catch(() => null)) as PatchBody | null; + if (!body) return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + if (body.classification && !VALID_CLASSIFICATIONS.includes(body.classification)) { + return NextResponse.json({ error: 'Invalid classification' }, { status: 400 }); + } + if (body.reason !== undefined && typeof body.reason !== 'string') { + return NextResponse.json({ error: 'reason must be a string' }, { status: 400 }); + } + + // Translate nulls to undefined for storage layer + const updates: Parameters[0]['updates'] = {}; + if (body.classification !== undefined) updates.classification = body.classification; + if (body.reason !== undefined) updates.reason = body.reason; + if ('notes' in body) updates.notes = body.notes ?? undefined; + if ('expiresAt' in body) updates.expiresAt = body.expiresAt ?? undefined; + + const exc = updateException({ projectId: id, exceptionId, updates }); + if (!exc) return NextResponse.json({ error: 'Exception not found' }, { status: 404 }); + + logger.info( + 'security', + 'exception_modified', + `Exception ${exc.id} modified`, + { + projectId: id, + meta: { exceptionId: exc.id, parentPackage: exc.parentPackage, changes: Object.keys(updates) }, + }, + ); + + return NextResponse.json({ success: true, exception: exc }); +} diff --git a/src/app/api/projects/[id]/security/exceptions/route.ts b/src/app/api/projects/[id]/security/exceptions/route.ts new file mode 100644 index 0000000..08c6094 --- /dev/null +++ b/src/app/api/projects/[id]/security/exceptions/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProject } from '@/lib/config'; +import { listExceptions, createException, type ExceptionClassification } from '@/lib/security/exceptions'; +import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; +import { logger } from '@/lib/logger'; + +const VALID_CLASSIFICATIONS: ExceptionClassification[] = [ + 'risk-accepted', + 'false-positive', + 'compensating-control', + 'deferred', + 'unfixable', + 'deviation', +]; + +interface CreateBody { + parentPackage: string; + classification: ExceptionClassification; + reason: string; + notes?: string; + expiresAt?: string; +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return NextResponse.json({ exceptions: listExceptions(id) }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!AUTO_APPLY_ENABLED) { + return NextResponse.json( + { success: false, error: 'Auto-apply is disabled in HexOps.' }, + { status: 409 }, + ); + } + const { id } = await params; + if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + + const body = (await req.json().catch(() => null)) as CreateBody | null; + if ( + !body || + typeof body.parentPackage !== 'string' || + typeof body.reason !== 'string' || + !VALID_CLASSIFICATIONS.includes(body.classification) + ) { + return NextResponse.json( + { error: 'Body must include parentPackage, reason, and a valid classification.' }, + { status: 400 }, + ); + } + + const exc = createException({ + projectId: id, + parentPackage: body.parentPackage, + classification: body.classification, + reason: body.reason, + notes: body.notes, + expiresAt: body.expiresAt, + }); + + logger.info( + 'security', + 'exception_filed', + `Exception ${exc.id} filed for ${exc.parentPackage}`, + { + projectId: id, + meta: { + exceptionId: exc.id, + parentPackage: exc.parentPackage, + classification: exc.classification, + expiresAt: exc.expiresAt, + }, + }, + ); + + return NextResponse.json({ success: true, exception: exc }); +} diff --git a/src/app/api/projects/[id]/security/remediation/[attemptId]/complete/route.ts b/src/app/api/projects/[id]/security/remediation/[attemptId]/complete/route.ts new file mode 100644 index 0000000..9e506ef --- /dev/null +++ b/src/app/api/projects/[id]/security/remediation/[attemptId]/complete/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProject } from '@/lib/config'; +import { logger } from '@/lib/logger'; + +interface CompleteBody { + outcome: { + status: 'resolved' | 'partial' | 'unresolved' | 'error'; + previousFindingCount: number; + currentFindingCount: number; + findingsCovered?: string[]; // dedupKeys that were targeted + findingsResolved?: string[]; // dedupKeys that disappeared post-verify + findingsRemaining?: string[]; // dedupKeys still present post-verify + error?: string; // when status === 'error' + }; + source?: string; // for thread-back to the original attempt's source (grype, cve-lite) +} + +// No AUTO_APPLY_ENABLED gate — this is an audit-log entry, not a mutation +// that touches code/state (same principle as exception endpoints). +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string; attemptId: string }> }, +) { + const { id, attemptId } = await params; + if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + + const body = (await req.json().catch(() => null)) as CompleteBody | null; + if (!body || !body.outcome || typeof body.outcome.status !== 'string') { + return NextResponse.json({ error: 'Body must include outcome.status' }, { status: 400 }); + } + + const validStatus = ['resolved', 'partial', 'unresolved', 'error']; + if (!validStatus.includes(body.outcome.status)) { + return NextResponse.json( + { error: `outcome.status must be one of ${validStatus.join(', ')}` }, + { status: 400 }, + ); + } + + logger.info('security', 'remediation_completed', `Apply attempt ${attemptId} completed: ${body.outcome.status}`, { + projectId: id, + meta: { + attemptId, + source: body.source, + outcome: body.outcome, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/projects/[id]/update/route.guard.test.ts b/src/app/api/projects/[id]/update/route.guard.test.ts new file mode 100644 index 0000000..3c540b4 --- /dev/null +++ b/src/app/api/projects/[id]/update/route.guard.test.ts @@ -0,0 +1,63 @@ +// src/app/api/projects/[id]/update/route.guard.test.ts +// #109 — applying a patch to hexops itself, while its dev server serves the +// request, must be refused (you cannot stop->apply->restart that process). +import { describe, it, expect, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/auto-apply-flag', () => ({ AUTO_APPLY_ENABLED: true as boolean })); + +// getProject returns hexops's OWN checkout (path === cwd) -> isHexopsSelf is true +vi.mock('@/lib/config', () => ({ + getProject: vi.fn().mockReturnValue({ + id: 'hexops', + path: process.cwd(), + name: 'HexOps', + port: 3000, + category: 'app', + scripts: { dev: 'next dev', build: 'next build' }, + }), + getProjects: vi.fn().mockReturnValue([]), +})); +vi.mock('@/lib/patch-storage', () => ({ invalidateProjectCache: vi.fn() })); +vi.mock('@/lib/patch-scanner', () => ({ detectPackageManager: vi.fn(), scanProject: vi.fn() })); +vi.mock('@/lib/lockfile-resolver', () => ({ + // would short-circuit to 500 if the guard let us through — proving the guard ran + resolveLockfile: vi.fn().mockResolvedValue({ success: false, packageManager: 'pnpm', mode: 'clean-slate' }), +})); +vi.mock('@/lib/settings', () => ({ + getGlobalSettings: vi.fn().mockReturnValue({}), + getProjectSettings: vi.fn().mockReturnValue({}), +})); +vi.mock('@/lib/extended-status', () => ({ invalidatePackageStatusCache: vi.fn() })); +vi.mock('@/app/api/projects/[id]/package-health/route', () => ({ clearInMemoryCache: vi.fn() })); +vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +vi.mock('@/lib/updaters/common', () => ({ verifyAuditClear: vi.fn(), execAsync: vi.fn() })); +vi.mock('@/lib/updaters/npm', () => ({ + checkNodeModulesHealth: vi.fn(), + cleanNodeModules: vi.fn(), + buildNpmUpdateCmd: vi.fn(), +})); +vi.mock('@/lib/updaters/pnpm', () => ({ + checkPnpmLockfileHealth: vi.fn(), + repairPnpmLockfile: vi.fn(), + buildPnpmUpdateCmd: vi.fn(), +})); +vi.mock('@/lib/updaters/yarn', () => ({ buildYarnUpdateCmd: vi.fn() })); +vi.mock('@/lib/updaters/override', () => ({ + applyOverrides: vi.fn(), + removeOverrideConflicts: vi.fn(), + cleanStaleOverrides: vi.fn(), +})); +vi.mock('@/lib/updaters/install', () => ({ installPackages: vi.fn() })); + +import { POST } from './route'; + +describe('POST /update — #109 self-patch guard', () => { + it('refuses with 409 when the target project is hexops itself', async () => { + const req = new NextRequest('http://localhost/api/projects/hexops/update', { method: 'POST' }); + const res = await POST(req, { params: Promise.resolve({ id: 'hexops' }) }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.devServerGuard?.action).toBe('block-self'); + }); +}); diff --git a/src/app/api/projects/[id]/update/route.ts b/src/app/api/projects/[id]/update/route.ts index 9ca5015..e7483d8 100644 --- a/src/app/api/projects/[id]/update/route.ts +++ b/src/app/api/projects/[id]/update/route.ts @@ -19,7 +19,10 @@ import { buildYarnUpdateCmd } from '@/lib/updaters/yarn'; import { applyOverrides, removeOverrideConflicts, cleanStaleOverrides } from '@/lib/updaters/override'; import { installPackages } from '@/lib/updaters/install'; import { execAsync } from '@/lib/updaters/common'; +import { SECURITY_PLUGINS } from '@/lib/security/plugins'; +import { isPluginEnabledForProject } from '@/lib/security/plugins/config'; import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; +import { runWithDevServerGuard } from '@/lib/process-manager'; const NPM_INSTALL_TIMEOUT = 120000; @@ -32,7 +35,12 @@ interface UpdateRequestBody { fixByParent?: { name: string; version: string }; }>; lockfileResolution?: LockfileResolutionMode; - auditContext?: { source?: string; advisories?: string[]; severity?: string }; + auditContext?: { + source?: string; + advisories?: string[]; + severity?: string; + attemptId?: string; // change-control tracking id + }; } export async function POST( @@ -51,9 +59,16 @@ export async function POST( const body: UpdateRequestBody = await request.json().catch(() => ({})); const packages = body.packages || []; + // Change-control: extract attemptId up-front so it's available to all log sites + const attemptId = body.auditContext?.attemptId; + const project = getProject(id); if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + // #109: if this project's dev server is live, stop it before churning + // node_modules and restart it after; refuse outright if the target is + // hexops itself (we'd kill the server serving this request mid-apply). + const runApply = async (): Promise<{ status?: number; body: Record }> => { const cwd = project.path; const projectSettings = getProjectSettings(id); @@ -67,11 +82,29 @@ export async function POST( const resolution = await resolveLockfile(cwd, resolutionMode); if (!resolution.success) { - return NextResponse.json({ - success: false, - error: `Lockfile resolution (${resolutionMode}) failed`, - resolution, - }, { status: 500 }); + return { status: 500, body: { success: false, error: `Lockfile resolution (${resolutionMode}) failed`, resolution } }; + } + + // Change-control: log intent before the install runs so failure cases still have a record + if (attemptId && body.auditContext?.source) { + logger.info('security', 'remediation_initiated', `Apply attempt ${attemptId} initiated for ${id}`, { + projectId: id, + meta: { + attemptId, + source: body.auditContext.source, + parameters: { + packages: body.packages?.map(p => ({ + name: p.name, + fromVersion: p.fromVersion, + toVersion: p.toVersion, + fixViaOverride: p.fixViaOverride ?? false, + })) ?? [], + advisoryIds: body.auditContext.advisories ?? [], + severity: body.auditContext.severity, + lockfileResolution: body.lockfileResolution, + }, + }, + }); } const packageManager = resolution.packageManager; @@ -87,6 +120,11 @@ export async function POST( const results: Array<{ package: string; success: boolean; output: string; error?: string }> = []; + // Install-gate state — set when an installGate plugin rewrites the binary; + // carried to the audit-trail log at the bottom of the closure. + let installBinOverride: string | undefined; + let activeGatePlugin: string | undefined; + // Pre-flight health checks if (packageManager === 'npm' && packages.length > 1) { const health = await checkNodeModulesHealth(cwd); @@ -197,7 +235,25 @@ export async function POST( if (directPkgs.length > 0) { removeOverrideConflicts(join(cwd, 'package.json'), directPkgs, packageManager, id); - const installResults = await installPackages(directPkgs, packageManager, isWorkspaceProject, cwd, id); + + // Install-gate: Safe Chain (and any future installGate plugins) can rewrite + // the install binary to interpose between us and the package manager. + for (const plugin of SECURITY_PLUGINS) { + if (plugin.kind !== 'installGate') continue; + if (!isPluginEnabledForProject(project, plugin.id)) continue; + const wrapped = await plugin.wrapInstall({ + project, + command: [packageManager], + env: process.env, + }); + if (wrapped.command[0] && wrapped.command[0] !== packageManager) { + installBinOverride = wrapped.command[0]; + activeGatePlugin = plugin.id; + break; // first enabled plugin wins; chaining is a future story + } + } + + const installResults = await installPackages(directPkgs, packageManager, isWorkspaceProject, cwd, id, installBinOverride); results.push(...installResults); } } else { @@ -325,18 +381,36 @@ export async function POST( } catch { /* non-fatal */ } } - // Origin-tagged remediation audit trail (e.g. applied from the CVE Lite dashboard). + // Origin-tagged remediation audit trail (e.g. applied from the CVE Lite dashboard or grype panel). if (anySucceeded && body.auditContext?.source) { const successfulNames = results.filter(r => r.success).map(r => r.package).filter(n => n !== '*'); - logger.info('patches', 'security_remediation_applied', + logger.info('security', 'remediation_install_complete', `Applied security fix in ${id}: ${successfulNames.join(', ') || '(reconcile)'}`, { projectId: id, meta: { + attemptId, // may be undefined for non-change-control calls source: body.auditContext.source, advisories: body.auditContext.advisories ?? [], severity: body.auditContext.severity, packages: successfulNames, + installGate: activeGatePlugin + ? { plugin: activeGatePlugin, binOverride: installBinOverride } + : undefined, + }, + }); + } else if (!anySucceeded && body.auditContext?.source) { + // Change-control: log failure so the audit trail captures intent even when install fails + logger.info('security', 'remediation_install_failed', + `Apply attempt ${attemptId ?? '(no-id)'} failed for ${id}`, + { + projectId: id, + meta: { + attemptId, + source: body.auditContext.source, + advisories: body.auditContext.advisories ?? [], + severity: body.auditContext.severity, + attemptedPackages: body.packages?.map(p => p.name) ?? [], }, }); } @@ -344,13 +418,33 @@ export async function POST( const allSucceeded = results.every(r => r.success); const output = results.map(r => r.output).join('\n\n'); - return NextResponse.json({ + return { body: { success: allSucceeded, packageManager, results, output: output || 'Packages updated successfully.', ...(auditSummary !== undefined && { auditSummary }), - }); + } }; + }; + + const outcome = await runWithDevServerGuard(project, runApply, { clearBuildDir: true }); + if (outcome.blocked) { + return NextResponse.json( + { success: false, error: outcome.reason, devServerGuard: { action: outcome.decision, reason: outcome.reason } }, + { status: 409 }, + ); + } + const devServerGuard = { + action: outcome.decision, + stopped: outcome.stopped, + restarted: outcome.restarted, + ...(outcome.restartError ? { restartError: outcome.restartError } : {}), + }; + const applied = outcome.result!; + return NextResponse.json( + { ...applied.body, devServerGuard }, + applied.status ? { status: applied.status } : undefined, + ); } catch (error) { console.error('Error updating packages:', error); const execError = error as { stdout?: string; stderr?: string; message?: string }; diff --git a/src/app/api/security/cve-lite/[id]/fix/route.ts b/src/app/api/security/cve-lite/[id]/fix/route.ts index 28fc455..addf066 100644 --- a/src/app/api/security/cve-lite/[id]/fix/route.ts +++ b/src/app/api/security/cve-lite/[id]/fix/route.ts @@ -7,6 +7,7 @@ import { CveLiteSource, runCveLite } from '@/lib/security/sources/cve-lite'; import { scanProject as runSecurityScan } from '@/lib/security/runner'; import { logger } from '@/lib/logger'; import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; +import { runWithDevServerGuard } from '@/lib/process-manager'; const execAsync = promisify(exec); const BIN = join(process.cwd(), 'node_modules', '.bin', 'cve-lite'); @@ -31,33 +32,94 @@ export async function POST( if (body.mode !== 'all') { return NextResponse.json({ error: "mode must be 'all'" }, { status: 400 }); } - const auditContext = body.auditContext as { source?: string; advisories?: string[]; severity?: string } | undefined; - let summary = ''; - let ok = true; - try { - const { stdout } = await execAsync( - `${JSON.stringify(BIN)} --fix ${JSON.stringify(project.path)}`, - { cwd: project.path, timeout: 300_000, maxBuffer: 64 * 1024 * 1024 }, + const auditContext = body.auditContext as { source?: string; advisories?: string[]; severity?: string; attemptId?: string } | undefined; + + // Log remediation_initiated at the start if attemptId and source are present + const attemptId = auditContext?.attemptId; + if (attemptId && auditContext?.source) { + logger.info('security', 'remediation_initiated', `Apply attempt ${attemptId} initiated for ${id}`, { + projectId: id, + meta: { + attemptId, + source: auditContext.source, + parameters: { + mode: body.mode, + advisoryIds: auditContext.advisories ?? [], + severity: auditContext.severity, + }, + }, + }); + } + // #109: cve-lite --fix runs an install; guard the dev server (refuse self-patch). + const guardOutcome = await runWithDevServerGuard(project, async () => { + try { + const { stdout } = await execAsync( + `${JSON.stringify(BIN)} --fix ${JSON.stringify(project.path)}`, + { cwd: project.path, timeout: 300_000, maxBuffer: 64 * 1024 * 1024 }, + ); + return { ok: true, summary: stdout.slice(-2000) }; + } catch (err) { + const summary = err instanceof Error ? err.message.slice(-2000) : 'fix failed'; + logger.error('api', 'cve_lite_fix_failed', summary, { projectId: id }); + return { ok: false, summary }; + } + }, { clearBuildDir: true }); + + if (guardOutcome.blocked) { + // Log failure if we have an attemptId + if (attemptId && auditContext?.source) { + logger.info('security', 'remediation_install_failed', `Apply attempt ${attemptId} blocked by dev-server guard: ${guardOutcome.reason}`, { + projectId: id, + meta: { + attemptId, + source: auditContext.source, + reason: guardOutcome.reason, + decision: guardOutcome.decision, + }, + }); + } + return NextResponse.json( + { ok: false, error: guardOutcome.reason, devServerGuard: { action: guardOutcome.decision, reason: guardOutcome.reason } }, + { status: 409 }, ); - summary = stdout.slice(-2000); - } catch (err) { - ok = false; - summary = err instanceof Error ? err.message.slice(-2000) : 'fix failed'; - logger.error('api', 'cve_lite_fix_failed', summary, { projectId: id }); } - // Always rescan to reflect real post-fix state (cve-lite + the 3-source security scan). - await runCveLite(project, { force: true }).catch(() => {}); - await runSecurityScan(project).catch(() => {}); + const { ok, summary } = guardOutcome.result!; + const devServerGuard = { + action: guardOutcome.decision, + stopped: guardOutcome.stopped, + restarted: guardOutcome.restarted, + ...(guardOutcome.restartError ? { restartError: guardOutcome.restartError } : {}), + }; + + if (!ok) { + // Log failure if we have an attemptId + if (attemptId && auditContext?.source) { + logger.info('security', 'remediation_install_failed', `Apply attempt ${attemptId} failed: ${summary.slice(-200)}`, { + projectId: id, + meta: { + attemptId, + source: auditContext.source, + error: summary.slice(-500), + }, + }); + } + } else { + // Always rescan to reflect real post-fix state (cve-lite + the 3-source security scan). + await runCveLite(project, { force: true }).catch(() => {}); + await runSecurityScan(project).catch(() => {}); + } + logger.info('api', 'cve_lite_fix', `cve-lite --fix on ${id} (ok=${ok})`, { projectId: id }); if (ok && auditContext?.source) { - logger.info('patches', 'security_remediation_applied', `Applied cve-lite --fix in ${id}`, { + logger.info('security', 'remediation_install_complete', `Applied cve-lite --fix in ${id}`, { projectId: id, meta: { + attemptId, source: auditContext.source, advisories: auditContext.advisories ?? [], severity: auditContext.severity, }, }); } - return NextResponse.json({ ok, summary, rescanned: true }); + return NextResponse.json({ ok, summary, rescanned: true, devServerGuard }); } diff --git a/src/app/api/security/findings/route.test.ts b/src/app/api/security/findings/route.test.ts index a53cd7e..7e08b09 100644 --- a/src/app/api/security/findings/route.test.ts +++ b/src/app/api/security/findings/route.test.ts @@ -10,6 +10,9 @@ vi.mock('@/lib/config', () => ({ vi.mock('@/lib/security/persistence', () => ({ readSecurityCache: vi.fn().mockReturnValue(null), })); +vi.mock('@/lib/security/finding-states', () => ({ + getFindingStates: vi.fn().mockReturnValue({}), +})); import { GET } from './route'; @@ -35,4 +38,12 @@ describe('GET /api/security/findings', () => { const data = await res.json(); expect(data.projects).toHaveLength(0); }); + + it('includes findingStates per project', async () => { + const req = new NextRequest('http://localhost/api/security/findings'); + const res = await GET(req); + const data = await res.json(); + expect(data.projects[0]).toHaveProperty('findingStates'); + expect(typeof data.projects[0].findingStates).toBe('object'); + }); }); diff --git a/src/app/api/security/findings/route.ts b/src/app/api/security/findings/route.ts index d71aec1..864a924 100644 --- a/src/app/api/security/findings/route.ts +++ b/src/app/api/security/findings/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getProjects } from '@/lib/config'; import { readSecurityCache } from '@/lib/security/persistence'; +import { getFindingStates } from '@/lib/security/finding-states'; export async function GET(req: NextRequest) { const projectFilter = req.nextUrl.searchParams.get('project'); @@ -15,6 +16,7 @@ export async function GET(req: NextRequest) { timestamp: cached?.timestamp ?? null, sources: cached?.sources ?? {}, findings: cached?.findings ?? [], + findingStates: getFindingStates(p.id), }; }); return NextResponse.json({ projects: perProject }); diff --git a/src/app/api/security/plugins/[id]/status/route.ts b/src/app/api/security/plugins/[id]/status/route.ts new file mode 100644 index 0000000..bf6357f --- /dev/null +++ b/src/app/api/security/plugins/[id]/status/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPlugin } from '@/lib/security/plugins'; +import { getProject } from '@/lib/config'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + req: NextRequest, + ctx: { params: Promise<{ id: string }> } +) { + const { id } = await ctx.params; + const url = new URL(req.url); + const projectId = url.searchParams.get('projectId'); + + if (!projectId) { + return NextResponse.json( + { error: 'projectId query param required' }, + { status: 400 } + ); + } + + const plugin = getPlugin(id); + if (!plugin) { + return NextResponse.json( + { error: `unknown plugin: ${id}` }, + { status: 404 } + ); + } + + const project = getProject(projectId); + if (!project) { + return NextResponse.json( + { error: `unknown project: ${projectId}` }, + { status: 404 } + ); + } + + let host; + try { + host = await plugin.isAvailable(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + host = { available: false, reason: `isAvailable threw: ${msg}` }; + } + + let card; + try { + card = await plugin.renderCard(project); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + card = { status: 'error', headline: 'plugin error', error: msg }; + } + + return NextResponse.json({ pluginId: plugin.id, projectId, host, card }); +} diff --git a/src/app/api/security/plugins/route.ts b/src/app/api/security/plugins/route.ts new file mode 100644 index 0000000..7cbbb33 --- /dev/null +++ b/src/app/api/security/plugins/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { SECURITY_PLUGINS } from '@/lib/security/plugins'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + // Host-availability only (no project context here — project-specific status + // goes through /api/security/plugins/[id]/status?projectId=…). + const entries = await Promise.all( + SECURITY_PLUGINS.map(async (p) => { + try { + const host = await p.isAvailable(); + return { + id: p.id, + name: p.name, + description: p.description, + kind: p.kind, + detailRoute: p.detailRoute, + host, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + id: p.id, + name: p.name, + description: p.description, + kind: p.kind, + detailRoute: p.detailRoute, + host: { available: false, reason: `isAvailable threw: ${msg}` }, + }; + } + }), + ); + return NextResponse.json({ plugins: entries }); +} diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx index ac605d0..13d227d 100644 --- a/src/app/security/page.tsx +++ b/src/app/security/page.tsx @@ -1,365 +1,243 @@ 'use client'; -import { useEffect, useState, useCallback, Suspense } from 'react'; -import type { ReactNode } from 'react'; +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; -import type { CveLiteOutput, ScanOptions } from '@/lib/security/sources/cve-lite'; -import type { DbStatus } from '@/lib/security/cve-lite-db'; -import type { FindingRow } from '@/lib/security/cve-lite-view'; -import type { SourceResult } from '@/lib/security/types'; -import { selectFixPlan, findingRows, deriveReachable } from '@/lib/security/cve-lite-view'; -import { FleetProjectRail, type FleetProject } from '@/components/security/fleet-project-rail'; -import { FixPlan } from '@/components/security/cve-lite/fix-plan'; -import { CveLiteFindings } from '@/components/security/cve-lite/cve-lite-findings'; -import { CveLiteToolbar } from '@/components/security/cve-lite/cve-lite-toolbar'; -import { CveLiteScanControls } from '@/components/security/cve-lite/cve-lite-scan-controls'; -import { CveLiteManage } from '@/components/security/cve-lite/cve-lite-manage'; -import { SourceStrip } from '@/components/security/source-strip'; -import { ConfirmDialog } from '@/components/security/cve-lite/confirm-dialog'; -import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; -import type { UpdatedPackage } from '@/lib/patch-commit-message'; -import { generatePatchCommitMessage } from '@/lib/patch-commit-message'; -import { remediationFromRow, remediationFromRows } from '@/lib/security/remediation-commit'; -import { PendingCommitBanner } from '@/components/security/cve-lite/pending-commit-banner'; - -interface ConfirmState { title: string; body: ReactNode; run: () => Promise } -interface GitStatus { branch: string; ahead: number; behind: number; dirty: boolean } -interface PendingCommit { - packages: UpdatedPackage[]; - advisories: string[]; - severity?: string; - message: string; - isEditing: boolean; +import { SecurityHeader, type ScanSourceId } from '@/components/security/security-header'; +import { SecuritySummaryBar } from '@/components/security/security-summary-bar'; +import { ProjectSecurityAccordion } from '@/components/security/project-security-accordion'; +import type { SourceResult, Finding } from '@/lib/security/types'; +import type { FindingState } from '@/lib/security/finding-states'; +import { mapWithConcurrency } from '@/lib/concurrency'; +import { deriveParentPackage } from '@/lib/security/parent-package'; + +interface ProjectsResponse { projects: Array<{ id: string; name: string }> } + +interface FleetScanState { + meters: Partial>; + inflight: boolean; } +type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'; +type SeverityCounts = Record; + function SecurityHubInner() { const searchParams = useSearchParams(); const initialProject = searchParams.get('project') ?? ''; - const [railProjects, setRailProjects] = useState([]); - const [allSources, setAllSources] = useState>>({}); - const [selected, setSelected] = useState(initialProject); - const [report, setReport] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [importedOnly, setImportedOnly] = useState(false); - const [scannedAt, setScannedAt] = useState(null); - const [options, setOptions] = useState({}); - const [dbStatus, setDbStatus] = useState(null); - const [confirm, setConfirm] = useState(null); - const [busy, setBusy] = useState(false); - const [pendingCommit, setPendingCommit] = useState(null); - const [gitStatus, setGitStatus] = useState(null); - const [isCommitting, setIsCommitting] = useState(false); - const [isPushing, setIsPushing] = useState(false); - const [committed, setCommitted] = useState(false); + const [projects, setProjects] = useState>([]); + const [perProjectSources, setPerProjectSources] = useState>>({}); + const [perProjectSeverity, setPerProjectSeverity] = useState>({}); + const [perProjectFindings, setPerProjectFindings] = useState>({}); + const [perProjectFindingStates, setPerProjectFindingStates] = useState>>({}); + const [allFindingsSeverity, setAllFindingsSeverity] = useState<{ + critical: number; high: number; medium: number; low: number; info: number; + }>({ critical: 0, high: 0, medium: 0, low: 0, info: 0 }); - const refreshRail = useCallback(() => { - fetch('/api/security/cve-lite/summary') - .then(r => r.json()) - .then(d => setRailProjects( - (d.projects ?? []).sort((a: FleetProject, b: FleetProject) => a.name.localeCompare(b.name)) - )) - .catch(() => {}); + const [fleetScan, setFleetScan] = useState({ meters: {}, inflight: false }); + const [osv, setOsv] = useState<{ lastSync?: string }>({}); + const [syncingOsv, setSyncingOsv] = useState(false); + + const refresh = useCallback(async () => { + try { + const [projectsRes, findingsRes] = await Promise.all([ + fetch('/api/projects').then(r => r.json() as Promise), + fetch('/api/security/findings').then(r => r.json()), + ]); + // Project list — sorted alphabetically for stable ordering + const ps = ((projectsRes as ProjectsResponse).projects ?? []).sort( + (a: { id: string; name: string }, b: { id: string; name: string }) => a.name.localeCompare(b.name) + ); + setProjects(ps); + + // Fetch active exceptions for all projects in parallel + const exceptionsEntries = await Promise.all( + ps.map(async (p) => { + try { + const r = await fetch(`/api/projects/${encodeURIComponent(p.id)}/security/exceptions`); + if (!r.ok) return [p.id, new Set()] as const; + const j = await r.json(); + const active = ((j.exceptions ?? []) as Array<{ revokedAt?: string; expiresAt?: string; parentPackage: string }>) + .filter((e) => !e.revokedAt && (!e.expiresAt || new Date(e.expiresAt) > new Date())); + return [p.id, new Set(active.map((e) => e.parentPackage))] as const; + } catch { + return [p.id, new Set()] as const; + } + }), + ); + const exceptionsByProject: Record> = {}; + for (const [pid, set] of exceptionsEntries) exceptionsByProject[pid] = set; + + // Per-project source map for the accordion headers, and per-project severity counts + const sourcesMap: Record> = {}; + const perProj: Record = {}; + const findingsMap: Record = {}; + const findingStatesMap: Record> = {}; + const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; + for (const p of (findingsRes as { projects?: Array<{ projectId: string; sources?: Record; findings?: Array<{ severity?: string }>; findingStates?: Record }> }).projects ?? []) { + sourcesMap[p.projectId] = p.sources ?? {}; + findingStatesMap[p.projectId] = p.findingStates ?? {}; + const excludedParents = exceptionsByProject[p.projectId] ?? new Set(); + const allFindings = (p.findings ?? []) as Finding[]; + // Filter out findings whose parent package has an active exception + const filteredFindings = allFindings.filter((f) => { + const parent = deriveParentPackage(f); + return !parent || !excludedParents.has(parent); + }); + findingsMap[p.projectId] = filteredFindings; + const c: SeverityCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; + for (const f of filteredFindings) { + const s = (f.severity ?? '').toLowerCase(); + if (s === 'critical') { c.critical++; sev.critical++; } + else if (s === 'high') { c.high++; sev.high++; } + else if (s === 'medium' || s === 'moderate') { c.medium++; sev.medium++; } + else if (s === 'low') { c.low++; sev.low++; } + else { c.info++; sev.info++; } + } + perProj[p.projectId] = c; + } + setPerProjectSources(sourcesMap); + setPerProjectSeverity(perProj); + setPerProjectFindings(findingsMap); + setPerProjectFindingStates(findingStatesMap); + setAllFindingsSeverity(sev); + } catch { + // best-effort — leave previous state intact on network error + } }, []); + // Fetch OSV DB status on mount useEffect(() => { - fetch('/api/security/cve-lite/summary') - .then(r => r.json()) - .then((d) => { - const ps: FleetProject[] = (d.projects ?? []).sort( - (a: FleetProject, b: FleetProject) => a.name.localeCompare(b.name) - ); - setRailProjects(ps); - if (!selected && ps.length) setSelected(ps[0].id); - }) - .catch(() => {}); - - fetch('/api/security/findings') - .then(r => r.json()) - .then((d) => { - const map: Record> = {}; - for (const p of d.projects ?? []) map[p.projectId] = p.sources ?? {}; - setAllSources(map); - }) - .catch(() => {}); - fetch('/api/security/cve-lite/db-status') .then(r => r.json()) - .then(setDbStatus) + .then((d: { builtAt?: string; lastSync?: string; mtime?: string; timestamp?: string }) => + setOsv({ lastSync: d?.builtAt ?? d?.lastSync ?? d?.mtime ?? d?.timestamp ?? undefined }) + ) .catch(() => {}); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { refresh(); }, [refresh]); - const load = useCallback(async (force = false) => { - if (!selected) return; - setLoading(true); setError(null); + const onSyncOsv = useCallback(async () => { + setSyncingOsv(true); try { - const qs = new URLSearchParams(); - if (force) qs.set('force', 'true'); - if (options.minSeverity) qs.set('minSeverity', options.minSeverity); - if (options.prodOnly) qs.set('prodOnly', 'true'); - if (options.onlyUsed) qs.set('onlyUsed', 'true'); - if (options.all) qs.set('all', 'true'); - const res = await fetch(`/api/security/cve-lite/${selected}?${qs.toString()}`); - if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e.error ?? `HTTP ${res.status}`); + const res = await fetch('/api/security/cve-lite/sync', { method: 'POST' }); + if (res.ok) { + const j = await res.json().catch(() => null); + setOsv({ lastSync: j?.builtAt ?? j?.lastSync ?? new Date().toISOString() }); } - setReport(await res.json()); - setScannedAt(new Date().toISOString()); - refreshRail(); - } catch (e) { - setError(e instanceof Error ? e.message : 'scan failed'); - setReport(null); } finally { - setLoading(false); - } - }, [selected, options, refreshRail]); - - useEffect(() => { load(false); }, [load]); - - const fetchGitStatus = useCallback(async (projectId: string): Promise => { - try { - const res = await fetch(`/api/projects/${projectId}/git`); - if (!res.ok) return null; - const d = await res.json(); - return { branch: d.branch ?? '', ahead: d.aheadCount ?? 0, behind: d.behindCount ?? 0, dirty: !!d.isDirty }; - } catch { - return null; + setSyncingOsv(false); } }, []); - const beginPendingCommit = useCallback( - async (rc: { packages: UpdatedPackage[]; advisories: string[]; severity?: string }) => { - const status = await fetchGitStatus(selected); - setGitStatus(status); - // An apply can produce nothing to commit: "Fix all direct" (cve-lite --fix) on a - // transitive-only advisory is a no-op, and some fixes only touch gitignored node_modules. - // Don't open a commit banner that would dead-end on "No changes to commit". - if (!status?.dirty) { - setPendingCommit(null); - setCommitted(false); - setError('Fix ran, but there were no file changes to commit. A transitive advisory cannot be fixed by "Fix all direct" — use the per-finding Apply, which adds a package override.'); - return; - } - setError(null); - const generated = generatePatchCommitMessage(rc.packages).full; - const message = generated || `chore(deps): apply cve-lite fixes in ${selected}`; - setPendingCommit({ ...rc, message, isEditing: false }); - setCommitted(false); - }, - [selected, fetchGitStatus], - ); - - const handleCommit = useCallback(async () => { - if (!pendingCommit) return; - setIsCommitting(true); setError(null); - try { - const res = await fetch(`/api/projects/${selected}/git-commit`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: pendingCommit.message, source: 'cve-lite', advisories: pendingCommit.advisories }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data.success === false) throw new Error(data.error ?? `commit failed (HTTP ${res.status})`); - setCommitted(true); - setPendingCommit((pc) => (pc ? { ...pc, isEditing: false } : pc)); - setGitStatus(await fetchGitStatus(selected)); - } catch (e) { - setError(e instanceof Error ? e.message : 'commit failed'); - } finally { - setIsCommitting(false); + const onScan = useCallback(async (sources: ScanSourceId[] | 'all') => { + if (projects.length === 0) { + // No projects loaded yet — fall back to cache-only refresh + await refresh(); + return; } - }, [pendingCommit, selected, fetchGitStatus]); + const sourceIds: ScanSourceId[] = sources === 'all' + ? ['pnpm-audit', 'grype', 'cve-lite'] + : sources; + const query = sources === 'all' ? '' : `?sources=${sourceIds.join(',')}`; + + // Initialize meters + const initial: FleetScanState = { + inflight: true, + meters: Object.fromEntries(sourceIds.map(s => [s, { done: 0, total: projects.length, active: 0 }])), + }; + setFleetScan(initial); - const handlePush = useCallback(async () => { - if (!pendingCommit) return; - setIsPushing(true); setError(null); try { - const res = await fetch(`/api/projects/${selected}/git-push`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: 'cve-lite', advisories: pendingCommit.advisories }), + await mapWithConcurrency(projects, 3, async (p) => { + // Mark this project as active for all requested sources + setFleetScan(prev => ({ + ...prev, + meters: Object.fromEntries( + Object.entries(prev.meters).map(([sid, m]) => [sid, { ...m!, active: (m!.active ?? 0) + 1 }]), + ), + })); + let result: { sources?: Record } = {}; + try { + const res = await fetch(`/api/projects/${p.id}/security-scan${query}`, { method: 'POST' }); + if (res.ok) result = await res.json(); + } catch {/* best-effort */} + // Tick done for each source returned in the response (or each requested source if response missing) + const respondedSources = result.sources ? Object.keys(result.sources) : sourceIds; + setFleetScan(prev => ({ + ...prev, + meters: Object.fromEntries( + Object.entries(prev.meters).map(([sid, m]) => { + const ticked = respondedSources.includes(sid); + return [sid, { ...m!, active: Math.max(0, (m!.active ?? 0) - 1), done: m!.done + (ticked ? 1 : 0) }]; + }), + ), + })); }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data.success === false) throw new Error(data.error ?? `push failed (HTTP ${res.status})`); - setPendingCommit(null); setCommitted(false); - setGitStatus(await fetchGitStatus(selected)); - } catch (e) { - setError(e instanceof Error ? e.message : 'push failed'); - } finally { - setIsPushing(false); - } - }, [pendingCommit, selected, fetchGitStatus]); - - // Clear any pending commit when the user switches projects. - useEffect(() => { - setPendingCommit(null); - setCommitted(false); - setGitStatus(null); - }, [selected]); - - const runConfirmed = async () => { - if (!confirm) return; - setBusy(true); setError(null); - try { - await confirm.run(); - } catch (e) { - setError(e instanceof Error ? e.message : 'action failed'); + await refresh(); // pull updated findings into the page state } finally { - setBusy(false); setConfirm(null); + setFleetScan({ meters: {}, inflight: false }); } - }; + }, [projects, refresh]); - const fixAll = () => setConfirm({ - title: 'Fix all direct dependencies?', - body: ( - <> - Runs cve-lite --fix in {selected}, rewriting package.json + lockfile - (may reinstall). A rescan runs after. You may need to restart that project's dev server. - - ), - run: async () => { - // Audit context covers all direct fixable findings — `cve-lite --fix` ignores the - // importedOnly/reachability filter, so derive from the unfiltered report, not `rows`. - const rc = remediationFromRows(report ? findingRows(report) : []); - const res = await fetch(`/api/security/cve-lite/${selected}/fix`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'all', auditContext: { source: 'cve-lite', advisories: rc.advisories, severity: rc.severity } }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data.ok === false) throw new Error(data.error ?? data.summary ?? `fix failed (HTTP ${res.status})`); - await load(true); - await beginPendingCommit(rc); - }, - }); + const findingsCount = useMemo( + () => allFindingsSeverity.critical + allFindingsSeverity.high + allFindingsSeverity.medium + allFindingsSeverity.low, + [allFindingsSeverity], + ); - const applyOne = (row: FindingRow) => setConfirm({ - title: `Apply fix for ${row.package}?`, - body: ( - <> - Updates {row.package} {row.version ?? '?'} → {row.validatedFixVersion} via the - patch pipeline{row.relationship === 'transitive' ? ' (flat override)' : ''}, then rescans. - - ), - run: async () => { - const rc = remediationFromRow(row); - const res = await fetch(`/api/projects/${selected}/update`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - packages: [{ - name: row.package, - fromVersion: row.version, - toVersion: row.validatedFixVersion, - fixViaOverride: row.relationship === 'transitive', - }], - auditContext: { source: 'cve-lite', advisories: rc.advisories, severity: rc.severity }, - }), - }); - if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e.error ?? `update failed (HTTP ${res.status})`); + const lastScan = useMemo( + () => { + // Pick the latest startedAt across all sources, fall back to undefined. + let latest: string | undefined; + for (const proj of Object.values(perProjectSources)) { + for (const src of Object.values(proj)) { + if (!latest || (src.startedAt && src.startedAt > latest)) latest = src.startedAt; + } } - await load(true); - await beginPendingCommit(rc); + return latest; }, - }); - - const installSkill = () => setConfirm({ - title: 'Generate cve-lite skill files?', - body: ( - <> - Runs cve-lite install-skill in {selected}, writing AI-assistant skill - files into the project. - - ), - run: async () => { - const res = await fetch(`/api/security/cve-lite/${selected}/install-skill`, { method: 'POST' }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data.ok === false) throw new Error(data.error ?? `install-skill failed (HTTP ${res.status})`); - }, - }); + [perProjectSources], + ); - const visibleReport: CveLiteOutput | null = report && importedOnly - ? { ...report, findings: (report.findings ?? []).filter(f => deriveReachable(f.usage) === true) } - : report; - const groups = visibleReport ? selectFixPlan(visibleReport) : []; - const rows = visibleReport ? findingRows(visibleReport) : []; - const selectedSources = allSources[selected] ?? {}; + // Count of unique sources across the fleet (e.g. pnpm-audit, grype, cve-lite) + const sourcesCount = useMemo( + () => new Set(Object.values(perProjectSources).flatMap(o => Object.keys(o))).size, + [perProjectSources], + ); return ( -
- + -
- load(true)} - /> - -
- - | - load(true)} - /> -
- {pendingCommit && AUTO_APPLY_ENABLED && ( - setPendingCommit((pc) => (pc ? { ...pc, message: msg } : pc))} - onToggleEdit={() => setPendingCommit((pc) => (pc ? { ...pc, isEditing: !pc.isEditing } : pc))} - onCommit={handleCommit} - onPush={handlePush} - onDismiss={() => { setPendingCommit(null); setCommitted(false); }} - /> - )} - {loading &&
Scanning…
} - {error &&
{error}
} - {!loading && !error && visibleReport && ( - <> -
-

Fix plan

- -
-
-

Findings

- -
- - )} - {confirm && ( - setConfirm(null)} - /> + +
+ {projects.length === 0 ? ( +
{fleetScan.inflight ? 'Scanning…' : 'No projects.'}
+ ) : ( + projects.map(p => ( + + )) )}
-
+ ); } diff --git a/src/app/security/safe-chain/page.test.ts b/src/app/security/safe-chain/page.test.ts new file mode 100644 index 0000000..ec77c63 --- /dev/null +++ b/src/app/security/safe-chain/page.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import SafeChainPage from './page'; + +describe('SafeChainPage', () => { + it('exports a default function component', () => { + expect(typeof SafeChainPage).toBe('function'); + }); +}); diff --git a/src/app/security/safe-chain/page.tsx b/src/app/security/safe-chain/page.tsx new file mode 100644 index 0000000..ce4e0e4 --- /dev/null +++ b/src/app/security/safe-chain/page.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ProjectConfig } from '@/lib/types'; +import type { PluginCardData, PluginHostStatus } from '@/lib/security/plugins/types'; + +interface PerProjectEntry { + project: ProjectConfig; + status: PluginCardData | null; + host: PluginHostStatus | null; +} + +export default function SafeChainPage() { + const [host, setHost] = useState(null); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [togglingId, setTogglingId] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const projectsRes = await fetch('/api/projects'); + if (!projectsRes.ok) throw new Error(`projects fetch failed: ${projectsRes.status}`); + const projectsJson = await projectsRes.json(); + const projects: ProjectConfig[] = projectsJson.projects ?? []; + + const pluginsRes = await fetch('/api/security/plugins'); + if (pluginsRes.ok) { + const pluginsJson = await pluginsRes.json(); + const sc = (pluginsJson.plugins ?? []).find( + (p: { id: string; host: PluginHostStatus }) => p.id === 'safe-chain', + ); + setHost(sc?.host ?? null); + } + + const perProject = await Promise.all( + projects.map(async (p) => { + try { + const r = await fetch( + `/api/security/plugins/safe-chain/status?projectId=${encodeURIComponent(p.id)}`, + ); + if (!r.ok) return { project: p, status: null, host: null }; + const j = await r.json(); + return { + project: p, + status: j.card as PluginCardData, + host: j.host as PluginHostStatus, + }; + } catch { + return { project: p, status: null, host: null }; + } + }), + ); + setEntries(perProject); + } catch (e) { + setError(e instanceof Error ? e.message : 'refresh failed'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const toggle = useCallback( + async (projectId: string, next: boolean) => { + setTogglingId(projectId); + setError(null); + try { + const res = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/plugins/safe-chain`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: next }), + }, + ); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + throw new Error(data.error ?? `toggle failed (HTTP ${res.status})`); + } + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : 'toggle failed'); + } finally { + setTogglingId(null); + } + }, + [refresh], + ); + + return ( +
+ {/* Header strip */} +
+
+
+

Aikido Safe Chain

+

Pre-install malware/typosquat interceptor

+
+
+ + ← Security + + +
+
+
+ + {/* Host status section */} +
+

Host status

+ {!host ? ( +
Checking…
+ ) : host.available ? ( +
+ ✓ safe-chain available{' '} + {host.version && v{host.version}} +
+ ) : ( + <> +
✗ not installed — {host.reason}
+ {host.installHint && ( +
{host.installHint}
+ )} + + )} +
+ + {/* Error strip */} + {error &&
{error}
} + + {/* Per-project enable section (scrollable) */} +
+
+

Per-project enable

+ {entries.length === 0 ? ( + loading ? ( +
Loading…
+ ) : ( +
No projects.
+ ) + ) : ( + + + + + + + + + + {entries.map(({ project, status, host: entryHost }) => { + const enabled = status?.status === 'enabled'; + const hostMissing = entryHost != null && !entryHost.available; + return ( + + + + + + ); + })} + +
ProjectStatus
{project.name} + {status ? `${status.status} · ${status.headline}` : '…'} + + +
+ )} +
+
+
+ ); +} diff --git a/src/components/log-viewer.tsx b/src/components/log-viewer.tsx index 4aad394..54ed3f2 100644 --- a/src/components/log-viewer.tsx +++ b/src/components/log-viewer.tsx @@ -49,6 +49,7 @@ const CATEGORY_COLORS: Record = { api: 'text-orange-400', system: 'text-zinc-400', scheduler: 'text-cyan-400', + security: 'text-red-400', }; export function LogViewer({ projectId, showProjectFilter = true, className }: LogViewerProps) { diff --git a/src/components/security/exception-dialog.tsx b/src/components/security/exception-dialog.tsx new file mode 100644 index 0000000..4a21bd2 --- /dev/null +++ b/src/components/security/exception-dialog.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; + +const CLASSIFICATIONS: Array<{ value: string; label: string; description: string }> = [ + { value: 'risk-accepted', label: 'Risk accepted', description: 'We acknowledge it and choose not to fix' }, + { value: 'false-positive', label: 'False positive', description: 'Scanner is wrong' }, + { value: 'compensating-control', label: 'Compensating control', description: 'Mitigated by other means' }, + { value: 'deferred', label: 'Deferred', description: 'Will fix later, not now' }, + { value: 'unfixable', label: 'Unfixable', description: 'No upstream fix exists' }, + { value: 'deviation', label: 'Deviation', description: 'Documented departure from standard practice' }, +]; + +export interface ExceptionDialogProps { + parentPackage: string; + findingsCount?: number; // only shown in file mode (omit in edit) + existing?: { + classification: string; + reason: string; + notes?: string; + expiresAt?: string; + }; // when present, dialog is in edit mode + onSubmit(payload: { classification: string; reason: string; expiresAt?: string; notes?: string }): Promise; + onCancel(): void; + busy?: boolean; +} + +export function ExceptionDialog({ + parentPackage, + findingsCount, + existing, + onSubmit, + onCancel, + busy, +}: ExceptionDialogProps) { + const [classification, setClassification] = useState(existing?.classification ?? 'risk-accepted'); + const [reason, setReason] = useState(existing?.reason ?? ''); + const [notes, setNotes] = useState(existing?.notes ?? ''); + const [expiresInDays, setExpiresInDays] = useState(() => { + if (!existing?.expiresAt) return existing ? '0' : '90'; + const ms = new Date(existing.expiresAt).getTime() - Date.now(); + return Math.max(0, Math.round(ms / 86400000)).toString(); + }); + + const handleSubmit = async () => { + if (!reason.trim()) return; + let expiresAt: string | undefined; + const days = parseInt(expiresInDays, 10); + if (!isNaN(days) && days > 0) { + const d = new Date(); + d.setDate(d.getDate() + days); + expiresAt = d.toISOString(); + } + await onSubmit({ + classification, + reason: reason.trim(), + expiresAt, + notes: notes.trim() || undefined, + }); + }; + + const title = existing + ? `Edit exception for ${parentPackage}` + : `File exception for ${parentPackage}`; + + return ( +
+
+
+

{title}

+ {!existing && findingsCount !== undefined && ( +

+ {findingsCount} finding{findingsCount !== 1 ? 's' : ''} covered +

+ )} +
+
+ +