import { Injectable } from "@nestjs/common"; import * as crypto from "crypto"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { RequestContext } from "./abuse.types"; type AbuseSeverity = "low" | "medium" | "high"; type RecordAbuseEventInput = RequestContext & { userId?: string; eventType: string; riskPoints: number; severity: AbuseSeverity; metadata?: Record; }; const RISK_WINDOW_HOURS = 24; const PAGINATION_EVENT_TYPES = ["PAGINATION_LIMIT_CLAMPED", "PAGINATION_DEEP_SCAN"] as const; @Injectable() export class AbuseService { constructor(private readonly prisma: PrismaService) {} async recordEvent(input: RecordAbuseEventInput) { return this.prisma.abuseEvent.create({ data: { userId: input.userId, eventType: input.eventType, riskPoints: input.riskPoints, severity: input.severity, ipAddress: input.ipAddress, userAgent: input.userAgent, metadata: (input.metadata ?? {}) as Prisma.InputJsonValue, }, }); } async recordLoginFailure(email: string, userId: string | undefined, context?: RequestContext) { await this.recordEvent({ userId, eventType: "AUTH_LOGIN_FAILURE", riskPoints: userId ? 8 : 4, severity: userId ? "medium" : "low", ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: { emailHash: this.hashValue(email.toLowerCase().trim()), knownUser: Boolean(userId), }, }); } async recordInvalidToken(context?: RequestContext) { await this.recordEvent({ eventType: "AUTH_INVALID_TOKEN", riskPoints: 5, severity: "low", ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: {}, }); } async recordExportActivity(userId: string, rowCount: number, filters: Record, context?: RequestContext) { const since = new Date(Date.now() - 60 * 60 * 1000); const recentExports = await this.prisma.exportLog.count({ where: { userId, createdAt: { gte: since } }, }); if (rowCount >= 500) { await this.recordEvent({ userId, eventType: "EXPORT_LARGE", riskPoints: rowCount >= 1000 ? 25 : 15, severity: rowCount >= 1000 ? "high" : "medium", ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: { rowCount, filters }, }); } if (recentExports >= 5) { await this.recordEvent({ userId, eventType: "EXPORT_REPEATED", riskPoints: 20, severity: "high", ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: { exportsInLastHour: recentExports, rowCount }, }); } } async recordPaginationActivity( userId: string, resource: string, page: number, requestedLimit: number | undefined, appliedLimit: number, context?: RequestContext, ) { const normalizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; const normalizedRequestedLimit = Number.isFinite(requestedLimit) && requestedLimit ? Math.floor(requestedLimit) : appliedLimit; const isLimitClamped = normalizedRequestedLimit > appliedLimit; const isDeepScan = normalizedPage >= 20; if (!isLimitClamped && !isDeepScan) return; const since = new Date(Date.now() - 60 * 60 * 1000); const recentSignals = await this.prisma.abuseEvent.count({ where: { userId, eventType: { in: [...PAGINATION_EVENT_TYPES] }, createdAt: { gte: since }, }, }); const repeated = recentSignals >= 10; if (isLimitClamped) { await this.recordEvent({ userId, eventType: "PAGINATION_LIMIT_CLAMPED", riskPoints: repeated ? 10 : 3, severity: repeated ? "medium" : "low", ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: { resource, page: normalizedPage, requestedLimit: normalizedRequestedLimit, appliedLimit, recentSignals, }, }); } if (isDeepScan) { await this.recordEvent({ userId, eventType: "PAGINATION_DEEP_SCAN", riskPoints: repeated || normalizedPage >= 100 ? 20 : 8, severity: repeated || normalizedPage >= 100 ? "high" : "medium", ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: { resource, page: normalizedPage, requestedLimit: normalizedRequestedLimit, appliedLimit, recentSignals, }, }); } } async getRiskProfile(userId: string) { const since = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000); const events = await this.prisma.abuseEvent.findMany({ where: { userId, createdAt: { gte: since } }, orderBy: { createdAt: "desc" }, take: 25, }); const score = Math.min(100, events.reduce((sum, event) => sum + event.riskPoints, 0)); return { score, level: this.levelForScore(score), windowHours: RISK_WINDOW_HOURS, recentEvents: events.map((event) => ({ id: event.id, eventType: event.eventType, severity: event.severity, riskPoints: event.riskPoints, createdAt: event.createdAt, })), }; } private levelForScore(score: number) { if (score >= 70) return "high"; if (score >= 30) return "medium"; return "low"; } private hashValue(value: string) { return crypto.createHash("sha256").update(value).digest("hex"); } }