diff --git a/api/entrance.go b/api/entrance.go index 6678261..b8a35fd 100644 --- a/api/entrance.go +++ b/api/entrance.go @@ -62,6 +62,7 @@ type APIGroup struct { WafHostGuardApi WafHostConnApi WafThreatIPApi + WafThreatIPExcludeApi WafCDNIPApi WafIPLookupApi WafPluginApi @@ -156,7 +157,10 @@ var ( wafHostConnService = waf_service.WafHostConnServiceApp wafThreatIPService = waf_service.WafThreatIPServiceApp wafCDNIPService = waf_service.WafCDNIPServiceApp - wafIPLookupService = waf_service.WafIPLookupServiceApp + + wafThreatIPExcludeService = waf_service.WafThreatIPExcludeServiceApp + wafThreatIPExcludeAuditService = waf_service.WafThreatIPExcludeAuditApp + wafIPLookupService = waf_service.WafIPLookupServiceApp wafOPlatformKeyService = waf_service.WafOPlatformKeyServiceApp wafOPlatformLogService = waf_service.WafOPlatformLogServiceApp diff --git a/api/waf_threat_ip_api.go b/api/waf_threat_ip_api.go index 13cda92..d58f21c 100644 --- a/api/waf_threat_ip_api.go +++ b/api/waf_threat_ip_api.go @@ -115,7 +115,7 @@ func (w *WafThreatIPApi) LandedIPsApi(c *gin.Context) { response.FailWithMessage("解析失败: "+err.Error(), c) return } - ips, total := wafThreatIPService.GetLandedIPs(req.Code, req.Keyword, req.PageIndex, req.PageSize) + ips, total := wafThreatIPService.GetLandedIPs(req.Code, req.Keyword, req.OnlyExcluded == 1, req.PageIndex, req.PageSize) response.OkWithDetailed(response.PageResult{ List: ips, Total: total, diff --git a/api/waf_threat_ip_exclude_api.go b/api/waf_threat_ip_exclude_api.go new file mode 100644 index 0000000..0936f51 --- /dev/null +++ b/api/waf_threat_ip_exclude_api.go @@ -0,0 +1,169 @@ +package api + +import ( + "SamWaf/model/common/response" + "SamWaf/model/request" + "SamWaf/service/waf_service" + "SamWaf/utils" + "fmt" + + "github.com/gin-gonic/gin" +) + +// WafThreatIPExcludeApi 威胁情报误报排除名单。 +// +// 排除是**主动降低防护**的动作,所以这一层比普通 CRUD 多两件事: +// 1. 入参严格校验(尤其要挡掉巨型网段,排除一个 /0 等于把威胁情报功能悄悄关掉) +// 2. 每个写操作都带上操作人与来源 IP 落审计流水 +type WafThreatIPExcludeApi struct { +} + +// operatorOf 取当前操作人与来源 IP。 +// 来源 IP 用 GetManageClientIP(带可信代理校验),不能用 gin 默认的 c.ClientIP()—— +// 后者信任全网转发头,审计里记下的可能是攻击者伪造的地址。 +func operatorOf(c *gin.Context) (string, string) { + account := "" + if v, ok := c.Get("loginAccount"); ok { + account, _ = v.(string) + } + if account == "" { + account = "unknown" + } + return account, utils.GetManageClientIP(c) +} + +// AddApi 新增排除条目 +func (w *WafThreatIPExcludeApi) AddApi(c *gin.Context) { + var req request.WafThreatIPExcludeAddReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败: "+err.Error(), c) + return + } + if len(req.Remarks) > 500 { + response.FailWithMessage("备注长度不能超过500个字符", c) + return + } + operator, operatorIP := operatorOf(c) + res, err := wafThreatIPExcludeService.AddApi(req, operator, operatorIP) + if err != nil { + response.FailWithMessage("添加失败: "+err.Error(), c) + return + } + response.OkWithDetailed(res, addResultMessage(res), c) +} + +// addResultMessage 把"实际影响了什么"直接告诉用户。 +// 影响为 0 时必须说清楚可能的原因——最典型的是排除了 1.2.3.4, +// 而快照里其实是 1.2.3.0/24,小的排不掉大的,用户光看"添加成功"会以为已经生效了。 +func addResultMessage(res *waf_service.PreviewResult) string { + if res.AffectedItems > 0 { + return fmt.Sprintf("添加成功:已从 %d 个渠道剔除 %d 条,WAF 层已即时生效,系统防火墙正在后台重建", + res.AffectedChans, res.AffectedItems) + } + if res.CoveringEntry != "" { + return fmt.Sprintf("添加成功,但**未匹配到任何威胁情报条目**:该地址属于网段 %s,需要排除整段才会生效", res.CoveringEntry) + } + return "添加成功,但未匹配到任何威胁情报条目(该地址当前不在任何启用渠道的情报里,排除条目会一直保留并对后续同步生效)" +} + +// PreviewApi 试算一条排除条目的影响,不落库。前端在用户点保存前调用。 +func (w *WafThreatIPExcludeApi) PreviewApi(c *gin.Context) { + var req request.WafThreatIPExcludePreviewReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败: "+err.Error(), c) + return + } + res, err := wafThreatIPExcludeService.Preview(req.Entry) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + response.OkWithDetailed(res, "试算完成", c) +} + +// ModifyApi 修改备注 / 启停 +func (w *WafThreatIPExcludeApi) ModifyApi(c *gin.Context) { + var req request.WafThreatIPExcludeEditReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败: "+err.Error(), c) + return + } + if len(req.Remarks) > 500 { + response.FailWithMessage("备注长度不能超过500个字符", c) + return + } + if req.Enable != 0 && req.Enable != 1 { + response.FailWithMessage("启用状态取值非法", c) + return + } + operator, operatorIP := operatorOf(c) + if err := wafThreatIPExcludeService.ModifyApi(req, operator, operatorIP); err != nil { + response.FailWithMessage("修改失败: "+err.Error(), c) + return + } + response.OkWithMessage("修改成功,正在后台重新落地", c) +} + +// DelApi 删除排除条目(该地址将重新按威胁情报拦截) +func (w *WafThreatIPExcludeApi) DelApi(c *gin.Context) { + var req request.WafThreatIPExcludeDelReq + if err := c.ShouldBind(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + operator, operatorIP := operatorOf(c) + if err := wafThreatIPExcludeService.DelApi(req.Id, operator, operatorIP); err != nil { + response.FailWithMessage("删除失败: "+err.Error(), c) + return + } + response.OkWithMessage("删除成功,该地址将重新按威胁情报拦截,正在后台重新落地", c) +} + +// GetListApi 分页查询排除名单 +func (w *WafThreatIPExcludeApi) GetListApi(c *gin.Context) { + var req request.WafThreatIPExcludeSearchReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + list, total, err := wafThreatIPExcludeService.GetListApi(req) + if err != nil { + response.FailWithMessage("查询失败: "+err.Error(), c) + return + } + response.OkWithDetailed(response.PageResult{ + List: list, + Total: total, + PageIndex: req.PageIndex, + PageSize: req.PageSize, + }, "获取成功", c) +} + +// EffectiveRulesApi 列出当前生效的内置排除规则。 +// +// 内置来源(回环/本机网卡/内网段/管理端白名单/活跃管理会话)不落库, +// 只看排除名单表的话,用户会看到"已排除6条"却在名单里找不到任何条目。 +// 降低防护的规则必须全部可见,哪怕是系统内置的。 +func (w *WafThreatIPExcludeApi) EffectiveRulesApi(c *gin.Context) { + response.OkWithDetailed(wafThreatIPExcludeService.EffectiveRules(), "获取成功", c) +} + +// GetAuditListApi 分页查询排除操作审计流水 +func (w *WafThreatIPExcludeApi) GetAuditListApi(c *gin.Context) { + var req request.WafThreatIPExcludeAuditSearchReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + list, total, err := wafThreatIPExcludeAuditService.GetListApi(req) + if err != nil { + response.FailWithMessage("查询失败: "+err.Error(), c) + return + } + response.OkWithDetailed(response.PageResult{ + List: list, + Total: total, + PageIndex: req.PageIndex, + PageSize: req.PageSize, + }, "获取成功", c) +} diff --git a/model/request/waf_threat_ip_exclude_req.go b/model/request/waf_threat_ip_exclude_req.go new file mode 100644 index 0000000..b86c1dc --- /dev/null +++ b/model/request/waf_threat_ip_exclude_req.go @@ -0,0 +1,40 @@ +package request + +import "SamWaf/model/common/request" + +// WafThreatIPExcludeAddReq 新增威胁情报误报排除条目 +type WafThreatIPExcludeAddReq struct { + Entry string `json:"entry" binding:"required"` // 单 IP 或 CIDR,如 1.2.3.4 / 1.2.3.0/24 + Remarks string `json:"remarks"` // 备注:为什么认为是误报 +} + +// WafThreatIPExcludeEditReq 修改排除条目(条目原文不可改,要改就删了重加,保证审计可追溯) +type WafThreatIPExcludeEditReq struct { + Id string `json:"id" binding:"required"` + Remarks string `json:"remarks"` + Enable int `json:"enable"` // 1 生效 0 停用 +} + +// WafThreatIPExcludeDelReq 删除排除条目 +type WafThreatIPExcludeDelReq struct { + Id string `json:"id" form:"id" binding:"required"` +} + +// WafThreatIPExcludeSearchReq 排除名单分页查询 +type WafThreatIPExcludeSearchReq struct { + Entry string `json:"entry"` // 条目子串过滤 + Source string `json:"source"` // manual | auto | ""(全部) + request.PageInfo +} + +// WafThreatIPExcludePreviewReq 试算一条排除条目的影响(不落库) +type WafThreatIPExcludePreviewReq struct { + Entry string `json:"entry" binding:"required"` +} + +// WafThreatIPExcludeAuditSearchReq 排除操作审计流水查询 +type WafThreatIPExcludeAuditSearchReq struct { + Entry string `json:"entry"` + Action string `json:"action"` // add | del | enable | disable | ""(全部) + request.PageInfo +} diff --git a/model/request/waf_threat_ip_req.go b/model/request/waf_threat_ip_req.go index 081c291..2a02342 100644 --- a/model/request/waf_threat_ip_req.go +++ b/model/request/waf_threat_ip_req.go @@ -59,5 +59,7 @@ type WafThreatIPLandedSummaryReq struct { type WafThreatIPLandedIPReq struct { Code string `json:"code" binding:"required"` // 渠道短码 Keyword string `json:"keyword"` // IP 子串过滤(可空) + // OnlyExcluded=1 时只列被误报排除名单剔掉的条目,供用户核对排除的实际效果 + OnlyExcluded int `json:"only_excluded"` request.PageInfo } diff --git a/model/threat_ip_channel.go b/model/threat_ip_channel.go index fbf4bba..eb18d54 100644 --- a/model/threat_ip_channel.go +++ b/model/threat_ip_channel.go @@ -36,9 +36,12 @@ type ThreatIPChannel struct { // 让前端能在后台拉取期间给出反馈并自动轮询,而不是点完什么都看不到。 Syncing bool `gorm:"-" json:"syncing"` // 该渠道当前是否有同步在进行 SyncStartedAt int64 `gorm:"-" json:"sync_started_at"` // 本次同步开始时间戳(秒),Syncing 为 false 时无意义 - // LandedOK 系统防火墙是否已确认落地到当前快照。由服务端比对 LandedSha 与快照 sha 得出, + // LandedOK 系统防火墙是否已确认落地到当前应有的内容(有效集)。由服务端比对 LandedSha 与有效集 sha 得出, // 不让前端拿条数去猜(落地层不含系统层、环境不支持 ipset 等情况都不该报警)。 LandedOK bool `gorm:"-" json:"landed_ok"` + // ExcludedCount 本渠道被误报排除名单剔掉的条数(内容集 - 有效集)。 + // 页面据此显示"已排除 N 条",让用户知道防火墙里的数字为什么比收录条数少。 + ExcludedCount int `gorm:"-" json:"excluded_count"` } // TableName 表名 diff --git a/model/threat_ip_exclude.go b/model/threat_ip_exclude.go new file mode 100644 index 0000000..f02713d --- /dev/null +++ b/model/threat_ip_exclude.go @@ -0,0 +1,39 @@ +package model + +import ( + "SamWaf/model/baseorm" +) + +// ThreatIPExclude 威胁情报误报排除名单。 +// +// 订阅源给的是**全量快照**,每个周期整份覆盖,用户手工从防火墙里删掉的条目下次同步就回来了; +// 落到系统防火墙的部分又是内核丢包,WAF 的 IP 白名单(CheckAllowIP)根本轮不到判定。 +// 所以误报必须有一份"跟着每次同步/对账一起重新应用"的本地排除声明。 +// +// 本表是**整机级**的(不按站点):系统防火墙本身就是整机级,用 per-host 白名单驱动整机级排除 +// 会出现"A 站点的白名单顺带给 SSH 开门"的语义错配。 +type ThreatIPExclude struct { + baseorm.BaseOrm + Entry string `gorm:"size:64;index" json:"entry"` // 单 IP 或 CIDR,如 1.2.3.4 / 1.2.3.0/24 + Source string `gorm:"size:16" json:"source"` // 来源:manual 手工添加 | auto 系统自动固化 + Reason string `gorm:"size:64" json:"reason"` // Source=auto 时的自动排除原因(loopback/local/lan/config/manage/admin_ip) + Remarks string `gorm:"size:500" json:"remarks"` // 备注:为什么认为是误报 + Enable int `json:"enable"` // 1 生效 0 停用(停用保留记录,便于回溯) + + // HitCount/LastHitAt 记录最近一次计算时这条排除**实际剔除了多少条**威胁情报内容。 + // 用途是让用户一眼看出"写了但没生效"——最典型的是排除了 1.2.3.4, + // 而快照里其实是 1.2.3.0/24(小的排不掉大的,见设计文档 §5.1)。 + HitCount int `json:"hit_count"` + LastHitAt int64 `json:"last_hit_at"` // 最近一次命中的时间戳(秒) +} + +// TableName 表名 +func (ThreatIPExclude) TableName() string { + return "threat_ip_exclude" +} + +// 排除条目来源 +const ( + ThreatExcludeSourceManual = "manual" + ThreatExcludeSourceAuto = "auto" +) diff --git a/model/threat_ip_exclude_audit.go b/model/threat_ip_exclude_audit.go new file mode 100644 index 0000000..3b3790b --- /dev/null +++ b/model/threat_ip_exclude_audit.go @@ -0,0 +1,41 @@ +package model + +import ( + "SamWaf/model/baseorm" +) + +// ThreatIPExcludeAudit 威胁情报排除名单操作审计。 +// +// 放日志库(GWAF_LOCAL_LOG_DB)而不是核心库:这是只增不改的审计流水, +// 与 LoginHistory / AccessAuditLog 同性质。 +// +// 排除名单是**主动降低防护**的操作,删除排除条目后原记录就没了, +// 所以"曾经排除过什么、谁排的、什么时候"必须单独留一份流水,删除动作本身也要记。 +type ThreatIPExcludeAudit struct { + baseorm.BaseOrm + Action string `gorm:"size:16;index" json:"action"` // add | del | enable | disable + Entry string `gorm:"size:64;index" json:"entry"` // 排除条目(单 IP 或 CIDR) + Source string `gorm:"size:16" json:"source"` // manual | auto + Reason string `gorm:"size:64" json:"reason"` // Source=auto 时的自动排除原因 + + Operator string `gorm:"size:64" json:"operator"` // 操作账号;系统自动固化时为 system + OperatorIP string `gorm:"size:64" json:"operator_ip"` // 操作来源IP,走 GetManageClientIP(可信代理校验),不用 c.ClientIP() + + AffectedChans int `json:"affected_chans"` // 本次影响的渠道数 + AffectedItems int `json:"affected_items"` // 本次剔除(或恢复)的威胁情报条数 + + Remarks string `gorm:"size:500" json:"remarks"` // 操作备注 +} + +// TableName 表名 +func (ThreatIPExcludeAudit) TableName() string { + return "threat_ip_exclude_audit" +} + +// 审计动作 +const ( + ThreatExcludeActionAdd = "add" + ThreatExcludeActionDel = "del" + ThreatExcludeActionEnable = "enable" + ThreatExcludeActionDisable = "disable" +) diff --git a/router/waf_threat_ip_router.go b/router/waf_threat_ip_router.go index 209c1a2..a5d3fc1 100644 --- a/router/waf_threat_ip_router.go +++ b/router/waf_threat_ip_router.go @@ -22,4 +22,14 @@ func (receiver *ThreatIPRouter) InitThreatIPRouter(group *gin.RouterGroup) { router.GET("/api/v1/threatip/landed/summary", apiObj.LandedSummaryApi) // 落地汇总(订阅来源Tab) router.POST("/api/v1/threatip/landed/ips", apiObj.LandedIPsApi) // 某渠道落地IP分页浏览(只读) + + // 误报排除名单:订阅源误把正常 IP 列为恶意、且用户无法在上游订正时的本地补救 + exclude := api.APIGroupAPP.WafThreatIPExcludeApi + router.POST("/api/v1/threatip/exclude/list", exclude.GetListApi) // 排除名单列表 + router.POST("/api/v1/threatip/exclude/add", exclude.AddApi) // 新增排除 + router.POST("/api/v1/threatip/exclude/edit", exclude.ModifyApi) // 改备注/启停 + router.GET("/api/v1/threatip/exclude/del", exclude.DelApi) // 删除排除 + router.POST("/api/v1/threatip/exclude/preview", exclude.PreviewApi) // 试算影响(不落库) + router.POST("/api/v1/threatip/exclude/audit", exclude.GetAuditListApi) // 操作审计流水 + router.GET("/api/v1/threatip/exclude/builtin", exclude.EffectiveRulesApi) // 内置排除规则(只读,不落库) } diff --git a/service/waf_service/waf_ip_lookup_service.go b/service/waf_service/waf_ip_lookup_service.go index f7d9cd1..88c12fb 100644 --- a/service/waf_service/waf_ip_lookup_service.go +++ b/service/waf_service/waf_ip_lookup_service.go @@ -82,14 +82,17 @@ func matchedEntryIn(items []string, ip net.IP) string { // 来源码,与前端组件的图例一一对应 const ( - srcIPBlack = "ip_black" - srcIPWhite = "ip_white" - srcIPGroup = "ip_group" - srcThreatIP = "threat_ip" - srcIPFailure = "ip_failure" - srcCCBan = "cc_ban" - srcFirewall = "firewall" - srcCDN = "cdn" + srcIPBlack = "ip_black" + srcIPWhite = "ip_white" + srcIPGroup = "ip_group" + srcThreatIP = "threat_ip" + // srcThreatExclude 威胁情报误报排除名单。放在结果里是为了回答"为什么它没被拦"—— + // 只报 block 不报 allow 的话,用户排除完再查会看到一片空白,分不清是不在情报里还是已豁免。 + srcThreatExclude = "threat_exclude" + srcIPFailure = "ip_failure" + srcCCBan = "cc_ban" + srcFirewall = "firewall" + srcCDN = "cdn" ) // normalizeLookupInput 把用户输入归一成一个可查的 IP。 @@ -376,6 +379,24 @@ func (r *WafIPLookupService) groupRefs() map[string]groupRef { // 先用引擎那份全局并集(ipset 常数级判定)问一句「在不在」,不在就直接收工; // 只有确实命中了才展开各渠道快照去定位是哪一家收录的。 func (r *WafIPLookupService) matchThreatIP(ip string, parsed net.IP, resp *response2.IPLookupResp) { + // 排除名单必须参与进来:查询结果要回答的是"这个 IP 现在会不会被拦", + // 用户排除完再来查却还显示 block,只会让他以为排除没生效。 + exclude := WafThreatIPExcludeServiceApp.Get() + + // 被豁免的情况要显式报出来,而且必须在"全局并集里没有"这个早退**之前**判—— + // 排除生效后并集里本来就查不到它了,放在早退之后就永远不会执行。 + // 不报的话用户看到的是"什么都没查到",分不清是"不在情报里"还是"在情报里但已豁免"。 + if hit := exclude.MatchedEntry(parsed); hit != nil { + resp.Hits = append(resp.Hits, response2.IPLookupHit{ + Source: srcThreatExclude, + SourceName: "威胁情报排除名单", + Scope: hit.ScopeText(), + Matched: hit.Raw, + Effect: "allow", + Detail: "该地址已被误报排除名单豁免,威胁情报不会拦截它(其它名单仍可能拦截)", + }) + } + matcher := ipset.GetGlobalThreatMatcher() if matcher == nil || !matcher.Contains(parsed) { return @@ -387,6 +408,10 @@ func (r *WafIPLookupService) matchThreatIP(ip string, parsed net.IP, resp *respo return } + // 缓存 key 必须带上排除集指纹:排除名单变了但快照 sha 没变, + // 只按快照 sha 失效的话缓存不会重建,查询结果会一直停留在排除之前。 + excludeSha := exclude.Sha() + found := false for _, ch := range channels { // 只读表头拿 sha,命中缓存就完全不用解压 @@ -395,7 +420,7 @@ func (r *WafIPLookupService) matchThreatIP(ip string, parsed net.IP, resp *respo continue } decodeFailed := false - set := matcherFor(threatMatchers, ch.Code, meta.Sha256, func() []string { + set := matcherFor(threatMatchers, ch.Code, meta.Sha256+"_"+excludeSha, func() []string { var snap model.ThreatIPSnapshot if err := global.GWAF_LOCAL_DB.Where("channel_code = ?", ch.Code).First(&snap).Error; err != nil { return nil @@ -405,7 +430,7 @@ func (r *WafIPLookupService) matchThreatIP(ip string, parsed net.IP, resp *respo decodeFailed = true return nil } - return ips + return exclude.Filter(ips).Effective }) if decodeFailed { resp.Degraded = append(resp.Degraded, srcThreatIP) @@ -415,12 +440,14 @@ func (r *WafIPLookupService) matchThreatIP(ip string, parsed net.IP, resp *respo continue } - // 到这儿才解压一次去定位具体命中的那条规则——命中很罕见,这份开销可以接受 + // 到这儿才解压一次去定位具体命中的那条规则——命中很罕见,这份开销可以接受。 + // entry 就是"实际命中的那条原文",可能是个网段(如 1.2.3.0/24); + // 前端的「排除此项」按钮直接拿它预填,用户不必自己判断该排单 IP 还是整段。 entry := "" var snap model.ThreatIPSnapshot if err := global.GWAF_LOCAL_DB.Where("channel_code = ?", ch.Code).First(&snap).Error; err == nil { if ips, derr := threatip.DecodeSnapshot(snap.Payload); derr == nil { - entry = matchedEntryIn(ips, parsed) + entry = matchedEntryIn(exclude.Filter(ips).Effective, parsed) } } found = true diff --git a/service/waf_service/waf_sql_query.go b/service/waf_service/waf_sql_query.go index 3386846..59250a3 100644 --- a/service/waf_service/waf_sql_query.go +++ b/service/waf_service/waf_sql_query.go @@ -61,6 +61,10 @@ var sensitiveTableSubstrings = []string{ "access_ticket", // 跨域票据:含回跳地址与会话关联 "access_config", "access_audit", // 审计日志有专门的只读接口,不需要也不应该从裸查询通道导出 + // 威胁情报误报排除:排除名单是"哪些地址被主动放行了"的清单,泄露等于告诉攻击者 + // 从哪个地址来不会被情报拦;审计流水同理,且它是只增不改的合规证据, + // 有专门的只读接口,不该从裸查询通道导出。 + "threat_ip_exclude", } // allowedSqlOps 结构化条件允许的运算符白名单。 diff --git a/service/waf_service/waf_threat_ip_exclude_audit_service.go b/service/waf_service/waf_threat_ip_exclude_audit_service.go new file mode 100644 index 0000000..811b64f --- /dev/null +++ b/service/waf_service/waf_threat_ip_exclude_audit_service.go @@ -0,0 +1,64 @@ +package waf_service + +import ( + "SamWaf/common/uuid" + "SamWaf/common/zlog" + "SamWaf/customtype" + "SamWaf/global" + "SamWaf/model" + "SamWaf/model/baseorm" + "SamWaf/model/request" + "strings" + "time" +) + +// WafThreatIPExcludeAuditService 排除名单操作审计。 +// +// 单独一份只增不改的流水:排除条目本身会被删除,删了就查不到"曾经排除过什么", +// 而排除是主动降低防护的动作,必须能回溯到人和时间。落日志库,随保留策略清理。 +type WafThreatIPExcludeAuditService struct{} + +var WafThreatIPExcludeAuditApp = new(WafThreatIPExcludeAuditService) + +// Write 写一条审计流水。审计失败只记日志、不阻断主流程—— +// 不能因为流水写不进去就让用户没法处理误报。 +func (r *WafThreatIPExcludeAuditService) Write(rec model.ThreatIPExcludeAudit) { + if global.GWAF_LOCAL_LOG_DB == nil { + return + } + rec.BaseOrm = baseorm.BaseOrm{ + Id: uuid.GenUUID(), + USER_CODE: global.GWAF_USER_CODE, + Tenant_ID: global.GWAF_TENANT_ID, + CREATE_TIME: customtype.JsonTime(time.Now()), + UPDATE_TIME: customtype.JsonTime(time.Now()), + } + if rec.Operator == "" { + rec.Operator = "unknown" + } + if err := global.GWAF_LOCAL_LOG_DB.Create(&rec).Error; err != nil { + zlog.Error("写威胁情报排除审计失败: " + err.Error()) + } +} + +// GetListApi 分页查询审计流水 +func (r *WafThreatIPExcludeAuditService) GetListApi(req request.WafThreatIPExcludeAuditSearchReq) ([]model.ThreatIPExcludeAudit, int64, error) { + var list []model.ThreatIPExcludeAudit + var total int64 + if global.GWAF_LOCAL_LOG_DB == nil { + return list, 0, nil + } + db := global.GWAF_LOCAL_LOG_DB.Model(&model.ThreatIPExcludeAudit{}) + if v := strings.TrimSpace(req.Entry); v != "" { + db = db.Where("entry LIKE ?", "%"+v+"%") + } + if v := strings.TrimSpace(req.Action); v != "" { + db = db.Where("action = ?", v) + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + err := db.Order("create_time DESC"). + Limit(req.PageSize).Offset(req.PageSize * (req.PageIndex - 1)).Find(&list).Error + return list, total, err +} diff --git a/service/waf_service/waf_threat_ip_exclude_service.go b/service/waf_service/waf_threat_ip_exclude_service.go new file mode 100644 index 0000000..6f7b289 --- /dev/null +++ b/service/waf_service/waf_threat_ip_exclude_service.go @@ -0,0 +1,707 @@ +package waf_service + +import ( + "SamWaf/common/uuid" + "SamWaf/common/zlog" + "SamWaf/customtype" + "SamWaf/global" + "SamWaf/model" + "SamWaf/model/baseorm" + "SamWaf/model/request" + "SamWaf/wafenginecore/ipset" + "SamWaf/wafhostguard" + "SamWaf/waftask/threatip" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// 威胁情报误报排除名单。 +// +// 设计要点见 SamWafTechDoc/威胁IP库同步/SamWaf-威胁情报IP误报排除-设计文档.md。 +// 一句话概括:订阅源是全量快照、每周期整份覆盖,用户手工删掉的条目下次同步就回来; +// 系统层又是内核丢包、WAF 白名单救不了。所以误报必须有一份每次落地都重新应用的本地声明。 +// +// 本文件只负责"排除集怎么来、怎么算有效集",落地判据的改造在 waf_threat_ip_service.go。 + +type WafThreatIPExcludeService struct { + mu sync.Mutex // 保护重建过程,避免并发重复编译 + cur atomic.Pointer[ExcludeSet] // 当前排除集(RCU 发布,构建完即只读,读侧无锁) +} + +var WafThreatIPExcludeServiceApp = new(WafThreatIPExcludeService) + +func (r *WafThreatIPExcludeService) load() *ExcludeSet { return r.cur.Load() } +func (r *WafThreatIPExcludeService) store(s *ExcludeSet) { r.cur.Store(s) } + +// excludeEntry 一条编译好的排除条目 +type excludeEntry struct { + Id string // 手工/固化条目的主键;纯配置来源为空 + Raw string // 原文 + Reason string // 自动来源的原因;手工为空 + Volatile bool // 易变源(活跃管理会话IP),命中后需固化 + pat ipset.Pattern // 解析结果 + exact bool // 能否做"网段包含"精确判定(Prefix>=0) +} + +// ExcludeSet 编译后的排除集,构建完成即只读。 +type ExcludeSet struct { + entries []excludeEntry + // fast 是整表编译成的匹配集,只用于**快速否定**: + // 十万条快照逐条去比对几十个排除条目太贵,先用一次 trie 查询把绝大多数条目挡掉, + // 只有可能命中的少数条目才进入精确的"网段包含"判定。 + fast *ipset.MatchSet + // sha 只覆盖**稳定来源**(库里的条目 + 配置类自动源)。 + // 活跃管理会话 IP 有 30 分钟 TTL,算进 sha 会导致 effSha 随 TTL 反复抖动、 + // 每小时对账都判定不一致而重建,所以它靠"命中即固化"转成库里的稳定条目。 + sha string +} + +// Sha 排除集的稳定指纹 +func (e *ExcludeSet) Sha() string { + if e == nil { + return "" + } + return e.sha +} + +// Len 生效条目数 +func (e *ExcludeSet) Len() int { + if e == nil { + return 0 + } + return len(e.entries) +} + +// IsEmpty 排除集是否为空。为空时 effectiveIPs 是恒等变换,effSha == contentSha, +// 存量 landed_sha 保持有效,升级不会触发任何重建。 +func (e *ExcludeSet) IsEmpty() bool { + return e == nil || len(e.entries) == 0 +} + +// Get 取当前排除集,未构建过则构建 +func (r *WafThreatIPExcludeService) Get() *ExcludeSet { + if s := r.load(); s != nil { + return s + } + return r.Rebuild() +} + +// Invalidate 丢弃当前排除集,下次取用时重建 +func (r *WafThreatIPExcludeService) Invalidate() { + r.store(nil) +} + +// NotifySourceChanged 排除集的**外部来源**发生了变化(内置自动排除依赖的那几项配置: +// 内网段豁免开关、防爆破白名单等)。 +// +// 与直接改排除名单同样处理:重建排除集、重建 WAF 并集立即生效、后台跑一次落地对账 +// 把系统防火墙拉到一致。effSha 没变时直接返回,不惊动落地——这一步很重要, +// 否则每次改配置都要让整轮对账去枚举一遍系统防火墙规则。 +func (r *WafThreatIPExcludeService) NotifySourceChanged() { + before := r.Get().Sha() + if r.Rebuild().Sha() == before { + return // 白名单改的是与威胁情报无关的部分,不必惊动落地 + } + safeGo("威胁情报排除来源变更后重新落地", func() { + WafThreatIPServiceApp.RebuildWAFUnion() + WafThreatIPServiceApp.ReconcileLanding() + }) +} + +// Rebuild 由两个来源重建排除集: +// +// ① 内置自动排除(wafhostguard.AutoExcludeSources:回环/本机/内网/配置/管理端/活跃管理会话) +// ② 专用排除名单表 threat_ip_exclude +// +// **刻意不含任何站点的 IP 白名单,包括全局站点。** 起初设计里是含全局站点白名单的, +// 理由是"我明明把办公室 IP 加了全局白名单,结果还是连不上 SSH"很反直觉。实际数据打脸: +// 线上有用户的全局白名单是批量导入的 15 万条,与威胁情报重叠 8000+ 条—— +// 等于**在用户完全不知情的情况下把威胁情报静默削掉 25%**,而且每次重建都要 +// 从库里捞 15 万行、编译 15 万条匹配集,把管理端接口拖到超时。 +// +// 降低防护这件事必须是显式的。要豁免就在排除名单里明确写一条(IP 归属查询里一键就能加), +// 别让它作为另一个功能的副作用悄悄发生。 +// +// 站点级白名单同样不含:系统防火墙是整机级的,用 per-host 白名单驱动整机级排除 +// 会出现"A 站点的白名单顺带给 SSH 开门"的语义错配。 +func (r *WafThreatIPExcludeService) Rebuild() *ExcludeSet { + r.mu.Lock() + defer r.mu.Unlock() + + // 配置加载可能早于数据库就绪。此时返回空集(恒等过滤)而**不缓存**, + // 下次取用会重新构建——缓存了空集就等于永久关闭排除功能直到重启。 + if global.GWAF_LOCAL_DB == nil { + return &ExcludeSet{} + } + + entries := make([]excludeEntry, 0, 64) + seen := make(map[string]struct{}, 64) + stable := make([]string, 0, 64) // 参与 sha 的稳定条目原文 + + appendEntry := func(raw, id, reason string, volatile bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return + } + key := strings.ToLower(raw) + if _, ok := seen[key]; ok { + return + } + pat, err := ipset.ParsePatternLenient(raw) + if err != nil { + zlog.Warn("威胁情报排除条目解析失败,已忽略", "entry", raw, "error", err.Error()) + return + } + seen[key] = struct{}{} + entries = append(entries, excludeEntry{ + Id: id, Raw: raw, Reason: reason, Volatile: volatile, + pat: pat, + exact: pat.Prefix >= 0 && len(pat.Mask) == pat.Width, + }) + if !volatile { + stable = append(stable, raw) + } + } + + // ③ 专用排除名单(仅启用行) + var rows []model.ThreatIPExclude + global.GWAF_LOCAL_DB.Where("enable = ?", 1).Find(&rows) + for _, row := range rows { + appendEntry(row.Entry, row.Id, row.Reason, false) + } + + // ① 内置自动排除 + for _, item := range wafhostguard.AutoExcludeSources() { + appendEntry(item.Entry, "", item.Reason, item.Volatile) + } + + set := &ExcludeSet{entries: entries} + if len(entries) > 0 { + raws := make([]string, 0, len(entries)) + for _, e := range entries { + raws = append(raws, e.Raw) + } + set.fast = ipset.BuildMatchSet(raws) + } + sort.Strings(stable) + sum := sha256.Sum256([]byte(strings.Join(stable, "\n"))) + set.sha = hex.EncodeToString(sum[:]) + + r.store(set) + return set +} + +// EffectiveRule 一条**当前生效**的排除规则,供页面展示。 +// +// 存在的理由:内置自动来源(回环/本机网卡/内网段/管理端白名单/活跃管理会话)不落库, +// 只看 threat_ip_exclude 表的话,用户会看到"已排除6条"却在排除名单里找不到任何条目, +// 完全不知道是谁排的。降低防护的规则必须全部可见,哪怕它是系统内置的。 +type EffectiveRule struct { + Entry string `json:"entry"` + Source string `json:"source"` // manual | auto | builtin(内置,不落库、不可删) + Reason string `json:"reason"` // 内置来源的说明,如"内网段自动豁免" + Editable bool `json:"editable"` // 是否可在页面上删除/停用 +} + +// EffectiveRules 列出当前生效的全部排除规则(含不落库的内置来源) +func (r *WafThreatIPExcludeService) EffectiveRules() []EffectiveRule { + set := r.Get() + out := make([]EffectiveRule, 0, set.Len()) + for i := range set.entries { + e := &set.entries[i] + if e.Id != "" { + continue // 落库条目由排除名单列表自己展示,这里只补内置来源 + } + out = append(out, EffectiveRule{ + Entry: e.Raw, + Source: "builtin", + Reason: e.Reason, + Editable: false, + }) + } + return out +} + +// FilterResult 一次过滤的结果 +type FilterResult struct { + Effective []string // 有效集:应当真正写进防火墙 / 并入 WAF 集合的内容 + Excluded int // 被剔除的条数 + HitsById map[string]int // 库内条目 Id -> 剔除条数,供回写 hit_count + // VolatileHits 命中了易变源(活跃管理会话IP)的条目,需要由调用方固化落库, + // 否则 TTL 一过 effSha 就变回去,会出现"排除生效→过期→重建→又命中"的反复重建。 + VolatileHits []excludeEntry +} + +// Filter 由内容集算出有效集。ips 必须是已排序去重的快照内容。 +// +// 判定规则(设计文档 §5.1):剔除快照条目 S,当且仅当存在排除条目 E 使得 S ⊆ E。 +// 方向性很重要——排除 1.2.3.4 剔不掉快照里的 1.2.3.0/24,小的排不掉大的。 +func (e *ExcludeSet) Filter(ips []string) FilterResult { + res := FilterResult{HitsById: map[string]int{}} + if e.IsEmpty() || len(ips) == 0 { + res.Effective = ips + return res + } + out := make([]string, 0, len(ips)) + volatileSeen := map[string]struct{}{} + for _, raw := range ips { + hit := e.matchEntry(raw) + if hit == nil { + out = append(out, raw) + continue + } + res.Excluded++ + if hit.Id != "" { + res.HitsById[hit.Id]++ + } + if hit.Volatile { + if _, ok := volatileSeen[hit.Raw]; !ok { + volatileSeen[hit.Raw] = struct{}{} + res.VolatileHits = append(res.VolatileHits, *hit) + } + } + } + res.Effective = out + return res +} + +// MatchedEntry 找出豁免了 ip 的那条排除条目,没有则返回 nil。供 IP 归属查询回答 +// "这个 IP 为什么没被威胁情报拦"。 +// +// 只认**落库的**排除条目(Id != ""),不认环境类自动来源(回环/本机网卡/内网段/管理端白名单)。 +// 后者虽然确实参与过滤,但它们不是"针对威胁情报的误报判断"——查任意一个内网地址都会 +// 命中 10.0.0.0/8,报出来只是噪音,还会让用户以为自己排除过这个地址。 +// 落库的条目则相反:它的存在本身就意味着有人(或系统固化)判定过"这在威胁情报里是误报"。 +func (e *ExcludeSet) MatchedEntry(ip net.IP) *ExcludeHit { + if e.IsEmpty() || ip == nil { + return nil + } + if e.fast != nil && !e.fast.Contains(ip) { + return nil + } + for i := range e.entries { + if e.entries[i].Id == "" { + continue + } + if e.entries[i].pat.Match(ip) { + return &ExcludeHit{Raw: e.entries[i].Raw, Reason: e.entries[i].Reason, Id: e.entries[i].Id} + } + } + return nil +} + +// ExcludeHit 一次排除命中的对外描述 +type ExcludeHit struct { + Id string + Raw string + Reason string +} + +// ScopeText 命中来源的展示文案 +func (h *ExcludeHit) ScopeText() string { + if h.Reason != "" { + return h.Reason + } + return "手工排除" +} + +// matchEntry 找出剔除 raw 的那条排除条目,没有则返回 nil +func (e *ExcludeSet) matchEntry(raw string) *excludeEntry { + // 快路径:绝大多数快照条目是**单个 IP**。这时 ParsePattern 的分支判定与 Pattern + // 结构分配纯属浪费——十万条乘下来很可观,而这个函数会被列表页每行调一次。 + // + // 对单个 IP 来说 entryCovers 与 pat.Match(ip) 完全等价: + // - E 是连续掩码时,S.Prefix 恒为满长度,前缀比较必然通过,只剩掩码比对; + // - E 是区间/非连续通配符时,降级路径本来就是 pat.Match。 + if ip := net.ParseIP(raw); ip != nil { + if e.fast != nil && !e.fast.Contains(ip) { + return nil + } + for i := range e.entries { + if e.entries[i].pat.Match(ip) { + return &e.entries[i] + } + } + return nil + } + + // 慢路径:网段等需要做"包含"判定的条目 + sp, err := ipset.ParsePatternLenient(raw) + if err != nil { + return nil // 快照里出现解析不了的内容:保守起见不剔除 + } + // 快速否定:先用整表匹配集问一句"这条的网络地址有没有可能被排除" + if e.fast != nil && !e.fast.Contains(net.IP(sp.Value)) { + return nil + } + for i := range e.entries { + if entryCovers(&e.entries[i], sp) { + return &e.entries[i] + } + } + return nil +} + +// entryCovers 判断排除条目 E 是否**完全包含**快照条目 S。 +// +// 两级判定(设计文档 §5.2): +// - E 能表达成连续掩码(单IP/CIDR/可降级通配符)时做精确的网段包含判定,能整段剔除; +// - E 是任意区间或非连续通配符时降级:只剔除快照里的**单 IP** 条目,网段一律不剔除。 +// 宁可漏剔也不能错剔——错剔等于悄悄放行一整段真实威胁。 +func entryCovers(e *excludeEntry, s ipset.Pattern) bool { + if e.pat.Width != s.Width { + return false // 协议族不同 + } + if !e.exact { + // 降级路径:只处理单 IP + if s.Kind != ipset.KindSingle { + return false + } + return e.pat.Match(net.IP(s.Value)) + } + if s.Prefix < 0 { + // S 自己是区间/非连续通配符,没有可比较的前缀长度,退化为逐地址判定太贵, + // 这类内容在威胁情报快照里不存在(源只给单 IP 和 CIDR),直接不剔除 + return false + } + // E 必须不小于 S,否则是"小的想排掉大的" + if e.pat.Prefix > s.Prefix { + return false + } + // S 的网络地址套上 E 的掩码后应当等于 E 的网络地址 + masked := make([]byte, e.pat.Width) + for i := 0; i < e.pat.Width; i++ { + masked[i] = s.Value[i] & e.pat.Mask[i] + } + return bytes.Equal(masked, e.pat.Value) +} + +// EffectiveIPs 由快照内容算出有效集与其 sha。 +// +// **这是整个误报排除功能的唯一真相源**:同步落地、启动重放、落地对账、WAF 并集、 +// 页面条数、归属查询全部必须经由此处,任何一处直接用快照原文都会导致 +// "落地的是 N-k 条、对账的期望还是 N 条",于是每小时全量重建且永远对不上。 +func (r *WafThreatIPExcludeService) EffectiveIPs(ips []string) (eff []string, effSha string, excluded int) { + set := r.Get() + res := set.Filter(ips) + r.promoteVolatile(res.VolatileHits) + return res.Effective, threatip.ShaOf(res.Effective), res.Excluded +} + +// promoteVolatile 把命中了易变源的条目固化成库里的排除记录。 +// +// 活跃管理会话 IP 只记 30 分钟。如果它真的从威胁情报里剔掉了东西,说明 +// "管理员自己的 IP 被情报源当成了恶意 IP"——这正是最该长期排除的情况。 +// 不固化的话 TTL 一过 effSha 就变回去,会出现"排除生效→过期→重建→又命中→再变"的 +// 反复重建;固化之后只抖一次,而且用户在页面上看得见系统替他做了什么。 +func (r *WafThreatIPExcludeService) promoteVolatile(hits []excludeEntry) { + if len(hits) == 0 { + return + } + changed := false + for _, h := range hits { + var cnt int64 + global.GWAF_LOCAL_DB.Model(&model.ThreatIPExclude{}).Where("entry = ?", h.Raw).Count(&cnt) + if cnt > 0 { + continue + } + row := model.ThreatIPExclude{ + BaseOrm: baseorm.BaseOrm{ + Id: uuid.GenUUID(), + USER_CODE: global.GWAF_USER_CODE, + Tenant_ID: global.GWAF_TENANT_ID, + CREATE_TIME: customtype.JsonTime(time.Now()), + UPDATE_TIME: customtype.JsonTime(time.Now()), + }, + Entry: h.Raw, + Source: model.ThreatExcludeSourceAuto, + Reason: h.Reason, + Enable: 1, + Remarks: fmt.Sprintf("系统自动排除:%s。该地址被威胁情报源收录,为避免把自己锁在门外已固化保留,"+ + "确认无误后可手工删除。", h.Reason), + } + if err := global.GWAF_LOCAL_DB.Create(&row).Error; err != nil { + zlog.Error("固化自动排除条目失败: " + err.Error()) + continue + } + changed = true + zlog.Warn("检测到自己人被威胁情报收录,已自动加入排除名单", "entry", h.Raw, "reason", h.Reason) + WafThreatIPExcludeAuditApp.Write(model.ThreatIPExcludeAudit{ + Action: model.ThreatExcludeActionAdd, Entry: h.Raw, + Source: model.ThreatExcludeSourceAuto, Reason: h.Reason, + Operator: "system", Remarks: "自动固化:该地址命中活跃管理会话IP豁免", + }) + } + if changed { + r.Invalidate() + } +} + +// ApplyHitCounts 回写各排除条目"本轮实际剔除了多少条"。 +// +// 这个数字是给用户看的:最典型的误用是排除了 1.2.3.4,而快照里其实是 1.2.3.0/24, +// 此时 hit_count 恒为 0,用户一眼就能看出"写了但没生效"。 +func (r *WafThreatIPExcludeService) ApplyHitCounts(hits map[string]int) { + if len(hits) == 0 { + return + } + now := time.Now().Unix() + for id, n := range hits { + global.GWAF_LOCAL_DB.Model(&model.ThreatIPExclude{}).Where("id = ?", id). + Updates(map[string]interface{}{"hit_count": n, "last_hit_at": now, "update_time": customtype.JsonTime(time.Now())}) + } +} + +// ---------- 增删改查 ---------- + +// PreviewResult 试算结果:这条排除会影响哪些渠道、剔掉多少条 +type PreviewResult struct { + Entry string `json:"entry"` + AffectedChans int `json:"affected_chans"` + AffectedItems int `json:"affected_items"` + ChannelNames []string `json:"channel_names"` + // SampleMatched 命中的快照条目样例(最多 5 条)。 + // 用户排除 1.2.3.4 却没生效时,这里会显示 1.2.3.0/24,直接告诉他该排整段。 + SampleMatched []string `json:"sample_matched"` + // CoveringEntry 若 entry 本身没剔掉任何东西,但它落在某个更大的快照网段里, + // 这里给出那个网段,前端据此提示"需排除整段" + CoveringEntry string `json:"covering_entry"` +} + +// Preview 试算一条排除条目的影响,不落库。 +func (r *WafThreatIPExcludeService) Preview(entry string) (*PreviewResult, error) { + entry = strings.TrimSpace(entry) + if err := ValidateExcludeEntry(entry); err != nil { + return nil, err + } + pat, err := ipset.ParsePatternLenient(entry) + if err != nil { + return nil, err + } + one := &ExcludeSet{ + entries: []excludeEntry{{ + Raw: entry, pat: pat, + exact: pat.Prefix >= 0 && len(pat.Mask) == pat.Width, + }}, + fast: ipset.BuildMatchSet([]string{entry}), + } + + out := &PreviewResult{Entry: entry, ChannelNames: []string{}, SampleMatched: []string{}} + var channels []model.ThreatIPChannel + global.GWAF_LOCAL_DB.Where("enable = ?", 1).Find(&channels) + for _, ch := range channels { + ips, _ := WafThreatIPServiceApp.loadSnapshot(ch.Code) + if len(ips) == 0 { + continue + } + hit := 0 + for _, ip := range ips { + if one.matchEntry(ip) != nil { + hit++ + if len(out.SampleMatched) < 5 { + out.SampleMatched = append(out.SampleMatched, ip) + } + } + } + if hit > 0 { + out.AffectedChans++ + out.AffectedItems += hit + out.ChannelNames = append(out.ChannelNames, ch.Name) + continue + } + // 没剔掉任何东西:看看它是不是落在某个更大的网段里(方向性陷阱) + if out.CoveringEntry == "" { + out.CoveringEntry = coveringEntryIn(ips, pat) + } + } + return out, nil +} + +// coveringEntryIn 找出 ips 里**包含** pat 的那条网段(用于"小的排不掉大的"的提示) +func coveringEntryIn(ips []string, pat ipset.Pattern) string { + for _, raw := range ips { + sp, err := ipset.ParsePatternLenient(raw) + if err != nil || sp.Width != pat.Width || sp.Prefix < 0 || sp.Prefix >= pat.Prefix { + continue + } + e := excludeEntry{Raw: raw, pat: sp, exact: len(sp.Mask) == sp.Width} + if entryCovers(&e, pat) { + return raw + } + } + return "" +} + +// ValidateExcludeEntry 校验一条排除条目。写入路径专用,严格。 +func ValidateExcludeEntry(entry string) error { + entry = strings.TrimSpace(entry) + if entry == "" { + return fmt.Errorf("排除条目不能为空") + } + if len(entry) > 64 { + return fmt.Errorf("排除条目长度不能超过64个字符") + } + pat, err := ipset.ParsePattern(entry) + if err != nil { + return fmt.Errorf("格式不合法:%s(仅支持单个IP或CIDR网段,如 1.2.3.4 或 1.2.3.0/24)", err.Error()) + } + if pat.Kind != ipset.KindSingle && pat.Kind != ipset.KindCIDR { + return fmt.Errorf("排除条目仅支持单个IP或CIDR网段,不支持通配符与区间写法") + } + // 排除一个巨型网段等于把整个威胁情报功能悄悄关掉,必须在写入侧挡死 + if pat.Width == net.IPv4len && pat.Prefix < 8 { + return fmt.Errorf("IPv4 排除网段不能大于 /8(当前 /%d):这会让威胁情报形同虚设", pat.Prefix) + } + if pat.Width == net.IPv6len && pat.Prefix < 32 { + return fmt.Errorf("IPv6 排除网段不能大于 /32(当前 /%d):这会让威胁情报形同虚设", pat.Prefix) + } + return nil +} + +// AddApi 新增排除条目 +func (r *WafThreatIPExcludeService) AddApi(req request.WafThreatIPExcludeAddReq, operator, operatorIP string) (*PreviewResult, error) { + entry := strings.TrimSpace(req.Entry) + if err := ValidateExcludeEntry(entry); err != nil { + return nil, err + } + var cnt int64 + global.GWAF_LOCAL_DB.Model(&model.ThreatIPExclude{}).Where("entry = ?", entry).Count(&cnt) + if cnt > 0 { + return nil, fmt.Errorf("该排除条目已存在:%s", entry) + } + row := model.ThreatIPExclude{ + BaseOrm: baseorm.BaseOrm{ + Id: uuid.GenUUID(), + USER_CODE: global.GWAF_USER_CODE, + Tenant_ID: global.GWAF_TENANT_ID, + CREATE_TIME: customtype.JsonTime(time.Now()), + UPDATE_TIME: customtype.JsonTime(time.Now()), + }, + Entry: entry, + Source: model.ThreatExcludeSourceManual, + Remarks: req.Remarks, + Enable: 1, + } + if err := global.GWAF_LOCAL_DB.Create(&row).Error; err != nil { + return nil, err + } + res := r.applyChange() + WafThreatIPExcludeAuditApp.Write(model.ThreatIPExcludeAudit{ + Action: model.ThreatExcludeActionAdd, Entry: entry, + Source: model.ThreatExcludeSourceManual, Operator: operator, OperatorIP: operatorIP, + AffectedChans: res.AffectedChans, AffectedItems: res.AffectedItems, Remarks: req.Remarks, + }) + res.Entry = entry + return res, nil +} + +// DelApi 删除排除条目(删除后该 IP 会重新被封) +func (r *WafThreatIPExcludeService) DelApi(id, operator, operatorIP string) error { + var row model.ThreatIPExclude + if err := global.GWAF_LOCAL_DB.Where("id = ?", id).First(&row).Error; err != nil { + return fmt.Errorf("排除条目不存在") + } + if err := global.GWAF_LOCAL_DB.Where("id = ?", id).Delete(model.ThreatIPExclude{}).Error; err != nil { + return err + } + res := r.applyChange() + WafThreatIPExcludeAuditApp.Write(model.ThreatIPExcludeAudit{ + Action: model.ThreatExcludeActionDel, Entry: row.Entry, + Source: row.Source, Reason: row.Reason, Operator: operator, OperatorIP: operatorIP, + AffectedChans: res.AffectedChans, AffectedItems: res.AffectedItems, + Remarks: "删除排除条目,该地址将重新按威胁情报拦截", + }) + return nil +} + +// ModifyApi 改备注 / 启停 +func (r *WafThreatIPExcludeService) ModifyApi(req request.WafThreatIPExcludeEditReq, operator, operatorIP string) error { + var row model.ThreatIPExclude + if err := global.GWAF_LOCAL_DB.Where("id = ?", req.Id).First(&row).Error; err != nil { + return fmt.Errorf("排除条目不存在") + } + enableChanged := row.Enable != req.Enable + if err := global.GWAF_LOCAL_DB.Model(&model.ThreatIPExclude{}).Where("id = ?", req.Id). + Updates(map[string]interface{}{ + "remarks": req.Remarks, + "enable": req.Enable, + "update_time": customtype.JsonTime(time.Now()), + }).Error; err != nil { + return err + } + if !enableChanged { + return nil + } + res := r.applyChange() + action := model.ThreatExcludeActionEnable + if req.Enable == 0 { + action = model.ThreatExcludeActionDisable + } + WafThreatIPExcludeAuditApp.Write(model.ThreatIPExcludeAudit{ + Action: action, Entry: row.Entry, Source: row.Source, Reason: row.Reason, + Operator: operator, OperatorIP: operatorIP, + AffectedChans: res.AffectedChans, AffectedItems: res.AffectedItems, Remarks: req.Remarks, + }) + return nil +} + +// GetListApi 分页查询排除名单 +func (r *WafThreatIPExcludeService) GetListApi(req request.WafThreatIPExcludeSearchReq) ([]model.ThreatIPExclude, int64, error) { + var list []model.ThreatIPExclude + var total int64 + db := global.GWAF_LOCAL_DB.Model(&model.ThreatIPExclude{}) + if s := strings.TrimSpace(req.Source); s != "" { + db = db.Where("source = ?", s) + } + if k := strings.TrimSpace(req.Entry); k != "" { + db = db.Where("entry LIKE ?", "%"+k+"%") + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + err := db.Order("create_time DESC"). + Limit(req.PageSize).Offset(req.PageSize * (req.PageIndex - 1)).Find(&list).Error + return list, total, err +} + +// applyChange 排除名单变更后的统一生效路径: +// +// 重建排除集 → 重建 WAF 并集(毫秒级立即生效) → 逐渠道对账重建系统防火墙 +// +// 系统层复用 ReconcileLanding:它以有效集为期望值,排除变了 effSha 就变, +// 自然会发现落地态对不上并覆盖式重建;effSha 没变的渠道原地跳过, +// 不会为了一个不在任何渠道里的 IP 做几十次 netsh 的无谓重建。 +func (r *WafThreatIPExcludeService) applyChange() *PreviewResult { + r.Rebuild() + out := &PreviewResult{ChannelNames: []string{}, SampleMatched: []string{}} + + set := r.Get() + var channels []model.ThreatIPChannel + global.GWAF_LOCAL_DB.Where("enable = ?", 1).Find(&channels) + for _, ch := range channels { + ips, _ := WafThreatIPServiceApp.loadSnapshot(ch.Code) + if len(ips) == 0 { + continue + } + res := set.Filter(ips) + if res.Excluded > 0 { + out.AffectedChans++ + out.AffectedItems += res.Excluded + out.ChannelNames = append(out.ChannelNames, ch.Name) + } + } + + WafThreatIPServiceApp.RebuildWAFUnion() + safeGo("威胁情报排除变更后落地对账", func() { + WafThreatIPServiceApp.ReconcileLanding() + }) + return out +} diff --git a/service/waf_service/waf_threat_ip_exclude_test.go b/service/waf_service/waf_threat_ip_exclude_test.go new file mode 100644 index 0000000..a5b4a8b --- /dev/null +++ b/service/waf_service/waf_threat_ip_exclude_test.go @@ -0,0 +1,188 @@ +package waf_service + +import ( + "SamWaf/wafenginecore/ipset" + "SamWaf/waftask/threatip" + "testing" +) + +// buildSet 用一组原文构造排除集,绕开数据库(单测不依赖 DB) +func buildSet(t *testing.T, raws ...string) *ExcludeSet { + t.Helper() + entries := make([]excludeEntry, 0, len(raws)) + for _, raw := range raws { + pat, err := ipset.ParsePatternLenient(raw) + if err != nil { + t.Fatalf("排除条目 %q 解析失败: %v", raw, err) + } + entries = append(entries, excludeEntry{ + Raw: raw, pat: pat, + exact: pat.Prefix >= 0 && len(pat.Mask) == pat.Width, + }) + } + return &ExcludeSet{entries: entries, fast: ipset.BuildMatchSet(raws)} +} + +// TestEntryCoversDirection 钉死方向性:只有"大的能排掉小的",反过来不行。 +// 这是用户最容易踩的坑——排除 1.2.3.4 却指望剔掉快照里的 1.2.3.0/24。 +func TestEntryCoversDirection(t *testing.T) { + cases := []struct { + exclude string + snapshot string + want bool + why string + }{ + {"1.2.3.4", "1.2.3.4", true, "完全相等"}, + {"1.2.3.0/24", "1.2.3.4", true, "单IP落在排除段内"}, + {"1.2.3.0/24", "1.2.3.0/24", true, "网段相等"}, + {"1.2.0.0/16", "1.2.3.0/24", true, "大段包含小段"}, + {"1.2.3.4", "1.2.3.0/24", false, "小的排不掉大的——必须为 false"}, + {"1.2.3.0/24", "1.2.4.0/24", false, "相邻但不包含"}, + {"1.2.3.0/25", "1.2.3.0/24", false, "排除段比快照段小"}, + {"10.0.0.0/8", "10.1.2.3", true, "内网大段"}, + {"2001:db8::/32", "2001:db8::1", true, "IPv6 段含单地址"}, + {"1.2.3.0/24", "2001:db8::1", false, "协议族不同"}, + } + for _, c := range cases { + set := buildSet(t, c.exclude) + got := set.matchEntry(c.snapshot) != nil + if got != c.want { + t.Errorf("排除 %q 对快照 %q:期望 %v 实际 %v(%s)", c.exclude, c.snapshot, c.want, got, c.why) + } + } +} + +// TestFilterKeepsOrderAndCounts 过滤后仍是有序去重的列表,且条数统计准确 +func TestFilterKeepsOrderAndCounts(t *testing.T) { + snapshot := []string{"1.2.3.4", "1.2.3.5", "5.6.7.0/24", "9.9.9.9"} + set := buildSet(t, "1.2.3.0/24") + + res := set.Filter(snapshot) + if res.Excluded != 2 { + t.Fatalf("期望剔除 2 条,实际 %d", res.Excluded) + } + want := []string{"5.6.7.0/24", "9.9.9.9"} + if len(res.Effective) != len(want) { + t.Fatalf("有效集条数不符:期望 %d 实际 %d (%v)", len(want), len(res.Effective), res.Effective) + } + for i := range want { + if res.Effective[i] != want[i] { + t.Errorf("有效集第 %d 条:期望 %q 实际 %q", i, want[i], res.Effective[i]) + } + } +} + +// TestEmptyExcludeIsIdentity 排除集为空时必须是恒等变换。 +// +// 这条是升级平滑性的地基:effSha == contentSha 时,存量 landed_sha 全部保持有效, +// 升级后不会触发一次全量重建(Windows 上那是几十次 netsh × 每个渠道)。 +func TestEmptyExcludeIsIdentity(t *testing.T) { + snapshot := []string{"1.2.3.4", "5.6.7.0/24"} + var empty *ExcludeSet // nil 也必须安全 + + for name, set := range map[string]*ExcludeSet{"nil": empty, "空集": {}} { + res := set.Filter(snapshot) + if res.Excluded != 0 { + t.Errorf("%s:不应剔除任何条目,实际剔了 %d", name, res.Excluded) + } + if threatip.ShaOf(res.Effective) != threatip.ShaOf(snapshot) { + t.Errorf("%s:有效集 sha 必须等于内容 sha", name) + } + } +} + +// TestShaOfMatchesEncodeSnapshot effSha 与 contentSha 必须同算法,否则两者无法比较, +// "内容没变且落地态一致就跳过"这个判据会永远不成立、每次同步都重建。 +func TestShaOfMatchesEncodeSnapshot(t *testing.T) { + ips := []string{"5.6.7.0/24", "1.2.3.4", "1.2.3.4"} // 故意乱序 + 重复 + _, sha, count, err := threatip.EncodeSnapshot(ips) + if err != nil { + t.Fatalf("EncodeSnapshot 失败: %v", err) + } + if count != 2 { + t.Fatalf("去重后应为 2 条,实际 %d", count) + } + if got := threatip.ShaOf(ips); got != sha { + t.Errorf("ShaOf 与 EncodeSnapshot 的 sha 不一致:%s vs %s", got, sha) + } +} + +// TestExcludeChangesEffSha 排除名单一变 effSha 就必须变—— +// "排除生效"整个靠这一点驱动,不需要额外的缓存失效机制。 +func TestExcludeChangesEffSha(t *testing.T) { + snapshot := []string{"1.2.3.4", "5.6.7.8"} + before := threatip.ShaOf(buildSet(t).Filter(snapshot).Effective) + after := threatip.ShaOf(buildSet(t, "1.2.3.4").Filter(snapshot).Effective) + if before == after { + t.Error("加了排除条目后 effSha 必须变化,否则对账不会重建、排除永远不生效") + } +} + +// TestMatchedEntryForLookup 归属查询要能回答"这个 IP 为什么没被拦"。 +// +// 同时钉死噪音边界:环境类自动来源(内网段/本机网卡/回环)虽然参与过滤, +// 但不该在归属查询里报出来——否则查任何一个内网地址都会显示"已被排除名单豁免", +// 用户会误以为自己排除过它。 +func TestMatchedEntryForLookup(t *testing.T) { + set := buildSet(t, "1.2.3.0/24") + set.entries[0].Id = "row-1" // 模拟落库条目 + + if hit := set.MatchedEntry(mustIP(t, "1.2.3.99")); hit == nil || hit.Raw != "1.2.3.0/24" { + t.Errorf("段内地址应报告命中 1.2.3.0/24,实际 %+v", hit) + } + if hit := set.MatchedEntry(mustIP(t, "9.9.9.9")); hit != nil { + t.Errorf("段外地址不应命中,实际 %+v", hit) + } + + ambient := buildSet(t, "10.0.0.0/8") // Id 为空 = 环境类自动来源 + if hit := ambient.MatchedEntry(mustIP(t, "10.1.2.3")); hit != nil { + t.Errorf("环境类自动来源不该在归属查询里报出来,实际 %+v", hit) + } + // 但它仍然必须参与过滤——只是不报,不是不生效 + if res := ambient.Filter([]string{"10.1.2.3"}); res.Excluded != 1 { + t.Errorf("环境类来源必须照常参与过滤,期望剔除 1 条,实际 %d", res.Excluded) + } +} + +// TestValidateExcludeEntry 写入侧校验:巨型网段必须挡死, +// 排除一个 /0 等于把整个威胁情报功能悄悄关掉。 +func TestValidateExcludeEntry(t *testing.T) { + bad := []string{"", "0.0.0.0/0", "::/0", "1.0.0.0/4", "10.10.*.*", "1.2.3.4-1.2.3.9", "not-an-ip", + "2001:db8::/16"} + for _, v := range bad { + if err := ValidateExcludeEntry(v); err == nil { + t.Errorf("%q 应当被拒绝", v) + } + } + good := []string{"1.2.3.4", "1.2.3.0/24", "10.0.0.0/8", "2001:db8::1", "2001:db8::/32"} + for _, v := range good { + if err := ValidateExcludeEntry(v); err != nil { + t.Errorf("%q 应当通过,实际被拒: %v", v, err) + } + } +} + +// TestCoveringEntryIn 排除没生效时,要能指出"你其实该排这个段" +func TestCoveringEntryIn(t *testing.T) { + snapshot := []string{"1.2.3.0/24", "9.9.9.9"} + pat, err := ipset.ParsePattern("1.2.3.4") + if err != nil { + t.Fatal(err) + } + if got := coveringEntryIn(snapshot, pat); got != "1.2.3.0/24" { + t.Errorf("应提示所属网段 1.2.3.0/24,实际 %q", got) + } + pat2, _ := ipset.ParsePattern("8.8.8.8") + if got := coveringEntryIn(snapshot, pat2); got != "" { + t.Errorf("不属于任何网段时应返回空,实际 %q", got) + } +} + +func mustIP(t *testing.T, s string) []byte { + t.Helper() + p, err := ipset.ParsePattern(s) + if err != nil { + t.Fatalf("解析 %q 失败: %v", s, err) + } + return p.Value +} diff --git a/service/waf_service/waf_threat_ip_landing_test.go b/service/waf_service/waf_threat_ip_landing_test.go index 55769de..45219d7 100644 --- a/service/waf_service/waf_threat_ip_landing_test.go +++ b/service/waf_service/waf_threat_ip_landing_test.go @@ -16,11 +16,14 @@ func TestLandingUpToDate(t *testing.T) { const shaA = "aaaa1111" const shaB = "bbbb2222" + const shaEff = "cccc3333" + cases := []struct { name string contentSha string // 本次拉取解析出来的内容 sha snapshotSha string // 库里快照的 sha landedSha string // 已确认落地的 sha + effSha string // 应当落地的内容(内容集剔除误报排除后)的 sha;空表示与 contentSha 相同 want bool // 是否可以完全跳过落地 why string }{ @@ -54,13 +57,32 @@ func TestLandingUpToDate(t *testing.T) { contentSha: shaA, snapshotSha: "", landedSha: shaA, want: false, why: "快照都没有,落地态不可信,不能据此跳过", }, + { + name: "内容没变但用户刚改了误报排除-必须重新落地", + contentSha: shaA, snapshotSha: shaA, landedSha: shaA, effSha: shaEff, + want: false, why: "源内容一个字没变,但该落地的东西变了——排除名单生效全靠这条", + }, + { + name: "排除生效后已按有效集落地-可跳过", + contentSha: shaA, snapshotSha: shaA, landedSha: shaEff, effSha: shaEff, + want: true, why: "落地态等于有效集,稳态,不该反复重建", + }, + { + name: "有排除但落地态还等于内容sha-必须重新落地", + contentSha: shaA, snapshotSha: shaA, landedSha: shaA, effSha: shaEff, + want: false, why: "防呆:不能拿内容 sha 去比,否则排除永远不落地", + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := landingUpToDate(c.contentSha, c.snapshotSha, c.landedSha); got != c.want { - t.Errorf("landingUpToDate(%q,%q,%q) = %v, want %v —— %s", - c.contentSha, c.snapshotSha, c.landedSha, got, c.want, c.why) + effSha := c.effSha + if effSha == "" { + effSha = c.contentSha // 没有排除名单时有效集恒等于内容集 + } + if got := landingUpToDate(c.contentSha, c.snapshotSha, c.landedSha, effSha); got != c.want { + t.Errorf("landingUpToDate(%q,%q,%q,%q) = %v, want %v —— %s", + c.contentSha, c.snapshotSha, c.landedSha, effSha, got, c.want, c.why) } }) } @@ -91,11 +113,11 @@ func TestLandedOK(t *testing.T) { } cases := []struct { - name string - ch model.ThreatIPChannel - snapshotSha string - want bool - why string + name string + ch model.ThreatIPChannel + effSha string // 当前应当落地的内容(有效集)的 sha + want bool + why string }{ {"系统层已落地", ch(model.ThreatLandSystem, shaA), shaA, true, "正常稳态"}, {"两者已落地", ch(model.ThreatLandBoth, shaA), shaA, true, "正常稳态"}, @@ -103,12 +125,14 @@ func TestLandedOK(t *testing.T) { {"系统层从未确认落地", ch(model.ThreatLandSystem, ""), shaA, false, "老库升级上来,对账会在一小时内补上"}, {"仅WAF层-不该报警", ch(model.ThreatLandWAF, ""), shaA, true, "本来就不往系统防火墙写"}, {"还没有快照-不该报警", ch(model.ThreatLandSystem, ""), "", true, "从没同步过,没什么可落地的"}, + {"刚加了排除还没重新落地", ch(model.ThreatLandSystem, shaA), shaB, false, + "排除改了 effSha 就变,落地态对不上,该提示用户正在重新落地"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := landedOK(c.ch, c.snapshotSha); got != c.want { - t.Errorf("landedOK(land=%s, landedSha=%q, snapshotSha=%q) = %v, want %v —— %s", - c.ch.LandTarget, c.ch.LandedSha, c.snapshotSha, got, c.want, c.why) + if got := landedOK(c.ch, c.effSha); got != c.want { + t.Errorf("landedOK(land=%s, landedSha=%q, effSha=%q) = %v, want %v —— %s", + c.ch.LandTarget, c.ch.LandedSha, c.effSha, got, c.want, c.why) } }) } diff --git a/service/waf_service/waf_threat_ip_service.go b/service/waf_service/waf_threat_ip_service.go index 02ff1eb..13c512b 100644 --- a/service/waf_service/waf_threat_ip_service.go +++ b/service/waf_service/waf_threat_ip_service.go @@ -308,29 +308,86 @@ func (r *WafThreatIPService) GetListApi(req request.WafThreatIPChannelSearchReq) if cnt >= 0 { list[i].LastCount = cnt } - list[i].LandedOK = landedOK(list[i], sha) + effSha, excluded := r.effectiveMeta(list[i], sha) + list[i].ExcludedCount = excluded + list[i].LandedOK = landedOK(list[i], effSha) // 回填内存态"同步中",前端据此显示进行中并自动轮询 list[i].Syncing, list[i].SyncStartedAt = r.syncingOf(list[i].Code) } return list, total, err } -// landedOK 判断该渠道的系统防火墙是否已确认落地到当前快照。 +// effMetaEntry 某渠道有效集元信息的缓存项 +type effMetaEntry struct { + snapSha string // 算出这份结果时的快照 sha + excludeSha string // 算出这份结果时的排除集 sha + sha string + excluded int +} + +var ( + effMetaMu sync.Mutex + effMetaStore = map[string]effMetaEntry{} +) + +// rememberEffMeta 由**已经算过有效集**的路径(同步/启动重放/落地对账)回填缓存。 +// 这些路径本来就要解压快照并过滤,顺手记一笔,展示接口就永远不必自己算。 +func rememberEffMeta(code, snapshotSha, excludeSha, effSha string, excluded int) { + effMetaMu.Lock() + effMetaStore[code] = effMetaEntry{snapSha: snapshotSha, excludeSha: excludeSha, sha: effSha, excluded: excluded} + effMetaMu.Unlock() +} + +// effectiveMeta 取某渠道有效集的 sha 与被排除条数,供列表页/落地汇总展示。 +// +// **这个函数只读缓存,绝不自己解压快照。** 它在列表接口里是**每行**调一次的, +// 一旦允许它按需计算,一次刷新就要解压 N 个渠道的快照并逐条过滤;线上实测叠加 +// 启动重放/落地对账的 netsh 开销后,直接把 /threatip/channel/list 拖到 20s 超时。 +// +// 缓存未命中(刚启动、还没跑过同步或对账)时退回**已落库的状态**: +// 拿 landed_sha 当 effSha、last_count-landed_count 当排除条数。 +// 这会让"未完全落地"标签在冷启动的头一分钟内偏向"正常"——这正是我们要的方向, +// 宁可漏报也不能误报;启动时的对账跑完就会回填真实值。 +func (r *WafThreatIPService) effectiveMeta(ch model.ThreatIPChannel, snapshotSha string) (effSha string, excluded int) { + if snapshotSha == "" { + return "", 0 + } + excludeSha := WafThreatIPExcludeServiceApp.Get().Sha() + + effMetaMu.Lock() + c, ok := effMetaStore[ch.Code] + effMetaMu.Unlock() + if ok && c.snapSha == snapshotSha && c.excludeSha == excludeSha { + return c.sha, c.excluded + } + + // 退回落库状态:不做任何 IO,展示接口必须是常数级的 + fallbackExcluded := ch.LastCount - ch.LandedCount + if fallbackExcluded < 0 || ch.LandedSha == "" { + fallbackExcluded = 0 + } + if ch.LandedSha == "" { + return snapshotSha, 0 + } + return ch.LandedSha, fallbackExcluded +} + +// landedOK 判断该渠道的系统防火墙是否已确认落地到**当前应有的内容**(有效集)。 // // 以下情况一律算"到位",避免给用户报无意义的警: // - 落地层不含系统防火墙:本来就不往系统层写 // - 还没有任何快照:没什么可落地的 // -// 环境不支持 ipset 的情况不用在这里特判——落地流程已经把 LandedSha 记成了内容 sha +// 环境不支持 ipset 的情况不用在这里特判——落地流程已经把 LandedSha 记成了有效集 sha // (见 landSystemLayer 的说明:那不是"落地失败",是"这台机器就不做系统层")。 -func landedOK(ch model.ThreatIPChannel, snapshotSha string) bool { +func landedOK(ch model.ThreatIPChannel, effSha string) bool { if ch.LandTarget != model.ThreatLandSystem && ch.LandTarget != model.ThreatLandBoth { return true } - if snapshotSha == "" { + if effSha == "" { return true } - return ch.LandedSha == snapshotSha + return ch.LandedSha == effSha } // syncingOf 查某渠道当前是否正在同步,以及本次同步的开始时间戳 @@ -350,8 +407,9 @@ type LandedChannelSummary struct { Name string `json:"name"` LandTarget string `json:"land_target"` Enable int `json:"enable"` - Count int `json:"count"` // 实际生效(已落地)条数:启用=快照条数,停用=0(已从防火墙/WAF移除) + Count int `json:"count"` // 实际生效(已落地)条数:启用=有效集条数,停用=0(已从防火墙/WAF移除) SnapshotCount int `json:"snapshot_count"` // 快照收录条数(不论启用与否,供停用时提示"再启用可回灌") + ExcludedCount int `json:"excluded_count"` // 被误报排除名单剔掉的条数,解释 Count 为什么比 SnapshotCount 少 LastSyncAt int64 `json:"last_sync_at"` // 上次同步时间戳(秒) LastStatus string `json:"last_status"` // 上次同步结果 Syncing bool `json:"syncing"` // 当前是否有同步在进行(内存态) @@ -368,12 +426,17 @@ func (r *WafThreatIPService) GetLandedSummary(land string) []LandedChannelSummar if !landMatches(ch.LandTarget, land) { continue } - snapCnt := r.snapshotCount(ch.Code) + snapCnt, snapSha := r.snapshotMeta(ch.Code) if snapCnt < 0 { snapCnt = 0 } - // 实际落地条数:停用渠道已从防火墙/WAF 移除,落地数应为 0(快照仍保留以便再启用秒回灌) - landed := snapCnt + // 实际落地条数要扣掉被排除的误报,否则页面数字与防火墙里的真实条数对不上 + _, excluded := r.effectiveMeta(ch, snapSha) + landed := snapCnt - excluded + if landed < 0 { + landed = 0 + } + // 停用渠道已从防火墙/WAF 移除,落地数应为 0(快照仍保留以便再启用秒回灌) if ch.Enable == 0 { landed = 0 } @@ -385,6 +448,7 @@ func (r *WafThreatIPService) GetLandedSummary(land string) []LandedChannelSummar Enable: ch.Enable, Count: landed, SnapshotCount: snapCnt, + ExcludedCount: excluded, LastSyncAt: ch.LastSyncAt, LastStatus: ch.LastStatus, Syncing: syncing, @@ -394,14 +458,42 @@ func (r *WafThreatIPService) GetLandedSummary(land string) []LandedChannelSummar return out } -// GetLandedIPs 分页浏览某渠道快照里的 IP/CIDR(只读)。keyword 为子串过滤(可空)。 -// 返回当前页切片与过滤后总数。 -func (r *WafThreatIPService) GetLandedIPs(code, keyword string, pageIndex, pageSize int) ([]string, int64) { - ips, _ := r.loadSnapshot(code) // 已排序去重 +// LandedIP 落地 IP 浏览的一行。 +// ExcludedBy/Reason 只在"仅看已排除"模式下有值——用户问的是"这条为什么没进防火墙", +// 光给个 IP 列表回答不了,必须指名是哪条排除规则干的。 +type LandedIP struct { + IP string `json:"ip"` + ExcludedBy string `json:"excluded_by"` // 命中的排除条目原文 + Reason string `json:"reason"` // 该条目的来源说明(内置自动来源才有) +} + +// GetLandedIPs 分页浏览某渠道的 IP/CIDR(只读)。keyword 为子串过滤(可空)。 +// +// 默认列的是**有效集**——页面标题是"已落地",就该和防火墙里实际存在的东西一致, +// 否则用户排除完还能在这里看到那条 IP,会以为排除没生效。 +// onlyExcluded=true 时反过来只列被排除掉的那些,并带上是被哪条规则排除的。 +func (r *WafThreatIPService) GetLandedIPs(code, keyword string, onlyExcluded bool, pageIndex, pageSize int) ([]LandedIP, int64) { + all, _ := r.loadSnapshot(code) // 已排序去重 + set := WafThreatIPExcludeServiceApp.Get() + + ips := make([]LandedIP, 0, len(all)) + for _, ip := range all { + hit := set.matchEntry(ip) + if onlyExcluded { + if hit != nil { + ips = append(ips, LandedIP{IP: ip, ExcludedBy: hit.Raw, Reason: hit.Reason}) + } + continue + } + if hit == nil { + ips = append(ips, LandedIP{IP: ip}) + } + } + if keyword = strings.TrimSpace(keyword); keyword != "" { - filtered := make([]string, 0, len(ips)) + filtered := make([]LandedIP, 0, len(ips)) for _, ip := range ips { - if strings.Contains(ip, keyword) { + if strings.Contains(ip.IP, keyword) { filtered = append(filtered, ip) } } @@ -416,7 +508,7 @@ func (r *WafThreatIPService) GetLandedIPs(code, keyword string, pageIndex, pageS } start := (pageIndex - 1) * pageSize if start >= len(ips) { - return []string{}, total + return []LandedIP{}, total } end := start + pageSize if end > len(ips) { @@ -557,24 +649,35 @@ func (r *WafThreatIPService) syncChannelWithTrigger(ch model.ThreatIPChannel, tr r.markSyncFail(ch.Id, "快照编码失败: "+err.Error()) return err } + // 应用误报排除名单:内容集 → 有效集。落地相关的一切判据从这里往下都只认有效集, + // 内容 sha 只继续负责"源内容变没变"。二者混用会导致"落地的是 N-k 条、对账的期望还是 N 条", + // 于是每小时判定不一致、全量重建、且永远对不上。 + effIPs, effSha, excluded := WafThreatIPExcludeServiceApp.EffectiveIPs(newIPs) + rememberEffMeta(ch.Code, sha, WafThreatIPExcludeServiceApp.Get().Sha(), effSha, excluded) + if excluded > 0 { + zlog.Info("威胁情报订阅已应用误报排除", "channel", ch.Code, "content", count, "excluded", excluded, "effective", len(effIPs)) + } + contentSame := sameContent(sha, oldSha) - if landingUpToDate(sha, oldSha, ch.LandedSha) { + if landingUpToDate(sha, oldSha, ch.LandedSha, effSha) { // 内容与落地态都没变,才真的可以什么都不做 zlog.Info("威胁情报订阅内容无变化且落地态一致,跳过落地", "channel", ch.Code, "count", count, "elapsed", time.Since(start).Round(time.Millisecond).String()) - r.markSyncOK(ch.Id, fmt.Sprintf("ok(无变化,%s触发,耗时%s)", trigger, time.Since(start).Round(time.Millisecond)), count, sha) + r.markSyncOK(ch.Id, fmt.Sprintf("ok(无变化,%s触发,耗时%s)%s", trigger, time.Since(start).Round(time.Millisecond), excludedNote(excluded)), count, effSha, len(effIPs)) return nil } if contentSame { - // 内容没变但落地态对不上(上次落地中断/半截、或从老版本升级上来 landed_sha 为空): + // 内容没变但落地态对不上(上次落地中断/半截、排除名单刚改过、或从老版本升级上来 landed_sha 为空): // 不早退,往下走一遍覆盖式重建把系统层拉回一致。 zlog.Warn("威胁情报订阅内容无变化但落地态不一致,将覆盖式重建", "channel", ch.Code, - "landed_sha", shortSha(ch.LandedSha), "content_sha", shortSha(sha)) + "landed_sha", shortSha(ch.LandedSha), "eff_sha", shortSha(effSha)) } added, removed := threatip.Diff(oldIPs, newIPs) - // 保存新快照(替换该渠道旧快照)。内容没变时无需重写,省一次大 blob 读写。 + // 保存新快照(替换该渠道旧快照)。快照存的是**内容集原文**,不是有效集—— + // 排除名单随时可能改回去,把过滤结果落库就再也还原不出源到底给了什么。 + // 内容没变时无需重写,省一次大 blob 读写。 if !contentSame { if err := r.saveSnapshot(ch.Code, payload, sha, count); err != nil { zlog.Error(fmt.Sprintf("威胁情报订阅保存快照失败 channel=%s error=%s", ch.Code, err.Error())) @@ -584,7 +687,7 @@ func (r *WafThreatIPService) syncChannelWithTrigger(ch model.ThreatIPChannel, tr } // 落地系统防火墙(该渠道私有集合,全量重建) - landErr := r.landSystemLayer(ch, newIPs, count) + landErr := r.landSystemLayer(ch, effIPs, len(effIPs)) // 落地 WAF 应用层(重建全局并集)。这层是纯内存 + atomic 发布,不存在半截状态。 r.RebuildWAFUnion() @@ -599,7 +702,7 @@ func (r *WafThreatIPService) syncChannelWithTrigger(ch model.ThreatIPChannel, tr return landErr } - r.markSyncOK(ch.Id, fmt.Sprintf("ok(+%d/-%d,丢弃%d,%s触发,耗时%s)", len(added), len(removed), parseRes.Dropped, trigger, elapsed), count, sha) + r.markSyncOK(ch.Id, fmt.Sprintf("ok(+%d/-%d,丢弃%d,%s触发,耗时%s)%s", len(added), len(removed), parseRes.Dropped, trigger, elapsed, excludedNote(excluded)), count, effSha, len(effIPs)) zlog.Info("威胁情报订阅同步完成", "channel", ch.Code, "trigger", trigger, "count", count, "added", len(added), "removed", len(removed), "elapsed", elapsed.String()) return nil @@ -640,9 +743,22 @@ func sameContent(contentSha, snapshotSha string) bool { // 这是本模块最容易踩错的一个判据。只看内容 sha 的话会漏掉这条链路: // 快照先落库 → 系统层落地中断(只封了一半) → 下次同步拉到相同内容 → 判定"无变化"早退 // → 永远不再落地,页面却一直显示 ok。加上 landedSha 之后, -// 落地没成功就永远不会等于内容 sha,下一轮必定重建,直到真的落到位。 -func landingUpToDate(contentSha, snapshotSha, landedSha string) bool { - return sameContent(contentSha, snapshotSha) && landedSha == contentSha +// 落地没成功就永远不会等于应有的 sha,下一轮必定重建,直到真的落到位。 +// +// landedSha 比的是 **effSha(有效集)** 而不是内容 sha:源内容没变、但用户改了误报排除名单时, +// 该落地的东西已经变了,必须重建。effSha 由过滤后的列表算出,排除名单一改它就变, +// 于是"排除生效"这件事完全不需要额外的失效通知机制,靠既有的对账循环自然完成。 +// 排除名单为空时 effSha == contentSha,存量 landed_sha 保持有效,升级不会触发重建。 +func landingUpToDate(contentSha, snapshotSha, landedSha, effSha string) bool { + return sameContent(contentSha, snapshotSha) && landedSha == effSha && effSha != "" +} + +// excludedNote 把"本次排除了多少条"拼进 last_status,让用户在列表上直接看到排除起了作用 +func excludedNote(excluded int) string { + if excluded <= 0 { + return "" + } + return fmt.Sprintf("(已排除%d条误报)", excluded) } // shortSha 日志里只留 sha 前 8 位,够区分且不刷屏;空值显示为"(空)" @@ -661,13 +777,21 @@ func (r *WafThreatIPService) RebuildWAFUnion() { var channels []model.ThreatIPChannel global.GWAF_LOCAL_DB.Where("enable = 1").Find(&channels) + // 排除名单同样作用于 WAF 层:它的语义是"这个 IP 不是威胁", + // 而不是"这个 IP 别进防火墙",两层心智必须一致。 + // 并集本来每次就全量重建、没有 sha 缓存,多一道过滤是白送的。 + exclude := WafThreatIPExcludeServiceApp.Get() + uniq := make(map[string]struct{}) + excluded := 0 for _, ch := range channels { if ch.LandTarget != model.ThreatLandWAF && ch.LandTarget != model.ThreatLandBoth { continue } ips, _ := r.loadSnapshot(ch.Code) - for _, ip := range ips { + res := exclude.Filter(ips) + excluded += res.Excluded + for _, ip := range res.Effective { uniq[ip] = struct{}{} } } @@ -681,7 +805,7 @@ func (r *WafThreatIPService) RebuildWAFUnion() { } buildStart := time.Now() ipset.SetGlobalThreatMatcher(ipset.BuildMatchSet(items)) - zlog.Info("威胁情报 WAF 并集已重建", "total", len(items), + zlog.Info("威胁情报 WAF 并集已重建", "total", len(items), "excluded", excluded, "elapsed", time.Since(buildStart).Round(time.Millisecond).String()) } @@ -714,9 +838,16 @@ func (r *WafThreatIPService) RestoreAllOnStartup() { if len(ips) == 0 { continue } + // 重放的必须是**有效集**:直接灌快照原文,会把用户排除掉的误报 IP 每次重启都封回去 + effIPs, effSha, excluded := WafThreatIPExcludeServiceApp.EffectiveIPs(ips) + _, snapSha := r.snapshotMeta(ch.Code) + rememberEffMeta(ch.Code, snapSha, WafThreatIPExcludeServiceApp.Get().Sha(), effSha, excluded) + if len(effIPs) == 0 { + continue + } setName := setNameForChannel(ch.Code) // 系统里已经就是这份内容(Windows 规则持久化)就别重建:省掉上百次 netsh,也不用抢锁 - if r.fw.IPSetUpToDate(setName, ips) { + if r.fw.IPSetUpToDate(setName, effIPs) { skipped++ continue } @@ -724,13 +855,12 @@ func (r *WafThreatIPService) RestoreAllOnStartup() { zlog.Warn("启动重放跳过该渠道(未获取同步锁)", "channel", ch.Code, "detail", r.busyHint()) continue } - if err := r.fw.RestoreIPSet(setName, ips); err != nil { + if err := r.fw.RestoreIPSet(setName, effIPs); err != nil { // 不写 landed_sha:每小时的落地对账会发现落地态对不上并重试 zlog.Error(fmt.Sprintf("启动重放系统层失败 channel=%s error=%s", ch.Code, err.Error())) } else { restored++ - _, sha := r.loadSnapshot(ch.Code) - r.markLanded(ch.Id, sha, len(ips), "") // 启动重放不改"上次状态",它反映的是上次同步结果 + r.markLanded(ch.Id, effSha, len(effIPs), "") // 启动重放不改"上次状态",它反映的是上次同步结果 } r.unlockSync() } @@ -771,29 +901,42 @@ func (r *WafThreatIPService) ReconcileLanding() { if ch.LandTarget != model.ThreatLandSystem && ch.LandTarget != model.ThreatLandBoth { continue } - ips, sha := r.loadSnapshot(ch.Code) + ips, _ := r.loadSnapshot(ch.Code) if len(ips) == 0 { continue // 还没同步过,没什么可对 } + // 期望值必须是**有效集**,与落地时用的完全同源。 + // 若这里拿快照原文去比对,而落地的是过滤后的 N-k 条,就会每小时判定不一致、 + // 每小时全量重建、而且永远修不好——期望值和实际值天生对不上。 + effIPs, effSha, excluded := WafThreatIPExcludeServiceApp.EffectiveIPs(ips) + _, snapSha := r.snapshotMeta(ch.Code) + rememberEffMeta(ch.Code, snapSha, WafThreatIPExcludeServiceApp.Get().Sha(), effSha, excluded) + if len(effIPs) == 0 { + continue + } checked++ - // 先看系统里的实际状态。Windows 走短 TTL 缓存,一轮对账只枚举一次全部规则。 - if r.fw.IPSetUpToDate(setNameForChannel(ch.Code), ips) { - if ch.LandedSha != sha { - // 实际是一致的,只是落地态没记上(从老版本升级上来 landed_sha 为空)。 - // 只补记,不动"上次状态"——什么都没发生,不该把用户上次看到的结果冲掉。 - r.markLanded(ch.Id, sha, len(ips), "") + // 只问一次防火墙。IPSetUpToDate 在 Windows 上要枚举整张防火墙规则表, + // 规则多时单次就是秒级——按渠道各问两遍会把整轮对账拖成分钟级, + // 期间 CPU/磁盘被 netsh 占满,管理端接口跟着一起变慢。 + upToDate := r.fw.IPSetUpToDate(setNameForChannel(ch.Code), effIPs) + if upToDate { + if ch.LandedSha != effSha { + // 系统里其实是对的,只是落地态没记上:只补记,不动"上次状态"—— + // 什么都没发生,不该把用户上次看到的结果冲掉。 + r.markLanded(ch.Id, effSha, len(effIPs), "") } continue } zlog.Warn("威胁情报落地对账发现不一致,开始覆盖式重建", "channel", ch.Code, - "count", len(ips), "landed_sha", shortSha(ch.LandedSha), "content_sha", shortSha(sha)) + "effective", len(effIPs), "excluded", excluded, + "landed_sha", shortSha(ch.LandedSha), "eff_sha", shortSha(effSha)) if !r.tryLockSync(lockWaitStartupPerChannel, fmt.Sprintf("渠道[%s]落地对账", ch.Code)) { zlog.Warn("落地对账跳过该渠道(未获取同步锁)", "channel", ch.Code, "detail", r.busyHint()) continue } - err := r.fw.RestoreIPSet(setNameForChannel(ch.Code), ips) + err := r.fw.RestoreIPSet(setNameForChannel(ch.Code), effIPs) r.unlockSync() if err != nil { @@ -805,8 +948,8 @@ func (r *WafThreatIPService) ReconcileLanding() { } repaired++ // 必须覆盖"上次状态":上一条多半是"系统层落地失败…",修好了还挂着就成了假报错 - r.markLanded(ch.Id, sha, len(ips), fmt.Sprintf("ok(落地对账已修复,%d条)", len(ips))) - zlog.Info("威胁情报落地对账重建完成", "channel", ch.Code, "count", len(ips)) + r.markLanded(ch.Id, effSha, len(effIPs), fmt.Sprintf("ok(落地对账已修复,%d条)%s", len(effIPs), excludedNote(excluded))) + zlog.Info("威胁情报落地对账重建完成", "channel", ch.Code, "count", len(effIPs)) } if checked > 0 { @@ -871,15 +1014,17 @@ func (r *WafThreatIPService) saveSnapshot(code string, payload []byte, sha strin return global.GWAF_LOCAL_DB.Create(snap).Error } -// markSyncOK 同步成功后回写:刷新同步时间、收录条数、状态,以及**已确认落地**的快照 sha。 -// landedSha 是这次确认落到位的内容 sha —— 它与内容 sha 一致,才允许下次以"无变化"跳过落地。 -func (r *WafThreatIPService) markSyncOK(id, status string, count int, landedSha string) { +// markSyncOK 同步成功后回写:刷新同步时间、收录条数、状态,以及**已确认落地**的有效集 sha。 +// +// count 是**内容集**条数(源收录了多少),landedCount 是**有效集**条数(实际落地了多少)。 +// 两者在有误报排除时会不一样,必须分开记:前者反映订阅源,后者反映防火墙里真实的样子。 +func (r *WafThreatIPService) markSyncOK(id, status string, count int, landedSha string, landedCount int) { r.updateSyncFields(id, map[string]interface{}{ "LastSyncAt": time.Now().Unix(), "LastCount": count, "LastStatus": truncateStatus(status), "LandedSha": landedSha, - "LandedCount": count, + "LandedCount": landedCount, }) } diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index 13e31d5..00c64dc 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -1808,6 +1808,26 @@ func RunCoreDBMigrations(db *gorm.DB) error { return nil }, }, + // 迁移: 创建威胁情报误报排除名单表 + // + // 订阅源是全量快照且每周期整份覆盖,用户手工从防火墙删掉的条目下次同步就回来; + // 系统层又是内核丢包,WAF 白名单救不了。所以误报需要一份"每次同步/对账都重新应用" + // 的本地排除声明,见 SamWafTechDoc/威胁IP库同步/SamWaf-威胁情报IP误报排除-设计文档.md + { + ID: "202608110001_add_threat_ip_exclude_table", + Migrate: func(tx *gorm.DB) error { + zlog.Info("迁移 202608110001: 创建威胁情报误报排除名单表") + if err := tx.AutoMigrate(&model.ThreatIPExclude{}); err != nil { + return fmt.Errorf("创建威胁情报排除名单表失败: %w", err) + } + zlog.Info("威胁情报误报排除名单表创建成功") + return nil + }, + Rollback: func(tx *gorm.DB) error { + zlog.Info("回滚 202608110001: 删除威胁情报误报排除名单表") + return tx.Migrator().DropTable(&model.ThreatIPExclude{}) + }, + }, }) // 执行迁移 diff --git a/wafdb/migrations_log.go b/wafdb/migrations_log.go index b0cf4d6..4fb8904 100644 --- a/wafdb/migrations_log.go +++ b/wafdb/migrations_log.go @@ -349,6 +349,29 @@ func RunLogDBMigrations(db *gorm.DB) error { return tx.Migrator().DropTable(&model.HostLoginEvent{}) }, }, + // 迁移: 创建威胁情报排除名单审计表 + // 排除是主动降低防护的操作,删除排除条目后原记录就没了, + // 所以"曾经排除过什么、谁排的"必须另留一份只增不改的流水。 + { + ID: "202608110002_add_threat_ip_exclude_audit_table", + Migrate: func(tx *gorm.DB) error { + zlog.Info("迁移 202608110002: 创建威胁情报排除名单审计表") + if err := tx.AutoMigrate(&model.ThreatIPExcludeAudit{}); err != nil { + return fmt.Errorf("创建威胁情报排除审计表失败: %w", err) + } + // (create_time):按时间倒序分页 + 保留期清理 + if err := safeCreateIndex(tx, "threat_ip_exclude_audit", "idx_tiea_create_time", + "CREATE INDEX IF NOT EXISTS idx_tiea_create_time ON threat_ip_exclude_audit(create_time)"); err != nil { + zlog.Warn("创建索引 idx_tiea_create_time 失败", "error", err.Error()) + } + zlog.Info("威胁情报排除名单审计表创建成功") + return nil + }, + Rollback: func(tx *gorm.DB) error { + zlog.Info("回滚 202608110002: 删除威胁情报排除名单审计表") + return tx.Migrator().DropTable(&model.ThreatIPExcludeAudit{}) + }, + }, }) // 执行迁移 diff --git a/wafhostguard/whitelist.go b/wafhostguard/whitelist.go index c26bb16..0753962 100644 --- a/wafhostguard/whitelist.go +++ b/wafhostguard/whitelist.go @@ -122,6 +122,56 @@ func IsWhitelisted(ip string) (bool, string) { return false, "" } +// AutoExcludeItem 一条可枚举的"自己人"地址/网段。 +// +// IsWhitelisted 只回答"这个 IP 是不是自己人",而威胁情报误报排除需要反过来 +// **枚举**出所有自己人,才能在把情报写进系统防火墙之前先把它们剔掉。 +type AutoExcludeItem struct { + Entry string // IP 或 CIDR + Reason string // WhiteReasonXxx + Volatile bool // 是否易变源:易变源命中后需要固化落库,否则 sha 会随 TTL 反复抖动 +} + +// AutoExcludeSources 枚举全部"自己人"地址与网段,供威胁情报误报排除使用。 +// +// 与 IsWhitelisted 共用同一批数据源,保证两个功能对"谁是自己人"的判断永远一致。 +// 唯一的易变源是活跃管理会话 IP(30 分钟 TTL),调用方需要对它做"命中即固化"。 +func AutoExcludeSources() []AutoExcludeItem { + out := make([]AutoExcludeItem, 0, 24) + add := func(items []string, reason string, volatile bool) { + for _, it := range items { + if it = strings.TrimSpace(it); it != "" { + out = append(out, AutoExcludeItem{Entry: it, Reason: reason, Volatile: volatile}) + } + } + } + add(loopbackCIDRs, WhiteReasonLoopback, false) + add(LocalAddrs(), WhiteReasonLocal, false) + if global.GCONFIG_HOST_GUARD_AUTO_LAN == 1 { + add(lanCIDRs, WhiteReasonLAN, false) + } + add(splitList(global.GCONFIG_HOST_GUARD_WHITELIST), WhiteReasonConfig, false) + // 管理端白名单默认是 0.0.0.0/0,原样拿来会把整个威胁情报功能架空,必须先剔全网段 + add(dropCatchAll(splitList(global.GWAF_IP_WHITELIST)), WhiteReasonManage, false) + add(ActiveAdminIPs(), WhiteReasonAdminIP, true) + return out +} + +// ActiveAdminIPs 列出 30 分钟内使用过管理端的客户端 IP。 +func ActiveAdminIPs() []string { + if global.GCACHE_WAFCACHE == nil { + return nil + } + keys := global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(enums.CACHE_HOST_GUARD_ADMIN_PRE) + out := make([]string, 0, len(keys)) + for k := range keys { + if ip := strings.TrimPrefix(k, enums.CACHE_HOST_GUARD_ADMIN_PRE); ip != "" && net.ParseIP(ip) != nil { + out = append(out, ip) + } + } + return out +} + // adminIPTTL 活跃管理会话 IP 的记忆时长 const adminIPTTL = 30 * time.Minute diff --git a/waftask/task_config.go b/waftask/task_config.go index 1dd9572..59db035 100644 --- a/waftask/task_config.go +++ b/waftask/task_config.go @@ -205,6 +205,12 @@ func setConfigIntValue(name string, value int64, change int) { case "host_guard_auto_lan": global.GCONFIG_HOST_GUARD_AUTO_LAN = value wafhostguard.InvalidateWhitelist() + // 内网段豁免同时是威胁情报误报排除集的来源,开关一变要重新落地。 + // 只在真的变了时做:启动加载(change=0)时排除集本来就会按需构建, + // 这里再触发一次对账纯属浪费,还可能赶在防火墙引擎就绪之前。 + if change == 1 { + waf_service.WafThreatIPExcludeServiceApp.NotifySourceChanged() + } break case "host_guard_debounce_sec": global.GCONFIG_HOST_GUARD_DEBOUNCE_SEC = value @@ -380,6 +386,10 @@ func setConfigStringValue(name string, value string, change int) { global.GCONFIG_HOST_GUARD_WHITELIST = value // 白名单是防误封的主力,改完必须立刻重建,不能等下一个刷新周期 wafhostguard.InvalidateWhitelist() + // 同一份白名单也用来把"自己人"从威胁情报里剔出去,改完要重新落地 + if change == 1 { + waf_service.WafThreatIPExcludeServiceApp.NotifySourceChanged() + } break case "host_guard_log_paths": global.GCONFIG_HOST_GUARD_LOG_PATHS = value diff --git a/waftask/threatip/codec.go b/waftask/threatip/codec.go index 23c98b0..5a15a52 100644 --- a/waftask/threatip/codec.go +++ b/waftask/threatip/codec.go @@ -15,9 +15,7 @@ import ( func EncodeSnapshot(ips []string) (payload []byte, sha string, count int, err error) { uniq := sortedUnique(ips) text := strings.Join(uniq, "\n") - - sum := sha256.Sum256([]byte(text)) - sha = hex.EncodeToString(sum[:]) + sha = shaOfText(text) var buf bytes.Buffer zw := gzip.NewWriter(&buf) @@ -51,6 +49,20 @@ func DecodeSnapshot(payload []byte) ([]string, error) { return strings.Split(text, "\n"), nil } +// ShaOf 计算一份 IP/CIDR 列表的内容 sha256,算法与 EncodeSnapshot 完全一致 +// (排序去重 → \n 连接 → sha256),因此两者产出的 sha 可以直接互相比较。 +// +// 误报排除功能靠这一点成立:「有效集 sha」(内容集剔除排除项后)与快照的「内容 sha」 +// 同算法,排除名单为空时二者恒等,存量 landed_sha 升级后仍然有效、不会触发全量重建。 +func ShaOf(ips []string) string { + return shaOfText(strings.Join(sortedUnique(ips), "\n")) +} + +func shaOfText(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) +} + // sortedUnique 排序去重 func sortedUnique(ips []string) []string { seen := make(map[string]struct{}, len(ips))