Files
Nexus/apps/web/src/app/api/cron/auth-anomaly-check/detect.ts
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

80 lines
2.3 KiB
TypeScript

import { prisma } from "@nexus/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<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,
};
}