From 87c50bd54be57ccf64b6e7877fa81d27289d71dc Mon Sep 17 00:00:00 2001 From: samwaf Date: Mon, 3 Aug 2026 13:34:59 +0800 Subject: [PATCH] fix:rule bug #907 --- api/waf_gpt.go | 4 +- model/rules.go | 4 + service/waf_service/waf_rule.go | 12 ++ utils/rule_action.go | 20 +++ utils/rule_action_test.go | 69 ++++++++ wafenginecore/checkrule.go | 170 ++++++++++-------- wafenginecore/checkrule_scope_test.go | 238 ++++++++++++++++++++++++++ wafenginecore/static_server.go | 21 +-- wafenginecore/tamper.go | 8 +- wafenginecore/wafengine.go | 71 +------- wafenginecore/weblog_record.go | 46 +++++ 11 files changed, 493 insertions(+), 170 deletions(-) create mode 100644 wafenginecore/checkrule_scope_test.go create mode 100644 wafenginecore/weblog_record.go diff --git a/api/waf_gpt.go b/api/waf_gpt.go index fd896a8..95d54a7 100644 --- a/api/waf_gpt.go +++ b/api/waf_gpt.go @@ -44,7 +44,7 @@ rule R<唯一标识> "规则中文描述" salience <优先级数字> { } 说明: - R<唯一标识>:规则名,以大写 R 开头,只能由字母和数字组成(不含横线)。 -- salience:优先级,数值越大越先命中。默认写 10;放行类建议写 100 使其优先。 +- salience:优先级,数值越大越优先。站点规则和全局规则一起按 salience 仲裁,同优先级时按 拦截 > 放行 > 仅记录 取。默认写 10;放行类要压过全局拦截规则时写 100。 - when:条件,为 true 时命中。then:命中后的动作,必须写且只能写一个。 # 可用请求字段(MF 开头,代表当前请求) @@ -103,7 +103,7 @@ rule R "rule description" salience { } Notes: - R: rule name, starts with uppercase R, letters and digits only (no dash). -- salience: priority, higher wins first. Use 10 by default; use 100 for allow rules so they take precedence. +- salience: priority, higher wins. Site rules and global rules are arbitrated together by salience; on a tie the order is deny > allow > log. Use 10 by default; use 100 for allow rules that must override a global deny rule. - when: condition; matches when true. then: exactly one action. # Request fields (MF = current request) diff --git a/model/rules.go b/model/rules.go index f33d873..f9e3a19 100644 --- a/model/rules.go +++ b/model/rules.go @@ -16,4 +16,8 @@ type Rules struct { IsPublicRule int `json:"is_public_rule"` //是否为公共规则 IsManualRule int `json:"is_manual_rule"` //是否为手工写规则 1:手工编写 0 :UI界面形式 RuleStatus int `json:"rule_status"` //规则是否开启 1,开启 0,关闭不生效 999 删除 + + //Salience 规则优先级,不落库:从 RuleContent 里解析出来供列表展示 + //优先级本身写在规则文本(GRL)里,手工模式和界面模式都有,所以统一从文本解析,无需加数据库列 + Salience int `gorm:"-" json:"salience"` } diff --git a/service/waf_service/waf_rule.go b/service/waf_service/waf_rule.go index 7467750..7054ee4 100644 --- a/service/waf_service/waf_rule.go +++ b/service/waf_service/waf_rule.go @@ -8,6 +8,7 @@ import ( "SamWaf/model" "SamWaf/model/baseorm" "SamWaf/model/request" + "SamWaf/utils" "errors" "time" ) @@ -147,9 +148,19 @@ func (receiver *WafRuleService) GetListApi(wafRuleSearchReq request.WafRuleSearc global.GWAF_LOCAL_DB.Model(&model.Rules{}).Where(whereField, whereValues...).Limit(wafRuleSearchReq.PageSize).Offset(wafRuleSearchReq.PageSize * (wafRuleSearchReq.PageIndex - 1)).Find(&rules) global.GWAF_LOCAL_DB.Model(&model.Rules{}).Where(whereField, whereValues...).Count(&total) + fillRuleSalience(rules) return rules, total, nil } +// fillRuleSalience 给列表数据补上优先级 +// 优先级只存在于规则文本(GRL)里,没有数据库列;手工模式和界面模式的规则文本都带 salience, +// 所以统一从文本解析,避免为了展示一个字段去加列 + 写迁移 + 回填存量数据 +func fillRuleSalience(rules []model.Rules) { + for i := range rules { + rules[i].Salience = utils.ExtractRuleSalience(rules[i].RuleContent) + } +} + func (receiver *WafRuleService) GetListByHostCodeApi(wafRuleSearchReq request.WafRuleSearchReq) ([]model.Rules, int64, error) { var total int64 = 0 var rules []model.Rules @@ -159,6 +170,7 @@ func (receiver *WafRuleService) GetListByHostCodeApi(wafRuleSearchReq request.Wa global.GWAF_LOCAL_DB.Where("host_code = ? and rule_status <> 999", wafRuleSearchReq.HostCode).Model(&model.Rules{}).Count(&total) + fillRuleSalience(rules) return rules, total, nil } diff --git a/utils/rule_action.go b/utils/rule_action.go index 71ff314..6e9ffc7 100644 --- a/utils/rule_action.go +++ b/utils/rule_action.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" ) @@ -21,6 +22,8 @@ const ( // ruleSkipModules 允许被跳过的检测模块白名单 // 注意:跳过只对"排在自定义规则之后"的检测环节生效,具体取决于规则编排模式 +// Allow 和 AllowAll 都同样参与站点/全局的跨作用域仲裁(按 salience 比较), +// 两者的区别只在于放行之后跳过多大范围的后续检测 var ruleSkipModules = []string{ "BOT", // 爬虫检测 "SQLI", // SQL注入 @@ -77,6 +80,8 @@ var ( ruleThenRegex = regexp.MustCompile(`\bthen\b`) // 动作标记:RF.Allow(...) / RF.AllowAll() / RF.Deny() / RF.Log() ruleActionRegex = regexp.MustCompile(`RF\s*\.\s*(AllowAll|Allow|Deny|Log)\s*\(([^)]*)\)`) + // 优先级:rule Rxxx "描述" salience 10 { + ruleSalienceRegex = regexp.MustCompile(`\bsalience\s+(\d+)`) ) // BuildGrlSkeleton 生成规则文本的"骨架":把字符串字面量和注释里的内容全部替换成空格, @@ -363,6 +368,21 @@ func CountRuleBlocks(ruleText string) int { return len(splitRuleBlocks(BuildGrlSkeleton(ruleText))) } +// ExtractRuleSalience 提取规则文本里的优先级(salience),用于列表展示 +// 在骨架上匹配,避免把 MF.URL.Contains("salience 999") 这类字符串字面量里的内容当成真优先级。 +// 一段文本含多条规则时取第一条;解析不出来返回 0。 +func ExtractRuleSalience(ruleText string) int { + m := ruleSalienceRegex.FindStringSubmatch(BuildGrlSkeleton(ruleText)) + if len(m) < 2 { + return 0 + } + v, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } + return v +} + // ExtractRuleNamesFromText 提取规则文本中所有规则名(含 R 前缀) func ExtractRuleNamesFromText(ruleText string) []string { blocks := splitRuleBlocks(BuildGrlSkeleton(ruleText)) diff --git a/utils/rule_action_test.go b/utils/rule_action_test.go index 14bfe25..afa82a8 100644 --- a/utils/rule_action_test.go +++ b/utils/rule_action_test.go @@ -259,6 +259,75 @@ func TestBuildGrlSkeleton_KeepsLength(t *testing.T) { } } +// ---------- 优先级解析 ---------- + +func TestExtractRuleSalience(t *testing.T) { + cases := []struct { + name string + ruleText string + want int + }{ + { + name: "正常解析", + ruleText: `rule Rabc123 "拦截" salience 90 { + when MF.URL == "/admin" + then RF.Deny(); +}`, + want: 90, + }, + { + name: "salience为0", + ruleText: `rule Rabc123 "拦截" salience 0 { + when MF.URL == "/admin" + then RF.Deny(); +}`, + want: 0, + }, + { + name: "字符串字面量里的假优先级不该被解析", + ruleText: `rule Rabc123 "拦截" salience 10 { + when MF.URL.Contains("salience 999") == true + then RF.Deny(); +}`, + want: 10, + }, + { + name: "注释里的假优先级不该被解析", + ruleText: `// salience 999 +rule Rabc123 "拦截" salience 10 { + when MF.URL == "/admin" + then RF.Deny(); +}`, + want: 10, + }, + { + name: "解析不出来返回0", + ruleText: `这不是规则`, + want: 0, + }, + { + name: "多条规则取第一条", + ruleText: `rule Rone "放行" salience 100 { + when MF.SRC_IP == "1.1.1.1" + then RF.Allow(); +} +rule Rtwo "拦截" salience 10 { + when MF.URL == "/admin" + then RF.Deny(); +}`, + want: 100, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ExtractRuleSalience(tc.ruleText); got != tc.want { + t.Errorf("期望 %d 实际 %d", tc.want, got) + } + }) + } +} + // ---------- 多条规则 ---------- func TestExtractRuleActions_MultipleRules(t *testing.T) { diff --git a/wafenginecore/checkrule.go b/wafenginecore/checkrule.go index 0c6a354..6cb43ec 100644 --- a/wafenginecore/checkrule.go +++ b/wafenginecore/checkrule.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "regexp" + "sort" "strconv" "github.com/hyperjumptech/grule-rule-engine/ast" @@ -83,6 +84,8 @@ type ruleMatchResult struct { Matched bool Action utils.RuleActionInfo Title string + // TopSalience 本侧命中规则里的最高优先级,用于和另一侧仲裁 + TopSalience int } // matchRules 执行一组规则并解析出动作 @@ -114,9 +117,73 @@ func matchRules(ruleHelper *utils.RuleHelper, weblogbean *innerbean.WebLog, titl out.Matched = true out.Action = pickRuleAction(ruleHelper, ruleMatchs) out.Title = titlePrefix + rulestr + // grule 的 FetchMatchingRules 已按 salience 降序返回,第一条即最高优先级 + out.TopSalience = ruleMatchs[0].Salience return out } +// arbitrate 在站点规则和全局规则的命中结果之间裁决最终动作 +// +// salience 只在单个 KnowledgeBase 内排序,而站点和全局是两个独立的规则引擎, +// 所以跨作用域的优先级必须在这里显式仲裁,否则用户在界面上填的"优先级"跨作用域就是空的。 +// +// 1. 只有一侧命中 → 用那一侧 +// 2. 两侧都命中 → salience 高的一侧胜出 +// 3. salience 相同 → 按 拦截 > 放行 > 仅记录 取,保证偏安全,也和历史行为一致 +// (老配置站点和全局默认都是 salience 10,此时全局拦截依旧压过站点放行) +// +// 两侧同为放行且同优先级时,跳过模块取并集、Title 拼接。 +func arbitrate(local, globalR ruleMatchResult) ruleMatchResult { + if !local.Matched { + return globalR + } + if !globalR.Matched { + return local + } + + if local.TopSalience > globalR.TopSalience { + return local + } + if globalR.TopSalience > local.TopSalience { + return globalR + } + + // 同优先级:按动作强弱兜底 + rank := map[string]int{ + utils.RuleActionDeny: 3, + utils.RuleActionAllow: 2, + utils.RuleActionLog: 1, + } + if rank[local.Action.Action] > rank[globalR.Action.Action] { + return local + } + if rank[globalR.Action.Action] > rank[local.Action.Action] { + return globalR + } + + // 动作也相同:合并两侧信息 + merged := ruleMatchResult{ + Matched: true, + Action: utils.RuleActionInfo{Action: local.Action.Action}, + Title: local.Title + globalR.Title, + TopSalience: local.TopSalience, + } + if merged.Action.Action == utils.RuleActionAllow { + skipSet := make(map[string]bool) + for _, m := range local.Action.SkipModules { + skipSet[m] = true + } + for _, m := range globalR.Action.SkipModules { + skipSet[m] = true + } + for m := range skipSet { + merged.Action.SkipModules = append(merged.Action.SkipModules, m) + } + sort.Strings(merged.Action.SkipModules) + } + return merged +} + /* * 检测rule @@ -129,94 +196,45 @@ func (waf *WafEngine) CheckRule(r *http.Request, weblogbean *innerbean.WebLog, f Content: "", } - logTitle := "" - allowTitle := "" - skipSet := make(map[string]bool) - //规则判断 (局部) + localResult := ruleMatchResult{} if hostTarget.Rule != nil { - localResult := matchRules(hostTarget.Rule, weblogbean, "") - if localResult.Matched { - switch localResult.Action.Action { - case utils.RuleActionDeny: - weblogbean.RISK_LEVEL = 1 - result.IsBlock = true - result.Title = localResult.Title - result.Content = "您的访问被阻止触发规则" - return result - case utils.RuleActionAllow: - allowTitle = localResult.Title - for _, m := range localResult.Action.SkipModules { - skipSet[m] = true - } - //放行且跳过后续所有检测,全局规则也不用再看了 - if localResult.Action.SkipAll() { - result.JumpGuardResult = true - result.IsRuleAllow = true - result.SkipModules = []string{utils.RuleSkipAll} - result.Title = allowTitle - return result - } - case utils.RuleActionLog: - weblogbean.RISK_LEVEL = 1 - logTitle = localResult.Title - } - } + localResult = matchRules(hostTarget.Rule, weblogbean, "") } //规则判断 (全局网站) + //两侧都要跑完再仲裁:局部先命中就 return 的话,全局那条优先级更高的规则永远没机会生效 + globalResult := ruleMatchResult{} globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] if globalHost != nil && globalHost.Host.GUARD_STATUS == 1 && globalHost.Rule != nil { - globalResult := matchRules(globalHost.Rule, weblogbean, "【全局】") - if globalResult.Matched { - switch globalResult.Action.Action { - case utils.RuleActionDeny: - //全局拦截优先级最高,可以覆盖局部的放行/仅记录 - weblogbean.RISK_LEVEL = 1 - result.IsBlock = true - result.Title = globalResult.Title - result.Content = "您的访问被阻止触发规则" - return result - case utils.RuleActionAllow: - if allowTitle == "" { - allowTitle = globalResult.Title - } else { - allowTitle = allowTitle + globalResult.Title - } - for _, m := range globalResult.Action.SkipModules { - skipSet[m] = true - } - if globalResult.Action.SkipAll() { - result.JumpGuardResult = true - result.IsRuleAllow = true - result.SkipModules = []string{utils.RuleSkipAll} - result.Title = allowTitle - return result - } - case utils.RuleActionLog: - weblogbean.RISK_LEVEL = 1 - if logTitle == "" { - logTitle = globalResult.Title - } else { - logTitle = logTitle + globalResult.Title - } - } - } + globalResult = matchRules(globalHost.Rule, weblogbean, "【全局】") } - //放行优先于仅记录 - if allowTitle != "" { - result.IsRuleAllow = true - result.Title = allowTitle - for m := range skipSet { - result.SkipModules = append(result.SkipModules, m) - } + final := arbitrate(localResult, globalResult) + if !final.Matched { return result } - if logTitle != "" { + + switch final.Action.Action { + case utils.RuleActionDeny: + weblogbean.RISK_LEVEL = 1 + result.IsBlock = true + result.Title = final.Title + result.Content = "您的访问被阻止触发规则" + case utils.RuleActionAllow: + result.IsRuleAllow = true + result.Title = final.Title + if final.Action.SkipAll() { + //放行且跳过后续所有检测,直通后端 + result.JumpGuardResult = true + result.SkipModules = []string{utils.RuleSkipAll} + } else { + result.SkipModules = final.Action.SkipModules + } + case utils.RuleActionLog: + weblogbean.RISK_LEVEL = 1 result.IsLogOnly = true - result.Title = logTitle - return result + result.Title = final.Title } return result } diff --git a/wafenginecore/checkrule_scope_test.go b/wafenginecore/checkrule_scope_test.go new file mode 100644 index 0000000..68ee0bc --- /dev/null +++ b/wafenginecore/checkrule_scope_test.go @@ -0,0 +1,238 @@ +package wafenginecore + +import ( + "SamWaf/global" + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/model/wafenginmodel" + "testing" +) + +// newTestEngineWithGlobalRule 造一个只挂了"全局网站"的引擎,全局规则用 globalRule 文本 +// globalRule 传空串表示全局网站没有任何规则 +func newTestEngineWithGlobalRule(t *testing.T, globalRule string) *WafEngine { + t.Helper() + waf := &WafEngine{} + waf.InitRouting() + if globalRule == "" { + return waf + } + globalHost := &wafenginmodel.HostSafe{ + Host: model.Hosts{GUARD_STATUS: 1, GLOBAL_HOST: 1}, + Rule: buildRuleHelper(t, globalRule), + } + waf.withWriteTable(func(nt *routingTable) { + nt.HostTarget[global.GWAF_GLOBAL_HOST_NAME] = globalHost + }) + return waf +} + +// newTestSiteHost 造一个挂了站点规则的 HostSafe,siteRule 传空串表示站点没有规则 +func newTestSiteHost(t *testing.T, siteRule string) *wafenginmodel.HostSafe { + t.Helper() + h := &wafenginmodel.HostSafe{Host: model.Hosts{GUARD_STATUS: 1}} + if siteRule != "" { + h.Rule = buildRuleHelper(t, siteRule) + } + return h +} + +// TestCheckRule_CrossScopeArbitration 站点规则与全局规则按 salience 统一仲裁 +// +// 覆盖 issue #907:站点白名单 salience 90 必须能压过全局拦截 salience 10; +// 同时保证老配置(两侧都是默认 salience 10)行为不变——全局拦截依旧赢。 +func TestCheckRule_CrossScopeArbitration(t *testing.T) { + const testIP = "1.2.3.4" + + // 站点放行规则模板:命中测试 IP + siteAllow := func(salience string, action string) string { + return ` +rule Rsite001 "站点白名单" salience ` + salience + ` { + when MF.SRC_IP == "` + testIP + `" + then ` + action + ` +}` + } + // 全局拦截规则模板:命中测试 IP + globalRule := func(salience string, action string) string { + return ` +rule Rglobal001 "全局屏蔽" salience ` + salience + ` { + when MF.SRC_IP == "` + testIP + `" + then ` + action + ` +}` + } + + cases := []struct { + name string + siteRule string + globalRule string + wantBlock bool + wantAllow bool + wantLogOnly bool + wantJump bool + }{ + { + name: "#907场景_站点放行salience90压过全局拦截salience10", + siteRule: siteAllow("90", "RF.Allow();"), + globalRule: globalRule("10", "RF.Deny();"), + wantAllow: true, + }, + { + name: "同优先级_全局拦截仍旧赢_兼容老配置", + siteRule: siteAllow("10", "RF.Allow();"), + globalRule: globalRule("10", "RF.Deny();"), + wantBlock: true, + }, + { + name: "全局放行salience90压过站点拦截salience10", + siteRule: siteAllow("10", "RF.Deny();"), + globalRule: globalRule("90", "RF.Allow();"), + wantAllow: true, + }, + { + name: "站点拦截salience90压过全局放行salience10", + siteRule: siteAllow("90", "RF.Deny();"), + globalRule: globalRule("10", "RF.Allow();"), + wantBlock: true, + }, + { + name: "站点AllowAll压过全局拦截_并跳过后续全部检测", + siteRule: siteAllow("90", "RF.AllowAll();"), + globalRule: globalRule("10", "RF.Deny();"), + wantAllow: true, + wantJump: true, + }, + { + name: "站点仅记录salience90压过全局拦截_不拦截", + siteRule: siteAllow("90", "RF.Log();"), + globalRule: globalRule("10", "RF.Deny();"), + wantLogOnly: true, + }, + { + name: "站点无规则_全局拦截生效", + siteRule: "", + globalRule: globalRule("10", "RF.Deny();"), + wantBlock: true, + }, + { + name: "全局无规则_站点放行生效", + siteRule: siteAllow("10", "RF.Allow();"), + wantAllow: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + waf := newTestEngineWithGlobalRule(t, tc.globalRule) + hostTarget := newTestSiteHost(t, tc.siteRule) + weblog := &innerbean.WebLog{SRC_IP: testIP, URL: "/download"} + + result := waf.CheckRule(nil, weblog, nil, hostTarget, nil) + + if result.IsBlock != tc.wantBlock { + t.Errorf("IsBlock 期望 %v 实际 %v (Title=%s)", tc.wantBlock, result.IsBlock, result.Title) + } + if result.IsRuleAllow != tc.wantAllow { + t.Errorf("IsRuleAllow 期望 %v 实际 %v (Title=%s)", tc.wantAllow, result.IsRuleAllow, result.Title) + } + if result.IsLogOnly != tc.wantLogOnly { + t.Errorf("IsLogOnly 期望 %v 实际 %v (Title=%s)", tc.wantLogOnly, result.IsLogOnly, result.Title) + } + if result.JumpGuardResult != tc.wantJump { + t.Errorf("JumpGuardResult 期望 %v 实际 %v", tc.wantJump, result.JumpGuardResult) + } + }) + } +} + +// TestCheckRule_NoMatch 两侧都不命中时不拦不放 +func TestCheckRule_NoMatch(t *testing.T) { + waf := newTestEngineWithGlobalRule(t, ` +rule Rglobal001 "全局屏蔽" salience 10 { + when MF.SRC_IP == "9.9.9.9" + then RF.Deny(); +}`) + hostTarget := newTestSiteHost(t, ` +rule Rsite001 "站点白名单" salience 90 { + when MF.SRC_IP == "1.2.3.4" + then RF.Allow(); +}`) + + result := waf.CheckRule(nil, &innerbean.WebLog{SRC_IP: "8.8.8.8"}, nil, hostTarget, nil) + if result.IsBlock || result.IsRuleAllow || result.IsLogOnly { + t.Fatalf("两侧都不命中时应无任何动作, 实际 %+v", result) + } +} + +// TestCheckRule_AllowSkipModulesUnion 同优先级两侧都放行时,跳过模块取并集 +func TestCheckRule_AllowSkipModulesUnion(t *testing.T) { + const testIP = "1.2.3.4" + waf := newTestEngineWithGlobalRule(t, ` +rule Rglobal001 "全局放行" salience 50 { + when MF.SRC_IP == "`+testIP+`" + then RF.Allow("AI", "CC"); +}`) + hostTarget := newTestSiteHost(t, ` +rule Rsite001 "站点放行" salience 50 { + when MF.SRC_IP == "`+testIP+`" + then RF.Allow("CC", "XSS"); +}`) + + result := waf.CheckRule(nil, &innerbean.WebLog{SRC_IP: testIP}, nil, hostTarget, nil) + if !result.IsRuleAllow { + t.Fatalf("应放行, 实际 %+v", result) + } + got := map[string]bool{} + for _, m := range result.SkipModules { + got[m] = true + } + for _, want := range []string{"AI", "CC", "XSS"} { + if !got[want] { + t.Errorf("跳过模块应包含 %s, 实际 %v", want, result.SkipModules) + } + } + if len(result.SkipModules) != 3 { + t.Errorf("跳过模块应去重为3个, 实际 %v", result.SkipModules) + } +} + +// TestShouldRecordWebLog abnormal 模式下"命中了但没拦"的请求必须留痕 +func TestShouldRecordWebLog(t *testing.T) { + origin := global.GWAF_RUNTIME_RECORD_LOG_TYPE + defer func() { global.GWAF_RUNTIME_RECORD_LOG_TYPE = origin }() + + cases := []struct { + name string + logType string + weblog *innerbean.WebLog + excludeURL string + want bool + }{ + {"all模式_普通放行也记", "all", + &innerbean.WebLog{ACTION: "放行", URL: "/a"}, "", true}, + {"all模式_排除前缀命中不记", "all", + &innerbean.WebLog{ACTION: "放行", URL: "/static/x.js"}, "/static", false}, + {"abnormal模式_普通放行不记", "abnormal", + &innerbean.WebLog{ACTION: "放行", URL: "/a"}, "", false}, + {"abnormal模式_拦截要记", "abnormal", + &innerbean.WebLog{ACTION: "阻止", URL: "/a"}, "", true}, + {"abnormal模式_自定义规则放行要留痕", "abnormal", + &innerbean.WebLog{ACTION: "放行", URL: "/a", RULE: "自定义规则放行:站点白名单"}, "", true}, + {"abnormal模式_自定义规则仅记录要留痕", "abnormal", + &innerbean.WebLog{ACTION: "放行", URL: "/a", RULE: "自定义规则记录:观察"}, "", true}, + {"abnormal模式_站点仅记录模式要留痕", "abnormal", + &innerbean.WebLog{ACTION: "放行", URL: "/a", LogOnlyMode: 1}, "", true}, + {"abnormal模式_命中规则但URL被排除仍不记", "abnormal", + &innerbean.WebLog{ACTION: "放行", URL: "/static/x.js", RULE: "自定义规则放行:x"}, "/static", false}, + {"排除列表里的空行不该吞掉全部日志", "all", + &innerbean.WebLog{ACTION: "放行", URL: "/a"}, "\n \n", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + global.GWAF_RUNTIME_RECORD_LOG_TYPE = tc.logType + if got := shouldRecordWebLog(tc.weblog, tc.excludeURL); got != tc.want { + t.Errorf("期望 %v 实际 %v", tc.want, got) + } + }) + } +} diff --git a/wafenginecore/static_server.go b/wafenginecore/static_server.go index af3a7b2..6bf0526 100644 --- a/wafenginecore/static_server.go +++ b/wafenginecore/static_server.go @@ -613,25 +613,8 @@ func (waf *WafEngine) logStaticFileAccess(path, remoteAddr string, fileSize int6 weblog.RISK_LEVEL = 0 // 无风险 // 按照全局日志记录策略决定是否记录 - if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "all" { - if hostsafe.Host.EXCLUDE_URL_LOG == "" { - global.GQEQUE_LOG_DB.Enqueue(weblog) - } else { - lines := strings.Split(hostsafe.Host.EXCLUDE_URL_LOG, "\n") - isRecordLog := true - // 检查每一行 - for _, line := range lines { - if strings.HasPrefix(weblog.URL, line) { - isRecordLog = false - } - } - if isRecordLog { - global.GQEQUE_LOG_DB.Enqueue(weblog) - } - } - } else if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "abnormal" && weblog.ACTION != "放行" { - // 对于静态文件成功访问,ACTION是"放行",所以在abnormal模式下不会记录 - // 这里保持逻辑一致性,虽然实际上不会执行 + // abnormal 模式下静态文件成功访问(ACTION=放行)通常不记录,但若命中过自定义规则(RULE 非空)仍要留痕 + if shouldRecordWebLog(weblog, hostsafe.Host.EXCLUDE_URL_LOG) { global.GQEQUE_LOG_DB.Enqueue(weblog) } } diff --git a/wafenginecore/tamper.go b/wafenginecore/tamper.go index 654b805..3b411af 100644 --- a/wafenginecore/tamper.go +++ b/wafenginecore/tamper.go @@ -282,12 +282,8 @@ func (waf *WafEngine) enqueueTamperLog(weblog *innerbean.WebLog, excludeURLLog s weblog.TASK_FLAG = 1 // 防篡改替换属于异常事件,all / abnormal 两种模式都记录 - if excludeURLLog != "" { - for _, line := range strings.Split(excludeURLLog, "\n") { - if line != "" && strings.HasPrefix(weblog.URL, line) { - return - } - } + if isURLLogExcluded(weblog.URL, excludeURLLog) { + return } global.GQEQUE_LOG_DB.Enqueue(weblog) } diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 7cec127..2c29313 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -1170,23 +1170,7 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { weblogfrist.TASK_FLAG = 1 // 记录日志 - if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "all" { - if waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG == "" { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } else { - lines := strings.Split(waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG, "\n") - isRecordLog := true - // 检查每一行 - for _, line := range lines { - if strings.HasPrefix(weblogfrist.URL, line) { - isRecordLog = false - } - } - if isRecordLog { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } - } - } else if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "abnormal" && weblogfrist.ACTION != "放行" { + if shouldRecordWebLog(weblogfrist, waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG) { global.GQEQUE_LOG_DB.Enqueue(weblogfrist) } } @@ -1257,22 +1241,7 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { weblogfrist.BackendCheckCost = time.Now().UnixNano()/1e6 - backendCheckStart //响应数据处理时间 // 记录流式访问日志 - if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "all" { - if waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG == "" { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } else { - lines := strings.Split(waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG, "\n") - isRecordLog := true - for _, line := range lines { - if strings.HasPrefix(weblogfrist.URL, line) { - isRecordLog = false - } - } - if isRecordLog { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } - } - } else if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "abnormal" && weblogfrist.ACTION != "放行" { + if shouldRecordWebLog(weblogfrist, waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG) { global.GQEQUE_LOG_DB.Enqueue(weblogfrist) } @@ -1534,23 +1503,7 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { weblogfrist.BackendCheckCost = time.Now().UnixNano()/1e6 - backendCheckStart // 记录日志 - 根据配置决定是否记录 - if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "all" { - if waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG == "" { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } else { - lines := strings.Split(waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG, "\n") - isRecordLog := true - for _, line := range lines { - if strings.HasPrefix(weblogfrist.URL, line) { - isRecordLog = false - } - } - if isRecordLog { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } - } - } else if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "abnormal" && weblogfrist.ACTION != "放行" { - // 自定义错误页也属于"异常"情况,需要记录 + if shouldRecordWebLog(weblogfrist, waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG) { global.GQEQUE_LOG_DB.Enqueue(weblogfrist) } @@ -1566,23 +1519,7 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { weblogfrist.STATUS_CODE = resp.StatusCode weblogfrist.TASK_FLAG = 1 weblogfrist.BackendCheckCost = time.Now().UnixNano()/1e6 - backendCheckStart //响应数据处理时间 - if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "all" { - if waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG == "" { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } else { - lines := strings.Split(waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG, "\n") - isRecordLog := true - // 检查每一行 - for _, line := range lines { - if strings.HasPrefix(weblogfrist.URL, line) { - isRecordLog = false - } - } - if isRecordLog { - global.GQEQUE_LOG_DB.Enqueue(weblogfrist) - } - } - } else if global.GWAF_RUNTIME_RECORD_LOG_TYPE == "abnormal" && weblogfrist.ACTION != "放行" { + if shouldRecordWebLog(weblogfrist, waf.rt().HostTarget[host].Host.EXCLUDE_URL_LOG) { global.GQEQUE_LOG_DB.Enqueue(weblogfrist) } } diff --git a/wafenginecore/weblog_record.go b/wafenginecore/weblog_record.go new file mode 100644 index 0000000..8a01ae6 --- /dev/null +++ b/wafenginecore/weblog_record.go @@ -0,0 +1,46 @@ +package wafenginecore + +import ( + "SamWaf/global" + "SamWaf/innerbean" + "strings" +) + +// isURLLogExcluded 判断该 URL 是否命中站点配置的"排除记录"前缀列表 +// excludeURLLog 为换行分隔的前缀列表;空行直接忽略(否则 HasPrefix(url,"") 恒为 true, +// 配置里多敲一个回车就会把整站日志全部吞掉) +func isURLLogExcluded(url string, excludeURLLog string) bool { + if excludeURLLog == "" { + return false + } + for _, line := range strings.Split(excludeURLLog, "\n") { + line = strings.TrimSpace(line) + if line != "" && strings.HasPrefix(url, line) { + return true + } + } + return false +} + +// shouldRecordWebLog 判断一条走完正常流程的访问日志该不该落库 +// +// 记录类型 all:除排除前缀外全记。 +// 记录类型 abnormal:除了非放行的请求,"命中了规则但没拦"的请求也必须留痕 —— +// 自定义规则放行/仅记录、站点仅记录模式(LogOnlyMode)、AI 观察命中,这些请求最终 +// ACTION 都会被 modifyResponse 覆写成"放行",若只看 ACTION 就会被整条丢弃, +// 导致白名单被谁用了、仅记录模式抓到了什么完全无法审计。 +func shouldRecordWebLog(weblog *innerbean.WebLog, excludeURLLog string) bool { + if weblog == nil { + return false + } + if isURLLogExcluded(weblog.URL, excludeURLLog) { + return false + } + switch global.GWAF_RUNTIME_RECORD_LOG_TYPE { + case "all": + return true + case "abnormal": + return weblog.ACTION != "放行" || weblog.RULE != "" || weblog.LogOnlyMode == 1 + } + return false +}