diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index f6972b0..155fb81 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -17,6 +17,11 @@ const nextConfig: NextConfig = { "@capakraken/staffing", ], typedRoutes: true, + eslint: { + // ESLint runs separately via `pnpm lint` — skip during `next build` to + // avoid plugin resolution issues in the build environment. + ignoreDuringBuilds: true, + }, async redirects() { return [ // Common URL alias — redirect to the real auth entry point @@ -86,6 +91,7 @@ const nextConfig: NextConfig = { let exportedConfig: NextConfig = nextConfig; if (process.env.NODE_ENV === "production") { try { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { withSentryConfig } = require("@sentry/nextjs"); exportedConfig = withSentryConfig(nextConfig, { silent: true, diff --git a/apps/web/src/app/api/cron/auth-anomaly-check/detect.ts b/apps/web/src/app/api/cron/auth-anomaly-check/detect.ts new file mode 100644 index 0000000..04a71f7 --- /dev/null +++ b/apps/web/src/app/api/cron/auth-anomaly-check/detect.ts @@ -0,0 +1,79 @@ +import { prisma } from "@capakraken/db"; + +/** 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 { + 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(); + 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, + }; +} diff --git a/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts b/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts index 327a4eb..5f1a540 100644 --- a/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts +++ b/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts @@ -10,8 +10,8 @@ * - No admin notification when no anomalies */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { THRESHOLDS } from "./route.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { THRESHOLDS } from "./detect.js"; // ─── Prisma mock ───────────────────────────────────────────────────────────── const auditLogFindManyMock = vi.hoisted(() => vi.fn()); @@ -62,10 +62,17 @@ async function importRoute() { return mod; } +async function importDetect() { + const mod = await import("./detect.js"); + return mod; +} + // ─── tests ──────────────────────────────────────────────────────────────────── describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => { - beforeEach(() => { vi.clearAllMocks(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); it("returns 401 when verifyCronSecret denies the request", async () => { const { NextResponse } = await import("next/server"); @@ -102,7 +109,7 @@ describe("GET /api/cron/auth-anomaly-check — no anomalies", () => { ); const { GET } = await importRoute(); const res = await GET(makeRequest()); - const body = await res.json() as { ok: boolean; anomalies: unknown[] }; + const body = (await res.json()) as { ok: boolean; anomalies: unknown[] }; expect(res.status).toBe(200); expect(body.ok).toBe(true); expect(body.anomalies).toHaveLength(0); @@ -129,7 +136,7 @@ describe("GET /api/cron/auth-anomaly-check — HIGH_GLOBAL_FAILURE_RATE", () => userFindManyMock.mockResolvedValue([{ id: "admin_1" }]); const { GET } = await importRoute(); const res = await GET(makeRequest()); - const body = await res.json() as { anomalies: Array<{ type: string }> }; + const body = (await res.json()) as { anomalies: Array<{ type: string }> }; expect(body.anomalies.some((a) => a.type === "HIGH_GLOBAL_FAILURE_RATE")).toBe(true); }); @@ -141,7 +148,10 @@ describe("GET /api/cron/auth-anomaly-check — HIGH_GLOBAL_FAILURE_RATE", () => const { GET } = await importRoute(); await GET(makeRequest()); expect(createNotificationsMock).toHaveBeenCalledOnce(); - const call = createNotificationsMock.mock.calls[0]![0] as { userIds: string[]; priority: string }; + const call = createNotificationsMock.mock.calls[0]![0] as { + userIds: string[]; + priority: string; + }; expect(call.userIds).toContain("admin_1"); expect(call.priority).toBe("CRITICAL"); }); @@ -161,7 +171,7 @@ describe("GET /api/cron/auth-anomaly-check — CONCENTRATED_FAILURES", () => { userFindManyMock.mockResolvedValue([{ id: "admin_1" }]); const { GET } = await importRoute(); const res = await GET(makeRequest()); - const body = await res.json() as { anomalies: Array<{ type: string; entityId: string }> }; + const body = (await res.json()) as { anomalies: Array<{ type: string; entityId: string }> }; const concentrated = body.anomalies.find((a) => a.type === "CONCENTRATED_FAILURES"); expect(concentrated).toBeDefined(); expect(concentrated!.entityId).toBe("target_user"); @@ -169,11 +179,13 @@ describe("GET /api/cron/auth-anomaly-check — CONCENTRATED_FAILURES", () => { it("does not flag an entity that is below the per-entity threshold", async () => { auditLogFindManyMock.mockResolvedValue( - Array.from({ length: THRESHOLDS.perEntityFailures - 1 }, () => makeFailureEvent("target_user")), + Array.from({ length: THRESHOLDS.perEntityFailures - 1 }, () => + makeFailureEvent("target_user"), + ), ); const { GET } = await importRoute(); const res = await GET(makeRequest()); - const body = await res.json() as { anomalies: Array<{ type: string }> }; + const body = (await res.json()) as { anomalies: Array<{ type: string }> }; expect(body.anomalies.some((a) => a.type === "CONCENTRATED_FAILURES")).toBe(false); }); @@ -199,7 +211,7 @@ describe("GET /api/cron/auth-anomaly-check — error handling", () => { const { GET } = await importRoute(); const res = await GET(makeRequest()); expect(res.status).toBe(500); - const body = await res.json() as { ok: boolean }; + const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(false); }); }); @@ -209,11 +221,13 @@ describe("GET /api/cron/auth-anomaly-check — error handling", () => { // the CRON_SECRET check, to verify threshold logic in isolation. describe("detectAuthAnomalies — unit tests", () => { - beforeEach(() => { vi.clearAllMocks(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); it("returns empty anomalies and zero totalFailures when no events are found", async () => { auditLogFindManyMock.mockResolvedValue([]); - const { detectAuthAnomalies } = await importRoute(); + const { detectAuthAnomalies } = await importDetect(); const result = await detectAuthAnomalies(); @@ -227,13 +241,11 @@ describe("detectAuthAnomalies — unit tests", () => { summary: "Login failed: bad password", })); auditLogFindManyMock.mockResolvedValue(events); - const { detectAuthAnomalies } = await importRoute(); + const { detectAuthAnomalies } = await importDetect(); const result = await detectAuthAnomalies(); - const globalAnomaly = result.anomalies.find( - (a) => a.type === "HIGH_GLOBAL_FAILURE_RATE", - ); + const globalAnomaly = result.anomalies.find((a) => a.type === "HIGH_GLOBAL_FAILURE_RATE"); expect(globalAnomaly).toBeDefined(); expect(globalAnomaly!.count).toBe(THRESHOLDS.globalFailures); expect(result.totalFailures).toBe(THRESHOLDS.globalFailures); @@ -245,13 +257,11 @@ describe("detectAuthAnomalies — unit tests", () => { summary: "Login failed: bad password", })); auditLogFindManyMock.mockResolvedValue(events); - const { detectAuthAnomalies } = await importRoute(); + const { detectAuthAnomalies } = await importDetect(); const result = await detectAuthAnomalies(); - const concentrated = result.anomalies.find( - (a) => a.type === "CONCENTRATED_FAILURES", - ); + const concentrated = result.anomalies.find((a) => a.type === "CONCENTRATED_FAILURES"); expect(concentrated).toBeDefined(); expect(concentrated!.count).toBe(THRESHOLDS.perEntityFailures); expect(concentrated!.entityId).toBe("user_attacker"); @@ -265,13 +275,11 @@ describe("detectAuthAnomalies — unit tests", () => { summary: "Login failed: unknown user", })); auditLogFindManyMock.mockResolvedValue(events); - const { detectAuthAnomalies } = await importRoute(); + const { detectAuthAnomalies } = await importDetect(); const result = await detectAuthAnomalies(); - const concentrated = result.anomalies.find( - (a) => a.type === "CONCENTRATED_FAILURES", - ); + const concentrated = result.anomalies.find((a) => a.type === "CONCENTRATED_FAILURES"); expect(concentrated).toBeUndefined(); }); @@ -289,15 +297,13 @@ describe("detectAuthAnomalies — unit tests", () => { }), ); auditLogFindManyMock.mockResolvedValue([...botEvents, ...otherEvents]); - const { detectAuthAnomalies } = await importRoute(); + const { detectAuthAnomalies } = await importDetect(); const result = await detectAuthAnomalies(); expect(result.totalFailures).toBe(THRESHOLDS.globalFailures); - const globalAnomaly = result.anomalies.find( - (a) => a.type === "HIGH_GLOBAL_FAILURE_RATE", - ); + const globalAnomaly = result.anomalies.find((a) => a.type === "HIGH_GLOBAL_FAILURE_RATE"); expect(globalAnomaly).toBeDefined(); const concentrated = result.anomalies.find( diff --git a/apps/web/src/app/api/cron/auth-anomaly-check/route.ts b/apps/web/src/app/api/cron/auth-anomaly-check/route.ts index 6437a15..3f1f404 100644 --- a/apps/web/src/app/api/cron/auth-anomaly-check/route.ts +++ b/apps/web/src/app/api/cron/auth-anomaly-check/route.ts @@ -3,88 +3,11 @@ import { prisma } from "@capakraken/db"; import { createNotificationsForUsers } from "@capakraken/api"; import { logger } from "@capakraken/api/lib/logger"; import { verifyCronSecret } from "~/lib/cron-auth.js"; +import { detectAuthAnomalies } from "./detect.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 { - 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(); - 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 * diff --git a/apps/web/src/test-utils.tsx b/apps/web/src/test-utils.tsx index 1c3b8a4..68302b0 100644 --- a/apps/web/src/test-utils.tsx +++ b/apps/web/src/test-utils.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, type RenderOptions } from "@testing-library/react"; +import { render, type RenderOptions, type RenderResult } from "@testing-library/react"; import type { ReactElement } from "react"; function createTestQueryClient() { @@ -16,7 +16,7 @@ function TestProviders({ children }: { children: React.ReactNode }) { return {children}; } -function customRender(ui: ReactElement, options?: Omit) { +function customRender(ui: ReactElement, options?: Omit): RenderResult { return render(ui, { wrapper: TestProviders, ...options }); }