构建基于 Flux CD 的 WAF 规则 GitOps 交付流水线与单元测试实践


一次线上故障的起因,仅仅是 WAF 自定义规则中的一个正则表达式打错了括号。手动变更,没有评审,直接在生产环境的 Web 服务器上 systemctl reload nginx。后果是核心交易路径的流量被大面积误拦截,持续了十五分钟。这次事故暴露了一个长期被我们忽视的问题:安全策略的管理,还停留在手工运维的石器时代。它缺乏版本控制、自动化测试和发布流程,与我们应用代码的现代化 CI/CD 体系格格不入。

我们的目标很明确:将 WAF 规则的管理纳入 GitOps 流程。规则即代码 (Policy as Code),每一次变更都必须经过 Pull Request 评审,必须通过自动化测试,最终由 Flux CD 自动同步到 Kubernetes 集群中。这套流程里,Flux CD 负责发布,Git 负责版本控制,最大的挑战落在了“自动化测试”这一环。市面上没有现成的、轻量级的 ModSecurity 规则单元测试框架。所以,我们必须自己造一个。

技术选型与架构构想

  1. WAF 引擎: 我们选择 NGINX Ingress Controller 内置的 ModSecurity 模块。规则集以 OWASP Core Rule Set (CRS) 为基础,并叠加我们自己的业务自定义规则。
  2. GitOps 工具: Flux CD 是我们集群内的标准部署工具,它会监控我们的 WAF 规则 Git 仓库,并将规则变更同步为 Kubernetes 的 ConfigMap 资源。
  3. 单元测试框架: 我们决定用 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 触发了拦截。这能让测试用例写得更加精确,减少误报。


  目录