|
|
|
@@ -0,0 +1,246 @@
|
|
|
|
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
|
|
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
|
|
|
import { isIP } from 'node:net';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
import { SecurityAgentClient } from './security-agent.client';
|
|
|
|
|
import { SECURITY_BLOCK_DURATIONS, SECURITY_RULE_CODE_SET, SECURITY_SEVERITIES, type SecurityRuleCode } from './security-detection.constants';
|
|
|
|
|
|
|
|
|
|
export type SecurityEventInput = {
|
|
|
|
|
eventKey?: string; ruleCode: SecurityRuleCode; sourceIp: string; sourcePort?: number;
|
|
|
|
|
account?: string; path?: string; protocol?: string; resultCode?: string;
|
|
|
|
|
evidence?: Record<string, unknown>; occurredAt?: string | Date;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class SecurityDetectionService {
|
|
|
|
|
constructor(private readonly prisma: PrismaService, private readonly agent: SecurityAgentClient) {}
|
|
|
|
|
|
|
|
|
|
async recordEvent(input: SecurityEventInput) {
|
|
|
|
|
if (!SECURITY_RULE_CODE_SET.has(input.ruleCode)) throw new BadRequestException('不支持的安全检测类型');
|
|
|
|
|
const sourceIp = normalizeIp(input.sourceIp);
|
|
|
|
|
const occurredAt = input.occurredAt ? new Date(input.occurredAt) : new Date();
|
|
|
|
|
if (!Number.isFinite(occurredAt.getTime())) throw new BadRequestException('安全事件时间无效');
|
|
|
|
|
const eventKey = input.eventKey ?? createHash('sha256').update(JSON.stringify([
|
|
|
|
|
input.ruleCode, sourceIp, input.sourcePort, input.account, input.path, input.resultCode,
|
|
|
|
|
occurredAt.toISOString(), input.evidence,
|
|
|
|
|
])).digest('hex');
|
|
|
|
|
const rule = await this.prisma.securityDetectionRule.findUnique({ where: { code: input.ruleCode } });
|
|
|
|
|
if (!rule) throw new NotFoundException('安全检测规则不存在');
|
|
|
|
|
|
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
|
|
|
// 同一来源和规则串行聚合,避免并发计数跨过阈值时创建多个告警。
|
|
|
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`${rule.id}:${sourceIp}`}))`;
|
|
|
|
|
try {
|
|
|
|
|
await tx.securityDetectionEvent.create({ data: {
|
|
|
|
|
eventKey, ruleId: rule.id, sourceIp, sourcePort: input.sourcePort,
|
|
|
|
|
accountHash: input.account ? createHash('sha256').update(input.account).digest('hex') : undefined,
|
|
|
|
|
path: input.path?.slice(0, 512), protocol: input.protocol?.slice(0, 32), resultCode: input.resultCode?.slice(0, 128),
|
|
|
|
|
evidence: sanitizeEvidence(input.evidence), occurredAt,
|
|
|
|
|
} });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isUniqueViolation(error)) return { accepted: true, duplicate: true, alertId: null };
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
if (!rule.enabled) return { accepted: true, duplicate: false, alertId: null };
|
|
|
|
|
|
|
|
|
|
const windowStartedAt = new Date(occurredAt.getTime() - rule.windowSeconds * 1000);
|
|
|
|
|
const storedEventCount = await tx.securityDetectionEvent.count({
|
|
|
|
|
where: { ruleId: rule.id, sourceIp, occurredAt: { gte: windowStartedAt, lte: occurredAt } },
|
|
|
|
|
});
|
|
|
|
|
// Fail2ban上报代表其自身窗口已经达到maxretry;应用事件则逐条在数据库窗口内计数。
|
|
|
|
|
const eventCount = rule.sourceType === 'fail2ban' ? Math.max(storedEventCount, rule.threshold) : storedEventCount;
|
|
|
|
|
if (eventCount < rule.threshold) return { accepted: true, duplicate: false, alertId: null };
|
|
|
|
|
|
|
|
|
|
const cooldownStart = new Date(occurredAt.getTime() - rule.cooldownSeconds * 1000);
|
|
|
|
|
const active = await tx.securityAlert.findFirst({
|
|
|
|
|
where: { ruleId: rule.id, sourceIp, status: { in: ['open', 'acknowledged', 'block_failed', 'blocked'] }, lastOccurredAt: { gte: cooldownStart } },
|
|
|
|
|
orderBy: { lastOccurredAt: 'desc' },
|
|
|
|
|
});
|
|
|
|
|
if (active) {
|
|
|
|
|
const updated = await tx.securityAlert.update({ where: { id: active.id }, data: { eventCount, lastOccurredAt: occurredAt } });
|
|
|
|
|
return { accepted: true, duplicate: false, alertId: updated.id };
|
|
|
|
|
}
|
|
|
|
|
const fingerprint = createHash('sha256').update(`${rule.id}:${sourceIp}:${occurredAt.toISOString()}`).digest('hex');
|
|
|
|
|
const alert = await tx.securityAlert.create({ data: {
|
|
|
|
|
fingerprint, ruleId: rule.id, sourceIp, severity: rule.severity, eventCount,
|
|
|
|
|
windowStartedAt, firstOccurredAt: occurredAt, lastOccurredAt: occurredAt,
|
|
|
|
|
} });
|
|
|
|
|
return { accepted: true, duplicate: false, alertId: alert.id };
|
|
|
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async overview(range = '24h') {
|
|
|
|
|
if (!['1h', '24h', '7d'].includes(range)) throw new BadRequestException('仅支持1h、24h或7d安全检测范围');
|
|
|
|
|
const hours = range === '1h' ? 1 : range === '7d' ? 168 : 24;
|
|
|
|
|
const since = new Date(Date.now() - hours * 3600_000);
|
|
|
|
|
const activeStatuses = ['open', 'acknowledged', 'block_failed'];
|
|
|
|
|
const [alerts, totalEvents, activeBlocks, rules, activeAlerts, criticalAlerts, distribution, agentStatus] = await Promise.all([
|
|
|
|
|
this.prisma.securityAlert.findMany({ where: { lastOccurredAt: { gte: since } }, include: { rule: true }, orderBy: { lastOccurredAt: 'desc' }, take: 12 }),
|
|
|
|
|
this.prisma.securityDetectionEvent.count({ where: { occurredAt: { gte: since } } }),
|
|
|
|
|
this.prisma.securityBlock.count({ where: { status: 'blocked', expiresAt: { gt: new Date() } } }),
|
|
|
|
|
this.prisma.securityDetectionRule.findMany({ orderBy: { name: 'asc' } }),
|
|
|
|
|
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses } } }),
|
|
|
|
|
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses }, severity: 'critical' } }),
|
|
|
|
|
this.prisma.securityAlert.groupBy({ by: ['ruleId'], where: { lastOccurredAt: { gte: since } }, _sum: { eventCount: true } }),
|
|
|
|
|
this.agent.status().catch((error: Error) => ({ ok: false, active: false, error: error.message })),
|
|
|
|
|
]);
|
|
|
|
|
const ruleNames = new Map(rules.map((rule) => [rule.id, rule.name]));
|
|
|
|
|
return {
|
|
|
|
|
range, collectedAt: new Date().toISOString(), totalEvents, activeAlerts, criticalAlerts, activeBlocks,
|
|
|
|
|
health: { agent: agentStatus.ok && agentStatus.active ? 'healthy' : 'unavailable', agentError: agentStatus.error, rulesEffective: rules.filter((rule) => rule.applyStatus === 'effective').length, rulesTotal: rules.length },
|
|
|
|
|
sourceDistribution: distribution.map((item) => ({ name: ruleNames.get(item.ruleId) ?? item.ruleId, value: item._sum.eventCount ?? 0 })),
|
|
|
|
|
alerts,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listAlerts(query: { status?: string; ruleCode?: string; sourceIp?: string; page?: string; pageSize?: string }) {
|
|
|
|
|
const page = positiveInt(query.page, 1, 100000);
|
|
|
|
|
const pageSize = positiveInt(query.pageSize, 20, 100);
|
|
|
|
|
const where: Prisma.SecurityAlertWhereInput = {
|
|
|
|
|
...(query.status ? { status: query.status } : {}),
|
|
|
|
|
...(query.ruleCode ? { rule: { code: query.ruleCode } } : {}),
|
|
|
|
|
...(query.sourceIp ? { sourceIp: normalizeIp(query.sourceIp) } : {}),
|
|
|
|
|
};
|
|
|
|
|
return Promise.all([
|
|
|
|
|
this.prisma.securityAlert.findMany({ where, include: { rule: true }, orderBy: { lastOccurredAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
|
|
|
|
|
this.prisma.securityAlert.count({ where }),
|
|
|
|
|
]).then(([items, total]) => ({ items, total, page, pageSize }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listRules() { return this.prisma.securityDetectionRule.findMany({ orderBy: [{ sourceType: 'asc' }, { name: 'asc' }] }); }
|
|
|
|
|
|
|
|
|
|
async updateRule(id: string, input: Record<string, unknown>, operatorId: string) {
|
|
|
|
|
assertAllowedKeys(input, ['configVersion', 'enabled', 'threshold', 'windowSeconds', 'cooldownSeconds', 'severity', 'defaultBlockSeconds', 'maximumBlockSeconds']);
|
|
|
|
|
if (typeof input.enabled !== 'boolean') throw new BadRequestException('启用状态必须为布尔值');
|
|
|
|
|
const current = await this.prisma.securityDetectionRule.findUnique({ where: { id } });
|
|
|
|
|
if (!current) throw new NotFoundException('规则不存在');
|
|
|
|
|
if (Number(input.configVersion) !== current.configVersion) throw new ConflictException('规则已被其他管理员修改,请刷新后重试');
|
|
|
|
|
const threshold = boundedInt(input.threshold, 1, 100000, '触发次数');
|
|
|
|
|
const windowSeconds = boundedInt(input.windowSeconds, 10, 86400, '检测窗口');
|
|
|
|
|
const cooldownSeconds = boundedInt(input.cooldownSeconds, 0, 604800, '告警冷却');
|
|
|
|
|
const defaultBlockSeconds = boundedInt(input.defaultBlockSeconds, 600, 604800, '默认封禁时长');
|
|
|
|
|
const maximumBlockSeconds = boundedInt(input.maximumBlockSeconds, defaultBlockSeconds, 604800, '最大封禁时长');
|
|
|
|
|
const severity = String(input.severity ?? '');
|
|
|
|
|
if (!SECURITY_SEVERITIES.has(severity)) throw new BadRequestException('告警级别无效');
|
|
|
|
|
const version = current.configVersion + 1;
|
|
|
|
|
const nextConfig = { enabled: Boolean(input.enabled), threshold, windowSeconds, cooldownSeconds, severity, defaultBlockSeconds, maximumBlockSeconds };
|
|
|
|
|
await this.prisma.securityDetectionRule.update({ where: { id }, data: {
|
|
|
|
|
configVersion: version, applyStatus: 'applying', lastApplyError: null, pendingConfig: nextConfig,
|
|
|
|
|
} });
|
|
|
|
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.rule_updated', resource: 'security_detection_rule', resourceId: id, detail: { version, beforeVersion: current.configVersion } } });
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.agent.applyRules(version, (await this.listRules()).map((rule) => rule.id === id
|
|
|
|
|
? { code: rule.code, enabled: nextConfig.enabled, threshold: nextConfig.threshold, windowSeconds: nextConfig.windowSeconds, cooldownSeconds: nextConfig.cooldownSeconds }
|
|
|
|
|
: { code: rule.code, enabled: rule.enabled, threshold: rule.threshold, windowSeconds: rule.windowSeconds, cooldownSeconds: rule.cooldownSeconds }));
|
|
|
|
|
if (!response.ok) throw new Error(response.error ?? '安全代理拒绝应用规则');
|
|
|
|
|
return this.prisma.securityDetectionRule.update({ where: { id }, data: { ...nextConfig, effectiveVersion: version, applyStatus: 'effective', pendingConfig: Prisma.JsonNull } });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : '规则应用失败';
|
|
|
|
|
await this.prisma.securityDetectionRule.update({ where: { id }, data: { applyStatus: 'failed', lastApplyError: message } });
|
|
|
|
|
throw new ConflictException({ code: 'SECURITY_RULE_APPLY_FAILED', message });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async block(alertId: string, input: { durationSeconds?: number; reason?: string }, operatorId: string) {
|
|
|
|
|
assertAllowedKeys(input as Record<string, unknown>, ['durationSeconds', 'reason']);
|
|
|
|
|
const alert = await this.prisma.securityAlert.findUnique({ where: { id: alertId }, include: { rule: true } });
|
|
|
|
|
if (!alert) throw new NotFoundException('告警不存在');
|
|
|
|
|
if (!['open', 'acknowledged', 'block_failed'].includes(alert.status)) throw new ConflictException('该告警当前不可封禁');
|
|
|
|
|
const durationSeconds = Number(input.durationSeconds ?? alert.rule.defaultBlockSeconds);
|
|
|
|
|
if (!SECURITY_BLOCK_DURATIONS.has(durationSeconds) || durationSeconds > alert.rule.maximumBlockSeconds) throw new BadRequestException('封禁时长不在允许范围内');
|
|
|
|
|
const reason = String(input.reason ?? '').trim();
|
|
|
|
|
if (reason.length < 5 || reason.length > 500) throw new BadRequestException('封禁原因需为5至500个字符');
|
|
|
|
|
if (isSystemProtected(alert.sourceIp) || await this.isProtected(alert.sourceIp)) throw new ConflictException({ code: 'PROTECTED_NETWORK', message: '该地址属于系统或人工保护名单,禁止封禁' });
|
|
|
|
|
// 执行器由可信的规则入口固定映射,绝不接受浏览器指定,避免把Cloudflare访客IP错误交给nftables。
|
|
|
|
|
const executor = ['admin_login_failure', 'client_login_failure'].includes(alert.rule.code) ? 'nginx_real_ip' : 'nftables';
|
|
|
|
|
const operationKey = randomUUID();
|
|
|
|
|
const block = await this.prisma.$transaction(async (tx) => {
|
|
|
|
|
const claimed = await tx.securityAlert.updateMany({ where: { id: alert.id, status: { in: ['open', 'acknowledged', 'block_failed'] } }, data: { status: 'block_requested' } });
|
|
|
|
|
if (!claimed.count) throw new ConflictException('告警已由其他管理员处理,请刷新后重试');
|
|
|
|
|
return tx.securityBlock.create({ data: { operationKey, alertId, sourceIp: alert.sourceIp, executor, durationSeconds, reason, requestedById: operatorId } });
|
|
|
|
|
});
|
|
|
|
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.block_requested', resource: 'security_block', resourceId: block.id, detail: { alertId, sourceIp: alert.sourceIp, executor, durationSeconds, reason } } });
|
|
|
|
|
try {
|
|
|
|
|
const applied = await this.agent.block({ operationKey, sourceIp: alert.sourceIp, executor, durationSeconds });
|
|
|
|
|
if (!applied.ok) throw new Error(applied.error ?? '安全代理拒绝封禁');
|
|
|
|
|
const readback = await this.agent.status(alert.sourceIp, executor);
|
|
|
|
|
if (!readback.ok || !readback.blocked) throw new Error(readback.error ?? '执行后未读到真实封禁状态');
|
|
|
|
|
const expiresAt = new Date(Date.now() + durationSeconds * 1000);
|
|
|
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
|
|
|
const updated = await tx.securityBlock.update({ where: { id: block.id }, data: { status: 'blocked', appliedAt: new Date(), expiresAt, executorReference: applied.reference } });
|
|
|
|
|
await tx.securityAlert.update({ where: { id: alert.id }, data: { status: 'blocked', blockId: block.id } });
|
|
|
|
|
return updated;
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : '封禁执行失败';
|
|
|
|
|
await this.prisma.$transaction([
|
|
|
|
|
this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'failed', lastError: message } }),
|
|
|
|
|
this.prisma.securityAlert.update({ where: { id: alert.id }, data: { status: 'block_failed' } }),
|
|
|
|
|
]);
|
|
|
|
|
throw new ConflictException({ code: 'SECURITY_BLOCK_FAILED', message });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ignore(alertId: string, reason: string, operatorId: string) {
|
|
|
|
|
if (reason.trim().length < 5) throw new BadRequestException('忽略原因至少5个字符');
|
|
|
|
|
const updated = await this.prisma.securityAlert.updateMany({ where: { id: alertId, status: { in: ['open', 'acknowledged', 'block_failed'] } }, data: { status: 'ignored', ignoredAt: new Date(), ignoredById: operatorId, ignoreReason: reason.trim() } });
|
|
|
|
|
if (!updated.count) throw new ConflictException('告警状态已变化,请刷新后重试');
|
|
|
|
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.alert_ignored', resource: 'security_alert', resourceId: alertId, detail: { reason: reason.trim() } } });
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listBlocks() { return this.prisma.securityBlock.findMany({ orderBy: { requestedAt: 'desc' }, take: 200 }); }
|
|
|
|
|
|
|
|
|
|
async unblock(blockId: string, reason: string, operatorId: string) {
|
|
|
|
|
if (reason.trim().length < 5) throw new BadRequestException('解封原因至少5个字符');
|
|
|
|
|
const block = await this.prisma.securityBlock.findUnique({ where: { id: blockId } });
|
|
|
|
|
if (!block) throw new NotFoundException('封禁记录不存在');
|
|
|
|
|
if (block.status !== 'blocked') throw new ConflictException('该记录当前不可解封');
|
|
|
|
|
const claimed = await this.prisma.securityBlock.updateMany({ where: { id: blockId, status: 'blocked' }, data: { status: 'unblock_requested' } });
|
|
|
|
|
if (!claimed.count) throw new ConflictException('封禁状态已变化,请刷新后重试');
|
|
|
|
|
try {
|
|
|
|
|
const result = await this.agent.unblock({ operationKey: randomUUID(), sourceIp: block.sourceIp, executor: block.executor });
|
|
|
|
|
if (!result.ok) throw new Error(result.error ?? '安全代理拒绝解封');
|
|
|
|
|
const readback = await this.agent.status(block.sourceIp, block.executor);
|
|
|
|
|
if (!readback.ok || readback.blocked) throw new Error(readback.error ?? '执行后仍读到封禁规则');
|
|
|
|
|
const updated = await this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'released', releasedAt: new Date(), releasedById: operatorId } });
|
|
|
|
|
if (block.alertId) await this.prisma.securityAlert.updateMany({ where: { id: block.alertId, blockId: block.id }, data: { status: 'unblocked' } });
|
|
|
|
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.block_released', resource: 'security_block', resourceId: block.id, detail: { sourceIp: block.sourceIp, executor: block.executor, reason: reason.trim() } } });
|
|
|
|
|
return updated;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : '解封失败';
|
|
|
|
|
await this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'blocked', lastError: message } });
|
|
|
|
|
throw new ConflictException({ code: 'SECURITY_UNBLOCK_FAILED', message });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
listProtectedNetworks() { return this.prisma.securityProtectedNetwork.findMany({ orderBy: { createdAt: 'desc' } }); }
|
|
|
|
|
|
|
|
|
|
async addProtectedNetwork(input: { network?: string; name?: string; reason?: string }, operatorId: string) {
|
|
|
|
|
const network = normalizeNetwork(String(input.network ?? ''));
|
|
|
|
|
if (!input.name?.trim() || !input.reason?.trim()) throw new BadRequestException('名称和保护原因不能为空');
|
|
|
|
|
const result = await this.prisma.securityProtectedNetwork.create({ data: { network, name: input.name.trim(), reason: input.reason.trim(), createdById: operatorId } });
|
|
|
|
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.protected_network_created', resource: 'security_protected_network', resourceId: result.id, detail: { network } } });
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async isProtected(ip: string) {
|
|
|
|
|
const entries = await this.prisma.securityProtectedNetwork.findMany({ where: { enabled: true }, select: { network: true } });
|
|
|
|
|
return entries.some((entry) => networkContains(entry.network, ip));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeIp(value: string) { const normalized = value?.trim().replace(/^::ffff:/, ''); if (!isIP(normalized)) throw new BadRequestException('来源IP无效'); return normalized; }
|
|
|
|
|
function normalizeNetwork(value: string) { const [address, prefix] = value.trim().split('/'); const family = isIP(address); if (!family) throw new BadRequestException('保护网段无效'); if (prefix === undefined) return address; const bits = Number(prefix); const max = family === 4 ? 32 : 128; if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException('保护网段前缀无效'); return `${address}/${bits}`; }
|
|
|
|
|
function networkContains(network: string, ip: string) { const [address, prefixText] = network.split('/'); if (isIP(address) !== isIP(ip)) return false; if (prefixText === undefined) return address === ip; const bits = Number(prefixText); return (addressToBigInt(address) >> BigInt((isIP(address) === 4 ? 32 : 128) - bits)) === (addressToBigInt(ip) >> BigInt((isIP(ip) === 4 ? 32 : 128) - bits)); }
|
|
|
|
|
function addressToBigInt(value: string) { if (isIP(value) === 4) return value.split('.').reduce((total, part) => (total << 8n) + BigInt(part), 0n); const [left, right = ''] = value.toLowerCase().split('::'); const leftParts = left ? left.split(':') : []; const rightParts = right ? right.split(':') : []; const parts = [...leftParts, ...Array(Math.max(0, 8 - leftParts.length - rightParts.length)).fill('0'), ...rightParts]; return parts.reduce((total, part) => (total << 16n) + BigInt(`0x${part || '0'}`), 0n); }
|
|
|
|
|
function positiveInt(value: string | undefined, fallback: number, max: number) { const parsed = Number(value ?? fallback); return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback; }
|
|
|
|
|
function boundedInt(value: unknown, min: number, max: number, label: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new BadRequestException(`${label}必须在${min}至${max}之间`); return parsed; }
|
|
|
|
|
function sanitizeEvidence(value?: Record<string, unknown>) { if (!value) return undefined; const sanitized = JSON.parse(JSON.stringify(value, (key, item) => /password|secret|token|signature|access.?key/i.test(key) ? '[REDACTED]' : item)); return sanitized as Prisma.InputJsonValue; }
|
|
|
|
|
function isUniqueViolation(error: unknown) { return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; }
|
|
|
|
|
function assertAllowedKeys(input: Record<string, unknown>, allowed: string[]) { const unknown = Object.keys(input).filter((key) => !allowed.includes(key)); if (unknown.length) throw new BadRequestException(`不支持的字段: ${unknown.join(', ')}`); }
|
|
|
|
|
function isSystemProtected(ip: string) {
|
|
|
|
|
const builtIns = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16', '224.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', ...(process.env.SECURITY_BUILTIN_PROTECTED_NETWORKS ?? '').split(',').map((item) => item.trim()).filter(Boolean)];
|
|
|
|
|
return builtIns.some((network) => networkContains(network, ip));
|
|
|
|
|
}
|