feat: add Fail2ban security detection console
This commit is contained in:
@@ -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);
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user