在GitHub Actions中为OpenFaaS函数动态生成嵌入式WAF规则的技术实践


团队转向Serverless架构后,一个棘手的问题很快浮出水面:安全防护。部署在API网关层的通用WAF策略,在面对成百上千、功能各异的FaaS函数时显得力不从心。过于宽泛的规则集导致了大量误报,而为每个函数手动维护一套精准的规则又几乎是不可能完成的任务。这种安全与运维之间的矛盾,促使我们必须寻找一种将安全策略“左移”到开发流程中,并且能够与函数生命周期紧密绑定的自动化方案。

我们的初步构想是:安全规则应该像代码一样,与函数逻辑一同被版本控制。当一个函数被部署时,一套为其量身定制的、最小化的WAF规则集也应该被“嵌入”到其运行环境中。这意味着CI/CD流水线不仅要负责构建和部署业务代码,还必须承担起理解代码、生成安全策略并打包交付的职责。

技术选型与架构决策

要实现这个目标,整个工具链的选择至关重要。

  1. 运行时与框架 (Go): 我们选择Go作为函数的开发语言。它的静态编译特性可以产出无依赖的单一二进制文件,这对于构建轻量级容器镜像至关重要。同时,Go强大的标准库和并发性能非常适合高吞吐的Serverless场景。我们不引入重型框架,直接基于net/http构建,以保持最大的控制力和透明度。

  2. Serverless平台 (OpenFaaS): 相比于其他FaaS平台,OpenFaaS的开放性和灵活性是决定性因素。它允许我们使用标准的Docker/OCI镜像作为函数,并支持自定义模板。这为我们注入自定义的WAF中间件提供了可能性。

  3. CI/CD (GitHub Actions): 作为代码托管平台,使用GitHub Actions可以实现无缝的GitOps流程。其丰富的生态和强大的自定义能力,使我们能够轻松地在流水线中插入代码分析和规则生成的步骤。

  4. 容器构建 (Jib): 在CI环境中,避免使用Docker Daemon是一个常见的最佳实践。Jib可以直接从Java/Go等源码构建优化的OCI镜像,无需docker build,完美契合在GitHub Actions的Runner中运行。

  5. WAF引擎 (Coraza): 我们需要在函数运行时内部执行WAF策略。Coraza是一个Go语言实现的、兼容ModSecurity规则集的WAF库。我们可以将其作为一个HTTP中间件集成到我们的OpenFaaS Go模板中。

最终的架构流程如下:

graph TD
    subgraph GitHub Repository
        A[Developer Pushes Go Code] --> B{Code includes special annotations};
    end

    A --> C{GitHub Actions Workflow Triggered};

    subgraph CI/CD Pipeline on GitHub Runner
        C --> D[1. Checkout Code];
        D --> E[2. Static Analysis & Rule Generation];
        E -- Generates --> F[waf-rules.conf];
        E -- Go Source --> G[3. Build Go Binary];
        G & F --> H[4. Build OCI Image with Jib];
        H -- Injects Binary & WAF rules --> I[Function Image];
    end

    subgraph Container Registry
        I --> J[ghcr.io];
    end

    subgraph OpenFaaS Cluster
        K[OpenFaaS Operator] -- Watches --> L[Function Definition YAML];
        K -- Deploys --> M[Function Pod];
        M -- Loads Image from --> J;
        subgraph Function Pod
            N[Go Binary]
            O[Embedded waf-rules.conf]
            P[Coraza WAF Middleware]
        end
        P -- Loads rules from --> O;
        P -- Wraps --> N;
    end

    C --> L_update[5. Update OpenFaaS YAML & Deploy];
    L_update -- git push --> L;

    U[User Request] --> Q[API Gateway];
    Q --> M;

核心实现:从代码注解到运行时防护

整个方案的核心在于三个部分:代码注解规范、规则生成脚本、以及集成了WAF的自定义OpenFaaS模板。

1. 定义函数与安全注解

我们约定了一种特殊的代码注释格式,// @waf-rule:, 开发者在定义HTTP Handler时,必须明确声明其期望的输入参数和约束。这不仅是文档,更是生成WAF规则的直接来源。

这是一个处理用户资料查询的函数示例 user-profile/handler.go:

package function

import (
	"fmt"
	"net/http"
	"regexp"
)

//
// @waf-rule: id ^[a-zA-Z0-9_-]{8,36}$
// @waf-rule: fields ^[a-z,]+$
//
func Handle(w http.ResponseWriter, r *http.Request) {
	// WAF中间件已经在此Handler执行前处理了请求

	id := r.URL.Query().Get("id")
	if id == "" {
		http.Error(w, "user id is required", http.StatusBadRequest)
		return
	}

	fields := r.URL.Query().Get("fields")

	// 模拟业务逻辑
	fmt.Fprintf(w, "Fetching profile for user ID: %s with fields: %s\n", id, fields)
}

// Simple validator for demonstration. In a real project, this would be more robust.
// This logic is effectively enforced by WAF before reaching here.
func validateInput(id, fields string) bool {
	if match, _ := regexp.MatchString(`^[a-zA-Z0-9_-]{8,36}$`, id); !match {
		return false
	}
	if fields != "" {
		if match, _ := regexp.MatchString(`^[a-z,]+$`, fields); !match {
			return false
		}
	}
	return true
}

这里的@waf-rule注解简单直观,定义了id参数必须匹配的正则表达式,以及可选的fields参数格式。

2. 自定义Go模板与Coraza中间件集成

我们需要创建一个自定义的OpenFaaS模板,它在标准的Go HTTP服务器上包裹一层Coraza WAF中间件。

首先是项目结构:

template/go-waf/
├── Dockerfile
├── function
│   ├── handler.go
│   └── go.mod
├── go.mod
├── go.sum
└── main.go

template/go-waf/main.go 是模板的入口点,负责启动服务器和加载中间件:

package main

import (
	"log"
	"net/http"
	"os"

	"github.com/corazawaf/coraza/v3"
	"github.com/corazawaf/coraza/v3/http/negroni"
	handler "function"
)

const (
	defaultRulesPath = "/home/app/waf-rules.conf"
)

func main() {
	// 默认WAF配置,生产环境应该更复杂
	conf := coraza.NewWAFConfig().
		WithDirectives(`
			SecRequestBodyAccess On
			SecResponseBodyAccess On
		`)

	// 检查函数特定的规则文件是否存在
	if _, err := os.Stat(defaultRulesPath); !os.IsNotExist(err) {
		log.Printf("Found function-specific WAF rules at %s, applying them.", defaultRulesPath)
		conf = conf.WithDirectivesFromFile(defaultRulesPath)
	} else {
		log.Println("No function-specific WAF rules found. Running with default config.")
		// 在没有特定规则时,可以加载一个基础的防护规则集,例如OWASP Core Rule Set的一部分
		conf = conf.WithDirectives(`
			SecRuleEngine DetectionOnly
			SecRule ARGS "@rx evil" "id:101,phase:2,block,msg:'Generic evil pattern detected'"
		`)
	}

	waf, err := coraza.NewWAF(conf)
	if err != nil {
		log.Fatalf("Failed to initialize WAF: %v", err)
	}

	// 使用 Negroni (或任何其他兼容的中间件库) 来包装 Coraza WAF
	mux := http.NewServeMux()
	mux.HandleFunc("/", handler.Handle)

	// 创建中间件实例
	wafMiddleware := negroni.NewWAF(waf)
	
	// 构建处理器链
	finalHandler := http.Handler(mux)
	finalHandler = wafMiddleware.WAF(finalHandler)

	log.Println("Starting server with WAF protection on port 8080")
	if err := http.ListenAndServe(":8080", finalHandler); err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

这个main.go会检查/home/app/waf-rules.conf文件。如果存在,就加载其中的规则;如果不存在,则可能加载一套默认规则或以仅检测模式运行。

对应的Dockerfile则需要确保Go环境和我们的模板代码被正确打包:

FROM golang:1.21-alpine as build

WORKDIR /src/
COPY . .

# 构建模板的主入口
RUN CGO_ENABLED=0 go build -o /app/main .

# 构建函数本身,如果需要的话
# 在OpenFaaS的实际构建流程中,这里会被替换为用户的函数代码
COPY ./function/go.mod ./function/go.sum ./function/
RUN cd ./function && go mod download
COPY ./function .
RUN CGO_ENABLED=0 go build -o /app/function-handler-binary ./...

# 最终的运行时镜像
FROM alpine:3.18

# 增加非root用户,这是生产环境的最佳实践
RUN addgroup -S app && adduser -S -g app app
USER app

WORKDIR /home/app
COPY --from=build /app/main .
# 注意:实际的函数二进制和WAF规则文件将由Jib在CI/CD阶段注入
# 这里只是一个占位符
# COPY --from=build /app/function-handler-binary . 
# COPY ./waf-rules.conf .

# OpenFaaS Watchdog 会调用这个入口
ENV fprocess="./main"
CMD ["fwatchdog"]

3. 规则生成脚本

这是自动化流程的核心。我们用一个简单的Go程序来解析源码中的@waf-rule注解,并生成ModSecurity规则。

tools/rule-generator/main.go:

package main

import (
	"bufio"
	"fmt"
	"log"
	"os"
	"strings"
	"sync/atomic"
)

func main() {
	if len(os.Args) < 3 {
		log.Fatal("Usage: go run main.go <source-file> <output-file>")
	}
	sourceFile := os.Args[1]
	outputFile := os.Args[2]

	file, err := os.Open(sourceFile)
	if err != nil {
		log.Fatalf("Failed to open source file: %v", err)
	}
	defer file.Close()

	var rules []string
	var ruleID int32 = 1000 // 起始规则ID

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if strings.HasPrefix(line, "// @waf-rule:") {
			parts := strings.SplitN(strings.TrimPrefix(line, "// @waf-rule:"), " ", 2)
			if len(parts) != 2 {
				log.Printf("Skipping invalid rule format: %s", line)
				continue
			}

			argName := strings.TrimSpace(parts[0])
			pattern := strings.TrimSpace(parts[1])
			
			// SecRule ARGS:id "!@rx ^[a-zA-Z0-9_-]{8,36}$" "id:1001,phase:2,block,msg:'Invalid user ID format'"
			rule := fmt.Sprintf(`SecRule ARGS:%s "!@rx %s" "id:%d,phase:2,block,msg:'Invalid format for parameter %s'"`,
				argName, pattern, atomic.AddInt32(&ruleID, 1), argName)
			rules = append(rules, rule)
		}
	}

	if err := scanner.Err(); err != nil {
		log.Fatalf("Error reading source file: %v", err)
	}

	if len(rules) == 0 {
		log.Println("No WAF rules generated. Creating an empty file.")
		// 即使没有规则,也要创建一个空文件或一个只有SecRuleEngine On的文件,以避免WAF加载失败
		os.WriteFile(outputFile, []byte("SecRuleEngine On\n# No function-specific rules defined.\n"), 0644)
		return
	}

	header := "SecRuleEngine On\n# Auto-generated WAF rules from source code annotations\n"
	content := header + strings.Join(rules, "\n")

	err = os.WriteFile(outputFile, []byte(content), 0644)
	if err != nil {
		log.Fatalf("Failed to write to output file: %v", err)
	}

	log.Printf("Successfully generated %d WAF rules to %s", len(rules), outputFile)
}

这个脚本足够简单,但在真实项目中,可能需要使用Go的AST(抽象语法树)包来进行更可靠的解析,而不仅仅是文本扫描。

4. GitHub Actions工作流与Jib集成

现在,我们将所有部分串联起来。.github/workflows/deploy.yml文件定义了完整的CI/CD流程。

name: Deploy Go Function with Embedded WAF to OpenFaaS

on:
  push:
    branches:
      - main
    paths:
      - 'user-profile/**'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    env:
      FUNCTION_NAME: user-profile
      REGISTRY: ghcr.io/${{ github.repository_owner }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: '1.21'

      - name: Generate WAF Rules
        id: waf_rules
        run: |
          go run ./tools/rule-generator/main.go \
            ./${{ env.FUNCTION_NAME }}/handler.go \
            ./${{ env.FUNCTION_NAME }}/waf-rules.conf
          echo "Rules generated in ./${{ env.FUNCTION_NAME }}/waf-rules.conf"

      - name: Set up Jib
        uses: anbuselvan/setup-jib@v1
      
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push OCI Image with Jib
        run: |
          jib build \
            --target=image \
            --from=ghcr.io/openfaas/of-watchdog:0.9.5 as watchdog \
            --from-image=golang:1.21-alpine as builder \
            --build-file=jib-build.toml \
            --image=${{ env.REGISTRY }}/${{ env.FUNCTION_NAME }}:latest

    # Jib的配置文件 jib-build.toml
    # --- jib-build.toml ---
    # [from]
    # image = "alpine:3.18"
    #
    # [[layers]]
    # type = "files"
    # [layers.entries."/home/app/"]
    #   properties = { filePermissions = "755", user = "app", group = "app" }
    #   [layers.entries."/home/app/".files]
    #     "main" = "template/go-waf/main" # Assume pre-built
    #     "function" = "${FUNCTION_NAME}/handler" # Assume pre-built
    #     "waf-rules.conf" = "${FUNCTION_NAME}/waf-rules.conf"
    # [entrypoint]
    # command = ["./fwatchdog"]
    # [environment]
    # fprocess = "./main"
    # --- jib-build.toml ---
    # 注:为简化YAML,上面的Jib命令需要更复杂的参数来模拟toml文件。
    # 一个更实际的Jib CLI用法如下:
    # jib build --target=image \
    #   --from=alpine:3.18 \
    #   --entrypoint="/home/app/fwatchdog" \
    #   --environment="fprocess=/home/app/main" \
    #   --user="app" \
    #   --layers-from-jib-cli="builder:/src/${FUNCTION_NAME}=/home/app/function-binary" \
    #   --layers-from-jib-cli="builder:/src/template/go-waf/main=/home/app/main" \
    #   --layers-from-jib-cli="local:${FUNCTION_NAME}/waf-rules.conf=/home/app/waf-rules.conf"
    #   ... (这部分需要一个更复杂的构建脚本来先编译Go二进制文件)

      - name: Install faas-cli
        run: |
          curl -sSL https://cli.openfaas.com | sudo sh

      - name: Deploy to OpenFaaS
        run: |
          echo "${{ secrets.OPENFAAS_PASSWORD }}" | faas-cli login --username admin --password-stdin
          
          # 定义一个简单的部署YAML
          cat <<EOF > ${{ env.FUNCTION_NAME }}.yml
          provider:
            name: openfaas
            gateway: ${{ secrets.OPENFAAS_GATEWAY }}
          functions:
            ${{ env.FUNCTION_NAME }}:
              lang: dockerfile
              image: ${{ env.REGISTRY }}/${{ env.FUNCTION_NAME }}:latest
              annotations:
                com.openfaas.scale.min: "1"
              labels:
                "com.openfaas.profile": "devsecops-profile"
          EOF
          
          faas-cli deploy -f ./${{ env.FUNCTION_NAME }}.yml

注意: 上述Jib部分为了清晰起见有所简化。在真实场景中,我们会在一个步骤中编译Go二进制文件,然后在jib build命令中通过--layers参数精确地将编译好的二进制文件和生成的waf-rules.conf放置到镜像的正确位置 /home/app/

验证效果

部署完成后,我们可以通过curl来测试WAF是否生效。
合规请求:

# curl "http://<openfaas-gw>/function/user-profile?id=user-id-12345678&fields=name,email"
Fetching profile for user ID: user-id-12345678 with fields: name,email

该请求会成功,因为参数格式符合@waf-rule的定义。

违规请求(注入SQLi payload):

# curl "http://<openfaas-gw>/function/user-profile?id=1'%20OR%20'1'='1"
# ... HTML for a 403 Forbidden page or empty reply ...

这个请求会被Coraza中间件拦截,因为它不匹配^[a-zA-Z0-9_-]{8,36}$这个正则表达式,根本不会到达handler.go中的业务逻辑。

方案的局限性与未来展望

这套方案成功地将函数级的安全防护自动化,并融入了GitOps流程,但它并非没有缺点。

首先,规则生成的健壮性依赖于静态代码分析。目前的简单文本扫描很脆弱,容易因代码格式变化而失效。切换到基于AST的分析会更可靠,但也会增加实现的复杂度。

其次,性能开销是必须考虑的。在每个函数Pod内运行一个WAF实例会引入额外的CPU和内存消耗。尽管Coraza性能不错,但在极端高并发场景下,这种开销的累积效应需要被精确评估和监控。

再者,此方案主要防御的是已知的、基于输入验证的攻击(OWASP Top 10中的注入类)。对于更复杂的业务逻辑漏洞或0-day攻击,它的能力有限。

未来的迭代方向可以集中在以下几点:

  1. 动态规则更新:集成威胁情报源,通过一个控制平面的服务,动态地向正在运行的函数Pod推送新的WAF规则,以应对突发威胁,而无需重新部署。
  2. 扩展分析维度:除了参数格式,规则生成器可以被扩展,以分析代码中的数据库查询、文件系统访问、外部API调用等行为,生成更全面的HIDS(主机入侵检测系统)类规则。
  3. eBPF的探索:考虑使用eBPF技术在内核层面进行请求过滤和行为监控。这可以提供比应用层中间件更低的性能开销和更深度的可见性,但其实现和维护的门槛也更高。
  4. 与DAST集成:在CI流水线中集成动态应用安全测试(DAST)工具,自动对部署后的函数进行攻击测试,并将发现的漏洞反馈到规则生成器,形成一个持续改进的安全闭环。

  目录