feat: add Fail2ban security detection console

This commit is contained in:
hectorzhao
2026-08-14 10:58:18 +08:00
parent b78faa1aa2
commit d30d9ea4d0
45 changed files with 1967 additions and 18 deletions
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createConnection } from 'node:net';
type AgentResponse = { ok: boolean; reference?: string; blocked?: boolean; active?: boolean; error?: string };
@Injectable()
export class SecurityAgentClient {
constructor(private readonly config: ConfigService) {}
block(input: { operationKey: string; sourceIp: string; executor: string; durationSeconds: number }) {
return this.call({ action: 'block', ...input });
}
unblock(input: { operationKey: string; sourceIp: string; executor: string }) {
return this.call({ action: 'unblock', ...input });
}
status(sourceIp?: string, executor?: string) {
return this.call({ action: 'status', sourceIp, executor });
}
applyRules(version: number, rules: Array<Record<string, unknown>>) {
return this.call({ action: 'apply_rules', version, rules });
}
private call(payload: Record<string, unknown>): Promise<AgentResponse> {
const socketPath = this.config.get<string>('SECURITY_AGENT_SOCKET') ?? '/run/cmpp-security-agent/agent.sock';
const timeoutMs = Number(this.config.get<string>('SECURITY_AGENT_TIMEOUT_MS') ?? 3000);
return new Promise((resolve, reject) => {
const socket = createConnection(socketPath);
let settled = false;
let response = '';
const finish = (error?: Error) => {
if (settled) return;
settled = true;
socket.destroy();
if (error) reject(error);
};
socket.setTimeout(timeoutMs, () => finish(new Error('安全执行代理响应超时')));
socket.on('error', (error) => finish(new Error(`安全执行代理不可用: ${error.message}`)));
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
socket.on('data', (chunk) => {
response += chunk.toString('utf8');
const lineEnd = response.indexOf('\n');
if (lineEnd < 0) return;
try {
const parsed = JSON.parse(response.slice(0, lineEnd)) as AgentResponse;
settled = true;
socket.end();
resolve(parsed);
} catch {
finish(new Error('安全执行代理返回了非法响应'));
}
});
});
}
}
@@ -0,0 +1,10 @@
export const SECURITY_RULE_CODES = [
'admin_login_failure', 'client_login_failure', 'ssh_auth_failure', 'cmpp_auth_failure',
'cmpp_protocol_abuse', 'http_invalid_api_key', 'http_signature_failure',
'http_replay_attempt', 'http_malicious_scan',
] as const;
export type SecurityRuleCode = typeof SECURITY_RULE_CODES[number];
export const SECURITY_RULE_CODE_SET = new Set<string>(SECURITY_RULE_CODES);
export const SECURITY_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
export const SECURITY_BLOCK_DURATIONS = new Set([600, 3600, 86400, 604800]);
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { SecurityDetectionService } from './security-detection.service';
@ApiTags('security-detection')
@Controller('admin/security-detection')
export class SecurityDetectionController {
constructor(private readonly security: SecurityDetectionService) {}
@Get('overview') overview(@Query('range') range?: string) { return this.security.overview(range); }
@Get('alerts') alerts(@Query() query: Record<string, string>) { return this.security.listAlerts(query); }
@Get('rules') rules() { return this.security.listRules(); }
@Put('rules/:id') @RequireRecentAuthentication() updateRule(@Param('id') id: string, @Body() body: Record<string, unknown>, @CurrentSessionUserId() userId: string) { return this.security.updateRule(id, body, userId); }
@Post('alerts/:id/block') @RequireRecentAuthentication() block(@Param('id') id: string, @Body() body: { durationSeconds?: number; reason?: string }, @CurrentSessionUserId() userId: string) { return this.security.block(id, body, userId); }
@Post('alerts/:id/ignore') @RequireRecentAuthentication() ignore(@Param('id') id: string, @Body('reason') reason: string, @CurrentSessionUserId() userId: string) { return this.security.ignore(id, reason ?? '', userId); }
@Get('blocks') blocks() { return this.security.listBlocks(); }
@Post('blocks/:id/unblock') @RequireRecentAuthentication() unblock(@Param('id') id: string, @Body('reason') reason: string, @CurrentSessionUserId() userId: string) { return this.security.unblock(id, reason ?? '', userId); }
@Get('protected-networks') protectedNetworks() { return this.security.listProtectedNetworks(); }
@Post('protected-networks') @RequireRecentAuthentication() addProtectedNetwork(@Body() body: { network?: string; name?: string; reason?: string }, @CurrentSessionUserId() userId: string) { return this.security.addProtectedNetwork(body, userId); }
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SecurityAgentClient } from './security-agent.client';
import { SecurityDetectionController } from './security-detection.controller';
import { SecurityEventController } from './security-event.controller';
import { SecurityDetectionService } from './security-detection.service';
@Module({ controllers: [SecurityDetectionController, SecurityEventController], providers: [SecurityAgentClient, SecurityDetectionService], exports: [SecurityDetectionService] })
export class SecurityDetectionModule {}
@@ -0,0 +1,65 @@
import { ConflictException } from '@nestjs/common';
import { SecurityDetectionService } from './security-detection.service';
function createPrisma() {
const tx = {
$executeRaw: jest.fn(),
securityDetectionEvent: { create: jest.fn(), count: jest.fn() },
securityAlert: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
securityBlock: { create: jest.fn(), update: jest.fn() },
};
const prisma = {
securityDetectionRule: { findUnique: jest.fn(), findMany: jest.fn() },
securityDetectionEvent: { count: jest.fn() },
securityAlert: { findUnique: jest.fn(), update: jest.fn() },
securityBlock: { create: jest.fn(), update: jest.fn() },
securityProtectedNetwork: { findMany: jest.fn().mockResolvedValue([]) },
operationLog: { create: jest.fn() },
$transaction: jest.fn(async (value: unknown) => typeof value === 'function' ? value(tx) : Promise.all(value as Promise<unknown>[])),
};
return { prisma, tx };
}
describe('SecurityDetectionService', () => {
it('keeps a below-threshold event without creating a false alert', async () => {
const { prisma, tx } = createPrisma();
prisma.securityDetectionRule.findUnique.mockResolvedValue({ id: 'rule-1', enabled: true, threshold: 3, windowSeconds: 60, cooldownSeconds: 60, severity: 'high' });
tx.securityDetectionEvent.create.mockResolvedValue({ id: 'event-1' });
tx.securityDetectionEvent.count.mockResolvedValue(2);
const service = new SecurityDetectionService(prisma as never, {} as never);
await expect(service.recordEvent({ eventKey: 'event-key-1', ruleCode: 'http_signature_failure', sourceIp: '203.0.113.5' })).resolves.toEqual({ accepted: true, duplicate: false, alertId: null });
expect(tx.securityAlert.create).not.toHaveBeenCalled();
});
it('refuses built-in protected addresses before calling the privileged agent', async () => {
const { prisma } = createPrisma();
prisma.securityAlert.findUnique.mockResolvedValue({
id: 'alert-1', sourceIp: '127.0.0.1', status: 'open',
rule: { code: 'ssh_auth_failure', defaultBlockSeconds: 600, maximumBlockSeconds: 604800 },
});
const agent = { block: jest.fn(), status: jest.fn() };
const service = new SecurityDetectionService(prisma as never, agent as never);
await expect(service.block('alert-1', { durationSeconds: 600, reason: '隔离测试封禁' }, 'operator-1')).rejects.toBeInstanceOf(ConflictException);
expect(agent.block).not.toHaveBeenCalled();
});
it('maps an admin-login alert to nginx and marks blocked only after readback', async () => {
const { prisma, tx } = createPrisma();
prisma.securityAlert.findUnique.mockResolvedValue({
id: 'alert-1', sourceIp: '203.0.113.8', status: 'open',
rule: { code: 'admin_login_failure', defaultBlockSeconds: 600, maximumBlockSeconds: 604800 },
});
tx.securityAlert.updateMany.mockResolvedValue({ count: 1 });
tx.securityBlock.create.mockResolvedValue({ id: 'block-1' });
tx.securityBlock.update.mockResolvedValue({ id: 'block-1', status: 'blocked' });
tx.securityAlert.update.mockResolvedValue({ id: 'alert-1', status: 'blocked' });
const agent = { block: jest.fn().mockResolvedValue({ ok: true, reference: 'op-1' }), status: jest.fn().mockResolvedValue({ ok: true, blocked: true }) };
const service = new SecurityDetectionService(prisma as never, agent as never);
await expect(service.block('alert-1', { durationSeconds: 600, reason: '确认恶意登录扫描' }, 'operator-1')).resolves.toEqual(expect.objectContaining({ status: 'blocked' }));
expect(agent.block).toHaveBeenCalledWith(expect.objectContaining({ executor: 'nginx_real_ip', sourceIp: '203.0.113.8' }));
expect(agent.status).toHaveBeenCalledWith('203.0.113.8', 'nginx_real_ip');
});
});
@@ -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));
}
@@ -0,0 +1,20 @@
import { UnauthorizedException } from '@nestjs/common';
import { SecurityEventController } from './security-event.controller';
describe('SecurityEventController', () => {
const security = { recordEvent: jest.fn().mockResolvedValue({ accepted: true }) };
const config = { get: jest.fn().mockReturnValue('internal-token-0123456789') };
const controller = new SecurityEventController(security as never, config as never);
beforeEach(() => jest.clearAllMocks());
it('rejects a public event injection without the internal token', () => {
expect(() => controller.record({ ruleCode: 'ssh_auth_failure', sourceIp: '203.0.113.9' }, undefined)).toThrow(UnauthorizedException);
expect(security.recordEvent).not.toHaveBeenCalled();
});
it('accepts a fixed event from an authenticated local producer', async () => {
await expect(controller.record({ ruleCode: 'ssh_auth_failure', sourceIp: '203.0.113.9' }, 'internal-token-0123456789')).resolves.toEqual({ accepted: true });
expect(security.recordEvent).toHaveBeenCalledWith(expect.objectContaining({ ruleCode: 'ssh_auth_failure' }));
});
});
@@ -0,0 +1,18 @@
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'node:crypto';
import { ApiTags } from '@nestjs/swagger';
import { SecurityDetectionService, type SecurityEventInput } from './security-detection.service';
@ApiTags('gateway-security-events')
@Controller('gateway/events/security-detection')
export class SecurityEventController {
constructor(private readonly security: SecurityDetectionService, private readonly config: ConfigService) {}
@Post() record(@Body() body: SecurityEventInput, @Headers('x-security-event-token') supplied?: string) {
const expected = this.config.get<string>('SECURITY_EVENT_TOKEN');
if (!expected || !supplied || !safeEqual(expected, supplied)) throw new UnauthorizedException('安全事件来源认证失败');
return this.security.recordEvent(body);
}
}
function safeEqual(left: string, right: string) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b); }