From f457f31eeed9bb88aeb9d40a99505ff548e5fcef Mon Sep 17 00:00:00 2001 From: samwaf Date: Wed, 22 Apr 2026 15:55:58 +0800 Subject: [PATCH] feat: owaspruleset #546 --- api/waf_owasp.go | 101 +++++++ .../owasp/samwaf/after/99-samwaf-custom.conf | 61 +++++ .../samwaf/before/00-samwaf-exclusions.conf | 60 +++++ .../samwaf/before/01-samwaf-init-vars.conf | 96 +++++++ cmd/tools/pack_owasp/main.go | 248 ++++++++++++++++++ router/waf_owasp.go | 9 + wafowasp/hit_stats.go | 154 +++++++++++ wafowasp/initowasp.go | 12 + wafowasp/manager.go | 101 +++++-- wafowasp/overrides.go | 218 +++++++++++++-- wafowasp/upgrader.go | 52 +++- wafowasp/usage_doc.go | 38 ++- 12 files changed, 1095 insertions(+), 55 deletions(-) create mode 100644 cmd/samwaf/exedata/owasp/samwaf/after/99-samwaf-custom.conf create mode 100644 cmd/samwaf/exedata/owasp/samwaf/before/00-samwaf-exclusions.conf create mode 100644 cmd/samwaf/exedata/owasp/samwaf/before/01-samwaf-init-vars.conf create mode 100644 cmd/tools/pack_owasp/main.go create mode 100644 wafowasp/hit_stats.go diff --git a/api/waf_owasp.go b/api/waf_owasp.go index 4bdbf9e..d7b4521 100644 --- a/api/waf_owasp.go +++ b/api/waf_owasp.go @@ -405,6 +405,78 @@ func (w *WafOwaspApi) CRSVarDeleteApi(c *gin.Context) { response.OkWithMessage("CRS 变量已删除并热重载", c) } +// BaseConfigGetApi 读取 Layer 1 基线配置(samwaf_base.json)。 +func (w *WafOwaspApi) BaseConfigGetApi(c *gin.Context) { + m := w.manager(c) + if m == nil { + return + } + cfg, err := m.Overrides().GetBaseConfig() + if err != nil { + response.FailWithMessage("读取失败: "+err.Error(), c) + return + } + response.OkWithDetailed(cfg, "获取成功", c) +} + +// BaseConfigSetApi 写入 Layer 1 基线配置并热重载。 +// Layer 1 仅包含 CRS tx.* 数值变量;SecRuleEngine 和 CustomVars 不在此管理。 +func (w *WafOwaspApi) BaseConfigSetApi(c *gin.Context) { + m := w.manager(c) + if m == nil { + return + } + raw := map[string]interface{}{} + if err := c.ShouldBindJSON(&raw); err != nil { + response.FailWithMessage("解析失败: "+err.Error(), c) + return + } + pickInt := func(keys ...string) int { + for _, k := range keys { + if v, ok := raw[k]; ok && v != nil { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case string: + if x, err := strconv.Atoi(n); err == nil { + return x + } + } + } + } + return 0 + } + cfg := wafowasp.BaseConfig{ + BlockingParanoia: pickInt("blocking_paranoia_level", "blocking_paranoia"), + DetectionParanoia: pickInt("detection_paranoia_level", "detection_paranoia"), + InboundThreshold: pickInt("inbound_anomaly_score_threshold", "inbound_threshold"), + OutboundThreshold: pickInt("outbound_anomaly_score_threshold", "outbound_threshold"), + EarlyBlocking: pickInt("early_blocking"), + EnforceBodyProcessor: pickInt("enforce_bodyproc_urlencoded", "enforce_body_processor"), + } + if cfg.BlockingParanoia < 1 || cfg.BlockingParanoia > 4 { + response.FailWithMessage("blocking_paranoia_level 必须在 1..4", c) + return + } + if cfg.DetectionParanoia < cfg.BlockingParanoia || cfg.DetectionParanoia > 4 { + response.FailWithMessage("detection_paranoia_level 必须 >= blocking_paranoia_level 且 <= 4", c) + return + } + if cfg.InboundThreshold <= 0 { + cfg.InboundThreshold = 7 + } + if cfg.OutboundThreshold <= 0 { + cfg.OutboundThreshold = 4 + } + if err := m.ApplyBaseConfig(cfg); err != nil { + response.FailWithMessage("应用失败: "+err.Error(), c) + return + } + response.OkWithMessage("Layer 1 基线已更新并热重载", c) +} + // TuningGetApi 读取当前 tuning。 func (w *WafOwaspApi) TuningGetApi(c *gin.Context) { m := w.manager(c) @@ -800,3 +872,32 @@ func buildDryRunResp(tx types.Transaction, it *types.Interruption) DryRunResp { } return resp } + +// HitStatsApi 返回运行期间规则命中 Top-N 统计(内存数据,重启清零)。 +// +// Query params: +// +// limit int 最多返回条数,默认 50,最大 500 +// mode string 排序维度:all(默认,按总次数) / blocked / detected +func (w *WafOwaspApi) HitStatsApi(c *gin.Context) { + limit := 50 + if v, err := strconv.Atoi(c.DefaultQuery("limit", "50")); err == nil && v > 0 { + limit = v + } + if limit > 500 { + limit = 500 + } + mode := c.DefaultQuery("mode", "all") + + list := wafowasp.GlobalHitStats.TopN(limit, mode) + response.OkWithDetailed(gin.H{ + "list": list, + "rule_count": wafowasp.GlobalHitStats.TotalRuleCount(), + }, "获取成功", c) +} + +// HitStatsResetApi 清空内存中的规则命中统计。 +func (w *WafOwaspApi) HitStatsResetApi(c *gin.Context) { + wafowasp.GlobalHitStats.Reset() + response.OkWithMessage("命中统计已清空", c) +} diff --git a/cmd/samwaf/exedata/owasp/samwaf/after/99-samwaf-custom.conf b/cmd/samwaf/exedata/owasp/samwaf/after/99-samwaf-custom.conf new file mode 100644 index 0000000..5631bb6 --- /dev/null +++ b/cmd/samwaf/exedata/owasp/samwaf/after/99-samwaf-custom.conf @@ -0,0 +1,61 @@ +# ============================================================================= +# SamWaf Layer 1 — After-CRS 钩子示例 +# 文件位置:data/owasp/samwaf/after/ +# ============================================================================= +# +# 【用途】 +# 本目录下的 .conf 文件在 CRS rules/*.conf 之后、用户 overrides 之前加载, +# 适合放置需要"在 CRS 评分完成后"再做处理的指令: +# - 基于累积评分的自定义动作(如评分超过某阈值时记录日志) +# - SamWaf 特有的补丁规则(修复 CRS 漏报) +# - 对响应体做额外检测 +# +# 【加载顺序(完整)】 +# coraza.conf +# → coreruleset/crs-setup.conf +# → overrides/00-samwaf-base.conf (Layer 1 数值变量) +# → overrides/05-user-vars.conf (Layer 2 用户变量) +# → samwaf/before/*.conf (Layer 1 前置,见 before/ 目录) +# → coreruleset/rules/*.conf (CRS 官方规则) +# → samwaf/after/*.conf ← 本目录,此处 +# → overrides/10-disabled-rules.conf (用户禁用) +# → overrides/20-custom-rules.conf (用户改写) +# +# 【注意事项】 +# 1. 本目录由 SamWaf 开发团队管理,随 SamWaf 版本发布更新(非用户层)。 +# 2. 在此处使用 SecRuleRemoveById 无效(CRS 规则已执行完毕); +# 需要禁用 CRS 规则请放到 before/ 目录或使用 SecRuleRemoveTargetById。 +# 3. .conf.example 文件不会被加载;将其复制为 .conf 后才生效。 +# +# 【写法示例】 +# +# ID 使用 991100-991199 段(Layer 1 after-hooks 专用) +# +# -- 示例 1:SamWaf 补充检测——检测 Log4Shell(额外保险层)-- +# +# SecRule REQUEST_HEADERS|ARGS|REQUEST_BODY \ +# "@rx (?i)\$\{.*j.*n.*d.*i.*:" \ +# "id:991100,\ +# phase:2,\ +# deny,\ +# status:403,\ +# log,\ +# auditlog,\ +# msg:'SamWaf: Log4Shell attempt',\ +# severity:'CRITICAL',\ +# tag:'SamWaf_layer1',\ +# tag:'attack-injection'" +# +# +# -- 示例 2:在评分超过阈值但未触发 block 时记录警告日志 -- +# +# SecRule TX:ANOMALY_SCORE "@ge 3" \ +# "id:991101,\ +# phase:5,\ +# pass,\ +# log,\ +# noauditlog,\ +# msg:'SamWaf: elevated anomaly score %{tx.anomaly_score}',\ +# tag:'SamWaf_layer1'" +# +# ============================================================================= diff --git a/cmd/samwaf/exedata/owasp/samwaf/before/00-samwaf-exclusions.conf b/cmd/samwaf/exedata/owasp/samwaf/before/00-samwaf-exclusions.conf new file mode 100644 index 0000000..ce4737f --- /dev/null +++ b/cmd/samwaf/exedata/owasp/samwaf/before/00-samwaf-exclusions.conf @@ -0,0 +1,60 @@ +# ============================================================================= +# SamWaf Layer 1 — Before-CRS 钩子示例 +# 文件位置:data/owasp/samwaf/before/ +# ============================================================================= +# +# 【用途】 +# 本目录下的 .conf 文件在 CRS rules/*.conf 之前加载, +# 适合放置需要"抢在 CRS 规则执行前"生效的指令: +# - 已知误报的全局排除(SecRuleRemoveTargetById) +# - 白名单 IP / User-Agent +# - 为特定路径提前 setvar +# +# 【加载顺序(完整)】 +# coraza.conf +# → coreruleset/crs-setup.conf +# → overrides/00-samwaf-base.conf (Layer 1 数值变量,由 samwaf_base.json 驱动) +# → overrides/05-user-vars.conf (Layer 2 用户变量,优先级最高) +# → samwaf/before/*.conf ← 本目录,此处 +# → coreruleset/rules/*.conf (CRS 官方规则) +# → samwaf/after/*.conf (Layer 1 后置,见 after/ 目录) +# → overrides/10-disabled-rules.conf (用户禁用) +# → overrides/20-custom-rules.conf (用户改写) +# +# 【注意事项】 +# 1. 本目录由 SamWaf 开发团队管理,随 SamWaf 版本发布更新(非用户层)。 +# 2. 用户自定义排除请使用 overrides/ 目录(通过后台界面操作), +# 这样升级不会丢失用户设置。 +# 3. .conf.example 文件不会被加载;将其复制为 .conf 后才生效。 +# +# 【写法示例】 +# +# -- 示例 1:对某个规则 ID 排除特定请求参数(全局误报修复)-- +# +# # 排除 SQL 注入规则对 "search" 参数的检测(已知该参数允许含 SQL 关键字) +# SecRuleRemoveTargetById 942100 "ARGS:search" +# +# +# -- 示例 2:对某个 URL 路径完全跳过 CRS 检测 -- +# ID 使用 991050-991099 段(Layer 1 before 自定义排除专用,避免与 01-samwaf-init-vars.conf 的 991001-991049 冲突) +# +# SecRule REQUEST_URI "@beginsWith /api/webhook/" \ +# "id:991050,\ +# phase:1,\ +# pass,\ +# nolog,\ +# t:none,\ +# ctl:ruleEngine=DetectionOnly" +# +# +# -- 示例 3:白名单内网 IP,跳过所有 CRS 规则 -- +# +# SecRule REMOTE_ADDR "@ipMatch 192.168.0.0/16,10.0.0.0/8" \ +# "id:991051,\ +# phase:1,\ +# pass,\ +# nolog,\ +# t:none,\ +# ctl:ruleEngine=Off" +# +# ============================================================================= diff --git a/cmd/samwaf/exedata/owasp/samwaf/before/01-samwaf-init-vars.conf b/cmd/samwaf/exedata/owasp/samwaf/before/01-samwaf-init-vars.conf new file mode 100644 index 0000000..f0e8981 --- /dev/null +++ b/cmd/samwaf/exedata/owasp/samwaf/before/01-samwaf-init-vars.conf @@ -0,0 +1,96 @@ +# ============================================================================= +# SamWaf Layer 1 — CRS 字符串/列表型变量初始化 +# 文件:data/owasp/samwaf/before/01-samwaf-init-vars.conf +# 由 SamWaf 随版本发布,本文件会在 SamWaf 升级时被覆盖。 +# 用户如需覆盖,请通过 后台 → OWASP 规则管理 → 全局调参 → 自定义 CRS 变量 操作, +# 系统会将覆盖值写入 overrides/05-user-vars.conf(永不被升级覆盖)。 +# +# 【设计原则】 +# 本文件采用"宽松默认值"策略,覆盖大多数现代 Web / REST API 场景。 +# 若业务需要更严格的限制,可通过后台覆盖对应变量,无需修改本文件。 +# +# 加载顺序: +# 00-samwaf-base.conf (990001-990009) ← 数值型变量(paranoia/threshold) +# 本文件 (991001-991099) ← 字符串/列表型变量(methods/content-type 等) +# overrides/05-user-vars.conf ← 用户覆盖层(最终胜出) +# coreruleset/rules/REQUEST-901-INITIALIZATION.conf rule:901160 +# → 检测"变量未设置"时才写默认值,本文件已提前 setvar,901160 会跳过。 +# +# ID 范围:991001–991099(SamWaf Layer 1 before-hooks,CRS 安全区) +# ============================================================================= + +# 允许的 HTTP 方法(CRS rule 911100 依赖此变量) +# 包含 REST API 常用的 PUT / DELETE;如需禁用请通过后台写入 05-user-vars.conf +SecAction \ + "id:991001,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PATCH PUT DELETE'" + +# 允许的请求体 Content-Type(CRS rule 920420 依赖此变量) +# 在官方基础上加入 text/plain / application/octet-stream / ld+json 等现代常见类型 +SecAction \ + "id:991002,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.allowed_request_content_type=|application/x-www-form-urlencoded| |multipart/form-data| |multipart/related| |text/xml| |application/xml| |application/soap+xml| |application/json| |application/cloudevents+json| |application/cloudevents-batch+json| |application/ld+json| |application/x-json| |text/plain| |application/octet-stream|'" + +# 允许的 Content-Type 字符集(CRS rule 920480 依赖此变量) +# 加入 utf-16 / gbk / gb2312 / big5,兼容中文及多语言站点 +SecAction \ + "id:991003,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.allowed_request_content_type_charset=utf-8|utf-16|iso-8859-1|iso-8859-15|windows-1252|us-ascii|gbk|gb2312|big5'" + +# 允许的 HTTP 版本(CRS rule 920430 依赖此变量) +SecAction \ + "id:991004,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.allowed_http_versions=HTTP/1.0 HTTP/1.1 HTTP/2 HTTP/2.0 HTTP/3 HTTP/3.0'" + +# 单请求最大参数数量(CRS rule 920271 依赖此变量) +# 官方默认 255;1000 兼容参数较多的富前端页面 +SecAction \ + "id:991005,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.max_num_args=1000'" + +# 单参数值最大长度(字节,CRS rule 920271 依赖此变量) +# 官方默认 400;2000 适应长 token / base64 / 富文本字段 +SecAction \ + "id:991006,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.arg_length=2000'" + +# 参数总长度上限(字节,CRS rule 920271 依赖此变量) +# 官方默认 64000;1048576(1 MB)兼容大表单 / 富文本编辑器提交 +SecAction \ + "id:991007,\ + phase:1,\ + pass,\ + nolog,\ + t:none,\ + tag:'SamWaf_layer1',\ + setvar:'tx.total_arg_length=1048576'" diff --git a/cmd/tools/pack_owasp/main.go b/cmd/tools/pack_owasp/main.go new file mode 100644 index 0000000..59e4081 --- /dev/null +++ b/cmd/tools/pack_owasp/main.go @@ -0,0 +1,248 @@ +// pack_owasp 打包 OWASP CRS 规则集为升级包。 +// +// 用法: +// +// go run ./cmd/tools/pack_owasp [flags] +// +// Flags: +// +// -version string 版本号,格式 1.0.YYYYMMDD(默认读取 exedata/owasp/version) +// -changelog string 本次更新说明(默认 "") +// -source string owasp 数据根目录(默认 cmd/samwaf/exedata/owasp) +// -output string 输出目录(默认 release/web/owasp-ruleset) +// -base-url string 下载基础 URL(默认 https://update.samwaf.com) +// +// 输出: +// +// /owasp-.zip 规则包(内含 coreruleset/ + samwaf/ 目录树) +// /latest.json 升级清单(version / url / sha256 / changelog) +// +// ZIP 内部结构: +// +// coreruleset/ +// crs-setup.conf +// rules/*.conf +// samwaf/ +// before/01-samwaf-init-vars.conf +// after/... +// +// 示例: +// +// go run ./cmd/tools/pack_owasp -version 1.0.20260422 -changelog "升级 CRS 到最新" +package main + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// packEntry 描述一个要打入 ZIP 的子目录。 +// zipBase 是 ZIP 内部顶层目录名(通常与 srcDir 的 Base() 相同)。 +type packEntry struct { + srcDir string // 要打包的目录绝对路径 + zipBase string // ZIP 内部顶层名称 +} + +func main() { + var ( + version = flag.String("version", "", "版本号,格式 1.0.YYYYMMDD(留空则读取 source/version 文件)") + changelog = flag.String("changelog", "", "本次更新说明") + source = flag.String("source", "", "owasp 数据根目录(留空自动定位到项目内的 exedata/owasp)") + output = flag.String("output", "", "输出目录(留空则输出到 release/web/owasp-ruleset)") + baseURL = flag.String("base-url", "https://update.samwaf.com", "下载基础 URL,latest.json 中的 url 字段") + ) + flag.Parse() + + // 自动定位项目根(从工作目录向上找 go.mod) + projectRoot, err := findProjectRoot() + if err != nil { + fatalf("定位项目根失败: %v", err) + } + + // 解析路径 + if *source == "" { + *source = filepath.Join(projectRoot, "cmd", "samwaf", "exedata", "owasp") + } + if *output == "" { + *output = filepath.Join(projectRoot, "release", "web", "owasp-ruleset") + } + + // 读取版本号 + if *version == "" { + vFile := filepath.Join(*source, "version") + data, err := os.ReadFile(vFile) + if err != nil { + fatalf("读取版本文件失败(%s): %v", vFile, err) + } + *version = strings.TrimSpace(string(data)) + } + if *version == "" { + fatalf("-version 不能为空") + } + + // 确认必须存在的目录 + coreDir := filepath.Join(*source, "coreruleset") + if _, err := os.Stat(coreDir); err != nil { + fatalf("coreruleset 目录不存在: %s", coreDir) + } + + // 构建待打包列表:coreruleset 必选,samwaf 存在时自动加入 + entries := []packEntry{ + {srcDir: coreDir, zipBase: "coreruleset"}, + } + samwafDir := filepath.Join(*source, "samwaf") + if st, err := os.Stat(samwafDir); err == nil && st.IsDir() { + entries = append(entries, packEntry{srcDir: samwafDir, zipBase: "samwaf"}) + fmt.Printf("samwaf 层: %s ✓\n", samwafDir) + } else { + fmt.Printf("samwaf 层: 不存在,跳过(%s)\n", samwafDir) + } + + // 创建输出目录 + if err := os.MkdirAll(*output, 0755); err != nil { + fatalf("创建输出目录失败: %v", err) + } + + zipName := fmt.Sprintf("owasp-%s.zip", *version) + zipPath := filepath.Join(*output, zipName) + + fmt.Printf("打包版本: %s\n", *version) + fmt.Printf("输出 ZIP: %s\n", zipPath) + + sha, err := packZip(entries, zipPath) + if err != nil { + fatalf("打包失败: %v", err) + } + fmt.Printf("SHA256: %s\n", sha) + + // 写 latest.json + downloadURL := strings.TrimRight(*baseURL, "/") + "/owasp-ruleset/" + zipName + manifest := map[string]string{ + "version": *version, + "url": downloadURL, + "sha256": sha, + "changelog": *changelog, + "built_at": time.Now().Format(time.RFC3339), + } + latestPath := filepath.Join(*output, "latest.json") + if err := writeJSON(latestPath, manifest); err != nil { + fatalf("写 latest.json 失败: %v", err) + } + fmt.Printf("latest.json: %s\n", latestPath) + fmt.Println("打包完成。") +} + +// packZip 将多个 packEntry 按照各自的 zipBase 打入同一个 ZIP 文件, +// ZIP 内部结构:/<相对路径> +// 返回最终 ZIP 文件的十六进制 SHA256。 +func packZip(entries []packEntry, dstZip string) (string, error) { + f, err := os.Create(dstZip) + if err != nil { + return "", err + } + defer f.Close() + + w := zip.NewWriter(f) + + for _, entry := range entries { + // 父目录(用于计算相对路径) + parentDir := filepath.Dir(entry.srcDir) + + if err := filepath.Walk(entry.srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(parentDir, path) + if err != nil { + return err + } + zipPath := filepath.ToSlash(rel) // 统一正斜杠 + + if info.IsDir() { + // 顶层目录本身不单独写(Walk 会进入其子项) + if zipPath == entry.zipBase { + return nil + } + _, err = w.Create(zipPath + "/") + return err + } + fw, err := w.Create(zipPath) + if err != nil { + return err + } + src, err := os.Open(path) + if err != nil { + return err + } + defer src.Close() + _, err = io.Copy(fw, src) + return err + }); err != nil { + _ = w.Close() + return "", fmt.Errorf("打包 %s 失败: %w", entry.zipBase, err) + } + } + + if err := w.Close(); err != nil { + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + + return fileSHA256(dstZip) +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func writeJSON(path string, v interface{}) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// findProjectRoot 从当前工作目录向上搜索 go.mod 所在目录。 +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("未找到 go.mod,请在项目根目录下运行") +} + +func fatalf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "错误: "+format+"\n", args...) + os.Exit(1) +} diff --git a/router/waf_owasp.go b/router/waf_owasp.go index a982c81..27e5708 100644 --- a/router/waf_owasp.go +++ b/router/waf_owasp.go @@ -21,6 +21,11 @@ func (r *WafOwaspRouter) InitWafOwaspRouter(group *gin.RouterGroup) { g.POST("/api/v1/owasp/rule_reset", owaspApi.RuleResetApi) g.GET("/api/v1/owasp/files", owaspApi.FilesListApi) g.GET("/api/v1/owasp/file_content", owaspApi.FileContentApi) + // Layer 1 基线配置(可修改,优先级低于 Layer 2 tuning) + g.GET("/api/v1/owasp/base_config", owaspApi.BaseConfigGetApi) + g.POST("/api/v1/owasp/base_config", owaspApi.BaseConfigSetApi) + + // Layer 2 用户覆盖配置(优先级最高) g.GET("/api/v1/owasp/tuning", owaspApi.TuningGetApi) g.POST("/api/v1/owasp/tuning", owaspApi.TuningSetApi) g.POST("/api/v1/owasp/reload", owaspApi.ReloadApi) @@ -40,4 +45,8 @@ func (r *WafOwaspRouter) InitWafOwaspRouter(group *gin.RouterGroup) { // 使用文档 g.GET("/api/v1/owasp/usage/doc", owaspApi.UsageDocApi) + + // 规则命中统计(运行期内存统计,重启清零) + g.GET("/api/v1/owasp/hit_stats", owaspApi.HitStatsApi) + g.POST("/api/v1/owasp/hit_stats/reset", owaspApi.HitStatsResetApi) } diff --git a/wafowasp/hit_stats.go b/wafowasp/hit_stats.go new file mode 100644 index 0000000..8ee17e1 --- /dev/null +++ b/wafowasp/hit_stats.go @@ -0,0 +1,154 @@ +package wafowasp + +import ( + "sort" + "sync" + "sync/atomic" + "time" +) + +// RuleHitEntry 单条规则的命中统计(无锁计数器 + 一次性初始化的元数据)。 +type RuleHitEntry struct { + once sync.Once // 保证 Message/Severity 只初始化一次 + Message string + Severity string + TotalHits atomic.Int64 + BlockedHits atomic.Int64 + DetectedHits atomic.Int64 + lastSeenNano atomic.Int64 // Unix nano +} + +func (e *RuleHitEntry) initMeta(message, severity string) { + e.once.Do(func() { + e.Message = message + e.Severity = severity + }) +} + +// HitStatsTracker 全局规则命中统计,完全线程安全,热路径无互斥锁。 +type HitStatsTracker struct { + m sync.Map // key: int (ruleID) → *RuleHitEntry +} + +// GlobalHitStats 全局单例,由 initowasp.go 热路径调用。 +var GlobalHitStats = &HitStatsTracker{} + +func (t *HitStatsTracker) entry(ruleID int, message, severity string) *RuleHitEntry { + if v, ok := t.m.Load(ruleID); ok { + e := v.(*RuleHitEntry) + if message != "" { + e.initMeta(message, severity) + } + return e + } + ne := &RuleHitEntry{} + if message != "" { + ne.Message = message + ne.Severity = severity + } + actual, _ := t.m.LoadOrStore(ruleID, ne) + e := actual.(*RuleHitEntry) + if message != "" { + e.initMeta(message, severity) + } + return e +} + +// RecordBlocked 记录一次拦截命中(On 模式下 handleInterruption 调用)。 +func (t *HitStatsTracker) RecordBlocked(ruleID int, message, severity string) { + if ruleID <= 0 { + return + } + e := t.entry(ruleID, message, severity) + e.TotalHits.Add(1) + e.BlockedHits.Add(1) + e.lastSeenNano.Store(time.Now().UnixNano()) +} + +// RecordDetected 记录一次观察模式命中(DetectionOnly 下 logDetectionOnlyWouldBlock 调用)。 +func (t *HitStatsTracker) RecordDetected(ruleID int, message, severity string) { + if ruleID <= 0 { + return + } + e := t.entry(ruleID, message, severity) + e.TotalHits.Add(1) + e.DetectedHits.Add(1) + e.lastSeenNano.Store(time.Now().UnixNano()) +} + +// RuleHitStat 对外暴露的规则命中快照(JSON 序列化友好)。 +type RuleHitStat struct { + RuleID int `json:"rule_id"` + Message string `json:"message"` + Severity string `json:"severity"` + TotalHits int64 `json:"total_hits"` + BlockedHits int64 `json:"blocked_hits"` + DetectedHits int64 `json:"detected_hits"` + LastSeenAt string `json:"last_seen_at"` +} + +// TopN 返回按指定维度降序的前 n 条记录。 +// mode: "blocked" / "detected" / 其他 → 按 total_hits 排序。 +func (t *HitStatsTracker) TopN(n int, mode string) []RuleHitStat { + if n <= 0 { + n = 50 + } + var out []RuleHitStat + t.m.Range(func(k, v interface{}) bool { + e := v.(*RuleHitEntry) + nano := e.lastSeenNano.Load() + lastSeen := "" + if nano > 0 { + lastSeen = time.Unix(0, nano).Format("2006-01-02 15:04:05") + } + out = append(out, RuleHitStat{ + RuleID: k.(int), + Message: e.Message, + Severity: e.Severity, + TotalHits: e.TotalHits.Load(), + BlockedHits: e.BlockedHits.Load(), + DetectedHits: e.DetectedHits.Load(), + LastSeenAt: lastSeen, + }) + return true + }) + + sort.Slice(out, func(i, j int) bool { + var ci, cj int64 + switch mode { + case "blocked": + ci, cj = out[i].BlockedHits, out[j].BlockedHits + case "detected": + ci, cj = out[i].DetectedHits, out[j].DetectedHits + default: + ci, cj = out[i].TotalHits, out[j].TotalHits + } + if ci != cj { + return ci > cj + } + return out[i].RuleID < out[j].RuleID + }) + + if n > len(out) { + n = len(out) + } + return out[:n] +} + +// TotalRuleCount 返回当前有命中记录的规则数量。 +func (t *HitStatsTracker) TotalRuleCount() int { + count := 0 + t.m.Range(func(_, _ interface{}) bool { + count++ + return true + }) + return count +} + +// Reset 清空所有命中计数(注意:内存释放由 GC 负责)。 +func (t *HitStatsTracker) Reset() { + t.m.Range(func(k, _ interface{}) bool { + t.m.Delete(k) + return true + }) +} diff --git a/wafowasp/initowasp.go b/wafowasp/initowasp.go index a6300f5..afa3915 100644 --- a/wafowasp/initowasp.go +++ b/wafowasp/initowasp.go @@ -216,6 +216,10 @@ func (w *WafOWASP) logDetectionOnlyWouldBlock(tx types.Transaction, r *http.Requ "severity": rm.Severity().String(), "msg": mr.Message(), }) + // 命中统计:DetectionOnly 下记录观察模式命中 + if rm.ID() > 0 { + GlobalHitStats.RecordDetected(rm.ID(), mr.Message(), rm.Severity().String()) + } } srcIP := "" @@ -410,6 +414,14 @@ func (w *WafOWASP) handleInterruption(tx types.Transaction, it *types.Interrupti it.Data = "" } + // 命中统计:只记录有 message 的规则(排除 CRS 初始化类 SecAction,它们 message 为空)。 + for _, mr := range matched { + rm := mr.Rule() + if rm.ID() > 0 && mr.Message() != "" { + GlobalHitStats.RecordBlocked(rm.ID(), mr.Message(), rm.Severity().String()) + } + } + // 慢路径:仅 debug 模式下采集完整上下文 if debugEnabled.Load() { details := w.collectTransactionDetails(tx, matched) diff --git a/wafowasp/manager.go b/wafowasp/manager.go index 0d2ab85..96b2db9 100644 --- a/wafowasp/manager.go +++ b/wafowasp/manager.go @@ -52,6 +52,17 @@ func (m *OwaspManager) Overrides() *OverrideStore { return m.overrides } +// ApplyBaseConfig 写入 Layer 1 基线配置(samwaf_base.json + 00-samwaf-base.conf)并触发热重载。 +func (m *OwaspManager) ApplyBaseConfig(cfg BaseConfig) error { + if m.overrides == nil { + return fmt.Errorf("override store not initialized") + } + if err := m.overrides.SetBaseConfig(cfg); err != nil { + return err + } + return m.Reload() +} + // ApplyTuning 写入新 tuning 并触发 WAF 热重载。失败时保留旧实例。 func (m *OwaspManager) ApplyTuning(t TuningConfig) error { if m.overrides == nil { @@ -145,17 +156,57 @@ func (m *OwaspManager) OverridesDir() string { return filepath.Join(m.OwaspRoot(), "overrides") } +// samwafHooksDir 返回 samwaf 钩子子目录(before 或 after)的路径。 +// 仅在目录存在时才有效;不存在时调用方跳过即可,不视为错误。 +func (m *OwaspManager) samwafHooksDir(sub string) string { + return filepath.Join(m.OwaspRoot(), "samwaf", sub) +} + +// loadDirConfs 将指定目录下所有 .conf 文件(按字典序)依次追加到 cfg。 +// 目录不存在或为空时静默跳过。 +func loadDirConfs(cfg coraza.WAFConfig, dir string) coraza.WAFConfig { + entries, err := os.ReadDir(dir) + if err != nil { + return cfg // 目录不存在,静默跳过 + } + var confs []string + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".conf" { + confs = append(confs, filepath.Join(dir, e.Name())) + } + } + sort.Strings(confs) + for _, p := range confs { + cfg = cfg.WithDirectivesFromFile(p) + } + return cfg +} + // Reload 重建 Coraza WAF 实例并原子替换。 // -// 加载顺序(顺序决定 Phase 1 规则链的执行先后): -// 1. data/owasp/coraza.conf (基础配置) -// 2. data/owasp/coreruleset/crs-setup.conf (CRS 配置,定义变量注释占位) -// 3. data/owasp/overrides/00-tuning.conf (SamWaf 参数调优,含 tx.* 变量初始化) -// ↑ 必须在 CRS rules 之前:CRS REQUEST-901-INITIALIZATION.conf 的 rule 901160 会检查 -// "&TX:allowed_methods @eq 0",只有变量 未设置 时才写默认值;我们在此提前设置, -// 901160 就会跳过默认值,我们的值才能生效。 -// 4. data/owasp/coreruleset/rules/*.conf (官方规则集,包括 901160 初始化和 911100 检查) -// 5. data/owasp/overrides/10-*.conf 等 (SecRuleRemoveById 和用户自定义规则,须在规则之后) +// 完整加载顺序(优先级从低到高,后加载者覆盖同名变量): +// +// Layer 1(SamWaf 出厂默认,全部先于 Layer 2 加载): +// 1. data/owasp/coraza.conf +// 2. data/owasp/coreruleset/crs-setup.conf +// 3. data/owasp/overrides/00-samwaf-base.conf ← 数值变量(paranoia/threshold,990001-990009) +// 4. data/owasp/samwaf/before/*.conf ← 字符串变量(allowed_methods 等,991001-991099) +// +// Layer 2(用户设置,最后加载 → 优先级最高): +// 5. data/owasp/overrides/05-user-vars.conf ← 用户所有覆盖(950001+ / 990100+) +// +// ↑ 步骤 3-5 必须在 CRS rules 之前:CRS rule 901160 只在变量未设置时才写默认值。 +// ↑ Layer 2(步骤 5)加载最晚 → 同名变量用户值胜出,正确覆盖 Layer 1。 +// +// Layer 0(CRS 规则集): +// 6. data/owasp/coreruleset/rules/*.conf +// +// Layer 1 后置(CRS 执行后补充): +// 7. data/owasp/samwaf/after/*.conf ← 补充检测(991100-991199) +// +// Layer 2 后置(用户禁用 / 改写,须在 CRS 之后): +// 8. data/owasp/overrides/10-disabled-rules.conf +// 9. data/owasp/overrides/20-custom-rules.conf // // 构建失败时旧实例保留,返回错误由调用方决定如何处理。 func (m *OwaspManager) Reload() error { @@ -178,23 +229,41 @@ func (m *OwaspManager) Reload() error { zlog.Error("crs-setup.conf missing, skipping", map[string]interface{}{"path": setupConf, "err": err.Error()}) } - // 步骤 3:提前加载 00-tuning.conf(变量初始化) - // 必须在 rules/*.conf 之前,否则 tx.* 变量会被 CRS 默认初始化规则覆盖 + // 步骤 3:Layer 1a — 00-samwaf-base.conf(数值型变量:paranoia / threshold,ID 990001-990009) + samwafBase := filepath.Join(m.OverridesDir(), OverrideSamWafBaseFile) + if _, err := os.Stat(samwafBase); err == nil { + cfg = cfg.WithDirectivesFromFile(samwafBase) + } + + // 步骤 4:Layer 1b — samwaf/before/*.conf(字符串/列表型变量:allowed_methods 等,ID 991001-991099) + // 必须在 Layer 2(05-user-vars.conf)之前,确保用户覆盖优先级更高。 + cfg = loadDirConfs(cfg, m.samwafHooksDir("before")) + + // 步骤 5:Layer 2 — 05-user-vars.conf(用户覆盖层,最后加载 → 同名变量用户值胜出) + // 同时覆盖 Layer 1a 的数值变量和 Layer 1b 的字符串变量。 tuningConf := filepath.Join(m.OverridesDir(), OverrideTuningFile) if _, err := os.Stat(tuningConf); err == nil { cfg = cfg.WithDirectivesFromFile(tuningConf) } - // 步骤 4:加载 CRS 官方规则集 + // 步骤 6:Layer 0 — CRS 官方规则集(此时所有 tx.* 变量均已就绪) rulesGlob := filepath.Join(root, "coreruleset", "rules", "*.conf") cfg = cfg.WithDirectivesFromFile(rulesGlob) - // 步骤 5:加载其余 overrides(禁用/改写规则,须在 CRS 规则之后才能 RemoveById) - // 跳过已在步骤 3 提前加载的 00-tuning.conf + // 步骤 7:Layer 1 后置钩子(samwaf/after/*.conf,ID 991100-991199)—— CRS 规则执行后 + // 用途:补充检测规则、基于评分的自定义动作等。 + cfg = loadDirConfs(cfg, m.samwafHooksDir("after")) + + // 步骤 8-9:Layer 2 后置文件(用户禁用 / 改写规则,须在 CRS 规则之后才能 RemoveById) + // 跳过已在步骤 3-5 提前加载的 pre-rules 文件。 + preRules := map[string]bool{ + OverrideSamWafBaseFile: true, + OverrideTuningFile: true, + } if overrides, err := listOverrideConfs(m.OverridesDir()); err == nil && len(overrides) > 0 { for _, p := range overrides { - if filepath.Base(p) == OverrideTuningFile { - continue // 已在 CRS rules 之前加载,跳过 + if preRules[filepath.Base(p)] { + continue } cfg = cfg.WithDirectivesFromFile(p) } diff --git a/wafowasp/overrides.go b/wafowasp/overrides.go index b0bba92..7609b93 100644 --- a/wafowasp/overrides.go +++ b/wafowasp/overrides.go @@ -93,12 +93,26 @@ func (s *OverrideStore) LoadAuditLog() ([]AuditLogEntry, error) { } // Override 相关的文件名,集中在此以便升级器、API 层复用。 +// +// 三层策略叠加(加载顺序): +// +// Layer 0: coreruleset/rules/*.conf ← CRS 上游,在线升级时覆盖 +// Layer 1: overrides/00-samwaf-base.conf ← SamWaf 产品基线,由 samwaf_base.json 驱动 +// Layer 2: overrides/05-user-vars.conf ← 用户 tx.* 变量,优先于 Layer 1(永不被升级覆盖) +// overrides/10-disabled-rules.conf +// overrides/20-custom-rules.conf +// +// 重要:Layer 1 和 Layer 2 均须在 rules/*.conf 之前加载,原因: +// CRS REQUEST-901-INITIALIZATION.conf 的 rule 901160 检查"变量未设置时才写默认值", +// 必须在它之前 setvar,否则 tx.allowed_methods 等设置永远无效。 const ( - OverrideTuningFile = "00-tuning.conf" // 全局参数调优 - OverrideDisabledFile = "10-disabled-rules.conf" // 按 ID 禁用的规则 - OverrideCustomFile = "20-custom-rules.conf" // 用户覆盖/自定义的规则 - OverrideRegistryFile = "override_registry.json" // 元数据:哪些 ID 被用户改过 - OverrideRegistryShema = 1 + OverrideSamWafBaseFile = "00-samwaf-base.conf" // Layer 1:SamWaf 产品基线(由 samwaf_base.json 生成) + OverrideBaseConfigFile = "samwaf_base.json" // Layer 1 持久化:存储 SamWaf 基线参数 + OverrideTuningFile = "05-user-vars.conf" // Layer 2:用户 tx.* 变量覆盖层(永不被升级覆盖) + OverrideDisabledFile = "10-disabled-rules.conf" // Layer 2:用户按 ID 禁用的规则 + OverrideCustomFile = "20-custom-rules.conf" // Layer 2:用户覆盖/自定义的规则 + OverrideRegistryFile = "override_registry.json" // 元数据:所有用户改动的单一事实来源 + OverrideRegistryShema = 1 ) // OverrideAction 用户对某条规则的动作。 @@ -134,17 +148,101 @@ type TuningConfig struct { } // DefaultTuning 首次运行时写入的宽松默认值,较官方默认更容忍误报。 +// 引擎模式默认为 DetectionOnly(观察模式),避免新部署时误伤正常流量。 +// 用户确认规则无误报后,可在后台手动切换到 On(拦截模式)。 func DefaultTuning() TuningConfig { return TuningConfig{ BlockingParanoia: 1, DetectionParanoia: 2, InboundThreshold: 7, // 官方默认 5,我们放宽到 7,降低单条 critical 直接 block 的概率 OutboundThreshold: 4, - RuleEngine: "On", + RuleEngine: "DetectionOnly", EarlyBlocking: 0, } } +// BaseConfig Layer 1 的持久化参数。仅包含 CRS tx.* 数值变量, +// 不含 SecRuleEngine(属于用户运营决策)和 CustomVars(属于 Layer 2)。 +type BaseConfig struct { + BlockingParanoia int `json:"blocking_paranoia_level"` + DetectionParanoia int `json:"detection_paranoia_level"` + InboundThreshold int `json:"inbound_anomaly_score_threshold"` + OutboundThreshold int `json:"outbound_anomaly_score_threshold"` + EarlyBlocking int `json:"early_blocking"` + EnforceBodyProcessor int `json:"enforce_bodyproc_urlencoded"` +} + +// DefaultBaseConfig Layer 1 出厂默认值(与 DefaultTuning 数值部分保持一致)。 +func DefaultBaseConfig() BaseConfig { + d := DefaultTuning() + return BaseConfig{ + BlockingParanoia: d.BlockingParanoia, + DetectionParanoia: d.DetectionParanoia, + InboundThreshold: d.InboundThreshold, + OutboundThreshold: d.OutboundThreshold, + EarlyBlocking: d.EarlyBlocking, + } +} + +// toTuningConfig 将 BaseConfig 转为 TuningConfig 以便复用 writeSamWafBaseConfFile。 +func (b BaseConfig) toTuningConfig() TuningConfig { + return TuningConfig{ + BlockingParanoia: b.BlockingParanoia, + DetectionParanoia: b.DetectionParanoia, + InboundThreshold: b.InboundThreshold, + OutboundThreshold: b.OutboundThreshold, + EarlyBlocking: b.EarlyBlocking, + EnforceBodyProcessor: b.EnforceBodyProcessor, + } +} + +// GetBaseConfig 读取 Layer 1 基线配置(来自 samwaf_base.json)。 +// 文件不存在时返回 DefaultBaseConfig()(不视为错误)。 +func (s *OverrideStore) GetBaseConfig() (BaseConfig, error) { + s.mu.Lock() + defer s.mu.Unlock() + return loadBaseConfigLocked(filepath.Join(s.dir, OverrideBaseConfigFile)) +} + +func loadBaseConfigLocked(path string) (BaseConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return DefaultBaseConfig(), nil + } + return BaseConfig{}, err + } + var cfg BaseConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return BaseConfig{}, fmt.Errorf("parse %s: %w", path, err) + } + return cfg, nil +} + +// SetBaseConfig 持久化 Layer 1 配置并重新生成 00-samwaf-base.conf。 +// 调用方(ApplyBaseConfig)负责触发 Reload()。 +func (s *OverrideStore) SetBaseConfig(cfg BaseConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + if err := atomicWriteFile(filepath.Join(s.dir, OverrideBaseConfigFile), data); err != nil { + return fmt.Errorf("write %s: %w", OverrideBaseConfigFile, err) + } + if err := writeSamWafBaseConfFile(filepath.Join(s.dir, OverrideSamWafBaseFile), cfg.toTuningConfig()); err != nil { + return fmt.Errorf("write %s: %w", OverrideSamWafBaseFile, err) + } + s.AppendAuditLog(AuditLogEntry{ + Action: AuditTuning, + Note: fmt.Sprintf("base_config: blocking_pl=%d detection_pl=%d inbound=%d outbound=%d", + cfg.BlockingParanoia, cfg.DetectionParanoia, cfg.InboundThreshold, cfg.OutboundThreshold), + }) + return nil +} + // OverrideRegistry overrides 层的元数据。 type OverrideRegistry struct { Version int `json:"version"` @@ -171,8 +269,12 @@ func (s *OverrideStore) Dir() string { return s.dir } -// EnsureDirAndDefaults 确保 overrides 目录存在,若 registry 缺失则写入默认 tuning + 空 registry。 -// 已存在的内容一律不覆盖(幂等)。 +// EnsureDirAndDefaults 确保 overrides 目录存在并写入各层默认文件。 +// +// 行为: +// - 00-samwaf-base.conf(Layer 1):每次调用均重写,确保 SamWaf 产品默认值随二进制更新。 +// - 05-user-vars.conf(Layer 2):仅首次(registry 缺失时)写入;后续由用户操作写入。 +// - 兼容迁移:若发现旧版 00-tuning.conf,自动重命名为 05-user-vars.conf。 func (s *OverrideStore) EnsureDirAndDefaults() error { s.mu.Lock() defer s.mu.Unlock() @@ -181,6 +283,33 @@ func (s *OverrideStore) EnsureDirAndDefaults() error { return fmt.Errorf("mkdir overrides: %w", err) } + // Layer 1:从 samwaf_base.json 读取配置(不存在时用出厂默认值),生成 00-samwaf-base.conf。 + // samwaf_base.json 可由 API 修改,因此每次启动只重新生成 .conf 文件而不覆盖 .json。 + baseJSONPath := filepath.Join(s.dir, OverrideBaseConfigFile) + baseCfg, _ := loadBaseConfigLocked(baseJSONPath) + // 首次运行:写入出厂默认值到 samwaf_base.json + if _, err := os.Stat(baseJSONPath); os.IsNotExist(err) { + if data, err2 := json.MarshalIndent(baseCfg, "", " "); err2 == nil { + _ = atomicWriteFile(baseJSONPath, data) + } + } + basePath := filepath.Join(s.dir, OverrideSamWafBaseFile) + if err := writeSamWafBaseConfFile(basePath, baseCfg.toTuningConfig()); err != nil { + return fmt.Errorf("write %s: %w", OverrideSamWafBaseFile, err) + } + + // 兼容迁移:旧版使用 00-tuning.conf,自动重命名为 05-user-vars.conf。 + oldTuning := filepath.Join(s.dir, "00-tuning.conf") + newTuning := filepath.Join(s.dir, OverrideTuningFile) + if _, err := os.Stat(oldTuning); err == nil { + if _, err2 := os.Stat(newTuning); os.IsNotExist(err2) { + _ = os.Rename(oldTuning, newTuning) + } else { + // 两者都存在(异常情况):保留新文件,删除旧文件。 + _ = os.Remove(oldTuning) + } + } + regPath := filepath.Join(s.dir, OverrideRegistryFile) _, statErr := os.Stat(regPath) regExists := statErr == nil @@ -194,16 +323,15 @@ func (s *OverrideStore) EnsureDirAndDefaults() error { if err := writeRegistryLocked(regPath, reg); err != nil { return err } - // 若 00-tuning.conf 不存在则生成 - tuningPath := filepath.Join(s.dir, OverrideTuningFile) - if _, err := os.Stat(tuningPath); os.IsNotExist(err) { - if err := writeTuningConfFile(tuningPath, reg.Global); err != nil { + // Layer 2:05-user-vars.conf 仅首次写入(后续由用户操作写入)。 + if _, err := os.Stat(newTuning); os.IsNotExist(err) { + if err := writeTuningConfFile(newTuning, reg.Global); err != nil { return err } } } - // 保证 10-disabled-rules.conf 占位文件存在(避免 coraza 加载空 glob 时报错) + // 保证 10-disabled-rules.conf 占位文件存在(避免 coraza 加载空 glob 时报错)。 disabledPath := filepath.Join(s.dir, OverrideDisabledFile) if _, err := os.Stat(disabledPath); os.IsNotExist(err) { if err := os.WriteFile(disabledPath, []byte(disabledHeader()), 0644); err != nil { @@ -580,15 +708,69 @@ func rewriteCustomConfLocked(dir string, reg *OverrideRegistry) error { return atomicWriteFile(filepath.Join(dir, OverrideCustomFile), []byte(sb.String())) } -// writeTuningConfFile 根据 TuningConfig 生成 00-tuning.conf 内容。 +// ============================================================================= +// SamWaf SecAction/SecRule ID 分配表(全局,禁止跨层冲突) // -// 用 SecAction 在 phase:1 初始化 tx 变量;参考 crs-setup.conf 里的注释与官方 ID 段。 -// 我们选用 950000+ 段避开与官方规则 ID 冲突。 +// ID 范围 层 文件 +// 900000–989999 CRS coreruleset/rules/*.conf (禁止使用) +// 990001–990009 Layer 1 overrides/00-samwaf-base.conf(paranoia/threshold) +// 990100–990999 Layer 2 overrides/05-user-vars.conf(用户自定义 CRS 变量) +// 991001–991099 Layer 1 samwaf/before/*.conf(CRS 变量初始化,如 allowed_methods) +// 991100–991199 Layer 1 samwaf/after/*.conf(补充检测规则) +// 950001–950006 Layer 2 overrides/05-user-vars.conf(调参:paranoia/threshold 覆盖) +// 注:CRS 从 950010 开始,950001-950006 实测无冲突 +// +// 验证结论(2026-04-21): +// CRS 4.x 实际 rule ID 范围 901001~980170,没有落在 990xxx / 991xxx / 950001-950006 段。 +// ============================================================================= + +// writeSamWafBaseConfFile 生成 Layer 1 文件 00-samwaf-base.conf:SamWaf 基线 tx.* 数值变量。 +// +// 仅设置 paranoia level / anomaly threshold 等数值参数; +// 不包含 SecRuleEngine(用户运营决策)和字符串/列表型变量(由 samwaf/before/*.conf 负责)。 +// 使用 ID 段 990001–990009(CRS 安全区)。 +func writeSamWafBaseConfFile(path string, t TuningConfig) error { + var sb strings.Builder + sb.WriteString("# Layer 1: SamWaf product defaults (numeric). Managed by SamWaf, updated with SamWaf releases.\n") + sb.WriteString("# DO NOT EDIT MANUALLY. To override, use 后台 → OWASP 规则管理 → 全局调参。\n") + sb.WriteString("# User settings in 05-user-vars.conf take precedence (loaded after this file).\n") + sb.WriteString("# ID range: 990001-990009 (SamWaf Layer 1 base, safe above CRS max 980170)\n\n") + + writeSetvar := func(id int, desc, name string, value int) { + if value <= 0 { + return + } + sb.WriteString(fmt.Sprintf("# %s\n", desc)) + sb.WriteString(fmt.Sprintf("SecAction \\\n \"id:%d,\\\n phase:1,\\\n pass,\\\n nolog,\\\n t:none,\\\n tag:'SamWaf_base',\\\n setvar:'tx.%s=%d'\"\n\n", id, name, value)) + } + + writeSetvar(990001, "Blocking Paranoia Level", "blocking_paranoia_level", t.BlockingParanoia) + if t.DetectionParanoia >= t.BlockingParanoia { + writeSetvar(990002, "Detection Paranoia Level", "detection_paranoia_level", t.DetectionParanoia) + } + writeSetvar(990003, "Inbound Anomaly Score Threshold", "inbound_anomaly_score_threshold", t.InboundThreshold) + writeSetvar(990004, "Outbound Anomaly Score Threshold", "outbound_anomaly_score_threshold", t.OutboundThreshold) + if t.EarlyBlocking == 1 { + writeSetvar(990005, "Early Blocking", "early_blocking", 1) + } + if t.EnforceBodyProcessor == 1 { + writeSetvar(990006, "Enforce Body Processor URLENCODED", "enforce_bodyproc_urlencoded", 1) + } + + return atomicWriteFile(path, []byte(sb.String())) +} + +// writeTuningConfFile 根据 TuningConfig 生成 Layer 2 文件(05-user-vars.conf)。 +// +// 包含用户设置的全部内容:SecRuleEngine、tx.* 数值调参变量、用户自定义字符串变量。 +// 加载顺序在 00-samwaf-base.conf 和 samwaf/before/*.conf 之后,同名变量以本文件为准。 +// ID 段:调参变量 950001-950006;用户自定义 CRS 变量 990100-990999(上限约 900 条)。 +// 注:CRS 实际从 950010 开始,950001-950006 无冲突(已验证)。 func writeTuningConfFile(path string, t TuningConfig) error { var sb strings.Builder - sb.WriteString("# Auto-generated by SamWaf OwaspManager. DO NOT EDIT MANUALLY.\n") + sb.WriteString("# Layer 2: User variable overrides. Managed by SamWaf via API. NEVER overwritten by any upgrade.\n") sb.WriteString("# Use 后台 → OWASP 规则管理 → 全局调参 来修改这里的内容。\n") - sb.WriteString("# 本文件在 overrides/ 下,不会被 coreruleset 升级覆盖。\n\n") + sb.WriteString("# Values here take precedence over 00-samwaf-base.conf (SamWaf defaults).\n\n") // RuleEngine: DetectionOnly/On/Off 通过 SecRuleEngine 指令直接设定 engine := strings.TrimSpace(t.RuleEngine) diff --git a/wafowasp/upgrader.go b/wafowasp/upgrader.go index 5b330d5..23b5d4e 100644 --- a/wafowasp/upgrader.go +++ b/wafowasp/upgrader.go @@ -95,8 +95,8 @@ func CheckUpgrade(m *OwaspManager) (*UpgradeInfo, error) { // 1. CheckUpgrade 拿 manifest // 2. 下载 zip,sha256 校验 // 3. 解压到 tmp/owasp-/ -// 4. 备份当前 coreruleset → coreruleset.bak -// 5. 替换 coreruleset(保留 overrides) +// 4. 备份当前 coreruleset → coreruleset.bak,samwaf → samwaf.bak(若 ZIP 含 samwaf/) +// 5. 替换 coreruleset(保留 overrides);同步替换 samwaf(Layer 1 SamWaf 钩子) // 6. 将 registry 里标记为 modified 的 ID 从新的 coreruleset 中移除(合并策略 "by_rule_id") // 7. 更新 version 文件 // 8. m.Reload() @@ -162,7 +162,7 @@ func ApplyUpgrade(m *OwaspManager) error { } } - // 备份 + 替换 coreruleset + // ── 备份 + 替换 coreruleset ────────────────────────────────────────────── coreDir := filepath.Join(owaspRoot, "coreruleset") backupDir := filepath.Join(owaspRoot, "coreruleset.bak") _ = os.RemoveAll(backupDir) @@ -172,24 +172,54 @@ func ApplyUpgrade(m *OwaspManager) error { } } + rollbackCore := func() { + _ = os.RemoveAll(coreDir) + if _, statErr := os.Stat(backupDir); statErr == nil { + _ = os.Rename(backupDir, coreDir) + } + } + // 解压目录下可能是 coreruleset/ 直接结构,也可能是 /coreruleset/; // 这里尝试自动识别。 srcCore, err := resolveCorerulesetInExtract(extractDir) if err != nil { - // 回滚 - if _, statErr := os.Stat(backupDir); statErr == nil { - _ = os.Rename(backupDir, coreDir) - } + rollbackCore() return fmt.Errorf("定位 coreruleset 目录失败: %w", err) } if err := os.Rename(srcCore, coreDir); err != nil { - // 回滚 - if _, statErr := os.Stat(backupDir); statErr == nil { - _ = os.Rename(backupDir, coreDir) - } + rollbackCore() return fmt.Errorf("应用新 coreruleset 失败: %w", err) } + // ── 备份 + 替换 samwaf(Layer 1 SamWaf 钩子,ZIP 含 samwaf/ 时才执行)── + // samwaf/ 由 SamWaf 开发团队维护,随版本发布整体替换; + // 用户自定义内容应放在 overrides/,不受此步影响。 + samwafInExtract := filepath.Join(extractDir, "samwaf") + if st, statErr := os.Stat(samwafInExtract); statErr == nil && st.IsDir() { + samwafDir := filepath.Join(owaspRoot, "samwaf") + samwafBak := filepath.Join(owaspRoot, "samwaf.bak") + _ = os.RemoveAll(samwafBak) + if _, err := os.Stat(samwafDir); err == nil { + if err := os.Rename(samwafDir, samwafBak); err != nil { + // coreruleset 已替换成功,samwaf 备份失败不致命,记录日志继续 + zlog.Error("备份 samwaf 失败,继续升级", map[string]interface{}{"err": err.Error()}) + goto afterSamwaf + } + } + if err := os.Rename(samwafInExtract, samwafDir); err != nil { + // 回滚 samwaf + _ = os.RemoveAll(samwafDir) + if _, statErr := os.Stat(samwafBak); statErr == nil { + _ = os.Rename(samwafBak, samwafDir) + } + // 同时回滚 coreruleset + rollbackCore() + return fmt.Errorf("应用新 samwaf 失败: %w", err) + } + _ = os.RemoveAll(samwafBak) + } +afterSamwaf: + // 写 version 文件 if err := os.WriteFile(filepath.Join(owaspRoot, "version"), []byte(info.LatestVersion), 0644); err != nil { zlog.Error("写入 version 文件失败", map[string]interface{}{"err": err.Error()}) diff --git a/wafowasp/usage_doc.go b/wafowasp/usage_doc.go index cf7f7c1..d58111a 100644 --- a/wafowasp/usage_doc.go +++ b/wafowasp/usage_doc.go @@ -18,17 +18,35 @@ const usageDocMD = `# OWASP 规则在线管理使用指南 - CRS 采用**评分机制**:每条规则命中会按 severity 给 tx.anomaly_score 加分;累计达到阈值就拦截。 - 默认 paranoia-level=1、threshold=7(SamWaf 在官方默认值 5 基础上放宽至 7,降低单条规则直接 block 的概率)。 -## 二、目录结构 +## 二、目录结构与三层策略 -| 路径 | 作用 | -| --- | --- | -| data/owasp/coraza.conf | Coraza 基础配置(请勿直接改) | -| data/owasp/coreruleset/ | 官方规则基线(升级时整体替换) | -| data/owasp/overrides/ | 用户自定义层(**升级不覆盖**) | -| overrides/00-tuning.conf | 全局调优参数 | -| overrides/10-disabled-rules.conf | 按 ID 禁用的规则(SecRuleRemoveById) | -| overrides/20-custom-rules.conf | 用户改写后的规则 | -| overrides/override_registry.json | 元数据:记录哪些 ID 被改动 | +三层策略叠加,加载顺序如下: + + Layer 0 (CRS 上游,在线升级时整体替换) + data/owasp/coreruleset/rules/*.conf + + Layer 1 (SamWaf 产品默认层,随 SamWaf 版本更新) + data/owasp/overrides/00-samwaf-base.conf + + Layer 2 (用户自定义层,永不被任何升级覆盖) + data/owasp/overrides/05-user-vars.conf + data/owasp/overrides/10-disabled-rules.conf + data/owasp/overrides/20-custom-rules.conf + data/owasp/overrides/override_registry.json + +| 路径 | 层 | 作用 | +| --- | --- | --- | +| data/owasp/coraza.conf | — | Coraza 基础配置(请勿直接改) | +| data/owasp/coreruleset/ | 0 | 官方规则基线(CRS 在线升级时整体替换) | +| overrides/00-samwaf-base.conf | **1** | SamWaf 产品默认 tx.* 变量(随 SamWaf 二进制更新) | +| overrides/05-user-vars.conf | **2** | 用户覆盖的 tx.* 变量(优先于 Layer 1) | +| overrides/10-disabled-rules.conf | **2** | 按 ID 禁用的规则(SecRuleRemoveById) | +| overrides/20-custom-rules.conf | **2** | 用户改写后的规则 | +| overrides/override_registry.json | **2** | 元数据:记录哪些 ID 被改动 | + +加载顺序:coraza.conf → crs-setup.conf → 00-samwaf-base.conf → 05-user-vars.conf → rules/*.conf → 10-disabled-rules.conf → 20-custom-rules.conf + +tx.* 变量必须在 rules/*.conf 之前设置:CRS rule 901160 只在变量未设置时才写默认值。 ## 三、误报应对阶梯