nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
Raw
History Blame Contribute Delete
7.1 kB
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('. ') + '.'
}