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
+2
View File
@@ -25,6 +25,7 @@ import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module';
import { UsersModule } from './users/users.module';
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
import { SecurityDetectionModule } from './security-detection/security-detection.module';
@Module({
imports: [
@@ -53,6 +54,7 @@ import { SignatureRetirementModule } from './signature-retirement/signature-reti
InfrastructureMonitoringModule,
OpenApiModule,
SignatureRetirementModule,
SecurityDetectionModule,
],
controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
+30 -3
View File
@@ -7,6 +7,8 @@ import type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { requestContext } from '../common/request-context';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
type CookieResponse = {
cookie(name: string, value: string, options: Record<string, unknown>): void;
@@ -16,7 +18,7 @@ type CookieResponse = {
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
@Get('admin/auth/captcha')
adminCaptcha() {
@@ -25,7 +27,14 @@ export class AuthController {
@Post('admin/auth/login')
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
let result: Awaited<ReturnType<AuthService['login']>>;
try {
result = await this.auth.login(body, 'admin');
} catch (error) {
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
throw error;
}
return this.finishLogin(result, request, response);
}
@Get('client/auth/captcha')
@@ -35,7 +44,14 @@ export class AuthController {
@Post('client/auth/login')
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
let result: Awaited<ReturnType<AuthService['login']>>;
try {
result = await this.auth.login(body, 'client');
} catch (error) {
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
throw error;
}
return this.finishLogin(result, request, response);
}
@Get(['admin/auth/session', 'client/auth/session'])
@@ -121,6 +137,17 @@ export class AuthController {
return publicResult;
}
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
return this.security.recordEvent({
ruleCode,
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
account,
protocol: 'http',
path: ruleCode === 'admin_login_failure' ? '/admin/auth/login' : '/client/auth/login',
evidence: { userAgent: request.header('user-agent')?.slice(0, 256) },
});
}
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
+2 -1
View File
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RecentAuthenticationGuard } from './recent-authentication.guard';
import { SessionService } from './session.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({
imports: [UsersModule],
imports: [UsersModule, SecurityDetectionModule],
controllers: [AuthController],
providers: [
AuthService,
+4 -1
View File
@@ -8,7 +8,10 @@ export class RequestContextMiddleware implements NestMiddleware {
use(request: RequestLike, _response: unknown, next: () => void) {
const forwarded = request.headers['x-forwarded-for'];
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
const remoteAddress = request.socket?.remoteAddress?.trim().replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
// 仅可信反向代理可以声明客户端地址,防止攻击者伪造 X-Forwarded-For 绕过保护名单或嫁祸他人。
const ipAddress = (remoteAddress && trustedProxies.has(remoteAddress) ? firstForwarded : remoteAddress)?.trim().replace(/^::ffff:/, '');
requestContext.run({ ipAddress }, next);
}
}
+18 -2
View File
@@ -5,12 +5,13 @@ import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis;
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
const nonce = header(request, 'x-nonce');
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
await this.recordFailure('http_signature_failure', request, undefined, 'AUTH_HEADERS_MISSING');
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
}
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'NONCE_INVALID');
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
}
const credential = await this.prisma.httpApiCredential.findUnique({
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
});
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
await this.recordFailure('http_invalid_api_key', request, accessKey, 'CREDENTIAL_INVALID');
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
}
const config = credential.application.httpConfig;
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
}
const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
}
const sourceIp = requestIp(request);
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
}
const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
}
const second = Math.floor(Date.now() / 1000);
@@ -81,6 +88,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis;
}
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
const sourceIp = requestIp(request);
if (!sourceIp) return;
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
}
}
function header(request: OpenApiRequestLike, name: string) {
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, '');
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
}
function ipMatches(ip: string, rule: string) {
+2 -1
View File
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({
imports: [PrismaModule, forwardRef(() => SendChainModule)],
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService],
@@ -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); }
@@ -11,7 +11,7 @@ describe('GatewayEventsController protocol logging', () => {
const protocolLogs = {
record: jest.fn(),
};
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never);
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never, { recordEvent: jest.fn() } as never);
beforeEach(() => {
jest.clearAllMocks();
@@ -18,6 +18,7 @@ import { SendChainService } from './send-chain.service';
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
import { SmsConfigService } from '../sms-config/sms-config.service';
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@ApiTags('gateway-events')
@Controller('gateway/events')
@@ -26,6 +27,7 @@ export class GatewayEventsController {
private readonly sendChain: SendChainService,
private readonly smsConfig: SmsConfigService,
private readonly protocolLogs: ProtocolLogsService,
private readonly security: SecurityDetectionService,
) {}
@Post('submit-result')
@@ -81,8 +83,13 @@ export class GatewayEventsController {
}
@Post('inbound/authenticate')
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
async authenticateInbound(@Body() body: GatewayInboundAuthDto) {
try {
return await this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
} catch (error) {
if (body.remoteIp) await this.security.recordEvent({ ruleCode: 'cmpp_auth_failure', sourceIp: body.remoteIp, account: body.account, protocol: body.version ?? 'cmpp', resultCode: error instanceof Error ? error.name : 'AUTH_FAILED' }).catch(() => undefined);
throw error;
}
}
@Post('inbound/submit')
+2 -1
View File
@@ -9,9 +9,10 @@ import { AdminSendChainController } from './admin-send-chain.controller';
import { ClientSendChainController } from './client-send-chain.controller';
import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule],
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService],
exports: [SendChainService],