diff --git a/api/waf_gpt.go b/api/waf_gpt.go index 35aba43..fd896a8 100644 --- a/api/waf_gpt.go +++ b/api/waf_gpt.go @@ -3,15 +3,20 @@ package api import ( "SamWaf/global" "SamWaf/model" + "SamWaf/model/common/response" "SamWaf/model/request" "SamWaf/wafsec" + "SamWaf/waftask" "bufio" "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" + "regexp" "strings" + "time" "github.com/gin-gonic/gin" ) @@ -19,6 +24,275 @@ import ( type WafGPTApi struct { } +// ============ 规则生成提示词(唯一来源,中英) ============ +// 后端是提示词的唯一来源:内联 AI 生成用作 system prompt;前端"复制AI提示词"通过 +// /api/v1/wafhost/rule/aiprompt 接口取用(GetRuleAiPrompt)。改这里一处即可,前端不再各存一份。 +// +// 结构:知识主体(ruleAiKnowledgeXX) + 需求填空段(ruleAiRequireXX) +// - 内联生成系统提示词 = 知识主体(意图由 user 消息单独传入) +// - 复制给AI的完整提示词 = 知识主体 + 需求填空段 + +// ruleAiKnowledgeZH 规则知识主体(结构/字段/函数/动作/约束/示例) +const ruleAiKnowledgeZH = `你是 SamWaf(Go 编写的开源 WAF)自定义规则专家。请根据需求生成一条 SamWaf 规则脚本(GRL 语法)。只输出规则文本本身,不要解释、不要 markdown 代码块标记。 + +# 规则结构 +rule R<唯一标识> "规则中文描述" salience <优先级数字> { + when + <条件表达式> + then + <动作> +} +说明: +- R<唯一标识>:规则名,以大写 R 开头,只能由字母和数字组成(不含横线)。 +- salience:优先级,数值越大越先命中。默认写 10;放行类建议写 100 使其优先。 +- when:条件,为 true 时命中。then:命中后的动作,必须写且只能写一个。 + +# 可用请求字段(MF 开头,代表当前请求) +MF.HOST 请求域名 | MF.URL 请求地址 | MF.REFERER 来源页 | MF.USER_AGENT UA | MF.METHOD 请求方法 | MF.COOKIES Cookie | MF.BODY 请求体 | MF.PORT 端口(数值) | MF.SRC_IP 访客IP | MF.COUNTRY 国家(中文如"中国") | MF.PROVINCE 省 | MF.CITY 市 +字段方法: +- MF.<字符串字段>.Contains("子串") == true / HasPrefix("前缀") == true / HasSuffix("后缀") == true +- MF.GetHeaderValue("头名").Contains("值") == true 取任意请求头再判断 +- MF.GetIPFailureCount(分钟数) > 次数 该IP近N分钟失败次数 +- MF.IsSafeBot() == true 是否搜索引擎等安全爬虫 + +# 可用规则函数(RF 开头,返回布尔,用在 when) +RF.IPInRange(MF.SRC_IP,"起","止")==true | RF.IPInRanges(MF.SRC_IP,"起-止","CIDR",...)==true | RF.IPInCIDR(MF.SRC_IP,"192.168.1.0/24")==true | RF.IPEquals(MF.SRC_IP,"1.2.3.4")==true | RF.In(MF.METHOD,"GET","POST")==true | RF.InIgnoreCase(值,列表...)==true | RF.ContainsAny(MF.URL,"a","b")==true | RF.ContainsAnyIgnoreCase(MF.USER_AGENT,...)==true | RF.ContainsAll(MF.URL,"a","b")==true | RF.StartsWithAny(MF.URL,"/admin")==true | RF.EndsWithAny(MF.URL,".php")==true | RF.IntInRange(MF.PORT,8000,9000)==true | RF.IntIn(MF.PORT,80,443)==true | RF.Not(表达式)==true | RF.IsEmpty(值)==true | RF.IsNotEmpty(值)==true | RF.LengthBetween(MF.URL,0,512)==true +条件之间用 &&(且) 或 ||(或) 连接。 + +# 命中动作(then 里,四选一) +RF.Deny(); 拦截(默认) +RF.Log(); 仅记录不拦截(灰度观察) +RF.Allow(); 放行(后续检测照常) +RF.Allow("CC","AI"); 放行并跳过指定检测模块 +RF.AllowAll(); 放行并跳过后续所有检测 +可跳过模块:BOT SQLI XSS SCAN RCE DIR CC AI SENSITIVE OWASP ANTILEECH CSRF UPLOAD CAPTCHA(不区分大小写)。 +跳过 CC/SQLI 等前置检测需管理员在【系统配置 rule_chain_mode】设为"规则优先"。 + +# 硬性约束 +1. 只能生成一条规则(一个 rule 块);如需多条则分别输出,规则名不同。 +2. 字符串值含双引号或反斜杠要转义(\" 和 \\),不要出现换行。 +3. then 有且只有一个动作,不需要写 Retract。 +4. 国家/省/市用中文;HTTP 方法用大写;规则名只能字母数字。 + +# 输出示例 +rule Raiexample01 "拦截海外访问" salience 10 { + when + MF.COUNTRY != "中国" + then + RF.Deny(); +}` + +// ruleAiRequireZH "复制给AI"提示词末尾的需求填空段 +const ruleAiRequireZH = ` + +# 我的需求 +- 网站/业务:<填写,例如:只面向国内用户的博客站> +- 想做的防护:<填写,例如:只允许中国大陆访问,其余地区拦截> +- 命中后动作:<拦截 / 仅记录观察 / 放行,默认拦截> +- 优先级(可选):<不填默认 10>` + +// ruleAiKnowledgeEN 规则知识主体(英文) +const ruleAiKnowledgeEN = `You are an expert on SamWaf (an open-source WAF written in Go) custom rules. Based on the requirement, generate ONE SamWaf rule script (GRL syntax). Output ONLY the rule text, no explanation, no markdown code fences. + +# Rule structure +rule R "rule description" salience { + when + + then + +} +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. +- when: condition; matches when true. then: exactly one action. + +# Request fields (MF = current request) +MF.HOST host | MF.URL url | MF.REFERER referer | MF.USER_AGENT UA | MF.METHOD method | MF.COOKIES cookies | MF.BODY body | MF.PORT port(number) | MF.SRC_IP client IP | MF.COUNTRY country(Chinese, e.g. "中国") | MF.PROVINCE | MF.CITY +Field methods: +- MF..Contains("s") == true / HasPrefix("p") == true / HasSuffix("s") == true +- MF.GetHeaderValue("Name").Contains("v") == true read any request header +- MF.GetIPFailureCount(minutes) > n IP failure count in last N minutes +- MF.IsSafeBot() == true is a known safe bot (search engine) + +# Rule functions (RF, return bool, used in when) +RF.IPInRange(MF.SRC_IP,"start","end")==true | RF.IPInRanges(MF.SRC_IP,"start-end","CIDR",...)==true | RF.IPInCIDR(MF.SRC_IP,"192.168.1.0/24")==true | RF.IPEquals(MF.SRC_IP,"1.2.3.4")==true | RF.In(MF.METHOD,"GET","POST")==true | RF.InIgnoreCase(v,list...)==true | RF.ContainsAny(MF.URL,"a","b")==true | RF.ContainsAnyIgnoreCase(MF.USER_AGENT,...)==true | RF.ContainsAll(MF.URL,"a","b")==true | RF.StartsWithAny(MF.URL,"/admin")==true | RF.EndsWithAny(MF.URL,".php")==true | RF.IntInRange(MF.PORT,8000,9000)==true | RF.IntIn(MF.PORT,80,443)==true | RF.Not(expr)==true | RF.IsEmpty(v)==true | RF.IsNotEmpty(v)==true | RF.LengthBetween(MF.URL,0,512)==true +Join conditions with && (and) or || (or). + +# Actions (in then, pick one) +RF.Deny(); block (default) +RF.Log(); log only, no block (canary observation) +RF.Allow(); allow (later checks still run) +RF.Allow("CC","AI"); allow and skip given detection modules +RF.AllowAll(); allow and skip all later checks +Skippable modules: BOT SQLI XSS SCAN RCE DIR CC AI SENSITIVE OWASP ANTILEECH CSRF UPLOAD CAPTCHA (case-insensitive). +Skipping front checks like CC/SQLI requires the admin to set System Config rule_chain_mode to "Rule First". + +# Hard constraints +1. Generate exactly one rule (one rule block); if multiple are needed, output each separately with distinct names. +2. Escape double quotes and backslashes in string values (\" and \\); no newlines inside strings. +3. Exactly one action in then; no Retract needed. +4. Country/Province/City in Chinese; HTTP methods uppercase; rule name letters+digits only. + +# Output example +rule Raiexample01 "Block overseas access" salience 10 { + when + MF.COUNTRY != "中国" + then + RF.Deny(); +}` + +// ruleAiRequireEN "复制给AI"提示词末尾的需求填空段(英文) +const ruleAiRequireEN = ` + +# My requirements +- Site/business: +- Protection goal: +- Action on match: +- Priority (optional): ` + +// 内联 AI 生成的系统提示词 = 知识主体(意图作为 user 消息单独传入) +const ruleGenSystemPromptZH = ruleAiKnowledgeZH +const ruleGenSystemPromptEN = ruleAiKnowledgeEN + +// 完整"复制给AI"提示词 = 知识主体 + 需求填空段 +const ruleAiPromptZH = ruleAiKnowledgeZH + ruleAiRequireZH +const ruleAiPromptEN = ruleAiKnowledgeEN + ruleAiRequireEN + +// GetRuleAiPrompt 按语言返回"复制给AI"的完整提示词(供前端接口取用) +func GetRuleAiPrompt(lang string) string { + if strings.HasPrefix(strings.ToLower(lang), "en") { + return ruleAiPromptEN + } + return ruleAiPromptZH +} + +// callGPTOnce 非流式调用一次 GPT,返回完整回复内容 +func callGPTOnce(messages []model.GptMessage) (string, error) { + gptReq := model.GPTRequest{ + Messages: messages, + Model: global.GCONFIG_RECORD_GPT_MODEL, + MaxTokens: 2048, + ResponseFormat: model.GptResponseFormat{Type: "text"}, + Stream: false, + Temperature: 0.3, // 生成规则要稳定,降低随机性 + TopP: 1, + } + bodyBytes, _ := json.Marshal(gptReq) + + apiURL := global.GCONFIG_RECORD_GPT_URL + if strings.HasSuffix(apiURL, "/v1") { + apiURL += "/chat/completions" + } else { + apiURL += "/v1/chat/completions" + } + + httpReq, err := http.NewRequest("POST", apiURL, strings.NewReader(string(bodyBytes))) + if err != nil { + return "", err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+global.GCONFIG_RECORD_GPT_TOKEN) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(respBytes, &parsed); err != nil { + return "", fmt.Errorf("解析AI响应失败") + } + if parsed.Error != nil { + return "", errors.New(parsed.Error.Message) + } + if len(parsed.Choices) == 0 { + return "", errors.New("AI未返回内容") + } + return parsed.Choices[0].Message.Content, nil +} + +// cleanRuleText 清洗 AI 返回文本:去掉 markdown 围栏和前后杂质,从第一个 rule 关键字开始 +func cleanRuleText(s string) string { + s = strings.TrimSpace(s) + for _, fence := range []string{"```grl", "```GRL", "```go", "```json", "```"} { + s = strings.ReplaceAll(s, fence, "") + } + s = strings.TrimSpace(s) + if idx := strings.Index(s, "rule "); idx > 0 { + s = s[idx:] + } + return strings.TrimSpace(s) +} + +// ruleActionSemicolonRegex 匹配 then 里的动作调用(容忍空格),用于补分号 +// 只在调用括号后紧跟位置处理分号,不吞掉后面的换行,保持格式 +var ruleActionSemicolonRegex = regexp.MustCompile(`(RF\s*\.\s*(?:AllowAll|Allow|Deny|Log)\s*\([^)]*\))(;?)`) + +// normalizeGeneratedRule 归一化 AI 生成的规则文本 +// AI 常漏写动作末尾的分号,grule 会报 missing ';'。这里确定性地补上,不依赖模型自觉。 +func normalizeGeneratedRule(s string) string { + s = cleanRuleText(s) + // 给每个动作调用补上分号(已有分号的不会重复) + s = ruleActionSemicolonRegex.ReplaceAllString(s, "$1;") + return s +} + +// IsGPTConfigured GPT 是否已配置密钥 +func IsGPTConfigured() bool { + tok := strings.TrimSpace(global.GCONFIG_RECORD_GPT_TOKEN) + return tok != "" && tok != "SamWaf提示请输入密钥" +} + +// GetGptConfigApi 获取GPT参数(gpt_url/gpt_model/是否已配置密钥) +// 安全:token 是敏感凭证,接口只回传 has_token 布尔,绝不下发明文到浏览器(日志脱敏/防泄露原则)。 +func (w *WafGPTApi) GetGptConfigApi(c *gin.Context) { + response.OkWithDetailed(map[string]interface{}{ + "gpt_url": global.GCONFIG_RECORD_GPT_URL, + "gpt_model": global.GCONFIG_RECORD_GPT_MODEL, + "has_token": IsGPTConfigured(), + }, "获取成功", c) +} + +// SaveGptConfigApi 保存GPT参数并同步到 global +// gpt_url/gpt_model 直接覆盖;gpt_token 为空表示保留原密钥(配合 GET 不回传明文),非空才更新。 +func (w *WafGPTApi) SaveGptConfigApi(c *gin.Context) { + var req request.WafGptConfigSaveReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("参数解析失败", c) + return + } + + items := []request.WafSystemConfigEditByItemReq{ + {Item: "gpt_url", Value: strings.TrimSpace(req.GptUrl)}, + {Item: "gpt_model", Value: strings.TrimSpace(req.GptModel)}, + } + // 只有传了新密钥才更新,避免前端因不回传明文而把密钥清空 + if strings.TrimSpace(req.GptToken) != "" { + items = append(items, request.WafSystemConfigEditByItemReq{Item: "gpt_token", Value: strings.TrimSpace(req.GptToken)}) + } + for _, item := range items { + if err := wafSystemConfigService.ModifyByItemApi(item); err != nil { + response.FailWithMessage("保存失败: "+err.Error(), c) + return + } + } + // 重新加载配置到 global,热生效 + waftask.TaskLoadSetting(true) + response.OkWithMessage("保存成功", c) +} + // 新增用于解析流式响应的结构体 type StreamResponse struct { ID string `json:"id"` diff --git a/api/waf_gpt_normalize_test.go b/api/waf_gpt_normalize_test.go new file mode 100644 index 0000000..122d1ea --- /dev/null +++ b/api/waf_gpt_normalize_test.go @@ -0,0 +1,90 @@ +package api + +import "testing" + +func TestNormalizeGeneratedRule(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "动作漏分号自动补上", + in: `rule R001 "x" salience 10 { + when + MF.URL.HasPrefix("/admin") == true + then + RF.Deny() +}`, + want: `rule R001 "x" salience 10 { + when + MF.URL.HasPrefix("/admin") == true + then + RF.Deny(); +}`, + }, + { + name: "已有分号不重复", + in: `rule R001 "x" salience 10 { + when + MF.URL.HasPrefix("/admin") == true + then + RF.Deny(); +}`, + want: `rule R001 "x" salience 10 { + when + MF.URL.HasPrefix("/admin") == true + then + RF.Deny(); +}`, + }, + { + name: "去掉markdown围栏", + in: "```grl\nrule R001 \"x\" salience 10 {\n when\n MF.PORT == 80\n then\n RF.Log()\n}\n```", + want: `rule R001 "x" salience 10 { + when + MF.PORT == 80 + then + RF.Log(); +}`, + }, + { + name: "Allow带参数补分号", + in: `rule R001 "x" salience 100 { + when + RF.IPEquals(MF.SRC_IP, "1.2.3.4") == true + then + RF.Allow("CC", "AI") +}`, + want: `rule R001 "x" salience 100 { + when + RF.IPEquals(MF.SRC_IP, "1.2.3.4") == true + then + RF.Allow("CC", "AI"); +}`, + }, + { + name: "AllowAll补分号", + in: `rule R001 "x" salience 200 { + when + RF.IPInCIDR(MF.SRC_IP, "10.0.0.0/8") == true + then + RF.AllowAll() +}`, + want: `rule R001 "x" salience 200 { + when + RF.IPInCIDR(MF.SRC_IP, "10.0.0.0/8") == true + then + RF.AllowAll(); +}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeGeneratedRule(tc.in) + if got != tc.want { + t.Fatalf("normalizeGeneratedRule 不匹配\n--- got ---\n%s\n--- want ---\n%s", got, tc.want) + } + }) + } +} diff --git a/api/waf_rule.go b/api/waf_rule.go index 75e6b13..97082fe 100644 --- a/api/waf_rule.go +++ b/api/waf_rule.go @@ -23,6 +23,44 @@ import ( type WafRuleAPi struct { } +// 手工规则文本长度上限,防止超大规则拖垮编译 +const maxManualRuleContentLen = 64 * 1024 + +// checkManualRuleContent 手工模式规则的安全校验 +// 除了让 grule 真正编译一遍,还要保证"保存时看到的动作"和"运行时执行的动作"是同一个: +// - 一段规则内容里只能有一条规则(防止在一条规则后面再塞一条放行规则) +// - 规则名必须和规则码对得上(防止覆盖到别的规则) +// - 动作标记只能有一个,Allow 的参数必须是已知的检测模块 +func checkManualRuleContent(ruleHelper *utils.RuleHelper, ruleContent string, expectRuleCode string) error { + if len(ruleContent) > maxManualRuleContentLen { + return errors.New("规则内容过长") + } + //语法校验 + if err := ruleHelper.CheckRuleAvailable(ruleContent); err != nil { + return errors.New("规则校验失败") + } + //一条规则内容只允许定义一条规则 + names := utils.ExtractRuleNamesFromText(ruleContent) + if len(names) == 0 { + return errors.New("未找到规则定义") + } + if len(names) > 1 { + return errors.New("一条规则内容里只能定义一条规则") + } + //规则名必须与规则码一致 + if expectRuleCode != "" { + expectName := "R" + strings.Replace(expectRuleCode, "-", "", -1) + if !strings.EqualFold(names[0], expectName) { + return fmt.Errorf("规则标识与规则码不一致,应为 %s", expectName) + } + } + //动作校验 + if _, err := utils.ExtractRuleActionForCheck(ruleContent); err != nil { + return err + } + return nil +} + // AddApi 新增WAF规则 // @Summary 新增WAF规则 // @Description 新增一条WAF防护规则 @@ -77,13 +115,18 @@ func (w *WafRuleAPi) AddApi(c *gin.Context) { } ruleInfo.RuleBase.RuleName = strings.Replace(ruleCode, "-", "", -1) - var ruleContent = ruleTool.GenRuleInfo(ruleInfo, chsName) + var ruleContent string if req.IsManualRule == 1 { ruleContent = ruleInfo.RuleContent //检查规则是否合法 - err = ruleHelper.CheckRuleAvailable(ruleContent) + if err = checkManualRuleContent(ruleHelper, ruleContent, ruleCode); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + } else { + ruleContent, err = ruleTool.GenRuleInfo(ruleInfo, chsName) if err != nil { - response.FailWithMessage("规则校验失败", c) + response.FailWithMessage(err.Error(), c) return } } @@ -238,13 +281,18 @@ func (w *WafRuleAPi) ModifyRuleApi(c *gin.Context) { var ruleName = ruleInfo.RuleBase.RuleName //中文名 ruleInfo.RuleBase.RuleName = strings.Replace(rule.RuleCode, "-", "", -1) - var ruleContent = ruleTool.GenRuleInfo(ruleInfo, ruleName) + var ruleContent string if req.IsManualRule == 1 { ruleContent = ruleInfo.RuleContent //检查规则是否合法 - err = ruleHelper.CheckRuleAvailable(ruleContent) + if err = checkManualRuleContent(ruleHelper, ruleContent, rule.RuleCode); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + } else { + ruleContent, err = ruleTool.GenRuleInfo(ruleInfo, ruleName) if err != nil { - response.FailWithMessage("规则校验失败", c) + response.FailWithMessage(err.Error(), c) return } } @@ -361,14 +409,19 @@ func (w *WafRuleAPi) FormatRuleApi(c *gin.Context) { chsName := ruleInfo.RuleBase.RuleName ruleInfo.RuleBase.RuleName = strings.Replace(req.RuleCode, "-", "", -1) - var ruleContent = ruleTool.GenRuleInfo(ruleInfo, chsName) - // 手工模式走合法性校验 + var ruleContent string if req.IsManualRule == 1 { + // 手工模式走合法性校验 ruleContent = ruleInfo.RuleContent - err = ruleHelper.CheckRuleAvailable(ruleContent) + if err = checkManualRuleContent(ruleHelper, ruleContent, req.RuleCode); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + } else { + ruleContent, err = ruleTool.GenRuleInfo(ruleInfo, chsName) if err != nil { - response.FailWithMessage("规则校验失败", c) + response.FailWithMessage(err.Error(), c) return } } @@ -430,7 +483,11 @@ func (w *WafRuleAPi) TestRuleApi(c *gin.Context) { } chsName := ruleInfo.RuleBase.RuleName ruleInfo.RuleBase.RuleName = strings.Replace(req.RuleCode, "-", "", -1) - ruleContent = ruleTool.GenRuleInfo(ruleInfo, chsName) + ruleContent, err = ruleTool.GenRuleInfo(ruleInfo, chsName) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } } // 2. 初始化规则引擎并加载规则 @@ -495,15 +552,125 @@ func (w *WafRuleAPi) TestRuleApi(c *gin.Context) { // 7. 构建响应 var matchedRules []string + var matchedRuleDetails []gin.H for _, r := range ruleMatches { matchedRules = append(matchedRules, r.RuleDescription) + + //把每条命中规则真正生效的动作返回给前端。规则内容里可能混着用户/攻击者可控的文本, + //这里展示的是引擎实际认定的动作,保存前点一下测试就能看出规则有没有被写歪。 + action := ruleHelper.GetRuleAction(r.RuleName) + matchedRuleDetails = append(matchedRuleDetails, gin.H{ + "rule_name": r.RuleName, + "rule_description": r.RuleDescription, + "salience": r.Salience, + "action": action.Action, + "skip_modules": action.SkipModules, + }) + } + + //多条规则命中时,最终生效的动作 + effectiveAction := "" + var effectiveSkipModules []string + if len(ruleMatches) > 0 { + info := ruleHelper.GetRuleAction(ruleMatches[0].RuleName) + effectiveAction = info.Action + effectiveSkipModules = info.SkipModules } response.OkWithDetailed(gin.H{ - "is_match": len(ruleMatches) > 0, - "matched_rules": matchedRules, - "parsed_country": country, - "parsed_province": province, - "parsed_city": city, + "is_match": len(ruleMatches) > 0, + "matched_rules": matchedRules, + "matched_rule_details": matchedRuleDetails, + "effective_action": effectiveAction, + "effective_skip_modules": effectiveSkipModules, + "parsed_country": country, + "parsed_province": province, + "parsed_city": city, }, "测试完成", c) } + +// AiGenRuleApi 用后台配置的 AI 生成一条自定义规则 +// 生成后在服务端做校验(单条规则 + 语法编译 + 动作合法),不通过就把错误喂回模型重试, +// 返回时尽量保证规则可直接编译落地,且不会被注入成放行规则。 +// RuleAiPromptApi 返回"复制给AI"的提示词(唯一来源在后端 api/waf_gpt.go,前端直接取用) +func (w *WafRuleAPi) RuleAiPromptApi(c *gin.Context) { + response.OkWithDetailed(GetRuleAiPrompt(c.Query("lang")), "获取成功", c) +} + +func (w *WafRuleAPi) AiGenRuleApi(c *gin.Context) { + var req request.WafGptRuleReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("参数解析失败", c) + return + } + intent := strings.TrimSpace(req.Intent) + if intent == "" { + response.FailWithMessage("请填写你想要的防护需求", c) + return + } + if !IsGPTConfigured() { + response.FailWithMessage("尚未配置AI密钥,请到【系统配置】填写 gpt_url / gpt_token / gpt_model", c) + return + } + + sysPrompt := ruleGenSystemPromptZH + if strings.HasPrefix(strings.ToLower(req.Lang), "en") { + sysPrompt = ruleGenSystemPromptEN + } + messages := []model.GptMessage{ + {Role: "system", Content: sysPrompt}, + {Role: "user", Content: intent}, + } + + ruleHelper := &utils.RuleHelper{} + const maxAttempts = 3 + var ruleContent string + var lastErr string + valid := false + attempts := 0 + + for attempts < maxAttempts { + attempts++ + out, err := callGPTOnce(messages) + if err != nil { + response.FailWithMessage("AI 请求失败: "+err.Error(), c) + return + } + // 归一化(补动作分号等),再校验 + ruleContent = normalizeGeneratedRule(out) + + // 服务端校验闭环 + if n := utils.CountRuleBlocks(ruleContent); n != 1 { + lastErr = "必须且只能生成一条规则" + } else if err := ruleHelper.CheckRuleAvailable(ruleContent); err != nil { + lastErr = "规则语法有误: " + err.Error() + } else if _, err := utils.ExtractRuleActionForCheck(ruleContent); err != nil { + lastErr = err.Error() + } else { + valid = true + lastErr = "" + break + } + + // 修复重试:把上次输出和具体错误喂回模型,并强调常见坑 + messages = append(messages, + model.GptMessage{Role: "assistant", Content: out}, + model.GptMessage{Role: "user", Content: "上面的规则有问题:" + lastErr + "。请修正后只输出规则文本,不要解释。注意:then 里的动作必须以分号结尾(如 RF.Deny(); ),只能有一条规则。"}, + ) + } + + action := utils.RuleActionDeny + if valid { + if info, err := utils.ExtractRuleActionForCheck(ruleContent); err == nil && info.Action != "" { + action = info.Action + } + } + + response.OkWithDetailed(gin.H{ + "rule_content": ruleContent, // 生成的规则文本 + "valid": valid, // 是否通过服务端校验 + "action": action, // 生效动作 + "error": lastErr, // 未通过时的原因 + "attempts": attempts, // 实际尝试次数 + }, "生成完成", c) +} diff --git a/global/config.go b/global/config.go index f686aa6..c04cae3 100644 --- a/global/config.go +++ b/global/config.go @@ -35,6 +35,10 @@ var ( GCONFIG_AI_ENABLE int64 = 0 //AI智能检测总开关 1启用 0关闭(需先在AI模型管理上传模型包) GCONFIG_AI_MODE string = "observe" //AI检测工作模式: observe(仅记录/观察) / block(达到拦截阈值则拦截) + //自定义规则在检测链里的位置 0:默认(排在CC之后) 1:规则优先(排在黑名单之后、Bot/SQLI/XSS等之前) + //规则优先模式下,规则的放行动作(RF.Allow)才能跳过 Bot/SQLI/XSS/扫描/RCE/目录穿越/CC 这些检测 + GCONFIG_RULE_CHAIN_MODE int64 = 0 + GCONFIG_OWASP_BLOCK_THRESHOLD int64 = 7 //OWASP 入站 anomaly score 阈值(官方默认 5,我们宽松到 7) GCONFIG_RECORD_ENABLE_HTTP_80 int64 = 0 //启动80端口服务(为自动申请证书使用 HTTP文件验证类型,DNS验证不需要) GCONFIG_RECORD_SSLOrder_EXPIRE_DAY int64 = 30 // 提前多少天进行自动申请 diff --git a/innerbean/rule_func.go b/innerbean/rule_func.go index 61cee92..eac66d5 100644 --- a/innerbean/rule_func.go +++ b/innerbean/rule_func.go @@ -267,3 +267,30 @@ func (rf *RuleFunc) LengthBetween(value string, min, max int64) bool { length := int64(len(value)) return length >= min && length <= max } + +// ================== 规则动作标记函数 ================== +// +// 这几个方法本身不做任何事情(no-op),只是让规则的 then 块能声明"命中之后干什么"。 +// 引擎走的是 FetchMatchingRules(只求值 when,不执行 then),动作是在规则加载阶段 +// 由 utils.ExtractRuleActions 从规则文本里解析出来的,方法体永远不会被调用。 +// 之所以还要定义成真实方法,是因为 GRL 需要能编译通过。 +// +// 未声明任何动作的规则默认为拦截(Deny),保证老规则行为不变。 + +// Deny 命中后拦截请求(默认动作,可不写) +// 使用示例: then RF.Deny(); +func (rf *RuleFunc) Deny() {} + +// Log 命中后仅记录,不拦截,继续执行后续检测 +// 使用示例: then RF.Log(); +func (rf *RuleFunc) Log() {} + +// Allow 命中后放行(不被自定义规则拦截),可选跳过指定的后续检测模块 +// modules: 要跳过的检测模块名,如 "CC"、"AI"、"SQLI";传 "ALL" 表示跳过全部 +// 使用示例: then RF.Allow(); 仅本环节放行,后续检测照常 +// 使用示例: then RF.Allow("CC", "AI"); 放行并跳过CC和AI检测 +func (rf *RuleFunc) Allow(modules ...string) {} + +// AllowAll 命中后放行并跳过后续所有检测,直通后端(等价于 RF.Allow("ALL")) +// 使用示例: then RF.AllowAll(); +func (rf *RuleFunc) AllowAll() {} diff --git a/model/detection/checkresult.go b/model/detection/checkresult.go index 9f40f79..321a89e 100644 --- a/model/detection/checkresult.go +++ b/model/detection/checkresult.go @@ -21,4 +21,16 @@ type Result struct { 检测内容 */ Content string + /** + 仅记录:命中了但不拦截,记录日志后继续走后续检测 + */ + IsLogOnly bool + /** + 自定义规则放行:不拦截,可按 SkipModules 跳过后续指定检测 + */ + IsRuleAllow bool + /** + 放行时要跳过的检测模块(大写模块名,含 "ALL" 表示跳过全部) + */ + SkipModules []string } diff --git a/model/request/waf_gpt_send_req.go b/model/request/waf_gpt_send_req.go index 8586c3c..1801523 100644 --- a/model/request/waf_gpt_send_req.go +++ b/model/request/waf_gpt_send_req.go @@ -3,3 +3,18 @@ package request type WafGptSendReq struct { History [][]string `json:"history"` // 定义结构体 } + +// WafGptRuleReq AI 生成自定义规则请求 +type WafGptRuleReq struct { + Intent string `json:"intent"` // 用户用自然语言描述的防护需求 + Lang string `json:"lang"` // 界面语言 zh_CN / en_US,决定提示词语言 + HostCode string `json:"host_code"` // 可选,所属网站 +} + +// WafGptConfigSaveReq 保存GPT参数(gpt_url/gpt_model/gpt_token) +// GptToken 为空表示不修改(保留原密钥),非空才覆盖——配合 GET 接口不回传明文,避免密钥泄露 +type WafGptConfigSaveReq struct { + GptUrl string `json:"gpt_url"` + GptModel string `json:"gpt_model"` + GptToken string `json:"gpt_token"` +} diff --git a/model/rule_grl_safe.go b/model/rule_grl_safe.go new file mode 100644 index 0000000..1d63183 --- /dev/null +++ b/model/rule_grl_safe.go @@ -0,0 +1,164 @@ +package model + +import ( + "fmt" + "regexp" + "strings" +) + +// 规则文本是"模板 + 用户可控内容"拼出来的,而且内容不一定来自管理员本人: +// 从攻击日志一键新建规则时,拼进去的是攻击者构造的 URL / UA / Header 原文。 +// 引入规则动作(RF.Deny/RF.Allow)之后,一次注入就足以把一条拦截规则改写成放行规则, +// 所以这里对所有拼进 GRL 的内容做严格处理: +// - 值(字符串字面量):转义 +// - 结构位(字段名、运算符、关系符、类型、优先级):白名单,不合法直接拒绝 + +// EscapeGrlString 把字符串安全地转义成 GRL 双引号字面量的内容 +// 参照 grule 的词法定义 grulev3.g4: +// +// DQUOTA_STRING : '"' ( '\\'. | '""' | ~('"'|'\\') )* '"' +// +// 反斜杠和双引号需要转义;换行/回车等控制字符直接剔除(换行可以截断规则块,是逃逸的主要手段) +func EscapeGrlString(s string) string { + var sb strings.Builder + sb.Grow(len(s) + 8) + for _, r := range s { + switch r { + case '\\': + sb.WriteString(`\\`) + case '"': + sb.WriteString(`\"`) + case '\r', '\n', '\t': + // 结构性空白,直接丢弃 + default: + // 其余控制字符一律丢弃 + if r < 0x20 || r == 0x7f { + continue + } + sb.WriteRune(r) + } + } + return sb.String() +} + +// 允许在规则条件里使用的事实字段(与前端下拉保持一致,服务端独立校验,不信任前端传上来的值) +var ruleAttrSimpleWhiteList = map[string]bool{ + "HOST": true, + "URL": true, + "REFERER": true, + "USER_AGENT": true, + "METHOD": true, + "COOKIES": true, + "BODY": true, + "PORT": true, + "SRC_IP": true, + "COUNTRY": true, + "PROVINCE": true, + "CITY": true, + "IsSafeBot()": true, +} + +// 方法型字段:GetHeaderValue("xxx") / GetIPFailureCount(5) +var ( + ruleAttrHeaderRegex = regexp.MustCompile(`^GetHeaderValue\("[A-Za-z0-9_\-]{1,64}"\)$`) + ruleAttrFailCountRegex = regexp.MustCompile(`^GetIPFailureCount\(\d{1,5}\)$`) + ruleNumberRegex = regexp.MustCompile(`^-?\d+(\.\d+)?$`) + ruleNameRegex = regexp.MustCompile(`^[A-Za-z0-9]{1,64}$`) +) + +// 允许的判断运算符 +var ruleJudgeWhiteList = map[string]bool{ + "==": true, "!=": true, ">": true, "<": true, ">=": true, "<=": true, + "system.Contains": true, "system.HasPrefix": true, "system.HasSuffix": true, +} + +// 允许的条件关系符 +var ruleRelationSymbolWhiteList = map[string]bool{ + "&&": true, "||": true, +} + +// 允许的值类型 +var ruleAttrTypeWhiteList = map[string]bool{ + "string": true, "int": true, "float": true, "bool": true, +} + +// ValidateRuleFactName 校验事实对象名,界面编排的规则只允许用 MF(当前请求) +func ValidateRuleFactName(factName string) error { + if factName == "MF" { + return nil + } + return fmt.Errorf("不支持的事实对象: %s", factName) +} + +// ValidateRuleAttr 校验事实字段名 +func ValidateRuleAttr(attr string) error { + if ruleAttrSimpleWhiteList[attr] { + return nil + } + if ruleAttrHeaderRegex.MatchString(attr) || ruleAttrFailCountRegex.MatchString(attr) { + return nil + } + return fmt.Errorf("不支持的规则字段: %s", attr) +} + +// ValidateRuleJudge 校验判断运算符 +func ValidateRuleJudge(judge string) error { + if ruleJudgeWhiteList[judge] { + return nil + } + return fmt.Errorf("不支持的规则运算符: %s", judge) +} + +// ValidateRuleRelationSymbol 校验条件关系符(只有一个条件时可以为空) +func ValidateRuleRelationSymbol(symbol string) error { + if symbol == "" || ruleRelationSymbolWhiteList[symbol] { + return nil + } + return fmt.Errorf("不支持的条件关系符: %s", symbol) +} + +// ValidateRuleAttrType 校验值类型 +func ValidateRuleAttrType(attrType string) error { + if ruleAttrTypeWhiteList[attrType] { + return nil + } + return fmt.Errorf("不支持的值类型: %s", attrType) +} + +// ValidateRuleAttrVal 校验条件值 +// 字符串类型会被引号包裹+转义,怎么写都逃不出去; +// 但非字符串类型是裸拼进表达式的,必须限定为纯数字/布尔,否则 +// attr_type=int + attr_val=`1 then RF.AllowAll()` 就能直接逃逸出字符串上下文。 +func ValidateRuleAttrVal(attrType, attrVal string) error { + if attrType == "string" { + return nil + } + v := strings.TrimSpace(attrVal) + switch attrType { + case "int", "float": + if !ruleNumberRegex.MatchString(v) { + return fmt.Errorf("值类型为 %s 时,值必须是数字: %s", attrType, attrVal) + } + case "bool": + if v != "true" && v != "false" { + return fmt.Errorf("值类型为 bool 时,值只能是 true 或 false: %s", attrVal) + } + } + return nil +} + +// ValidateRuleBoolVal 校验函数结果判断值(只能是 true / false) +func ValidateRuleBoolVal(val string) error { + if val == "true" || val == "false" { + return nil + } + return fmt.Errorf("函数结果判断值只能是 true 或 false: %s", val) +} + +// ValidateRuleName 校验规则名(会被拼进 rule R${rule_name}) +func ValidateRuleName(name string) error { + if ruleNameRegex.MatchString(name) { + return nil + } + return fmt.Errorf("规则标识不合法,只能是字母和数字: %s", name) +} diff --git a/model/rule_grl_safe_test.go b/model/rule_grl_safe_test.go new file mode 100644 index 0000000..5bfbc73 --- /dev/null +++ b/model/rule_grl_safe_test.go @@ -0,0 +1,220 @@ +package model_test + +import ( + "SamWaf/model" + "SamWaf/utils" + "strings" + "testing" +) + +// buildRule 用界面编排的方式生成一条规则 +func buildRule(t *testing.T, action string, skips []string, remark string, conditions ...model.RelationDetail) (string, error) { + t.Helper() + ruleTool := model.RuleTool{} + ruleInfo := model.RuleInfo{ + RuleAction: action, + RuleActionSkips: skips, + RuleBase: model.RuleBase{ + Salience: 10, + RuleName: "abc123", + }, + RuleCondition: model.RuleCondition{ + RelationDetail: conditions, + RelationSymbol: "&&", + }, + } + return ruleTool.GenRuleInfo(ruleInfo, remark) +} + +func strCond(attr, judge, val string) model.RelationDetail { + return model.RelationDetail{ + FactName: "MF", + Attr: attr, + AttrType: "string", + AttrJudge: judge, + AttrVal: val, + AttrVal2: "true", + } +} + +// ---------- 动作语句生成 ---------- + +func TestGenRuleInfo_Action(t *testing.T) { + cases := []struct { + name string + action string + skips []string + wantStmt string + }{ + {"默认(老数据无动作)", "", nil, "RF.Deny();"}, + {"拦截", "deny", nil, "RF.Deny();"}, + {"仅记录", "log", nil, "RF.Log();"}, + {"放行", "allow", nil, "RF.Allow();"}, + {"放行并跳过指定模块", "allow", []string{"CC", "AI"}, `RF.Allow("CC", "AI");`}, + {"放行并跳过全部", "allow", []string{"ALL"}, "RF.AllowAll();"}, + {"非法模块名被丢弃", "allow", []string{"CC", "NOT_A_MODULE"}, `RF.Allow("CC");`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + content, err := buildRule(t, tc.action, tc.skips, "测试规则", strCond("URL", "system.Contains", "/admin")) + if err != nil { + t.Fatalf("生成规则失败: %v", err) + } + if !strings.Contains(content, tc.wantStmt) { + t.Fatalf("期望包含动作语句 %s,实际规则:\n%s", tc.wantStmt, content) + } + }) + } +} + +// ---------- 注入对抗(核心) ---------- + +// 这是本次改动最重要的安全保证。 +// 从攻击日志一键新建规则时,拼进 Contains(...) 里的是攻击者构造的 UA/URL 原文。 +// 攻击者只要发一个 UA 形如 `x") RF.AllowAll(); //` 的请求,诱导管理员点"加入规则", +// 生成的规则就会从"拦截"变成"放行"。下面这些用例保证这条路走不通。 +func TestGenRuleInfo_InjectionFromAttackerControlledValue(t *testing.T) { + payloads := []string{ + `x") RF.AllowAll(); //`, + `x") RF.Allow("ALL"); Retract("Rabc123"); //`, + "x\") \n RF.AllowAll(); \n //", + `x"} rule Revil "偷偷放行" salience 999 { when MF.SRC_IP == "6.6.6.6" then RF.AllowAll(); }`, + `x\") RF.AllowAll(); //`, + `x\\") RF.AllowAll(); //`, + } + + ruleHelper := &utils.RuleHelper{} + + for _, payload := range payloads { + t.Run(payload, func(t *testing.T) { + content, err := buildRule(t, "deny", nil, "拦截可疑UA", strCond("USER_AGENT", "system.Contains", payload)) + if err != nil { + t.Fatalf("生成规则失败: %v", err) + } + + // 1. 生成的规则必须还能编译(转义正确,没把语法搞坏) + if err := ruleHelper.CheckRuleAvailable(content); err != nil { + t.Fatalf("转义后的规则无法编译: %v\n规则内容:\n%s", err, content) + } + + // 2. 只能有一条规则(payload 里的 rule 定义不能真的生效) + if n := utils.CountRuleBlocks(content); n != 1 { + t.Fatalf("payload 注入出了额外的规则块(%d条):\n%s", n, content) + } + + // 3. 最关键:动作必须还是拦截,绝不能被 payload 改成放行 + actions := utils.ExtractRuleActions(content) + info, ok := actions["Rabc123"] + if !ok { + info = utils.RuleActionInfo{Action: utils.RuleActionDeny} + } + if info.Action != utils.RuleActionDeny { + t.Fatalf("规则动作被注入改写成了 %s(应为 deny):\n%s", info.Action, content) + } + }) + } +} + +// 规则名(备注)同样是用户可控的,也可能是从日志复制来的内容 +func TestGenRuleInfo_InjectionFromRemark(t *testing.T) { + ruleHelper := &utils.RuleHelper{} + content, err := buildRule(t, "deny", nil, `恶意备注" salience 999 { when true then RF.AllowAll(); } //`, + strCond("URL", "system.Contains", "/admin")) + if err != nil { + t.Fatalf("生成规则失败: %v", err) + } + if err := ruleHelper.CheckRuleAvailable(content); err != nil { + t.Fatalf("转义后的规则无法编译: %v\n%s", err, content) + } + if n := utils.CountRuleBlocks(content); n != 1 { + t.Fatalf("备注注入出了额外的规则块(%d条):\n%s", n, content) + } + actions := utils.ExtractRuleActions(content) + if info, ok := actions["Rabc123"]; ok && info.Action != utils.RuleActionDeny { + t.Fatalf("规则动作被备注注入改写成了 %s:\n%s", info.Action, content) + } +} + +// 非字符串类型的值是裸拼进表达式的,转义救不了,必须靠白名单拦住 +func TestGenRuleInfo_NonStringValueMustBeNumeric(t *testing.T) { + _, err := buildRule(t, "deny", nil, "端口规则", model.RelationDetail{ + FactName: "MF", + Attr: "PORT", + AttrType: "int", + AttrJudge: "==", + AttrVal: `1 then RF.AllowAll()`, + }) + if err == nil { + t.Fatal("int 类型的值不是数字时必须报错,否则可以直接逃逸出字符串上下文") + } +} + +// ---------- 结构位白名单 ---------- + +func TestGenRuleInfo_StructureWhiteList(t *testing.T) { + cases := []struct { + name string + cond model.RelationDetail + }{ + {"非法字段名", model.RelationDetail{FactName: "MF", Attr: `URL; RF.AllowAll()`, AttrType: "string", AttrJudge: "==", AttrVal: "x"}}, + {"非法运算符", model.RelationDetail{FactName: "MF", Attr: "URL", AttrType: "string", AttrJudge: "== \"x\" || true ||", AttrVal: "x"}}, + {"非法事实对象", model.RelationDetail{FactName: "EVIL", Attr: "URL", AttrType: "string", AttrJudge: "==", AttrVal: "x"}}, + {"非法值类型", model.RelationDetail{FactName: "MF", Attr: "URL", AttrType: "raw", AttrJudge: "==", AttrVal: "x"}}, + {"函数判断的结果值非法", model.RelationDetail{FactName: "MF", Attr: "URL", AttrType: "string", AttrJudge: "system.Contains", AttrVal: "x", AttrVal2: `true || RF.AllowAll()`}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := buildRule(t, "deny", nil, "x", tc.cond); err == nil { + t.Fatal("非法结构位必须报错") + } + }) + } +} + +func TestGenRuleInfo_InvalidRuleName(t *testing.T) { + ruleTool := model.RuleTool{} + ruleInfo := model.RuleInfo{ + RuleBase: model.RuleBase{Salience: 10, RuleName: `abc" salience 999 { when true then RF.AllowAll(); } //`}, + RuleCondition: model.RuleCondition{RelationDetail: []model.RelationDetail{strCond("URL", "==", "/x")}}, + } + if _, err := ruleTool.GenRuleInfo(ruleInfo, "x"); err == nil { + t.Fatal("非法规则标识必须报错") + } +} + +// ---------- 正常规则仍然可用 ---------- + +func TestGenRuleInfo_NormalRulesStillWork(t *testing.T) { + ruleHelper := &utils.RuleHelper{} + + // 含中文备注、含引号的普通值 + content, err := buildRule(t, "deny", nil, `禁止访问后台"管理"页`, + strCond("URL", "system.Contains", "/admin"), + strCond("USER_AGENT", "system.Contains", "curl")) + if err != nil { + t.Fatalf("生成规则失败: %v", err) + } + if err := ruleHelper.CheckRuleAvailable(content); err != nil { + t.Fatalf("正常规则应该能编译: %v\n%s", err, content) + } + if !strings.Contains(content, "&&") { + t.Fatalf("多条件应该用 && 连接:\n%s", content) + } +} + +func TestEscapeGrlString(t *testing.T) { + cases := map[string]string{ + `abc`: `abc`, + `a"b`: `a\"b`, + `a\b`: `a\\b`, + "a\nb": `ab`, + "a\tb": `ab`, + `a\") x`: `a\\\") x`, + "中文": "中文", + } + for in, want := range cases { + if got := model.EscapeGrlString(in); got != want { + t.Fatalf("EscapeGrlString(%q) = %q, 期望 %q", in, got, want) + } + } +} diff --git a/model/rule_info_json.go b/model/rule_info_json.go index e867698..3031602 100644 --- a/model/rule_info_json.go +++ b/model/rule_info_json.go @@ -20,7 +20,9 @@ func IfCompare(condition bool, trueVal, falseVal interface{}) interface{} { type RuleInfo struct { IsManualRule string `json:"is_manual_rule"` - RuleContent string `json:"rule_content"` //规则内容 + RuleContent string `json:"rule_content"` //规则内容 + RuleAction string `json:"rule_action"` //命中之后的动作 deny(拦截,默认) allow(放行) log(仅记录) + RuleActionSkips []string `json:"rule_action_skips"` //放行时要跳过的检测模块 如 ["CC","AI"],["ALL"]表示全部 RuleBase RuleBase `json:"rule_base"` RuleCondition RuleCondition `json:"rule_condition"` RuleDoAssignment []RuleDoAssignment `json:"rule_do_assignment"` @@ -73,49 +75,131 @@ func (receiver *RuleTool) LoadRule(jsonstr string) (RuleInfo, error) { func (receiver *RuleTool) GenRuleToFront(rule RuleInfo) { }*/ -//生成实际可使用的规则字符串 -func (receiver *RuleTool) GenRuleInfo(rule RuleInfo, remark string) string { +// genRuleActionStmt 根据界面选择的动作生成 then 块里的动作语句 +// 没有选择动作(老数据)时按拦截处理,与老规则行为一致 +func genRuleActionStmt(rule RuleInfo) string { + switch rule.RuleAction { + case "log": + return "RF.Log();" + case "allow": + //跳过的模块名只能来自白名单枚举,非法值直接丢弃,绝不把用户传来的字符串原样拼进规则 + skips := make([]string, 0, len(rule.RuleActionSkips)) + skipAll := false + for _, m := range rule.RuleActionSkips { + m = strings.ToUpper(strings.TrimSpace(m)) + if m == "ALL" { + skipAll = true + break + } + if ruleSkipModuleWhiteList[m] { + skips = append(skips, m) + } + } + if skipAll { + return "RF.AllowAll();" + } + if len(skips) == 0 { + return "RF.Allow();" + } + quoted := make([]string, 0, len(skips)) + for _, m := range skips { + quoted = append(quoted, "\""+m+"\"") + } + return fmt.Sprintf("RF.Allow(%s);", strings.Join(quoted, ", ")) + default: + return "RF.Deny();" + } +} + +// ruleSkipModuleWhiteList 与 utils.RuleSkipModules 保持一致 +// (model 包不能引 utils,会形成循环依赖,所以这里单独列一份,加规则模块时两边都要改) +var ruleSkipModuleWhiteList = map[string]bool{ + "BOT": true, "SQLI": true, "XSS": true, "SCAN": true, "RCE": true, + "DIR": true, "CC": true, "AI": true, "SENSITIVE": true, "OWASP": true, + "ANTILEECH": true, "CSRF": true, "UPLOAD": true, "CAPTCHA": true, +} + +// 生成实际可使用的规则字符串 +// 所有拼进 GRL 的内容都经过转义或白名单校验,防止用户/攻击者通过条件值注入额外的规则语句 +func (receiver *RuleTool) GenRuleInfo(rule RuleInfo, remark string) (string, error) { + + if err := ValidateRuleName(rule.RuleBase.RuleName); err != nil { + return "", err + } + if err := ValidateRuleRelationSymbol(rule.RuleCondition.RelationSymbol); err != nil { + return "", err + } + //优先级钳制,防止拼出非法的 salience + salience := rule.RuleBase.Salience + if salience < 0 { + salience = 0 + } + if salience > 10000 { + salience = 10000 + } var conditionTpl = "" for _, condition := range rule.RuleCondition.RelationDetail { + if err := ValidateRuleFactName(condition.FactName); err != nil { + return "", err + } + if err := ValidateRuleAttr(condition.Attr); err != nil { + return "", err + } + if err := ValidateRuleJudge(condition.AttrJudge); err != nil { + return "", err + } + if err := ValidateRuleAttrType(condition.AttrType); err != nil { + return "", err + } + if err := ValidateRuleAttrVal(condition.AttrType, condition.AttrVal); err != nil { + return "", err + } + + //字符串值转义后再包引号;非字符串值上面已校验为纯数字/布尔,可以裸拼 + attrVal := condition.AttrVal + if condition.AttrType == "string" { + attrVal = "\"" + EscapeGrlString(condition.AttrVal) + "\"" + } else { + attrVal = strings.TrimSpace(condition.AttrVal) + } + + var expr string + if strings.HasPrefix(condition.AttrJudge, "system.") { + if err := ValidateRuleBoolVal(condition.AttrVal2); err != nil { + return "", err + } + expr = fmt.Sprintf("%s.%s.%s(%s) == %s", + condition.FactName, condition.Attr, + strings.Replace(condition.AttrJudge, "system.", "", 1), + attrVal, condition.AttrVal2) + } else { + expr = fmt.Sprintf("%s.%s %s %s", + condition.FactName, condition.Attr, condition.AttrJudge, attrVal) + } if conditionTpl != "" { - if strings.HasPrefix(condition.AttrJudge, "system.") { - conditionTpl = conditionTpl + fmt.Sprintf(" %s %s.%s.%s(%s) == %s", - rule.RuleCondition.RelationSymbol, condition.FactName, condition.Attr, strings.Replace(condition.AttrJudge, "system.", "", 1), - IfCompare(condition.AttrType == "string", "\""+condition.AttrVal+"\"", condition.AttrVal), condition.AttrVal2) - } else { - conditionTpl = conditionTpl + fmt.Sprintf(" %s %s.%s %s %s", - rule.RuleCondition.RelationSymbol, condition.FactName, condition.Attr, condition.AttrJudge, - IfCompare(condition.AttrType == "string", "\""+condition.AttrVal+"\"", condition.AttrVal)) - } + conditionTpl = conditionTpl + " " + rule.RuleCondition.RelationSymbol + " " + expr } else { - if strings.HasPrefix(condition.AttrJudge, "system.") { - conditionTpl = conditionTpl + fmt.Sprintf("%s.%s.%s(%s) == %s", - condition.FactName, condition.Attr, strings.Replace(condition.AttrJudge, "system.", "", 1), - IfCompare(condition.AttrType == "string", "\""+condition.AttrVal+"\"", condition.AttrVal), condition.AttrVal2) - } else { - conditionTpl = conditionTpl + fmt.Sprintf("%s.%s %s %s", - condition.FactName, condition.Attr, condition.AttrJudge, - IfCompare(condition.AttrType == "string", "\""+condition.AttrVal+"\"", condition.AttrVal)) - } - + conditionTpl = expr } } + var dev = map[string]string{ - "rule_name": rule.RuleBase.RuleName, - "rule_remark": remark, - "rule_salience": strconv.Itoa(rule.RuleBase.Salience), - "rule_condition": conditionTpl, + "rule_name": rule.RuleBase.RuleName, + "rule_remark": EscapeGrlString(remark), + "rule_salience": strconv.Itoa(salience), + "rule_condition": conditionTpl, + "rule_action_stmt": genRuleActionStmt(rule), } var ruleTpl = ` rule R${rule_name} "${rule_remark}" salience ${rule_salience} { - when + when ${rule_condition} - then - Retract("R${rule_name}"); + then + ${rule_action_stmt} } ` s := os.Expand(ruleTpl, func(k string) string { return dev[k] }) - return s + return s, nil } diff --git a/router/waf_gpt.go b/router/waf_gpt.go index c475608..ec70daf 100644 --- a/router/waf_gpt.go +++ b/router/waf_gpt.go @@ -11,7 +11,14 @@ type WafGPTRouter struct { func (receiver *WafGPTRouter) InitGPTRouter(group *gin.RouterGroup) { api := api.APIGroupAPP.WafGPTApi + + // 流式对话:走 SSE 中间件 + streamRouter := group.Group("") + streamRouter.Use(middleware.StreamMiddleware()) + streamRouter.POST("/api/v1/gpt/chat", api.ChatApi) + + // 普通 JSON 接口:GPT 参数的读取/保存(不走流式中间件) router := group.Group("") - router.Use(middleware.StreamMiddleware()) - router.POST("/api/v1/gpt/chat", api.ChatApi) + router.GET("/api/v1/gpt/config", api.GetGptConfigApi) + router.POST("/api/v1/gpt/config/save", api.SaveGptConfigApi) } diff --git a/router/waf_rule.go b/router/waf_rule.go index b6de8b3..939c511 100644 --- a/router/waf_rule.go +++ b/router/waf_rule.go @@ -22,4 +22,6 @@ func (receiver *RuleRouter) InitRuleRouter(group *gin.RouterGroup) { wafRuleRouter.POST("/api/v1/wafhost/rule/format", ruleApi.FormatRuleApi) wafRuleRouter.GET("/api/v1/wafhost/rule/rulestatus", ruleApi.ModifyRuleStatusApi) wafRuleRouter.POST("/api/v1/wafhost/rule/test", ruleApi.TestRuleApi) + wafRuleRouter.POST("/api/v1/wafhost/rule/aigen", ruleApi.AiGenRuleApi) + wafRuleRouter.GET("/api/v1/wafhost/rule/aiprompt", ruleApi.RuleAiPromptApi) } diff --git a/test/Grule_regular_test.go b/test/Grule_regular_test.go index e7ac1e8..0d4d7bc 100644 --- a/test/Grule_regular_test.go +++ b/test/Grule_regular_test.go @@ -38,7 +38,8 @@ rule R77a9d95d14624d4baaa9ef7af3061404 "试试344" salience 10 { err = ruleBuilder.BuildRuleFromResource("Tutorial", "0.0.1", byteArr) assert.NoError(t, err) - knowledgeBase := knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + knowledgeBase, err := knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + assert.NoError(t, err) my_engine := engine.NewGruleEngine() processType := "match" diff --git a/test/Grule_test.go b/test/Grule_test.go index d1efdbb..b227613 100644 --- a/test/Grule_test.go +++ b/test/Grule_test.go @@ -38,7 +38,8 @@ rule R77a9d95d14624d4baaa9ef7af3061404 "试试344" salience 10 { err = ruleBuilder.BuildRuleFromResource("Tutorial", "0.0.1", byteArr) assert.NoError(t, err) - knowledgeBase := knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + knowledgeBase, err := knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + assert.NoError(t, err) my_engine := engine.NewGruleEngine() processType := "match" @@ -78,7 +79,8 @@ rule R77a9d95d14624d4baaa9ef7af3061404 "试试344" salience 10 { err = ruleBuilder.BuildRuleFromResource("Tutorial", "0.0.1", byteArr) assert.NoError(t, err) - knowledgeBase = knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + knowledgeBase, err = knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + assert.NoError(t, err) my_engine = engine.NewGruleEngine() processType = "match" diff --git a/test/TutorialExample_test.go b/test/TutorialExample_test.go index 1a3f890..2a5e608 100644 --- a/test/TutorialExample_test.go +++ b/test/TutorialExample_test.go @@ -55,7 +55,8 @@ rule CheckValues "Check the default values" salience 10 { err = ruleBuilder.BuildRuleFromResource("Tutorial", "0.0.1", byteArr) assert.NoError(t, err) - knowledgeBase := knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + knowledgeBase, err := knowledgeLibrary.NewKnowledgeBaseInstance("Tutorial", "0.0.1") + assert.NoError(t, err) engine := engine.NewGruleEngine() err = engine.Execute(dataCtx, knowledgeBase) diff --git a/test/ruleinfotest_test.go b/test/ruleinfotest_test.go index 76a26a1..041c9c9 100644 --- a/test/ruleinfotest_test.go +++ b/test/ruleinfotest_test.go @@ -19,5 +19,11 @@ func TestJsonTest(t *testing.T) { } fmt.Printf("反序列化后 RuleName=%v RuleDomainCode=%v \n", ruleInfo.RuleBase.RuleName, ruleInfo.RuleBase.RuleDomainCode) - fmt.Println(ruleTool.GenRuleInfo(ruleInfo, "规则触发了")) + //规则标识实际是由规则码替换进来的(只含字母数字),这里模拟真实调用 + ruleInfo.RuleBase.RuleName = "abc123" + ruleContent, err := ruleTool.GenRuleInfo(ruleInfo, "规则触发了") + if err != nil { + t.Fatalf("生成规则失败: %v", err) + } + fmt.Println(ruleContent) } diff --git a/utils/rule_action.go b/utils/rule_action.go new file mode 100644 index 0000000..71ff314 --- /dev/null +++ b/utils/rule_action.go @@ -0,0 +1,374 @@ +package utils + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// 规则动作 +const ( + RuleActionDeny = "deny" // 拦截(默认) + RuleActionAllow = "allow" // 放行 + RuleActionLog = "log" // 仅记录 +) + +// 可跳过的检测模块名(Allow 的参数只接受这些值,大小写不敏感) +const ( + RuleSkipAll = "ALL" +) + +// ruleSkipModules 允许被跳过的检测模块白名单 +// 注意:跳过只对"排在自定义规则之后"的检测环节生效,具体取决于规则编排模式 +var ruleSkipModules = []string{ + "BOT", // 爬虫检测 + "SQLI", // SQL注入 + "XSS", // XSS + "SCAN", // 扫描工具 + "RCE", // 远程命令执行 + "DIR", // 目录穿越 + "CC", // CC防护 + "AI", // AI智能检测 + "SENSITIVE", // 敏感词 + "OWASP", // OWASP规则集 + "ANTILEECH", // 防盗链 + "CSRF", // CSRF + "UPLOAD", // 文件上传检测 + "CAPTCHA", // 验证码 + RuleSkipAll, // 全部 +} + +var ruleSkipModuleSet = func() map[string]bool { + m := make(map[string]bool, len(ruleSkipModules)) + for _, v := range ruleSkipModules { + m[v] = true + } + return m +}() + +// RuleSkipModules 返回允许跳过的模块名列表(供接口/前端提示使用) +func RuleSkipModules() []string { + out := make([]string, len(ruleSkipModules)) + copy(out, ruleSkipModules) + return out +} + +// RuleActionInfo 一条规则的动作信息 +type RuleActionInfo struct { + Action string // deny / allow / log + SkipModules []string // 仅 allow 有效,元素为大写模块名;含 "ALL" 表示跳过全部 +} + +// SkipAll 是否跳过后续所有检测 +func (info RuleActionInfo) SkipAll() bool { + for _, m := range info.SkipModules { + if m == RuleSkipAll { + return true + } + } + return false +} + +var ( + // 规则块起始:rule 规则名 + ruleBlockRegex = regexp.MustCompile(`(?m)\brule\s+([A-Za-z0-9_]+)`) + // then 关键字 + ruleThenRegex = regexp.MustCompile(`\bthen\b`) + // 动作标记:RF.Allow(...) / RF.AllowAll() / RF.Deny() / RF.Log() + ruleActionRegex = regexp.MustCompile(`RF\s*\.\s*(AllowAll|Allow|Deny|Log)\s*\(([^)]*)\)`) +) + +// BuildGrlSkeleton 生成规则文本的"骨架":把字符串字面量和注释里的内容全部替换成空格, +// 其余字符原样保留,输出与输入等长(按字节),因此骨架上的位置可以直接对应到原文。 +// +// 这是动作解析的安全基础。规则文本里混着用户可控内容(比如 MF.URL.Contains("...") 里的值 +// 可能直接来自攻击者的请求),如果直接拿正则去原文里找 RF.Deny(),攻击者只要在值里写一段 +// 假的动作标记就能骗过解析,出现"保存时看到的是拦截、运行时按放行执行"的认知差。 +// 在骨架上定位就不会被字符串字面量和注释里的内容欺骗。 +// +// 词法参照 grule 的 grulev3.g4: +// +// DQUOTA_STRING : '"' ( '\\'. | '""' | ~('"' |'\\') )* '"' +// SQUOTA_STRING : '\'' ( '\\'. | '\'\'' | ~('\''|'\\') )* '\'' +func BuildGrlSkeleton(ruleText string) string { + src := []byte(ruleText) + out := make([]byte, len(src)) + + const ( + stCode = iota + stDQuote + stSQuote + stLineComment + stBlockComment + ) + state := stCode + + blank := func(i int) { + // 保留换行,方便按行定位;其余一律变空格 + if src[i] == '\n' || src[i] == '\r' { + out[i] = src[i] + } else { + out[i] = ' ' + } + } + + for i := 0; i < len(src); i++ { + c := src[i] + switch state { + case stCode: + switch { + case c == '"': + out[i] = c // 引号本身保留,方便取参数时定位 + state = stDQuote + case c == '\'': + out[i] = c + state = stSQuote + case c == '/' && i+1 < len(src) && src[i+1] == '/': + blank(i) + state = stLineComment + case c == '/' && i+1 < len(src) && src[i+1] == '*': + blank(i) + state = stBlockComment + default: + out[i] = c + } + case stDQuote: + if c == '\\' && i+1 < len(src) { + // 转义序列:两个字符都吃掉 + blank(i) + i++ + blank(i) + continue + } + if c == '"' { + // 连续两个双引号是转义写法 "",不是结束 + if i+1 < len(src) && src[i+1] == '"' { + blank(i) + i++ + blank(i) + continue + } + out[i] = c + state = stCode + continue + } + blank(i) + case stSQuote: + if c == '\\' && i+1 < len(src) { + blank(i) + i++ + blank(i) + continue + } + if c == '\'' { + if i+1 < len(src) && src[i+1] == '\'' { + blank(i) + i++ + blank(i) + continue + } + out[i] = c + state = stCode + continue + } + blank(i) + case stLineComment: + if c == '\n' { + out[i] = c + state = stCode + continue + } + blank(i) + case stBlockComment: + if c == '*' && i+1 < len(src) && src[i+1] == '/' { + blank(i) + i++ + blank(i) + state = stCode + continue + } + blank(i) + } + } + return string(out) +} + +// ruleBlock 规则块在文本中的位置 +type ruleBlock struct { + Name string // 规则名(含 R 前缀),与 ast.RuleEntry.RuleName 一致 + Start int // 在原文中的起始位置 + End int // 在原文中的结束位置(下一条规则的起点,或文本末尾) +} + +// splitRuleBlocks 在骨架上按 rule 关键字切分规则块 +func splitRuleBlocks(skeleton string) []ruleBlock { + locs := ruleBlockRegex.FindAllStringSubmatchIndex(skeleton, -1) + blocks := make([]ruleBlock, 0, len(locs)) + for i, loc := range locs { + end := len(skeleton) + if i+1 < len(locs) { + end = locs[i+1][0] + } + blocks = append(blocks, ruleBlock{ + Name: skeleton[loc[2]:loc[3]], + Start: loc[0], + End: end, + }) + } + return blocks +} + +// parseActionArgs 解析动作参数,返回大写去重后的模块名列表和非法模块名列表 +// argsRaw 取自原文(骨架里字符串内容已被抹空),形如: "CC", "AI" +func parseActionArgs(argsRaw string) (modules []string, invalid []string) { + seen := make(map[string]bool) + for _, part := range strings.Split(argsRaw, ",") { + v := strings.TrimSpace(part) + if v == "" { + continue + } + // 去掉包裹的引号 + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { + v = v[1 : len(v)-1] + } + v = strings.ToUpper(strings.TrimSpace(v)) + if v == "" { + continue + } + if !ruleSkipModuleSet[v] { + invalid = append(invalid, v) + continue + } + if !seen[v] { + seen[v] = true + modules = append(modules, v) + } + } + sort.Strings(modules) + return modules, invalid +} + +// extractBlockAction 解析单个规则块的动作 +// 只扫描 then 之后的部分,避免 when 里的内容干扰 +func extractBlockAction(skeletonBlock, rawBlock string) (info RuleActionInfo, count int, invalid []string) { + info = RuleActionInfo{Action: RuleActionDeny} + + thenLoc := ruleThenRegex.FindStringIndex(skeletonBlock) + if thenLoc == nil { + return info, 0, nil + } + offset := thenLoc[1] + matches := ruleActionRegex.FindAllStringSubmatchIndex(skeletonBlock[offset:], -1) + if len(matches) == 0 { + return info, 0, nil + } + + for i, m := range matches { + fnName := skeletonBlock[offset+m[2] : offset+m[3]] + // 参数从原文同位置取(骨架里字符串内容已被抹空) + argsRaw := "" + if m[4] >= 0 && offset+m[5] <= len(rawBlock) { + argsRaw = rawBlock[offset+m[4] : offset+m[5]] + } + + var cur RuleActionInfo + switch fnName { + case "Deny": + cur = RuleActionInfo{Action: RuleActionDeny} + case "Log": + cur = RuleActionInfo{Action: RuleActionLog} + case "AllowAll": + cur = RuleActionInfo{Action: RuleActionAllow, SkipModules: []string{RuleSkipAll}} + case "Allow": + mods, bad := parseActionArgs(argsRaw) + invalid = append(invalid, bad...) + cur = RuleActionInfo{Action: RuleActionAllow, SkipModules: mods} + } + // 多个标记时以第一个为准 + if i == 0 { + info = cur + } + } + return info, len(matches), invalid +} + +// ExtractRuleActions 从规则文本中解析出每条规则的动作 +// 支持一段文本包含多条规则。没有声明动作的规则不会出现在结果里(查不到即默认拦截)。 +// 运行期使用:宽容处理,多个动作标记取第一个,非法模块名忽略。 +func ExtractRuleActions(ruleText string) map[string]RuleActionInfo { + result := make(map[string]RuleActionInfo) + skeleton := BuildGrlSkeleton(ruleText) + for _, block := range splitRuleBlocks(skeleton) { + info, count, _ := extractBlockAction(skeleton[block.Start:block.End], ruleText[block.Start:block.End]) + if count == 0 { + continue + } + result[block.Name] = info + } + return result +} + +// ExtractRuleActionForCheck 保存规则时的动作校验(严格模式) +// 返回该规则的动作;发现下列情况直接报错: +// - 声明了多个不同的动作标记 +// - Allow 的参数里有不认识的模块名 +func ExtractRuleActionForCheck(ruleText string) (RuleActionInfo, error) { + skeleton := BuildGrlSkeleton(ruleText) + blocks := splitRuleBlocks(skeleton) + if len(blocks) == 0 { + return RuleActionInfo{Action: RuleActionDeny}, fmt.Errorf("未找到规则定义") + } + + info := RuleActionInfo{Action: RuleActionDeny} + for _, block := range blocks { + skeletonBlock := skeleton[block.Start:block.End] + blockInfo, count, invalid := extractBlockAction(skeletonBlock, ruleText[block.Start:block.End]) + if len(invalid) > 0 { + return info, fmt.Errorf("规则动作 RF.Allow 参数中存在无法识别的检测模块: %s,可用模块: %s", + strings.Join(invalid, ", "), strings.Join(ruleSkipModules, ", ")) + } + if count > 1 { + // 多个标记:只要动作语义不完全一致就报错 + if !hasSingleAction(skeletonBlock) { + return info, fmt.Errorf("规则 %s 中声明了多个不同的动作,一条规则只能有一个动作(RF.Deny/RF.Allow/RF.Log)", block.Name) + } + } + info = blockInfo + } + return info, nil +} + +// hasSingleAction 判断规则块里的多个动作标记是否语义一致(完全相同的调用视为重复书写,允许) +func hasSingleAction(skeletonBlock string) bool { + thenLoc := ruleThenRegex.FindStringIndex(skeletonBlock) + if thenLoc == nil { + return true + } + matches := ruleActionRegex.FindAllString(skeletonBlock[thenLoc[1]:], -1) + if len(matches) <= 1 { + return true + } + first := strings.Join(strings.Fields(matches[0]), "") + for _, m := range matches[1:] { + if strings.Join(strings.Fields(m), "") != first { + return false + } + } + return true +} + +// CountRuleBlocks 统计规则文本里有几条规则(用于保存时限制"一条规则内容只能有一条规则") +func CountRuleBlocks(ruleText string) int { + return len(splitRuleBlocks(BuildGrlSkeleton(ruleText))) +} + +// ExtractRuleNamesFromText 提取规则文本中所有规则名(含 R 前缀) +func ExtractRuleNamesFromText(ruleText string) []string { + blocks := splitRuleBlocks(BuildGrlSkeleton(ruleText)) + names := make([]string, 0, len(blocks)) + for _, b := range blocks { + names = append(names, b.Name) + } + return names +} diff --git a/utils/rule_action_test.go b/utils/rule_action_test.go new file mode 100644 index 0000000..14bfe25 --- /dev/null +++ b/utils/rule_action_test.go @@ -0,0 +1,393 @@ +package utils + +import ( + "strings" + "testing" +) + +// ---------- 动作解析基础用例 ---------- + +func TestExtractRuleActions_Basic(t *testing.T) { + cases := []struct { + name string + ruleText string + wantAction string + wantSkips []string + }{ + { + name: "没有动作标记默认拦截(老规则形态,带Retract)", + ruleText: `rule Rabc123 "老规则" salience 10 { + when + MF.URL.Contains("/admin") == true + then + Retract("Rabc123"); +}`, + wantAction: RuleActionDeny, + }, + { + name: "显式Deny", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.URL.Contains("/admin") == true + then + RF.Deny(); +}`, + wantAction: RuleActionDeny, + }, + { + name: "Deny和Retract共存", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.URL.Contains("/admin") == true + then + RF.Deny(); + Retract("Rabc123"); +}`, + wantAction: RuleActionDeny, + }, + { + name: "Log", + ruleText: `rule Rabc123 "仅记录" salience 10 { + when + MF.URL.Contains("/api") == true + then + RF.Log(); +}`, + wantAction: RuleActionLog, + }, + { + name: "Allow无参", + ruleText: `rule Rabc123 "放行" salience 10 { + when + MF.SRC_IP == "1.2.3.4" + then + RF.Allow(); +}`, + wantAction: RuleActionAllow, + }, + { + name: "Allow带模块参数", + ruleText: `rule Rabc123 "放行" salience 10 { + when + MF.SRC_IP == "1.2.3.4" + then + RF.Allow("CC", "AI"); +}`, + wantAction: RuleActionAllow, + wantSkips: []string{"AI", "CC"}, + }, + { + name: "Allow参数大小写不敏感", + ruleText: `rule Rabc123 "放行" salience 10 { + when + MF.SRC_IP == "1.2.3.4" + then + RF.Allow("cc", "sqli"); +}`, + wantAction: RuleActionAllow, + wantSkips: []string{"CC", "SQLI"}, + }, + { + name: "AllowAll", + ruleText: `rule Rabc123 "全放行" salience 10 { + when + MF.SRC_IP == "1.2.3.4" + then + RF.AllowAll(); +}`, + wantAction: RuleActionAllow, + wantSkips: []string{"ALL"}, + }, + { + name: "Allow(\"ALL\") 等价 AllowAll", + ruleText: `rule Rabc123 "全放行" salience 10 { + when + MF.SRC_IP == "1.2.3.4" + then + RF.Allow("ALL"); +}`, + wantAction: RuleActionAllow, + wantSkips: []string{"ALL"}, + }, + { + name: "写法带多余空格", + ruleText: `rule Rabc123 "放行" salience 10 { + when + MF.SRC_IP == "1.2.3.4" + then + RF . Allow ( "CC" ) ; +}`, + wantAction: RuleActionAllow, + wantSkips: []string{"CC"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actions := ExtractRuleActions(tc.ruleText) + info, ok := actions["Rabc123"] + if !ok { + // 没解析到动作等价于默认拦截 + info = RuleActionInfo{Action: RuleActionDeny} + } + if info.Action != tc.wantAction { + t.Fatalf("动作不对: 期望 %s, 实际 %s", tc.wantAction, info.Action) + } + if strings.Join(info.SkipModules, ",") != strings.Join(tc.wantSkips, ",") { + t.Fatalf("跳过模块不对: 期望 %v, 实际 %v", tc.wantSkips, info.SkipModules) + } + }) + } +} + +// ---------- 安全用例:字符串字面量和注释不能骗过动作解析 ---------- + +// 这一组是这次改动的核心安全保证: +// 规则文本里混着用户/攻击者可控的内容(比如从攻击日志一键建规则时,UA 原文会被拼进 Contains 里)。 +// 如果动作解析被字符串里的假标记骗过去,就会出现"保存时看到的是拦截、运行时按放行执行"的认知差。 +func TestExtractRuleActions_NotFooledByStringsAndComments(t *testing.T) { + cases := []struct { + name string + ruleText string + wantAction string + }{ + { + name: "when条件的字符串里藏着假的AllowAll", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.USER_AGENT.Contains("then RF.AllowAll();") == true + then + RF.Deny(); +}`, + wantAction: RuleActionDeny, + }, + { + name: "then里字符串参数中藏着假的AllowAll", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.URL.Contains("/x") == true + then + RF.Deny(); + Retract("Rabc123 RF.AllowAll();"); +}`, + wantAction: RuleActionDeny, + }, + { + name: "转义引号逃逸尝试", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.USER_AGENT.Contains("bad\") RF.AllowAll(); //") == true + then + RF.Deny(); +}`, + wantAction: RuleActionDeny, + }, + { + name: "行注释里的动作不生效", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.URL.Contains("/x") == true + then + // RF.AllowAll(); + RF.Deny(); +}`, + wantAction: RuleActionDeny, + }, + { + name: "块注释里的动作不生效", + ruleText: `rule Rabc123 "拦截" salience 10 { + when + MF.URL.Contains("/x") == true + then + /* RF.Allow("ALL"); */ + RF.Deny(); +}`, + wantAction: RuleActionDeny, + }, + { + name: "规则描述里藏动作", + ruleText: `rule Rabc123 "拦截 RF.AllowAll();" salience 10 { + when + MF.URL.Contains("/x") == true + then + RF.Deny(); +}`, + wantAction: RuleActionDeny, + }, + { + name: "when里的字符串包含then关键字", + ruleText: `rule Rabc123 "放行" salience 10 { + when + MF.URL.Contains("then") == true + then + RF.Allow(); +}`, + wantAction: RuleActionAllow, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actions := ExtractRuleActions(tc.ruleText) + info, ok := actions["Rabc123"] + if !ok { + info = RuleActionInfo{Action: RuleActionDeny} + } + if info.Action != tc.wantAction { + t.Fatalf("动作被骗了: 期望 %s, 实际 %s (skips=%v)", tc.wantAction, info.Action, info.SkipModules) + } + }) + } +} + +func TestBuildGrlSkeleton_KeepsLength(t *testing.T) { + src := `rule Rx "描述" salience 10 { + when + MF.URL.Contains("a\"b") == true // 注释 + then + /* 块注释 */ RF.Deny(); +}` + skeleton := BuildGrlSkeleton(src) + if len(skeleton) != len(src) { + t.Fatalf("骨架长度必须与原文一致: 原文 %d, 骨架 %d", len(src), len(skeleton)) + } + if strings.Contains(skeleton, "注释") { + t.Fatal("注释内容应该被抹掉") + } + if !strings.Contains(skeleton, "RF.Deny();") { + t.Fatal("代码部分应该保留") + } +} + +// ---------- 多条规则 ---------- + +func TestExtractRuleActions_MultipleRules(t *testing.T) { + ruleText := ` +rule Rone "放行" salience 100 { + when + MF.SRC_IP == "1.1.1.1" + then + RF.Allow("CC"); +} + +rule Rtwo "拦截" salience 10 { + when + MF.URL.Contains("/admin") == true + then + RF.Deny(); +} + +rule Rthree "仅记录" salience 5 { + when + MF.METHOD == "PUT" + then + RF.Log(); +}` + actions := ExtractRuleActions(ruleText) + if actions["Rone"].Action != RuleActionAllow || strings.Join(actions["Rone"].SkipModules, ",") != "CC" { + t.Fatalf("Rone 解析错误: %+v", actions["Rone"]) + } + if actions["Rtwo"].Action != RuleActionDeny { + t.Fatalf("Rtwo 解析错误: %+v", actions["Rtwo"]) + } + if actions["Rthree"].Action != RuleActionLog { + t.Fatalf("Rthree 解析错误: %+v", actions["Rthree"]) + } +} + +// ---------- 保存期严格校验 ---------- + +func TestExtractRuleActionForCheck(t *testing.T) { + t.Run("多个不同动作报错", func(t *testing.T) { + ruleText := `rule Rabc123 "x" salience 10 { + when + MF.URL.Contains("/x") == true + then + RF.Allow(); + RF.Deny(); +}` + if _, err := ExtractRuleActionForCheck(ruleText); err == nil { + t.Fatal("同时写 Allow 和 Deny 应该报错") + } + }) + + t.Run("重复写同一个动作允许", func(t *testing.T) { + ruleText := `rule Rabc123 "x" salience 10 { + when + MF.URL.Contains("/x") == true + then + RF.Deny(); + RF.Deny(); +}` + info, err := ExtractRuleActionForCheck(ruleText) + if err != nil { + t.Fatalf("重复写同一动作不应报错: %v", err) + } + if info.Action != RuleActionDeny { + t.Fatalf("动作应为 deny, 实际 %s", info.Action) + } + }) + + t.Run("非法模块名报错", func(t *testing.T) { + ruleText := `rule Rabc123 "x" salience 10 { + when + MF.URL.Contains("/x") == true + then + RF.Allow("NOT_A_MODULE"); +}` + _, err := ExtractRuleActionForCheck(ruleText) + if err == nil { + t.Fatal("非法模块名应该报错") + } + if !strings.Contains(err.Error(), "NOT_A_MODULE") { + t.Fatalf("错误信息应包含非法模块名: %v", err) + } + }) + + t.Run("注释掉的动作不影响校验", func(t *testing.T) { + ruleText := `rule Rabc123 "x" salience 10 { + when + MF.URL.Contains("/x") == true + then + // RF.Allow(); + RF.Deny(); +}` + info, err := ExtractRuleActionForCheck(ruleText) + if err != nil { + t.Fatalf("不应报错: %v", err) + } + if info.Action != RuleActionDeny { + t.Fatalf("动作应为 deny, 实际 %s", info.Action) + } + }) +} + +func TestCountRuleBlocks(t *testing.T) { + //一条规则内容里塞两条规则,第二条偷偷放行——保存时必须能识别出来 + ruleText := `rule Rabc123 "正常规则" salience 10 { + when + MF.URL.Contains("/admin") == true + then + RF.Deny(); +} +rule Revil "偷偷放行" salience 999 { + when + MF.SRC_IP == "6.6.6.6" + then + RF.AllowAll(); +}` + if got := CountRuleBlocks(ruleText); got != 2 { + t.Fatalf("应识别出 2 条规则, 实际 %d", got) + } + + //字符串里写 rule 不算规则 + single := `rule Rabc123 "x" salience 10 { + when + MF.URL.Contains("rule Revil") == true + then + RF.Deny(); +}` + if got := CountRuleBlocks(single); got != 1 { + t.Fatalf("字符串里的 rule 不该被算成规则, 实际 %d", got) + } +} diff --git a/utils/rulehelper.go b/utils/rulehelper.go index ca1d314..9619990 100644 --- a/utils/rulehelper.go +++ b/utils/rulehelper.go @@ -20,30 +20,59 @@ type RuleHelper struct { KnowledgeBase *ast.KnowledgeBase knowledgeLibrary *ast.KnowledgeLibrary ruleBuilder *builder.RuleBuilder + // 规则动作表 key为规则名(含R前缀,与 ast.RuleEntry.RuleName 一致) + // 只在规则加载阶段写入,加载完成后只读;规则变更走 copy-on-write 整体换掉 RuleHelper,因此无需加锁 + ruleActions map[string]RuleActionInfo } func (rulehelper *RuleHelper) InitRuleEngine() { rulehelper.knowledgeLibrary = ast.NewKnowledgeLibrary() rulehelper.ruleBuilder = builder.NewRuleBuilder(rulehelper.knowledgeLibrary) rulehelper.engine = engine.NewGruleEngine() + rulehelper.ruleActions = make(map[string]RuleActionInfo) } + +// GetRuleAction 获取规则的动作,没有声明动作的规则默认为拦截 +func (rulehelper *RuleHelper) GetRuleAction(ruleName string) RuleActionInfo { + if rulehelper.ruleActions != nil { + if info, ok := rulehelper.ruleActions[ruleName]; ok && info.Action != "" { + return info + } + } + return RuleActionInfo{Action: RuleActionDeny} +} + +// mergeRuleActions 把规则文本里解析出的动作并入动作表 +func (rulehelper *RuleHelper) mergeRuleActions(ruleContent string) { + if rulehelper.ruleActions == nil { + rulehelper.ruleActions = make(map[string]RuleActionInfo) + } + for name, info := range ExtractRuleActions(ruleContent) { + rulehelper.ruleActions[name] = info + } +} + func (rulehelper *RuleHelper) LoadRuleString(ruleContent string) error { + rulehelper.ruleActions = make(map[string]RuleActionInfo) byteArr := pkg.NewBytesResource([]byte(ruleContent)) err := rulehelper.ruleBuilder.BuildRuleFromResource("Region", "0.0.1", byteArr) if err != nil { zlog.Error("LoadRule", err) } + rulehelper.mergeRuleActions(ruleContent) rulehelper.KnowledgeBase, err = rulehelper.knowledgeLibrary.NewKnowledgeBaseInstance("Region", "0.0.1") return err } func (rulehelper *RuleHelper) LoadRule(ruleconfig model.Rules) error { + rulehelper.ruleActions = make(map[string]RuleActionInfo) byteArr := pkg.NewBytesResource([]byte(ruleconfig.RuleContent)) err := rulehelper.ruleBuilder.BuildRuleFromResource("Region", "0.0.1", byteArr) if err != nil { zlog.Error("LoadRule", err) } + rulehelper.mergeRuleActions(ruleconfig.RuleContent) rulehelper.KnowledgeBase, err = rulehelper.knowledgeLibrary.NewKnowledgeBaseInstance("Region", "0.0.1") return err } @@ -62,19 +91,34 @@ func (rulehelper *RuleHelper) LoadRules(ruleconfig []model.Rules) (string, error rulehelper.knowledgeLibrary.RemoveRuleEntry(ruleKey, value.Name, value.Version) } } - rulestr := "" - for _, v := range ruleconfig { - if v.RuleStatus == 1 { - rulestr = rulestr + v.RuleContent + " \n" - } + rulehelper.ruleActions = make(map[string]RuleActionInfo) + + //先建一个空的资源,保证即使一条规则都没有(或全部编译失败),KnowledgeBase 依然存在 + if err := rulehelper.ruleBuilder.BuildRuleFromResource("Region", "0.0.1", pkg.NewBytesResource([]byte(""))); err != nil { + zlog.Error("LoadRules 初始化规则库失败", err) } - byteArr := pkg.NewBytesResource([]byte(rulestr)) - err := rulehelper.ruleBuilder.BuildRuleFromResource("Region", "0.0.1", byteArr) + + rulestr := "" + //逐条编译:单条规则语法有问题只跳过这一条,不能把整个网站的规则都带崩(否则等于防护被清零) + for _, v := range ruleconfig { + if v.RuleStatus != 1 { + continue + } + byteArr := pkg.NewBytesResource([]byte(v.RuleContent)) + if err := rulehelper.ruleBuilder.BuildRuleFromResource("Region", "0.0.1", byteArr); err != nil { + //不打印规则内容,规则里可能带着攻击载荷或敏感值 + zlog.Error("LoadRules 规则编译失败,已跳过该条规则", "ruleCode", v.RuleCode, "ruleName", v.RuleName, "err", err.Error()) + continue + } + rulehelper.mergeRuleActions(v.RuleContent) + rulestr = rulestr + v.RuleContent + " \n" + } + + knowledgeBase, err := rulehelper.knowledgeLibrary.NewKnowledgeBaseInstance("Region", "0.0.1") if err != nil { zlog.Error("LoadRules", err) } - - rulehelper.KnowledgeBase, err = rulehelper.knowledgeLibrary.NewKnowledgeBaseInstance("Region", "0.0.1") + rulehelper.KnowledgeBase = knowledgeBase return rulestr, err } diff --git a/wafenginecore/checkrule.go b/wafenginecore/checkrule.go index f7f344f..0c6a354 100644 --- a/wafenginecore/checkrule.go +++ b/wafenginecore/checkrule.go @@ -6,10 +6,13 @@ import ( "SamWaf/innerbean" "SamWaf/model/detection" "SamWaf/model/wafenginmodel" + "SamWaf/utils" "net/http" "net/url" "regexp" "strconv" + + "github.com/hyperjumptech/grule-rule-engine/ast" ) // parseIPFailureThreshold 从规则文本中解析IP失败阈值信息 @@ -17,8 +20,9 @@ import ( // 返回: minutes, count, 是否找到 func parseIPFailureThreshold(ruleText string) (int64, int64, bool) { // 匹配 GetIPFailureCount(数字) > 数字 或 GetIPFailureCount(数字) >= 数字 的模式 + // 在骨架上匹配,避免把字符串字面量里的内容当成真的条件 re := regexp.MustCompile(`GetIPFailureCount\s*\(\s*(\d+)\s*\)\s*[>=]+\s*(\d+)`) - matches := re.FindStringSubmatch(ruleText) + matches := re.FindStringSubmatch(utils.BuildGrlSkeleton(ruleText)) if len(matches) >= 3 { minutes, err1 := strconv.ParseInt(matches[1], 10, 64) count, err2 := strconv.ParseInt(matches[2], 10, 64) @@ -29,6 +33,90 @@ func parseIPFailureThreshold(ruleText string) (int64, int64, bool) { return 0, 0, false } +// pickRuleAction 从命中的规则里挑出最终生效的动作 +// grule 的 FetchMatchingRules 已按 salience 降序返回,所以优先级最高的就是第一条。 +// 但同 salience 的规则之间顺序是不确定的(来自 map 遍历),所以在最高优先级这一档里 +// 按 拦截 > 放行 > 仅记录 取,保证结果稳定且偏安全。 +// 多条放行规则的跳过模块取并集。 +func pickRuleAction(ruleHelper *utils.RuleHelper, ruleMatchs []*ast.RuleEntry) utils.RuleActionInfo { + if len(ruleMatchs) == 0 { + return utils.RuleActionInfo{Action: utils.RuleActionDeny} + } + + topSalience := ruleMatchs[0].Salience + final := utils.RuleActionInfo{Action: ""} + skipSet := make(map[string]bool) + + rank := map[string]int{ + utils.RuleActionDeny: 3, + utils.RuleActionAllow: 2, + utils.RuleActionLog: 1, + } + + for _, v := range ruleMatchs { + if v.Salience != topSalience { + break + } + info := ruleHelper.GetRuleAction(v.RuleName) + if info.Action == utils.RuleActionAllow { + for _, m := range info.SkipModules { + skipSet[m] = true + } + } + if final.Action == "" || rank[info.Action] > rank[final.Action] { + final.Action = info.Action + } + } + if final.Action == "" { + final.Action = utils.RuleActionDeny + } + if final.Action == utils.RuleActionAllow { + for m := range skipSet { + final.SkipModules = append(final.SkipModules, m) + } + } + return final +} + +// ruleMatchResult 单侧(局部/全局)规则的命中结果 +type ruleMatchResult struct { + Matched bool + Action utils.RuleActionInfo + Title string +} + +// matchRules 执行一组规则并解析出动作 +func matchRules(ruleHelper *utils.RuleHelper, weblogbean *innerbean.WebLog, titlePrefix string) ruleMatchResult { + out := ruleMatchResult{} + if ruleHelper == nil || ruleHelper.KnowledgeBase == nil { + return out + } + ruleMatchs, err := ruleHelper.Match("MF", weblogbean) + if err != nil { + zlog.Debug("规则 ", err) + return out + } + if len(ruleMatchs) == 0 { + return out + } + + rulestr := "" + for _, v := range ruleMatchs { + rulestr = rulestr + v.RuleDescription + "," + + minutes, count, found := parseIPFailureThreshold(v.GrlText) + if found { + // 记录阈值信息 + weblogbean.RecordIPFailureThreshold(minutes, count) + } + } + + out.Matched = true + out.Action = pickRuleAction(ruleHelper, ruleMatchs) + out.Title = titlePrefix + rulestr + return out +} + /* * 检测rule @@ -40,68 +128,95 @@ func (waf *WafEngine) CheckRule(r *http.Request, weblogbean *innerbean.WebLog, f Title: "", Content: "", } + + logTitle := "" + allowTitle := "" + skipSet := make(map[string]bool) + //规则判断 (局部) if hostTarget.Rule != nil { - if hostTarget.Rule.KnowledgeBase != nil { - ruleMatchs, err := hostTarget.Rule.Match("MF", weblogbean) - if err == nil { - if len(ruleMatchs) > 0 { - rulestr := "" - for _, v := range ruleMatchs { - rulestr = rulestr + v.RuleDescription + "," - - minutes, count, found := parseIPFailureThreshold(v.GrlText) - if found { - // 记录阈值信息 - weblogbean.RecordIPFailureThreshold(minutes, count) - } - - } - weblogbean.RISK_LEVEL = 1 - - result.IsBlock = true - result.Title = rulestr - result.Content = "您的访问被阻止触发规则" + 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 } - } else { - zlog.Debug("规则 ", err) + case utils.RuleActionLog: + weblogbean.RISK_LEVEL = 1 + logTitle = localResult.Title } } } - //规则判断 (全局网站) - if waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME].Host.GUARD_STATUS == 1 && waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME].Rule != nil { - if waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME].Rule.KnowledgeBase != nil { - ruleMatchs, err := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME].Rule.Match("MF", weblogbean) - if err == nil { - if len(ruleMatchs) > 0 { - rulestr := "" - for _, v := range ruleMatchs { - rulestr = rulestr + v.RuleDescription + "," - } - // 尝试从规则数据中解析阈值信息 - // 遍历规则数据,查找包含 GetIPFailureCount 的规则 - for _, ruleData := range waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME].RuleData { - if ruleData.RuleContent != "" { - minutes, count, found := parseIPFailureThreshold(ruleData.RuleContent) - if found { - // 记录阈值信息 - weblogbean.RecordIPFailureThreshold(minutes, count) - break // 找到第一个匹配的规则即可 - } - } - } - weblogbean.RISK_LEVEL = 1 - result.IsBlock = true - result.Title = "【全局】" + rulestr - result.Content = "您的访问被阻止触发规则" + //规则判断 (全局网站) + 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 } - } else { - zlog.Debug("规则 ", err) + case utils.RuleActionLog: + weblogbean.RISK_LEVEL = 1 + if logTitle == "" { + logTitle = globalResult.Title + } else { + logTitle = logTitle + globalResult.Title + } } } } + + //放行优先于仅记录 + if allowTitle != "" { + result.IsRuleAllow = true + result.Title = allowTitle + for m := range skipSet { + result.SkipModules = append(result.SkipModules, m) + } + return result + } + if logTitle != "" { + result.IsLogOnly = true + result.Title = logTitle + return result + } return result } diff --git a/wafenginecore/checkrule_action_test.go b/wafenginecore/checkrule_action_test.go new file mode 100644 index 0000000..84b4e58 --- /dev/null +++ b/wafenginecore/checkrule_action_test.go @@ -0,0 +1,120 @@ +package wafenginecore + +import ( + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/utils" + "testing" +) + +// buildRuleHelper 用一段 GRL 文本构建一个可用的规则引擎 +func buildRuleHelper(t *testing.T, ruleContent string) *utils.RuleHelper { + t.Helper() + rh := &utils.RuleHelper{} + rh.InitRuleEngine() + rule := model.Rules{ + RuleCode: "Rtest001", + RuleName: "test", + RuleContent: ruleContent, + RuleStatus: 1, + } + if _, err := rh.LoadRules([]model.Rules{rule}); err != nil { + t.Fatalf("加载规则失败: %v", err) + } + return rh +} + +// TestPickRuleAction 验证命中后动作的选取逻辑(含 salience 优先级和同级冲突兜底) +func TestPickRuleAction_ThroughEngine(t *testing.T) { + t.Run("deny默认", func(t *testing.T) { + rh := buildRuleHelper(t, ` +rule Rtest001 "拦截" salience 10 { + when MF.URL == "/admin" + then RF.Deny(); +}`) + matchs, _ := rh.Match("MF", &innerbean.WebLog{URL: "/admin"}) + if len(matchs) == 0 { + t.Fatal("应命中规则") + } + info := pickRuleAction(rh, matchs) + if info.Action != utils.RuleActionDeny { + t.Fatalf("动作应为 deny, 实际 %s", info.Action) + } + }) + + t.Run("allow跳过模块", func(t *testing.T) { + rh := buildRuleHelper(t, ` +rule Rtest001 "放行" salience 10 { + when MF.SRC_IP == "1.2.3.4" + then RF.Allow("CC", "AI"); +}`) + matchs, _ := rh.Match("MF", &innerbean.WebLog{SRC_IP: "1.2.3.4"}) + info := pickRuleAction(rh, matchs) + if info.Action != utils.RuleActionAllow { + t.Fatalf("动作应为 allow, 实际 %s", info.Action) + } + if len(info.SkipModules) != 2 { + t.Fatalf("应跳过2个模块, 实际 %v", info.SkipModules) + } + }) + + t.Run("log", func(t *testing.T) { + rh := buildRuleHelper(t, ` +rule Rtest001 "记录" salience 10 { + when MF.METHOD == "PUT" + then RF.Log(); +}`) + matchs, _ := rh.Match("MF", &innerbean.WebLog{METHOD: "PUT"}) + info := pickRuleAction(rh, matchs) + if info.Action != utils.RuleActionLog { + t.Fatalf("动作应为 log, 实际 %s", info.Action) + } + }) + + t.Run("老规则无动作标记默认deny", func(t *testing.T) { + rh := buildRuleHelper(t, ` +rule Rtest001 "老规则" salience 10 { + when MF.URL == "/old" + then Retract("Rtest001"); +}`) + matchs, _ := rh.Match("MF", &innerbean.WebLog{URL: "/old"}) + info := pickRuleAction(rh, matchs) + if info.Action != utils.RuleActionDeny { + t.Fatalf("老规则应默认 deny, 实际 %s", info.Action) + } + }) +} + +// TestPickRuleAction_SalienceConflict 同优先级冲突时按 deny > allow > log 兜底 +func TestPickRuleAction_SalienceConflict(t *testing.T) { + rh := buildRuleHelper(t, ` +rule Rallow "放行" salience 10 { + when MF.URL == "/x" + then RF.Allow(); +} +rule Rdeny "拦截" salience 10 { + when MF.URL == "/x" + then RF.Deny(); +}`) + matchs, _ := rh.Match("MF", &innerbean.WebLog{URL: "/x"}) + if len(matchs) != 2 { + t.Fatalf("应命中2条规则, 实际 %d", len(matchs)) + } + info := pickRuleAction(rh, matchs) + // 同 salience 一放行一拦截,安全起见取拦截 + if info.Action != utils.RuleActionDeny { + t.Fatalf("同级冲突应偏向拦截, 实际 %s", info.Action) + } +} + +// TestParseIPFailureThreshold_NotFooledByString 阈值解析不被字符串字面量干扰 +func TestParseIPFailureThreshold_NotFooledByString(t *testing.T) { + // 真实条件 + if _, _, ok := parseIPFailureThreshold(`when MF.GetIPFailureCount(5) > 10 then`); !ok { + t.Fatal("正常条件应能解析出阈值") + } + // 藏在字符串里的假条件不该被解析 + if _, _, ok := parseIPFailureThreshold(`when MF.URL.Contains("GetIPFailureCount(5) > 10") == true then`); ok { + t.Fatal("字符串里的假条件不该被解析成阈值") + } +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index fc9ad71..8b00d19 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -479,9 +479,36 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.Header.Add("waf_req_uuid", weblogbean.REQ_UUID) if hostTarget.Host.GUARD_STATUS == 1 { + //自定义规则放行动作的结果:ruleSkipAll 跳过后续所有检测,ruleSkipModules 跳过指定检测 + ruleSkipAll := false + ruleSkipModules := map[string]bool{} + //是否要跳过某个检测模块 + ruleSkip := func(module string) bool { + return ruleSkipAll || ruleSkipModules[module] + } //一系列检测逻辑 handleBlock := func(checkFunc func(*http.Request, *innerbean.WebLog, url.Values, *wafenginmodel.HostSafe, *wafenginmodel.HostSafe) detection.Result) bool { detectionResult := checkFunc(r, &weblogbean, formValues, hostTarget, waf.rt().HostTarget[waf.rt().HostCode[global.GWAF_GLOBAL_HOST_CODE]]) + //自定义规则放行:不拦截,按需跳过后续检测 + if detectionResult.IsRuleAllow { + if detectionResult.JumpGuardResult { + ruleSkipAll = true + } + for _, m := range detectionResult.SkipModules { + if m == utils.RuleSkipAll { + ruleSkipAll = true + } else { + ruleSkipModules[m] = true + } + } + weblogbean.RULE = "自定义规则放行:" + detectionResult.Title + return false + } + //自定义规则仅记录:记录命中信息,继续走后续检测 + if detectionResult.IsLogOnly { + weblogbean.RULE = "自定义规则记录:" + detectionResult.Title + return false + } if detectionResult.IsBlock { if hostTarget.Host.LogOnlyMode == 1 { // 仅记录模式:记录攻击日志但不阻断请求 @@ -532,88 +559,108 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { } hostDefense := model.ParseHostsDefense(hostTarget.Host.DEFENSE_JSON) + + //规则判断(规则优先编排:排在黑名单之后、其余检测之前,此时规则的放行动作才能跳过后面的检测) + ranRuleCheck := false + if global.GCONFIG_RULE_CHAIN_MODE == 1 { + if handleBlock(waf.CheckRule) { + return + } + ranRuleCheck = true + } + //检测爬虫bot - if hostDefense.DEFENSE_BOT == 1 { + if hostDefense.DEFENSE_BOT == 1 && !ruleSkip("BOT") { if handleBlock(waf.CheckBot) { return } } //检测sqli - if hostDefense.DEFENSE_SQLI == 1 { + if hostDefense.DEFENSE_SQLI == 1 && !ruleSkip("SQLI") { if handleBlock(waf.CheckSql) { return } } //检测xss - if hostDefense.DEFENSE_XSS == 1 { + if hostDefense.DEFENSE_XSS == 1 && !ruleSkip("XSS") { if handleBlock(waf.CheckXss) { return } } //检测扫描工具 - if hostDefense.DEFENSE_SCAN == 1 { + if hostDefense.DEFENSE_SCAN == 1 && !ruleSkip("SCAN") { if handleBlock(waf.CheckSan) { return } } //检测RCE - if hostDefense.DEFENSE_RCE == 1 { + if hostDefense.DEFENSE_RCE == 1 && !ruleSkip("RCE") { if handleBlock(waf.CheckRce) { return } } //目录穿越检测 - if hostDefense.DEFENSE_DIR_TRAVERSAL == 1 { + if hostDefense.DEFENSE_DIR_TRAVERSAL == 1 && !ruleSkip("DIR") { if handleBlock(waf.CheckDirTraversal) { return } } //检测CC - if handleBlock(waf.CheckCC) { - return + if !ruleSkip("CC") { + if handleBlock(waf.CheckCC) { + return + } } - //规则判断 - if handleBlock(waf.CheckRule) { - return + //规则判断(默认编排:排在CC之后) + if !ranRuleCheck { + if handleBlock(waf.CheckRule) { + return + } } //AI智能检测(全局开关开启且站点开启,规则抓确定的,AI 抓漏网的) - if global.GCONFIG_AI_ENABLE == 1 && hostDefense.DEFENSE_AI == 1 { + if global.GCONFIG_AI_ENABLE == 1 && hostDefense.DEFENSE_AI == 1 && !ruleSkip("AI") { if handleBlock(waf.CheckAI) { return } } //检测敏感词 - if hostDefense.DEFENSE_SENSITIVE == 1 { + if hostDefense.DEFENSE_SENSITIVE == 1 && !ruleSkip("SENSITIVE") { if handleBlock(waf.CheckSensitive) { return } } //检测OWASP(全局开关或站点级 owaspset 启用时才进入) - if global.GCONFIG_RECORD_ENABLE_OWASP == 1 || hostDefense.DEFENSE_OWASP_SET == 1 { + if (global.GCONFIG_RECORD_ENABLE_OWASP == 1 || hostDefense.DEFENSE_OWASP_SET == 1) && !ruleSkip("OWASP") { if handleBlock(waf.CheckOwasp) { return } } // 添加防盗链检查 - if handleBlock(waf.CheckAntiLeech) { - return + if !ruleSkip("ANTILEECH") { + if handleBlock(waf.CheckAntiLeech) { + return + } } // CSRF 跨站请求伪造防护 - if handleBlock(waf.CheckCsrf) { - return + if !ruleSkip("CSRF") { + if handleBlock(waf.CheckCsrf) { + return + } } // 文件上传内容检测(multipart 上传的扩展名/Webshell/类型/大小) - if handleBlock(waf.CheckUpload) { - return + if !ruleSkip("UPLOAD") { + if handleBlock(waf.CheckUpload) { + return + } } // 验证码检测 captchaConfig := model.ParseCaptchaConfig(hostTarget.Host.CaptchaJSON) - if captchaConfig.IsEnableCaptcha == 1 { + if captchaConfig.IsEnableCaptcha == 1 && !ruleSkip("CAPTCHA") { if !waf.checkCaptchaToken(r, weblogbean, captchaConfig, hostTarget.Host.IPMode) { // 检查当前URL是否在排除列表中 currentURL := strings.ToLower(r.URL.Path) diff --git a/waftask/task_config.go b/waftask/task_config.go index 7aa0a9b..4b232fa 100644 --- a/waftask/task_config.go +++ b/waftask/task_config.go @@ -80,6 +80,12 @@ func setConfigIntValue(name string, value int64, change int) { case "ai_enable": global.GCONFIG_AI_ENABLE = value break + case "rule_chain_mode": + if value != 1 { + value = 0 + } + global.GCONFIG_RULE_CHAIN_MODE = value + break case "owasp_block_threshold": if value <= 0 { value = 7 @@ -424,6 +430,7 @@ func TaskLoadSetting(initLoad bool) { updateConfigIntItem(initLoad, "system", "enable_owasp", global.GCONFIG_RECORD_ENABLE_OWASP, "启动OWASP数据检测(1启动 0关闭)", "int", "", configMap) updateConfigIntItem(initLoad, "system", "ai_enable", global.GCONFIG_AI_ENABLE, "启动AI智能检测总开关(1启动 0关闭,需先在AI模型管理上传模型包并在站点开启)", "int", "", configMap) updateConfigStringItem(initLoad, "system", "ai_mode", global.GCONFIG_AI_MODE, "AI检测工作模式:observe(仅记录/观察) block(达拦截阈值则拦截)", "options", "observe|仅记录,block|拦截", configMap) + updateConfigIntItem(initLoad, "system", "rule_chain_mode", global.GCONFIG_RULE_CHAIN_MODE, "自定义规则在检测链中的位置(0默认:排在CC之后;1规则优先:排在黑名单之后、Bot/SQLI/XSS等之前,此时规则的放行动作才能跳过这些检测)", "int", "", configMap) updateConfigIntItem(initLoad, "ssl", "enable_http_80", global.GCONFIG_RECORD_ENABLE_HTTP_80, "启动80端口服务(为自动申请证书使用 HTTP文件验证类型需要,DNS验证不需要)", "int", "", configMap) updateConfigIntItem(initLoad, "ssl", "sslorder_expire_day", global.GCONFIG_RECORD_SSLOrder_EXPIRE_DAY, "自动续期检测小于多少天开始发起自动申请 默认30天", "int", "", configMap)