Spaces:
Runtime error
Runtime error
| import { NextRequest, NextResponse } from 'next/server' | |
| import { requireRole } from '@/lib/auth' | |
| import { logger } from '@/lib/logger' | |
| import { runSecurityScan, FIX_SAFETY, type CheckSeverity, type FixSafety, type Check } from '@/lib/security-scan' | |
| type FixScope = 'safe' | 'safe+restart' | 'all' | |
| interface AgentScanFixRequest { | |
| action: 'scan' | 'fix' | 'scan-and-fix' | |
| fixScope?: FixScope | |
| ids?: string[] | |
| force?: boolean | |
| dryRun?: boolean | |
| } | |
| function isFixableInScope(checkId: string, scope: FixScope, force: boolean): boolean { | |
| const safety = FIX_SAFETY[checkId] | |
| if (!safety) return false | |
| if (safety === 'safe') return true | |
| if (safety === 'requires-restart' && (scope === 'safe+restart' || scope === 'all')) return true | |
| if (safety === 'requires-review' && scope === 'all' && force) return true | |
| return false | |
| } | |
| export async function POST(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| let body: AgentScanFixRequest | |
| try { | |
| body = await request.json() | |
| } catch { | |
| return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) | |
| } | |
| const { action, fixScope = 'safe+restart', ids, force = false, dryRun = false } = body | |
| if (!action || !['scan', 'fix', 'scan-and-fix'].includes(action)) { | |
| return NextResponse.json({ error: 'action must be "scan", "fix", or "scan-and-fix"' }, { status: 400 }) | |
| } | |
| try { | |
| // Always scan first | |
| const scanResult = runSecurityScan() | |
| const allChecks = Object.values(scanResult.categories).flatMap(c => c.checks) | |
| const failingChecks = allChecks.filter(c => c.status !== 'pass') | |
| const scanResponse = { | |
| overall: scanResult.overall, | |
| score: scanResult.score, | |
| failingChecks: failingChecks.map(c => ({ | |
| id: c.id, | |
| name: c.name, | |
| status: c.status, | |
| severity: c.severity ?? 'medium' as CheckSeverity, | |
| detail: c.detail, | |
| fix: c.fix, | |
| fixSafety: FIX_SAFETY[c.id] ?? c.fixSafety ?? ('manual-only' as FixSafety), | |
| autoFixable: isFixableInScope(c.id, fixScope, force), | |
| })), | |
| passingCount: allChecks.length - failingChecks.length, | |
| totalCount: allChecks.length, | |
| categories: Object.fromEntries( | |
| Object.entries(scanResult.categories).map(([key, cat]) => [ | |
| key, | |
| { score: cat.score, failCount: cat.checks.filter(c => c.status !== 'pass').length }, | |
| ]) | |
| ), | |
| } | |
| if (action === 'scan') { | |
| const criticalCount = failingChecks.filter(c => c.severity === 'critical').length | |
| const highCount = failingChecks.filter(c => c.severity === 'high').length | |
| return NextResponse.json({ | |
| scan: scanResponse, | |
| summary: `Security score: ${scanResult.score}/100 (${scanResult.overall}). ${failingChecks.length} issue(s): ${criticalCount} critical, ${highCount} high.`, | |
| }) | |
| } | |
| // Fix or scan-and-fix | |
| const targetIds = ids ? new Set(ids) : null | |
| const checksToFix = failingChecks.filter(c => { | |
| if (targetIds && !targetIds.has(c.id)) return false | |
| return isFixableInScope(c.id, fixScope, force) | |
| }) | |
| const skipped: Array<{ id: string; reason: string }> = [] | |
| const requiresManual: Array<{ id: string; name: string; instructions: string }> = [] | |
| // Identify skipped and manual checks | |
| for (const c of failingChecks) { | |
| if (targetIds && !targetIds.has(c.id)) continue | |
| const safety = FIX_SAFETY[c.id] ?? c.fixSafety | |
| if (!safety || safety === 'manual-only') { | |
| requiresManual.push({ id: c.id, name: c.name, instructions: c.fix }) | |
| } else if (!isFixableInScope(c.id, fixScope, force)) { | |
| const reason = safety === 'requires-review' && !force | |
| ? 'requires-review: set force=true to apply' | |
| : safety === 'requires-restart' && fixScope === 'safe' | |
| ? 'requires-restart: use fixScope "safe+restart" or "all"' | |
| : `fix safety level "${safety}" not in scope "${fixScope}"` | |
| skipped.push({ id: c.id, reason }) | |
| } | |
| } | |
| if (dryRun) { | |
| return NextResponse.json({ | |
| scan: scanResponse, | |
| fixes: { | |
| applied: checksToFix.map(c => ({ | |
| id: c.id, | |
| name: c.name, | |
| fixed: false, | |
| detail: `[dry-run] Would apply fix: ${c.fix}`, | |
| fixSafety: FIX_SAFETY[c.id], | |
| })), | |
| skipped, | |
| requiresRestart: checksToFix.some(c => FIX_SAFETY[c.id] === 'requires-restart'), | |
| requiresManual, | |
| }, | |
| summary: `Dry run: ${checksToFix.length} fix(es) would be applied, ${skipped.length} skipped, ${requiresManual.length} require manual action.`, | |
| }) | |
| } | |
| // Actually apply fixes by calling the fix endpoint logic | |
| const fixIds = checksToFix.map(c => c.id) | |
| let fixResponse: any = { fixed: 0, failed: 0, results: [] } | |
| if (fixIds.length > 0) { | |
| // Import and call the fix route handler internally | |
| const fixUrl = new URL('/api/security-scan/fix', request.url) | |
| const fixReq = new NextRequest(fixUrl, { | |
| method: 'POST', | |
| headers: request.headers, | |
| body: JSON.stringify({ ids: fixIds }), | |
| }) | |
| // Dynamically import to avoid circular deps | |
| const { POST: fixHandler } = await import('../fix/route') | |
| const fixRes = await fixHandler(fixReq) | |
| fixResponse = await fixRes.json() | |
| } | |
| const applied = (fixResponse.results || []).map((r: any) => ({ | |
| ...r, | |
| fixSafety: FIX_SAFETY[r.id], | |
| })) | |
| const requiresRestart = applied.some((r: any) => r.fixed && FIX_SAFETY[r.id] === 'requires-restart') | |
| logger.info({ action, fixScope, force, dryRun, applied: applied.length, skipped: skipped.length }, 'Agent security scan+fix') | |
| // Re-scan after fixes to get updated score | |
| const postFixScan = fixIds.length > 0 ? runSecurityScan() : scanResult | |
| return NextResponse.json({ | |
| scan: { | |
| ...scanResponse, | |
| score: postFixScan.score, | |
| overall: postFixScan.overall, | |
| }, | |
| fixes: { | |
| applied, | |
| skipped, | |
| requiresRestart, | |
| requiresManual, | |
| }, | |
| summary: buildSummary(applied, skipped, requiresManual, requiresRestart, postFixScan.score, postFixScan.overall), | |
| }) | |
| } catch (error) { | |
| logger.error({ err: error }, 'Agent security scan error') | |
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | |
| } | |
| } | |
| function buildSummary( | |
| applied: any[], | |
| skipped: any[], | |
| requiresManual: any[], | |
| requiresRestart: boolean, | |
| score: number, | |
| overall: string, | |
| ): string { | |
| const parts: string[] = [] | |
| const fixedCount = applied.filter((r: any) => r.fixed).length | |
| if (fixedCount > 0) parts.push(`${fixedCount} issue(s) fixed`) | |
| if (skipped.length > 0) parts.push(`${skipped.length} skipped`) | |
| if (requiresManual.length > 0) parts.push(`${requiresManual.length} require manual action`) | |
| if (requiresRestart) parts.push('server restart recommended') | |
| parts.push(`score: ${score}/100 (${overall})`) | |
| return parts.join('. ') + '.' | |
| } | |