feat: add Fail2ban security detection console
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
CREATE TABLE "SecurityDetectionRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"sourceType" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"threshold" INTEGER NOT NULL,
|
||||
"windowSeconds" INTEGER NOT NULL,
|
||||
"cooldownSeconds" INTEGER NOT NULL,
|
||||
"severity" TEXT NOT NULL,
|
||||
"defaultBlockSeconds" INTEGER NOT NULL,
|
||||
"maximumBlockSeconds" INTEGER NOT NULL,
|
||||
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
"effectiveVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
"applyStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||
"lastApplyError" TEXT,
|
||||
"pendingConfig" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "SecurityDetectionRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE TABLE "SecurityDetectionEvent" (
|
||||
"id" TEXT NOT NULL, "eventKey" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||
"sourceIp" TEXT NOT NULL, "sourcePort" INTEGER, "accountHash" TEXT,
|
||||
"path" TEXT, "protocol" TEXT, "resultCode" TEXT, "evidence" JSONB,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SecurityDetectionEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE TABLE "SecurityAlert" (
|
||||
"id" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||
"sourceIp" TEXT NOT NULL, "severity" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'open',
|
||||
"eventCount" INTEGER NOT NULL DEFAULT 0, "windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||
"firstOccurredAt" TIMESTAMP(3) NOT NULL, "lastOccurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"acknowledgedAt" TIMESTAMP(3), "acknowledgedById" TEXT, "ignoredAt" TIMESTAMP(3),
|
||||
"ignoredById" TEXT, "ignoreReason" TEXT, "blockId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "SecurityAlert_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE TABLE "SecurityBlock" (
|
||||
"id" TEXT NOT NULL, "operationKey" TEXT NOT NULL, "alertId" TEXT, "sourceIp" TEXT NOT NULL,
|
||||
"executor" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'requested', "durationSeconds" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL, "requestedById" TEXT NOT NULL, "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"appliedAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "releasedAt" TIMESTAMP(3), "releasedById" TEXT,
|
||||
"executorReference" TEXT, "lastError" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SecurityBlock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE TABLE "SecurityProtectedNetwork" (
|
||||
"id" TEXT NOT NULL, "network" TEXT NOT NULL, "name" TEXT NOT NULL, "reason" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true, "createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "SecurityProtectedNetwork_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "SecurityDetectionRule_code_key" ON "SecurityDetectionRule"("code");
|
||||
CREATE INDEX "SecurityDetectionRule_enabled_sourceType_idx" ON "SecurityDetectionRule"("enabled", "sourceType");
|
||||
CREATE UNIQUE INDEX "SecurityDetectionEvent_eventKey_key" ON "SecurityDetectionEvent"("eventKey");
|
||||
CREATE INDEX "SecurityDetectionEvent_ruleId_occurredAt_idx" ON "SecurityDetectionEvent"("ruleId", "occurredAt");
|
||||
CREATE INDEX "SecurityDetectionEvent_sourceIp_occurredAt_idx" ON "SecurityDetectionEvent"("sourceIp", "occurredAt");
|
||||
CREATE UNIQUE INDEX "SecurityAlert_fingerprint_key" ON "SecurityAlert"("fingerprint");
|
||||
CREATE INDEX "SecurityAlert_status_severity_lastOccurredAt_idx" ON "SecurityAlert"("status", "severity", "lastOccurredAt");
|
||||
CREATE INDEX "SecurityAlert_sourceIp_status_lastOccurredAt_idx" ON "SecurityAlert"("sourceIp", "status", "lastOccurredAt");
|
||||
CREATE INDEX "SecurityAlert_ruleId_status_lastOccurredAt_idx" ON "SecurityAlert"("ruleId", "status", "lastOccurredAt");
|
||||
CREATE UNIQUE INDEX "SecurityBlock_operationKey_key" ON "SecurityBlock"("operationKey");
|
||||
CREATE INDEX "SecurityBlock_status_expiresAt_idx" ON "SecurityBlock"("status", "expiresAt");
|
||||
CREATE INDEX "SecurityBlock_sourceIp_status_requestedAt_idx" ON "SecurityBlock"("sourceIp", "status", "requestedAt");
|
||||
CREATE INDEX "SecurityBlock_alertId_idx" ON "SecurityBlock"("alertId");
|
||||
CREATE UNIQUE INDEX "SecurityProtectedNetwork_network_key" ON "SecurityProtectedNetwork"("network");
|
||||
CREATE INDEX "SecurityProtectedNetwork_enabled_createdAt_idx" ON "SecurityProtectedNetwork"("enabled", "createdAt");
|
||||
ALTER TABLE "SecurityDetectionEvent" ADD CONSTRAINT "SecurityDetectionEvent_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "SecurityAlert" ADD CONSTRAINT "SecurityAlert_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "SecurityDetectionRule" ("id", "code", "name", "sourceType", "threshold", "windowSeconds", "cooldownSeconds", "severity", "defaultBlockSeconds", "maximumBlockSeconds", "configVersion", "effectiveVersion", "applyStatus", "updatedAt") VALUES
|
||||
('sec_admin_login', 'admin_login_failure', '运营端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_client_login', 'client_login_failure', '客户端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_ssh_auth', 'ssh_auth_failure', 'SSH认证失败', 'fail2ban', 6, 600, 1800, 'high', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_cmpp_auth', 'cmpp_auth_failure', 'CMPP认证失败', 'gateway', 5, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_cmpp_abuse', 'cmpp_protocol_abuse', 'CMPP协议滥用', 'gateway', 20, 60, 900, 'critical', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_http_key', 'http_invalid_api_key', 'HTTP错误密钥', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_http_sign', 'http_signature_failure', 'HTTP签名错误', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_http_replay', 'http_replay_attempt', 'HTTP重放尝试', 'application', 3, 600, 1800, 'critical', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||
('sec_http_scan', 'http_malicious_scan', 'HTTP恶意扫描', 'fail2ban', 20, 60, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP);
|
||||
@@ -2321,3 +2321,114 @@ model GatewayDownstreamRecoveryStatus {
|
||||
@@index([state, updatedAt])
|
||||
@@index([nextRetryAt])
|
||||
}
|
||||
|
||||
model SecurityDetectionRule {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
sourceType String
|
||||
enabled Boolean @default(true)
|
||||
threshold Int
|
||||
windowSeconds Int
|
||||
cooldownSeconds Int
|
||||
severity String
|
||||
defaultBlockSeconds Int
|
||||
maximumBlockSeconds Int
|
||||
configVersion Int @default(1)
|
||||
effectiveVersion Int @default(0)
|
||||
applyStatus String @default("pending")
|
||||
lastApplyError String?
|
||||
pendingConfig Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
events SecurityDetectionEvent[]
|
||||
alerts SecurityAlert[]
|
||||
|
||||
@@index([enabled, sourceType])
|
||||
}
|
||||
|
||||
model SecurityDetectionEvent {
|
||||
id String @id @default(cuid())
|
||||
eventKey String @unique
|
||||
ruleId String
|
||||
sourceIp String
|
||||
sourcePort Int?
|
||||
accountHash String?
|
||||
path String?
|
||||
protocol String?
|
||||
resultCode String?
|
||||
evidence Json?
|
||||
occurredAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([ruleId, occurredAt])
|
||||
@@index([sourceIp, occurredAt])
|
||||
}
|
||||
|
||||
model SecurityAlert {
|
||||
id String @id @default(cuid())
|
||||
fingerprint String @unique
|
||||
ruleId String
|
||||
sourceIp String
|
||||
severity String
|
||||
status String @default("open")
|
||||
eventCount Int @default(0)
|
||||
windowStartedAt DateTime
|
||||
firstOccurredAt DateTime
|
||||
lastOccurredAt DateTime
|
||||
acknowledgedAt DateTime?
|
||||
acknowledgedById String?
|
||||
ignoredAt DateTime?
|
||||
ignoredById String?
|
||||
ignoreReason String?
|
||||
blockId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([status, severity, lastOccurredAt])
|
||||
@@index([sourceIp, status, lastOccurredAt])
|
||||
@@index([ruleId, status, lastOccurredAt])
|
||||
}
|
||||
|
||||
model SecurityBlock {
|
||||
id String @id @default(cuid())
|
||||
operationKey String @unique
|
||||
alertId String?
|
||||
sourceIp String
|
||||
executor String
|
||||
status String @default("requested")
|
||||
durationSeconds Int
|
||||
reason String
|
||||
requestedById String
|
||||
requestedAt DateTime @default(now())
|
||||
appliedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
releasedAt DateTime?
|
||||
releasedById String?
|
||||
executorReference String?
|
||||
lastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, expiresAt])
|
||||
@@index([sourceIp, status, requestedAt])
|
||||
@@index([alertId])
|
||||
}
|
||||
|
||||
model SecurityProtectedNetwork {
|
||||
id String @id @default(cuid())
|
||||
network String @unique
|
||||
name String
|
||||
reason String
|
||||
enabled Boolean @default(true)
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([enabled, createdAt])
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SmsConfigModule } from './sms-config/sms-config.module';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
|
||||
import { SecurityDetectionModule } from './security-detection/security-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -53,6 +54,7 @@ import { SignatureRetirementModule } from './signature-retirement/signature-reti
|
||||
InfrastructureMonitoringModule,
|
||||
OpenApiModule,
|
||||
SignatureRetirementModule,
|
||||
SecurityDetectionModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { SessionRequest } from './session-validation.middleware';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { requestContext } from '../common/request-context';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
|
||||
type CookieResponse = {
|
||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||
@@ -16,7 +18,7 @@ type CookieResponse = {
|
||||
@ApiTags('auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||
|
||||
@Get('admin/auth/captcha')
|
||||
adminCaptcha() {
|
||||
@@ -25,7 +27,14 @@ export class AuthController {
|
||||
|
||||
@Post('admin/auth/login')
|
||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
|
||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||
try {
|
||||
result = await this.auth.login(body, 'admin');
|
||||
} catch (error) {
|
||||
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return this.finishLogin(result, request, response);
|
||||
}
|
||||
|
||||
@Get('client/auth/captcha')
|
||||
@@ -35,7 +44,14 @@ export class AuthController {
|
||||
|
||||
@Post('client/auth/login')
|
||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||
try {
|
||||
result = await this.auth.login(body, 'client');
|
||||
} catch (error) {
|
||||
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return this.finishLogin(result, request, response);
|
||||
}
|
||||
|
||||
@Get(['admin/auth/session', 'client/auth/session'])
|
||||
@@ -121,6 +137,17 @@ export class AuthController {
|
||||
return publicResult;
|
||||
}
|
||||
|
||||
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
|
||||
return this.security.recordEvent({
|
||||
ruleCode,
|
||||
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
|
||||
account,
|
||||
protocol: 'http',
|
||||
path: ruleCode === 'admin_login_failure' ? '/admin/auth/login' : '/client/auth/login',
|
||||
evidence: { userAgent: request.header('user-agent')?.slice(0, 256) },
|
||||
});
|
||||
}
|
||||
|
||||
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
|
||||
|
||||
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||
import { SessionService } from './session.service';
|
||||
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
imports: [UsersModule, SecurityDetectionModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -8,7 +8,10 @@ export class RequestContextMiddleware implements NestMiddleware {
|
||||
use(request: RequestLike, _response: unknown, next: () => void) {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
||||
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
|
||||
const remoteAddress = request.socket?.remoteAddress?.trim().replace(/^::ffff:/, '');
|
||||
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||
// 仅可信反向代理可以声明客户端地址,防止攻击者伪造 X-Forwarded-For 绕过保护名单或嫁祸他人。
|
||||
const ipAddress = (remoteAddress && trustedProxies.has(remoteAddress) ? firstForwarded : remoteAddress)?.trim().replace(/^::ffff:/, '');
|
||||
requestContext.run({ ipAddress }, next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import IORedis from 'ioredis';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { decryptSecret } from './open-api.crypto';
|
||||
import type { OpenApiRequestLike } from './open-api.types';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
|
||||
@Injectable()
|
||||
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
private redis?: IORedis;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
||||
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
const nonce = header(request, 'x-nonce');
|
||||
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
||||
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
||||
await this.recordFailure('http_signature_failure', request, undefined, 'AUTH_HEADERS_MISSING');
|
||||
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
||||
}
|
||||
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
||||
await this.recordFailure('http_signature_failure', request, accessKey, 'NONCE_INVALID');
|
||||
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
||||
}
|
||||
const credential = await this.prisma.httpApiCredential.findUnique({
|
||||
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
||||
});
|
||||
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
||||
await this.recordFailure('http_invalid_api_key', request, accessKey, 'CREDENTIAL_INVALID');
|
||||
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
||||
}
|
||||
const config = credential.application.httpConfig;
|
||||
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
}
|
||||
const timestamp = Number(timestampText);
|
||||
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
||||
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
|
||||
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
||||
}
|
||||
const sourceIp = requestIp(request);
|
||||
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
||||
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
||||
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
|
||||
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
||||
}
|
||||
const redis = this.getRedis();
|
||||
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
||||
if (nonceAccepted !== 'OK') {
|
||||
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
|
||||
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
||||
}
|
||||
const second = Math.floor(Date.now() / 1000);
|
||||
@@ -81,6 +88,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
|
||||
const sourceIp = requestIp(request);
|
||||
if (!sourceIp) return;
|
||||
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
|
||||
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function header(request: OpenApiRequestLike, name: string) {
|
||||
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
|
||||
|
||||
function requestIp(request: OpenApiRequestLike) {
|
||||
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
||||
return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, '');
|
||||
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
|
||||
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
|
||||
}
|
||||
|
||||
function ipMatches(ip: string, rule: string) {
|
||||
|
||||
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
|
||||
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
||||
import { OpenApiController } from './open-api.controller';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
|
||||
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
||||
providers: [OpenApiService, OpenApiAuthGuard],
|
||||
exports: [OpenApiService],
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createConnection } from 'node:net';
|
||||
|
||||
type AgentResponse = { ok: boolean; reference?: string; blocked?: boolean; active?: boolean; error?: string };
|
||||
|
||||
@Injectable()
|
||||
export class SecurityAgentClient {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
block(input: { operationKey: string; sourceIp: string; executor: string; durationSeconds: number }) {
|
||||
return this.call({ action: 'block', ...input });
|
||||
}
|
||||
|
||||
unblock(input: { operationKey: string; sourceIp: string; executor: string }) {
|
||||
return this.call({ action: 'unblock', ...input });
|
||||
}
|
||||
|
||||
status(sourceIp?: string, executor?: string) {
|
||||
return this.call({ action: 'status', sourceIp, executor });
|
||||
}
|
||||
|
||||
applyRules(version: number, rules: Array<Record<string, unknown>>) {
|
||||
return this.call({ action: 'apply_rules', version, rules });
|
||||
}
|
||||
|
||||
private call(payload: Record<string, unknown>): Promise<AgentResponse> {
|
||||
const socketPath = this.config.get<string>('SECURITY_AGENT_SOCKET') ?? '/run/cmpp-security-agent/agent.sock';
|
||||
const timeoutMs = Number(this.config.get<string>('SECURITY_AGENT_TIMEOUT_MS') ?? 3000);
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
let settled = false;
|
||||
let response = '';
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => finish(new Error('安全执行代理响应超时')));
|
||||
socket.on('error', (error) => finish(new Error(`安全执行代理不可用: ${error.message}`)));
|
||||
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
|
||||
socket.on('data', (chunk) => {
|
||||
response += chunk.toString('utf8');
|
||||
const lineEnd = response.indexOf('\n');
|
||||
if (lineEnd < 0) return;
|
||||
try {
|
||||
const parsed = JSON.parse(response.slice(0, lineEnd)) as AgentResponse;
|
||||
settled = true;
|
||||
socket.end();
|
||||
resolve(parsed);
|
||||
} catch {
|
||||
finish(new Error('安全执行代理返回了非法响应'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const SECURITY_RULE_CODES = [
|
||||
'admin_login_failure', 'client_login_failure', 'ssh_auth_failure', 'cmpp_auth_failure',
|
||||
'cmpp_protocol_abuse', 'http_invalid_api_key', 'http_signature_failure',
|
||||
'http_replay_attempt', 'http_malicious_scan',
|
||||
] as const;
|
||||
|
||||
export type SecurityRuleCode = typeof SECURITY_RULE_CODES[number];
|
||||
export const SECURITY_RULE_CODE_SET = new Set<string>(SECURITY_RULE_CODES);
|
||||
export const SECURITY_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
||||
export const SECURITY_BLOCK_DURATIONS = new Set([600, 3600, 86400, 604800]);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { SecurityDetectionService } from './security-detection.service';
|
||||
|
||||
@ApiTags('security-detection')
|
||||
@Controller('admin/security-detection')
|
||||
export class SecurityDetectionController {
|
||||
constructor(private readonly security: SecurityDetectionService) {}
|
||||
@Get('overview') overview(@Query('range') range?: string) { return this.security.overview(range); }
|
||||
@Get('alerts') alerts(@Query() query: Record<string, string>) { return this.security.listAlerts(query); }
|
||||
@Get('rules') rules() { return this.security.listRules(); }
|
||||
@Put('rules/:id') @RequireRecentAuthentication() updateRule(@Param('id') id: string, @Body() body: Record<string, unknown>, @CurrentSessionUserId() userId: string) { return this.security.updateRule(id, body, userId); }
|
||||
@Post('alerts/:id/block') @RequireRecentAuthentication() block(@Param('id') id: string, @Body() body: { durationSeconds?: number; reason?: string }, @CurrentSessionUserId() userId: string) { return this.security.block(id, body, userId); }
|
||||
@Post('alerts/:id/ignore') @RequireRecentAuthentication() ignore(@Param('id') id: string, @Body('reason') reason: string, @CurrentSessionUserId() userId: string) { return this.security.ignore(id, reason ?? '', userId); }
|
||||
@Get('blocks') blocks() { return this.security.listBlocks(); }
|
||||
@Post('blocks/:id/unblock') @RequireRecentAuthentication() unblock(@Param('id') id: string, @Body('reason') reason: string, @CurrentSessionUserId() userId: string) { return this.security.unblock(id, reason ?? '', userId); }
|
||||
@Get('protected-networks') protectedNetworks() { return this.security.listProtectedNetworks(); }
|
||||
@Post('protected-networks') @RequireRecentAuthentication() addProtectedNetwork(@Body() body: { network?: string; name?: string; reason?: string }, @CurrentSessionUserId() userId: string) { return this.security.addProtectedNetwork(body, userId); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SecurityAgentClient } from './security-agent.client';
|
||||
import { SecurityDetectionController } from './security-detection.controller';
|
||||
import { SecurityEventController } from './security-event.controller';
|
||||
import { SecurityDetectionService } from './security-detection.service';
|
||||
|
||||
@Module({ controllers: [SecurityDetectionController, SecurityEventController], providers: [SecurityAgentClient, SecurityDetectionService], exports: [SecurityDetectionService] })
|
||||
export class SecurityDetectionModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { SecurityDetectionService } from './security-detection.service';
|
||||
|
||||
function createPrisma() {
|
||||
const tx = {
|
||||
$executeRaw: jest.fn(),
|
||||
securityDetectionEvent: { create: jest.fn(), count: jest.fn() },
|
||||
securityAlert: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||
securityBlock: { create: jest.fn(), update: jest.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
securityDetectionRule: { findUnique: jest.fn(), findMany: jest.fn() },
|
||||
securityDetectionEvent: { count: jest.fn() },
|
||||
securityAlert: { findUnique: jest.fn(), update: jest.fn() },
|
||||
securityBlock: { create: jest.fn(), update: jest.fn() },
|
||||
securityProtectedNetwork: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
operationLog: { create: jest.fn() },
|
||||
$transaction: jest.fn(async (value: unknown) => typeof value === 'function' ? value(tx) : Promise.all(value as Promise<unknown>[])),
|
||||
};
|
||||
return { prisma, tx };
|
||||
}
|
||||
|
||||
describe('SecurityDetectionService', () => {
|
||||
it('keeps a below-threshold event without creating a false alert', async () => {
|
||||
const { prisma, tx } = createPrisma();
|
||||
prisma.securityDetectionRule.findUnique.mockResolvedValue({ id: 'rule-1', enabled: true, threshold: 3, windowSeconds: 60, cooldownSeconds: 60, severity: 'high' });
|
||||
tx.securityDetectionEvent.create.mockResolvedValue({ id: 'event-1' });
|
||||
tx.securityDetectionEvent.count.mockResolvedValue(2);
|
||||
const service = new SecurityDetectionService(prisma as never, {} as never);
|
||||
|
||||
await expect(service.recordEvent({ eventKey: 'event-key-1', ruleCode: 'http_signature_failure', sourceIp: '203.0.113.5' })).resolves.toEqual({ accepted: true, duplicate: false, alertId: null });
|
||||
expect(tx.securityAlert.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses built-in protected addresses before calling the privileged agent', async () => {
|
||||
const { prisma } = createPrisma();
|
||||
prisma.securityAlert.findUnique.mockResolvedValue({
|
||||
id: 'alert-1', sourceIp: '127.0.0.1', status: 'open',
|
||||
rule: { code: 'ssh_auth_failure', defaultBlockSeconds: 600, maximumBlockSeconds: 604800 },
|
||||
});
|
||||
const agent = { block: jest.fn(), status: jest.fn() };
|
||||
const service = new SecurityDetectionService(prisma as never, agent as never);
|
||||
|
||||
await expect(service.block('alert-1', { durationSeconds: 600, reason: '隔离测试封禁' }, 'operator-1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(agent.block).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps an admin-login alert to nginx and marks blocked only after readback', async () => {
|
||||
const { prisma, tx } = createPrisma();
|
||||
prisma.securityAlert.findUnique.mockResolvedValue({
|
||||
id: 'alert-1', sourceIp: '203.0.113.8', status: 'open',
|
||||
rule: { code: 'admin_login_failure', defaultBlockSeconds: 600, maximumBlockSeconds: 604800 },
|
||||
});
|
||||
tx.securityAlert.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.securityBlock.create.mockResolvedValue({ id: 'block-1' });
|
||||
tx.securityBlock.update.mockResolvedValue({ id: 'block-1', status: 'blocked' });
|
||||
tx.securityAlert.update.mockResolvedValue({ id: 'alert-1', status: 'blocked' });
|
||||
const agent = { block: jest.fn().mockResolvedValue({ ok: true, reference: 'op-1' }), status: jest.fn().mockResolvedValue({ ok: true, blocked: true }) };
|
||||
const service = new SecurityDetectionService(prisma as never, agent as never);
|
||||
|
||||
await expect(service.block('alert-1', { durationSeconds: 600, reason: '确认恶意登录扫描' }, 'operator-1')).resolves.toEqual(expect.objectContaining({ status: 'blocked' }));
|
||||
expect(agent.block).toHaveBeenCalledWith(expect.objectContaining({ executor: 'nginx_real_ip', sourceIp: '203.0.113.8' }));
|
||||
expect(agent.status).toHaveBeenCalledWith('203.0.113.8', 'nginx_real_ip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SecurityAgentClient } from './security-agent.client';
|
||||
import { SECURITY_BLOCK_DURATIONS, SECURITY_RULE_CODE_SET, SECURITY_SEVERITIES, type SecurityRuleCode } from './security-detection.constants';
|
||||
|
||||
export type SecurityEventInput = {
|
||||
eventKey?: string; ruleCode: SecurityRuleCode; sourceIp: string; sourcePort?: number;
|
||||
account?: string; path?: string; protocol?: string; resultCode?: string;
|
||||
evidence?: Record<string, unknown>; occurredAt?: string | Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SecurityDetectionService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly agent: SecurityAgentClient) {}
|
||||
|
||||
async recordEvent(input: SecurityEventInput) {
|
||||
if (!SECURITY_RULE_CODE_SET.has(input.ruleCode)) throw new BadRequestException('不支持的安全检测类型');
|
||||
const sourceIp = normalizeIp(input.sourceIp);
|
||||
const occurredAt = input.occurredAt ? new Date(input.occurredAt) : new Date();
|
||||
if (!Number.isFinite(occurredAt.getTime())) throw new BadRequestException('安全事件时间无效');
|
||||
const eventKey = input.eventKey ?? createHash('sha256').update(JSON.stringify([
|
||||
input.ruleCode, sourceIp, input.sourcePort, input.account, input.path, input.resultCode,
|
||||
occurredAt.toISOString(), input.evidence,
|
||||
])).digest('hex');
|
||||
const rule = await this.prisma.securityDetectionRule.findUnique({ where: { code: input.ruleCode } });
|
||||
if (!rule) throw new NotFoundException('安全检测规则不存在');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 同一来源和规则串行聚合,避免并发计数跨过阈值时创建多个告警。
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`${rule.id}:${sourceIp}`}))`;
|
||||
try {
|
||||
await tx.securityDetectionEvent.create({ data: {
|
||||
eventKey, ruleId: rule.id, sourceIp, sourcePort: input.sourcePort,
|
||||
accountHash: input.account ? createHash('sha256').update(input.account).digest('hex') : undefined,
|
||||
path: input.path?.slice(0, 512), protocol: input.protocol?.slice(0, 32), resultCode: input.resultCode?.slice(0, 128),
|
||||
evidence: sanitizeEvidence(input.evidence), occurredAt,
|
||||
} });
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) return { accepted: true, duplicate: true, alertId: null };
|
||||
throw error;
|
||||
}
|
||||
if (!rule.enabled) return { accepted: true, duplicate: false, alertId: null };
|
||||
|
||||
const windowStartedAt = new Date(occurredAt.getTime() - rule.windowSeconds * 1000);
|
||||
const storedEventCount = await tx.securityDetectionEvent.count({
|
||||
where: { ruleId: rule.id, sourceIp, occurredAt: { gte: windowStartedAt, lte: occurredAt } },
|
||||
});
|
||||
// Fail2ban上报代表其自身窗口已经达到maxretry;应用事件则逐条在数据库窗口内计数。
|
||||
const eventCount = rule.sourceType === 'fail2ban' ? Math.max(storedEventCount, rule.threshold) : storedEventCount;
|
||||
if (eventCount < rule.threshold) return { accepted: true, duplicate: false, alertId: null };
|
||||
|
||||
const cooldownStart = new Date(occurredAt.getTime() - rule.cooldownSeconds * 1000);
|
||||
const active = await tx.securityAlert.findFirst({
|
||||
where: { ruleId: rule.id, sourceIp, status: { in: ['open', 'acknowledged', 'block_failed', 'blocked'] }, lastOccurredAt: { gte: cooldownStart } },
|
||||
orderBy: { lastOccurredAt: 'desc' },
|
||||
});
|
||||
if (active) {
|
||||
const updated = await tx.securityAlert.update({ where: { id: active.id }, data: { eventCount, lastOccurredAt: occurredAt } });
|
||||
return { accepted: true, duplicate: false, alertId: updated.id };
|
||||
}
|
||||
const fingerprint = createHash('sha256').update(`${rule.id}:${sourceIp}:${occurredAt.toISOString()}`).digest('hex');
|
||||
const alert = await tx.securityAlert.create({ data: {
|
||||
fingerprint, ruleId: rule.id, sourceIp, severity: rule.severity, eventCount,
|
||||
windowStartedAt, firstOccurredAt: occurredAt, lastOccurredAt: occurredAt,
|
||||
} });
|
||||
return { accepted: true, duplicate: false, alertId: alert.id };
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async overview(range = '24h') {
|
||||
if (!['1h', '24h', '7d'].includes(range)) throw new BadRequestException('仅支持1h、24h或7d安全检测范围');
|
||||
const hours = range === '1h' ? 1 : range === '7d' ? 168 : 24;
|
||||
const since = new Date(Date.now() - hours * 3600_000);
|
||||
const activeStatuses = ['open', 'acknowledged', 'block_failed'];
|
||||
const [alerts, totalEvents, activeBlocks, rules, activeAlerts, criticalAlerts, distribution, agentStatus] = await Promise.all([
|
||||
this.prisma.securityAlert.findMany({ where: { lastOccurredAt: { gte: since } }, include: { rule: true }, orderBy: { lastOccurredAt: 'desc' }, take: 12 }),
|
||||
this.prisma.securityDetectionEvent.count({ where: { occurredAt: { gte: since } } }),
|
||||
this.prisma.securityBlock.count({ where: { status: 'blocked', expiresAt: { gt: new Date() } } }),
|
||||
this.prisma.securityDetectionRule.findMany({ orderBy: { name: 'asc' } }),
|
||||
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses } } }),
|
||||
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses }, severity: 'critical' } }),
|
||||
this.prisma.securityAlert.groupBy({ by: ['ruleId'], where: { lastOccurredAt: { gte: since } }, _sum: { eventCount: true } }),
|
||||
this.agent.status().catch((error: Error) => ({ ok: false, active: false, error: error.message })),
|
||||
]);
|
||||
const ruleNames = new Map(rules.map((rule) => [rule.id, rule.name]));
|
||||
return {
|
||||
range, collectedAt: new Date().toISOString(), totalEvents, activeAlerts, criticalAlerts, activeBlocks,
|
||||
health: { agent: agentStatus.ok && agentStatus.active ? 'healthy' : 'unavailable', agentError: agentStatus.error, rulesEffective: rules.filter((rule) => rule.applyStatus === 'effective').length, rulesTotal: rules.length },
|
||||
sourceDistribution: distribution.map((item) => ({ name: ruleNames.get(item.ruleId) ?? item.ruleId, value: item._sum.eventCount ?? 0 })),
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
listAlerts(query: { status?: string; ruleCode?: string; sourceIp?: string; page?: string; pageSize?: string }) {
|
||||
const page = positiveInt(query.page, 1, 100000);
|
||||
const pageSize = positiveInt(query.pageSize, 20, 100);
|
||||
const where: Prisma.SecurityAlertWhereInput = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.ruleCode ? { rule: { code: query.ruleCode } } : {}),
|
||||
...(query.sourceIp ? { sourceIp: normalizeIp(query.sourceIp) } : {}),
|
||||
};
|
||||
return Promise.all([
|
||||
this.prisma.securityAlert.findMany({ where, include: { rule: true }, orderBy: { lastOccurredAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
|
||||
this.prisma.securityAlert.count({ where }),
|
||||
]).then(([items, total]) => ({ items, total, page, pageSize }));
|
||||
}
|
||||
|
||||
listRules() { return this.prisma.securityDetectionRule.findMany({ orderBy: [{ sourceType: 'asc' }, { name: 'asc' }] }); }
|
||||
|
||||
async updateRule(id: string, input: Record<string, unknown>, operatorId: string) {
|
||||
assertAllowedKeys(input, ['configVersion', 'enabled', 'threshold', 'windowSeconds', 'cooldownSeconds', 'severity', 'defaultBlockSeconds', 'maximumBlockSeconds']);
|
||||
if (typeof input.enabled !== 'boolean') throw new BadRequestException('启用状态必须为布尔值');
|
||||
const current = await this.prisma.securityDetectionRule.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('规则不存在');
|
||||
if (Number(input.configVersion) !== current.configVersion) throw new ConflictException('规则已被其他管理员修改,请刷新后重试');
|
||||
const threshold = boundedInt(input.threshold, 1, 100000, '触发次数');
|
||||
const windowSeconds = boundedInt(input.windowSeconds, 10, 86400, '检测窗口');
|
||||
const cooldownSeconds = boundedInt(input.cooldownSeconds, 0, 604800, '告警冷却');
|
||||
const defaultBlockSeconds = boundedInt(input.defaultBlockSeconds, 600, 604800, '默认封禁时长');
|
||||
const maximumBlockSeconds = boundedInt(input.maximumBlockSeconds, defaultBlockSeconds, 604800, '最大封禁时长');
|
||||
const severity = String(input.severity ?? '');
|
||||
if (!SECURITY_SEVERITIES.has(severity)) throw new BadRequestException('告警级别无效');
|
||||
const version = current.configVersion + 1;
|
||||
const nextConfig = { enabled: Boolean(input.enabled), threshold, windowSeconds, cooldownSeconds, severity, defaultBlockSeconds, maximumBlockSeconds };
|
||||
await this.prisma.securityDetectionRule.update({ where: { id }, data: {
|
||||
configVersion: version, applyStatus: 'applying', lastApplyError: null, pendingConfig: nextConfig,
|
||||
} });
|
||||
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.rule_updated', resource: 'security_detection_rule', resourceId: id, detail: { version, beforeVersion: current.configVersion } } });
|
||||
try {
|
||||
const response = await this.agent.applyRules(version, (await this.listRules()).map((rule) => rule.id === id
|
||||
? { code: rule.code, enabled: nextConfig.enabled, threshold: nextConfig.threshold, windowSeconds: nextConfig.windowSeconds, cooldownSeconds: nextConfig.cooldownSeconds }
|
||||
: { code: rule.code, enabled: rule.enabled, threshold: rule.threshold, windowSeconds: rule.windowSeconds, cooldownSeconds: rule.cooldownSeconds }));
|
||||
if (!response.ok) throw new Error(response.error ?? '安全代理拒绝应用规则');
|
||||
return this.prisma.securityDetectionRule.update({ where: { id }, data: { ...nextConfig, effectiveVersion: version, applyStatus: 'effective', pendingConfig: Prisma.JsonNull } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '规则应用失败';
|
||||
await this.prisma.securityDetectionRule.update({ where: { id }, data: { applyStatus: 'failed', lastApplyError: message } });
|
||||
throw new ConflictException({ code: 'SECURITY_RULE_APPLY_FAILED', message });
|
||||
}
|
||||
}
|
||||
|
||||
async block(alertId: string, input: { durationSeconds?: number; reason?: string }, operatorId: string) {
|
||||
assertAllowedKeys(input as Record<string, unknown>, ['durationSeconds', 'reason']);
|
||||
const alert = await this.prisma.securityAlert.findUnique({ where: { id: alertId }, include: { rule: true } });
|
||||
if (!alert) throw new NotFoundException('告警不存在');
|
||||
if (!['open', 'acknowledged', 'block_failed'].includes(alert.status)) throw new ConflictException('该告警当前不可封禁');
|
||||
const durationSeconds = Number(input.durationSeconds ?? alert.rule.defaultBlockSeconds);
|
||||
if (!SECURITY_BLOCK_DURATIONS.has(durationSeconds) || durationSeconds > alert.rule.maximumBlockSeconds) throw new BadRequestException('封禁时长不在允许范围内');
|
||||
const reason = String(input.reason ?? '').trim();
|
||||
if (reason.length < 5 || reason.length > 500) throw new BadRequestException('封禁原因需为5至500个字符');
|
||||
if (isSystemProtected(alert.sourceIp) || await this.isProtected(alert.sourceIp)) throw new ConflictException({ code: 'PROTECTED_NETWORK', message: '该地址属于系统或人工保护名单,禁止封禁' });
|
||||
// 执行器由可信的规则入口固定映射,绝不接受浏览器指定,避免把Cloudflare访客IP错误交给nftables。
|
||||
const executor = ['admin_login_failure', 'client_login_failure'].includes(alert.rule.code) ? 'nginx_real_ip' : 'nftables';
|
||||
const operationKey = randomUUID();
|
||||
const block = await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.securityAlert.updateMany({ where: { id: alert.id, status: { in: ['open', 'acknowledged', 'block_failed'] } }, data: { status: 'block_requested' } });
|
||||
if (!claimed.count) throw new ConflictException('告警已由其他管理员处理,请刷新后重试');
|
||||
return tx.securityBlock.create({ data: { operationKey, alertId, sourceIp: alert.sourceIp, executor, durationSeconds, reason, requestedById: operatorId } });
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.block_requested', resource: 'security_block', resourceId: block.id, detail: { alertId, sourceIp: alert.sourceIp, executor, durationSeconds, reason } } });
|
||||
try {
|
||||
const applied = await this.agent.block({ operationKey, sourceIp: alert.sourceIp, executor, durationSeconds });
|
||||
if (!applied.ok) throw new Error(applied.error ?? '安全代理拒绝封禁');
|
||||
const readback = await this.agent.status(alert.sourceIp, executor);
|
||||
if (!readback.ok || !readback.blocked) throw new Error(readback.error ?? '执行后未读到真实封禁状态');
|
||||
const expiresAt = new Date(Date.now() + durationSeconds * 1000);
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.securityBlock.update({ where: { id: block.id }, data: { status: 'blocked', appliedAt: new Date(), expiresAt, executorReference: applied.reference } });
|
||||
await tx.securityAlert.update({ where: { id: alert.id }, data: { status: 'blocked', blockId: block.id } });
|
||||
return updated;
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '封禁执行失败';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'failed', lastError: message } }),
|
||||
this.prisma.securityAlert.update({ where: { id: alert.id }, data: { status: 'block_failed' } }),
|
||||
]);
|
||||
throw new ConflictException({ code: 'SECURITY_BLOCK_FAILED', message });
|
||||
}
|
||||
}
|
||||
|
||||
async ignore(alertId: string, reason: string, operatorId: string) {
|
||||
if (reason.trim().length < 5) throw new BadRequestException('忽略原因至少5个字符');
|
||||
const updated = await this.prisma.securityAlert.updateMany({ where: { id: alertId, status: { in: ['open', 'acknowledged', 'block_failed'] } }, data: { status: 'ignored', ignoredAt: new Date(), ignoredById: operatorId, ignoreReason: reason.trim() } });
|
||||
if (!updated.count) throw new ConflictException('告警状态已变化,请刷新后重试');
|
||||
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.alert_ignored', resource: 'security_alert', resourceId: alertId, detail: { reason: reason.trim() } } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
listBlocks() { return this.prisma.securityBlock.findMany({ orderBy: { requestedAt: 'desc' }, take: 200 }); }
|
||||
|
||||
async unblock(blockId: string, reason: string, operatorId: string) {
|
||||
if (reason.trim().length < 5) throw new BadRequestException('解封原因至少5个字符');
|
||||
const block = await this.prisma.securityBlock.findUnique({ where: { id: blockId } });
|
||||
if (!block) throw new NotFoundException('封禁记录不存在');
|
||||
if (block.status !== 'blocked') throw new ConflictException('该记录当前不可解封');
|
||||
const claimed = await this.prisma.securityBlock.updateMany({ where: { id: blockId, status: 'blocked' }, data: { status: 'unblock_requested' } });
|
||||
if (!claimed.count) throw new ConflictException('封禁状态已变化,请刷新后重试');
|
||||
try {
|
||||
const result = await this.agent.unblock({ operationKey: randomUUID(), sourceIp: block.sourceIp, executor: block.executor });
|
||||
if (!result.ok) throw new Error(result.error ?? '安全代理拒绝解封');
|
||||
const readback = await this.agent.status(block.sourceIp, block.executor);
|
||||
if (!readback.ok || readback.blocked) throw new Error(readback.error ?? '执行后仍读到封禁规则');
|
||||
const updated = await this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'released', releasedAt: new Date(), releasedById: operatorId } });
|
||||
if (block.alertId) await this.prisma.securityAlert.updateMany({ where: { id: block.alertId, blockId: block.id }, data: { status: 'unblocked' } });
|
||||
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.block_released', resource: 'security_block', resourceId: block.id, detail: { sourceIp: block.sourceIp, executor: block.executor, reason: reason.trim() } } });
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '解封失败';
|
||||
await this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'blocked', lastError: message } });
|
||||
throw new ConflictException({ code: 'SECURITY_UNBLOCK_FAILED', message });
|
||||
}
|
||||
}
|
||||
listProtectedNetworks() { return this.prisma.securityProtectedNetwork.findMany({ orderBy: { createdAt: 'desc' } }); }
|
||||
|
||||
async addProtectedNetwork(input: { network?: string; name?: string; reason?: string }, operatorId: string) {
|
||||
const network = normalizeNetwork(String(input.network ?? ''));
|
||||
if (!input.name?.trim() || !input.reason?.trim()) throw new BadRequestException('名称和保护原因不能为空');
|
||||
const result = await this.prisma.securityProtectedNetwork.create({ data: { network, name: input.name.trim(), reason: input.reason.trim(), createdById: operatorId } });
|
||||
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.protected_network_created', resource: 'security_protected_network', resourceId: result.id, detail: { network } } });
|
||||
return result;
|
||||
}
|
||||
|
||||
private async isProtected(ip: string) {
|
||||
const entries = await this.prisma.securityProtectedNetwork.findMany({ where: { enabled: true }, select: { network: true } });
|
||||
return entries.some((entry) => networkContains(entry.network, ip));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIp(value: string) { const normalized = value?.trim().replace(/^::ffff:/, ''); if (!isIP(normalized)) throw new BadRequestException('来源IP无效'); return normalized; }
|
||||
function normalizeNetwork(value: string) { const [address, prefix] = value.trim().split('/'); const family = isIP(address); if (!family) throw new BadRequestException('保护网段无效'); if (prefix === undefined) return address; const bits = Number(prefix); const max = family === 4 ? 32 : 128; if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException('保护网段前缀无效'); return `${address}/${bits}`; }
|
||||
function networkContains(network: string, ip: string) { const [address, prefixText] = network.split('/'); if (isIP(address) !== isIP(ip)) return false; if (prefixText === undefined) return address === ip; const bits = Number(prefixText); return (addressToBigInt(address) >> BigInt((isIP(address) === 4 ? 32 : 128) - bits)) === (addressToBigInt(ip) >> BigInt((isIP(ip) === 4 ? 32 : 128) - bits)); }
|
||||
function addressToBigInt(value: string) { if (isIP(value) === 4) return value.split('.').reduce((total, part) => (total << 8n) + BigInt(part), 0n); const [left, right = ''] = value.toLowerCase().split('::'); const leftParts = left ? left.split(':') : []; const rightParts = right ? right.split(':') : []; const parts = [...leftParts, ...Array(Math.max(0, 8 - leftParts.length - rightParts.length)).fill('0'), ...rightParts]; return parts.reduce((total, part) => (total << 16n) + BigInt(`0x${part || '0'}`), 0n); }
|
||||
function positiveInt(value: string | undefined, fallback: number, max: number) { const parsed = Number(value ?? fallback); return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback; }
|
||||
function boundedInt(value: unknown, min: number, max: number, label: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new BadRequestException(`${label}必须在${min}至${max}之间`); return parsed; }
|
||||
function sanitizeEvidence(value?: Record<string, unknown>) { if (!value) return undefined; const sanitized = JSON.parse(JSON.stringify(value, (key, item) => /password|secret|token|signature|access.?key/i.test(key) ? '[REDACTED]' : item)); return sanitized as Prisma.InputJsonValue; }
|
||||
function isUniqueViolation(error: unknown) { return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; }
|
||||
function assertAllowedKeys(input: Record<string, unknown>, allowed: string[]) { const unknown = Object.keys(input).filter((key) => !allowed.includes(key)); if (unknown.length) throw new BadRequestException(`不支持的字段: ${unknown.join(', ')}`); }
|
||||
function isSystemProtected(ip: string) {
|
||||
const builtIns = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16', '224.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', ...(process.env.SECURITY_BUILTIN_PROTECTED_NETWORKS ?? '').split(',').map((item) => item.trim()).filter(Boolean)];
|
||||
return builtIns.some((network) => networkContains(network, ip));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { SecurityEventController } from './security-event.controller';
|
||||
|
||||
describe('SecurityEventController', () => {
|
||||
const security = { recordEvent: jest.fn().mockResolvedValue({ accepted: true }) };
|
||||
const config = { get: jest.fn().mockReturnValue('internal-token-0123456789') };
|
||||
const controller = new SecurityEventController(security as never, config as never);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('rejects a public event injection without the internal token', () => {
|
||||
expect(() => controller.record({ ruleCode: 'ssh_auth_failure', sourceIp: '203.0.113.9' }, undefined)).toThrow(UnauthorizedException);
|
||||
expect(security.recordEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a fixed event from an authenticated local producer', async () => {
|
||||
await expect(controller.record({ ruleCode: 'ssh_auth_failure', sourceIp: '203.0.113.9' }, 'internal-token-0123456789')).resolves.toEqual({ accepted: true });
|
||||
expect(security.recordEvent).toHaveBeenCalledWith(expect.objectContaining({ ruleCode: 'ssh_auth_failure' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { SecurityDetectionService, type SecurityEventInput } from './security-detection.service';
|
||||
|
||||
@ApiTags('gateway-security-events')
|
||||
@Controller('gateway/events/security-detection')
|
||||
export class SecurityEventController {
|
||||
constructor(private readonly security: SecurityDetectionService, private readonly config: ConfigService) {}
|
||||
@Post() record(@Body() body: SecurityEventInput, @Headers('x-security-event-token') supplied?: string) {
|
||||
const expected = this.config.get<string>('SECURITY_EVENT_TOKEN');
|
||||
if (!expected || !supplied || !safeEqual(expected, supplied)) throw new UnauthorizedException('安全事件来源认证失败');
|
||||
return this.security.recordEvent(body);
|
||||
}
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b); }
|
||||
@@ -11,7 +11,7 @@ describe('GatewayEventsController protocol logging', () => {
|
||||
const protocolLogs = {
|
||||
record: jest.fn(),
|
||||
};
|
||||
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never);
|
||||
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never, { recordEvent: jest.fn() } as never);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SendChainService } from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
|
||||
@ApiTags('gateway-events')
|
||||
@Controller('gateway/events')
|
||||
@@ -26,6 +27,7 @@ export class GatewayEventsController {
|
||||
private readonly sendChain: SendChainService,
|
||||
private readonly smsConfig: SmsConfigService,
|
||||
private readonly protocolLogs: ProtocolLogsService,
|
||||
private readonly security: SecurityDetectionService,
|
||||
) {}
|
||||
|
||||
@Post('submit-result')
|
||||
@@ -81,8 +83,13 @@ export class GatewayEventsController {
|
||||
}
|
||||
|
||||
@Post('inbound/authenticate')
|
||||
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||
return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
|
||||
async authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||
try {
|
||||
return await this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
|
||||
} catch (error) {
|
||||
if (body.remoteIp) await this.security.recordEvent({ ruleCode: 'cmpp_auth_failure', sourceIp: body.remoteIp, account: body.account, protocol: body.version ?? 'cmpp', resultCode: error instanceof Error ? error.name : 'AUTH_FAILED' }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('inbound/submit')
|
||||
|
||||
@@ -9,9 +9,10 @@ import { AdminSendChainController } from './admin-send-chain.controller';
|
||||
import { ClientSendChainController } from './client-send-chain.controller';
|
||||
import { GatewayEventsController } from './gateway-events.controller';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
|
||||
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule],
|
||||
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
||||
providers: [SendChainService],
|
||||
exports: [SendChainService],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[Definition]
|
||||
failregex = ^<HOST> .* "(?:GET|POST|HEAD) /(?:\.env|\.git|wp-admin|wp-login\.php|phpmyadmin|vendor/phpunit|actuator|cgi-bin)(?:[/? ][^\"]*)?" (?:400|403|404) .*$
|
||||
ignoreregex =
|
||||
@@ -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 <name> <ip>
|
||||
actionunban =
|
||||
@@ -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
|
||||
@@ -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 或安全代理。
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
# Fail2ban 安全检测与人工封禁平台设计方案
|
||||
|
||||
> 版本:V1.0(需求评审稿)<br>
|
||||
> 日期:2026-08-14<br>
|
||||
> 范围:运营端自研 UI、安全检测、阈值配置、告警处置、人工封禁与解封<br>
|
||||
> 边界:第一版不自动封禁、不开放任意 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 封禁。
|
||||
- 保护名单、重新认证、权限、幂等、并发和操作审计全部通过。
|
||||
- 只有回读执行器确认真实生效后,页面才显示“已封禁”。
|
||||
- 不发送短信、不修改通道账号/密码/启停状态、企业余额或客户连接。
|
||||
@@ -0,0 +1,174 @@
|
||||
# Fail2ban 安全检测与人工封禁第一版测试用例
|
||||
|
||||
> 版本:V1.0(设计评审用例)<br>
|
||||
> 日期:2026-08-14<br>
|
||||
> 关联设计:`docs/fail2ban-assisted-blocking-design-20260814.md`<br>
|
||||
> 原则:真实后端、真实 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 命令输出、只验证前端样式或只写数据库不验证真实执行器,不能作为功能通过。
|
||||
@@ -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` 为准。
|
||||
|
||||
@@ -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 和发布前数据库、源码、环境备份判断回滚方式。
|
||||
|
||||
## 账号和密钥
|
||||
|
||||
@@ -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 与执行器证据交叉验证。
|
||||
|
||||
@@ -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/`和空文件`=`继续作为受保护项排除提交。
|
||||
|
||||
@@ -46,6 +46,7 @@ func main() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
||||
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
|
||||
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
|
||||
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request<SecurityBlock>(`/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<SecurityBlock[]>('/admin/security-detection/blocks'),
|
||||
unblockSecurityBlock: (id: string, reason: string) => request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
listProtectedNetworks: () => request<SecurityProtectedNetwork[]>('/admin/security-detection/protected-networks'),
|
||||
addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './operations';
|
||||
export * from './governance';
|
||||
export * from './signature-retirement';
|
||||
export * from './infrastructure-monitoring';
|
||||
export * from './security-detection';
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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}}
|
||||
@@ -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<string, string> = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' };
|
||||
const severityLabels: Record<string, string> = { 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<ComponentProps<typeof BaseModal>, 'open'>) { return <BaseModal {...props} open />; }
|
||||
|
||||
export function AdminSecurityDetectionPage() {
|
||||
const [overview, setOverview] = useState<SecurityOverview | null>(null);
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||
const [rules, setRules] = useState<SecurityRule[]>([]);
|
||||
const [blocks, setBlocks] = useState<SecurityBlock[]>([]);
|
||||
const [networks, setNetworks] = useState<SecurityProtectedNetwork[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [blockAlert, setBlockAlert] = useState<SecurityAlert | null>(null);
|
||||
const [editRule, setEditRule] = useState<SecurityRule | null>(null);
|
||||
const [showNetwork, setShowNetwork] = useState(false);
|
||||
const [reasonAction, setReasonAction] = useState<{ title: string; confirmLabel: string; run: (reason: string) => Promise<void> } | 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<TableColumn<SecurityAlert>> = [
|
||||
{ key: 'level', title: '级别', width: '80px', render: (item) => <Tag tone={item.severity === 'critical' ? 'danger' : item.severity === 'high' ? 'warning' : 'info'}>{severityLabels[item.severity] ?? item.severity}</Tag> },
|
||||
{ key: 'rule', title: '检测类型', width: '180px', render: (item) => <div className="security-cell"><strong>{item.rule.name}</strong><span>{item.rule.code}</span></div> },
|
||||
{ key: 'ip', title: '来源 IP', width: '150px', render: (item) => <code>{item.sourceIp}</code> },
|
||||
{ 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) => <Tag tone={item.status === 'blocked' ? 'success' : item.status === 'block_failed' ? 'danger' : 'neutral'}>{statusLabels[item.status] ?? item.status}</Tag> },
|
||||
{ key: 'action', title: '人工处置', width: '190px', render: (item) => <div className="security-actions"><Button disabled={!['open', 'acknowledged', 'block_failed'].includes(item.status)} onClick={() => setBlockAlert(item)} size="sm" variant="danger">封禁</Button><Button disabled={!['open', 'acknowledged', 'block_failed'].includes(item.status)} onClick={() => void ignore(item)} size="sm" variant="ghost">忽略</Button></div> },
|
||||
];
|
||||
function ignore(item: SecurityAlert) { setReasonAction({ title: `忽略告警 · ${item.sourceIp}`, confirmLabel: '确认忽略', run: async (reason) => { await adminApi.ignoreSecurityAlert(item.id, reason); await load(); } }); }
|
||||
|
||||
const ruleColumns: Array<TableColumn<SecurityRule>> = [
|
||||
{ key: 'name', title: '规则', width: '230px', render: (item) => <div className="security-cell"><strong>{item.name}</strong><span>{item.code}</span></div> },
|
||||
{ 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) => <Tag tone={item.applyStatus === 'effective' ? 'success' : item.applyStatus === 'failed' ? 'danger' : 'warning'}>{item.effectiveVersion}/{item.configVersion}</Tag> },
|
||||
{ key: 'enabled', title: '启用', width: '90px', render: (item) => <Tag tone={item.enabled ? 'success' : 'neutral'}>{item.enabled ? '启用' : '停用'}</Tag> },
|
||||
{ key: 'action', title: '操作', width: '90px', render: (item) => <Button onClick={() => setEditRule(item)} size="sm" variant="ghost">配置</Button> },
|
||||
];
|
||||
const blockColumns: Array<TableColumn<SecurityBlock>> = [
|
||||
{ key: 'ip', title: 'IP', width: '160px', render: (item) => <code>{item.sourceIp}</code> }, { key: 'executor', title: '执行器', width: '140px', render: (item) => item.executor },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (item) => <Tag tone={item.status === 'blocked' ? 'success' : item.status === 'failed' ? 'danger' : 'neutral'}>{statusLabels[item.status] ?? item.status}</Tag> },
|
||||
{ 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) => <Button disabled={item.status !== 'blocked'} onClick={() => void releaseBlock(item)} size="sm" variant="ghost">解封</Button> },
|
||||
];
|
||||
function releaseBlock(item: SecurityBlock) { setReasonAction({ title: `人工解封 · ${item.sourceIp}`, confirmLabel: '确认解封', run: async (reason) => { await adminApi.unblockSecurityBlock(item.id, reason); await load(); } }); }
|
||||
const networkColumns: Array<TableColumn<SecurityProtectedNetwork>> = [
|
||||
{ key: 'network', title: 'IP / 网段', width: '180px', render: (item) => <code>{item.network}</code> }, { 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) => <Tag tone={item.enabled ? 'success' : 'neutral'}>{item.enabled ? '保护中' : '已停用'}</Tag> },
|
||||
];
|
||||
const chartOption = useMemo<EChartsOption>(() => ({ 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: <><div className="security-overview-grid"><article className="surface security-chart"><header><strong>24小时检测来源</strong><span>真实事件聚合</span></header>{overview?.sourceDistribution.length ? <Chart height={265} option={chartOption} /> : <div className="security-empty">暂无检测事件</div>}</article><article className="surface security-latest"><header><strong>最新告警</strong><span>{overview?.alerts.length ?? 0} 条</span></header>{overview?.alerts.slice(0, 6).map((item) => <div className="security-latest-row" key={item.id}><span className={`severity-dot is-${item.severity}`} /><div><strong>{item.rule.name}</strong><small>{item.sourceIp} · {item.eventCount} 次</small></div><time>{formatTime(item.lastOccurredAt)}</time></div>)}</article></div></> },
|
||||
{ value: 'alerts', label: '告警中心', content: <section className="surface security-table-card"><Table columns={alertColumns} data={alerts} emptyText="暂无安全告警" rowKey="id" /></section> },
|
||||
{ value: 'rules', label: '规则配置', content: <section className="surface security-table-card"><div className="security-note"><Settings2 size={17} /><span>所有阈值来自数据库;修改后必须由受限安全代理验证并应用,版本一致才标记生效。</span></div><Table columns={ruleColumns} data={rules} rowKey="id" /></section> },
|
||||
{ value: 'blocks', label: '封禁记录', content: <section className="surface security-table-card"><Table columns={blockColumns} data={blocks} emptyText="暂无人工封禁记录" rowKey="id" /></section> },
|
||||
{ value: 'protected', label: '保护名单', content: <section className="surface security-table-card"><div className="security-section-toolbar"><span>受保护的运维出口、内网和可信代理永远不能从面板封禁。</span><Button onClick={() => setShowNetwork(true)}>新增保护网段</Button></div><Table columns={networkColumns} data={networks} emptyText="暂无保护网段" rowKey="id" /></section> },
|
||||
];
|
||||
|
||||
return <section className="page-stack admin-security-page"><div className="page-heading security-heading"><div><Breadcrumb items={['安全控制', '安全检测与封禁']} /><h1>安全检测与封禁</h1><p>Fail2ban 与业务事件只负责检测,由运营人员复核后人工处置</p></div><Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void load()} variant="ghost">刷新</Button></div>
|
||||
{error ? <div className="security-error" role="alert"><AlertTriangle size={18} />{error}</div> : null}
|
||||
<div className="security-kpis"><Kpi icon={<BellRing />} label="24小时检测事件" value={overview?.totalEvents ?? 0} /><Kpi icon={<AlertTriangle />} label="待处置告警" value={overview?.activeAlerts ?? 0} danger={Boolean(overview?.criticalAlerts)} /><Kpi icon={<Ban />} label="生效封禁" value={overview?.activeBlocks ?? 0} /><Kpi icon={overview?.health.agent === 'healthy' ? <ShieldCheck /> : <ShieldOff />} label="安全代理" value={overview?.health.agent === 'healthy' ? '在线' : '不可用'} danger={overview?.health.agent !== 'healthy'} /></div>
|
||||
<Tabs items={tabs} />
|
||||
{blockAlert ? <BlockDialog alert={blockAlert} onClose={() => setBlockAlert(null)} onSaved={async () => { setBlockAlert(null); await load(); }} /> : null}
|
||||
{editRule ? <RuleDialog rule={editRule} onClose={() => setEditRule(null)} onSaved={async () => { setEditRule(null); await load(); }} /> : null}
|
||||
{showNetwork ? <NetworkDialog onClose={() => setShowNetwork(false)} onSaved={async () => { setShowNetwork(false); await load(); }} /> : null}
|
||||
{reasonAction ? <ActionReasonDialog action={reasonAction} onClose={() => setReasonAction(null)} /> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Kpi({ icon, label, value, danger = false }: { icon: React.ReactNode; label: string; value: React.ReactNode; danger?: boolean }) { return <article className={`surface security-kpi ${danger ? 'is-danger' : ''}`}><div>{icon}</div><span>{label}</span><strong>{value}</strong></article>; }
|
||||
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 <Modal title="确认人工封禁" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving || reason.trim().length < 5} onClick={() => void save()} variant="danger">{saving ? '执行并回读中' : '确认封禁'}</Button></>}><div className="security-dialog"><div className="security-target"><span>目标 IP</span><strong>{alert.sourceIp}</strong><small>{alert.rule.name} · 窗口命中 {alert.eventCount} 次</small></div><Input disabled label="服务端固定执行器" value={executor} /><Select label="封禁时长" options={durationOptions.filter((item) => Number(item.value) <= alert.rule.maximumBlockSeconds)} value={duration} onChange={(event) => setDuration(event.target.value)} /><Textarea label="封禁原因" minLength={5} onChange={(event) => setReason(event.target.value)} required value={reason} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
function RuleDialog({ rule, onClose, onSaved }: { rule: SecurityRule; onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState(rule); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const number = (key: keyof SecurityRule) => (event: React.ChangeEvent<HTMLInputElement>) => setValue({ ...value, [key]: Number(event.target.value) }); async function save() { setSaving(true); try { await adminApi.updateSecurityRule(rule.id, value); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : '规则保存失败'); } finally { setSaving(false); } } return <Modal title={`配置规则 · ${rule.name}`} onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '验证并应用中' : '保存并应用'}</Button></>}><div className="security-form-grid"><Select label="启用状态" options={[{ value: 'true', label: '启用' }, { value: 'false', label: '停用' }]} value={String(value.enabled)} onChange={(event) => setValue({ ...value, enabled: event.target.value === 'true' })} /><Select label="告警级别" options={['low','medium','high','critical'].map((item) => ({ value: item, label: severityLabels[item] }))} value={value.severity} onChange={(event) => setValue({ ...value, severity: event.target.value })} /><Input label="触发次数" min={1} onChange={number('threshold')} type="number" value={value.threshold} /><Input label="检测窗口(秒)" min={10} onChange={number('windowSeconds')} type="number" value={value.windowSeconds} /><Input label="告警冷却(秒)" min={0} onChange={number('cooldownSeconds')} type="number" value={value.cooldownSeconds} /><Input label="默认封禁(秒)" min={600} onChange={number('defaultBlockSeconds')} type="number" value={value.defaultBlockSeconds} /><Input label="最大封禁(秒)" min={600} onChange={number('maximumBlockSeconds')} type="number" value={value.maximumBlockSeconds} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
function NetworkDialog({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState({ network: '', name: '', reason: '' }); const [error, setError] = useState(''); async function save() { try { await adminApi.addProtectedNetwork(value); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : '新增失败'); } } return <Modal title="新增保护网段" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!value.network || !value.name || !value.reason} onClick={() => void save()}>确认保护</Button></>}><div className="security-dialog"><Input label="IP 或 CIDR" placeholder="例如 203.0.113.10 或 10.0.0.0/8" value={value.network} onChange={(event) => setValue({ ...value, network: event.target.value })} /><Input label="名称" value={value.name} onChange={(event) => setValue({ ...value, name: event.target.value })} /><Textarea label="保护原因" value={value.reason} onChange={(event) => setValue({ ...value, reason: event.target.value })} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
|
||||
function ActionReasonDialog({ action, onClose }: { action: { title: string; confirmLabel: string; run: (reason: string) => Promise<void> }; onClose: () => void }) { const [reason, setReason] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); async function save() { setSaving(true); setError(''); try { await action.run(reason); onClose(); } catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); } finally { setSaving(false); } } return <Modal title={action.title} onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving || reason.trim().length < 5} onClick={() => void save()}>{saving ? '处理中' : action.confirmLabel}</Button></>}><div className="security-dialog"><Textarea autoFocus label="操作原因" minLength={5} onChange={(event) => setReason(event.target.value)} required value={reason} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
@@ -188,6 +188,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||||
{ label: '安全检测与封禁', to: '/admin/security-detection', icon: ShieldCheck },
|
||||
{ label: '签名清退预警', to: '/admin/signature-retirement', icon: AlertTriangle },
|
||||
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
|
||||
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
|
||||
|
||||
@@ -39,6 +39,7 @@ import { AdminSignatureRetirementPage } from '@/apps/admin/AdminSignatureRetirem
|
||||
import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage';
|
||||
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
|
||||
import { AdminSystemMonitoringPage } from '@/apps/admin/system-monitoring/AdminSystemMonitoringPage';
|
||||
import { AdminSecurityDetectionPage } from '@/apps/admin/security-detection/AdminSecurityDetectionPage';
|
||||
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
|
||||
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
|
||||
import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage';
|
||||
@@ -144,6 +145,7 @@ export function AppRoutes() {
|
||||
<Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} />
|
||||
<Route path="system-logs" element={<AdminSystemLogsPage />} />
|
||||
<Route path="system-monitoring" element={<AdminSystemMonitoringPage />} />
|
||||
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -33,6 +33,8 @@ SMS_RECEIPT_TIMEOUT_HOURS="${SMS_RECEIPT_TIMEOUT_HOURS:-72}"
|
||||
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS="${SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS:-300000}"
|
||||
PROMETHEUS_URL="${PROMETHEUS_URL:-http://127.0.0.1:9090}"
|
||||
PROMETHEUS_QUERY_TIMEOUT_MS="${PROMETHEUS_QUERY_TIMEOUT_MS:-5000}"
|
||||
SECURITY_EVENT_TOKEN="${SECURITY_EVENT_TOKEN:-$(openssl rand -hex 32 | tr -d '\n')}"
|
||||
SECURITY_BUILTIN_PROTECTED_NETWORKS="${SECURITY_BUILTIN_PROTECTED_NETWORKS:-${CMPP_PUBLIC_HOST}/32}"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root." >&2
|
||||
@@ -45,7 +47,7 @@ install_packages() {
|
||||
log "Installing OS packages"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg git nginx redis-server postgresql postgresql-contrib build-essential tar gzip xz-utils openssl
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg git nginx redis-server postgresql postgresql-contrib build-essential tar gzip xz-utils openssl fail2ban nftables
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y ca-certificates curl git nginx redis postgresql-server postgresql-contrib gcc gcc-c++ make tar gzip xz openssl
|
||||
if [[ ! -d /var/lib/pgsql/data/base ]]; then
|
||||
@@ -208,6 +210,10 @@ GATEWAY_CMPP_ADDR=${GATEWAY_CMPP_ADDR}
|
||||
CMPP_PUBLIC_HOST=${CMPP_PUBLIC_HOST}
|
||||
CMPP_PUBLIC_PORT=${CMPP_PUBLIC_PORT}
|
||||
API_BASE_URL=http://127.0.0.1:${API_PORT}/api
|
||||
SECURITY_EVENT_TOKEN=${SECURITY_EVENT_TOKEN}
|
||||
SECURITY_AGENT_SOCKET=/run/cmpp-security-agent/agent.sock
|
||||
TRUSTED_PROXY_IPS=127.0.0.1,::1
|
||||
SECURITY_BUILTIN_PROTECTED_NETWORKS=${SECURITY_BUILTIN_PROTECTED_NETWORKS}
|
||||
EOF
|
||||
chmod 600 /etc/cmpp-platform/cmpp-platform.env
|
||||
cat >/etc/cmpp-platform/minio.env <<EOF
|
||||
@@ -221,6 +227,11 @@ write_services() {
|
||||
log "Writing systemd and nginx configuration"
|
||||
local node_bin
|
||||
node_bin="$(command -v node)"
|
||||
getent group cmpp-security >/dev/null || groupadd --system cmpp-security
|
||||
id cmpp-api >/dev/null 2>&1 || useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin --gid cmpp-security cmpp-api
|
||||
install -d -m 0750 /etc/nginx/snippets
|
||||
touch /etc/nginx/snippets/cmpp-security-deny.conf
|
||||
chmod 0640 /etc/nginx/snippets/cmpp-security-deny.conf
|
||||
cat >/etc/systemd/system/cmpp-minio.service <<'EOF'
|
||||
[Unit]
|
||||
Description=CMPP MinIO object storage
|
||||
@@ -244,6 +255,8 @@ Description=CMPP Platform API
|
||||
After=network.target postgresql.service redis.service cmpp-minio.service
|
||||
|
||||
[Service]
|
||||
User=cmpp-api
|
||||
Group=cmpp-security
|
||||
WorkingDirectory=${APP_DIR}/api
|
||||
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
|
||||
ExecStart=${node_bin} dist/main.js
|
||||
@@ -286,6 +299,7 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 5;
|
||||
gzip_types application/json application/javascript text/javascript text/css text/plain text/csv image/svg+xml;
|
||||
include /etc/nginx/snippets/cmpp-security-deny.conf;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:${API_PORT}/api/;
|
||||
|
||||
@@ -52,6 +52,7 @@ rm -rf api/dist api/tsconfig.build.tsbuildinfo "$APP_DIR/dist/cmpp-gateway"
|
||||
npm run build
|
||||
npm --prefix api run build
|
||||
(cd gateway && GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" /usr/local/bin/go build -o "$APP_DIR/dist/cmpp-gateway" ./cmd/gateway)
|
||||
(cd gateway && GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" /usr/local/bin/go build -o "$APP_DIR/dist/cmpp-security-agent" ./cmd/security-agent)
|
||||
chmod 755 "$APP_DIR/dist" "$APP_DIR/dist/assets"
|
||||
find "$APP_DIR/dist/assets" -type d -exec chmod 755 {} +
|
||||
find "$APP_DIR/dist/assets" -type f -exec chmod 644 {} +
|
||||
@@ -64,6 +65,9 @@ chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
|
||||
echo "[deploy] Ensuring runtime log directories"
|
||||
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/gateway"
|
||||
|
||||
echo "[deploy] Installing restricted security boundary"
|
||||
bash "$APP_DIR/tools/security/install-security-agent.sh"
|
||||
|
||||
echo "[deploy] Ensuring HTTP response compression"
|
||||
cat >/etc/nginx/conf.d/cmpp-compression.conf <<'EOF'
|
||||
gzip on;
|
||||
@@ -85,6 +89,7 @@ else
|
||||
systemctl enable --now cmpp-api cmpp-gateway nginx
|
||||
fi
|
||||
systemctl restart cmpp-gateway
|
||||
systemctl restart cmpp-security-agent
|
||||
systemctl restart cmpp-api
|
||||
systemctl restart nginx
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/cmpp-platform}"
|
||||
if [[ "$(id -u)" -ne 0 ]]; then echo "Run as root." >&2; exit 1; fi
|
||||
for command_name in fail2ban-client nft nginx systemctl; do command -v "$command_name" >/dev/null || { echo "Missing command: $command_name" >&2; exit 1; }; done
|
||||
[[ -x "$APP_DIR/dist/cmpp-security-agent" ]] || { echo "Missing built security agent" >&2; exit 1; }
|
||||
|
||||
getent group cmpp-security >/dev/null || groupadd --system cmpp-security
|
||||
id cmpp-api >/dev/null 2>&1 || useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin cmpp-api
|
||||
usermod -a -G cmpp-security cmpp-api
|
||||
install -d -o root -g cmpp-security -m 0770 /run/cmpp-security-agent
|
||||
install -d -o root -g cmpp-security -m 0750 /var/lib/cmpp-security-agent
|
||||
install -d -o cmpp-api -g cmpp-security -m 0750 "$APP_DIR/logs/api"
|
||||
[[ -d /var/lib/cmpp-platform/object-storage ]] && chown -R cmpp-api:cmpp-security /var/lib/cmpp-platform/object-storage
|
||||
|
||||
install -m 0640 "$APP_DIR/deploy/security/cmpp-report-only.conf" /etc/fail2ban/action.d/cmpp-report-only.conf
|
||||
install -m 0640 "$APP_DIR/deploy/security/cmpp-http-scan.conf" /etc/fail2ban/filter.d/cmpp-http-scan.conf
|
||||
install -d -m 0750 /etc/nginx/snippets /etc/nftables.d
|
||||
touch /etc/nginx/snippets/cmpp-security-deny.conf
|
||||
chmod 0640 /etc/nginx/snippets/cmpp-security-deny.conf
|
||||
|
||||
cat >/etc/nftables.d/cmpp-security.nft <<'EOF'
|
||||
table inet cmpp_security {
|
||||
set blocked_ipv4 { type ipv4_addr; flags timeout; }
|
||||
set blocked_ipv6 { type ipv6_addr; flags timeout; }
|
||||
chain input { type filter hook input priority -10; policy accept; ip saddr @blocked_ipv4 drop; ip6 saddr @blocked_ipv6 drop; }
|
||||
}
|
||||
EOF
|
||||
grep -q 'cmpp-security.nft' /etc/nftables.conf || printf '\ninclude "/etc/nftables.d/cmpp-security.nft"\n' >>/etc/nftables.conf
|
||||
nft -c -f /etc/nftables.conf
|
||||
nft list table inet cmpp_security >/dev/null 2>&1 || nft -f /etc/nftables.d/cmpp-security.nft
|
||||
|
||||
sed "s#/opt/cmpp-platform/current#$APP_DIR#g" "$APP_DIR/deploy/security/cmpp-security-agent.service" >/etc/systemd/system/cmpp-security-agent.service
|
||||
install -d -m 0755 /etc/systemd/system/cmpp-api.service.d
|
||||
cat >/etc/systemd/system/cmpp-api.service.d/security-boundary.conf <<EOF
|
||||
[Service]
|
||||
User=cmpp-api
|
||||
Group=cmpp-security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=true
|
||||
ReadWritePaths=$APP_DIR/logs/api /var/lib/cmpp-platform/object-storage
|
||||
EOF
|
||||
|
||||
fail2ban-client -t
|
||||
nginx -t
|
||||
systemctl daemon-reload
|
||||
systemctl enable cmpp-security-agent
|
||||
echo "Security boundary installed. Restart cmpp-security-agent and cmpp-api only in the approved release window."
|
||||
Reference in New Issue
Block a user