feat: add Fail2ban security detection console

This commit is contained in:
hectorzhao
2026-08-14 10:58:18 +08:00
parent b78faa1aa2
commit d30d9ea4d0
45 changed files with 1967 additions and 18 deletions
@@ -0,0 +1,80 @@
CREATE TABLE "SecurityDetectionRule" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sourceType" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"threshold" INTEGER NOT NULL,
"windowSeconds" INTEGER NOT NULL,
"cooldownSeconds" INTEGER NOT NULL,
"severity" TEXT NOT NULL,
"defaultBlockSeconds" INTEGER NOT NULL,
"maximumBlockSeconds" INTEGER NOT NULL,
"configVersion" INTEGER NOT NULL DEFAULT 1,
"effectiveVersion" INTEGER NOT NULL DEFAULT 0,
"applyStatus" TEXT NOT NULL DEFAULT 'pending',
"lastApplyError" TEXT,
"pendingConfig" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityDetectionRule_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityDetectionEvent" (
"id" TEXT NOT NULL, "eventKey" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
"sourceIp" TEXT NOT NULL, "sourcePort" INTEGER, "accountHash" TEXT,
"path" TEXT, "protocol" TEXT, "resultCode" TEXT, "evidence" JSONB,
"occurredAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SecurityDetectionEvent_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityAlert" (
"id" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
"sourceIp" TEXT NOT NULL, "severity" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'open',
"eventCount" INTEGER NOT NULL DEFAULT 0, "windowStartedAt" TIMESTAMP(3) NOT NULL,
"firstOccurredAt" TIMESTAMP(3) NOT NULL, "lastOccurredAt" TIMESTAMP(3) NOT NULL,
"acknowledgedAt" TIMESTAMP(3), "acknowledgedById" TEXT, "ignoredAt" TIMESTAMP(3),
"ignoredById" TEXT, "ignoreReason" TEXT, "blockId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityAlert_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityBlock" (
"id" TEXT NOT NULL, "operationKey" TEXT NOT NULL, "alertId" TEXT, "sourceIp" TEXT NOT NULL,
"executor" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'requested', "durationSeconds" INTEGER NOT NULL,
"reason" TEXT NOT NULL, "requestedById" TEXT NOT NULL, "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"appliedAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "releasedAt" TIMESTAMP(3), "releasedById" TEXT,
"executorReference" TEXT, "lastError" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SecurityBlock_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SecurityProtectedNetwork" (
"id" TEXT NOT NULL, "network" TEXT NOT NULL, "name" TEXT NOT NULL, "reason" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true, "createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityProtectedNetwork_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SecurityDetectionRule_code_key" ON "SecurityDetectionRule"("code");
CREATE INDEX "SecurityDetectionRule_enabled_sourceType_idx" ON "SecurityDetectionRule"("enabled", "sourceType");
CREATE UNIQUE INDEX "SecurityDetectionEvent_eventKey_key" ON "SecurityDetectionEvent"("eventKey");
CREATE INDEX "SecurityDetectionEvent_ruleId_occurredAt_idx" ON "SecurityDetectionEvent"("ruleId", "occurredAt");
CREATE INDEX "SecurityDetectionEvent_sourceIp_occurredAt_idx" ON "SecurityDetectionEvent"("sourceIp", "occurredAt");
CREATE UNIQUE INDEX "SecurityAlert_fingerprint_key" ON "SecurityAlert"("fingerprint");
CREATE INDEX "SecurityAlert_status_severity_lastOccurredAt_idx" ON "SecurityAlert"("status", "severity", "lastOccurredAt");
CREATE INDEX "SecurityAlert_sourceIp_status_lastOccurredAt_idx" ON "SecurityAlert"("sourceIp", "status", "lastOccurredAt");
CREATE INDEX "SecurityAlert_ruleId_status_lastOccurredAt_idx" ON "SecurityAlert"("ruleId", "status", "lastOccurredAt");
CREATE UNIQUE INDEX "SecurityBlock_operationKey_key" ON "SecurityBlock"("operationKey");
CREATE INDEX "SecurityBlock_status_expiresAt_idx" ON "SecurityBlock"("status", "expiresAt");
CREATE INDEX "SecurityBlock_sourceIp_status_requestedAt_idx" ON "SecurityBlock"("sourceIp", "status", "requestedAt");
CREATE INDEX "SecurityBlock_alertId_idx" ON "SecurityBlock"("alertId");
CREATE UNIQUE INDEX "SecurityProtectedNetwork_network_key" ON "SecurityProtectedNetwork"("network");
CREATE INDEX "SecurityProtectedNetwork_enabled_createdAt_idx" ON "SecurityProtectedNetwork"("enabled", "createdAt");
ALTER TABLE "SecurityDetectionEvent" ADD CONSTRAINT "SecurityDetectionEvent_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SecurityAlert" ADD CONSTRAINT "SecurityAlert_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO "SecurityDetectionRule" ("id", "code", "name", "sourceType", "threshold", "windowSeconds", "cooldownSeconds", "severity", "defaultBlockSeconds", "maximumBlockSeconds", "configVersion", "effectiveVersion", "applyStatus", "updatedAt") VALUES
('sec_admin_login', 'admin_login_failure', '运营端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_client_login', 'client_login_failure', '客户端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_ssh_auth', 'ssh_auth_failure', 'SSH认证失败', 'fail2ban', 6, 600, 1800, 'high', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_cmpp_auth', 'cmpp_auth_failure', 'CMPP认证失败', 'gateway', 5, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_cmpp_abuse', 'cmpp_protocol_abuse', 'CMPP协议滥用', 'gateway', 20, 60, 900, 'critical', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_key', 'http_invalid_api_key', 'HTTP错误密钥', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_sign', 'http_signature_failure', 'HTTP签名错误', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_replay', 'http_replay_attempt', 'HTTP重放尝试', 'application', 3, 600, 1800, 'critical', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
('sec_http_scan', 'http_malicious_scan', 'HTTP恶意扫描', 'fail2ban', 20, 60, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP);
+111
View File
@@ -2321,3 +2321,114 @@ model GatewayDownstreamRecoveryStatus {
@@index([state, updatedAt]) @@index([state, updatedAt])
@@index([nextRetryAt]) @@index([nextRetryAt])
} }
model SecurityDetectionRule {
id String @id @default(cuid())
code String @unique
name String
sourceType String
enabled Boolean @default(true)
threshold Int
windowSeconds Int
cooldownSeconds Int
severity String
defaultBlockSeconds Int
maximumBlockSeconds Int
configVersion Int @default(1)
effectiveVersion Int @default(0)
applyStatus String @default("pending")
lastApplyError String?
pendingConfig Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events SecurityDetectionEvent[]
alerts SecurityAlert[]
@@index([enabled, sourceType])
}
model SecurityDetectionEvent {
id String @id @default(cuid())
eventKey String @unique
ruleId String
sourceIp String
sourcePort Int?
accountHash String?
path String?
protocol String?
resultCode String?
evidence Json?
occurredAt DateTime
createdAt DateTime @default(now())
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
@@index([ruleId, occurredAt])
@@index([sourceIp, occurredAt])
}
model SecurityAlert {
id String @id @default(cuid())
fingerprint String @unique
ruleId String
sourceIp String
severity String
status String @default("open")
eventCount Int @default(0)
windowStartedAt DateTime
firstOccurredAt DateTime
lastOccurredAt DateTime
acknowledgedAt DateTime?
acknowledgedById String?
ignoredAt DateTime?
ignoredById String?
ignoreReason String?
blockId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
@@index([status, severity, lastOccurredAt])
@@index([sourceIp, status, lastOccurredAt])
@@index([ruleId, status, lastOccurredAt])
}
model SecurityBlock {
id String @id @default(cuid())
operationKey String @unique
alertId String?
sourceIp String
executor String
status String @default("requested")
durationSeconds Int
reason String
requestedById String
requestedAt DateTime @default(now())
appliedAt DateTime?
expiresAt DateTime?
releasedAt DateTime?
releasedById String?
executorReference String?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, expiresAt])
@@index([sourceIp, status, requestedAt])
@@index([alertId])
}
model SecurityProtectedNetwork {
id String @id @default(cuid())
network String @unique
name String
reason String
enabled Boolean @default(true)
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([enabled, createdAt])
}
+2
View File
@@ -25,6 +25,7 @@ import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module'; import { TenantsModule } from './tenants/tenants.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module'; import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
import { SecurityDetectionModule } from './security-detection/security-detection.module';
@Module({ @Module({
imports: [ imports: [
@@ -53,6 +54,7 @@ import { SignatureRetirementModule } from './signature-retirement/signature-reti
InfrastructureMonitoringModule, InfrastructureMonitoringModule,
OpenApiModule, OpenApiModule,
SignatureRetirementModule, SignatureRetirementModule,
SecurityDetectionModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
+30 -3
View File
@@ -7,6 +7,8 @@ import type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { requestContext } from '../common/request-context';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
type CookieResponse = { type CookieResponse = {
cookie(name: string, value: string, options: Record<string, unknown>): void; cookie(name: string, value: string, options: Record<string, unknown>): void;
@@ -16,7 +18,7 @@ type CookieResponse = {
@ApiTags('auth') @ApiTags('auth')
@Controller() @Controller()
export class AuthController { 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') @Get('admin/auth/captcha')
adminCaptcha() { adminCaptcha() {
@@ -25,7 +27,14 @@ export class AuthController {
@Post('admin/auth/login') @Post('admin/auth/login')
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { 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') @Get('client/auth/captcha')
@@ -35,7 +44,14 @@ export class AuthController {
@Post('client/auth/login') @Post('client/auth/login')
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { 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']) @Get(['admin/auth/session', 'client/auth/session'])
@@ -121,6 +137,17 @@ export class AuthController {
return publicResult; 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>) { private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({ 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 }, data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
+2 -1
View File
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RecentAuthenticationGuard } from './recent-authentication.guard'; import { RecentAuthenticationGuard } from './recent-authentication.guard';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({ @Module({
imports: [UsersModule], imports: [UsersModule, SecurityDetectionModule],
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [
AuthService, AuthService,
+4 -1
View File
@@ -8,7 +8,10 @@ export class RequestContextMiddleware implements NestMiddleware {
use(request: RequestLike, _response: unknown, next: () => void) { use(request: RequestLike, _response: unknown, next: () => void) {
const forwarded = request.headers['x-forwarded-for']; const forwarded = request.headers['x-forwarded-for'];
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0]; 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); requestContext.run({ ipAddress }, next);
} }
} }
+18 -2
View File
@@ -5,12 +5,13 @@ import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto'; import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types'; import type { OpenApiRequestLike } from './open-api.types';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable() @Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy { export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis; private redis?: IORedis;
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>(); const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
const nonce = header(request, 'x-nonce'); const nonce = header(request, 'x-nonce');
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, ''); const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
if (!accessKey || !timestampText || !nonce || !suppliedSignature) { 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接口鉴权请求头' }); throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
} }
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) { 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 格式非法' }); throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
} }
const credential = await this.prisma.httpApiCredential.findUnique({ const credential = await this.prisma.httpApiCredential.findUnique({
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } }, include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
}); });
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) { 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: '访问凭据无效或已失效' }); throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
} }
const config = credential.application.httpConfig; const config = credential.application.httpConfig;
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
} }
const timestamp = Number(timestampText); const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) { 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: '请求时间戳已过期' }); throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
} }
const sourceIp = requestIp(request); const sourceIp = requestIp(request);
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
const expectedBuffer = Buffer.from(expected, 'hex'); const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0); const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) { 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: '请求签名校验失败' }); throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
} }
const redis = this.getRedis(); const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX'); const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
if (nonceAccepted !== 'OK') { if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' }); throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
} }
const second = Math.floor(Date.now() / 1000); 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 }); this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis; 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) { function header(request: OpenApiRequestLike, name: string) {
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) { function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim(); 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) { function ipMatches(ip: string, rule: string) {
+2 -1
View File
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
import { OpenApiAuthGuard } from './open-api-auth.guard'; import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiController } from './open-api.controller'; import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service'; import { OpenApiService } from './open-api.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({ @Module({
imports: [PrismaModule, forwardRef(() => SendChainModule)], imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController], controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard], providers: [OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService], 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 = { const protocolLogs = {
record: jest.fn(), 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(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -18,6 +18,7 @@ import { SendChainService } from './send-chain.service';
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts'; import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service'; import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@ApiTags('gateway-events') @ApiTags('gateway-events')
@Controller('gateway/events') @Controller('gateway/events')
@@ -26,6 +27,7 @@ export class GatewayEventsController {
private readonly sendChain: SendChainService, private readonly sendChain: SendChainService,
private readonly smsConfig: SmsConfigService, private readonly smsConfig: SmsConfigService,
private readonly protocolLogs: ProtocolLogsService, private readonly protocolLogs: ProtocolLogsService,
private readonly security: SecurityDetectionService,
) {} ) {}
@Post('submit-result') @Post('submit-result')
@@ -81,8 +83,13 @@ export class GatewayEventsController {
} }
@Post('inbound/authenticate') @Post('inbound/authenticate')
authenticateInbound(@Body() body: GatewayInboundAuthDto) { async authenticateInbound(@Body() body: GatewayInboundAuthDto) {
return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform'); 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') @Post('inbound/submit')
+2 -1
View File
@@ -9,9 +9,10 @@ import { AdminSendChainController } from './admin-send-chain.controller';
import { ClientSendChainController } from './client-send-chain.controller'; import { ClientSendChainController } from './client-send-chain.controller';
import { GatewayEventsController } from './gateway-events.controller'; import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service'; import { SendChainService } from './send-chain.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@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], controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService], providers: [SendChainService],
exports: [SendChainService], exports: [SendChainService],
+3
View File
@@ -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 =
+8
View File
@@ -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
+5
View File
@@ -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. 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个明细模块;“今日活跃签名”指标仍使用当天真实发送聚合。今日消费金额的主数字必须与今日发送总量使用相同字号、字重和深色层级。 2. 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个明细模块;“今日活跃签名”指标仍使用当天真实发送聚合。今日消费金额的主数字必须与今日发送总量使用相同字号、字重和深色层级。
3. 企业应用管理列表的状态、到达率、单价列在现有基础上缩窄约20%,提升大屏一次展示完整表格的概率;不得通过隐藏真实字段实现。 3. 企业应用管理列表的状态、到达率、单价列在现有基础上缩窄约20%,提升大屏一次展示完整表格的概率;不得通过隐藏真实字段实现。
4. 移动、联通、电信数据展示统一使用全局`CarrierTag`低饱和标签,包括通道与通道组、监控、报备、签名、短信审核、任务号码、发送记录及客户端发送详情等页面。筛选/表单控件的选项文案、图表图例、导出文本和业务说明仍使用纯文本,避免破坏交互、可访问性和机器可读输出;可取得运营商集合的三网通道按三个运营商标签展示,只有历史通道级字段时使用中性的“三网”标签。 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` 为准。
+16
View File
@@ -93,6 +93,22 @@ git reset --hard origin/main
bash tools/deploy/production-deploy.sh 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-onlynftables/Nginx 回读与数据库状态一致。任何一项失败均不得开放人工封禁按钮。
部署脚本重启 Gateway、API 和 Nginx 后,会分别对 Gateway、API 健康接口执行最多 60 秒的逐秒就绪检查。Nest 初始化、活动通道恢复或生产数据量增加可能使 API 启动超过固定数秒;发布流程不得用单次固定延时把正常慢启动误判为失败。超过 60 秒仍不健康时才终止发布,并结合 systemd journal 和发布前数据库、源码、环境备份判断回滚方式。 部署脚本重启 Gateway、API 和 Nginx 后,会分别对 Gateway、API 健康接口执行最多 60 秒的逐秒就绪检查。Nest 初始化、活动通道恢复或生产数据量增加可能使 API 启动超过固定数秒;发布流程不得用单次固定延时把正常慢启动误判为失败。超过 60 秒仍不健康时才终止发布,并结合 systemd journal 和发布前数据库、源码、环境备份判断回滚方式。
## 账号和密钥 ## 账号和密钥
+6
View File
@@ -4650,3 +4650,9 @@ npm run verify:phase8
| TC-INFRA-MON-015 | 响应式与无障碍 | 在1536×1024、1280×800和390×844打开页面,操作范围和刷新按钮 | 桌面信息层级符合设计稿;窄屏无内容重叠和页面横向溢出;按钮有可读名称,活动范围和告警严重性不只依赖颜色表达 | | TC-INFRA-MON-015 | 响应式与无障碍 | 在1536×1024、1280×800和390×844打开页面,操作范围和刷新按钮 | 桌面信息层级符合设计稿;窄屏无内容重叠和页面横向溢出;按钮有可读名称,活动范围和告警严重性不只依赖颜色表达 |
| TC-INFRA-MON-016 | 配置和部署幂等 | 在测试服务器重复执行监控安装脚本和配置校验 | 不重复创建系统用户,不开放公网端口;配置通过`promtool check config/rules`,服务保持activeCMPP API/Gateway不因安装被重启 | | TC-INFRA-MON-016 | 配置和部署幂等 | 在测试服务器重复执行监控安装脚本和配置校验 | 不重复创建系统用户,不开放公网端口;配置通过`promtool check config/rules`,服务保持activeCMPP API/Gateway不因安装被重启 |
| TC-INFRA-MON-017 | 业务数据隔离 | 运行监控24小时并检查PostgreSQL业务库和指标标签 | 监控时序只保存在Prometheus TSDB,业务PostgreSQL无高频指标写入;标签、日志和API响应不含手机号、短信正文、账号或密钥 | | 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 与执行器证据交叉验证。
+9
View File
@@ -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提示。 - 专项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集成和告警触发验收必须在明确授权安装的测试或预生产窗口执行,当前不以单测替代真实基础设施验收。 - 浏览器优先检查现有页面:本地运营端无有效登录Session,被正常引导到带算术验证码的登录页;未绕过登录、未读取或填写验证码,因此登录后桌面与窄屏视觉验收尚未完成。真实Prometheus/Node Exporter集成和告警触发验收必须在明确授权安装的测试或预生产窗口执行,当前不以单测替代真实基础设施验收。
- 本轮代码、配置和文档保持未提交、未推送、未部署,没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额、客户连接或预生产数据。既有构建缓存、`outputs/`和空文件`=`继续保护;并行会话新增的Fail2ban设计与测试文档不属于本需求,不修改、不归因。 - 本轮代码、配置和文档保持未提交、未推送、未部署,没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额、客户连接或预生产数据。既有构建缓存、`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/`和空文件`=`继续作为受保护项排除提交。
+6 -5
View File
@@ -41,11 +41,12 @@ func main() {
go func() { go func() {
log.Printf("cmpp gateway inbound server listening on %s", cmppAddr) log.Printf("cmpp gateway inbound server listening on %s", cmppAddr)
if err := (inbound.Server{ if err := (inbound.Server{
Addr: cmppAddr, Addr: cmppAddr,
APIBaseURL: apiBaseURL, APIBaseURL: apiBaseURL,
PresenceStore: presenceStore, PresenceStore: presenceStore,
RecoveryStore: recoveryStore, RecoveryStore: recoveryStore,
GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()), GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()),
SecurityEventToken: os.Getenv("SECURITY_EVENT_TOKEN"),
}).ListenAndServe(); err != nil { }).ListenAndServe(); err != nil {
log.Fatalf("gateway inbound server stopped: %v", err) log.Fatalf("gateway inbound server stopped: %v", err)
} }
+395
View File
@@ -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") account := strings.TrimRight(req.SrcAddr, "\x00")
if account == "" { 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) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnInvalidSrcAddr, req.AuthSrc, "", req.Version)
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr] return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr]
} }
if req.Version != cmpp.V20 && req.Version != cmpp.V21 && req.Version != cmpp.V30 { 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) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30)
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh] 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) err := s.post(context.Background(), "/gateway/events/inbound/authenticate", payload, &result)
return result, err 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)
}
}
+1
View File
@@ -13,6 +13,7 @@ const defaultHTTPTimeout = 10 * time.Second
type Server struct { type Server struct {
Addr string Addr string
APIBaseURL string APIBaseURL string
SecurityEventToken string
HTTPClient *http.Client HTTPClient *http.Client
LogWriter io.Writer LogWriter io.Writer
PendingFlushInterval time.Duration PendingFlushInterval time.Duration
+3
View File
@@ -28,6 +28,9 @@ func (s Server) post(ctx context.Context, path string, payload any, result any)
return err return err
} }
req.Header.Set("Content-Type", "application/json") 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
+15
View File
@@ -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) }),
};
+2
View File
@@ -11,6 +11,7 @@ import { adminGovernanceApi } from './admin/governance.api';
import { adminFilesApi } from './admin/files.api'; import { adminFilesApi } from './admin/files.api';
import { adminSignatureRetirementApi } from './admin/signature-retirement.api'; import { adminSignatureRetirementApi } from './admin/signature-retirement.api';
import { adminInfrastructureMonitoringApi } from './admin/infrastructure-monitoring.api'; import { adminInfrastructureMonitoringApi } from './admin/infrastructure-monitoring.api';
import { adminSecurityDetectionApi } from './admin/security-detection.api';
export const adminApi = { export const adminApi = {
...adminIdentityApi, ...adminIdentityApi,
@@ -20,4 +21,5 @@ export const adminApi = {
...adminFilesApi, ...adminFilesApi,
...adminSignatureRetirementApi, ...adminSignatureRetirementApi,
...adminInfrastructureMonitoringApi, ...adminInfrastructureMonitoringApi,
...adminSecurityDetectionApi,
}; };
+1
View File
@@ -5,3 +5,4 @@ export * from './operations';
export * from './governance'; export * from './governance';
export * from './signature-retirement'; export * from './signature-retirement';
export * from './infrastructure-monitoring'; export * from './infrastructure-monitoring';
export * from './security-detection';
+18
View File
@@ -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>; }
+1
View File
@@ -188,6 +188,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
icon: Shield, icon: Shield,
items: [ items: [
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield }, { label: '风控规则', to: '/admin/risk-rules', icon: Shield },
{ label: '安全检测与封禁', to: '/admin/security-detection', icon: ShieldCheck },
{ label: '签名清退预警', to: '/admin/signature-retirement', icon: AlertTriangle }, { label: '签名清退预警', to: '/admin/signature-retirement', icon: AlertTriangle },
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX }, { label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff }, { label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
+2
View File
@@ -39,6 +39,7 @@ import { AdminSignatureRetirementPage } from '@/apps/admin/AdminSignatureRetirem
import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage'; import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage';
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage'; import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
import { AdminSystemMonitoringPage } from '@/apps/admin/system-monitoring/AdminSystemMonitoringPage'; import { AdminSystemMonitoringPage } from '@/apps/admin/system-monitoring/AdminSystemMonitoringPage';
import { AdminSecurityDetectionPage } from '@/apps/admin/security-detection/AdminSecurityDetectionPage';
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage'; import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage'; import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage'; import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage';
@@ -144,6 +145,7 @@ export function AppRoutes() {
<Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} /> <Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} /> <Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="system-monitoring" element={<AdminSystemMonitoringPage />} /> <Route path="system-monitoring" element={<AdminSystemMonitoringPage />} />
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
<Route path="*" element={<PagePlaceholder />} /> <Route path="*" element={<PagePlaceholder />} />
</Route> </Route>
</Routes> </Routes>
+15 -1
View File
@@ -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}" SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS="${SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS:-300000}"
PROMETHEUS_URL="${PROMETHEUS_URL:-http://127.0.0.1:9090}" PROMETHEUS_URL="${PROMETHEUS_URL:-http://127.0.0.1:9090}"
PROMETHEUS_QUERY_TIMEOUT_MS="${PROMETHEUS_QUERY_TIMEOUT_MS:-5000}" 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 if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root." >&2 echo "Run as root." >&2
@@ -45,7 +47,7 @@ install_packages() {
log "Installing OS packages" log "Installing OS packages"
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then
apt-get update 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 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 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 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_HOST=${CMPP_PUBLIC_HOST}
CMPP_PUBLIC_PORT=${CMPP_PUBLIC_PORT} CMPP_PUBLIC_PORT=${CMPP_PUBLIC_PORT}
API_BASE_URL=http://127.0.0.1:${API_PORT}/api 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 EOF
chmod 600 /etc/cmpp-platform/cmpp-platform.env chmod 600 /etc/cmpp-platform/cmpp-platform.env
cat >/etc/cmpp-platform/minio.env <<EOF cat >/etc/cmpp-platform/minio.env <<EOF
@@ -221,6 +227,11 @@ write_services() {
log "Writing systemd and nginx configuration" log "Writing systemd and nginx configuration"
local node_bin local node_bin
node_bin="$(command -v node)" 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' cat >/etc/systemd/system/cmpp-minio.service <<'EOF'
[Unit] [Unit]
Description=CMPP MinIO object storage Description=CMPP MinIO object storage
@@ -244,6 +255,8 @@ Description=CMPP Platform API
After=network.target postgresql.service redis.service cmpp-minio.service After=network.target postgresql.service redis.service cmpp-minio.service
[Service] [Service]
User=cmpp-api
Group=cmpp-security
WorkingDirectory=${APP_DIR}/api WorkingDirectory=${APP_DIR}/api
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
ExecStart=${node_bin} dist/main.js ExecStart=${node_bin} dist/main.js
@@ -286,6 +299,7 @@ server {
gzip_min_length 1024; gzip_min_length 1024;
gzip_comp_level 5; gzip_comp_level 5;
gzip_types application/json application/javascript text/javascript text/css text/plain text/csv image/svg+xml; 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/ { location /api/ {
proxy_pass http://127.0.0.1:${API_PORT}/api/; proxy_pass http://127.0.0.1:${API_PORT}/api/;
+5
View File
@@ -52,6 +52,7 @@ rm -rf api/dist api/tsconfig.build.tsbuildinfo "$APP_DIR/dist/cmpp-gateway"
npm run build npm run build
npm --prefix api 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-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" 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 d -exec chmod 755 {} +
find "$APP_DIR/dist/assets" -type f -exec chmod 644 {} + 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" echo "[deploy] Ensuring runtime log directories"
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/gateway" 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" echo "[deploy] Ensuring HTTP response compression"
cat >/etc/nginx/conf.d/cmpp-compression.conf <<'EOF' cat >/etc/nginx/conf.d/cmpp-compression.conf <<'EOF'
gzip on; gzip on;
@@ -85,6 +89,7 @@ else
systemctl enable --now cmpp-api cmpp-gateway nginx systemctl enable --now cmpp-api cmpp-gateway nginx
fi fi
systemctl restart cmpp-gateway systemctl restart cmpp-gateway
systemctl restart cmpp-security-agent
systemctl restart cmpp-api systemctl restart cmpp-api
systemctl restart nginx systemctl restart nginx
+51
View File
@@ -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."