148 lines
4.7 KiB
TypeScript

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<typeof payloadSchema>
): { 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 });
}