mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-11-03 03:31:58 +08:00
+2
-2
@@ -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<uniqueId> "rule description" salience <priority> {
|
||||
}
|
||||
Notes:
|
||||
- R<uniqueId>: 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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+94
-76
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user