Files
2026-07-25 21:54:30 +08:00

267 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package engine 封装 coraza WAF,提供规则加载、热加载、规则列举与单条输入检测。
package engine
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/types"
"github.com/micro_servers/waf/internal/logclient"
)
// Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。
type Engine struct {
mu sync.RWMutex
waf coraza.WAF
cfg *Config
}
// Config 引擎配置。
type Config struct {
// RulesDir 规则根目录,主入口文件为 <RulesDir>/waf.conf
RulesDir string
// ErrorCallback 规则加载/解析错误回调
ErrorCallback func(mr types.MatchedRule)
}
// New 创建引擎并首次加载规则。
func New(cfg *Config) (*Engine, error) {
e := &Engine{cfg: cfg}
if err := e.Reload(); err != nil {
return nil, err
}
return e, nil
}
// Reload 重新加载规则文件,原子替换内部 WAF 实例。
// 加载顺序:
// 1. <RulesDir>/waf.conf 基础配置
// 2. <RulesDir>/local/*.conf 本地自定义规则(按文件名字母序)
// 3. <RulesDir>/crs/coraza.conf OWASP CRS 入口(若存在)
// 4. <RulesDir>/crs/rules/*.conf OWASP CRS 规则(按文件名字母序)
//
// 不使用 coraza 的 Include 指令(其路径相对于 cwd 解析易出错),
// 改为 engine 自己扫描目录逐个 WithDirectivesFromFile 加载。
// 失败时保留旧实例不动,确保服务可用。
func (e *Engine) Reload() error {
mainFile := filepath.Join(e.cfg.RulesDir, "waf.conf")
if _, err := os.Stat(mainFile); err != nil {
return fmt.Errorf("主规则文件不存在: %w", err)
}
builder := coraza.NewWAFConfig()
if e.cfg.ErrorCallback != nil {
builder = builder.WithErrorCallback(e.cfg.ErrorCallback)
}
// 1. 主配置
builder = builder.WithDirectivesFromFile(mainFile)
// 2. 本地规则(local/*.conf,按字母序)
localFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "local", "*.conf"))
for _, f := range localFiles {
builder = builder.WithDirectivesFromFile(f)
}
// 3. OWASP CRS(可选,crs/coraza.conf + crs/rules/*.conf)
crsEntry := filepath.Join(e.cfg.RulesDir, "crs", "coraza.conf")
if _, err := os.Stat(crsEntry); err == nil {
builder = builder.WithDirectivesFromFile(crsEntry)
crsRuleFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "crs", "rules", "*.conf"))
for _, f := range crsRuleFiles {
builder = builder.WithDirectivesFromFile(f)
}
}
newWAF, err := coraza.NewWAF(builder)
if err != nil {
return fmt.Errorf("规则加载失败: %w", err)
}
e.mu.Lock()
e.waf = newWAF
e.mu.Unlock()
return nil
}
// NewTransaction 创建一个事务,由调用方 Close。
func (e *Engine) NewTransaction() types.Transaction {
e.mu.RLock()
defer e.mu.RUnlock()
return e.waf.NewTransaction()
}
// CheckInput 用当前规则测试单个输入字符串,返回命中的规则 ID 列表。
// 极客 CLI 用:waf -check "/?id=1' UNION SELECT * from users"
// 输入按原始未编码字符串处理,内部对 query 做 URL 编码以符合 coraza ParseRequestURI 要求。
func (e *Engine) CheckInput(input string) []int {
tx := e.NewTransaction()
defer tx.Close()
uri := input
if !strings.HasPrefix(uri, "/") {
uri = "/" + uri
}
// 分离 path 与 query,对 query 做合法 URL 编码(空格等非法字符需转义)
// 这样 coraza ProcessURI 内部的 url.ParseRequestURI 不会失败,ARGS 才能被正确提取
fullURI := uri
if idx := strings.Index(uri, "?"); idx >= 0 {
path := uri[:idx]
rawQuery := uri[idx+1:]
// 用 net/url 编码 query:保留 & = ,其他特殊字符转义
var parts []string
for _, kv := range strings.Split(rawQuery, "&") {
eq := strings.Index(kv, "=")
if eq < 0 {
parts = append(parts, url.QueryEscape(kv))
} else {
parts = append(parts, url.QueryEscape(kv[:eq])+"="+url.QueryEscape(kv[eq+1:]))
}
}
fullURI = path + "?" + strings.Join(parts, "&")
}
// 照搬 coraza/http/middleware.go 的 processRequest 流程
tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 12345)
tx.ProcessURI(fullURI, "GET", "HTTP/1.1")
tx.AddRequestHeader("Host", "localhost")
tx.SetServerName("localhost")
if it := tx.ProcessRequestHeaders(); it != nil {
return []int{it.RuleID}
}
if it, _ := tx.ProcessRequestBody(); it != nil {
return []int{it.RuleID}
}
return nil
}
// ListRules 扫描规则目录,返回所有 SecRule 的 ID 与原始行。
// 仅做文本扫描,不解析语义——极客用 grep 友好。
// 处理 `\` 续行:以 `\` 结尾的行与下一行合并后再抽取 id。
func (e *Engine) ListRules() ([]Rule, error) {
var rules []Rule
err := filepath.Walk(e.cfg.RulesDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
if !strings.HasSuffix(path, ".conf") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
rel, _ := filepath.Rel(e.cfg.RulesDir, path)
// 先合并续行,保留每条逻辑行在原文件的起始行号
type logicalLine struct {
startLine int
text string
}
var lines []logicalLine
rawLines := strings.Split(string(data), "\n")
for i := 0; i < len(rawLines); i++ {
start := i + 1
cur := strings.TrimRight(rawLines[i], "\r")
// 合并以 \ 结尾的续行
for strings.HasSuffix(cur, "\\") && i+1 < len(rawLines) {
cur = cur[:len(cur)-1] + " " + strings.TrimRight(rawLines[i+1], "\r")
i++
}
lines = append(lines, logicalLine{startLine: start, text: cur})
}
for _, ll := range lines {
line := strings.TrimSpace(ll.text)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if id, ok := extractRuleID(line); ok {
rules = append(rules, Rule{
ID: id,
File: rel,
Line: ll.startLine,
Source: line,
})
}
}
return nil
})
return rules, err
}
// Rule 描述一条 SecRule 的位置信息。
type Rule struct {
ID int `json:"id"`
File string `json:"file"`
Line int `json:"line"`
Source string `json:"source"`
}
// extractRuleID 从一行 SecRule 中抠出 id:nnnn 字段。
// 形如: SecRule REQUEST_URI "@rx (?i)union.*select" "id:100001,phase:1,deny"
func extractRuleID(line string) (int, bool) {
idx := strings.Index(line, "id:")
if idx < 0 {
return 0, false
}
rest := line[idx+3:]
end := 0
for end < len(rest) {
c := rest[end]
if c < '0' || c > '9' {
break
}
end++
}
if end == 0 {
return 0, false
}
var id int
fmt.Sscanf(rest[:end], "%d", &id)
return id, true
}
// TestFile 仅尝试加载单个规则文件,验证语法。
// 通过临时拼一个 Include 实现,目标文件转绝对路径以适配 coraza 解析目录。
func TestFile(file string) error {
abs, err := filepath.Abs(file)
if err != nil {
return err
}
if _, err := os.Stat(abs); err != nil {
return fmt.Errorf("文件不存在: %w", err)
}
tmp, err := os.CreateTemp("", "waf-test-*.conf")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(fmt.Sprintf("Include %s\n", abs))
tmp.Close()
if err != nil {
return err
}
builder := coraza.NewWAFConfig().WithDirectivesFromFile(tmp.Name())
_, err = coraza.NewWAF(builder)
if err != nil {
return fmt.Errorf("语法错误: %w", err)
}
return nil
}
// LogError 默认错误回调,推送到日志库。
func LogError(mr types.MatchedRule) {
logclient.Error("waf", fmt.Sprintf("coraza rule_id=%d severity=%s", mr.Rule().ID(), mr.Rule().Severity()),
map[string]any{"rule_id": mr.Rule().ID(), "msg": mr.ErrorLog()})
}