diff --git a/api/prisma/migrations/20260814150000_add_security_detection/migration.sql b/api/prisma/migrations/20260814150000_add_security_detection/migration.sql new file mode 100644 index 0000000..e4828b9 --- /dev/null +++ b/api/prisma/migrations/20260814150000_add_security_detection/migration.sql @@ -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); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e9d8ee7..086e13a 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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]) +} diff --git a/api/src/app.module.ts b/api/src/app.module.ts index dddb960..b390e82 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -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], diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 4861fc7..b95d66d 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -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): 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>; + 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>; + 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) { 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 }, diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index d427ebe..a34c272 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -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, diff --git a/api/src/common/request-context.middleware.ts b/api/src/common/request-context.middleware.ts index 4b1fdfa..bb0bfa0 100644 --- a/api/src/common/request-context.middleware.ts +++ b/api/src/common/request-context.middleware.ts @@ -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); } } diff --git a/api/src/open-api/open-api-auth.guard.ts b/api/src/open-api/open-api-auth.guard.ts index daf4e58..2858321 100644 --- a/api/src/open-api/open-api-auth.guard.ts +++ b/api/src/open-api/open-api-auth.guard.ts @@ -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(); @@ -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) { diff --git a/api/src/open-api/open-api.module.ts b/api/src/open-api/open-api.module.ts index 7da2733..42c9865 100644 --- a/api/src/open-api/open-api.module.ts +++ b/api/src/open-api/open-api.module.ts @@ -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], diff --git a/api/src/security-detection/security-agent.client.ts b/api/src/security-detection/security-agent.client.ts new file mode 100644 index 0000000..f33eecb --- /dev/null +++ b/api/src/security-detection/security-agent.client.ts @@ -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>) { + return this.call({ action: 'apply_rules', version, rules }); + } + + private call(payload: Record): Promise { + const socketPath = this.config.get('SECURITY_AGENT_SOCKET') ?? '/run/cmpp-security-agent/agent.sock'; + const timeoutMs = Number(this.config.get('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('安全执行代理返回了非法响应')); + } + }); + }); + } +} diff --git a/api/src/security-detection/security-detection.constants.ts b/api/src/security-detection/security-detection.constants.ts new file mode 100644 index 0000000..e4422b9 --- /dev/null +++ b/api/src/security-detection/security-detection.constants.ts @@ -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(SECURITY_RULE_CODES); +export const SECURITY_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']); +export const SECURITY_BLOCK_DURATIONS = new Set([600, 3600, 86400, 604800]); diff --git a/api/src/security-detection/security-detection.controller.ts b/api/src/security-detection/security-detection.controller.ts new file mode 100644 index 0000000..1541e31 --- /dev/null +++ b/api/src/security-detection/security-detection.controller.ts @@ -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) { return this.security.listAlerts(query); } + @Get('rules') rules() { return this.security.listRules(); } + @Put('rules/:id') @RequireRecentAuthentication() updateRule(@Param('id') id: string, @Body() body: Record, @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); } +} diff --git a/api/src/security-detection/security-detection.module.ts b/api/src/security-detection/security-detection.module.ts new file mode 100644 index 0000000..c13fd19 --- /dev/null +++ b/api/src/security-detection/security-detection.module.ts @@ -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 {} diff --git a/api/src/security-detection/security-detection.service.spec.ts b/api/src/security-detection/security-detection.service.spec.ts new file mode 100644 index 0000000..6534b64 --- /dev/null +++ b/api/src/security-detection/security-detection.service.spec.ts @@ -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[])), + }; + 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'); + }); +}); diff --git a/api/src/security-detection/security-detection.service.ts b/api/src/security-detection/security-detection.service.ts new file mode 100644 index 0000000..4349d43 --- /dev/null +++ b/api/src/security-detection/security-detection.service.ts @@ -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; 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, 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, ['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) { 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, 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)); +} diff --git a/api/src/security-detection/security-event.controller.spec.ts b/api/src/security-detection/security-event.controller.spec.ts new file mode 100644 index 0000000..9a0e3a2 --- /dev/null +++ b/api/src/security-detection/security-event.controller.spec.ts @@ -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' })); + }); +}); diff --git a/api/src/security-detection/security-event.controller.ts b/api/src/security-detection/security-event.controller.ts new file mode 100644 index 0000000..e3b7013 --- /dev/null +++ b/api/src/security-detection/security-event.controller.ts @@ -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('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); } diff --git a/api/src/send-chain/gateway-events.controller.spec.ts b/api/src/send-chain/gateway-events.controller.spec.ts index a67f1ce..eaa2a8b 100644 --- a/api/src/send-chain/gateway-events.controller.spec.ts +++ b/api/src/send-chain/gateway-events.controller.spec.ts @@ -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(); diff --git a/api/src/send-chain/gateway-events.controller.ts b/api/src/send-chain/gateway-events.controller.ts index 60b867b..117c52f 100644 --- a/api/src/send-chain/gateway-events.controller.ts +++ b/api/src/send-chain/gateway-events.controller.ts @@ -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') diff --git a/api/src/send-chain/send-chain.module.ts b/api/src/send-chain/send-chain.module.ts index c4ba168..49bb076 100644 --- a/api/src/send-chain/send-chain.module.ts +++ b/api/src/send-chain/send-chain.module.ts @@ -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], diff --git a/deploy/security/cmpp-http-scan.conf b/deploy/security/cmpp-http-scan.conf new file mode 100644 index 0000000..a5df6ab --- /dev/null +++ b/deploy/security/cmpp-http-scan.conf @@ -0,0 +1,3 @@ +[Definition] +failregex = ^ .* "(?:GET|POST|HEAD) /(?:\.env|\.git|wp-admin|wp-login\.php|phpmyadmin|vendor/phpunit|actuator|cgi-bin)(?:[/? ][^\"]*)?" (?:400|403|404) .*$ +ignoreregex = diff --git a/deploy/security/cmpp-report-only.conf b/deploy/security/cmpp-report-only.conf new file mode 100644 index 0000000..2c63737 --- /dev/null +++ b/deploy/security/cmpp-report-only.conf @@ -0,0 +1,8 @@ +[Definition] +# Detection remains report-only. The fixed action is installed by the deployment +# script and can only forward Fail2ban's matched IP/jail values to the local collector. +actionstart = +actionstop = +actioncheck = +actionban = /opt/cmpp-platform/current/bin/cmpp-security-agent report +actionunban = diff --git a/deploy/security/cmpp-security-agent.service b/deploy/security/cmpp-security-agent.service new file mode 100644 index 0000000..30181e5 --- /dev/null +++ b/deploy/security/cmpp-security-agent.service @@ -0,0 +1,25 @@ +[Unit] +Description=CMPP restricted security execution agent +After=network.target fail2ban.service nftables.service + +[Service] +Type=simple +User=root +Group=cmpp-security +EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env +ExecStart=/opt/cmpp-platform/current/bin/cmpp-security-agent +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/run/cmpp-security-agent /var/lib/cmpp-security-agent /etc/nginx/snippets /etc/fail2ban/jail.d +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +CapabilityBoundingSet=CAP_NET_ADMIN CAP_DAC_OVERRIDE CAP_KILL +AmbientCapabilities=CAP_NET_ADMIN CAP_DAC_OVERRIDE +LockPersonality=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/codebase-modularization-roadmap.md b/docs/codebase-modularization-roadmap.md index a601789..aa124e1 100644 --- a/docs/codebase-modularization-roadmap.md +++ b/docs/codebase-modularization-roadmap.md @@ -1138,3 +1138,8 @@ global,不能错误归入client。`client-signature-*`、发送页、企业认 ``` 每次开始新拆分版本时,在本路线图基础上另写该版本的短实施计划,不直接把路线图当作可执行变更清单。 +## 安全检测领域边界补充(2026-08-14) + +- `api/src/security-detection/` 是安全事件、聚合告警、规则版本和人工封禁编排的唯一业务边界;登录、OpenAPI 和 Gateway 只上报固定类型的结构化事件,不复制聚合或封禁逻辑。 +- `gateway/cmd/security-agent/` 是最小特权执行边界,不依赖 NestJS Service,不接受任意命令、路径、jail、action 或 shell 参数。该二进制与 `deploy/security/`、`tools/security/install-security-agent.sh` 作为同一发布单元评审。 +- 前端 `src/apps/admin/security-detection/` 通过 `src/api/admin/security-detection.api.ts` 访问稳定门面,不直接访问 Fail2ban、Nginx、nftables 或安全代理。 diff --git a/docs/fail2ban-assisted-blocking-design-20260814.md b/docs/fail2ban-assisted-blocking-design-20260814.md new file mode 100644 index 0000000..fa94e4f --- /dev/null +++ b/docs/fail2ban-assisted-blocking-design-20260814.md @@ -0,0 +1,378 @@ +# Fail2ban 安全检测与人工封禁平台设计方案 + +> 版本:V1.0(需求评审稿)
+> 日期:2026-08-14
+> 范围:运营端自研 UI、安全检测、阈值配置、告警处置、人工封禁与解封
+> 边界:第一版不自动封禁、不开放任意 Fail2ban/防火墙命令、不在线编辑正则表达式 + +## 1. 建设目标 + +在运营端“安全控制”下建设自研安全检测面板,统一接收 SSH、运营端登录、客户端登录、CMPP 入站和 HTTP API 的异常行为,按照可配置规则聚合为安全告警。平台管理员查看证据后,可人工执行固定时长封禁、解封、忽略或加入保护名单。 + +第一版采用“检测与执行分离”原则: + +1. Fail2ban 和应用侧检测器只生成事件及告警,不自动修改防火墙。 +2. 人工点击“封禁”后,后端重新校验告警、真实 IP、保护名单、执行目标和操作权限。 +3. 只有受限安全代理可以执行系统级封禁;NestJS 不直接获得 root、通用 sudo 或任意 shell 能力。 +4. PostgreSQL 保存配置、事件、告警、封禁事实和操作审计;页面不得使用 mock、静态数据或 localStorage 伪造状态。 + +## 2. 第一版范围 + +### 2.1 检测类型 + +| 规则编码 | 检测对象 | 事件来源 | 第一版默认建议值 | 默认风险 | +| --- | --- | --- | --- | --- | +| `admin_login_failure` | 运营端账号登录失败 | NestJS 结构化安全事件 | 同一 IP 10 分钟 8 次 | 中 | +| `client_login_failure` | 客户端账号登录失败 | NestJS 结构化安全事件 | 同一 IP 10 分钟 8 次 | 中 | +| `ssh_auth_failure` | SSH 12022 认证失败 | journald + Fail2ban filter | 同一 IP 10 分钟 6 次 | 高 | +| `cmpp_auth_failure` | CMPP 17890 未知账号、认证失败或不允许 IP | Gateway 结构化安全事件 | 同一 IP 5 分钟 5 次 | 高 | +| `cmpp_protocol_abuse` | 非法协议包、异常版本或高频无效连接 | Gateway 结构化安全事件 | 同一 IP 1 分钟 20 次 | 高 | +| `http_invalid_api_key` | HTTP API 错误或未知访问密钥 | NestJS HTTP API 鉴权事件 | 同一 IP 5 分钟 10 次 | 高 | +| `http_signature_failure` | HTTP API 签名缺失、格式错误或验签失败 | NestJS HTTP API 验签事件 | 同一 IP 5 分钟 10 次 | 高 | +| `http_replay_attempt` | nonce、时间戳或幂等凭证重放 | NestJS HTTP API 验签事件 | 同一 IP 10 分钟 3 次 | 严重 | +| `http_malicious_scan` | 扫描敏感路径、跨路径高频 404、明显漏洞探测 | Nginx 结构化日志 + Fail2ban filter | 同一 IP 1 分钟 20 次且涉及至少 8 个不同路径 | 高 | + +表中数值只是首次安装默认值,必须保存到真实数据库并可在运营端配置。业务 400、正常参数校验失败、合法客户偶发时钟偏差、普通 404、供应商连接失败不得直接归类为攻击。 + +### 2.2 处置能力 + +- 查看检测总览、趋势、来源、风险等级、Top IP 和待处置告警。 +- 查看告警详情、脱敏日志证据、规则快照和历史处置。 +- 人工封禁固定时长:10 分钟、1 小时、24 小时、7 天。 +- 人工解封、忽略告警、加入保护名单。 +- 查看当前真实封禁、执行目标、到期时间和同步状态。 +- 配置检测规则阈值、时间窗口、冷却时间、风险等级和启停状态。 +- 查看 Fail2ban、事件采集器、安全代理、规则版本及封禁执行器健康状态。 + +### 2.3 第一版不做 + +- 自动封禁。 +- 永久封禁或前端输入任意封禁秒数。 +- 在线编辑 Fail2ban regex、日志路径、action、iptables/nftables 或 Nginx 配置文本。 +- 从页面执行任意 shell、`fail2ban-client`、`systemctl` 或防火墙命令。 +- 自动将外部威胁情报加入封禁。 +- 删除原始告警和处置历史。 + +## 3. 总体架构 + +```text +sshd journal ── Fail2ban 检测 jail ─┐ +Nginx access/error ─ Fail2ban jail ─┤ +NestJS 登录/HTTP 鉴权安全事件 ──────┤ +Go Gateway CMPP 安全事件 ───────────┤ + ↓ + Security Event Collector + ↓ + PostgreSQL 事件、规则、告警、封禁 + ↓ + NestJS 运营端安全 API + ↓ + 自研运营端 UI + ↓ 人工确认 + Root Security Agent (Unix Socket) + ↓ ↓ + nftables/manual jail Nginx real-IP deny +``` + +### 3.1 组件职责 + +#### Fail2ban + +- 读取 sshd 和 Nginx 等系统日志。 +- 使用固定、随版本发布的 filter 识别失败行为。 +- 根据已生效规则计算窗口与阈值。 +- 触发“告警事件 action”,但不直接封禁。 +- 不作为平台告警历史和真实封禁状态的唯一数据源。 + +#### 应用侧安全事件 + +NestJS 和 Gateway 对其掌握真实业务语义的事件直接产生结构化安全事件,避免只靠文本正则猜测: + +- 登录入口、失败类型和真实请求 IP。 +- HTTP API 密钥查找失败、签名失败、重放拒绝。 +- CMPP 账号、AuthenticatorSource 校验、IP 白名单和协议异常。 + +任何安全事件不得记录明文密码、完整 API 密钥、签名密钥、AuthenticatorSource 或完整短信内容;账号、密钥标识只保存脱敏值或不可逆指纹。 + +#### Security Event Collector + +- 通过受限 Unix Socket 或 root 写入、collector 只读的事件目录接收 Fail2ban 事件。 +- 校验事件版本、规则编码、IP、时间和来源。 +- 写入 PostgreSQL,并依据数据库规则做幂等聚合。 +- 维护最后事件时间、丢弃数量和解析失败指标。 + +#### Root Security Agent + +- 独立于 NestJS,以最小 root 权限运行。 +- 仅监听本机 Unix Socket,不监听 TCP 公网端口。 +- 只接受固定 JSON 协议:`block`、`unblock`、`status`、`apply_rule_version`。 +- 对规则编码、执行器、IPv4/IPv6、时长和幂等键做白名单校验。 +- 使用无 shell 参数数组或原生库执行,不拼接命令。 +- 原子生成平台专属配置文件,校验后才 reload;失败保留旧版本。 + +#### NestJS + +- 读取真实 PostgreSQL 告警和配置。 +- 执行 RBAC、近期重新认证、保护名单和状态校验。 +- 创建封禁操作记录并调用本地安全代理。 +- 根据代理回读结果确认真实封禁状态。 +- 不以 root 运行,不直接写 `/etc/fail2ban/*` 或防火墙。 + +## 4. Cloudflare 与执行器选择 + +恢复 `CF-Connecting-IP` 只能让 Nginx/应用识别访客真实 IP,并不会改变到达服务器的 TCP 源地址。对 `sms.lisglo.com` 的 Cloudflare 橙云流量,nftables 封禁访客真实 IP 无效,误封 Cloudflare 节点反而可能中断全站。 + +第一版按入口固定执行器: + +| 入口 | 网络形态 | 检测 IP | 人工封禁执行器 | +| --- | --- | --- | --- | +| 运营端、客户端 | Cloudflare 橙云 | 经过可信 Cloudflare 网段恢复的真实 IP | Nginx real-IP deny;未来可扩展 Cloudflare API | +| `api.lisglo.com` | 灰云直连 | TCP 源 IP | nftables/manual jail | +| SSH 12022 | 公网直连 | TCP 源 IP | nftables/manual jail | +| CMPP 17890 | 公网直连 | Gateway TCP 远端 IP | nftables/manual jail | + +只有当请求 TCP 来源属于定期同步的 Cloudflare 官方网段时,才信任 `CF-Connecting-IP`。客户端直连源站时提供的同名 Header 必须忽略。配置上线前必须用真实请求证明日志、事件和页面展示 IP 一致。 + +## 5. 可配置规则 + +### 5.1 可配置字段 + +- 启用状态 `enabled`。 +- 统计窗口 `windowSeconds`。 +- 触发阈值 `threshold`。 +- 不同目标数量阈值 `distinctTargetThreshold`,仅恶意扫描等规则使用。 +- 告警冷却时间 `cooldownSeconds`。 +- 风险等级 `severity`。 +- 聚合维度,只能从规则预置集合选择,例如 `ip`、`ip+accountFingerprint`。 +- 默认封禁时长和允许的最大封禁时长。 +- 是否允许人工封禁;只读检测规则可以关闭封禁按钮。 + +### 5.2 不可由页面配置的字段 + +- filter 正则表达式。 +- 日志文件路径和 journal unit。 +- shell 命令、Fail2ban action、nftables 表/链。 +- Unix Socket 路径、systemd 服务名。 +- Cloudflare 可信网段来源。 +- 规则到执行器的映射。 + +这些内容属于发布资产,必须通过代码评审、自动化测试和标准部署更新。 + +### 5.3 配置保护 + +- 每类规则设置服务端最小值、最大值和允许枚举;前端约束不能替代后端校验。 +- 阈值修改使用乐观锁版本号,防止多人覆盖。 +- 保存后生成新规则版本,安全代理先语法校验,再原子切换并 reload。 +- reload 失败时数据库状态标记 `apply_failed`,保留旧生效版本,页面明确展示“已保存但未生效”。 +- 每次变更保存操作人、原因、旧值、新值、生效版本、代理回执和时间。 +- 配置修改要求 `security.rule.manage` 权限和近期重新认证。 + +## 6. 数据模型 + +### 6.1 `SecurityDetectionRule` + +- `id`、`code`(唯一)、`name`、`category`、`description`。 +- `enabled`、`windowSeconds`、`threshold`、`distinctTargetThreshold`。 +- `cooldownSeconds`、`severity`、`groupingMode`。 +- `manualBlockAllowed`、`defaultBlockDurationSeconds`、`maxBlockDurationSeconds`。 +- `configVersion`、`effectiveVersion`、`applyStatus`、`lastApplyError`。 +- `updatedById`、`createdAt`、`updatedAt`。 + +### 6.2 `SecurityDetectionEvent` + +- `id`、`eventKey`(唯一幂等键)、`ruleCode`、`sourceType`。 +- `sourceIp`、`accountFingerprint`、`targetFingerprint`、`requestPathNormalized`。 +- `occurredAt`、`receivedAt`、`evidenceSummary`、`metadata`。 +- `collectorInstanceId`、`ruleVersion`。 + +`metadata` 使用后端安全 DTO,仅保存允许字段;禁止保存密钥和完整认证材料。 + +### 6.3 `SecurityAlert` + +- `id`、`alertNo`、`fingerprint`、`ruleId`、`ruleSnapshot`。 +- `sourceIp`、`status`、`severity`。 +- `firstOccurredAt`、`lastOccurredAt`、`eventCount`、`distinctTargetCount`。 +- `cooldownUntil`、`assignedToId`、`handledById`、`handledAt`、`handleReason`。 +- `blockId`、`createdAt`、`updatedAt`。 + +同一规则、IP、聚合维度和窗口桶使用唯一 fingerprint,重复采集只增加计数,不重复创建告警。 + +### 6.4 `SecurityBlock` + +- `id`、`operationKey`(唯一)、`alertId`、`sourceIp`。 +- `executorType`、`executorTarget`、`durationSeconds`。 +- `status`:`requested/applying/blocked/unblock_requested/unblocked/expired/failed`。 +- `startedAt`、`expiresAt`、`verifiedAt`、`errorMessage`。 +- `requestedById`、`requestReason`、`unblockedById`、`unblockReason`。 +- `agentOperationId`、`createdAt`、`updatedAt`。 + +### 6.5 `SecurityProtectedNetwork` + +- IP/CIDR、名称、类型、适用入口、启停状态、来源和备注。 +- 系统内置保护项不可从页面删除,只允许通过受控发布更新。 +- 人工保护项新增、修改和停用均要求重新认证和审计。 + +## 7. 状态机与并发控制 + +### 7.1 告警状态 + +```text +pending ──→ block_requested ──→ blocked ──→ unblocked + │ └──────────→ block_failed + ├──→ ignored + ├──→ whitelisted + └──→ expired +``` + +- `pending` 仅表示达到检测阈值,绝不代表已被防火墙封禁。 +- 封禁按钮通过数据库条件更新原子认领,只有一名操作人能进入 `block_requested`。 +- 代理执行成功后必须回读执行器状态,确认存在真实规则才写 `blocked`。 +- 网络超时导致结果不确定时先查询 `operationKey`,不得盲目重复封禁。 +- 忽略、保护名单和封禁互斥;状态变化后旧页面操作返回 409。 + +### 7.2 封禁到期 + +- 执行器负责真实到期解除;平台定时回读并同步 `expired`。 +- 平台任务只做状态对账,不能仅靠数据库时间把记录标记为已解封。 +- 对账发现执行器缺失、额外规则或到期未解除时生成系统告警。 + +## 8. 后端接口 + +```text +GET /api/admin/security-detection/overview +GET /api/admin/security-detection/trends +GET /api/admin/security-detection/alerts +GET /api/admin/security-detection/alerts/:id +POST /api/admin/security-detection/alerts/:id/block +POST /api/admin/security-detection/alerts/:id/ignore +POST /api/admin/security-detection/alerts/:id/protect +GET /api/admin/security-detection/blocks +POST /api/admin/security-detection/blocks/:id/unblock +GET /api/admin/security-detection/rules +PUT /api/admin/security-detection/rules/:id +GET /api/admin/security-detection/protected-networks +GET /api/admin/security-detection/health +``` + +封禁请求只接受固定时长枚举和原因。IP、规则、入口和执行器全部从告警及服务端映射读取,禁止前端重传或覆盖。 + +## 9. 权限与审计 + +| 权限 | 能力 | +| --- | --- | +| `security.alert.read` | 查看面板、告警和脱敏证据 | +| `security.alert.handle` | 忽略告警、分派和填写处置说明 | +| `security.block.manage` | 人工封禁与解封 | +| `security.rule.manage` | 修改阈值、启停规则和保护名单 | + +封禁、解封、修改规则和保护名单必须要求近期重新认证;所有动作写入 `OperationLog`,记录资源、操作人、IP、原因、旧值、新值、代理回执和最终结果。读取完整证据也应记录访问审计。 + +## 10. 自研 UI 信息架构 + +菜单位置:`安全控制 / 安全检测`。 + +### 10.1 总览 + +- 待处置、高风险、当前真实封禁、24 小时攻击 IP、封禁失败五个指标。 +- 24 小时/7 天检测事件与告警趋势。 +- 按规则类型、入口和风险等级分布。 +- Top 攻击 IP、Top 扫描路径、Top 被尝试账号指纹。 +- Fail2ban、collector、agent、规则版本和执行器健康卡片。 + +### 10.2 告警中心 + +- 按关键词、IP、规则、风险、状态、入口和日期筛选,真实后端分页。 +- 列表展示风险、IP、类型、触发数、不同目标数、首次/最近时间、状态和操作。 +- 详情抽屉展示规则快照、聚合时间线、脱敏证据、关联告警和处置历史。 +- 封禁确认弹窗展示执行器、影响入口、时长、保护名单结果、近期合法访问提示和必填原因。 + +### 10.3 规则配置 + +- 使用平台现有自研 Card、Table、Tag、Modal、Form、Pagination 和图表体系,不嵌入 Fail2ban 第三方面板。 +- 每项配置展示当前生效值、待生效值、最后修改人和应用状态。 +- 数字输入同时展示单位、允许范围和默认建议值。 +- 保存前展示变更对比;应用失败不能显示成功 toast。 + +### 10.4 封禁与保护名单 + +- 独立展示真实封禁状态、执行器、到期时间、来源告警和操作人。 +- 保护名单命中时封禁按钮禁用并说明原因。 +- 不同执行器使用明确标签,避免把 Nginx deny 误称为防火墙封禁。 + +## 11. 检测准确性要求 + +### 11.1 HTTP API + +- 错误密钥:只记录不可逆密钥指纹,不记录完整 Header 或密钥。 +- 签名错误:区分缺失、格式错误、算法不支持、验签失败和时间偏差。 +- 重放:只有 nonce/幂等凭证已被真实使用或时间戳明显重复时计入;正常幂等重试按既有接口语义处理。 +- 恶意扫描:使用标准化路径,不保存 query 中的敏感值;规则需要“次数 + 不同路径数”双阈值,避免单个合法 404 被判攻击。 +- 反向代理真实 IP 必须经过可信代理链验证,禁止直接信任客户端 Header。 + +### 11.2 CMPP + +- 未知账号、错误 AuthenticatorSource、不允许 IP、停用企业/应用和协议异常分别分类。 +- 不记录明文密码、完整 AuthenticatorSource 或平台配置密钥。 +- 正常断线、心跳超时、最大连接数限制和服务重启恢复不计为恶意认证。 + +### 11.3 登录 + +- 账号锁定仍由现有账户安全逻辑负责,安全检测面板不替代账号锁定。 +- 图形验证码错误、账号错误、密码错误和角色入口错误分别保存分类,但页面证据统一脱敏。 +- 一个入口的登录失败不得清理另一个入口的有效会话。 + +## 12. 保留与隐私 + +- 原始检测事件默认在线保留 30 天,聚合告警、封禁记录和操作审计默认保留 180 天;最终期限在上线前由安全和运营确认。 +- 清理使用小批量、可恢复任务;不得删除仍关联活动封禁、未完成处置或审计保留期内的数据。 +- 页面和导出默认脱敏账号、路径参数、User-Agent 中的可识别信息。 +- 第一版不提供原始日志全文导出。 + +## 13. 可用性与降级 + +- Fail2ban 不可用:页面显示检测源异常,已有告警仍可查看;相关来源不允许宣称“无攻击”。 +- Collector 不可用:健康状态告警并记录事件积压;恢复后按事件键幂等补录。 +- Security Agent 不可用:封禁按钮返回明确失败,不修改告警为已封禁。 +- PostgreSQL 不可用:不允许执行无法审计的封禁操作。 +- 规则应用失败:继续使用上一生效版本,页面显示失败版本与原因。 +- Nginx 或 nftables 回读不一致:封禁状态标记异常并产生系统告警。 + +## 14. 部署前置条件 + +1. 将 `cmpp-api.service` 改为专用非 root 用户并完成文件、日志、MinIO/local storage 权限回归。 +2. 安装 Fail2ban,固定版本并使用 nftables 兼容 action。 +3. 为 Nginx、sshd、Gateway 和 NestJS 建立结构化、脱敏且可测试的事件格式。 +4. 验证 Cloudflare 可信 IP 网段、`real_ip_header` 和源站绕过防护。 +5. 建立 root security agent、Unix Socket 权限、systemd 加固和固定协议。 +6. 发布前备份 PostgreSQL、运行源码、环境文件、Fail2ban/Nginx 平台生成配置和 nftables 当前规则。 + +## 15. 分阶段实施建议 + +### 阶段 A:检测与只读面板 + +- 数据模型、默认规则、结构化事件、collector、Fail2ban alert-only jail。 +- 总览、告警列表、详情、规则只读展示和健康状态。 +- 使用真实日志、真实 PostgreSQL 和真实接口验收。 + +### 阶段 B:阈值配置 + +- 规则编辑、版本、后端边界、受控配置编译、校验、原子应用和回滚。 +- 配置变更对比、近期认证和审计。 + +### 阶段 C:人工封禁 + +- Security Agent、执行器、固定时长封禁、解封、保护名单、幂等和状态对账。 +- Cloudflare/Nginx 与直连/nftables 分入口验收。 + +阶段 A、B、C 可以作为同一第一版需求连续交付,但验收必须逐阶段通过,不能为了展示按钮而跳过权限隔离和真实执行验证。 + +## 16. 第一版完成标准 + +- 九类检测全部有真实事件来源、默认规则、可配置阈值和原子测试。 +- 自研 UI 通过真实 API 展示面板、告警、规则、封禁和健康数据。 +- HTTP 错误密钥、签名错误、重放和恶意扫描均纳入第一版。 +- NestJS 非 root,无法执行任意系统命令或编辑 Fail2ban 配置。 +- 人工封禁按入口选择正确执行器,Cloudflare 场景不使用无效的访客 IP nftables 封禁。 +- 保护名单、重新认证、权限、幂等、并发和操作审计全部通过。 +- 只有回读执行器确认真实生效后,页面才显示“已封禁”。 +- 不发送短信、不修改通道账号/密码/启停状态、企业余额或客户连接。 diff --git a/docs/fail2ban-assisted-blocking-test-cases-20260814.md b/docs/fail2ban-assisted-blocking-test-cases-20260814.md new file mode 100644 index 0000000..25b0948 --- /dev/null +++ b/docs/fail2ban-assisted-blocking-test-cases-20260814.md @@ -0,0 +1,174 @@ +# Fail2ban 安全检测与人工封禁第一版测试用例 + +> 版本:V1.0(设计评审用例)
+> 日期:2026-08-14
+> 关联设计:`docs/fail2ban-assisted-blocking-design-20260814.md`
+> 原则:真实后端、真实 PostgreSQL、真实日志与真实执行器;禁止 mock、静态数据和 localStorage 作为验收证据 + +## 1. 测试边界 + +- 自动化测试使用隔离的 Fail2ban/Nginx/nftables namespace 或测试节点,不得封禁测试执行机、预生产运维 IP、Cloudflare 节点或真实客户 IP。 +- 预生产验收优先使用文档保留测试 IP 和短时封禁;所有人工封禁必须先确认回滚路径。 +- 不发送、补发或重投短信,不修改通道账号、密码、启停状态、企业余额或客户连接。 +- HTTP 密钥、签名、CMPP AuthenticatorSource、账号和日志证据必须脱敏。 + +## 2. 规则配置 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-RULE-001 | P0 | 查询规则列表 | 返回九类第一版规则;数据来自 PostgreSQL;展示当前配置版本和生效版本 | +| TC-F2B-RULE-002 | P0 | 修改规则阈值、窗口、冷却时间和风险等级并保存 | 写入真实数据库,生成新版本,代理校验并应用成功;回读值一致;写操作日志 | +| TC-F2B-RULE-003 | P0 | 提交小于最小值、大于最大值、零、负数、小数、非数字或非法枚举 | 后端逐项拒绝,不生成规则版本,不依赖前端校验兜底 | +| TC-F2B-RULE-004 | P0 | 两名管理员基于同一旧版本并发保存不同阈值 | 仅一方成功;另一方返回版本冲突,不覆盖新配置 | +| TC-F2B-RULE-005 | P0 | 让新配置语法校验或 reload 失败 | 数据库标记 `apply_failed`;上一生效版本继续工作;页面不得提示已生效 | +| TC-F2B-RULE-006 | P1 | 停用一条规则后持续产生匹配日志 | 停用后不创建新告警;已有告警和历史事件保留 | +| TC-F2B-RULE-007 | P1 | 重新启用规则 | 新事件按当前版本统计,不错误合并停用期间日志 | +| TC-F2B-RULE-008 | P0 | 尝试通过接口提交 regex、日志路径、shell、action、jail 名或执行器 | DTO 不接受或后端拒绝;配置文件和系统命令不受影响 | +| TC-F2B-RULE-009 | P0 | 无 `security.rule.manage` 权限修改规则 | 返回 403,不写数据库、不调用安全代理 | +| TC-F2B-RULE-010 | P0 | 超过近期认证时间后修改规则 | 要求重新认证;重新认证成功后才能保存 | + +## 3. 登录与 SSH 检测 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-AUTH-001 | P0 | 同一测试 IP 在窗口内触发达到阈值的运营端密码错误 | 只生成一条聚合告警,计数准确,状态 `pending`,未自动封禁 | +| TC-F2B-AUTH-002 | P0 | 同一测试 IP 在客户端登录达到阈值 | 产生客户端规则告警,不与运营端规则错误合并 | +| TC-F2B-AUTH-003 | P1 | 失败次数低于阈值或分散在窗口外 | 保存检测事件但不产生达到阈值告警 | +| TC-F2B-AUTH-004 | P0 | 同一浏览器已有有效运营会话时,在客户端提交错误登录 | 产生正确检测事件;运营会话不被清除、广播退出或跳转 | +| TC-F2B-AUTH-005 | P0 | 检查事件、告警详情和日志 | 不含明文密码、密码散列、完整账号或验证码答案 | +| TC-F2B-SSH-001 | P0 | 从保留测试 IP 对 12022 触发达到阈值的 SSH 失败 | Fail2ban 检测 jail 产生事件和告警,但 nftables 未自动加入封禁 | +| TC-F2B-SSH-002 | P1 | SSH 登录成功、连接中断或握手超时 | 不错误计入认证失败规则 | +| TC-F2B-SSH-003 | P0 | 重启 Fail2ban/collector 后重放同一事件 | `eventKey` 幂等,不重复增加计数或创建告警 | + +## 4. CMPP 检测 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-CMPP-001 | P0 | 同一测试 IP 使用未知账号达到阈值 | 生成 `cmpp_auth_failure` 告警;保存脱敏账号指纹和真实 TCP IP | +| TC-F2B-CMPP-002 | P0 | 使用错误 AuthenticatorSource 达到阈值 | 认证继续按原协议拒绝;告警分类准确;不保存完整 AuthenticatorSource | +| TC-F2B-CMPP-003 | P0 | 从不允许 IP、停用企业或停用应用发起连接 | 原认证结果不变;事件分类可区分真实拒绝原因 | +| TC-F2B-CMPP-004 | P0 | 高频发送非法包、异常版本或短连接扫描 | 达到协议滥用规则阈值后形成告警,不污染普通认证失败统计 | +| TC-F2B-CMPP-005 | P1 | 正常断线、心跳超时、最大连接数限制或服务重启恢复 | 不作为恶意认证或协议滥用告警 | +| TC-F2B-CMPP-006 | P0 | 检查事件和页面证据 | 不包含平台密码、完整认证材料、短信正文或通道凭据 | + +## 5. HTTP API 检测 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-HTTP-001 | P0 | 同一 IP 使用不存在的 HTTP API 密钥达到阈值 | 生成 `http_invalid_api_key` 告警;仅保存不可逆密钥指纹 | +| TC-F2B-HTTP-002 | P0 | 使用存在但错误的签名达到阈值 | 生成 `http_signature_failure`,不与错误密钥混淆,不保存签名密钥或完整签名 Header | +| TC-F2B-HTTP-003 | P0 | 分别触发签名缺失、格式错误、算法不支持、验签失败和时间偏差 | 内部原因分类准确;页面使用安全文案;敏感细节不泄露给调用方 | +| TC-F2B-HTTP-004 | P0 | 重复使用已消费 nonce/签名请求达到阈值 | 生成 `http_replay_attempt` 严重告警;原接口仍按既有幂等/重放规则拒绝 | +| TC-F2B-HTTP-005 | P0 | 合法客户端按接口幂等协议重试同一业务请求 | 不误判为恶意重放;响应和业务幂等结果保持原语义 | +| TC-F2B-HTTP-006 | P0 | 请求常见敏感路径和漏洞路径,达到次数及不同路径双阈值 | 生成 `http_malicious_scan`;路径已标准化且 query 敏感值不保存 | +| TC-F2B-HTTP-007 | P0 | 对同一不存在业务路径重复请求,仅满足次数、不满足不同路径数 | 不触发恶意扫描告警 | +| TC-F2B-HTTP-008 | P1 | 产生普通业务 400、字段校验失败、合法 401/403 和单次 404 | 不错误计入恶意扫描或错误密钥规则 | +| TC-F2B-HTTP-009 | P0 | 从两个 IP 分别达到一半阈值 | 不跨 IP 错误聚合;每个 IP 独立计算 | +| TC-F2B-HTTP-010 | P0 | 修改 HTTP 规则阈值后继续产生事件 | 新事件使用新生效版本,告警规则快照可追溯当时阈值 | + +## 6. Cloudflare 与真实 IP + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-IP-001 | P0 | 经真实 Cloudflare 回源请求运营端和客户端 | 事件 IP 等于访客真实 IP,TCP 代理 IP 保留在安全元数据中但不作为攻击者 IP | +| TC-F2B-IP-002 | P0 | 直连源站并伪造 `CF-Connecting-IP` | Header 被忽略,事件使用真实 TCP 来源 IP | +| TC-F2B-IP-003 | P0 | 人工封禁 Cloudflare 入口告警 | 使用 Nginx real-IP deny,不调用访客 IP nftables action | +| TC-F2B-IP-004 | P0 | 人工封禁灰云 API、SSH 或 CMPP 告警 | 使用 nftables/manual jail,不修改 Nginx Cloudflare deny 列表 | +| TC-F2B-IP-005 | P0 | 尝试封禁 Cloudflare 官方节点、源站自身、回环、内网、运维和健康检查 IP | 后端和安全代理双重拒绝,记录保护名单命中,不产生真实封禁 | +| TC-F2B-IP-006 | P1 | Cloudflare 官方网段更新 | 使用受控来源更新并审计;旧配置切换原子化;失败保留旧可信网段 | + +## 7. 告警聚合与状态机 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-ALERT-001 | P0 | 同规则、IP、窗口内并发写入多条事件 | 唯一 fingerprint 生效,只创建一条告警,计数无丢失 | +| TC-F2B-ALERT-002 | P0 | 冷却时间内再次达到阈值 | 更新原告警计数和最近时间,不产生告警风暴 | +| TC-F2B-ALERT-003 | P1 | 冷却结束后再次达到阈值 | 按设计创建新告警或新周期,历史告警不覆盖 | +| TC-F2B-ALERT-004 | P0 | 对 `pending` 告警执行忽略 | 状态原子变为 `ignored`,保留事件和原因,封禁按钮不可再执行 | +| TC-F2B-ALERT-005 | P0 | 两名管理员同时对同一告警点击封禁和忽略 | 仅一个状态迁移成功,另一请求返回 409 | +| TC-F2B-ALERT-006 | P1 | 告警超过可处置期限 | 状态变为 `expired`;历史仍可查询;不能用旧告警封禁 | + +## 8. 人工封禁与解封 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-BLOCK-001 | P0 | 有权限管理员对待处理告警选择1小时、填写原因并确认 | 创建唯一操作记录,代理执行正确执行器,回读成功后状态才为 `blocked` | +| TC-F2B-BLOCK-002 | P0 | 前端篡改 IP、jail、action、执行器或时长 | 后端忽略未定义字段或拒绝请求;实际值只来自告警和固定映射 | +| TC-F2B-BLOCK-003 | P0 | 重复点击、网络超时重试或同一 `operationKey` 重放 | 代理和数据库幂等,仅存在一条真实封禁,不延长原期限 | +| TC-F2B-BLOCK-004 | P0 | 代理返回成功但执行器回读不存在规则 | 不标记 `blocked`;状态为失败/异常并生成系统告警 | +| TC-F2B-BLOCK-005 | P0 | Security Agent 停止或 Socket 不可用 | 明确返回封禁失败;告警不伪装已封禁;记录错误和审计 | +| TC-F2B-BLOCK-006 | P0 | PostgreSQL 不可用时点击封禁 | 拒绝无法审计的操作,不调用代理 | +| TC-F2B-BLOCK-007 | P0 | 无 `security.block.manage` 权限或近期认证过期 | 返回403或要求重新认证,不产生操作记录和系统封禁 | +| TC-F2B-BLOCK-008 | P0 | 对已封禁 IP执行解封并填写原因 | 调用原执行器解除,回读确认后变为 `unblocked`,完整记录操作人和原因 | +| TC-F2B-BLOCK-009 | P0 | 封禁自然到期 | 执行器真实解除;对账任务回读后更新 `expired`,不只依赖数据库时间 | +| TC-F2B-BLOCK-010 | P0 | 到期后执行器仍存在规则或平台记录与执行器不一致 | 标记同步异常并告警,不静默显示已解封 | +| TC-F2B-BLOCK-011 | P1 | 规则配置 `manualBlockAllowed=false` | 告警可查看,封禁按钮禁用,直接调用接口同样拒绝 | + +## 9. 保护名单 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-PROTECT-001 | P0 | 查询保护名单 | 返回真实数据库和系统内置项,展示类型、范围和来源 | +| TC-F2B-PROTECT-002 | P0 | 新增合法 IPv4、IPv6 或 CIDR 人工保护项 | 后端规范化、检查重叠并保存;要求权限、重新认证、原因和审计 | +| TC-F2B-PROTECT-003 | P0 | 提交非法、过宽、重复或与系统项冲突的网段 | 后端拒绝,现有保护项不变化 | +| TC-F2B-PROTECT-004 | P0 | 尝试删除系统内置保护项 | 拒绝;只能通过受控发布变更 | +| TC-F2B-PROTECT-005 | P0 | 待处理告警 IP 后续加入保护名单 | 告警更新为 `whitelisted` 或明确标记保护命中,不允许封禁 | + +## 10. 自研 UI 与真实数据 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-UI-001 | P0 | 进入“安全控制 / 安全检测” | 页面使用平台自研布局、组件和中文状态,不嵌入第三方 Fail2ban UI | +| TC-F2B-UI-002 | P0 | 对照 SQL/API 检查总览五项指标、趋势、分布和 Top IP | 页面与真实后端结果一致,无 mock、静态或 localStorage 数据 | +| TC-F2B-UI-003 | P0 | 使用 IP、规则、风险、状态、入口和日期组合筛选及分页 | 查询由后端完成;总数、页码、跨页结果准确 | +| TC-F2B-UI-004 | P0 | 打开告警详情 | 显示规则快照、聚合时间线、脱敏证据和处置历史;无密钥或认证材料泄露 | +| TC-F2B-UI-005 | P0 | 打开封禁确认弹窗 | 显示执行器、影响入口、固定时长、保护检查、风险提示和必填原因 | +| TC-F2B-UI-006 | P0 | 规则保存成功、保存但应用失败、版本冲突 | 三种状态分别准确提示;失败不得显示成功 toast | +| TC-F2B-UI-007 | P1 | 1440、1280、1024、768和375宽度验收 | 指标、图表、表格、筛选和弹窗无横向溢出;关键操作可见可用 | +| TC-F2B-UI-008 | P1 | 仅键盘和读屏操作页面 | 表单有 label,错误有关联说明,状态不只依赖颜色,弹窗焦点和按钮名称准确 | +| TC-F2B-UI-009 | P0 | 后端、collector 或 agent 不可用 | 页面显示明确降级和最后成功时间,不用空数据伪装“零攻击” | + +## 11. 权限、进程与系统加固 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-SEC-001 | P0 | 检查 `cmpp-api.service` 和运行进程 | NestJS 使用专用非 root 用户;有效 UID/GID 非0 | +| TC-F2B-SEC-002 | P0 | 检查 NestJS capabilities、sudoers、可写目录 | 无通用 sudo、无 `CAP_NET_ADMIN`/`CAP_SYS_ADMIN`;只能写明确业务和日志目录 | +| TC-F2B-SEC-003 | P0 | 以 NestJS 用户尝试读取/写入 `/etc/fail2ban`、运行防火墙命令或访问代理管理文件 | 全部被操作系统权限拒绝 | +| TC-F2B-SEC-004 | P0 | 检查 Security Agent Socket | 只在本机 Unix Socket,权限和用户组符合设计,无 TCP 监听 | +| TC-F2B-SEC-005 | P0 | 向 Agent 提交未知动作、非法 JSON、超长字段、shell 字符和非白名单规则 | 全部拒绝且无命令执行;记录限量安全日志 | +| TC-F2B-SEC-006 | P0 | 检查操作日志 | 规则变更、封禁、解封、忽略、保护名单、失败和回读异常均可追溯 | +| TC-F2B-SEC-007 | P1 | 查看告警详情和完整证据 | 读取动作按设计写访问审计;低权限用户只能看到脱敏数据 | + +## 12. 可用性、恢复和数据保留 + +| 编号 | 优先级 | 测试步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-F2B-OPS-001 | P0 | 停止 Fail2ban | 健康面板显示对应来源异常;已有告警可查;不得显示“当前无攻击” | +| TC-F2B-OPS-002 | P0 | Collector 暂停后恢复并重放积压 | 事件按幂等键补录,计数准确,无重复告警 | +| TC-F2B-OPS-003 | P0 | 重启 API、Gateway、Fail2ban、collector 和 agent | 已生效规则、活动封禁和告警状态恢复一致,不自动执行新封禁 | +| TC-F2B-OPS-004 | P0 | 发布失败触发回滚 | PostgreSQL、代码、环境、Fail2ban/Nginx生成配置和防火墙规则均有可验证恢复路径 | +| TC-F2B-OPS-005 | P1 | 执行30天事件、180天告警/审计保留任务 | 只删除到期且不受保护数据;活动封禁、未完成处置和审计期数据不删除 | +| TC-F2B-OPS-006 | P1 | 大量扫描事件压测 | Collector 有界处理,API与Gateway业务不被阻塞;告警聚合避免写放大 | +| TC-F2B-OPS-007 | P0 | 对比执行器、数据库和页面 | 当前封禁集合、到期时间和执行器类型一致;差异进入异常状态和告警 | + +## 13. 发布验收证据 + +第一版发布前至少保留: + +1. Fail2ban filter 单元样例,包含命中与不命中日志。 +2. 九类规则的专项自动化结果。 +3. API 权限、参数边界、并发、幂等和审计测试。 +4. PostgreSQL migration 状态和表/索引/唯一约束核对。 +5. NestJS 非 root、capabilities、sudoers 和文件权限证据。 +6. Cloudflare 真实 IP、伪造 Header 和 Nginx 执行器真实验证。 +7. 直连 API/SSH/CMPP 的隔离测试 IP nftables 封禁与解封证据。 +8. Fail2ban、collector、agent、API、Gateway、Nginx、PostgreSQL 和 Redis 健康证据。 +9. 自研 UI 桌面端、平板端和移动端截图以及控制台日志。 +10. 发布前 PostgreSQL、运行源码、环境文件、生成配置和防火墙规则恢复资产校验。 + +## 14. 判定规则 + +- 任一 P0 失败:第一版不得发布。 +- 检测达到阈值但未生成告警、未达到阈值却告警、错误 IP 聚合、密钥泄露、保护地址可被封禁、页面显示封禁而执行器未生效、NestJS 仍为 root或可执行任意系统命令,均按 P0 处理。 +- 只验证 Fail2ban 命令输出、只验证前端样式或只写数据库不验证真实执行器,不能作为功能通过。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index e657106..ad575b6 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2070,3 +2070,11 @@ 2. 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个明细模块;“今日活跃签名”指标仍使用当天真实发送聚合。今日消费金额的主数字必须与今日发送总量使用相同字号、字重和深色层级。 3. 企业应用管理列表的状态、到达率、单价列在现有基础上缩窄约20%,提升大屏一次展示完整表格的概率;不得通过隐藏真实字段实现。 4. 移动、联通、电信数据展示统一使用全局`CarrierTag`低饱和标签,包括通道与通道组、监控、报备、签名、短信审核、任务号码、发送记录及客户端发送详情等页面。筛选/表单控件的选项文案、图表图例、导出文本和业务说明仍使用纯文本,避免破坏交互、可访问性和机器可读输出;可取得运营商集合的三网通道按三个运营商标签展示,只有历史通道级字段时使用中性的“三网”标签。 +## Fail2ban 安全检测与人工封禁(2026-08-14) + +- 运营端“安全控制”新增“安全检测与封禁”自研页面,数据必须来自真实 NestJS API 与 PostgreSQL,包含总览、告警中心、九类规则配置、人工封禁记录和保护名单;不得嵌入第三方面板或使用 Mock、静态数据、localStorage 伪造检测状态。 +- 第一版检测运营端登录失败、客户端登录失败、SSH 认证失败、CMPP 认证失败、CMPP 协议滥用、HTTP 错误密钥、HTTP 签名错误、HTTP 重放及 HTTP 恶意扫描。规则阈值、窗口、冷却、风险级别、启停状态与封禁时长边界保存在数据库,并使用配置版本防止并发覆盖。 +- 检测只产生事件与人工告警,绝不自动封禁。人工封禁只允许 10 分钟、1 小时、24 小时或 7 天;操作要求近期重新认证、必填原因、保护名单校验、数据库原子认领和操作审计。执行器由服务端按可信入口固定映射,浏览器不得提交 jail、action、shell 参数或自选执行器。 +- NestJS 必须以专用非 root 用户运行,不得获得通用 sudo、任意 shell、直接编辑 `/etc/fail2ban/*` 或防火墙的能力。独立 root security agent 只监听本机 Unix Socket、接受固定 JSON 动作、使用参数数组执行固定操作,并在真实 nftables 或 Nginx deny 回读成功后才允许数据库标记 `blocked`。 +- 运营端/客户端的 Cloudflare 入口使用 Nginx real-IP deny;直连 HTTP API、SSH 与 CMPP 使用 nftables。只有可信代理 TCP 来源可以提供访客 IP;系统回环、私网、链路本地、组播和配置的运维/健康检查网段必须内置保护。 +- 规则更新先保存待应用版本,由安全代理生成固定 Fail2ban 配置、执行语法校验并 reload;失败保留上一生效值并展示失败原因,不得显示为已生效。完整架构、状态机、字段、接口和安全边界以 `docs/fail2ban-assisted-blocking-design-20260814.md` 为准。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index eb62926..fad5d7d 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -93,6 +93,22 @@ git reset --hard origin/main bash tools/deploy/production-deploy.sh ``` +### Fail2ban 安全检测发布前置条件 + +本功能包含新增 PostgreSQL migration、非 root API 身份、安全代理、Fail2ban、Nginx include 和 nftables 表,不能按普通前端热发布处理。发布前除平台标准 PostgreSQL、运行源码和环境文件恢复资产外,必须额外备份 `/etc/systemd/system/cmpp-api.service*`、`/etc/systemd/system/cmpp-security-agent.service`、`/etc/fail2ban`、`/etc/nginx`、`/etc/nftables.conf`、`/etc/nftables.d` 和 `/var/lib/cmpp-security-agent`,并逐项生成、复核 SHA-256。 + +发布脚本会构建 `cmpp-security-agent` 并运行 `tools/security/install-security-agent.sh`。安装器创建专用 `cmpp-api` 用户和 `cmpp-security` 组、写入 systemd 加固 drop-in、安装固定 Fail2ban filter/action、校验 Nginx/Fail2ban/nftables,但不会执行任何人工封禁。环境文件至少明确: + +```bash +SECURITY_AGENT_SOCKET=/run/cmpp-security-agent/agent.sock +SECURITY_AGENT_TIMEOUT_MS=3000 +SECURITY_EVENT_TOKEN=<至少32字节随机值,仅供Gateway和安全代理上报固定事件> +TRUSTED_PROXY_IPS=127.0.0.1,::1 +SECURITY_BUILTIN_PROTECTED_NETWORKS=<运维出口CIDR,健康检查CIDR,源站公网IP> +``` + +正式 Nginx 的 `sms.lisglo.com` 运营端/客户端 server 块必须 `include /etc/nginx/snippets/cmpp-security-deny.conf;`;API 专用域名继续只开放客户接口。Cloudflare `real_ip_header` 及可信网段必须按官方来源单独维护和验证,禁止信任任意客户端 `CF-Connecting-IP` 或 `X-Forwarded-For`。发布后需证明 `cmpp-api` 进程用户不是 root、无 sudo 权限且无法写 `/etc/fail2ban`,安全代理 Socket 不监听 TCP,九类规则版本一致,Fail2ban 为 report-only,nftables/Nginx 回读与数据库状态一致。任何一项失败均不得开放人工封禁按钮。 + 部署脚本重启 Gateway、API 和 Nginx 后,会分别对 Gateway、API 健康接口执行最多 60 秒的逐秒就绪检查。Nest 初始化、活动通道恢复或生产数据量增加可能使 API 启动超过固定数秒;发布流程不得用单次固定延时把正常慢启动误判为失败。超过 60 秒仍不健康时才终止发布,并结合 systemd journal 和发布前数据库、源码、环境备份判断回滚方式。 ## 账号和密钥 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 0dfcc37..12c96b8 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4650,3 +4650,9 @@ npm run verify:phase8 | TC-INFRA-MON-015 | 响应式与无障碍 | 在1536×1024、1280×800和390×844打开页面,操作范围和刷新按钮 | 桌面信息层级符合设计稿;窄屏无内容重叠和页面横向溢出;按钮有可读名称,活动范围和告警严重性不只依赖颜色表达 | | TC-INFRA-MON-016 | 配置和部署幂等 | 在测试服务器重复执行监控安装脚本和配置校验 | 不重复创建系统用户,不开放公网端口;配置通过`promtool check config/rules`,服务保持active,CMPP API/Gateway不因安装被重启 | | TC-INFRA-MON-017 | 业务数据隔离 | 运行监控24小时并检查PostgreSQL业务库和指标标签 | 监控时序只保存在Prometheus TSDB,业务PostgreSQL无高频指标写入;标签、日志和API响应不含手机号、短信正文、账号或密钥 | +## Fail2ban 安全检测与人工封禁测试矩阵(2026-08-14) + +- 本模块必须执行 `docs/fail2ban-assisted-blocking-test-cases-20260814.md` 中 TC-F2B 全量用例,专项用例是本平台功能测试的组成部分,不是可选附录。 +- P0 门禁至少覆盖:九类规则真实 PostgreSQL 默认值与版本冲突、阈值边界、规则应用失败保留旧生效值、登录/HTTP/CMPP/SSH/Nginx 真实事件脱敏、事件键幂等、窗口聚合并发、可信代理 IP、Cloudflare 与直连入口执行器映射、系统和人工保护网段、近期重新认证、重复封禁原子认领、代理超时/失败、真实执行器回读、非 root NestJS 及任意命令/参数注入拒绝。 +- 集成验收必须在隔离测试节点或网络 namespace 使用文档保留 IP;不得封禁预生产运维出口、Cloudflare 节点或真实客户 IP。未安装真实 Fail2ban/nftables/Nginx 资产时,只能把相关用例标记阻塞,不得用 Mock 通过代替。 +- UI 验收覆盖桌面与窄屏的总览、告警、规则、封禁记录、保护名单、加载、空数据、失败和规则未生效状态;所有数字与操作结果必须能从 API、数据库、agent 与执行器证据交叉验证。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index a45e027..b3ae84f 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3582,3 +3582,12 @@ git diff --check - 专项Jest 1套/4项、API全量38套/467项通过,覆盖范围白名单、Prometheus HTTP响应契约解析、固定60秒step、服务别名、活动告警、不可用无陈旧数据及监控地址安全约束;全量Jest仍因仓库既有异步句柄使用`--forceExit`收尾。API正式TypeScript、前端TypeScript、Vite 8.1.5生产构建、两个Shell脚本语法及`git diff --check`通过;Vite仅保留既有约2.10MB单chunk提示。 - 浏览器优先检查现有页面:本地运营端无有效登录Session,被正常引导到带算术验证码的登录页;未绕过登录、未读取或填写验证码,因此登录后桌面与窄屏视觉验收尚未完成。真实Prometheus/Node Exporter集成和告警触发验收必须在明确授权安装的测试或预生产窗口执行,当前不以单测替代真实基础设施验收。 - 本轮代码、配置和文档保持未提交、未推送、未部署,没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额、客户连接或预生产数据。既有构建缓存、`outputs/`和空文件`=`继续保护;并行会话新增的Fail2ban设计与测试文档不属于本需求,不修改、不归因。 +# 2026-08-14 Fail2ban 安全检测与人工封禁第一版(本地实现完成、未发布) + +- 新增真实 PostgreSQL 安全规则、事件、告警、封禁和保护网段模型及第88条 migration `20260814150000_add_security_detection`;九类默认规则覆盖运营端/客户端登录、SSH、CMPP认证与协议滥用、HTTP错误密钥/签名/重放和Nginx恶意扫描。应用事件按事件键幂等并使用PostgreSQL advisory transaction lock串行聚合;Fail2ban命中作为其自身窗口已达到阈值的聚合事件处理,不会再要求重复达到第二层阈值。 +- 运营端新增“安全控制 / 安全检测与封禁”自研页面,使用真实API展示总览、告警、规则、封禁记录和保护名单;规则修改、封禁、解封、忽略和保护名单写操作要求近期重新认证。封禁时长为固定枚举,执行器由服务端按入口映射,浏览器不能传jail、action、shell或执行器参数;系统私网/回环等内置保护和人工CIDR保护在调用代理前拦截。 +- 新增独立Go `cmpp-security-agent`、Unix Socket固定协议、report-only Fail2ban action/filter、Nginx real-IP deny、nftables timeout set和systemd加固。NestJS部署身份改为专用非root `cmpp-api`,安全事件入口新增独立内部令牌;agent不使用shell拼接,规则/Nginx配置校验或reload失败会恢复旧文件,只有真实执行器回读命中后数据库才标记`blocked`。 +- 登录、OpenAPI鉴权和Gateway已接入结构化安全事件;错误HTTP密钥只保存不可逆账号指纹,证据字段统一过滤password/secret/token/signature/access-key。反向代理地址只在TCP来源属于`TRUSTED_PROXY_IPS`时信任`X-Forwarded-For`,避免客户端伪造来源IP。 +- Prisma schema validate、API与前端TypeScript、API全量40套/472项、Fail2ban专项和Gateway控制器3套/11项、Gateway全量`go test ./...`、Vite 8.1.5生产构建、3个Shell脚本语法和`git diff --check`通过;Vite仅有既有约2.11MiB单chunk提示,全量Jest仍使用`--forceExit`收尾既有异步句柄。 +- 浏览器优先接管本地路由,`/admin/security-detection`在无有效Session时正确跳转运营端登录并保留返回地址,控制台error/warn为0;未读取、重置或猜测账号,登录后页面视觉与交互验收尚未完成。真实Fail2ban、Nginx、nftables、Unix Socket和第88条migration未在本机数据库或预生产安装/执行,必须在具备恢复资产和文档保留测试IP的授权发布窗口完成,当前不以Mock替代集成验收。 +- 本轮未发送、补发或重投短信,未修改通道账号、密码、启停状态、企业余额、客户连接或预生产数据;`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续作为受保护项排除提交。 diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 03eca4a..e327dff 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -41,11 +41,12 @@ func main() { go func() { log.Printf("cmpp gateway inbound server listening on %s", cmppAddr) if err := (inbound.Server{ - Addr: cmppAddr, - APIBaseURL: apiBaseURL, - PresenceStore: presenceStore, - RecoveryStore: recoveryStore, - GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()), + Addr: cmppAddr, + APIBaseURL: apiBaseURL, + PresenceStore: presenceStore, + RecoveryStore: recoveryStore, + GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()), + SecurityEventToken: os.Getenv("SECURITY_EVENT_TOKEN"), }).ListenAndServe(); err != nil { log.Fatalf("gateway inbound server stopped: %v", err) } diff --git a/gateway/cmd/security-agent/main.go b/gateway/cmd/security-agent/main.go new file mode 100644 index 0000000..f916488 --- /dev/null +++ b/gateway/cmd/security-agent/main.go @@ -0,0 +1,395 @@ +// security-agent is the deliberately tiny privileged boundary for manual blocking. +// It accepts only a fixed JSON protocol over a Unix socket; it never invokes a shell +// and never accepts command, jail, action, path, or argument strings from NestJS. +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +type request struct { + Action string `json:"action"` + OperationKey string `json:"operationKey"` + SourceIP string `json:"sourceIp"` + Executor string `json:"executor"` + DurationSeconds int `json:"durationSeconds"` + Version int `json:"version"` + Rules []rule `json:"rules"` +} +type rule struct { + Code string `json:"code"` + Enabled bool `json:"enabled"` + Threshold int `json:"threshold"` + WindowSeconds int `json:"windowSeconds"` + CooldownSeconds int `json:"cooldownSeconds"` +} +type response struct { + OK bool `json:"ok"` + Reference string `json:"reference,omitempty"` + Blocked bool `json:"blocked,omitempty"` + Active bool `json:"active,omitempty"` + Error string `json:"error,omitempty"` +} +type block struct { + OperationKey string `json:"operationKey"` + SourceIP string `json:"sourceIp"` + Executor string `json:"executor"` + ExpiresAt time.Time `json:"expiresAt"` +} +type state struct { + Blocks map[string]block `json:"blocks"` + RuleVersion int `json:"ruleVersion"` +} + +var allowedDurations = map[int]bool{600: true, 3600: true, 86400: true, 604800: true} +var allowedRules = map[string]bool{"admin_login_failure": true, "client_login_failure": true, "ssh_auth_failure": true, "cmpp_auth_failure": true, "cmpp_protocol_abuse": true, "http_invalid_api_key": true, "http_signature_failure": true, "http_replay_attempt": true, "http_malicious_scan": true} + +type agent struct { + mu sync.Mutex + statePath, nginxInclude, fail2banConfig string + data state +} + +func main() { + if len(os.Args) == 4 && os.Args[1] == "report" { + if err := reportEvent(os.Args[2], os.Args[3]); err != nil { + log.Fatal(err) + } + return + } + socketPath := env("SECURITY_AGENT_SOCKET", "/run/cmpp-security-agent/agent.sock") + a := &agent{statePath: env("SECURITY_AGENT_STATE", "/var/lib/cmpp-security-agent/state.json"), nginxInclude: env("SECURITY_NGINX_DENY_INCLUDE", "/etc/nginx/snippets/cmpp-security-deny.conf"), fail2banConfig: env("SECURITY_FAIL2BAN_CONFIG", "/etc/fail2ban/jail.d/cmpp-platform-generated.local"), data: state{Blocks: map[string]block{}}} + if err := a.load(); err != nil { + log.Fatalf("load state: %v", err) + } + if err := os.MkdirAll(filepath.Dir(socketPath), 0750); err != nil { + log.Fatal(err) + } + _ = os.Remove(socketPath) + listener, err := net.Listen("unix", socketPath) + if err != nil { + log.Fatal(err) + } + if err := os.Chmod(socketPath, 0660); err != nil { + log.Fatal(err) + } + defer listener.Close() + log.Printf("security agent listening on %s", socketPath) + for { + connection, err := listener.Accept() + if err != nil { + log.Printf("accept: %v", err) + continue + } + go a.serve(connection) + } +} + +func (a *agent) serve(connection net.Conn) { + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + var req request + if err := json.NewDecoder(bufio.NewReader(connection)).Decode(&req); err != nil { + write(connection, response{Error: "invalid request"}) + return + } + a.mu.Lock() + defer a.mu.Unlock() + a.prune() + var result response + switch req.Action { + case "block": + result = a.block(req) + case "unblock": + result = a.unblock(req) + case "status": + result = a.status(req) + case "apply_rules": + result = a.applyRules(req) + default: + result = response{Error: "unsupported action"} + } + write(connection, result) +} + +func (a *agent) block(req request) response { + if net.ParseIP(req.SourceIP) == nil || !allowedDurations[req.DurationSeconds] || (req.Executor != "nftables" && req.Executor != "nginx_real_ip") || len(req.OperationKey) < 16 { + return response{Error: "invalid fixed block parameters"} + } + if existing, ok := a.data.Blocks[req.OperationKey]; ok { + return response{OK: true, Reference: req.OperationKey, Blocked: existing.ExpiresAt.After(time.Now())} + } + if req.Executor == "nftables" { + if err := nftBlock(req.SourceIP, req.DurationSeconds); err != nil { + return response{Error: err.Error()} + } + } + a.data.Blocks[req.OperationKey] = block{OperationKey: req.OperationKey, SourceIP: req.SourceIP, Executor: req.Executor, ExpiresAt: time.Now().Add(time.Duration(req.DurationSeconds) * time.Second)} + if req.Executor == "nginx_real_ip" { + if err := a.writeNginx(); err != nil { + delete(a.data.Blocks, req.OperationKey) + return response{Error: err.Error()} + } + } + if err := a.save(); err != nil { + return response{Error: err.Error()} + } + return a.status(req) +} + +func (a *agent) unblock(req request) response { + if net.ParseIP(req.SourceIP) == nil || (req.Executor != "nftables" && req.Executor != "nginx_real_ip") { + return response{Error: "invalid fixed unblock parameters"} + } + if req.Executor == "nftables" { + if err := nftUnblock(req.SourceIP); err != nil { + return response{Error: err.Error()} + } + } + for key, item := range a.data.Blocks { + if item.SourceIP == req.SourceIP && item.Executor == req.Executor { + delete(a.data.Blocks, key) + } + } + if req.Executor == "nginx_real_ip" { + if err := a.writeNginx(); err != nil { + return response{Error: err.Error()} + } + } + if err := a.save(); err != nil { + return response{Error: err.Error()} + } + return response{OK: true, Reference: req.OperationKey} +} + +func (a *agent) status(req request) response { + active := command("systemctl", "is-active", "--quiet", "fail2ban") == nil + if req.SourceIP == "" { + return response{OK: true, Active: active} + } + blocked := false + if req.Executor == "nftables" { + family := "blocked_ipv6" + if net.ParseIP(req.SourceIP).To4() != nil { + family = "blocked_ipv4" + } + output, err := exec.Command("nft", "list", "set", "inet", "cmpp_security", family).CombinedOutput() + if err != nil { + return response{Error: "nftables readback failed: " + strings.TrimSpace(string(output))} + } + blocked = strings.Contains(string(output), req.SourceIP+" timeout") || strings.Contains(string(output), req.SourceIP+" expires") + } else if req.Executor == "nginx_real_ip" { + content, err := os.ReadFile(a.nginxInclude) + if err != nil { + return response{Error: "nginx deny readback failed: " + err.Error()} + } + blocked = strings.Contains(string(content), "deny "+req.SourceIP+";") + } else { + return response{Error: "invalid executor"} + } + for _, item := range a.data.Blocks { + if blocked && item.SourceIP == req.SourceIP && item.Executor == req.Executor && item.ExpiresAt.After(time.Now()) { + return response{OK: true, Active: active, Blocked: true, Reference: item.OperationKey} + } + } + return response{OK: true, Active: active, Blocked: false} +} + +func (a *agent) applyRules(req request) response { + if req.Version <= a.data.RuleVersion { + return response{OK: true, Reference: strconv.Itoa(a.data.RuleVersion)} + } + for _, item := range req.Rules { + if !allowedRules[item.Code] || item.Threshold < 1 || item.Threshold > 100000 || item.WindowSeconds < 10 || item.WindowSeconds > 86400 || item.CooldownSeconds < 0 || item.CooldownSeconds > 604800 { + return response{Error: "invalid fixed rule configuration"} + } + } + var ssh, scan *rule + for index := range req.Rules { + if req.Rules[index].Code == "ssh_auth_failure" { + ssh = &req.Rules[index] + } + if req.Rules[index].Code == "http_malicious_scan" { + scan = &req.Rules[index] + } + } + content := "# Generated by cmpp-security-agent. Manual changes will be overwritten.\n" + if ssh != nil { + content += jail("sshd", *ssh) + } + if scan != nil { + content += jail("cmpp-http-scan", *scan) + } + previous, previousErr := os.ReadFile(a.fail2banConfig) + if err := atomicWrite(a.fail2banConfig, []byte(content), 0640); err != nil { + return response{Error: err.Error()} + } + if err := command("fail2ban-client", "-t"); err != nil { + restore(a.fail2banConfig, previous, previousErr, 0640) + return response{Error: "fail2ban validation failed: " + err.Error()} + } + if err := command("fail2ban-client", "reload"); err != nil { + restore(a.fail2banConfig, previous, previousErr, 0640) + return response{Error: "fail2ban reload failed: " + err.Error()} + } + a.data.RuleVersion = req.Version + if err := a.save(); err != nil { + return response{Error: err.Error()} + } + return response{OK: true, Reference: strconv.Itoa(req.Version), Active: true} +} + +func jail(name string, item rule) string { + enabled := "false" + if item.Enabled { + enabled = "true" + } + bantime := item.CooldownSeconds + if bantime < item.WindowSeconds { + bantime = item.WindowSeconds + } + return fmt.Sprintf("\n[%s]\nenabled = %s\nfindtime = %d\nmaxretry = %d\nbantime = %d\naction = cmpp-report-only\n", name, enabled, item.WindowSeconds, item.Threshold, bantime) +} +func nftBlock(ip string, seconds int) error { + family := "blocked_ipv6" + if net.ParseIP(ip).To4() != nil { + family = "blocked_ipv4" + } + return command("nft", "add", "element", "inet", "cmpp_security", family, fmt.Sprintf("{ %s timeout %ds }", ip, seconds)) +} +func nftUnblock(ip string) error { + family := "blocked_ipv6" + if net.ParseIP(ip).To4() != nil { + family = "blocked_ipv4" + } + err := command("nft", "delete", "element", "inet", "cmpp_security", family, fmt.Sprintf("{ %s }", ip)) + if err != nil && strings.Contains(err.Error(), "No such file") { + return nil + } + return err +} +func (a *agent) writeNginx() error { + ips := []string{} + for _, item := range a.data.Blocks { + if item.Executor == "nginx_real_ip" && item.ExpiresAt.After(time.Now()) { + ips = append(ips, item.SourceIP) + } + } + sort.Strings(ips) + lines := []string{"# Generated by cmpp-security-agent."} + for _, ip := range ips { + lines = append(lines, "deny "+ip+";") + } + previous, previousErr := os.ReadFile(a.nginxInclude) + if err := atomicWrite(a.nginxInclude, []byte(strings.Join(lines, "\n")+"\n"), 0640); err != nil { + return err + } + if err := command("nginx", "-t"); err != nil { + restore(a.nginxInclude, previous, previousErr, 0640) + return err + } + if err := command("systemctl", "reload", "nginx"); err != nil { + restore(a.nginxInclude, previous, previousErr, 0640) + _ = command("systemctl", "reload", "nginx") + return err + } + return nil +} +func (a *agent) prune() { + changed := false + for key, item := range a.data.Blocks { + if !item.ExpiresAt.After(time.Now()) { + delete(a.data.Blocks, key) + changed = true + } + } + if changed { + _ = a.writeNginx() + _ = a.save() + } +} +func (a *agent) load() error { + bytes, err := os.ReadFile(a.statePath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return json.Unmarshal(bytes, &a.data) +} +func (a *agent) save() error { + bytes, err := json.MarshalIndent(a.data, "", " ") + if err != nil { + return err + } + return atomicWrite(a.statePath, bytes, 0600) +} +func atomicWrite(path string, bytes []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return err + } + temporary := path + ".tmp" + if err := os.WriteFile(temporary, bytes, mode); err != nil { + return err + } + return os.Rename(temporary, path) +} +func restore(path string, previous []byte, previousErr error, mode os.FileMode) { + if previousErr == nil { + _ = atomicWrite(path, previous, mode) + } else if errors.Is(previousErr, os.ErrNotExist) { + _ = os.Remove(path) + } +} +func command(name string, args ...string) error { + output, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(output))) + } + return nil +} +func write(connection net.Conn, value response) { _ = json.NewEncoder(connection).Encode(value) } +func env(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} +func reportEvent(jail, ip string) error { + ruleCode := map[string]string{"sshd": "ssh_auth_failure", "cmpp-http-scan": "http_malicious_scan"}[jail] + if ruleCode == "" || net.ParseIP(ip) == nil { + return errors.New("unsupported report event") + } + body := fmt.Sprintf(`{"ruleCode":%q,"sourceIp":%q,"protocol":%q,"resultCode":%q}`, ruleCode, ip, "fail2ban", jail) + client := &http.Client{Timeout: 3 * time.Second} + request, err := http.NewRequest(http.MethodPost, env("SECURITY_EVENT_URL", "http://127.0.0.1:3000/api/gateway/events/security-detection"), strings.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Security-Event-Token", os.Getenv("SECURITY_EVENT_TOKEN")) + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("event collector returned %s", response.Status) + } + return nil +} diff --git a/gateway/internal/inbound/authentication.go b/gateway/internal/inbound/authentication.go index 5cb9be0..dd1e08e 100644 --- a/gateway/internal/inbound/authentication.go +++ b/gateway/internal/inbound/authentication.go @@ -37,10 +37,12 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger } account := strings.TrimRight(req.SrcAddr, "\x00") if account == "" { + go s.reportSecurityEvent("cmpp_protocol_abuse", remoteIP(packet.Conn.Conn.RemoteAddr()), "EMPTY_SOURCE_ADDRESS", cmppVersionName(req.Version)) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnInvalidSrcAddr, req.AuthSrc, "", req.Version) return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr] } if req.Version != cmpp.V20 && req.Version != cmpp.V21 && req.Version != cmpp.V30 { + go s.reportSecurityEvent("cmpp_protocol_abuse", remoteIP(packet.Conn.Conn.RemoteAddr()), "UNSUPPORTED_VERSION", cmppVersionName(req.Version)) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30) return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh] } @@ -129,3 +131,18 @@ func (s Server) authenticate(remote net.Addr, account string, authSource string, err := s.post(context.Background(), "/gateway/events/inbound/authenticate", payload, &result) return result, err } + +func (s Server) reportSecurityEvent(ruleCode, sourceIP, resultCode, protocol string) { + if net.ParseIP(sourceIP) == nil { + return + } + payload := map[string]any{ + "ruleCode": ruleCode, "sourceIp": sourceIP, "resultCode": resultCode, "protocol": protocol, + } + var result struct { + Accepted bool `json:"accepted"` + } + if err := s.post(context.Background(), "/gateway/events/security-detection", payload, &result); err != nil { + log.Printf("security event report failed rule=%s remote=%s err=%v", ruleCode, sourceIP, err) + } +} diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index 2a9e11c..a26c2fd 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -13,6 +13,7 @@ const defaultHTTPTimeout = 10 * time.Second type Server struct { Addr string APIBaseURL string + SecurityEventToken string HTTPClient *http.Client LogWriter io.Writer PendingFlushInterval time.Duration diff --git a/gateway/internal/inbound/transport.go b/gateway/internal/inbound/transport.go index 3e8c689..befcb75 100644 --- a/gateway/internal/inbound/transport.go +++ b/gateway/internal/inbound/transport.go @@ -28,6 +28,9 @@ func (s Server) post(ctx context.Context, path string, payload any, result any) return err } req.Header.Set("Content-Type", "application/json") + if strings.HasSuffix(path, "/security-detection") && s.SecurityEventToken != "" { + req.Header.Set("X-Security-Event-Token", s.SecurityEventToken) + } resp, err := client.Do(req) if err != nil { return err diff --git a/src/api/admin/security-detection.api.ts b/src/api/admin/security-detection.api.ts new file mode 100644 index 0000000..ddde601 --- /dev/null +++ b/src/api/admin/security-detection.api.ts @@ -0,0 +1,15 @@ +import { request, withQuery } from '../core/httpClient'; +import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types'; + +export const adminSecurityDetectionApi = { + getSecurityOverview: (range = '24h') => request(withQuery('/admin/security-detection/overview', { range })), + listSecurityAlerts: (query: Record = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)), + listSecurityRules: () => request('/admin/security-detection/rules'), + updateSecurityRule: (id: string, body: Partial) => request(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request(`/admin/security-detection/alerts/${id}/block`, { method: 'POST', body: JSON.stringify(body) }), + ignoreSecurityAlert: (id: string, reason: string) => request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { method: 'POST', body: JSON.stringify({ reason }) }), + listSecurityBlocks: () => request('/admin/security-detection/blocks'), + unblockSecurityBlock: (id: string, reason: string) => request(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }), + listProtectedNetworks: () => request('/admin/security-detection/protected-networks'), + addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }), +}; diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index dca5a3b..c7cd4fc 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -11,6 +11,7 @@ import { adminGovernanceApi } from './admin/governance.api'; import { adminFilesApi } from './admin/files.api'; import { adminSignatureRetirementApi } from './admin/signature-retirement.api'; import { adminInfrastructureMonitoringApi } from './admin/infrastructure-monitoring.api'; +import { adminSecurityDetectionApi } from './admin/security-detection.api'; export const adminApi = { ...adminIdentityApi, @@ -20,4 +21,5 @@ export const adminApi = { ...adminFilesApi, ...adminSignatureRetirementApi, ...adminInfrastructureMonitoringApi, + ...adminSecurityDetectionApi, }; diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 5488e4a..67e5b3a 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -5,3 +5,4 @@ export * from './operations'; export * from './governance'; export * from './signature-retirement'; export * from './infrastructure-monitoring'; +export * from './security-detection'; diff --git a/src/api/types/security-detection.ts b/src/api/types/security-detection.ts new file mode 100644 index 0000000..1bf357b --- /dev/null +++ b/src/api/types/security-detection.ts @@ -0,0 +1,18 @@ +export type SecurityRule = { + id: string; code: string; name: string; sourceType: string; enabled: boolean; + threshold: number; windowSeconds: number; cooldownSeconds: number; severity: string; + defaultBlockSeconds: number; maximumBlockSeconds: number; configVersion: number; + effectiveVersion: number; applyStatus: string; lastApplyError?: string | null; +}; +export type SecurityAlert = { + id: string; fingerprint: string; sourceIp: string; severity: string; status: string; + eventCount: number; firstOccurredAt: string; lastOccurredAt: string; rule: SecurityRule; +}; +export type SecurityBlock = { id: string; sourceIp: string; executor: string; status: string; durationSeconds: number; reason: string; requestedAt: string; expiresAt?: string | null; lastError?: string | null }; +export type SecurityProtectedNetwork = { id: string; network: string; name: string; reason: string; enabled: boolean; createdAt: string }; +export type SecurityOverview = { + range: string; collectedAt: string; totalEvents: number; activeAlerts: number; criticalAlerts: number; activeBlocks: number; + health: { agent: string; agentError?: string; rulesEffective: number; rulesTotal: number }; + sourceDistribution: Array<{ name: string; value: number }>; + alerts: SecurityAlert[]; +}; diff --git a/src/apps/admin/security-detection/AdminSecurityDetectionPage.css b/src/apps/admin/security-detection/AdminSecurityDetectionPage.css new file mode 100644 index 0000000..fa07c6f --- /dev/null +++ b/src/apps/admin/security-detection/AdminSecurityDetectionPage.css @@ -0,0 +1 @@ +.admin-security-page{gap:20px}.security-heading{align-items:flex-end}.security-heading h1{margin:10px 0 4px;font-size:26px}.security-heading p{margin:0;color:#64748b}.security-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.security-kpi{display:grid;grid-template-columns:42px 1fr;grid-template-rows:auto auto;gap:3px 12px;padding:18px}.security-kpi>div{grid-row:1/3;width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:#e8f0ff;color:#2563eb}.security-kpi>div svg{width:20px}.security-kpi span{font-size:13px;color:#64748b}.security-kpi strong{font-size:24px;line-height:1.1}.security-kpi.is-danger>div{background:#fef2f2;color:#dc2626}.security-overview-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.8fr);gap:16px}.security-chart,.security-latest,.security-table-card{padding:18px}.security-chart header,.security-latest header{display:flex;justify-content:space-between}.security-chart header span,.security-latest header span{font-size:12px;color:#94a3b8}.security-latest-row{display:grid;grid-template-columns:10px 1fr auto;gap:10px;align-items:center;padding:13px 0;border-bottom:1px solid #eef2f7}.security-latest-row div{display:grid;gap:3px}.security-latest-row small,.security-latest-row time{color:#64748b;font-size:12px}.severity-dot{width:8px;height:8px;border-radius:99px;background:#3b82f6}.severity-dot.is-high{background:#f59e0b}.severity-dot.is-critical{background:#ef4444}.security-cell{display:grid;gap:3px}.security-cell span{font-size:11px;color:#94a3b8}.security-actions{display:flex;gap:6px}.security-note,.security-section-toolbar{display:flex;align-items:center;gap:9px;margin-bottom:16px;padding:12px 14px;border-radius:10px;background:#f8fafc;color:#475569;font-size:13px}.security-section-toolbar{justify-content:space-between}.security-error{display:flex;gap:8px;align-items:center;padding:12px 14px;border:1px solid #fecaca;border-radius:10px;background:#fef2f2;color:#b91c1c}.security-empty{height:265px;display:grid;place-items:center;color:#94a3b8}.security-dialog{display:grid;gap:16px}.security-target{display:grid;gap:4px;padding:14px;border-radius:10px;background:#f8fafc}.security-target span,.security-target small{color:#64748b;font-size:12px}.security-target strong{font-family:ui-monospace,monospace;font-size:18px}.security-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.security-form-grid .security-error{grid-column:1/-1}.admin-security-page code{font-size:12px;color:#334155}@media(max-width:1100px){.security-kpis{grid-template-columns:repeat(2,1fr)}.security-overview-grid{grid-template-columns:1fr}}@media(max-width:640px){.security-kpis{grid-template-columns:1fr}.security-form-grid{grid-template-columns:1fr}} diff --git a/src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx b/src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx new file mode 100644 index 0000000..c472792 --- /dev/null +++ b/src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react'; +import type { EChartsOption } from 'echarts'; +import { AlertTriangle, Ban, BellRing, RefreshCw, Settings2, ShieldCheck, ShieldOff } from 'lucide-react'; +import { adminApi, type SecurityAlert, type SecurityBlock, type SecurityOverview, type SecurityProtectedNetwork, type SecurityRule } from '@/api/adminApi'; +import { Breadcrumb, Button, Chart, Input, Modal as BaseModal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui'; +import './AdminSecurityDetectionPage.css'; + +const statusLabels: Record = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' }; +const severityLabels: Record = { low: '低', medium: '中', high: '高', critical: '严重' }; +const durationOptions = [{ value: '600', label: '10分钟' }, { value: '3600', label: '1小时' }, { value: '86400', label: '24小时' }, { value: '604800', label: '7天' }]; +const formatTime = (value?: string | null) => value ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) : '—'; +function Modal(props: Omit, 'open'>) { return ; } + +export function AdminSecurityDetectionPage() { + const [overview, setOverview] = useState(null); + const [alerts, setAlerts] = useState([]); + const [rules, setRules] = useState([]); + const [blocks, setBlocks] = useState([]); + const [networks, setNetworks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [blockAlert, setBlockAlert] = useState(null); + const [editRule, setEditRule] = useState(null); + const [showNetwork, setShowNetwork] = useState(false); + const [reasonAction, setReasonAction] = useState<{ title: string; confirmLabel: string; run: (reason: string) => Promise } | null>(null); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const [nextOverview, nextAlerts, nextRules, nextBlocks, nextNetworks] = await Promise.all([ + adminApi.getSecurityOverview('24h'), adminApi.listSecurityAlerts({ pageSize: 100 }), adminApi.listSecurityRules(), adminApi.listSecurityBlocks(), adminApi.listProtectedNetworks(), + ]); + setOverview(nextOverview); setAlerts(nextAlerts.items); setRules(nextRules); setBlocks(nextBlocks); setNetworks(nextNetworks); + } catch (reason) { setError(reason instanceof Error ? reason.message : '安全检测数据加载失败'); } + finally { setLoading(false); } + }, []); + useEffect(() => { void load(); }, [load]); + + const alertColumns: Array> = [ + { key: 'level', title: '级别', width: '80px', render: (item) => {severityLabels[item.severity] ?? item.severity} }, + { key: 'rule', title: '检测类型', width: '180px', render: (item) =>
{item.rule.name}{item.rule.code}
}, + { key: 'ip', title: '来源 IP', width: '150px', render: (item) => {item.sourceIp} }, + { key: 'count', title: '窗口命中', width: '100px', render: (item) => `${item.eventCount} 次` }, + { key: 'time', title: '最后发生', width: '140px', render: (item) => formatTime(item.lastOccurredAt) }, + { key: 'status', title: '状态', width: '96px', render: (item) => {statusLabels[item.status] ?? item.status} }, + { key: 'action', title: '人工处置', width: '190px', render: (item) =>
}, + ]; + function ignore(item: SecurityAlert) { setReasonAction({ title: `忽略告警 · ${item.sourceIp}`, confirmLabel: '确认忽略', run: async (reason) => { await adminApi.ignoreSecurityAlert(item.id, reason); await load(); } }); } + + const ruleColumns: Array> = [ + { key: 'name', title: '规则', width: '230px', render: (item) =>
{item.name}{item.code}
}, + { key: 'source', title: '数据源', width: '110px', render: (item) => item.sourceType }, + { key: 'threshold', title: '阈值 / 窗口', width: '150px', render: (item) => `${item.threshold} 次 / ${item.windowSeconds} 秒` }, + { key: 'cooldown', title: '冷却', width: '100px', render: (item) => `${item.cooldownSeconds} 秒` }, + { key: 'version', title: '生效版本', width: '120px', render: (item) => {item.effectiveVersion}/{item.configVersion} }, + { key: 'enabled', title: '启用', width: '90px', render: (item) => {item.enabled ? '启用' : '停用'} }, + { key: 'action', title: '操作', width: '90px', render: (item) => }, + ]; + const blockColumns: Array> = [ + { key: 'ip', title: 'IP', width: '160px', render: (item) => {item.sourceIp} }, { key: 'executor', title: '执行器', width: '140px', render: (item) => item.executor }, + { key: 'status', title: '状态', width: '100px', render: (item) => {statusLabels[item.status] ?? item.status} }, + { key: 'reason', title: '原因', width: '260px', render: (item) => item.reason }, { key: 'expiry', title: '到期时间', width: '150px', render: (item) => formatTime(item.expiresAt) }, { key: 'error', title: '执行结果', width: '220px', render: (item) => item.lastError ?? '已由执行器回读确认' }, + { key: 'action', title: '操作', width: '90px', render: (item) => }, + ]; + function releaseBlock(item: SecurityBlock) { setReasonAction({ title: `人工解封 · ${item.sourceIp}`, confirmLabel: '确认解封', run: async (reason) => { await adminApi.unblockSecurityBlock(item.id, reason); await load(); } }); } + const networkColumns: Array> = [ + { key: 'network', title: 'IP / 网段', width: '180px', render: (item) => {item.network} }, { key: 'name', title: '名称', width: '180px', render: (item) => item.name }, { key: 'reason', title: '保护原因', width: '320px', render: (item) => item.reason }, { key: 'status', title: '状态', width: '100px', render: (item) => {item.enabled ? '保护中' : '已停用'} }, + ]; + const chartOption = useMemo(() => ({ tooltip: { trigger: 'item' }, legend: { bottom: 0, type: 'scroll' }, series: [{ type: 'pie', radius: ['52%', '76%'], center: ['50%', '43%'], label: { show: false }, data: overview?.sourceDistribution ?? [] }] }), [overview]); + + const tabs = [ + { value: 'overview', label: '总览', content: <>
24小时检测来源真实事件聚合
{overview?.sourceDistribution.length ? :
暂无检测事件
}
最新告警{overview?.alerts.length ?? 0} 条
{overview?.alerts.slice(0, 6).map((item) =>
{item.rule.name}{item.sourceIp} · {item.eventCount} 次
)}
}, + { value: 'alerts', label: '告警中心', content:
}, + { value: 'rules', label: '规则配置', content:
所有阈值来自数据库;修改后必须由受限安全代理验证并应用,版本一致才标记生效。
}, + { value: 'blocks', label: '封禁记录', content:
}, + { value: 'protected', label: '保护名单', content:
受保护的运维出口、内网和可信代理永远不能从面板封禁。
}, + ]; + + return

安全检测与封禁

Fail2ban 与业务事件只负责检测,由运营人员复核后人工处置

+ {error ?
{error}
: null} +
} label="24小时检测事件" value={overview?.totalEvents ?? 0} />} label="待处置告警" value={overview?.activeAlerts ?? 0} danger={Boolean(overview?.criticalAlerts)} />} label="生效封禁" value={overview?.activeBlocks ?? 0} /> : } label="安全代理" value={overview?.health.agent === 'healthy' ? '在线' : '不可用'} danger={overview?.health.agent !== 'healthy'} />
+ + {blockAlert ? setBlockAlert(null)} onSaved={async () => { setBlockAlert(null); await load(); }} /> : null} + {editRule ? setEditRule(null)} onSaved={async () => { setEditRule(null); await load(); }} /> : null} + {showNetwork ? setShowNetwork(false)} onSaved={async () => { setShowNetwork(false); await load(); }} /> : null} + {reasonAction ? setReasonAction(null)} /> : null} +
; +} + +function Kpi({ icon, label, value, danger = false }: { icon: React.ReactNode; label: string; value: React.ReactNode; danger?: boolean }) { return
{icon}
{label}{value}
; } +function BlockDialog({ alert, onClose, onSaved }: { alert: SecurityAlert; onClose: () => void; onSaved: () => void }) { const [duration, setDuration] = useState(String(alert.rule.defaultBlockSeconds)); const [reason, setReason] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const executor = alert.rule.code.startsWith('admin_') || alert.rule.code.startsWith('client_') ? 'Nginx Real-IP deny' : 'nftables'; async function save() { setSaving(true); setError(''); try { await adminApi.blockSecurityAlert(alert.id, { durationSeconds: Number(duration), reason }); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : '封禁失败'); } finally { setSaving(false); } } return }>
目标 IP{alert.sourceIp}{alert.rule.name} · 窗口命中 {alert.eventCount} 次