feat:threat intel IP exclusion list for false positives

This commit is contained in:
samwaf
2026-08-11 14:52:20 +08:00
parent 1dff238889
commit 8bd938c3cb
21 changed files with 1659 additions and 77 deletions
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+169
View File
@@ -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)
}
@@ -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
}
+2
View File
@@ -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
}
+4 -1
View File
@@ -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 表名
+39
View File
@@ -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"
)
+41
View File
@@ -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"
)
+10
View File
@@ -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) // 内置排除规则(只读,不落库)
}
+39 -12
View File
@@ -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
+4
View File
@@ -61,6 +61,10 @@ var sensitiveTableSubstrings = []string{
"access_ticket", // 跨域票据:含回跳地址与会话关联
"access_config",
"access_audit", // 审计日志有专门的只读接口,不需要也不应该从裸查询通道导出
// 威胁情报误报排除:排除名单是"哪些地址被主动放行了"的清单,泄露等于告诉攻击者
// 从哪个地址来不会被情报拦;审计流水同理,且它是只增不改的合规证据,
// 有专门的只读接口,不该从裸查询通道导出。
"threat_ip_exclude",
}
// allowedSqlOps 结构化条件允许的运算符白名单。
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
})
}
+193 -48
View File
@@ -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,
})
}
+20
View File
@@ -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{})
},
},
})
// 执行迁移
+23
View File
@@ -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{})
},
},
})
// 执行迁移
+50
View File
@@ -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
+10
View File
@@ -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
+15 -3
View File
@@ -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))