feat: owasp

#546
This commit is contained in:
samwaf
2026-04-20 17:32:11 +08:00
parent 075dda578b
commit 25c5bbedfe
22 changed files with 3200 additions and 137 deletions
+1
View File
@@ -58,6 +58,7 @@ type APIGroup struct {
WafOPlatformLogApi
WafOPlatformDocApi
WafDataRetentionApi
WafOwaspApi
}
var APIGroupAPP = new(APIGroup)
+802
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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) //引擎链
+1
View File
@@ -56,6 +56,7 @@ type ApiGroup struct {
WafOPlatformLogRouter
WafOPlatformDocRouter
WafDataRetentionRouter
WafOwaspRouter
}
type PublicApiGroup struct {
LoginRouter
+43
View File
@@ -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)
}
+3
View File
@@ -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
View File
@@ -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
}
+10 -3
View File
@@ -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
}
}
// 添加防盗链检查
+1
View File
@@ -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 访问(安全敏感接口)
+79
View File
@@ -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
View File
@@ -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 {
+9 -31
View File
@@ -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) {
}
+247
View File
@@ -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
}
+687
View File
@@ -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
}
+302
View File
@@ -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()
}
+68
View File
@@ -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))
}
}
+30
View File
@@ -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)
}
+470
View File
@@ -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
}
+67
View File
@@ -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,热重载后恢复官方行为。
`
+20
View File
@@ -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