mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-30 17:51:44 +08:00
@@ -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'"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 只在变量未设置时才写默认值。
|
||||
|
||||
## 三、误报应对阶梯
|
||||
|
||||
|
||||
Reference in New Issue
Block a user