feat: owaspruleset

#546
This commit is contained in:
samwaf
2026-04-22 15:55:58 +08:00
parent 25c5bbedfe
commit f457f31eee
12 changed files with 1095 additions and 55 deletions
+101
View File
@@ -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)
}
@@ -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'"
#
# =============================================================================
@@ -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"
#
# =============================================================================
@@ -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'"
+248
View File
@@ -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)
//
// 输出:
//
// <output>/owasp-<version>.zip 规则包(内含 coreruleset/ + samwaf/ 目录树)
// <output>/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 内部结构:<zipBase>/<相对路径>
// 返回最终 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)
}
+9
View File
@@ -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)
}
+154
View File
@@ -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
})
}
+12
View File
@@ -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)
+85 -16
View File
@@ -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)
}
+200 -18
View File
@@ -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)
+41 -11
View File
@@ -95,8 +95,8 @@ func CheckUpgrade(m *OwaspManager) (*UpgradeInfo, error) {
// 1. CheckUpgrade 拿 manifest
// 2. 下载 zip,sha256 校验
// 3. 解压到 tmp/owasp-<ver>/
// 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/ 直接结构,也可能是 <name>/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()})
+28 -10
View File
@@ -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 只在变量未设置时才写默认值。
## 三、误报应对阶梯