一次线上故障的起因,仅仅是 WAF 自定义规则中的一个正则表达式打错了括号。手动变更,没有评审,直接在生产环境的 Web 服务器上 systemctl reload nginx
。后果是核心交易路径的流量被大面积误拦截,持续了十五分钟。这次事故暴露了一个长期被我们忽视的问题:安全策略的管理,还停留在手工运维的石器时代。它缺乏版本控制、自动化测试和发布流程,与我们应用代码的现代化 CI/CD 体系格格不入。
我们的目标很明确:将 WAF 规则的管理纳入 GitOps 流程。规则即代码 (Policy as Code),每一次变更都必须经过 Pull Request 评审,必须通过自动化测试,最终由 Flux CD 自动同步到 Kubernetes 集群中。这套流程里,Flux CD 负责发布,Git 负责版本控制,最大的挑战落在了“自动化测试”这一环。市面上没有现成的、轻量级的 ModSecurity 规则单元测试框架。所以,我们必须自己造一个。
技术选型与架构构想
- WAF 引擎: 我们选择 NGINX Ingress Controller 内置的 ModSecurity 模块。规则集以 OWASP Core Rule Set (CRS) 为基础,并叠加我们自己的业务自定义规则。
- GitOps 工具: Flux CD 是我们集群内的标准部署工具,它会监控我们的 WAF 规则 Git 仓库,并将规则变更同步为 Kubernetes 的 ConfigMap 资源。
- 单元测试框架: 我们决定用 Go 语言编写一个命令行工具
rule-tester
。选择 Go 是因为它编译为单个二进制文件,便于在任何 CI 环境中运行,并且其强大的并发能力和net/http/httptest
库非常适合模拟 HTTP 请求来触发 WAF 规则。这个工具将作为 CI 流水线中的一个关键步骤。
整个工作流程的设想如下:
graph TD subgraph "Local/CI Environment" A[Developer pushes new WAF rule to a feature branch] --> B{Git Push}; B --> C[GitHub Actions Triggered]; C --> D[Run 'rule-tester' for unit tests]; D -- Tests Pass --> E[Merge to main branch]; D -- Tests Fail --> F[Block Merge]; end subgraph "Kubernetes Cluster" G[Flux CD Controller] -- Watches --> H[(Git Repository: main branch)]; H -- Detects Change --> G; G -- Reconciles --> I[Generates/Updates ConfigMap with WAF rules]; I -- Is Mounted By --> J[NGINX Ingress Controller Pods]; J --> K[Pods Rolling Update to load new rules]; end E --> H;
Git 仓库结构设计
一个清晰的仓库结构是项目成功的基石。我们最终确定的结构如下:
waf-rules-repo/
├── .github/
│ └── workflows/
│ └── ci.yml # GitHub Actions CI 配置文件
├── deploy/ # Kubernetes 部署清单
│ ├── base/
│ │ ├── kustomization.yaml
│ │ └── rules-cm.yaml # ConfigMap 生成器配置
│ └── production/
│ ├── kustomization.yaml
│ └── flux-sync.yaml # Flux GitRepository 和 Kustomization 资源
├── rules/ # WAF 规则目录
│ ├── REQUEST-901-CUSTOM-BLOCKING.conf
│ └── RESPONSE-902-CUSTOM-DATA-LEAKAGE.conf
└── tests/ # 测试用例目录
├── main_test.go # Go 测试入口
├── testcases/ # YAML 格式的测试用例
│ ├── sql_injection.yaml
│ └── data_leakage.yaml
└── tools/ # Go 单元测试工具源码
├── runner.go
└── server.go
-
rules/
: 存放所有自定义的 ModSecurity 规则文件。 -
tests/
: 包含我们的 Go 测试工具源码和 YAML 格式的测试定义。 -
deploy/
: 存放用于 Flux CD 的 Kustomize 配置。
核心实现:Go 语言的 WAF 规则测试器
这是整个方案中最具技术挑战性的部分。我们需要一个工具,能够加载指定的 ModSecurity 规则文件,然后发送一个预设的 HTTP 请求,最后断言响应是否符合预期(例如,是否被拦截、状态码是否为 403、日志中是否包含特定规则 ID)。
一个常见的错误是尝试在 Go 中完整模拟 ModSecurity 的行为,这非常复杂。更务实的做法是利用 libmodsecurity
的 C 语言库,通过 Cgo 进行调用。但这会引入 C 库依赖,增加 CI 环境的复杂性。
我们最终选择了另一种更轻量级的、解耦的方案:在测试代码中动态启动一个嵌入了 ModSecurity 的真实 Web 服务器(如 OpenResty 或带有 ModSecurity 模块的 NGINX),然后对其发送请求。为了追求极致的轻量化和速度,我们选择了 Coraza WAF,一个纯 Go 实现的、兼容 ModSecurity 规则的 WAF 库。这让我们摆脱了 Cgo 和外部进程依赖。
tests/tools/server.go
: 测试服务器封装
我们首先封装一个可以加载规则并处理请求的 HTTP 服务器。
package tools
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/seclang"
)
// WAFTestServer is a wrapper around httptest.Server with a Coraza WAF instance.
type WAFTestServer struct {
Server *httptest.Server
WAF coraza.WAF
RuleFiles []string
DefaultDeny int
}
// NewWAFTestServer creates and starts a new test server with specified rule files.
func NewWAFTestServer(ruleFiles ...string) (*WAFTestServer, error) {
conf := coraza.NewWAFConfig()
// 在真实项目中,这里会有更复杂的配置,比如从外部文件加载
waf, err := coraza.NewWAF(conf.
WithDirectives(`
SecResponseBodyAccess On
SecResponseBodyMimeType text/plain
`))
if err != nil {
return nil, err
}
parser, _ := seclang.NewParser(waf)
// 加载所有指定的规则文件
for _, file := range ruleFiles {
content, err := os.ReadFile(file)
if err != nil {
return nil, err
}
if err := parser.FromString(string(content)); err != nil {
return nil, err
}
}
ts := &WAFTestServer{
WAF: waf,
RuleFiles: ruleFiles,
DefaultDeny: 403, // 默认的拒绝状态码
}
// 定义一个简单的后端应用,WAF 将会保护它
backendApp := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello, world!"))
})
// WAF 中间件逻辑
wafMiddleware := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tx := ts.WAF.NewTransaction()
defer tx.Close()
// 处理请求阶段
interruption := tx.ProcessRequest(r)
if interruption != nil {
w.WriteHeader(ts.DefaultDeny)
return
}
// 如果请求通过,则代理到后端,并捕获响应
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
// 将后端响应的头信息复制到真实响应中
for k, v := range rec.Header() {
w.Header()[k] = v
}
// 处理响应阶段
interruption = tx.ProcessResponse(rec.Result())
if interruption != nil {
// 清除可能已写入的响应头和内容
for k := range w.Header() {
delete(w.Header(), k)
}
w.WriteHeader(ts.DefaultDeny)
return
}
w.WriteHeader(rec.Code)
w.Write(rec.Body.Bytes())
})
}
ts.Server = httptest.NewServer(wafMiddleware(backendApp))
return ts, nil
}
// Close shuts down the test server.
func (ts *WAFTestServer) Close() {
ts.Server.Close()
}
tests/testcases/sql_injection.yaml
: YAML 测试用例
将测试用例定义为声明式的 YAML 文件,可以让安全工程师和开发工程师都能轻松读写。
# tests/testcases/sql_injection.yaml
name: "Test Custom SQL Injection Rule"
rule_files:
- "../../rules/REQUEST-901-CUSTOM-BLOCKING.conf"
cases:
- description: "Should block simple SQL injection in query parameter"
request:
method: "GET"
uri: "/search?q=1'%20or%20'1'='1"
headers:
User-Agent: "WAF-Test-Runner"
expected:
status: 403
- description: "Should allow normal query parameter"
request:
method: "GET"
uri: "/search?q=product-name"
headers:
User-Agent: "WAF-Test-Runner"
expected:
status: 200
tests/main_test.go
: 测试驱动程序
这个 Go 测试文件会读取所有 YAML 文件,并为每个 case
生成一个子测试。
package tests
import (
"fmt"
"gopkg.in/yaml.v2"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"waf-testing/tests/tools" // 假设项目模块路径为 waf-testing
)
// TestCase defines the structure for a single test case within a YAML file.
type TestCase struct {
Description string `yaml:"description"`
Request struct {
Method string `yaml:"method"`
URI string `yaml:"uri"`
Headers map[string]string `yaml:"headers"`
Body string `yaml:"body"`
} `yaml:"request"`
Expected struct {
Status int `yaml:"status"`
} `yaml:"expected"`
}
// TestSuite defines the structure of a YAML test file.
type TestSuite struct {
Name string `yaml:"name"`
RuleFiles []string `yaml:"rule_files"`
Cases []TestCase `yaml:"cases"`
}
func TestWAFRules(t *testing.T) {
testcaseDir := "./testcases"
files, err := filepath.Glob(filepath.Join(testcaseDir, "*.yaml"))
if err != nil {
t.Fatalf("Failed to find test case files: %v", err)
}
if len(files) == 0 {
t.Log("No test case files found, skipping WAF rule tests.")
return
}
for _, file := range files {
suiteFile := file
t.Run(fmt.Sprintf("TestSuite_%s", filepath.Base(suiteFile)), func(t *testing.T) {
t.Parallel() // 并行执行不同的测试文件
data, err := os.ReadFile(suiteFile)
if err != nil {
t.Fatalf("Failed to read test suite file %s: %v", suiteFile, err)
}
var suite TestSuite
if err := yaml.Unmarshal(data, &suite); err != nil {
t.Fatalf("Failed to unmarshal YAML from %s: %v", suiteFile, err)
}
// 为 YAML 文件中定义的规则文件路径添加相对路径前缀
var absoluteRuleFiles []string
for _, rf := range suite.RuleFiles {
// 这里的路径解析需要根据实际文件结构调整
absPath := filepath.Join(filepath.Dir(suiteFile), rf)
absoluteRuleFiles = append(absoluteRuleFiles, absPath)
}
server, err := tools.NewWAFTestServer(absoluteRuleFiles...)
if err != nil {
t.Fatalf("Failed to create WAF test server for suite %s: %v", suite.Name, err)
}
defer server.Close()
for _, tc := range suite.Cases {
testCase := tc // Capture range variable
t.Run(testCase.Description, func(t *testing.T) {
t.Parallel() // 并行执行同一个文件内的不同 case
var reqBody io.Reader
if testCase.Request.Body != "" {
reqBody = strings.NewReader(testCase.Request.Body)
}
req, err := http.NewRequest(testCase.Request.Method, server.Server.URL+testCase.Request.URI, reqBody)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
for k, v := range testCase.Request.Headers {
req.Header.Set(k, v)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to execute request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != testCase.Expected.Status {
t.Errorf("Expected status code %d, but got %d", testCase.Expected.Status, resp.StatusCode)
}
})
}
})
}
}
CI 与 GitOps 的整合
GitHub Actions CI 流水线
.github/workflows/ci.yml
文件定义了在每次提交和 PR 时运行的检查。
# .github/workflows/ci.yml
name: WAF Rules CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Go Tidy
run: go mod tidy
working-directory: ./tests
- name: Run WAF Rule Tests
run: go test -v ./...
working-directory: ./tests
现在,任何对 .conf
规则文件的修改,如果对应的测试用例没有创建或未通过,PR 将会被阻塞。这强制实现了“测试先行”或至少“测试覆盖”的开发文化,即使对于安全规则也是如此。
Flux CD 部署配置
Flux CD 的配置是这套流程的最后一公里。我们使用 Kustomize 来从规则文件动态生成 ConfigMap。
deploy/base/kustomization.yaml
:
# deploy/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# 使用 configMapGenerator 从文件生成 ConfigMap
# 这里的 key (文件名) 会成为 ConfigMap 中的 data key
# NGINX Ingress 需要这些规则在同一个文件中,所以我们稍后会通过某种方式合并它们
# 或者,更简单地,让 NGINX Ingress Controller 加载整个目录
configMapGenerator:
- name: custom-waf-rules
files:
- ../../rules/REQUEST-901-CUSTOM-BLOCKING.conf
- ../../rules/RESPONSE-902-CUSTOM-DATA-LEAKAGE.conf
deploy/production/flux-sync.yaml
:
# deploy/production/flux-sync.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: waf-rules
namespace: flux-system
spec:
interval: 1m
url: https://github.com/your-org/waf-rules-repo
ref:
branch: main
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: waf-rules-sync
namespace: security # 规则部署在 security 命名空间
spec:
interval: 5m
path: "./deploy/base" # Kustomize 构建的入口目录
prune: true
sourceRef:
kind: GitRepository
name: waf-rules
targetNamespace: ingress-nginx # ConfigMap 将被创建在这个命名空间
当 main
分支有更新,Flux CD 会检测到变更,运行 Kustomize 构建,生成一个新的 custom-waf-rules
ConfigMap,并将其应用到 ingress-nginx
命名空间。NGINX Ingress Controller 检测到其挂载的 ConfigMap 发生变化后,会自动、平滑地重载配置,应用新的 WAF 规则。
局限性与未来迭代方向
这套系统解决了 WAF 规则管理的核心痛点,但它并非完美。
首先,目前的测试是“单元测试”级别的,它验证单个规则在隔离环境下的行为。但 WAF 规则之间可能存在复杂的相互作用和顺序依赖。一个更完整的方案需要“集成测试”,即在一个包含所有基础规则(如 OWASP CRS)的环境中运行测试用例,确保新规则不会与现有规则集冲突或被意外绕过。
其次,性能测试是缺失的一环。一个编写拙劣的、包含复杂回溯的正则表达式可能会显著增加请求处理的延迟。未来的迭代可以考虑在 CI 流程中加入一个简单的性能基准测试步骤,当某个规则导致 P99 延迟急剧增加时发出警告。
最后,我们的测试器目前只断言了 HTTP 状态码。一个更健壮的测试器应该能检查响应体内容、响应头,甚至是通过某种方式捕获 WAF 的审计日志,来断言是哪条具体的规则 ID 触发了拦截。这能让测试用例写得更加精确,减少误报。