nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
import { NextRequest, NextResponse } from 'next/server'
import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth'
import { readLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
import { getSecurityPosture } from '@/lib/security-events'
import { getMcpCallStats } from '@/lib/mcp-audit'
import { runSecurityScan } from '@/lib/security-scan'
type Timeframe = 'hour' | 'day' | 'week' | 'month'
const TIMEFRAME_SECONDS: Record<Timeframe, number> = {
hour: 3600,
day: 86400,
week: 7 * 86400,
month: 30 * 86400,
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = readLimiter(request)
if (rateCheck) return rateCheck
try {
const { searchParams } = new URL(request.url)
const timeframe = (searchParams.get('timeframe') || 'day') as Timeframe
const eventTypeFilter = searchParams.get('event_type')
const severityFilter = searchParams.get('severity')
const agentFilter = searchParams.get('agent')
const workspaceId = auth.user.workspace_id ?? 1
const seconds = TIMEFRAME_SECONDS[timeframe] || TIMEFRAME_SECONDS.day
const since = Math.floor(Date.now() / 1000) - seconds
const db = getDatabase()
// Infrastructure scan (same as onboarding security scan)
const scan = runSecurityScan()
// Event-based posture (incidents, trust scores)
const eventPosture = getSecurityPosture(workspaceId)
// Blend: weighted average — 70% infrastructure config, 30% event history
const blendedScore = Math.round(scan.score * 0.7 + eventPosture.score * 0.3)
const level = blendedScore >= 90 ? 'hardened'
: blendedScore >= 70 ? 'secure'
: blendedScore >= 40 ? 'needs-attention'
: 'at-risk'
// Auth events
const authEventsQuery = db.prepare(`
SELECT event_type, severity, agent_name, detail, ip_address, created_at
FROM security_events
WHERE workspace_id = ? AND created_at > ?
AND event_type IN ('auth.failure', 'auth.token_rotation', 'auth.access_denied')
ORDER BY created_at DESC
LIMIT 50
`).all(workspaceId, since) as any[]
const loginFailures = authEventsQuery.filter(e => e.event_type === 'auth.failure').length
const tokenRotations = authEventsQuery.filter(e => e.event_type === 'auth.token_rotation').length
const accessDenials = authEventsQuery.filter(e => e.event_type === 'auth.access_denied').length
// Agent trust
const agents = db.prepare(`
SELECT agent_name, trust_score, last_anomaly_at,
auth_failures + injection_attempts + rate_limit_hits + secret_exposures as anomalies
FROM agent_trust_scores
WHERE workspace_id = ?
ORDER BY trust_score ASC
`).all(workspaceId) as any[]
const flaggedCount = agents.filter((a: any) => a.trust_score < 0.8).length
// Secret exposures
const secretEvents = db.prepare(`
SELECT event_type, severity, agent_name, detail, created_at
FROM security_events
WHERE workspace_id = ? AND created_at > ? AND event_type = 'secret.exposure'
ORDER BY created_at DESC
LIMIT 20
`).all(workspaceId, since) as any[]
// MCP audit summary
const mcpTotals = db.prepare(`
SELECT
COUNT(*) as total_calls,
COUNT(DISTINCT tool_name) as unique_tools,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failures
FROM mcp_call_log
WHERE workspace_id = ? AND created_at > ?
`).get(workspaceId, since) as any
const topTools = db.prepare(`
SELECT tool_name, COUNT(*) as count
FROM mcp_call_log
WHERE workspace_id = ? AND created_at > ?
GROUP BY tool_name
ORDER BY count DESC
LIMIT 10
`).all(workspaceId, since) as any[]
const totalCalls = mcpTotals?.total_calls ?? 0
const failureRate = totalCalls > 0
? Math.round(((mcpTotals?.failures ?? 0) / totalCalls) * 10000) / 100
: 0
// Rate limit hits
const rateLimitEvents = db.prepare(`
SELECT COUNT(*) as total
FROM security_events
WHERE workspace_id = ? AND created_at > ? AND event_type = 'rate_limit.hit'
`).get(workspaceId, since) as any
const rateLimitByIp = db.prepare(`
SELECT ip_address, COUNT(*) as count
FROM security_events
WHERE workspace_id = ? AND created_at > ? AND event_type = 'rate_limit.hit' AND ip_address IS NOT NULL
GROUP BY ip_address
ORDER BY count DESC
LIMIT 10
`).all(workspaceId, since) as any[]
// Injection attempts
const injectionEvents = db.prepare(`
SELECT event_type, severity, agent_name, detail, ip_address, created_at
FROM security_events
WHERE workspace_id = ? AND created_at > ? AND event_type = 'injection.attempt'
ORDER BY created_at DESC
LIMIT 20
`).all(workspaceId, since) as any[]
// Timeline (bucketed by hour)
const bucketSize = timeframe === 'hour' ? 300 : 3600
let timelineQuery = `
SELECT
(created_at / ${bucketSize}) * ${bucketSize} as bucket,
COUNT(*) as event_count,
MAX(CASE WHEN severity = 'critical' THEN 3 WHEN severity = 'warning' THEN 2 ELSE 1 END) as max_severity
FROM security_events
WHERE workspace_id = ? AND created_at > ?
`
const timelineParams: any[] = [workspaceId, since]
if (eventTypeFilter) {
timelineQuery += ' AND event_type = ?'
timelineParams.push(eventTypeFilter)
}
if (severityFilter) {
timelineQuery += ' AND severity = ?'
timelineParams.push(severityFilter)
}
if (agentFilter) {
timelineQuery += ' AND agent_name = ?'
timelineParams.push(agentFilter)
}
timelineQuery += ' GROUP BY bucket ORDER BY bucket ASC'
const timeline = db.prepare(timelineQuery).all(...timelineParams) as any[]
const severityMap: Record<number, string> = { 3: 'critical', 2: 'warning', 1: 'info' }
return NextResponse.json({
posture: { score: blendedScore, level },
scan: {
score: scan.score,
overall: scan.overall,
categories: scan.categories,
},
authEvents: {
loginFailures,
tokenRotations,
accessDenials,
recentEvents: authEventsQuery.slice(0, 10),
},
agentTrust: {
agents: agents.map((a: any) => ({
name: a.agent_name,
score: Math.round(a.trust_score * 100) / 100,
anomalies: a.anomalies,
})),
flaggedCount,
},
secretExposures: {
total: secretEvents.length,
recent: secretEvents.slice(0, 5),
},
mcpAudit: {
totalCalls,
uniqueTools: mcpTotals?.unique_tools ?? 0,
failureRate,
topTools: topTools.map((t: any) => ({ name: t.tool_name, count: t.count })),
},
rateLimits: {
totalHits: rateLimitEvents?.total ?? 0,
byIp: rateLimitByIp.map((r: any) => ({ ip: r.ip_address, count: r.count })),
},
injectionAttempts: {
total: injectionEvents.length,
recent: injectionEvents.slice(0, 5),
},
timeline: timeline.map((t: any) => ({
timestamp: t.bucket,
eventCount: t.event_count,
severity: severityMap[t.max_severity] || 'info',
})),
})
} catch (error) {
logger.error({ err: error }, 'GET /api/security-audit error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}