ledgerone_backend/src/abuse/abuse.service.ts

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");
}
}