Spring Boot实现漏洞补偿控制台

安全扫描告警最容易出现的治理断层,是“代码没修,但外层控制已经降低风险”这类状态。

GitHub Code Scanning 新增 Mitigated dismissal reason 后,这个状态终于可以和 Won't fix 分开。但如果企业只在 GitHub 页面里点一下 Mitigated,治理仍然不完整。

真正需要的是一套补偿控制台,把四个对象连起来:

Vulnerability
↓
Compensating Control
↓
Evidence
↓
Expiry / Revalidation

目标不是再做一个漏洞管理平台,而是确保:

漏洞还在的时候,
补偿控制真的有效;
补偿控制失效的时候,
漏洞能重新进入风险队列。

下面直接用 Spring Boot 做一个最小可运行版本。

数据模型先从Alert开始

@Entity
@Table(name = "security_alert")
public class SecurityAlertEntity {

    @Id
    private String alertId;

    private String repository;

    private String ruleId;

    @Enumerated(EnumType.STRING)
    private Severity severity;

    @Enumerated(EnumType.STRING)
    private AlertStatus status;

    private String sourceRef;

    private Instant discoveredAt;

    private Instant updatedAt;

    @Version
    private long rowVersion;
}

状态:

public enum AlertStatus {
    OPEN,
    MITIGATION_PROPOSED,
    MITIGATED,
    FIXED,
    REOPENED,
    ACCEPTED_RISK
}

注意:

MITIGATED

必须是独立状态。

不能直接映射成:

CLOSED

因为代码风险仍存在。

补偿控制实体

@Entity
@Table(name = "compensating_control")
public class CompensatingControlEntity {

    @Id
    private String controlId;

    @Enumerated(EnumType.STRING)
    private ControlType type;

    private String externalControlRef;

    private String owner;

    @Enumerated(EnumType.STRING)
    private ControlStatus status;

    private Instant effectiveAt;

    private Instant expiresAt;

    private Instant lastVerifiedAt;

    private Instant nextReviewAt;

    private String verificationPolicyVersion;

    @Version
    private long rowVersion;
}

类型:

public enum ControlType {
    WAF_RULE,
    NETWORK_POLICY,
    RATE_LIMIT,
    FEATURE_FLAG,
    ACCESS_POLICY,
    FIREWALL_RULE,
    TEMPORARY_ISOLATION
}

为什么Alert和Control要分表

一个补偿控制可能覆盖多个漏洞。

例如:

WAF Rule 8821

可能同时保护:

Alert A
Alert B
Alert C

如果每个 Alert 都复制一份 WAF 信息,很快会出现:

A说Rule v17
B说Rule v18
C根本没更新

所以关系应该是:

Alert
many-to-many
Control

关联表

@Entity
@Table(name = "alert_control_binding")
public class AlertControlBindingEntity {

    @EmbeddedId
    private AlertControlKey id;

    private String rationale;

    private String approvedBy;

    private Instant approvedAt;

    private Instant invalidatedAt;
}

Key:

@Embeddable
public record AlertControlKey(
        String alertId,
        String controlId) implements Serializable {
}

Evidence单独存

@Entity
@Table(name = "control_evidence")
public class ControlEvidenceEntity {

    @Id
    private String evidenceId;

    private String controlId;

    @Enumerated(EnumType.STRING)
    private EvidenceType type;

    private String contentRef;

    private String contentHash;

    private String collectedBy;

    private Instant collectedAt;
}

Evidence Type:

public enum EvidenceType {
    CONFIG_SNAPSHOT,
    POLICY_EXPORT,
    TEST_RESULT,
    SCREENSHOT,
    TRACE,
    CHANGE_TICKET
}

最重要的是:

contentHash

这样 Evidence 后续被替换,可以检测。

一条Mitigation申请应该长什么样

API:

POST /api/alerts/{alertId}/mitigations

请求:

{
  "controlType": "WAF_RULE",
  "externalControlRef": "waf/rule-8821",
  "owner": "platform-security",
  "expiresAt": "2026-11-20T00:00:00Z",
  "rationale": "Block the vulnerable request pattern before origin",
  "evidence": [
    {
      "type": "TEST_RESULT",
      "contentRef": "artifact/security-test-918"
    }
  ]
}

这里有三个字段不能允许为空:

owner
expiresAt
evidence

没有它们就不允许进入 Mitigated。

Bean Validation

public record ProposeMitigationRequest(

        @NotNull
        ControlType controlType,

        @NotBlank
        String externalControlRef,

        @NotBlank
        String owner,

        @Future
        Instant expiresAt,

        @NotBlank
        String rationale,

        @NotEmpty
        List evidence) {
}

不同严重度限制不同有效期

@Component
public class MitigationValidityPolicy {

    public Duration maximumValidity(
            Severity severity) {

        return switch (severity) {
            case CRITICAL ->
                    Duration.ofDays(30);

            case HIGH ->
                    Duration.ofDays(60);

            case MEDIUM ->
                    Duration.ofDays(90);

            case LOW ->
                    Duration.ofDays(180);
        };
    }
}

申请:

Critical

却填:

expiresAt = 1年后

直接拒绝。

Approval也要按Severity分级

public record ApprovalRequirement(
        boolean securityApproval,
        boolean serviceOwnerApproval,
        boolean riskOwnerApproval) {
}

规则:

public ApprovalRequirement requirement(
        Severity severity) {

    return switch (severity) {
        case CRITICAL ->
            new ApprovalRequirement(
                true,
                true,
                true);

        case HIGH ->
            new ApprovalRequirement(
                true,
                true,
                false);

        default ->
            new ApprovalRequirement(
                false,
                true,
                false);
    };
}

不要让开发者自己点一下就把 Critical 漏洞设成 Mitigated。

Control Verification是这套系统的核心

存在一个 WAF Rule 不代表:

它真的拦住漏洞

所以每种 Control 都需要 Verifier。

public interface ControlVerifier {

    boolean supports(
            ControlType type);

    VerificationResult verify(
            CompensatingControlEntity control,
            SecurityAlertEntity alert);
}

WAF:

@Component
public class WafControlVerifier
        implements ControlVerifier {

    @Override
    public boolean supports(
            ControlType type) {
        return type == ControlType.WAF_RULE;
    }

    @Override
    public VerificationResult verify(
            CompensatingControlEntity control,
            SecurityAlertEntity alert) {

        // 1. 读取配置快照
        // 2. 检查规则是否启用
        // 3. 运行安全测试
        // 4. 确认请求未到达 Origin

        return VerificationResult.passed(
                "artifact/test-918");
    }
}

生产里 Verifier 可以接:

  • WAF API;
  • Kubernetes API;
  • Network Policy;
  • API Gateway;
  • 测试环境。

Verification Result

public record VerificationResult(
        VerificationStatus status,
        String evidenceRef,
        List details,
        Instant verifiedAt) {

    public static VerificationResult passed(
            String evidenceRef) {

        return new VerificationResult(
                VerificationStatus.PASSED,
                evidenceRef,
                List.of(),
                Instant.now());
    }
}

只有Verification PASS才能变MITIGATED

@Transactional
public void activateMitigation(
        String alertId,
        String controlId) {

    SecurityAlertEntity alert =
            alertRepository.findById(alertId)
                .orElseThrow();

    CompensatingControlEntity control =
            controlRepository.findById(controlId)
                .orElseThrow();

    VerificationResult result =
            verifierRegistry
                .forType(control.getType())
                .verify(control, alert);

    verificationRepository.save(
            VerificationEntity.from(
                alertId,
                controlId,
                result));

    if (result.status()
            != VerificationStatus.PASSED) {

        throw new MitigationVerificationException();
    }

    alert.setStatus(
            AlertStatus.MITIGATED);

    control.setStatus(
            ControlStatus.ACTIVE);

    control.setLastVerifiedAt(
            result.verifiedAt());

    control.setNextReviewAt(
            reviewPolicy.nextReview(
                alert.getSeverity(),
                result.verifiedAt()));
}

定期复审不能靠人工记日历

Scheduler:

@Scheduled(cron = "0 0 * * * *")
public void scheduleReviews() {

    List due =
            controlRepository
                .findDueForReview(
                    Instant.now());

    due.forEach(
        controlReviewQueue::publish);
}

查询:

select *
from compensating_control
where status = 'ACTIVE'
  and next_review_at  alertIds =
            bindingRepository
                .findActiveAlertIds(
                    controlId);

    alertIds.forEach(alertId -> {
        SecurityAlertEntity alert =
                alertRepository
                    .lockById(alertId);

        if (alert.getStatus()
                == AlertStatus.MITIGATED) {

            alert.setStatus(
                    AlertStatus.REOPENED);
        }
    });

    outboxRepository.save(
            SecurityEvent
                .controlInvalidated(
                    controlId,
                    alertIds,
                    reason));
}

这是整套系统最重要的逻辑。

如果没有:

Control Invalid
→ Alert Reopen

所谓补偿控制治理只完成了一半。

多个Control怎么办

一个漏洞可能同时依赖:

WAF
+
Network Policy

要明确关系是:

AND

还是:

OR

例如:

两个都必须存在

才能缓解:

AND

任一存在就够:

OR

不要隐含在备注里。

public enum ControlComposition {
    ALL_REQUIRED,
    ANY_SUFFICIENT
}

风险重新计算

补偿控制可以降低:

Likelihood

但不一定改变:

Impact

例如 RCE:

Impact = Critical

WAF 只是让可利用概率下降。

所以 Dashboard 可以展示:

Inherent Risk:
Critical

Residual Risk:
Medium

Mitigation:
WAF Rule 8821

不要直接把 Severity 改成 Medium,丢失原始风险。

一个Residual Risk模型

public record RiskScore(
        int likelihood,
        int impact) {

    public int value() {
        return likelihood * impact;
    }
}

Control 只修改:

Residual Likelihood

原始 Inherent Risk 永久保留。

Dashboard最少显示这些列

Alert
Severity
Repository
Control
Owner
Last Verified
Next Review
Expiry
Age
Residual Risk

特别是:

Age

一眼看出哪些 Mitigation 已经长期存在。

告警

14d before expiry
7d before expiry
1d before expiry
expired
verification failed
control changed
owner missing

如果 WAF 配置发生变更,也应该主动触发重新验证。

可以让配置系统发送:

ControlChanged

事件。

Control Hash

例如保存:

WAF Policy Hash

每次检查:

current_hash != verified_hash

说明 Evidence 已经陈旧。

立即:

REVIEW_REQUIRED

不要等下个季度复审。

Outbox保证状态变更不会漏通知

事务里同时:

更新 Control
更新 Alert
写 Outbox
begin;

update compensating_control ...;

update security_alert ...;

insert into outbox_event ...;

commit;

避免数据库已经 Reopen,但通知消息没发出去。

GitHub同步最好做成Adapter

public interface SecurityAlertProvider {

    void dismissAsMitigated(
            String alertId,
            String comment);

    void reopen(
            String alertId);
}

Control Plane 自己是真实状态。

GitHub 是外部执行端。

不要把所有治理数据只存 GitHub Comment。

同步失败状态

public enum SyncStatus {
    PENDING,
    SYNCED,
    FAILED
}

如果内部已经批准 Mitigation,但 GitHub API 调用失败:

内部状态不能假装同步成功

由 Outbox Worker 重试。

一个审计事件

public record MitigationAuditEvent(
        String eventId,
        String alertId,
        String controlId,
        String eventType,
        String actor,
        String evidenceHash,
        String policyVersion,
        Instant occurredAt) {
}

事件:

PROPOSED
APPROVED
VERIFIED
ACTIVATED
REVERIFIED
EXPIRED
INVALIDATED
REOPENED
FIXED

以后可以完整还原。

生产指标

security_mitigation_active_total
security_mitigation_expiring_total
security_mitigation_verification_failed_total
security_alert_reopened_total
security_mitigation_age_days

再加:

linked_alert_count / control

找控制集中度。

单元测试

至少:

Critical超过30天有效期 → 拒绝
没有Evidence → 拒绝
Verification失败 → 不能Mitigated
Control失效 → Alert Reopen
Control过期 → Alert Reopen
多个AND Control少一个 → Reopen
多个OR Control仍有一个有效 → 保持
配置Hash变化 → Review Required

并发测试也要做

两个 Reviewer 同时批准。

两个 Scheduler 同时处理 Expiry。

需要:

Optimistic Lock
或
SELECT FOR UPDATE

避免状态跳跃。

最终状态不是Mitigated,而是Fixed

补偿控制台应该一直推动永久修复。

可以为 Mitigated Alert 保存:

permanent_fix_ticket

例如:

{
  "ticket": "SEC-1842",
  "target_release": "2026.09"
}

Dashboard 里如果:

Mitigated
+
没有 Permanent Fix Plan

单独标红。


GitHub 新增 Mitigated reason 解决了一个现实问题:代码漏洞和业务风险之间,并不总是“未修=完全暴露”。

但真正生产化以后,Mitigated 必须被看成一个新的治理对象,而不是一个关闭理由。

一套合格的补偿控制台至少应该保证:

有Owner
有Evidence
有Expiry
能自动复验
失效能Reopen
最终仍推动Code Fix

如果只做了第一步:

把告警状态改成Mitigated

那只是把安全债从代码里搬到了一个更不容易被看见的地方。


更多企业级 AI 应用、Agent、RAG 与模型工程化内容,我会继续整理在 智元界

https://www.zyentor.com/