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) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
80 lines
2.3 KiB
TypeScript
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,
|
|
};
|
|
}
|