188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
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<string, unknown>;
|
|
};
|
|
|
|
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<string, string>, 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");
|
|
}
|
|
}
|