团队转向Serverless架构后,一个棘手的问题很快浮出水面:安全防护。部署在API网关层的通用WAF策略,在面对成百上千、功能各异的FaaS函数时显得力不从心。过于宽泛的规则集导致了大量误报,而为每个函数手动维护一套精准的规则又几乎是不可能完成的任务。这种安全与运维之间的矛盾,促使我们必须寻找一种将安全策略“左移”到开发流程中,并且能够与函数生命周期紧密绑定的自动化方案。
我们的初步构想是:安全规则应该像代码一样,与函数逻辑一同被版本控制。当一个函数被部署时,一套为其量身定制的、最小化的WAF规则集也应该被“嵌入”到其运行环境中。这意味着CI/CD流水线不仅要负责构建和部署业务代码,还必须承担起理解代码、生成安全策略并打包交付的职责。
技术选型与架构决策
要实现这个目标,整个工具链的选择至关重要。
运行时与框架 (Go): 我们选择Go作为函数的开发语言。它的静态编译特性可以产出无依赖的单一二进制文件,这对于构建轻量级容器镜像至关重要。同时,Go强大的标准库和并发性能非常适合高吞吐的Serverless场景。我们不引入重型框架,直接基于
net/http
构建,以保持最大的控制力和透明度。Serverless平台 (OpenFaaS): 相比于其他FaaS平台,OpenFaaS的开放性和灵活性是决定性因素。它允许我们使用标准的Docker/OCI镜像作为函数,并支持自定义模板。这为我们注入自定义的WAF中间件提供了可能性。
CI/CD (GitHub Actions): 作为代码托管平台,使用GitHub Actions可以实现无缝的GitOps流程。其丰富的生态和强大的自定义能力,使我们能够轻松地在流水线中插入代码分析和规则生成的步骤。
容器构建 (Jib): 在CI环境中,避免使用Docker Daemon是一个常见的最佳实践。Jib可以直接从Java/Go等源码构建优化的OCI镜像,无需
docker build
,完美契合在GitHub Actions的Runner中运行。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攻击,它的能力有限。
未来的迭代方向可以集中在以下几点:
- 动态规则更新:集成威胁情报源,通过一个控制平面的服务,动态地向正在运行的函数Pod推送新的WAF规则,以应对突发威胁,而无需重新部署。
- 扩展分析维度:除了参数格式,规则生成器可以被扩展,以分析代码中的数据库查询、文件系统访问、外部API调用等行为,生成更全面的HIDS(主机入侵检测系统)类规则。
- eBPF的探索:考虑使用eBPF技术在内核层面进行请求过滤和行为监控。这可以提供比应用层中间件更低的性能开销和更深度的可见性,但其实现和维护的门槛也更高。
- 与DAST集成:在CI流水线中集成动态应用安全测试(DAST)工具,自动对部署后的函数进行攻击测试,并将发现的漏洞反馈到规则生成器,形成一个持续改进的安全闭环。