import { NextResponse } from "next/server"; import { z } from "zod"; import { type AccessLogSeverity, recordAccessIpEvent, } from "@/lib/security/access-log"; export const runtime = "nodejs"; export const maxDuration = 10; const payloadSchema = z.object({ eventType: z.string().trim().max(32).optional(), ip: z.string().trim().max(128).optional().nullable(), userId: z.string().trim().uuid().optional().nullable(), userEmail: z.string().trim().max(128).optional().nullable(), userRole: z.string().trim().max(32).optional().nullable(), method: z.string().trim().max(12).optional().nullable(), path: z.string().trim().max(256).optional().nullable(), routeGroup: z.string().trim().max(64).optional().nullable(), statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(), outcome: z.string().trim().max(32).optional().nullable(), severity: z.enum(["info", "watch", "high", "critical"]).optional(), reasons: z.array(z.string().trim().max(80)).max(12).optional(), userAgent: z.string().trim().max(512).optional().nullable(), referer: z.string().trim().max(512).optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), occurredAt: z.union([z.string(), z.number(), z.date()]).optional(), }); const suspiciousPathPatterns = [ /\.env/i, /\/\.git/i, /\/wp-admin/i, /\/phpmyadmin/i, /\/actuator/i, /\/vendor\/phpunit/i, /\/eval-stdin\.php/i, /\/containers\/json/i, /\/index\.php/i, ]; const suspiciousUserAgentPatterns = [ /libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i, /wget|curl|python-requests|go-http-client/i, /nuclei|crawlergo|httpx/i, ]; function pushReason(reasons: string[], reason: string) { if (!reasons.includes(reason)) reasons.push(reason); } function rankSeverity(value: AccessLogSeverity) { return { info: 0, watch: 1, high: 2, critical: 3 }[value]; } function maxSeverity( current: AccessLogSeverity, next: AccessLogSeverity ): AccessLogSeverity { return rankSeverity(next) > rankSeverity(current) ? next : current; } function classifyPayload( payload: z.infer ): { severity: AccessLogSeverity; reasons: string[] } { let severity: AccessLogSeverity = payload.severity || "info"; const reasons = [...(payload.reasons || [])]; const path = payload.path || ""; const userAgent = payload.userAgent || ""; const statusCode = payload.statusCode || 0; const outcome = payload.outcome || "allowed"; if (outcome === "rate_limited") { severity = maxSeverity(severity, "high"); pushReason(reasons, "触发访问频率限制"); } if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) { severity = maxSeverity(severity, "watch"); pushReason(reasons, "出现未授权访问"); } if (statusCode === 429) { severity = maxSeverity(severity, "high"); pushReason(reasons, "返回 429 限流"); } else if (statusCode >= 500) { severity = maxSeverity(severity, "high"); pushReason(reasons, "触发服务端错误"); } else if (statusCode >= 400) { severity = maxSeverity(severity, "watch"); pushReason(reasons, "返回 4xx 异常状态"); } if (path.startsWith("/api/auth") && statusCode >= 400) { severity = maxSeverity(severity, "high"); pushReason(reasons, "认证接口失败"); } if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) { severity = maxSeverity(severity, "high"); pushReason(reasons, "命中敏感探测路径"); } if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) { severity = maxSeverity(severity, "watch"); pushReason(reasons, "User-Agent 异常"); } if (reasons.includes("静态黑名单命中但未封禁")) { severity = maxSeverity(severity, "high"); } return { severity, reasons: Array.from(new Set(reasons)).slice(0, 8), }; } function getExpectedSecret() { return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || ""; } export async function POST(request: Request) { const expectedSecret = getExpectedSecret(); const providedSecret = request.headers.get("x-access-log-internal-secret") || ""; if (!expectedSecret || providedSecret !== expectedSecret) { return new NextResponse("Not found", { status: 404 }); } const raw = await request.json().catch(() => null); const parsed = payloadSchema.safeParse(raw); if (!parsed.success) { return NextResponse.json({ ok: true }); } const classification = classifyPayload(parsed.data); await recordAccessIpEvent({ ...parsed.data, severity: classification.severity, reasons: classification.reasons, }).catch((error) => { console.warn("[AccessIpEvent] failed to record access event", error); }); return NextResponse.json({ ok: true }); }