mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-30 17:51:44 +08:00
@@ -58,6 +58,7 @@ type APIGroup struct {
|
||||
WafOPlatformLogApi
|
||||
WafOPlatformDocApi
|
||||
WafDataRetentionApi
|
||||
WafOwaspApi
|
||||
}
|
||||
|
||||
var APIGroupAPP = new(APIGroup)
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"SamWaf/global"
|
||||
"SamWaf/model/common/response"
|
||||
"SamWaf/wafowasp"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
|
||||
"github.com/corazawaf/coraza/v3/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// WafOwaspApi OWASP 规则在线管理 API。
|
||||
type WafOwaspApi struct{}
|
||||
|
||||
// manager 辅助:保证 manager 存在,否则返回 nil 并写失败响应。
|
||||
func (w *WafOwaspApi) manager(c *gin.Context) *wafowasp.OwaspManager {
|
||||
m := global.GWAF_OWASP_MANAGER
|
||||
if m == nil {
|
||||
response.FailWithMessage("OWASP 管理器尚未初始化", c)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// RulesListReq 规则列表查询参数。
|
||||
type RulesListReq struct {
|
||||
File string `form:"file"`
|
||||
Severity string `form:"severity"`
|
||||
Paranoia int `form:"paranoia"`
|
||||
Keyword string `form:"keyword"`
|
||||
Status string `form:"status"` // disabled / modified / default / ""(全部)
|
||||
PageIndex int `form:"page_index"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
// RuleListItem 前端展示用的规则条目。
|
||||
type RuleListItem struct {
|
||||
wafowasp.RuleMeta
|
||||
Disabled bool `json:"disabled"`
|
||||
Modified bool `json:"modified"`
|
||||
}
|
||||
|
||||
// RulesListApi 列出所有规则(扫描 coreruleset + overrides)。
|
||||
func (w *WafOwaspApi) RulesListApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var req RulesListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
if req.PageIndex <= 0 {
|
||||
req.PageIndex = 1
|
||||
}
|
||||
if req.PageSize <= 0 || req.PageSize > 500 {
|
||||
req.PageSize = 50
|
||||
}
|
||||
|
||||
owaspRoot := m.OwaspRoot()
|
||||
files, err := wafowasp.ScanAllRules(owaspRoot)
|
||||
if err != nil {
|
||||
response.FailWithMessage("扫描规则失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
reg, _ := m.Overrides().LoadRegistry()
|
||||
disabled := map[int]bool{}
|
||||
modified := map[int]bool{}
|
||||
if reg != nil {
|
||||
for k, v := range reg.Rules {
|
||||
id, err := strconv.Atoi(k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v.Action == wafowasp.OverrideDisabled {
|
||||
disabled[id] = true
|
||||
}
|
||||
if v.Action == wafowasp.OverrideModified {
|
||||
modified[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all := make([]RuleListItem, 0, 2048)
|
||||
for _, f := range files {
|
||||
for _, r := range f.Rules {
|
||||
if req.File != "" && !strings.Contains(r.File, req.File) {
|
||||
continue
|
||||
}
|
||||
if req.Severity != "" && !strings.EqualFold(r.Severity, req.Severity) {
|
||||
continue
|
||||
}
|
||||
if req.Paranoia > 0 && r.Paranoia != req.Paranoia {
|
||||
continue
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
kw := strings.ToLower(req.Keyword)
|
||||
if !strings.Contains(strings.ToLower(r.Message), kw) &&
|
||||
!strings.Contains(strconv.Itoa(r.ID), kw) &&
|
||||
!strings.Contains(strings.ToLower(r.File), kw) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
isDisabled := disabled[r.ID]
|
||||
isModified := modified[r.ID]
|
||||
switch req.Status {
|
||||
case "disabled":
|
||||
if !isDisabled {
|
||||
continue
|
||||
}
|
||||
case "modified":
|
||||
if !isModified {
|
||||
continue
|
||||
}
|
||||
case "default":
|
||||
if isDisabled || isModified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
all = append(all, RuleListItem{
|
||||
RuleMeta: r,
|
||||
Disabled: isDisabled,
|
||||
Modified: isModified,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 统计全量(不受分页影响)的禁用/改写数量,供前端快捷筛选徽标显示。
|
||||
var totalDisabled, totalModified int
|
||||
for _, entry := range reg.Rules {
|
||||
switch entry.Action {
|
||||
case wafowasp.OverrideDisabled:
|
||||
totalDisabled++
|
||||
case wafowasp.OverrideModified:
|
||||
totalModified++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(all)
|
||||
start := (req.PageIndex - 1) * req.PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + req.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
response.OkWithDetailed(gin.H{
|
||||
"list": all[start:end],
|
||||
"total": total,
|
||||
"page_index": req.PageIndex,
|
||||
"page_size": req.PageSize,
|
||||
"disabled_count": totalDisabled,
|
||||
"modified_count": totalModified,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// RuleDetailApi 返回单条规则的详情(含原始内容、是否被改写、用户版本)。
|
||||
func (w *WafOwaspApi) RuleDetailApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
idStr := c.Query("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
response.FailWithMessage("id 非法", c)
|
||||
return
|
||||
}
|
||||
files, err := wafowasp.ScanAllRules(m.OwaspRoot())
|
||||
if err != nil {
|
||||
response.FailWithMessage("扫描规则失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
var target *wafowasp.RuleMeta
|
||||
for _, f := range files {
|
||||
for i := range f.Rules {
|
||||
if f.Rules[i].ID == id {
|
||||
rm := f.Rules[i]
|
||||
target = &rm
|
||||
break
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
response.FailWithMessage("未找到该规则 ID", c)
|
||||
return
|
||||
}
|
||||
|
||||
reg, _ := m.Overrides().LoadRegistry()
|
||||
var entry *wafowasp.RuleOverrideEntry
|
||||
if reg != nil {
|
||||
if e, ok := reg.Rules[idStr]; ok {
|
||||
entry = &e
|
||||
}
|
||||
}
|
||||
|
||||
response.OkWithDetailed(gin.H{
|
||||
"rule": target,
|
||||
"override": entry,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// RuleToggleReq 启用/禁用请求。
|
||||
type RuleToggleReq struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
Disabled bool `json:"disabled"`
|
||||
SourceFile string `json:"source_file"`
|
||||
}
|
||||
|
||||
// RuleToggleApi 开启/关闭单条规则。
|
||||
func (w *WafOwaspApi) RuleToggleApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var req RuleToggleReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("解析失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.FailWithMessage("id 非法", c)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if req.Disabled {
|
||||
err = m.DisableRuleAndReload(req.ID, req.SourceFile)
|
||||
} else {
|
||||
err = m.EnableRuleAndReload(req.ID)
|
||||
}
|
||||
if err != nil {
|
||||
response.FailWithMessage("操作失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("已生效(已热重载 WAF)", c)
|
||||
}
|
||||
|
||||
// RuleOverrideReq 改写规则请求。
|
||||
type RuleOverrideReq struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
SourceFile string `json:"source_file"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// RuleOverrideApi 用户改写规则内容。
|
||||
func (w *WafOwaspApi) RuleOverrideApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var req RuleOverrideReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("解析失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.FailWithMessage("id 非法", c)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
response.FailWithMessage("content 不能为空", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算原始内容 hash(如果能从扫描里找到)
|
||||
originalHash := ""
|
||||
if files, err := wafowasp.ScanAllRules(m.OwaspRoot()); err == nil {
|
||||
for _, f := range files {
|
||||
for _, r := range f.Rules {
|
||||
if r.ID == req.ID {
|
||||
sum := sha256.Sum256([]byte(r.RawSnippet))
|
||||
originalHash = hex.EncodeToString(sum[:])
|
||||
if req.SourceFile == "" {
|
||||
req.SourceFile = r.File
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.OverrideRuleAndReload(req.ID, req.SourceFile, originalHash, req.Content); err != nil {
|
||||
response.FailWithMessage("操作失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("规则已改写并热重载", c)
|
||||
}
|
||||
|
||||
// RuleResetReq 还原规则。
|
||||
type RuleResetReq struct {
|
||||
ID int `json:"id" form:"id" binding:"required"`
|
||||
}
|
||||
|
||||
// RuleResetApi 还原某条规则为上游版本。
|
||||
func (w *WafOwaspApi) RuleResetApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var req RuleResetReq
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.FailWithMessage("解析失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.FailWithMessage("id 非法", c)
|
||||
return
|
||||
}
|
||||
if err := m.ResetRuleAndReload(req.ID); err != nil {
|
||||
response.FailWithMessage("操作失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("已还原并热重载", c)
|
||||
}
|
||||
|
||||
// AuditLogApi 返回 OWASP override 变更审计日志(倒序,最新在前)。
|
||||
func (w *WafOwaspApi) AuditLogApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
entries, err := m.Overrides().LoadAuditLog()
|
||||
if err != nil {
|
||||
response.FailWithMessage("读取变更日志失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(gin.H{"entries": entries, "total": len(entries)}, "获取成功", c)
|
||||
}
|
||||
|
||||
// CRSVarsGetApi 返回当前所有用户自定义 CRS 事务变量。
|
||||
func (w *WafOwaspApi) CRSVarsGetApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
vars, err := m.Overrides().GetCRSVars()
|
||||
if err != nil {
|
||||
response.FailWithMessage("读取失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(gin.H{"vars": vars}, "获取成功", c)
|
||||
}
|
||||
|
||||
// CRSVarSetReq 设置单个 CRS 变量请求体。
|
||||
type CRSVarSetReq struct {
|
||||
Key string `json:"key" binding:"required"` // 变量名,可带或不带 tx. 前缀
|
||||
Value string `json:"value"` // 变量值(允许空字符串以清空值)
|
||||
}
|
||||
|
||||
// CRSVarSetApi 设置单个 CRS 事务变量并热重载 WAF。
|
||||
func (w *WafOwaspApi) CRSVarSetApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var req CRSVarSetReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("解析失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := m.Overrides().SetCRSVar(req.Key, req.Value); err != nil {
|
||||
response.FailWithMessage("设置失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
// 热重载使新变量立即生效
|
||||
if err := m.Reload(); err != nil {
|
||||
response.FailWithMessage("变量已保存,但热重载失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("CRS 变量已更新并热重载", c)
|
||||
}
|
||||
|
||||
// CRSVarDeleteApi 删除单个 CRS 事务变量并热重载 WAF。
|
||||
func (w *WafOwaspApi) CRSVarDeleteApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
key := c.Query("key")
|
||||
if key == "" {
|
||||
response.FailWithMessage("key 不能为空", c)
|
||||
return
|
||||
}
|
||||
if err := m.Overrides().DeleteCRSVar(key); err != nil {
|
||||
response.FailWithMessage("删除失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
response.FailWithMessage("变量已删除,但热重载失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("CRS 变量已删除并热重载", c)
|
||||
}
|
||||
|
||||
// TuningGetApi 读取当前 tuning。
|
||||
func (w *WafOwaspApi) TuningGetApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
t, err := m.Overrides().GetTuning()
|
||||
if err != nil {
|
||||
response.FailWithMessage("读取失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(t, "获取成功", c)
|
||||
}
|
||||
|
||||
// TuningSetApi 写入 tuning 并热重载。
|
||||
//
|
||||
// 兼容说明:
|
||||
//
|
||||
// 历史上 TuningConfig 的 JSON key 使用 CRS 惯用的长名(如 inbound_anomaly_score_threshold),
|
||||
// 但前端最初写的是短名(如 inbound_threshold)。为避免字段名错配导致静默保存失败,
|
||||
// 这里显式读一层原始 map,同时吃"长名"和"短名"两套 key,最终组装 TuningConfig。
|
||||
func (w *WafOwaspApi) TuningSetApi(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
|
||||
}
|
||||
pickStr := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := raw[k]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
req := wafowasp.TuningConfig{
|
||||
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"),
|
||||
RuleEngine: pickStr("rule_engine"),
|
||||
EarlyBlocking: pickInt("early_blocking"),
|
||||
EnforceBodyProcessor: pickInt("enforce_bodyproc_urlencoded", "enforce_body_processor"),
|
||||
}
|
||||
if req.BlockingParanoia < 1 || req.BlockingParanoia > 4 {
|
||||
response.FailWithMessage("blocking_paranoia_level 必须在 1..4", c)
|
||||
return
|
||||
}
|
||||
if req.DetectionParanoia < req.BlockingParanoia || req.DetectionParanoia > 4 {
|
||||
response.FailWithMessage("detection_paranoia_level 必须 >= blocking_paranoia_level 且 <= 4", c)
|
||||
return
|
||||
}
|
||||
if req.InboundThreshold <= 0 {
|
||||
req.InboundThreshold = 7
|
||||
}
|
||||
if req.OutboundThreshold <= 0 {
|
||||
req.OutboundThreshold = 4
|
||||
}
|
||||
switch req.RuleEngine {
|
||||
case "On", "DetectionOnly", "Off":
|
||||
case "":
|
||||
req.RuleEngine = "On"
|
||||
default:
|
||||
response.FailWithMessage("rule_engine 取值应为 On/DetectionOnly/Off", c)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.ApplyTuning(req); err != nil {
|
||||
response.FailWithMessage("应用失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
global.GCONFIG_OWASP_MODE = req.RuleEngine
|
||||
global.GCONFIG_OWASP_BLOCK_THRESHOLD = int64(req.InboundThreshold)
|
||||
response.OkWithMessage("已生效(已热重载)", c)
|
||||
}
|
||||
|
||||
// ReloadApi 手动触发 WAF 热重载。
|
||||
func (w *WafOwaspApi) ReloadApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
wafowasp.InvalidateRuleCache()
|
||||
if err := m.Reload(); err != nil {
|
||||
response.FailWithMessage("reload 失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("reload 成功", c)
|
||||
}
|
||||
|
||||
// FilesListApi 列出 data/owasp 下所有可管理文件(只读参考)。
|
||||
func (w *WafOwaspApi) FilesListApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
files, err := wafowasp.ScanAllRules(m.OwaspRoot())
|
||||
if err != nil {
|
||||
response.FailWithMessage("扫描失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
type fileSum struct {
|
||||
File string `json:"file"`
|
||||
RuleCount int `json:"rule_count"`
|
||||
ModTime int64 `json:"mtime"`
|
||||
}
|
||||
out := make([]fileSum, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, fileSum{File: f.File, RuleCount: len(f.Rules), ModTime: f.ModTime})
|
||||
}
|
||||
response.OkWithDetailed(out, "获取成功", c)
|
||||
}
|
||||
|
||||
// FileContentApi 返回文件原始内容(仅限 data/owasp 下的 .conf)。
|
||||
func (w *WafOwaspApi) FileContentApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
rel := c.Query("path")
|
||||
if rel == "" {
|
||||
response.FailWithMessage("path 不能为空", c)
|
||||
return
|
||||
}
|
||||
if strings.Contains(rel, "..") {
|
||||
response.FailWithMessage("非法路径", c)
|
||||
return
|
||||
}
|
||||
abs := filepath.Join(m.OwaspRoot(), filepath.FromSlash(rel))
|
||||
cleanRoot, _ := filepath.Abs(m.OwaspRoot())
|
||||
cleanAbs, _ := filepath.Abs(abs)
|
||||
if !strings.HasPrefix(cleanAbs, cleanRoot) {
|
||||
response.FailWithMessage("路径越界", c)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(cleanAbs)
|
||||
if err != nil {
|
||||
response.FailWithMessage("读取失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(gin.H{"path": rel, "content": string(data)}, "获取成功", c)
|
||||
}
|
||||
|
||||
// DryRunReq 沙盒测试请求。
|
||||
type DryRunReq struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// DryRunHit 单条规则命中记录。
|
||||
type DryRunHit struct {
|
||||
ID int `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
Phase int `json:"phase"`
|
||||
File string `json:"file"`
|
||||
Tags []string `json:"tags"`
|
||||
Paranoia int `json:"paranoia"` // 从 tag:paranoia-level/N 提取,0 表示未标记(任意 PL 生效)
|
||||
}
|
||||
|
||||
// DryRunResp 沙盒测试响应。
|
||||
type DryRunResp struct {
|
||||
Interrupted bool `json:"interrupted"`
|
||||
InterruptID int `json:"interrupt_rule_id"`
|
||||
InterruptData string `json:"interrupt_data"`
|
||||
AnomalyScore int `json:"anomaly_score"` // = blocking_inbound_anomaly_score(CRS 4.x 用于拦截判定的核心分值)
|
||||
DetectionScore int `json:"detection_score"` // = detection_inbound_anomaly_score(含观察模式命中)
|
||||
BlockingThreshold int `json:"blocking_threshold"` // 当前配置的入站拦截阈值(来自 tuning)
|
||||
BlockingParanoia int `json:"blocking_paranoia"` // 当前拦截 PL
|
||||
DetectionParanoia int `json:"detection_paranoia"` // 当前检测 PL
|
||||
Hits []DryRunHit `json:"hits"`
|
||||
}
|
||||
|
||||
// TestDryRunApi 沙盒测试。模拟一次请求并返回命中规则、累计 anomaly 分、拦截与否。
|
||||
func (w *WafOwaspApi) TestDryRunApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
inst := m.Current()
|
||||
if inst == nil || inst.WAF == nil {
|
||||
response.FailWithMessage("WAF 实例未就绪", c)
|
||||
return
|
||||
}
|
||||
|
||||
var req DryRunReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("解析失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if req.Method == "" {
|
||||
req.Method = "GET"
|
||||
}
|
||||
if req.URL == "" {
|
||||
req.URL = "/"
|
||||
}
|
||||
parsedURL, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
response.FailWithMessage("url 不合法: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
tx := inst.WAF.NewTransaction()
|
||||
defer tx.Close()
|
||||
|
||||
tx.ProcessConnection("127.0.0.1", 0, "127.0.0.1", 80)
|
||||
tx.ProcessURI(parsedURL.RequestURI(), req.Method, "HTTP/1.1")
|
||||
for k, v := range req.Headers {
|
||||
tx.AddRequestHeader(k, v)
|
||||
}
|
||||
if parsedURL.Host != "" {
|
||||
tx.AddRequestHeader("Host", parsedURL.Host)
|
||||
}
|
||||
|
||||
message := "未拦截"
|
||||
if it := tx.ProcessRequestHeaders(); it != nil {
|
||||
message = "规则头阶段拦截"
|
||||
response.OkWithDetailed(buildDryRunResp(tx, it), message, c)
|
||||
return
|
||||
}
|
||||
if req.Body != "" {
|
||||
if _, _, err := tx.WriteRequestBody([]byte(req.Body)); err != nil {
|
||||
response.FailWithMessage("写入 body 失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if it, err := tx.ProcessRequestBody(); err != nil {
|
||||
response.FailWithMessage("处理 body 失败: "+err.Error(), c)
|
||||
return
|
||||
} else if it != nil {
|
||||
resp := buildDryRunResp(tx, it)
|
||||
fillTuningMeta(m, &resp)
|
||||
response.OkWithDetailed(resp, "规则体阶段拦截", c)
|
||||
return
|
||||
}
|
||||
|
||||
resp := buildDryRunResp(tx, tx.Interruption())
|
||||
fillTuningMeta(m, &resp)
|
||||
response.OkWithDetailed(resp, message, c)
|
||||
}
|
||||
|
||||
// fillTuningMeta 把当前 tuning 的阈值与 PL 写入响应,便于前端做"命中但分不够"诊断提示。
|
||||
func fillTuningMeta(m *wafowasp.OwaspManager, resp *DryRunResp) {
|
||||
if m == nil || m.Overrides() == nil {
|
||||
return
|
||||
}
|
||||
t, err := m.Overrides().GetTuning()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.BlockingThreshold = t.InboundThreshold
|
||||
resp.BlockingParanoia = t.BlockingParanoia
|
||||
resp.DetectionParanoia = t.DetectionParanoia
|
||||
}
|
||||
|
||||
// UpdateCheckApi 检查远端规则库是否有新版本。具体实现见 wafowasp/upgrader.go。
|
||||
// 未实现远端时返回当前版本。
|
||||
func (w *WafOwaspApi) UpdateCheckApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
info, err := wafowasp.CheckUpgrade(m)
|
||||
if err != nil {
|
||||
response.FailWithMessage("检查升级失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(info, "获取成功", c)
|
||||
}
|
||||
|
||||
// UpdateApplyApi 执行升级。后台异步处理,接口立即返回。
|
||||
func (w *WafOwaspApi) UpdateApplyApi(c *gin.Context) {
|
||||
m := w.manager(c)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := wafowasp.ApplyUpgrade(m); err != nil {
|
||||
// 失败信息写入内部队列,由前端通过 WS 获取
|
||||
wafowasp.NotifyUpgradeResult(false, err.Error())
|
||||
return
|
||||
}
|
||||
wafowasp.NotifyUpgradeResult(true, "升级完成并已热重载")
|
||||
}()
|
||||
response.OkWithMessage("升级已触发,结果将通过后台消息推送", c)
|
||||
}
|
||||
|
||||
// UsageDocApi 返回 OWASP 规则管理的使用讲解 Markdown。
|
||||
func (w *WafOwaspApi) UsageDocApi(c *gin.Context) {
|
||||
content := wafowasp.UsageDocMarkdown()
|
||||
response.OkWithDetailed(gin.H{"content": content}, "获取成功", c)
|
||||
}
|
||||
|
||||
// extractParanoiaFromTags 从 CRS 规则 tags 里解析 paranoia 级别。
|
||||
// CRS 里的 paranoia 以 tag 形式标注,格式为 "paranoia-level/1" ~ "paranoia-level/4"。
|
||||
// 未找到时返回 0,表示该规则在任意 PL 下都生效(如 initialization / evaluation 类规则)。
|
||||
func extractParanoiaFromTags(tags []string) int {
|
||||
const prefix = "paranoia-level/"
|
||||
for _, t := range tags {
|
||||
if strings.HasPrefix(t, prefix) {
|
||||
if v, err := strconv.Atoi(t[len(prefix):]); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// buildDryRunResp 从 coraza transaction 聚合命中结果。
|
||||
func buildDryRunResp(tx types.Transaction, it *types.Interruption) DryRunResp {
|
||||
resp := DryRunResp{}
|
||||
if it != nil {
|
||||
resp.Interrupted = true
|
||||
resp.InterruptID = it.RuleID
|
||||
resp.InterruptData = it.Data
|
||||
}
|
||||
for _, mr := range tx.MatchedRules() {
|
||||
if mr.Message() == "" {
|
||||
continue
|
||||
}
|
||||
rm := mr.Rule()
|
||||
hit := DryRunHit{
|
||||
ID: rm.ID(),
|
||||
Message: mr.Message(),
|
||||
Phase: int(rm.Phase()),
|
||||
File: rm.File(),
|
||||
Tags: rm.Tags(),
|
||||
}
|
||||
sev := rm.Severity()
|
||||
hit.Severity = sev.String()
|
||||
hit.Paranoia = extractParanoiaFromTags(hit.Tags)
|
||||
resp.Hits = append(resp.Hits, hit)
|
||||
}
|
||||
// 读取 anomaly_score。
|
||||
// CRS 4.x:真正用于拦截判定的是 tx.blocking_inbound_anomaly_score(phase 2 末尾判定)。
|
||||
// detection_inbound_anomaly_score 是包含观察模式(PL > blocking PL)命中的总分。
|
||||
// CRS 3.x:使用 tx.anomaly_score。
|
||||
// 注:tx.anomaly_score 是由 RESPONSE-980 phase:5 才聚合的,沙盒里因为没有响应体,所以可能一直是 0。
|
||||
if txState, ok := tx.(plugintypes.TransactionState); ok {
|
||||
txCol := txState.Variables().TX()
|
||||
readInt := func(key string) int {
|
||||
if vals := txCol.Get(key); len(vals) > 0 {
|
||||
if v, err := strconv.Atoi(vals[0]); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// 按优先级取 blocking 分。
|
||||
for _, key := range []string{
|
||||
"blocking_inbound_anomaly_score",
|
||||
"anomaly_score",
|
||||
"inbound_anomaly_score",
|
||||
} {
|
||||
if v := readInt(key); v > resp.AnomalyScore {
|
||||
resp.AnomalyScore = v
|
||||
}
|
||||
}
|
||||
resp.DetectionScore = readInt("detection_inbound_anomaly_score")
|
||||
// 兜底:若上述都为 0,尝试从 per-PL 累加器相加(某些极少数场景 blocking_inbound_anomaly_score 未被汇总)。
|
||||
if resp.AnomalyScore == 0 {
|
||||
sum := 0
|
||||
for i := 1; i <= 4; i++ {
|
||||
sum += readInt("inbound_anomaly_score_pl" + strconv.Itoa(i))
|
||||
}
|
||||
resp.AnomalyScore = sum
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
+25
-2
@@ -8,6 +8,7 @@ import (
|
||||
"SamWaf/enums"
|
||||
"SamWaf/global"
|
||||
"SamWaf/globalobj"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/iplocation"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/wafenginmodel"
|
||||
@@ -266,8 +267,30 @@ func (m *wafSystenService) run() {
|
||||
// 创建 Snowflake 实例
|
||||
global.GWAF_SNOWFLAKE_GEN = wafsnowflake.NewSnowflake(1609459200000, 1, 1) // 设置epoch时间、机器ID和数据中心ID
|
||||
|
||||
// 创建owasp
|
||||
global.GWAF_OWASP = wafowasp.NewWafOWASP(true, utils.GetCurrentDir())
|
||||
// 创建owasp 管理器(支持热重载)
|
||||
global.GWAF_OWASP_MANAGER = wafowasp.NewOwaspManager(utils.GetCurrentDir())
|
||||
global.GWAF_OWASP = global.GWAF_OWASP_MANAGER.Current()
|
||||
// 注入升级上下文:避免 wafowasp → global 的循环依赖
|
||||
wafowasp.ConfigureUpgrader(wafowasp.UpgradeConfig{
|
||||
UpdateVersionURL: global.GUPDATE_VERSION_URL,
|
||||
NotifyFunc: func(success bool, msg string) {
|
||||
if global.GQEQUE_MESSAGE_DB == nil {
|
||||
return
|
||||
}
|
||||
successStr := "false"
|
||||
if success {
|
||||
successStr = "true"
|
||||
}
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.UpdateResultMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{
|
||||
OperaType: "OWASP 规则升级",
|
||||
Server: global.GWAF_CUSTOM_SERVER_NAME,
|
||||
},
|
||||
Msg: msg,
|
||||
Success: successStr,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 初始化ip ban
|
||||
wafipban.InitIPBanManager(global.GCACHE_WAFCACHE)
|
||||
|
||||
@@ -27,6 +27,8 @@ var (
|
||||
GCONFIG_ENABLE_STRICT_IP_BINDING int64 = 1 // 是否启用严格IP绑定 1启用 0禁用
|
||||
|
||||
GCONFIG_RECORD_ENABLE_OWASP int64 = 0 //启动OWASP数据检测
|
||||
GCONFIG_OWASP_MODE string = "On" //OWASP 检测引擎工作模式: On(拦截) / DetectionOnly(观察/仅记录) / Off(关闭)
|
||||
GCONFIG_OWASP_BLOCK_THRESHOLD int64 = 7 //OWASP 入站 anomaly score 阈值(官方默认 5,我们宽松到 7)
|
||||
GCONFIG_RECORD_ENABLE_HTTP_80 int64 = 0 //启动80端口服务(为自动申请证书使用 HTTP文件验证类型,DNS验证不需要)
|
||||
GCONFIG_RECORD_SSLOrder_EXPIRE_DAY int64 = 30 // 提前多少天进行自动申请
|
||||
GCONFIG_RECORD_SSL_IP_CERT_IP string = "" // 获取IP证书时的IP地址
|
||||
|
||||
+2
-1
@@ -95,7 +95,8 @@ var (
|
||||
GWAF_DLP dlpheader.EngineAPI // 脱敏引擎
|
||||
GWAF_DLP_CONFIG string // 脱敏引擎配置数据
|
||||
|
||||
GWAF_OWASP *wafowasp.WafOWASP //owasp引擎
|
||||
GWAF_OWASP *wafowasp.WafOWASP //owasp引擎(兼容保留:由 GWAF_OWASP_MANAGER.Current() 更新;请勿直接赋值新实例,否则热重载会失效)
|
||||
GWAF_OWASP_MANAGER *wafowasp.OwaspManager //owasp 管理器(支持热重载)
|
||||
/**链聚合**/
|
||||
GWAF_CHAN_HOST = make(chan model.Hosts, 10) //主机链
|
||||
GWAF_CHAN_ENGINE = make(chan int, 10) //引擎链
|
||||
|
||||
@@ -56,6 +56,7 @@ type ApiGroup struct {
|
||||
WafOPlatformLogRouter
|
||||
WafOPlatformDocRouter
|
||||
WafDataRetentionRouter
|
||||
WafOwaspRouter
|
||||
}
|
||||
type PublicApiGroup struct {
|
||||
LoginRouter
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"SamWaf/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WafOwaspRouter struct{}
|
||||
|
||||
// InitWafOwaspRouter 注册 OWASP 规则管理相关路由。
|
||||
// 前缀 /api/v1/owasp/,大部分为写操作,需要登录后台鉴权(已由外层 Auth 中间件保护)。
|
||||
func (r *WafOwaspRouter) InitWafOwaspRouter(group *gin.RouterGroup) {
|
||||
owaspApi := api.APIGroupAPP.WafOwaspApi
|
||||
g := group.Group("")
|
||||
|
||||
g.GET("/api/v1/owasp/rules", owaspApi.RulesListApi)
|
||||
g.GET("/api/v1/owasp/rule_detail", owaspApi.RuleDetailApi)
|
||||
g.POST("/api/v1/owasp/rule_toggle", owaspApi.RuleToggleApi)
|
||||
g.POST("/api/v1/owasp/rule_override", owaspApi.RuleOverrideApi)
|
||||
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)
|
||||
g.GET("/api/v1/owasp/tuning", owaspApi.TuningGetApi)
|
||||
g.POST("/api/v1/owasp/tuning", owaspApi.TuningSetApi)
|
||||
g.POST("/api/v1/owasp/reload", owaspApi.ReloadApi)
|
||||
g.POST("/api/v1/owasp/test/dry_run", owaspApi.TestDryRunApi)
|
||||
|
||||
// 升级相关(在 upgrader 步骤里实现具体 handler)
|
||||
g.GET("/api/v1/owasp/update/check", owaspApi.UpdateCheckApi)
|
||||
g.POST("/api/v1/owasp/update/apply", owaspApi.UpdateApplyApi)
|
||||
|
||||
// 变更审计日志
|
||||
g.GET("/api/v1/owasp/audit_log", owaspApi.AuditLogApi)
|
||||
|
||||
// CRS 事务变量管理
|
||||
g.GET("/api/v1/owasp/crs_vars", owaspApi.CRSVarsGetApi)
|
||||
g.POST("/api/v1/owasp/crs_var", owaspApi.CRSVarSetApi)
|
||||
g.DELETE("/api/v1/owasp/crs_var", owaspApi.CRSVarDeleteApi)
|
||||
|
||||
// 使用文档
|
||||
g.GET("/api/v1/owasp/usage/doc", owaspApi.UsageDocApi)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"SamWaf/common/uuid"
|
||||
"SamWaf/global"
|
||||
"SamWaf/utils"
|
||||
"SamWaf/wafowasp"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"github.com/denisbrodbeck/machineid"
|
||||
@@ -134,6 +135,8 @@ func LoadAndInitConfig() {
|
||||
config.Set("zlog.debug_enable", global.GWAF_LOG_DEBUG_ENABLE)
|
||||
configChanged = true
|
||||
}
|
||||
// 同步 OWASP 详情日志开关(debug 开才采集完整 collection,否则热路径零额外开销)
|
||||
wafowasp.SetDebugEnabled(global.GWAF_LOG_DEBUG_ENABLE)
|
||||
|
||||
// 添加数据库日志开关配置
|
||||
if config.IsSet("zlog.db_debug_enable") {
|
||||
|
||||
+66
-12
@@ -1,16 +1,21 @@
|
||||
package wafenginecore
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/detection"
|
||||
"SamWaf/model/wafenginmodel"
|
||||
"SamWaf/wafowasp"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CheckOwasp OWASP CRS 检测。
|
||||
//
|
||||
// 注意:本函数假设调用方已经判断过 OWASP 是否启用(参考 wafengine.go 调用处),
|
||||
// 内部不再解析 DEFENSE_JSON,避免热路径上重复 JSON 反序列化。
|
||||
func (waf *WafEngine) CheckOwasp(r *http.Request, weblogbean *innerbean.WebLog, formValue url.Values, hostTarget *wafenginmodel.HostSafe, globalHostTarget *wafenginmodel.HostSafe) detection.Result {
|
||||
result := detection.Result{
|
||||
JumpGuardResult: false,
|
||||
@@ -18,19 +23,68 @@ func (waf *WafEngine) CheckOwasp(r *http.Request, weblogbean *innerbean.WebLog,
|
||||
Title: "",
|
||||
Content: "",
|
||||
}
|
||||
hostDefense := model.ParseHostsDefense(hostTarget.Host.DEFENSE_JSON)
|
||||
if global.GCONFIG_RECORD_ENABLE_OWASP == 1 || hostDefense.DEFENSE_OWASP_SET == 1 {
|
||||
isInteeruption, interruption, err := global.GWAF_OWASP.ProcessRequest(r, *weblogbean)
|
||||
if err == nil && isInteeruption {
|
||||
result.IsBlock = true
|
||||
// 使用中断对象中的详细信息
|
||||
if interruption.Data != "" {
|
||||
result.Title = "OWASP:" + strconv.Itoa(interruption.RuleID) + interruption.Data
|
||||
}
|
||||
result.Content = "访问不合法"
|
||||
weblogbean.RISK_LEVEL = 2
|
||||
|
||||
// 优先取 manager 当前实例(支持热重载原子切换),回退到旧的 GWAF_OWASP 句柄
|
||||
var inst *wafowasp.WafOWASP
|
||||
if global.GWAF_OWASP_MANAGER != nil {
|
||||
inst = global.GWAF_OWASP_MANAGER.Current()
|
||||
}
|
||||
if inst == nil {
|
||||
inst = global.GWAF_OWASP
|
||||
}
|
||||
if inst == nil {
|
||||
// 没有可用实例:热重载失败或初始化未完成。记一条 warn,避免静默放行
|
||||
zlog.Warn("CheckOwasp", "OWASP WAF 实例未就绪,本次检测被跳过")
|
||||
return result
|
||||
}
|
||||
if !inst.IsActive {
|
||||
// manager 被 SetActive(false) 或显式关闭后,ProcessRequest 自身也会短路,这里提前记录便于诊断
|
||||
if wafowasp.IsDebugEnabled() {
|
||||
zlog.Debug("CheckOwasp", "OWASP 实例 IsActive=false,本次检测被跳过")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
isInteeruption, interruption, err := inst.ProcessRequest(r, weblogbean)
|
||||
if err != nil {
|
||||
// Coraza 处理异常:记录错误但不影响请求(fail-open),避免把引擎故障误判为攻击
|
||||
zlog.Error("CheckOwasp ProcessRequest err", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"method": r.Method,
|
||||
"uri": r.URL.RequestURI(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 防御:理论上 Coraza 保证 isInterrupted ↔ interruption!=nil 一致,
|
||||
// 但出于健壮性考虑,只要任一命中即视为拦截
|
||||
if isInteeruption || interruption != nil {
|
||||
ruleID := 0
|
||||
if interruption != nil {
|
||||
ruleID = interruption.RuleID
|
||||
}
|
||||
result.IsBlock = true
|
||||
result.Title = "OWASP:" + strconv.Itoa(ruleID)
|
||||
result.Content = "访问不合法"
|
||||
weblogbean.RISK_LEVEL = 2
|
||||
|
||||
zlog.Info("CheckOwasp blocked", map[string]interface{}{
|
||||
"rule_id": ruleID,
|
||||
"method": r.Method,
|
||||
"uri": r.URL.RequestURI(),
|
||||
"src_ip": weblogbean.SRC_IP,
|
||||
"host": r.Host,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 未拦截:debug 模式下记录一下执行痕迹,方便核对是否真的进入 OWASP 检测
|
||||
if wafowasp.IsDebugEnabled() {
|
||||
zlog.Debug("CheckOwasp pass", map[string]interface{}{
|
||||
"method": r.Method,
|
||||
"uri": r.URL.RequestURI(),
|
||||
"host": r.Host,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ func (waf *WafEngine) Error() string {
|
||||
func inferAttackType(ruleTitle string) string {
|
||||
ruleTitle = strings.ToLower(ruleTitle)
|
||||
|
||||
// OWASP CRS 拦截:Title 格式为 "owasp:<ruleID>",需在其他关键词匹配之前优先处理
|
||||
if strings.HasPrefix(ruleTitle, "owasp:") {
|
||||
return "owasp_attack"
|
||||
}
|
||||
|
||||
// CC攻击
|
||||
if strings.Contains(ruleTitle, "cc") || strings.Contains(ruleTitle, "频次") || strings.Contains(ruleTitle, "rate limit") {
|
||||
return "cc_attack"
|
||||
@@ -567,9 +572,11 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
//检测OWASP
|
||||
if handleBlock(waf.CheckOwasp) {
|
||||
return
|
||||
//检测OWASP(全局开关或站点级 owaspset 启用时才进入)
|
||||
if global.GCONFIG_RECORD_ENABLE_OWASP == 1 || hostDefense.DEFENSE_OWASP_SET == 1 {
|
||||
if handleBlock(waf.CheckOwasp) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 添加防盗链检查
|
||||
|
||||
@@ -148,6 +148,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
|
||||
router.ApiGroupApp.InitLogFileWriteRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitIPLocationRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafDataRetentionRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafOwaspRouter(RouterGroup)
|
||||
}
|
||||
|
||||
// 仅允许后台 Token 登录访问,拒绝 API Key 访问(安全敏感接口)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
|
||||
)
|
||||
|
||||
// TestAnomalyScoreExtraction 跑一条实际的 SQLi 请求,验证 TransactionState 断言是否成功、
|
||||
// 以及 blocking_inbound_anomaly_score / anomaly_score 等 TX 键是否有值。
|
||||
func TestAnomalyScoreExtraction(t *testing.T) {
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
repoRoot := filepath.Dir(filepath.Dir(thisFile))
|
||||
owaspRoot := filepath.Join(repoRoot, "cmd", "samwaf", "exedata", "owasp")
|
||||
|
||||
cfg := coraza.NewWAFConfig().
|
||||
WithDirectivesFromFile(filepath.Join(owaspRoot, "coraza.conf")).
|
||||
WithDirectivesFromFile(filepath.Join(owaspRoot, "coreruleset", "crs-setup.conf"))
|
||||
|
||||
rulesDir := filepath.Join(owaspRoot, "coreruleset", "rules")
|
||||
matches, _ := filepath.Glob(filepath.Join(rulesDir, "*.conf"))
|
||||
for _, p := range matches {
|
||||
cfg = cfg.WithDirectivesFromFile(p)
|
||||
}
|
||||
waf, err := coraza.NewWAF(cfg)
|
||||
if err != nil {
|
||||
t.Skipf("skip: can't init waf: %v", err)
|
||||
}
|
||||
|
||||
tx := waf.NewTransaction()
|
||||
defer tx.Close()
|
||||
tx.ProcessConnection("127.0.0.1", 0, "127.0.0.1", 80)
|
||||
tx.ProcessURI("/index.php?id=1%20AND%201=1", "GET", "HTTP/1.1")
|
||||
tx.AddRequestHeader("Host", "example.com")
|
||||
tx.AddRequestHeader("User-Agent", "curl/7.88")
|
||||
|
||||
if it := tx.ProcessRequestHeaders(); it != nil {
|
||||
t.Logf("phase1 blocked by rule %d: %s", it.RuleID, it.Data)
|
||||
}
|
||||
if _, err := tx.ProcessRequestBody(); err != nil {
|
||||
t.Fatalf("process body: %v", err)
|
||||
}
|
||||
if it := tx.Interruption(); it != nil {
|
||||
t.Logf("blocked by rule %d action=%s data=%s", it.RuleID, it.Action, it.Data)
|
||||
} else {
|
||||
t.Logf("not blocked")
|
||||
}
|
||||
|
||||
txState, ok := tx.(plugintypes.TransactionState)
|
||||
if !ok {
|
||||
t.Fatalf("transaction does NOT implement plugintypes.TransactionState - need alternative approach")
|
||||
}
|
||||
t.Logf("transaction implements TransactionState: OK")
|
||||
|
||||
txCol := txState.Variables().TX()
|
||||
keys := []string{
|
||||
"blocking_inbound_anomaly_score",
|
||||
"inbound_anomaly_score",
|
||||
"blocking_anomaly_score",
|
||||
"anomaly_score",
|
||||
"inbound_anomaly_score_pl1",
|
||||
"inbound_anomaly_score_pl2",
|
||||
"critical_anomaly_score",
|
||||
}
|
||||
for _, k := range keys {
|
||||
vals := txCol.Get(k)
|
||||
t.Logf("tx:%s = %v", k, vals)
|
||||
}
|
||||
// Also dump keys with "anomaly" in name
|
||||
for _, md := range txCol.FindAll() {
|
||||
k := md.Key()
|
||||
if len(k) > 6 {
|
||||
t.Logf("ALL tx:%s = %s", k, md.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
-88
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
"github.com/corazawaf/coraza/v3/collection"
|
||||
@@ -17,6 +19,60 @@ import (
|
||||
"github.com/corazawaf/coraza/v3/types/variables"
|
||||
)
|
||||
|
||||
// maxRequestBodyBytes 请求体读取上限,与 coraza.conf 中的 SecRequestBodyLimit 保持一致(13MB)。
|
||||
// 防止 io.ReadAll 在极端大请求体下出现无限分配。
|
||||
const maxRequestBodyBytes = 13 * 1024 * 1024
|
||||
|
||||
// debugEnabled 是否开启 OWASP 详细日志(慢路径:采集全部 collection)。
|
||||
// 本值由外部 bootstrap(例如 global.GWAF_LOG_DEBUG_ENABLE 变化时)通过 SetDebugEnabled 同步。
|
||||
// 使用 atomic.Bool 避免与请求并发读的 race。
|
||||
var debugEnabled atomic.Bool
|
||||
|
||||
// SetDebugEnabled 开启/关闭 OWASP 拦截详情的慢路径日志采集。
|
||||
// 关闭时 handleInterruption 仅记录简短的 MatchedRules 信息,内存/CPU 友好。
|
||||
func SetDebugEnabled(v bool) {
|
||||
debugEnabled.Store(v)
|
||||
}
|
||||
|
||||
// IsDebugEnabled 返回当前是否开启详细日志。
|
||||
func IsDebugEnabled() bool {
|
||||
return debugEnabled.Load()
|
||||
}
|
||||
|
||||
// engineMode 当前引擎模式: "On" / "DetectionOnly" / "Off"。
|
||||
// 由上层(main 启动、TuningSetApi、系统配置任务)通过 SetEngineMode 同步进来。
|
||||
// Coraza 的 SecRuleEngine 指令已经负责实际拦截语义,这里只是让热路径能"知道"
|
||||
// 当前是不是观察模式,以便命中而未拦截时用 INFO 记录"本该拦截"的日志。
|
||||
var engineMode atomic.Value // string
|
||||
|
||||
// SetEngineMode 设置当前引擎工作模式。建议值:"On" / "DetectionOnly" / "Off"。
|
||||
// 空串按 "On" 处理。该值只影响 INFO 级的"观察模式本该拦截"日志,不改变 Coraza 实际行为。
|
||||
func SetEngineMode(mode string) {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
mode = "On"
|
||||
}
|
||||
engineMode.Store(mode)
|
||||
}
|
||||
|
||||
// GetEngineMode 返回当前引擎工作模式,未设置时返回 "On"。
|
||||
func GetEngineMode() string {
|
||||
if v := engineMode.Load(); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "On"
|
||||
}
|
||||
|
||||
// bodyBufferPool 用于回放 r.Body 时复用底层缓冲区,降低 GC 压力。
|
||||
var bodyBufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
b := make([]byte, 0, 4096)
|
||||
return &b
|
||||
},
|
||||
}
|
||||
|
||||
// CustomLogger 自定义日志记录器,用于集成 zlog
|
||||
type CustomLogger struct{}
|
||||
|
||||
@@ -52,18 +108,14 @@ func NewWafOWASP(isActive bool, currentDir string) *WafOWASP {
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRequest 处理 HTTP 请求
|
||||
func (w *WafOWASP) ProcessRequest(r *http.Request, weblog innerbean.WebLog) (bool, *types.Interruption, error) {
|
||||
if !w.IsActive {
|
||||
// ProcessRequest 处理 HTTP 请求。weblog 使用指针避免热路径上的结构体值拷贝(WebLog 可能内嵌 MB 级 BODY/RES_BODY)。
|
||||
func (w *WafOWASP) ProcessRequest(r *http.Request, weblog *innerbean.WebLog) (bool, *types.Interruption, error) {
|
||||
if !w.IsActive || w.WAF == nil {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
tx := w.WAF.NewTransaction()
|
||||
defer func() {
|
||||
if err := tx.Close(); err != nil {
|
||||
w.logger.Error("Failed to close transaction", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}()
|
||||
defer tx.Close()
|
||||
|
||||
// 1. 处理连接信息
|
||||
if err := w.processConnection(tx, weblog, r); err != nil {
|
||||
@@ -71,21 +123,17 @@ func (w *WafOWASP) ProcessRequest(r *http.Request, weblog innerbean.WebLog) (boo
|
||||
}
|
||||
|
||||
// 2. 处理请求行
|
||||
if err := w.processRequestLine(tx, r); err != nil {
|
||||
return false, nil, fmt.Errorf("request line processing error: %v", err)
|
||||
}
|
||||
w.processRequestLine(tx, r)
|
||||
|
||||
// 3. 处理请求头
|
||||
if err := w.processRequestHeaders(tx, r); err != nil {
|
||||
return false, nil, fmt.Errorf("request headers processing error: %v", err)
|
||||
}
|
||||
w.processRequestHeaders(tx, r)
|
||||
|
||||
// 4. 检查请求头阶段的中断
|
||||
if it := tx.ProcessRequestHeaders(); it != nil {
|
||||
return w.handleInterruption(tx, it)
|
||||
}
|
||||
|
||||
// 5. 处理请求体
|
||||
// 5. 处理请求体(流式,避免多份副本)
|
||||
if err := w.processRequestBody(tx, r, weblog); err != nil {
|
||||
return false, nil, fmt.Errorf("request body processing error: %v", err)
|
||||
}
|
||||
@@ -105,12 +153,135 @@ func (w *WafOWASP) ProcessRequest(r *http.Request, weblog innerbean.WebLog) (boo
|
||||
return true, nil, nil
|
||||
}
|
||||
|
||||
// DetectionOnly 模式:Coraza 不会触发 disruptive action,tx.IsInterrupted 为 false。
|
||||
// 这里补一条 INFO 日志,告知管理员"本次若在 On 模式下会被拦截",否则观察模式下用户看不到拦截痕迹。
|
||||
if GetEngineMode() == "DetectionOnly" {
|
||||
w.logDetectionOnlyWouldBlock(tx, r, weblog)
|
||||
}
|
||||
|
||||
// 未拦截但 debug 模式打开:打出 Coraza 事务内所有 MatchedRules 和关键累计分,
|
||||
// 方便排查"真实访问没拦、沙盒能拦"这类差异(原因通常是:凑分规则未命中 / 阈值调太高 / 规则被禁用)。
|
||||
if debugEnabled.Load() {
|
||||
w.logNonInterruptTrace(tx)
|
||||
}
|
||||
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// logDetectionOnlyWouldBlock 观察模式下记录"本该被拦截"的请求。
|
||||
//
|
||||
// 判定条件:blocking_inbound_anomaly_score >= inbound_anomaly_score_threshold。
|
||||
// 即:按当前 blocking_paranoia 口径累积的 PL 分数已经达到了拦截阈值。
|
||||
//
|
||||
// 由于 DetectionOnly 下 Coraza 会抑制 disruptive action,tx.IsInterrupted() 始终为 false;
|
||||
// 用户只有在 On 模式切回去之前没机会看到任何命中记录,这里用 INFO 级别明确提示。
|
||||
func (w *WafOWASP) logDetectionOnlyWouldBlock(tx types.Transaction, r *http.Request, weblog *innerbean.WebLog) {
|
||||
txState, ok := tx.(plugintypes.TransactionState)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
readInt := func(name string) int {
|
||||
if vals := txState.Variables().TX().Get(name); len(vals) > 0 {
|
||||
if v, err := strconv.Atoi(vals[0]); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
threshold := readInt("inbound_anomaly_score_threshold")
|
||||
score := readInt("blocking_inbound_anomaly_score")
|
||||
if score == 0 {
|
||||
// 某些 CRS 版本下变量名不同,兜底再尝试两个常见别名
|
||||
if alt := readInt("anomaly_score"); alt > 0 {
|
||||
score = alt
|
||||
} else if alt := readInt("inbound_anomaly_score"); alt > 0 {
|
||||
score = alt
|
||||
}
|
||||
}
|
||||
if threshold <= 0 || score < threshold {
|
||||
return
|
||||
}
|
||||
|
||||
matched := tx.MatchedRules()
|
||||
hits := make([]map[string]interface{}, 0, len(matched))
|
||||
for _, mr := range matched {
|
||||
if mr.Message() == "" {
|
||||
continue
|
||||
}
|
||||
rm := mr.Rule()
|
||||
hits = append(hits, map[string]interface{}{
|
||||
"id": rm.ID(),
|
||||
"phase": rm.Phase(),
|
||||
"severity": rm.Severity().String(),
|
||||
"msg": mr.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
srcIP := ""
|
||||
if weblog != nil {
|
||||
srcIP = weblog.SRC_IP
|
||||
}
|
||||
|
||||
zlog.Info("OWASP DetectionOnly 命中但未拦截(观察模式)", map[string]interface{}{
|
||||
"would_block": true,
|
||||
"score": score,
|
||||
"threshold": threshold,
|
||||
"matched_total": len(hits),
|
||||
"matched": hits,
|
||||
"method": r.Method,
|
||||
"uri": r.URL.RequestURI(),
|
||||
"host": r.Host,
|
||||
"src_ip": srcIP,
|
||||
})
|
||||
}
|
||||
|
||||
// logNonInterruptTrace 在 debug 模式下输出未拦截事务的命中清单与 anomaly 分,供线下排查。
|
||||
// 注:只有 debug 开关打开才会调用,热路径无开销。
|
||||
func (w *WafOWASP) logNonInterruptTrace(tx types.Transaction) {
|
||||
matched := tx.MatchedRules()
|
||||
hits := make([]map[string]interface{}, 0, len(matched))
|
||||
for _, mr := range matched {
|
||||
if mr.Message() == "" {
|
||||
continue
|
||||
}
|
||||
rm := mr.Rule()
|
||||
hits = append(hits, map[string]interface{}{
|
||||
"id": rm.ID(),
|
||||
"phase": rm.Phase(),
|
||||
"severity": rm.Severity().String(),
|
||||
"msg": mr.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
readInt := func(key string) int {
|
||||
if txState, ok := tx.(plugintypes.TransactionState); ok {
|
||||
if vals := txState.Variables().TX().Get(key); len(vals) > 0 {
|
||||
if v, err := strconv.Atoi(vals[0]); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
zlog.Debug("OWASP tx non-interrupt trace", map[string]interface{}{
|
||||
"matched_total": len(hits),
|
||||
"matched": hits,
|
||||
"blocking_inbound_score": readInt("blocking_inbound_anomaly_score"),
|
||||
"detection_inbound_score": readInt("detection_inbound_anomaly_score"),
|
||||
"inbound_anomaly_score_pl1": readInt("inbound_anomaly_score_pl1"),
|
||||
"inbound_anomaly_score_pl2": readInt("inbound_anomaly_score_pl2"),
|
||||
"inbound_anomaly_threshold": readInt("inbound_anomaly_score_threshold"),
|
||||
})
|
||||
}
|
||||
|
||||
// processConnection 处理连接信息
|
||||
func (w *WafOWASP) processConnection(tx types.Transaction, weblog innerbean.WebLog, r *http.Request) error {
|
||||
clientIP := weblog.SRC_IP
|
||||
func (w *WafOWASP) processConnection(tx types.Transaction, weblog *innerbean.WebLog, r *http.Request) error {
|
||||
clientIP := ""
|
||||
if weblog != nil {
|
||||
clientIP = weblog.SRC_IP
|
||||
}
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
if idx := strings.LastIndex(clientIP, ":"); idx != -1 {
|
||||
@@ -119,7 +290,7 @@ func (w *WafOWASP) processConnection(tx types.Transaction, weblog innerbean.WebL
|
||||
}
|
||||
|
||||
clientPort := 0
|
||||
if weblog.SRC_PORT != "" {
|
||||
if weblog != nil && weblog.SRC_PORT != "" {
|
||||
if port, err := strconv.Atoi(weblog.SRC_PORT); err == nil {
|
||||
clientPort = port
|
||||
}
|
||||
@@ -131,13 +302,10 @@ func (w *WafOWASP) processConnection(tx types.Transaction, weblog innerbean.WebL
|
||||
|
||||
if r.Host != "" {
|
||||
host := r.Host
|
||||
if strings.Contains(host, ":") {
|
||||
parts := strings.Split(host, ":")
|
||||
if len(parts) == 2 {
|
||||
serverIP = parts[0]
|
||||
if port, err := strconv.Atoi(parts[1]); err == nil {
|
||||
serverPort = port
|
||||
}
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
serverIP = host[:idx]
|
||||
if port, err := strconv.Atoi(host[idx+1:]); err == nil {
|
||||
serverPort = port
|
||||
}
|
||||
} else {
|
||||
serverIP = host
|
||||
@@ -152,8 +320,7 @@ func (w *WafOWASP) processConnection(tx types.Transaction, weblog innerbean.WebL
|
||||
}
|
||||
|
||||
// processRequestLine 处理请求行
|
||||
func (w *WafOWASP) processRequestLine(tx types.Transaction, r *http.Request) error {
|
||||
// 构建完整的 URI
|
||||
func (w *WafOWASP) processRequestLine(tx types.Transaction, r *http.Request) {
|
||||
uri := r.URL.RequestURI()
|
||||
if uri == "" {
|
||||
uri = r.URL.Path
|
||||
@@ -161,49 +328,66 @@ func (w *WafOWASP) processRequestLine(tx types.Transaction, r *http.Request) err
|
||||
uri += "?" + r.URL.RawQuery
|
||||
}
|
||||
}
|
||||
|
||||
tx.ProcessURI(uri, r.Method, r.Proto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// processRequestHeaders 处理请求头
|
||||
func (w *WafOWASP) processRequestHeaders(tx types.Transaction, r *http.Request) error {
|
||||
// 添加所有请求头
|
||||
func (w *WafOWASP) processRequestHeaders(tx types.Transaction, r *http.Request) {
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
tx.AddRequestHeader(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Host 头存在
|
||||
if r.Host != "" && r.Header.Get("Host") == "" {
|
||||
tx.AddRequestHeader("Host", r.Host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processRequestBody 处理请求体
|
||||
func (w *WafOWASP) processRequestBody(tx types.Transaction, r *http.Request, weblog innerbean.WebLog) error {
|
||||
var bodyData []byte
|
||||
var err error
|
||||
|
||||
// 优先使用 weblog 中的 BODY
|
||||
if weblog.BODY != "" {
|
||||
bodyData = []byte(weblog.BODY)
|
||||
} else if r.Body != nil {
|
||||
// 从请求中读取 body
|
||||
bodyData, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read request body: %v", err)
|
||||
// processRequestBody 处理请求体(流式,避免多份内存副本)
|
||||
//
|
||||
// 优化要点:
|
||||
// - weblog.BODY 非空时直接 strings.NewReader 零拷贝共享底层字符串(原实现 []byte(weblog.BODY) 会多分配一份)
|
||||
// - 从 r.Body 读取时用 io.LimitReader 封顶,最大 maxRequestBodyBytes,避免无限读
|
||||
// - 读到的字节复用给下游消费,原实现会让同一 body 同时存在 3 份(weblog 字符串 + []byte 副本 + coraza 内部缓冲)
|
||||
func (w *WafOWASP) processRequestBody(tx types.Transaction, r *http.Request, weblog *innerbean.WebLog) error {
|
||||
// 优先使用上游已经读取好的 body,避免二次读 r.Body
|
||||
if weblog != nil && weblog.BODY != "" {
|
||||
// strings.NewReader 与底层字符串共享只读数据,零拷贝
|
||||
if _, _, err := tx.ReadRequestBodyFrom(strings.NewReader(weblog.BODY)); err != nil {
|
||||
return fmt.Errorf("failed to feed weblog body to coraza: %v", err)
|
||||
}
|
||||
// 重新设置 body,以便后续处理
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyData))
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(bodyData) > 0 {
|
||||
// 写入请求体数据,让 Coraza 自动解析
|
||||
if _, _, err := tx.WriteRequestBody(bodyData); err != nil {
|
||||
if r.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 读取 r.Body 并封顶。LimitReader + 1 用于探测是否超限(coraza 自身也会按 SecRequestBodyLimit 截断)
|
||||
bufPtr := bodyBufferPool.Get().(*[]byte)
|
||||
buf := bytes.NewBuffer((*bufPtr)[:0])
|
||||
defer func() {
|
||||
// 回池前丢弃过大的 buffer,避免长期占用
|
||||
if cap(buf.Bytes()) <= 1<<20 { // 1MB
|
||||
*bufPtr = buf.Bytes()[:0]
|
||||
bodyBufferPool.Put(bufPtr)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := io.Copy(buf, io.LimitReader(r.Body, maxRequestBodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read request body: %v", err)
|
||||
}
|
||||
|
||||
data := buf.Bytes()
|
||||
// 重置 body 以便下游继续消费
|
||||
bodyCopy := make([]byte, len(data))
|
||||
copy(bodyCopy, data)
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
|
||||
|
||||
if len(bodyCopy) > 0 {
|
||||
if _, _, err := tx.WriteRequestBody(bodyCopy); err != nil {
|
||||
return fmt.Errorf("failed to write request body: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -212,55 +396,53 @@ func (w *WafOWASP) processRequestBody(tx types.Transaction, r *http.Request, web
|
||||
}
|
||||
|
||||
// handleInterruption 处理中断情况
|
||||
//
|
||||
// 热路径优化:
|
||||
// - 默认仅从 MatchedRules 提取最关键信息,strings.Builder 预分配
|
||||
// - 仅当 global.GWAF_LOG_DEBUG_ENABLE 为 true 时才去遍历 Variables().All() 收集完整上下文
|
||||
// (原实现每次命中都全量扫描所有 collection,成百上千次 map 分配压垮 GC)
|
||||
func (w *WafOWASP) handleInterruption(tx types.Transaction, it *types.Interruption) (bool, *types.Interruption, error) {
|
||||
// 收集详细信息用于日志记录
|
||||
details := w.collectTransactionDetails(tx)
|
||||
matched := tx.MatchedRules()
|
||||
|
||||
// 记录详细的日志信息
|
||||
w.logger.Error("OWASP WAF Interruption", details)
|
||||
|
||||
// 将详细信息添加到中断对象中,以便返回给用户
|
||||
// 不向上层暴露命中规则详情:it.Data 保持空,完整细节通过 zlog 记录。
|
||||
// 这样 checkowasp.go 拼出的 result.Title 只有 "OWASP:<ruleID>",不会把规则消息泄漏给访客或塞满通知面板。
|
||||
if it != nil {
|
||||
// 将匹配的规则信息转换为字符串
|
||||
var ruleDetails string
|
||||
if matchedRules, ok := details["matched_rules"].([]map[string]interface{}); ok && len(matchedRules) > 0 {
|
||||
for i, rule := range matchedRules {
|
||||
ruleDetails += fmt.Sprintf("规则ID: %v, 消息: %v", rule["id"], rule["message"])
|
||||
if i < len(matchedRules)-1 {
|
||||
ruleDetails += "\n"
|
||||
}
|
||||
}
|
||||
// 将规则详情添加到中断数据中
|
||||
it.Data = ruleDetails
|
||||
}
|
||||
it.Data = ""
|
||||
}
|
||||
|
||||
// 慢路径:仅 debug 模式下采集完整上下文
|
||||
if debugEnabled.Load() {
|
||||
details := w.collectTransactionDetails(tx, matched)
|
||||
w.logger.Error("OWASP WAF Interruption", details)
|
||||
}
|
||||
|
||||
return true, it, nil
|
||||
}
|
||||
|
||||
// collectTransactionDetails 收集事务详细信息
|
||||
func (w *WafOWASP) collectTransactionDetails(tx types.Transaction) map[string]interface{} {
|
||||
// collectTransactionDetails 收集事务详细信息(仅 debug 模式调用)
|
||||
func (w *WafOWASP) collectTransactionDetails(tx types.Transaction, matched []types.MatchedRule) map[string]interface{} {
|
||||
details := map[string]interface{}{
|
||||
"transaction_id": tx.ID(),
|
||||
"interrupted": tx.IsInterrupted(),
|
||||
}
|
||||
|
||||
// 收集匹配的规则信息
|
||||
matchedRules := []map[string]interface{}{}
|
||||
for _, rule := range tx.MatchedRules() {
|
||||
if rule.Message() != "" {
|
||||
matchedRules = append(matchedRules, map[string]interface{}{
|
||||
"id": rule.Rule().ID(),
|
||||
"message": rule.Message(),
|
||||
"file": rule.Rule().File(),
|
||||
"line": rule.Rule().Line(),
|
||||
})
|
||||
// 匹配的规则信息
|
||||
matchedRules := make([]map[string]interface{}, 0, len(matched))
|
||||
for _, rule := range matched {
|
||||
if rule.Message() == "" {
|
||||
continue
|
||||
}
|
||||
matchedRules = append(matchedRules, map[string]interface{}{
|
||||
"id": rule.Rule().ID(),
|
||||
"message": rule.Message(),
|
||||
"file": rule.Rule().File(),
|
||||
"line": rule.Rule().Line(),
|
||||
})
|
||||
}
|
||||
details["matched_rules"] = matchedRules
|
||||
details["rules_matched_total"] = len(matchedRules)
|
||||
|
||||
// 收集变量信息(仅在需要时)
|
||||
// 变量信息:仅 debug 模式收集,在大请求下成本极高
|
||||
if txState, ok := tx.(plugintypes.TransactionState); ok {
|
||||
collections := []map[string]interface{}{}
|
||||
txState.Variables().All(func(_ variables.RuleVariable, v collection.Collection) bool {
|
||||
@@ -277,7 +459,6 @@ func (w *WafOWASP) collectTransactionDetails(tx types.Transaction) map[string]in
|
||||
details["collections"] = collections
|
||||
}
|
||||
|
||||
// 收集中断信息
|
||||
if it := tx.Interruption(); it != nil {
|
||||
details["interruption"] = map[string]interface{}{
|
||||
"action": it.Action,
|
||||
@@ -295,26 +476,22 @@ func (w *WafOWASP) ProcessResponse(tx types.Transaction, statusCode int, headers
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 添加响应头
|
||||
for name, values := range headers {
|
||||
for _, value := range values {
|
||||
tx.AddResponseHeader(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理响应头
|
||||
if it := tx.ProcessResponseHeaders(statusCode, "HTTP/1.1"); it != nil {
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// 写入响应体
|
||||
if len(body) > 0 {
|
||||
if _, _, err := tx.WriteResponseBody(body); err != nil {
|
||||
return nil, fmt.Errorf("failed to write response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理响应体
|
||||
if it, err := tx.ProcessResponseBody(); err != nil {
|
||||
return nil, fmt.Errorf("response body processing error: %v", err)
|
||||
} else if it != nil {
|
||||
|
||||
@@ -6,48 +6,26 @@ import (
|
||||
)
|
||||
|
||||
func TestOwasp(t *testing.T) {
|
||||
owasp := InitOwasp("..")
|
||||
tx := owasp.NewTransaction()
|
||||
t.Skip("InitOwasp helper not implemented; integration test requires releasing data/owasp assets")
|
||||
owasp := NewWafOWASP(true, "..")
|
||||
if owasp == nil || owasp.WAF == nil {
|
||||
t.Skip("owasp data not released, skipping")
|
||||
return
|
||||
}
|
||||
tx := owasp.WAF.NewTransaction()
|
||||
defer tx.Close()
|
||||
|
||||
// 127.0.0.1:55555 -> 127.0.0.1:80
|
||||
//tx.ProcessConnection("127.0.0.1", 55555, "127.0.0.1", 80)
|
||||
// Request URI was /some-url?with=args
|
||||
// 模拟请求头
|
||||
tx.AddRequestHeader("Host", "www.demo1.com")
|
||||
tx.AddRequestHeader("Cookie", "PHPSESSID=e0b36oh5nsrbm0f97d8p8j9ma2")
|
||||
tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||
tx.AddRequestHeader("Accept-Encoding", "gzip, deflate")
|
||||
tx.AddRequestHeader("Upgrade-Insecure-Requests", "1")
|
||||
tx.AddRequestHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8")
|
||||
tx.AddRequestHeader("Accept-Language", "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2")
|
||||
tx.AddRequestHeader("Priority", "u=0, i")
|
||||
tx.AddRequestHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0")
|
||||
tx.ProcessURI("/control/sqlinject/manifest_error.php?id=-1%27/*%2a%%2f*/union/*%2f%2f*//*!50144select*//*%2f%2f*/1,version()%23", "GET", "HTTP/1.1")
|
||||
|
||||
tx.ProcessRequestHeaders()
|
||||
/*bodybuffer := []string{}
|
||||
if _, _, err := tx.WriteRequestBody([]byte(strings.Join(bodybuffer, ""))); err != nil {
|
||||
fmt.Println(err)
|
||||
}*/
|
||||
if _, err := tx.ProcessRequestBody(); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
interrupted := tx.IsInterrupted()
|
||||
if interrupted {
|
||||
if tx.IsInterrupted() {
|
||||
fmt.Println("interrupted")
|
||||
}
|
||||
if it := tx.Interruption(); it != nil {
|
||||
switch it.Action {
|
||||
case "deny":
|
||||
fmt.Println(it.Status)
|
||||
fmt.Println(it.RuleID)
|
||||
fmt.Println(it.Data)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWafOwasp(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
)
|
||||
|
||||
// OwaspManager 管理 Coraza WAF 实例的生命周期与热重载。
|
||||
//
|
||||
// 热路径通过 Current() 原子读取当前活跃实例,写路径(Reload)用互斥锁串行化。
|
||||
// Reload 过程中旧实例依然可用,新实例构建成功后原子替换,失败时保留旧实例。
|
||||
type OwaspManager struct {
|
||||
current atomic.Pointer[WafOWASP] // 当前活跃实例
|
||||
dir string // WAF 数据根目录(绝对路径,其下应有 data/owasp/)
|
||||
mu sync.Mutex // 保证 Reload 串行
|
||||
active atomic.Bool // 是否处于激活态
|
||||
overrides *OverrideStore // overrides 层存储
|
||||
}
|
||||
|
||||
// NewOwaspManager 构造管理器并立刻尝试构建一次 WAF 实例。
|
||||
// 若 WAF 初始化失败(如数据文件尚未释放),不会 panic,返回空 manager,
|
||||
// 后续可通过 Reload() 再次尝试。
|
||||
func NewOwaspManager(currentDir string) *OwaspManager {
|
||||
m := &OwaspManager{dir: currentDir}
|
||||
m.active.Store(true)
|
||||
m.overrides = NewOverrideStore(m.OverridesDir())
|
||||
// 首次启动:若 overrides 目录不存在,生成默认 tuning 与 registry
|
||||
if err := m.overrides.EnsureDirAndDefaults(); err != nil {
|
||||
zlog.Error("EnsureDirAndDefaults overrides failed", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
zlog.Error("OwaspManager initial Reload failed", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
// 同步一次引擎模式:读磁盘上的 tuning(即便 Reload 失败也读一次默认值),
|
||||
// 热路径据此决定 DetectionOnly 下是否记 INFO 日志
|
||||
if t, err := m.overrides.GetTuning(); err == nil {
|
||||
SetEngineMode(t.RuleEngine)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Overrides 获取 overrides 存储。
|
||||
func (m *OwaspManager) Overrides() *OverrideStore {
|
||||
return m.overrides
|
||||
}
|
||||
|
||||
// ApplyTuning 写入新 tuning 并触发 WAF 热重载。失败时保留旧实例。
|
||||
func (m *OwaspManager) ApplyTuning(t TuningConfig) error {
|
||||
if m.overrides == nil {
|
||||
return fmt.Errorf("override store not initialized")
|
||||
}
|
||||
if err := m.overrides.SetTuning(t); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步引擎模式,供热路径判定"DetectionOnly 本该拦截"时记 INFO 日志
|
||||
SetEngineMode(t.RuleEngine)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableRuleAndReload 禁用某规则并热重载。
|
||||
func (m *OwaspManager) DisableRuleAndReload(id int, sourceFile string) error {
|
||||
if m.overrides == nil {
|
||||
return fmt.Errorf("override store not initialized")
|
||||
}
|
||||
if err := m.overrides.DisableRule(id, sourceFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Reload()
|
||||
}
|
||||
|
||||
// EnableRuleAndReload 启用被禁用的规则并热重载。
|
||||
func (m *OwaspManager) EnableRuleAndReload(id int) error {
|
||||
if m.overrides == nil {
|
||||
return fmt.Errorf("override store not initialized")
|
||||
}
|
||||
if err := m.overrides.EnableRule(id); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Reload()
|
||||
}
|
||||
|
||||
// OverrideRuleAndReload 改写规则并热重载。
|
||||
func (m *OwaspManager) OverrideRuleAndReload(id int, sourceFile, originalHash, content string) error {
|
||||
if m.overrides == nil {
|
||||
return fmt.Errorf("override store not initialized")
|
||||
}
|
||||
if err := m.overrides.OverrideRule(id, sourceFile, originalHash, content); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Reload()
|
||||
}
|
||||
|
||||
// ResetRuleAndReload 还原规则并热重载。
|
||||
func (m *OwaspManager) ResetRuleAndReload(id int) error {
|
||||
if m.overrides == nil {
|
||||
return fmt.Errorf("override store not initialized")
|
||||
}
|
||||
if err := m.overrides.ResetRule(id); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Reload()
|
||||
}
|
||||
|
||||
// SetActive 设置全局激活状态。关闭后请求路径直接短路返回。
|
||||
func (m *OwaspManager) SetActive(active bool) {
|
||||
m.active.Store(active)
|
||||
if w := m.current.Load(); w != nil {
|
||||
w.IsActive = active
|
||||
}
|
||||
}
|
||||
|
||||
// IsActive 返回当前激活状态。
|
||||
func (m *OwaspManager) IsActive() bool {
|
||||
return m.active.Load()
|
||||
}
|
||||
|
||||
// Current 获取当前活跃实例(热路径,无锁)。可能为 nil(首次初始化失败时)。
|
||||
func (m *OwaspManager) Current() *WafOWASP {
|
||||
return m.current.Load()
|
||||
}
|
||||
|
||||
// Dir 返回 SamWaf 当前工作目录。
|
||||
func (m *OwaspManager) Dir() string {
|
||||
return m.dir
|
||||
}
|
||||
|
||||
// OwaspRoot 返回 data/owasp 根目录。
|
||||
func (m *OwaspManager) OwaspRoot() string {
|
||||
return filepath.Join(m.dir, "data", "owasp")
|
||||
}
|
||||
|
||||
// OverridesDir 返回 overrides/ 目录(用户改动层)。
|
||||
func (m *OwaspManager) OverridesDir() string {
|
||||
return filepath.Join(m.OwaspRoot(), "overrides")
|
||||
}
|
||||
|
||||
// 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 和用户自定义规则,须在规则之后)
|
||||
//
|
||||
// 构建失败时旧实例保留,返回错误由调用方决定如何处理。
|
||||
func (m *OwaspManager) Reload() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
root := m.OwaspRoot()
|
||||
|
||||
corazaConf := filepath.Join(root, "coraza.conf")
|
||||
if _, err := os.Stat(corazaConf); err != nil {
|
||||
return fmt.Errorf("coraza.conf not found at %s: %w", corazaConf, err)
|
||||
}
|
||||
|
||||
cfg := coraza.NewWAFConfig().WithDirectivesFromFile(corazaConf)
|
||||
|
||||
setupConf := filepath.Join(root, "coreruleset", "crs-setup.conf")
|
||||
if _, err := os.Stat(setupConf); err == nil {
|
||||
cfg = cfg.WithDirectivesFromFile(setupConf)
|
||||
} else {
|
||||
zlog.Error("crs-setup.conf missing, skipping", map[string]interface{}{"path": setupConf, "err": err.Error()})
|
||||
}
|
||||
|
||||
// 步骤 3:提前加载 00-tuning.conf(变量初始化)
|
||||
// 必须在 rules/*.conf 之前,否则 tx.* 变量会被 CRS 默认初始化规则覆盖
|
||||
tuningConf := filepath.Join(m.OverridesDir(), OverrideTuningFile)
|
||||
if _, err := os.Stat(tuningConf); err == nil {
|
||||
cfg = cfg.WithDirectivesFromFile(tuningConf)
|
||||
}
|
||||
|
||||
// 步骤 4:加载 CRS 官方规则集
|
||||
rulesGlob := filepath.Join(root, "coreruleset", "rules", "*.conf")
|
||||
cfg = cfg.WithDirectivesFromFile(rulesGlob)
|
||||
|
||||
// 步骤 5:加载其余 overrides(禁用/改写规则,须在 CRS 规则之后才能 RemoveById)
|
||||
// 跳过已在步骤 3 提前加载的 00-tuning.conf
|
||||
if overrides, err := listOverrideConfs(m.OverridesDir()); err == nil && len(overrides) > 0 {
|
||||
for _, p := range overrides {
|
||||
if filepath.Base(p) == OverrideTuningFile {
|
||||
continue // 已在 CRS rules 之前加载,跳过
|
||||
}
|
||||
cfg = cfg.WithDirectivesFromFile(p)
|
||||
}
|
||||
}
|
||||
|
||||
waf, err := coraza.NewWAF(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build coraza WAF: %w", err)
|
||||
}
|
||||
|
||||
newInst := &WafOWASP{
|
||||
IsActive: m.active.Load(),
|
||||
WAF: waf,
|
||||
logger: &CustomLogger{},
|
||||
}
|
||||
old := m.current.Swap(newInst)
|
||||
if old != nil {
|
||||
// Coraza v3 没有显式 Close 方法;实例会被 GC 回收
|
||||
_ = old
|
||||
}
|
||||
zlog.Info("OwaspManager", "Coraza WAF reloaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOverrideConfs 列出 overrides 目录下所有 .conf,按字典序返回。
|
||||
// 目录不存在或无文件时返回 nil 错误,空切片。
|
||||
func listOverrideConfs(dir string) ([]string, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("empty overrides dir")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(e.Name()) != ".conf" {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(dir, e.Name()))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OverrideAuditLogFile 变更日志文件名。
|
||||
const OverrideAuditLogFile = "audit_log.json"
|
||||
|
||||
// maxAuditEntries 审计日志最大保留条数,超出时裁剪最旧的。
|
||||
const maxAuditEntries = 1000
|
||||
|
||||
// AuditAction 审计动作类型。
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
AuditDisabled AuditAction = "disabled" // 禁用规则
|
||||
AuditEnabled AuditAction = "enabled" // 启用规则
|
||||
AuditModified AuditAction = "modified" // 改写规则
|
||||
AuditReset AuditAction = "reset" // 还原规则
|
||||
AuditTuning AuditAction = "tuning" // 调整全局参数
|
||||
)
|
||||
|
||||
// AuditLogEntry 单条变更记录。
|
||||
type AuditLogEntry struct {
|
||||
Time string `json:"time"` // RFC3339
|
||||
RuleID int `json:"rule_id,omitempty"` // 0 表示非规则级操作(如 tuning)
|
||||
Action AuditAction `json:"action"`
|
||||
SourceFile string `json:"source_file,omitempty"` // 规则来源文件(仅规则操作)
|
||||
Note string `json:"note,omitempty"` // 附加说明
|
||||
}
|
||||
|
||||
// auditLogFile 内存中的日志文件结构(序列化用)。
|
||||
type auditLogFile struct {
|
||||
Version int `json:"version"`
|
||||
Entries []AuditLogEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// AppendAuditLog 追加一条变更记录到 audit_log.json(持有锁调用,线程安全)。
|
||||
func (s *OverrideStore) AppendAuditLog(entry AuditLogEntry) {
|
||||
// 不阻塞调用方;写失败仅打印,不影响规则操作
|
||||
if entry.Time == "" {
|
||||
entry.Time = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
path := filepath.Join(s.dir, OverrideAuditLogFile)
|
||||
|
||||
var af auditLogFile
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, &af)
|
||||
}
|
||||
if af.Entries == nil {
|
||||
af.Entries = []AuditLogEntry{}
|
||||
}
|
||||
af.Version = 1
|
||||
af.Entries = append(af.Entries, entry)
|
||||
// 裁剪超出上限的旧记录
|
||||
if len(af.Entries) > maxAuditEntries {
|
||||
af.Entries = af.Entries[len(af.Entries)-maxAuditEntries:]
|
||||
}
|
||||
if data, err := json.MarshalIndent(af, "", " "); err == nil {
|
||||
_ = atomicWriteFile(path, data)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAuditLog 读取变更日志(倒序返回,最新在前)。
|
||||
func (s *OverrideStore) LoadAuditLog() ([]AuditLogEntry, error) {
|
||||
path := filepath.Join(s.dir, OverrideAuditLogFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []AuditLogEntry{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var af auditLogFile
|
||||
if err := json.Unmarshal(data, &af); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 倒序
|
||||
entries := af.Entries
|
||||
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
||||
entries[i], entries[j] = entries[j], entries[i]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Override 相关的文件名,集中在此以便升级器、API 层复用。
|
||||
const (
|
||||
OverrideTuningFile = "00-tuning.conf" // 全局参数调优
|
||||
OverrideDisabledFile = "10-disabled-rules.conf" // 按 ID 禁用的规则
|
||||
OverrideCustomFile = "20-custom-rules.conf" // 用户覆盖/自定义的规则
|
||||
OverrideRegistryFile = "override_registry.json" // 元数据:哪些 ID 被用户改过
|
||||
OverrideRegistryShema = 1
|
||||
)
|
||||
|
||||
// OverrideAction 用户对某条规则的动作。
|
||||
type OverrideAction string
|
||||
|
||||
const (
|
||||
OverrideDisabled OverrideAction = "disabled" // 通过 SecRuleRemoveById 关闭
|
||||
OverrideModified OverrideAction = "modified" // 用户改写过内容
|
||||
)
|
||||
|
||||
// RuleOverrideEntry registry.json 中单条规则记录。
|
||||
type RuleOverrideEntry struct {
|
||||
Action OverrideAction `json:"action"`
|
||||
SourceFile string `json:"source_file"` // 规则原本所在文件(rules/REQUEST-942-*.conf)
|
||||
OriginalHash string `json:"original_hash,omitempty"` // 修改类操作记录原内容 sha256,用于后续升级时比对
|
||||
Content string `json:"content,omitempty"` // modified 时保存用户编辑后内容(单条完整 SecRule 块)
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TuningConfig 全局调优参数。
|
||||
type TuningConfig struct {
|
||||
BlockingParanoia int `json:"blocking_paranoia_level"` // 1..4
|
||||
DetectionParanoia int `json:"detection_paranoia_level"` // >= blocking
|
||||
InboundThreshold int `json:"inbound_anomaly_score_threshold"`
|
||||
OutboundThreshold int `json:"outbound_anomaly_score_threshold"`
|
||||
RuleEngine string `json:"rule_engine"` // On / DetectionOnly / Off
|
||||
EarlyBlocking int `json:"early_blocking"` // 0/1
|
||||
EnforceBodyProcessor int `json:"enforce_bodyproc_urlencoded"` // 0/1
|
||||
// CustomVars 用户自定义 CRS 事务变量(如 tx.allowed_methods)。
|
||||
// key 不含 tx. 前缀(如 "allowed_methods"),value 为字符串值。
|
||||
// 写入 00-tuning.conf 时以 SecAction setvar:'tx.KEY=VALUE' 形式追加。
|
||||
CustomVars map[string]string `json:"custom_vars,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultTuning 首次运行时写入的宽松默认值,较官方默认更容忍误报。
|
||||
func DefaultTuning() TuningConfig {
|
||||
return TuningConfig{
|
||||
BlockingParanoia: 1,
|
||||
DetectionParanoia: 2,
|
||||
InboundThreshold: 7, // 官方默认 5,我们放宽到 7,降低单条 critical 直接 block 的概率
|
||||
OutboundThreshold: 4,
|
||||
RuleEngine: "On",
|
||||
EarlyBlocking: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// OverrideRegistry overrides 层的元数据。
|
||||
type OverrideRegistry struct {
|
||||
Version int `json:"version"`
|
||||
Rules map[string]RuleOverrideEntry `json:"rules"`
|
||||
Global TuningConfig `json:"global"`
|
||||
|
||||
// 记录升级时应保留的已删除文件,升级逻辑据此跳过新写。
|
||||
DeletedFiles []string `json:"deleted_files,omitempty"`
|
||||
}
|
||||
|
||||
// OverrideStore 封装 overrides 目录的读写,内部持锁串行以避免并发写坏文件。
|
||||
type OverrideStore struct {
|
||||
dir string // overrides/ 目录绝对路径
|
||||
mu sync.Mutex // 写锁
|
||||
}
|
||||
|
||||
// NewOverrideStore 创建一个 OverrideStore,不会立即触达磁盘。
|
||||
func NewOverrideStore(dir string) *OverrideStore {
|
||||
return &OverrideStore{dir: dir}
|
||||
}
|
||||
|
||||
// Dir 返回 overrides 目录路径。
|
||||
func (s *OverrideStore) Dir() string {
|
||||
return s.dir
|
||||
}
|
||||
|
||||
// EnsureDirAndDefaults 确保 overrides 目录存在,若 registry 缺失则写入默认 tuning + 空 registry。
|
||||
// 已存在的内容一律不覆盖(幂等)。
|
||||
func (s *OverrideStore) EnsureDirAndDefaults() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir overrides: %w", err)
|
||||
}
|
||||
|
||||
regPath := filepath.Join(s.dir, OverrideRegistryFile)
|
||||
_, statErr := os.Stat(regPath)
|
||||
regExists := statErr == nil
|
||||
|
||||
if !regExists {
|
||||
reg := &OverrideRegistry{
|
||||
Version: OverrideRegistryShema,
|
||||
Rules: map[string]RuleOverrideEntry{},
|
||||
Global: DefaultTuning(),
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保证 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadRegistry 读取 registry.json。文件不存在时返回零值 registry(不视为错误)。
|
||||
func (s *OverrideStore) LoadRegistry() (*OverrideRegistry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return loadRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile))
|
||||
}
|
||||
|
||||
func loadRegistryLocked(path string) (*OverrideRegistry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &OverrideRegistry{
|
||||
Version: OverrideRegistryShema,
|
||||
Rules: map[string]RuleOverrideEntry{},
|
||||
Global: DefaultTuning(),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
reg := &OverrideRegistry{}
|
||||
if err := json.Unmarshal(data, reg); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if reg.Rules == nil {
|
||||
reg.Rules = map[string]RuleOverrideEntry{}
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// SaveRegistry 原子写入 registry.json。
|
||||
func (s *OverrideStore) SaveRegistry(reg *OverrideRegistry) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return writeRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile), reg)
|
||||
}
|
||||
|
||||
func writeRegistryLocked(path string, reg *OverrideRegistry) error {
|
||||
if reg.Version == 0 {
|
||||
reg.Version = OverrideRegistryShema
|
||||
}
|
||||
data, err := json.MarshalIndent(reg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// SetTuning 更新 tuning 参数:刷新 registry.global 和 overrides/00-tuning.conf 两处。
|
||||
func (s *OverrideStore) SetTuning(t TuningConfig) error {
|
||||
if err := s.setTuningNoLog(t); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
Action: AuditTuning,
|
||||
Note: fmt.Sprintf("blocking_pl=%d detection_pl=%d inbound_threshold=%d engine=%s",
|
||||
t.BlockingParanoia, t.DetectionParanoia, t.InboundThreshold, t.RuleEngine),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// setTuningNoLog 内部实现,无审计日志(供 SetCRSVar/DeleteCRSVar 调用,避免重复日志)。
|
||||
func (s *OverrideStore) setTuningNoLog(t TuningConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
regPath := filepath.Join(s.dir, OverrideRegistryFile)
|
||||
reg, err := loadRegistryLocked(regPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg.Global = t
|
||||
if err := writeRegistryLocked(regPath, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeTuningConfFile(filepath.Join(s.dir, OverrideTuningFile), t)
|
||||
}
|
||||
|
||||
// GetTuning 读取当前 tuning(来自 registry)。
|
||||
func (s *OverrideStore) GetTuning() (TuningConfig, error) {
|
||||
reg, err := s.LoadRegistry()
|
||||
if err != nil {
|
||||
return TuningConfig{}, err
|
||||
}
|
||||
return reg.Global, nil
|
||||
}
|
||||
|
||||
// GetCRSVars 返回当前所有自定义 CRS 事务变量(key 不含 tx. 前缀)。
|
||||
func (s *OverrideStore) GetCRSVars() (map[string]string, error) {
|
||||
t, err := s.GetTuning()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.CustomVars == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return t.CustomVars, nil
|
||||
}
|
||||
|
||||
// SetCRSVar 设置单个 CRS 事务变量并触发 tuning 重写。
|
||||
// key 可以带或不带 tx. 前缀,内部统一存储为不带前缀的形式。
|
||||
func (s *OverrideStore) SetCRSVar(key, value string) error {
|
||||
key = strings.TrimPrefix(key, "tx.")
|
||||
if key == "" {
|
||||
return fmt.Errorf("variable key must not be empty")
|
||||
}
|
||||
t, err := s.GetTuning()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.CustomVars == nil {
|
||||
t.CustomVars = map[string]string{}
|
||||
}
|
||||
oldVal := t.CustomVars[key]
|
||||
t.CustomVars[key] = value
|
||||
if err := s.setTuningNoLog(t); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
Action: AuditTuning,
|
||||
Note: fmt.Sprintf("set crs_var tx.%s: %q → %q", key, oldVal, value),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCRSVar 删除单个 CRS 事务变量并触发 tuning 重写。
|
||||
func (s *OverrideStore) DeleteCRSVar(key string) error {
|
||||
key = strings.TrimPrefix(key, "tx.")
|
||||
if key == "" {
|
||||
return fmt.Errorf("variable key must not be empty")
|
||||
}
|
||||
t, err := s.GetTuning()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := t.CustomVars[key]; !ok {
|
||||
return nil
|
||||
}
|
||||
oldVal := t.CustomVars[key]
|
||||
delete(t.CustomVars, key)
|
||||
if err := s.setTuningNoLog(t); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
Action: AuditTuning,
|
||||
Note: fmt.Sprintf("delete crs_var tx.%s (was %q)", key, oldVal),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableRule 禁用单条规则。实现方式:在 10-disabled-rules.conf 里追加 SecRuleRemoveById。
|
||||
// 同一 ID 重复调用等价于幂等,不会重复追加。
|
||||
func (s *OverrideStore) DisableRule(id int, sourceFile string) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("invalid rule id: %d", id)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
reg, err := loadRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg.Rules[strconv.Itoa(id)] = RuleOverrideEntry{
|
||||
Action: OverrideDisabled,
|
||||
SourceFile: sourceFile,
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if err := writeRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile), reg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteDisabledConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
RuleID: id,
|
||||
Action: AuditDisabled,
|
||||
SourceFile: sourceFile,
|
||||
Note: "SecRuleRemoveById 写入 10-disabled-rules.conf",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableRule 取消禁用(从 registry 移除 action=disabled 记录;modified 保留)。
|
||||
func (s *OverrideStore) EnableRule(id int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
reg, err := loadRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := strconv.Itoa(id)
|
||||
if entry, ok := reg.Rules[key]; ok && entry.Action == OverrideDisabled {
|
||||
srcFile := entry.SourceFile
|
||||
delete(reg.Rules, key)
|
||||
if err := writeRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile), reg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteDisabledConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
RuleID: id,
|
||||
Action: AuditEnabled,
|
||||
SourceFile: srcFile,
|
||||
Note: "从 10-disabled-rules.conf 移除 SecRuleRemoveById",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OverrideRule 用户改写规则。content 应是完整的 SecRule/SecAction 块字符串。
|
||||
// 内部策略:
|
||||
// - 注册为 modified,保存原内容 hash 和新内容
|
||||
// - 在 20-custom-rules.conf 中按 ID 聚合写入(重复 ID 覆盖)
|
||||
// - 额外在 10-disabled-rules.conf 里对该 ID 执行 SecRuleRemoveById(先删再加,避免 ID 冲突)
|
||||
func (s *OverrideStore) OverrideRule(id int, sourceFile, originalHash, content string) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("invalid rule id: %d", id)
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return fmt.Errorf("content is empty")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
reg, err := loadRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg.Rules[strconv.Itoa(id)] = RuleOverrideEntry{
|
||||
Action: OverrideModified,
|
||||
SourceFile: sourceFile,
|
||||
OriginalHash: originalHash,
|
||||
Content: content,
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if err := writeRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile), reg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteDisabledConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteCustomConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
RuleID: id,
|
||||
Action: AuditModified,
|
||||
SourceFile: sourceFile,
|
||||
Note: "用户改写内容写入 20-custom-rules.conf",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetRule 还原某条规则为上游版本(从 registry 和所有 overrides 文件中删除)。
|
||||
func (s *OverrideStore) ResetRule(id int) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("invalid rule id: %d", id)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
reg, err := loadRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := strconv.Itoa(id)
|
||||
oldEntry, existed := reg.Rules[key]
|
||||
if !existed {
|
||||
return nil
|
||||
}
|
||||
srcFile := oldEntry.SourceFile
|
||||
delete(reg.Rules, key)
|
||||
if err := writeRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile), reg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteDisabledConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteCustomConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.AppendAuditLog(AuditLogEntry{
|
||||
RuleID: id,
|
||||
Action: AuditReset,
|
||||
SourceFile: srcFile,
|
||||
Note: "还原为上游版本,移除所有 override 记录",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyRegistryToFiles 基于当前 registry 重新生成所有 overrides 的 .conf 文件(用于手工修复 / 升级后同步)。
|
||||
func (s *OverrideStore) ApplyRegistryToFiles() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
reg, err := loadRegistryLocked(filepath.Join(s.dir, OverrideRegistryFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeTuningConfFile(filepath.Join(s.dir, OverrideTuningFile), reg.Global); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rewriteDisabledConfLocked(s.dir, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
return rewriteCustomConfLocked(s.dir, reg)
|
||||
}
|
||||
|
||||
// rewriteDisabledConfLocked 从 registry 派生 10-disabled-rules.conf。
|
||||
// 同时禁用 action=disabled 和 action=modified 的 ID(modified 需要先 RemoveById 才能用 custom 文件里的新版本替代)。
|
||||
func rewriteDisabledConfLocked(dir string, reg *OverrideRegistry) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(disabledHeader())
|
||||
|
||||
// 收集需要 remove 的 ID
|
||||
ids := make([]int, 0, len(reg.Rules))
|
||||
for k := range reg.Rules {
|
||||
id, err := strconv.Atoi(k)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
for _, id := range ids {
|
||||
entry := reg.Rules[strconv.Itoa(id)]
|
||||
switch entry.Action {
|
||||
case OverrideDisabled:
|
||||
sb.WriteString(fmt.Sprintf("# disabled by user (source: %s) at %s\n", entry.SourceFile, entry.UpdatedAt))
|
||||
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %d\n\n", id))
|
||||
case OverrideModified:
|
||||
sb.WriteString(fmt.Sprintf("# replaced by user (source: %s) at %s; see %s\n", entry.SourceFile, entry.UpdatedAt, OverrideCustomFile))
|
||||
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %d\n\n", id))
|
||||
}
|
||||
}
|
||||
return atomicWriteFile(filepath.Join(dir, OverrideDisabledFile), []byte(sb.String()))
|
||||
}
|
||||
|
||||
// rewriteCustomConfLocked 重写 20-custom-rules.conf,收录所有 modified 条目的用户版本。
|
||||
func rewriteCustomConfLocked(dir string, reg *OverrideRegistry) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(customHeader())
|
||||
|
||||
ids := make([]int, 0, len(reg.Rules))
|
||||
for k := range reg.Rules {
|
||||
id, err := strconv.Atoi(k)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
for _, id := range ids {
|
||||
entry := reg.Rules[strconv.Itoa(id)]
|
||||
if entry.Action != OverrideModified {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("# --- user override for rule id=%d (source: %s) updated_at=%s ---\n", id, entry.SourceFile, entry.UpdatedAt))
|
||||
sb.WriteString(strings.TrimRight(entry.Content, "\n"))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
return atomicWriteFile(filepath.Join(dir, OverrideCustomFile), []byte(sb.String()))
|
||||
}
|
||||
|
||||
// writeTuningConfFile 根据 TuningConfig 生成 00-tuning.conf 内容。
|
||||
//
|
||||
// 用 SecAction 在 phase:1 初始化 tx 变量;参考 crs-setup.conf 里的注释与官方 ID 段。
|
||||
// 我们选用 950000+ 段避开与官方规则 ID 冲突。
|
||||
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("# Use 后台 → OWASP 规则管理 → 全局调参 来修改这里的内容。\n")
|
||||
sb.WriteString("# 本文件在 overrides/ 下,不会被 coreruleset 升级覆盖。\n\n")
|
||||
|
||||
// RuleEngine: DetectionOnly/On/Off 通过 SecRuleEngine 指令直接设定
|
||||
engine := strings.TrimSpace(t.RuleEngine)
|
||||
if engine == "" {
|
||||
engine = "On"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n\n", engine))
|
||||
|
||||
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_tuning',\\\n setvar:'tx.%s=%d'\"\n\n", id, name, value))
|
||||
}
|
||||
|
||||
writeSetvar(950001, "Blocking Paranoia Level", "blocking_paranoia_level", t.BlockingParanoia)
|
||||
if t.DetectionParanoia >= t.BlockingParanoia {
|
||||
writeSetvar(950002, "Detection Paranoia Level", "detection_paranoia_level", t.DetectionParanoia)
|
||||
}
|
||||
writeSetvar(950003, "Inbound Anomaly Score Threshold", "inbound_anomaly_score_threshold", t.InboundThreshold)
|
||||
writeSetvar(950004, "Outbound Anomaly Score Threshold", "outbound_anomaly_score_threshold", t.OutboundThreshold)
|
||||
if t.EarlyBlocking == 1 {
|
||||
writeSetvar(950005, "Early Blocking", "early_blocking", 1)
|
||||
}
|
||||
if t.EnforceBodyProcessor == 1 {
|
||||
writeSetvar(950006, "Enforce Body Processor URLENCODED", "enforce_bodyproc_urlencoded", 1)
|
||||
}
|
||||
|
||||
// 用户自定义 CRS 事务变量(tx.allowed_methods 等)
|
||||
// 按 key 排序保证文件内容稳定,从 ID 950100 起步
|
||||
if len(t.CustomVars) > 0 {
|
||||
keys := make([]string, 0, len(t.CustomVars))
|
||||
for k := range t.CustomVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
sb.WriteString("# --- User-defined CRS transaction variables ---\n")
|
||||
for i, k := range keys {
|
||||
v := t.CustomVars[k]
|
||||
varName := k
|
||||
if !strings.HasPrefix(varName, "tx.") {
|
||||
varName = "tx." + k
|
||||
}
|
||||
// 使用 990100+ 段:CRS 最高占用到 980xxx,990xxx 段不会与任何 CRS 规则 ID 冲突
|
||||
id := 990100 + i
|
||||
sb.WriteString(fmt.Sprintf("# CRS variable: %s\n", varName))
|
||||
sb.WriteString(fmt.Sprintf("SecAction \\\n \"id:%d,\\\n phase:1,\\\n pass,\\\n nolog,\\\n t:none,\\\n tag:'SamWaf_crsvar',\\\n setvar:'%s=%s'\"\n\n", id, varName, v))
|
||||
}
|
||||
}
|
||||
|
||||
return atomicWriteFile(path, []byte(sb.String()))
|
||||
}
|
||||
|
||||
func disabledHeader() string {
|
||||
return "# Auto-generated by SamWaf. Contains SecRuleRemoveById for user-disabled rules.\n# DO NOT EDIT MANUALLY; use 后台 → OWASP 规则管理 界面操作。\n\n"
|
||||
}
|
||||
|
||||
func customHeader() string {
|
||||
return "# Auto-generated by SamWaf. Contains user-overridden / custom SecRule blocks.\n# DO NOT EDIT MANUALLY; use 后台 → OWASP 规则管理 界面操作。\n\n"
|
||||
}
|
||||
|
||||
// atomicWriteFile 通过 tmp + rename 的方式原子写文件。
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".override-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RuleMeta 规则元数据(不包含完整正则细节,仅供前端展示与过滤)。
|
||||
type RuleMeta struct {
|
||||
ID int `json:"id"`
|
||||
File string `json:"file"` // 相对 data/owasp 的路径
|
||||
Phase int `json:"phase"`
|
||||
Severity string `json:"severity"` // CRITICAL/ERROR/WARNING/NOTICE/...
|
||||
Paranoia int `json:"paranoia"` // 1..4,0 表示未指定
|
||||
Message string `json:"message"`
|
||||
Tags []string `json:"tags"`
|
||||
RawSnippet string `json:"raw"` // 该 SecRule/SecAction 块的原始多行文本
|
||||
LineStart int `json:"line_start"` // 起始行号(1-based)
|
||||
LineEnd int `json:"line_end"`
|
||||
Directive string `json:"directive"` // SecRule / SecAction
|
||||
}
|
||||
|
||||
// ParsedRuleFile 一个规则文件的全部解析结果。
|
||||
type ParsedRuleFile struct {
|
||||
File string `json:"file"` // 相对 owasp 根目录
|
||||
ModTime int64 `json:"mtime"` // mtime 纳秒时间戳
|
||||
Rules []RuleMeta `json:"rules"`
|
||||
}
|
||||
|
||||
// ruleDirectiveRe 匹配 SecRule/SecAction 指令开头(大小写不敏感,忽略开头空白)。
|
||||
var ruleDirectiveRe = regexp.MustCompile(`(?i)^\s*(SecRule|SecAction)\b`)
|
||||
|
||||
// 元数据正则。都使用 (?i) 不区分大小写;id 必需,其他尽量提取。
|
||||
var (
|
||||
reID = regexp.MustCompile(`(?i)\bid\s*:\s*'?(\d+)'?`)
|
||||
rePhase = regexp.MustCompile(`(?i)\bphase\s*:\s*'?(\d+)'?`)
|
||||
reSeverity = regexp.MustCompile(`(?i)\bseverity\s*:\s*'?([A-Za-z]+)'?`)
|
||||
// paranoia-level 可能是 tag:'paranoia-level/2' 形式
|
||||
reParanoia = regexp.MustCompile(`(?i)tag\s*:\s*'paranoia-level/(\d+)'`)
|
||||
reTag = regexp.MustCompile(`(?i)\btag\s*:\s*'([^']+)'`)
|
||||
reMsg = regexp.MustCompile(`(?i)\bmsg\s*:\s*'((?:[^'\\]|\\.)*)'`)
|
||||
)
|
||||
|
||||
// ruleFileCache 规则文件的解析缓存。
|
||||
// key: 文件绝对路径 → value: {mtime, rules}
|
||||
// 读多写少,用 RWMutex 即可。
|
||||
var (
|
||||
ruleFileCache = make(map[string]*ParsedRuleFile)
|
||||
ruleFileCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ParseRuleFile 解析单个 .conf 文件。带 mtime 缓存,文件未变动时直接复用。
|
||||
//
|
||||
// absPath 应为绝对路径,relFile 是相对 data/owasp 的路径(填入 RuleMeta.File)。
|
||||
// 空路径返回 nil, nil。
|
||||
func ParseRuleFile(absPath string, relFile string) (*ParsedRuleFile, error) {
|
||||
if absPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mtime := st.ModTime().UnixNano()
|
||||
|
||||
ruleFileCacheMu.RLock()
|
||||
if cached, ok := ruleFileCache[absPath]; ok && cached.ModTime == mtime {
|
||||
ruleFileCacheMu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
ruleFileCacheMu.RUnlock()
|
||||
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
rules, err := parseRules(f, relFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed := &ParsedRuleFile{
|
||||
File: relFile,
|
||||
ModTime: mtime,
|
||||
Rules: rules,
|
||||
}
|
||||
|
||||
ruleFileCacheMu.Lock()
|
||||
ruleFileCache[absPath] = parsed
|
||||
ruleFileCacheMu.Unlock()
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseRules 从 reader 流式读取并解析 SecRule/SecAction 块。
|
||||
func parseRules(r io.Reader, relFile string) ([]RuleMeta, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// 单条 SecRule 最长可达几十 KB(PHP 黑词列表),给足缓冲
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
|
||||
var (
|
||||
result []RuleMeta
|
||||
current strings.Builder
|
||||
startLine int
|
||||
lineNum int
|
||||
inBlock bool
|
||||
directive string
|
||||
)
|
||||
|
||||
flush := func(endLine int) {
|
||||
if !inBlock {
|
||||
return
|
||||
}
|
||||
raw := current.String()
|
||||
if meta := extractRuleMeta(raw, relFile, startLine, endLine, directive); meta.ID != 0 {
|
||||
result = append(result, meta)
|
||||
}
|
||||
current.Reset()
|
||||
inBlock = false
|
||||
directive = ""
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
|
||||
// 注释行(不属于 SecRule 内部时忽略)
|
||||
if !inBlock && strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !inBlock {
|
||||
if m := ruleDirectiveRe.FindStringSubmatch(trimmed); m != nil {
|
||||
inBlock = true
|
||||
startLine = lineNum
|
||||
directive = m[1]
|
||||
current.WriteString(line)
|
||||
current.WriteByte('\n')
|
||||
// 判断本行是否以续行符结束
|
||||
if !endsWithBackslash(line) {
|
||||
flush(lineNum)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 其他顶级指令(SecComponentSignature / SecDefaultAction 等)忽略
|
||||
continue
|
||||
}
|
||||
|
||||
current.WriteString(line)
|
||||
current.WriteByte('\n')
|
||||
if !endsWithBackslash(line) {
|
||||
flush(lineNum)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flush(lineNum)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// endsWithBackslash 判断行是否以续行符结尾(忽略尾部空白)。
|
||||
func endsWithBackslash(line string) bool {
|
||||
t := strings.TrimRight(line, " \t")
|
||||
return strings.HasSuffix(t, "\\")
|
||||
}
|
||||
|
||||
// extractRuleMeta 从规则原始块中提取元数据。
|
||||
func extractRuleMeta(raw, relFile string, startLine, endLine int, directive string) RuleMeta {
|
||||
meta := RuleMeta{
|
||||
File: relFile,
|
||||
RawSnippet: raw,
|
||||
LineStart: startLine,
|
||||
LineEnd: endLine,
|
||||
Directive: directive,
|
||||
}
|
||||
|
||||
if m := reID.FindStringSubmatch(raw); len(m) == 2 {
|
||||
if id, err := strconv.Atoi(m[1]); err == nil {
|
||||
meta.ID = id
|
||||
}
|
||||
}
|
||||
if meta.ID == 0 {
|
||||
return meta
|
||||
}
|
||||
|
||||
if m := rePhase.FindStringSubmatch(raw); len(m) == 2 {
|
||||
if v, err := strconv.Atoi(m[1]); err == nil {
|
||||
meta.Phase = v
|
||||
}
|
||||
}
|
||||
if m := reSeverity.FindStringSubmatch(raw); len(m) == 2 {
|
||||
meta.Severity = strings.ToUpper(m[1])
|
||||
}
|
||||
if m := reParanoia.FindStringSubmatch(raw); len(m) == 2 {
|
||||
if v, err := strconv.Atoi(m[1]); err == nil {
|
||||
meta.Paranoia = v
|
||||
}
|
||||
}
|
||||
if m := reMsg.FindStringSubmatch(raw); len(m) == 2 {
|
||||
meta.Message = unescapeSingleQuoted(m[1])
|
||||
}
|
||||
|
||||
tagMatches := reTag.FindAllStringSubmatch(raw, -1)
|
||||
if len(tagMatches) > 0 {
|
||||
tags := make([]string, 0, len(tagMatches))
|
||||
for _, tm := range tagMatches {
|
||||
tags = append(tags, tm[1])
|
||||
}
|
||||
meta.Tags = tags
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// unescapeSingleQuoted 处理单引号字符串里的 \\ 和 \'
|
||||
func unescapeSingleQuoted(s string) string {
|
||||
if !strings.ContainsRune(s, '\\') {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
sb.WriteByte(s[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(s[i])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ScanAllRules 扫描 data/owasp 下所有规则 .conf(coreruleset + overrides),
|
||||
// 返回按文件聚合的列表。
|
||||
//
|
||||
// owaspRoot 是 data/owasp 目录绝对路径。
|
||||
func ScanAllRules(owaspRoot string) ([]*ParsedRuleFile, error) {
|
||||
if owaspRoot == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var files []string
|
||||
// overridesDir 下的文件是 SamWaf 自动生成的调参/禁用指令,不是 CRS WAF 规则,排除扫描。
|
||||
overridesDir := filepath.Join(owaspRoot, "overrides")
|
||||
err := filepath.Walk(owaspRoot, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
// 跳过 overrides 目录(包含 SamWaf 生成的调参/禁用指令,不是真正的 WAF 规则)
|
||||
if filepath.Clean(p) == filepath.Clean(overridesDir) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(info.Name()) != ".conf" {
|
||||
return nil
|
||||
}
|
||||
// 忽略 .example 文件(官方示例,不被加载)
|
||||
if strings.HasSuffix(info.Name(), ".conf.example") {
|
||||
return nil
|
||||
}
|
||||
files = append(files, p)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
out := make([]*ParsedRuleFile, 0, len(files))
|
||||
for _, abs := range files {
|
||||
rel, e := filepath.Rel(owaspRoot, abs)
|
||||
if e != nil {
|
||||
rel = filepath.Base(abs)
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
parsed, perr := ParseRuleFile(abs, rel)
|
||||
if perr != nil || parsed == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, parsed)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InvalidateRuleCache 清空规则解析缓存(用于在写文件后强制下次重新解析)。
|
||||
func InvalidateRuleCache() {
|
||||
ruleFileCacheMu.Lock()
|
||||
ruleFileCache = make(map[string]*ParsedRuleFile)
|
||||
ruleFileCacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRules_Basic(t *testing.T) {
|
||||
raw := `# comment
|
||||
SecRule REQUEST_HEADERS:Content-Type "^application/json" \
|
||||
"id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON"
|
||||
|
||||
SecAction \
|
||||
"id:900110,\
|
||||
phase:1,\
|
||||
pass,\
|
||||
t:none,\
|
||||
nolog,\
|
||||
tag:'OWASP_CRS',\
|
||||
ver:'OWASP_CRS/4.9.0-dev',\
|
||||
setvar:tx.inbound_anomaly_score_threshold=5,\
|
||||
setvar:tx.outbound_anomaly_score_threshold=4"
|
||||
|
||||
SecRule &TX:crs_setup_version "@eq 0" \
|
||||
"id:901001,\
|
||||
phase:1,\
|
||||
deny,\
|
||||
status:500,\
|
||||
msg:'Hello it\\'s broken',\
|
||||
tag:'OWASP_CRS',\
|
||||
tag:'paranoia-level/2',\
|
||||
severity:'CRITICAL'"
|
||||
`
|
||||
|
||||
got, err := parseRules(strings.NewReader(raw), "test.conf")
|
||||
if err != nil {
|
||||
t.Fatalf("parseRules error: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 rules, got %d", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != 200001 || got[0].Phase != 1 || got[0].Directive != "SecRule" {
|
||||
t.Errorf("rule0 mismatch: %+v", got[0])
|
||||
}
|
||||
if got[1].ID != 900110 || got[1].Directive != "SecAction" {
|
||||
t.Errorf("rule1 mismatch: %+v", got[1])
|
||||
}
|
||||
if got[2].ID != 901001 || got[2].Severity != "CRITICAL" || got[2].Paranoia != 2 {
|
||||
t.Errorf("rule2 mismatch: %+v", got[2])
|
||||
}
|
||||
if got[2].Message == "" || !strings.Contains(got[2].Message, "Hello") {
|
||||
t.Errorf("rule2 msg not extracted: %q", got[2].Message)
|
||||
}
|
||||
if len(got[2].Tags) != 2 {
|
||||
t.Errorf("rule2 tags expected 2 got %v", got[2].Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRules_EmptyFile(t *testing.T) {
|
||||
got, err := parseRules(strings.NewReader("# just a comment\n"), "empty.conf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected 0 rules, got %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 集成测试:扫描仓库内真实的 CRS 规则目录,打印每个文件的规则数,用来检查解析是否漏规则。
|
||||
// 需要 cmd/samwaf/exedata/owasp 已释放。CI 上若不存在会跳过。
|
||||
func TestScanRealCRS(t *testing.T) {
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
repoRoot := filepath.Dir(filepath.Dir(thisFile)) // .../SamWaf
|
||||
owaspRoot := filepath.Join(repoRoot, "cmd", "samwaf", "exedata", "owasp")
|
||||
|
||||
files, err := ScanAllRules(owaspRoot)
|
||||
if err != nil {
|
||||
t.Skipf("skip: scan owasp: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Skip("skip: no .conf under cmd/samwaf/exedata/owasp")
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, f := range files {
|
||||
t.Logf("%-80s rules=%d", f.File, len(f.Rules))
|
||||
total += len(f.Rules)
|
||||
}
|
||||
t.Logf("TOTAL conf files=%d rules=%d", len(files), total)
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package wafowasp
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpgradeConfig 升级所需的外部配置(通过 ConfigureUpgrader 由上层注入,避免 wafowasp → global 循环依赖)。
|
||||
type UpgradeConfig struct {
|
||||
UpdateVersionURL string // 升级源根 URL,例如 http://update.samwaf.com/update/
|
||||
// NotifyFunc 当升级流程产生结果时被回调,可用于推送 WS 消息。success=false 表示失败。
|
||||
NotifyFunc func(success bool, msg string)
|
||||
}
|
||||
|
||||
var upgradeCfg atomic.Pointer[UpgradeConfig]
|
||||
|
||||
// ConfigureUpgrader 注入升级所需上下文。main 启动阶段调用一次即可。
|
||||
func ConfigureUpgrader(c UpgradeConfig) {
|
||||
cp := c
|
||||
upgradeCfg.Store(&cp)
|
||||
}
|
||||
|
||||
func currentUpgradeCfg() UpgradeConfig {
|
||||
if c := upgradeCfg.Load(); c != nil {
|
||||
return *c
|
||||
}
|
||||
return UpgradeConfig{}
|
||||
}
|
||||
|
||||
// UpgradeInfo 远端最新规则包的元数据。
|
||||
type UpgradeInfo struct {
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Changelog string `json:"changelog"`
|
||||
NeedUpdate bool `json:"need_update"`
|
||||
LastCheckAt string `json:"last_check_at"`
|
||||
}
|
||||
|
||||
// remoteManifest 从远端下载的 latest.json 反序列化结构。
|
||||
type remoteManifest struct {
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Changelog string `json:"changelog"`
|
||||
}
|
||||
|
||||
// upgrading 并发保护:一次只允许一个升级流程。
|
||||
var upgrading atomic.Bool
|
||||
|
||||
// httpTimeout 下载 zip 的总超时(zip 可能较大,给 2 分钟)。
|
||||
const httpTimeout = 2 * time.Minute
|
||||
|
||||
// CheckUpgrade 查询远端 latest.json 并返回对比结果。
|
||||
//
|
||||
// 升级源约定:{GUPDATE_VERSION_URL}owasp-ruleset/latest.json
|
||||
// 响应:{"version": "1.0.20260401", "url": "...zip", "sha256": "...", "changelog": "..."}
|
||||
func CheckUpgrade(m *OwaspManager) (*UpgradeInfo, error) {
|
||||
cur := ReadLocalVersion(m.OwaspRoot())
|
||||
info := &UpgradeInfo{
|
||||
CurrentVersion: cur,
|
||||
LastCheckAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
cfg := currentUpgradeCfg()
|
||||
if cfg.UpdateVersionURL == "" {
|
||||
return info, fmt.Errorf("未配置升级源 GUPDATE_VERSION_URL")
|
||||
}
|
||||
manifestURL := strings.TrimRight(cfg.UpdateVersionURL, "/") + "/owasp-ruleset/latest.json"
|
||||
manifest, err := fetchManifest(manifestURL)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.LatestVersion = manifest.Version
|
||||
info.URL = manifest.URL
|
||||
info.SHA256 = manifest.SHA256
|
||||
info.Changelog = manifest.Changelog
|
||||
info.NeedUpdate = manifest.Version != "" && manifest.Version != cur
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ApplyUpgrade 实施升级。流程:
|
||||
// 1. CheckUpgrade 拿 manifest
|
||||
// 2. 下载 zip,sha256 校验
|
||||
// 3. 解压到 tmp/owasp-<ver>/
|
||||
// 4. 备份当前 coreruleset → coreruleset.bak
|
||||
// 5. 替换 coreruleset(保留 overrides)
|
||||
// 6. 将 registry 里标记为 modified 的 ID 从新的 coreruleset 中移除(合并策略 "by_rule_id")
|
||||
// 7. 更新 version 文件
|
||||
// 8. m.Reload()
|
||||
// 9. 失败时恢复备份
|
||||
func ApplyUpgrade(m *OwaspManager) error {
|
||||
if !upgrading.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("正在升级中,请稍后")
|
||||
}
|
||||
defer upgrading.Store(false)
|
||||
|
||||
if currentUpgradeCfg().UpdateVersionURL == "" {
|
||||
return fmt.Errorf("未配置升级源 GUPDATE_VERSION_URL")
|
||||
}
|
||||
|
||||
owaspRoot := m.OwaspRoot()
|
||||
|
||||
info, err := CheckUpgrade(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.NeedUpdate {
|
||||
return fmt.Errorf("当前已是最新版本: %s", info.CurrentVersion)
|
||||
}
|
||||
if info.URL == "" {
|
||||
return fmt.Errorf("远端未提供 URL")
|
||||
}
|
||||
|
||||
// 下载 zip 到 tmp
|
||||
tmpDir := filepath.Join(owaspRoot, "tmp")
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir tmp: %w", err)
|
||||
}
|
||||
zipPath := filepath.Join(tmpDir, fmt.Sprintf("owasp-%s.zip", info.LatestVersion))
|
||||
if err := downloadFile(info.URL, zipPath); err != nil {
|
||||
return fmt.Errorf("下载失败: %w", err)
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
|
||||
if info.SHA256 != "" {
|
||||
actual, err := fileSHA256(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sha256 失败: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(actual, info.SHA256) {
|
||||
return fmt.Errorf("sha256 校验不通过,预期 %s, 实际 %s", info.SHA256, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// 解压
|
||||
extractDir := filepath.Join(tmpDir, fmt.Sprintf("owasp-%s", info.LatestVersion))
|
||||
_ = os.RemoveAll(extractDir)
|
||||
if err := unzip(zipPath, extractDir); err != nil {
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(extractDir)
|
||||
|
||||
// 合并:从新 coreruleset 中剔除用户 modified 的规则块(保留用户版本)
|
||||
reg, _ := m.Overrides().LoadRegistry()
|
||||
modifiedIDs := collectModifiedIDs(reg)
|
||||
if len(modifiedIDs) > 0 {
|
||||
if err := stripRulesInDir(extractDir, modifiedIDs); err != nil {
|
||||
return fmt.Errorf("合并用户改动失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 备份 + 替换 coreruleset
|
||||
coreDir := filepath.Join(owaspRoot, "coreruleset")
|
||||
backupDir := filepath.Join(owaspRoot, "coreruleset.bak")
|
||||
_ = os.RemoveAll(backupDir)
|
||||
if _, err := os.Stat(coreDir); err == nil {
|
||||
if err := os.Rename(coreDir, backupDir); err != nil {
|
||||
return fmt.Errorf("备份 coreruleset 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解压目录下可能是 coreruleset/ 直接结构,也可能是 <name>/coreruleset/;
|
||||
// 这里尝试自动识别。
|
||||
srcCore, err := resolveCorerulesetInExtract(extractDir)
|
||||
if err != nil {
|
||||
// 回滚
|
||||
if _, statErr := os.Stat(backupDir); statErr == nil {
|
||||
_ = os.Rename(backupDir, coreDir)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return fmt.Errorf("应用新 coreruleset 失败: %w", err)
|
||||
}
|
||||
|
||||
// 写 version 文件
|
||||
if err := os.WriteFile(filepath.Join(owaspRoot, "version"), []byte(info.LatestVersion), 0644); err != nil {
|
||||
zlog.Error("写入 version 文件失败", map[string]interface{}{"err": err.Error()})
|
||||
}
|
||||
|
||||
// 刷新规则解析缓存,触发热重载
|
||||
InvalidateRuleCache()
|
||||
if err := m.Reload(); err != nil {
|
||||
// reload 失败时尝试回滚到备份
|
||||
_ = os.RemoveAll(coreDir)
|
||||
if _, statErr := os.Stat(backupDir); statErr == nil {
|
||||
_ = os.Rename(backupDir, coreDir)
|
||||
}
|
||||
InvalidateRuleCache()
|
||||
_ = m.Reload()
|
||||
return fmt.Errorf("升级后 Reload 失败,已回滚: %w", err)
|
||||
}
|
||||
|
||||
_ = os.RemoveAll(backupDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyUpgradeResult 将升级结果通过注入的 NotifyFunc 推送(WS / 消息队列等)。
|
||||
// 如果上层未调用 ConfigureUpgrader 注入 NotifyFunc,则仅记录日志。
|
||||
func NotifyUpgradeResult(success bool, msg string) {
|
||||
cfg := currentUpgradeCfg()
|
||||
if cfg.NotifyFunc != nil {
|
||||
cfg.NotifyFunc(success, msg)
|
||||
} else {
|
||||
zlog.Info("OWASP 升级结果", map[string]interface{}{"success": success, "msg": msg})
|
||||
}
|
||||
}
|
||||
|
||||
// ReadLocalVersion 读取 data/owasp/version;缺失返回 "unknown"。
|
||||
func ReadLocalVersion(owaspRoot string) string {
|
||||
data, err := os.ReadFile(filepath.Join(owaspRoot, "version"))
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
v := strings.TrimSpace(string(data))
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- 辅助 ----
|
||||
|
||||
var fetchClient = &http.Client{Timeout: httpTimeout}
|
||||
var fetchOnce sync.Once
|
||||
|
||||
func fetchManifest(url string) (*remoteManifest, error) {
|
||||
fetchOnce.Do(func() {})
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := fetchClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("manifest HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var m remoteManifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dst string) error {
|
||||
resp, err := fetchClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// unzip 解压 zip 到 dst 目录。安全检查:拒绝 zip slip。
|
||||
func unzip(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanDst, _ := filepath.Abs(dst)
|
||||
for _, f := range r.File {
|
||||
target := filepath.Join(dst, f.Name)
|
||||
absTarget, _ := filepath.Abs(target)
|
||||
if !strings.HasPrefix(absTarget, cleanDst) {
|
||||
return fmt.Errorf("zip slip detected: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, f.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
rc.Close()
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
rc.Close()
|
||||
out.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCorerulesetInExtract(dir string) (string, error) {
|
||||
// 优先检查 dir/coreruleset/
|
||||
if st, err := os.Stat(filepath.Join(dir, "coreruleset")); err == nil && st.IsDir() {
|
||||
return filepath.Join(dir, "coreruleset"), nil
|
||||
}
|
||||
// 否则看是否 zip 顶层就是 rules/ + crs-setup.conf
|
||||
if st, err := os.Stat(filepath.Join(dir, "rules")); err == nil && st.IsDir() {
|
||||
return dir, nil
|
||||
}
|
||||
// 再向下一层找第一个包含 rules/ 的目录
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
cand := filepath.Join(dir, e.Name())
|
||||
if st, err := os.Stat(filepath.Join(cand, "rules")); err == nil && st.IsDir() {
|
||||
return cand, nil
|
||||
}
|
||||
if st, err := os.Stat(filepath.Join(cand, "coreruleset")); err == nil && st.IsDir() {
|
||||
return filepath.Join(cand, "coreruleset"), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("zip 内找不到 coreruleset 或 rules 目录")
|
||||
}
|
||||
|
||||
func collectModifiedIDs(reg *OverrideRegistry) map[int]struct{} {
|
||||
out := map[int]struct{}{}
|
||||
if reg == nil {
|
||||
return out
|
||||
}
|
||||
for k, v := range reg.Rules {
|
||||
if v.Action != OverrideModified {
|
||||
continue
|
||||
}
|
||||
id := 0
|
||||
for _, ch := range k {
|
||||
if ch < '0' || ch > '9' {
|
||||
id = 0
|
||||
break
|
||||
}
|
||||
id = id*10 + int(ch-'0')
|
||||
}
|
||||
if id > 0 {
|
||||
out[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripRulesInDir 遍历 dir 下所有 .conf,将文件中指定 ID 的 SecRule/SecAction 块删除。
|
||||
// 用于升级时在新官方规则包中移除用户已改写的规则 ID,避免加载两份引起冲突。
|
||||
func stripRulesInDir(dir string, ids map[int]struct{}) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(info.Name()) != ".conf" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newData, changed := removeRulesByID(string(data), ids)
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(p, []byte(newData), info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
// removeRulesByID 删除内容中命中 ID 的 SecRule/SecAction 块。
|
||||
// 简单实现:按行走一遍,发现 SecRule/SecAction 起始且 ID 命中则跳过整块(直到第一个不以 \ 结尾的行)。
|
||||
func removeRulesByID(content string, ids map[int]struct{}) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var out []string
|
||||
changed := false
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "secrule") || strings.HasPrefix(lower, "secaction") {
|
||||
// 收集整个块
|
||||
start := i
|
||||
block := []string{line}
|
||||
for endsWithBackslash(line) && i+1 < len(lines) {
|
||||
i++
|
||||
line = lines[i]
|
||||
block = append(block, line)
|
||||
}
|
||||
// 检查块内是否包含目标 ID
|
||||
blockText := strings.Join(block, "\n")
|
||||
hit := false
|
||||
if m := reID.FindStringSubmatch(blockText); len(m) == 2 {
|
||||
id := 0
|
||||
for _, ch := range m[1] {
|
||||
id = id*10 + int(ch-'0')
|
||||
}
|
||||
if _, ok := ids[id]; ok {
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
changed = true
|
||||
i++
|
||||
_ = start
|
||||
continue
|
||||
}
|
||||
out = append(out, block...)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
i++
|
||||
}
|
||||
if !changed {
|
||||
return content, false
|
||||
}
|
||||
return strings.Join(out, "\n"), true
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package wafowasp
|
||||
|
||||
// UsageDocMarkdown 返回 OWASP 规则管理的使用讲解(Markdown)。
|
||||
//
|
||||
// 这份文档同时也是 SamWafTechDoc 下《OWASP规则在线管理使用指南.md》的主体内容。
|
||||
// 保持在代码里可以支持单机离线查阅,前端通过 /api/v1/owasp/usage/doc 取回 render。
|
||||
func UsageDocMarkdown() string {
|
||||
return usageDocMD
|
||||
}
|
||||
|
||||
const usageDocMD = `# OWASP 规则在线管理使用指南
|
||||
|
||||
本页面介绍如何在 SamWaf 后台管理 OWASP CRS 规则:禁用/改写单条规则、调优全局阈值、在线升级、沙盒验证。
|
||||
|
||||
## 一、工作原理速览
|
||||
|
||||
- SamWaf 集成了 [Coraza WAF](https://github.com/corazawaf/coraza) + **OWASP ModSecurity Core Rule Set (CRS)**。
|
||||
- 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 被改动 |
|
||||
|
||||
## 三、误报应对阶梯
|
||||
|
||||
当线上出现误报,按如下顺序排查:
|
||||
|
||||
1. **先用观察模式(DetectionOnly)评估**:在"全局调参"把 rule_engine 改成 DetectionOnly,线上只记录不拦截,跑 24h 看看哪些规则频繁误报。
|
||||
2. **调高总阈值**:把 inbound_anomaly_score_threshold 从 7 提到 10 甚至更高;但不要低于 3,过低时一次合规请求都可能触发。
|
||||
3. **禁用单条规则**:在"规则管理"搜到对应 ID,勾上禁用;后台会在 overrides/10-disabled-rules.conf 追加 SecRuleRemoveById,热重载即可生效。
|
||||
4. **改写单条规则**:对于只想修改动作或部分匹配条件的规则,使用"编辑"功能。保存的是 overrides/20-custom-rules.conf,原文件不动。
|
||||
5. **实在不行再关闭整个 OWASP**:通过系统配置 enable_owasp=0 或者 rule_engine=Off。
|
||||
|
||||
## 四、在线升级
|
||||
|
||||
- 升级源:SamWaf 自有服务
|
||||
- 流程:检查版本 → 下载 zip → 校验 sha256 → 合并(保留用户改动)→ 热重载
|
||||
- 用户在任意规则上的禁用或改写**升级时不会丢失**,原理是合并阶段会比对 override_registry.json。
|
||||
|
||||
## 五、沙盒验证
|
||||
|
||||
在"测试沙盒"可以构造请求(method/url/headers/body)实时看命中规则与累计分数。典型用例:
|
||||
|
||||
- 正常 GET /:应 0 命中。
|
||||
- GET /?id=1%27%20OR%201=1:应命中 942xxx 系列 SQLi 规则。
|
||||
- POST + body 含 <script>alert(1)</script>:应命中 941xxx 系列 XSS 规则。
|
||||
|
||||
## 六、性能注意
|
||||
|
||||
- 开启 debug 日志会让 SamWaf 额外采集 Coraza 全部变量,RSS 会升高。生产环境建议关闭。
|
||||
- 大请求体(> 13MB)会被 SecRequestBodyLimit 截断后再检测,不会全量驻留。
|
||||
|
||||
## 七、常见问题
|
||||
|
||||
1. **沙盒命中但线上没命中?** 多半是 paranoia-level 或阈值差异:确认两边 tuning 一致。
|
||||
2. **升级后规则变多了?** 正常。CRS 会随版本迭代新增规则。若想锁定版本,创建 data/owasp/lock.txt。
|
||||
3. **误禁用了重要规则?** 在规则管理里点击"还原",会从 registry 移除该 ID,热重载后恢复官方行为。
|
||||
`
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"SamWaf/model/request"
|
||||
"SamWaf/wafipban"
|
||||
"SamWaf/wafnotify/logfilewriter"
|
||||
"SamWaf/wafowasp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -76,6 +77,12 @@ func setConfigIntValue(name string, value int64, change int) {
|
||||
case "enable_owasp":
|
||||
global.GCONFIG_RECORD_ENABLE_OWASP = value
|
||||
break
|
||||
case "owasp_block_threshold":
|
||||
if value <= 0 {
|
||||
value = 7
|
||||
}
|
||||
global.GCONFIG_OWASP_BLOCK_THRESHOLD = value
|
||||
break
|
||||
case "enable_http_80":
|
||||
global.GCONFIG_RECORD_ENABLE_HTTP_80 = value
|
||||
break
|
||||
@@ -203,6 +210,19 @@ func setConfigStringValue(name string, value string, change int) {
|
||||
case "gwaf_proxy_header":
|
||||
global.GCONFIG_RECORD_PROXY_HEADER = value
|
||||
break
|
||||
case "owasp_mode":
|
||||
switch value {
|
||||
case "On", "DetectionOnly", "Off":
|
||||
global.GCONFIG_OWASP_MODE = value
|
||||
case "":
|
||||
global.GCONFIG_OWASP_MODE = "On"
|
||||
default:
|
||||
zlog.Warn("invalid owasp_mode value, fallback to On", value)
|
||||
global.GCONFIG_OWASP_MODE = "On"
|
||||
}
|
||||
// 同步到 wafowasp 热路径,使 DetectionOnly "本该拦截" 的 INFO 日志能按当前模式生效
|
||||
wafowasp.SetEngineMode(global.GCONFIG_OWASP_MODE)
|
||||
break
|
||||
case "kafka_url":
|
||||
global.GCONFIG_RECORD_KAFKA_URL = value
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user