Files
Nexus/apps/web/src/app/api/cron/auth-anomaly-check/route.ts
T
Hartmut 82acc56b8d chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)
- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files
- Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error
- Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin
- Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments
- Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example
- Add coverage artifact upload step to CI test job
- Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:49:29 +02:00

148 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/** Window over which auth events are analysed. */
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
/**
* Alert thresholds — tune per deployment if needed.
* Exported so tests can reference them without re-declaring magic numbers.
*/
export const THRESHOLDS = {
/** Total failed login attempts in the window before alerting. */
globalFailures: 20,
/** Failed attempts attributed to a single entityId (userId / IP placeholder) before alerting. */
perEntityFailures: 10,
};
export interface AnomalyReport {
windowStartedAt: string;
windowEndedAt: string;
totalFailures: number;
anomalies: Array<{ type: string; count: number; entityId: string | null }>;
}
/**
* Analyses recent auth audit events and returns detected anomalies.
* Exported for unit testing without an HTTP layer.
*/
export async function detectAuthAnomalies(windowMs = WINDOW_MS): Promise<AnomalyReport> {
const windowEnd = new Date();
const windowStart = new Date(windowEnd.getTime() - windowMs);
const failureEvents = await prisma.auditLog.findMany({
where: {
entityType: "Auth",
action: "CREATE",
summary: { startsWith: "Login failed" },
createdAt: { gte: windowStart, lte: windowEnd },
},
select: {
entityId: true,
summary: true,
},
});
const anomalies: AnomalyReport["anomalies"] = [];
// Global threshold: too many failures overall
if (failureEvents.length >= THRESHOLDS.globalFailures) {
anomalies.push({
type: "HIGH_GLOBAL_FAILURE_RATE",
count: failureEvents.length,
entityId: null,
});
}
// Per-entity threshold: one entity accumulating failures (brute-force pattern)
const countByEntity = new Map<string, number>();
for (const event of failureEvents) {
if (event.entityId) {
countByEntity.set(event.entityId, (countByEntity.get(event.entityId) ?? 0) + 1);
}
}
for (const [entityId, count] of countByEntity.entries()) {
if (count >= THRESHOLDS.perEntityFailures) {
anomalies.push({
type: "CONCENTRATED_FAILURES",
count,
entityId,
});
}
}
return {
windowStartedAt: windowStart.toISOString(),
windowEndedAt: windowEnd.toISOString(),
totalFailures: failureEvents.length,
anomalies,
};
}
/**
* GET /api/cron/auth-anomaly-check
*
* Scans recent auth audit events for brute-force / anomaly patterns and
* alerts ADMIN users when thresholds are exceeded.
*
* Protected by CRON_SECRET. Run every 30 minutes via cron or Vercel Cron.
*/
export async function GET(request: Request) {
const deny = verifyCronSecret(request);
if (deny) return deny;
try {
const report = await detectAuthAnomalies();
if (report.anomalies.length > 0) {
const adminUsers = await prisma.user.findMany({
where: { systemRole: "ADMIN" },
select: { id: true },
});
if (adminUsers.length > 0) {
const summary = report.anomalies
.map((a) =>
a.entityId
? `${a.type}: ${a.count} failures for entity ${a.entityId}`
: `${a.type}: ${a.count} total failures`,
)
.join("; ");
await createNotificationsForUsers({
db: prisma as any,
userIds: adminUsers.map((u) => u.id),
type: "SYSTEM_ALERT",
title: `Auth Anomaly Detected (${report.anomalies.length} signal${report.anomalies.length > 1 ? "s" : ""})`,
body: `${summary}. Window: ${report.windowStartedAt} ${report.windowEndedAt}. Review audit logs at /admin/settings.`,
category: "system",
priority: "CRITICAL",
link: "/admin/settings",
});
logger.warn(
{
anomalies: report.anomalies,
window: { start: report.windowStartedAt, end: report.windowEndedAt },
},
"Auth anomaly cron: anomalies detected and admins notified",
);
}
}
return NextResponse.json({
ok: true,
...report,
});
} catch (error) {
logger.error({ error, route: "/api/cron/auth-anomaly-check" }, "Auth anomaly cron failed");
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
}
}