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,80 @@
CREATE TABLE "SecurityDetectionRule" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sourceType" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"threshold" INTEGER NOT NULL,
"windowSeconds" INTEGER NOT NULL,
"cooldownSeconds" INTEGER NOT NULL,
"severity" TEXT NOT NULL,
"defaultBlockSeconds" INTEGER NOT NULL,
"maximumBlockSeconds" INTEGER NOT NULL,
"configVersion" INTEGER NOT NULL DEFAULT 1,
"effectiveVersion" INTEGER NOT NULL DEFAULT 0,
"applyStatus" TEXT NOT NULL DEFAULT 'pending',
"lastApplyError" TEXT,
"pendingConfig" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityDetectionRule_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityDetectionEvent" (
"id" TEXT NOT NULL, "eventKey" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
"sourceIp" TEXT NOT NULL, "sourcePort" INTEGER, "accountHash" TEXT,
"path" TEXT, "protocol" TEXT, "resultCode" TEXT, "evidence" JSONB,
"occurredAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SecurityDetectionEvent_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityAlert" (
"id" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
"sourceIp" TEXT NOT NULL, "severity" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'open',
"eventCount" INTEGER NOT NULL DEFAULT 0, "windowStartedAt" TIMESTAMP(3) NOT NULL,
"firstOccurredAt" TIMESTAMP(3) NOT NULL, "lastOccurredAt" TIMESTAMP(3) NOT NULL,
"acknowledgedAt" TIMESTAMP(3), "acknowledgedById" TEXT, "ignoredAt" TIMESTAMP(3),
"ignoredById" TEXT, "ignoreReason" TEXT, "blockId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityAlert_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityBlock" (
"id" TEXT NOT NULL, "operationKey" TEXT NOT NULL, "alertId" TEXT, "sourceIp" TEXT NOT NULL,
"executor" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'requested', "durationSeconds" INTEGER NOT NULL,
"reason" TEXT NOT NULL, "requestedById" TEXT NOT NULL, "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"appliedAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "releasedAt" TIMESTAMP(3), "releasedById" TEXT,
"executorReference" TEXT, "lastError" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SecurityBlock_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityProtectedNetwork" (
"id" TEXT NOT NULL, "network" TEXT NOT NULL, "name" TEXT NOT NULL, "reason" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true, "createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityProtectedNetwork_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SecurityDetectionRule_code_key" ON "SecurityDetectionRule"("code");
CREATE INDEX "SecurityDetectionRule_enabled_sourceType_idx" ON "SecurityDetectionRule"("enabled", "sourceType");
CREATE UNIQUE INDEX "SecurityDetectionEvent_eventKey_key" ON "SecurityDetectionEvent"("eventKey");
CREATE INDEX "SecurityDetectionEvent_ruleId_occurredAt_idx" ON "SecurityDetectionEvent"("ruleId", "occurredAt");
CREATE INDEX "SecurityDetectionEvent_sourceIp_occurredAt_idx" ON "SecurityDetectionEvent"("sourceIp", "occurredAt");
CREATE UNIQUE INDEX "SecurityAlert_fingerprint_key" ON "SecurityAlert"("fingerprint");
CREATE INDEX "SecurityAlert_status_severity_lastOccurredAt_idx" ON "SecurityAlert"("status", "severity", "lastOccurredAt");
CREATE INDEX "SecurityAlert_sourceIp_status_lastOccurredAt_idx" ON "SecurityAlert"("sourceIp", "status", "lastOccurredAt");
CREATE INDEX "SecurityAlert_ruleId_status_lastOccurredAt_idx" ON "SecurityAlert"("ruleId", "status", "lastOccurredAt");
CREATE UNIQUE INDEX "SecurityBlock_operationKey_key" ON "SecurityBlock"("operationKey");
CREATE INDEX "SecurityBlock_status_expiresAt_idx" ON "SecurityBlock"("status", "expiresAt");
CREATE INDEX "SecurityBlock_sourceIp_status_requestedAt_idx" ON "SecurityBlock"("sourceIp", "status", "requestedAt");
CREATE INDEX "SecurityBlock_alertId_idx" ON "SecurityBlock"("alertId");
CREATE UNIQUE INDEX "SecurityProtectedNetwork_network_key" ON "SecurityProtectedNetwork"("network");
CREATE INDEX "SecurityProtectedNetwork_enabled_createdAt_idx" ON "SecurityProtectedNetwork"("enabled", "createdAt");
ALTER TABLE "SecurityDetectionEvent" ADD CONSTRAINT "SecurityDetectionEvent_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SecurityAlert" ADD CONSTRAINT "SecurityAlert_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO "SecurityDetectionRule" ("id", "code", "name", "sourceType", "threshold", "windowSeconds", "cooldownSeconds", "severity", "defaultBlockSeconds", "maximumBlockSeconds", "configVersion", "effectiveVersion", "applyStatus", "updatedAt") VALUES
('sec_admin_login', 'admin_login_failure', '运营端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_client_login', 'client_login_failure', '客户端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_ssh_auth', 'ssh_auth_failure', 'SSH认证失败', 'fail2ban', 6, 600, 1800, 'high', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_cmpp_auth', 'cmpp_auth_failure', 'CMPP认证失败', 'gateway', 5, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_cmpp_abuse', 'cmpp_protocol_abuse', 'CMPP协议滥用', 'gateway', 20, 60, 900, 'critical', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_key', 'http_invalid_api_key', 'HTTP错误密钥', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_sign', 'http_signature_failure', 'HTTP签名错误', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_replay', 'http_replay_attempt', 'HTTP重放尝试', 'application', 3, 600, 1800, 'critical', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_scan', 'http_malicious_scan', 'HTTP恶意扫描', 'fail2ban', 20, 60, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP);
+111
View File
@@ -2321,3 +2321,114 @@ model GatewayDownstreamRecoveryStatus {
@@index([state, updatedAt])
@@index([nextRetryAt])
}
model SecurityDetectionRule {
id String @id @default(cuid())
code String @unique
name String
sourceType String
enabled Boolean @default(true)
threshold Int
windowSeconds Int
cooldownSeconds Int
severity String
defaultBlockSeconds Int
maximumBlockSeconds Int
configVersion Int @default(1)
effectiveVersion Int @default(0)
applyStatus String @default("pending")
lastApplyError String?
pendingConfig Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events SecurityDetectionEvent[]
alerts SecurityAlert[]
@@index([enabled, sourceType])
}
model SecurityDetectionEvent {
id String @id @default(cuid())
eventKey String @unique
ruleId String
sourceIp String
sourcePort Int?
accountHash String?
path String?
protocol String?
resultCode String?
evidence Json?
occurredAt DateTime
createdAt DateTime @default(now())
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
@@index([ruleId, occurredAt])
@@index([sourceIp, occurredAt])
}
model SecurityAlert {
id String @id @default(cuid())
fingerprint String @unique
ruleId String
sourceIp String
severity String
status String @default("open")
eventCount Int @default(0)
windowStartedAt DateTime
firstOccurredAt DateTime
lastOccurredAt DateTime
acknowledgedAt DateTime?
acknowledgedById String?
ignoredAt DateTime?
ignoredById String?
ignoreReason String?
blockId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
@@index([status, severity, lastOccurredAt])
@@index([sourceIp, status, lastOccurredAt])
@@index([ruleId, status, lastOccurredAt])
}
model SecurityBlock {
id String @id @default(cuid())
operationKey String @unique
alertId String?
sourceIp String
executor String
status String @default("requested")
durationSeconds Int
reason String
requestedById String
requestedAt DateTime @default(now())
appliedAt DateTime?
expiresAt DateTime?
releasedAt DateTime?
releasedById String?
executorReference String?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, expiresAt])
@@index([sourceIp, status, requestedAt])
@@index([alertId])
}
model SecurityProtectedNetwork {
id String @id @default(cuid())
network String @unique
name String
reason String
enabled Boolean @default(true)
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([enabled, createdAt])
}
+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],