在CI/CD流水线中集成动态应用安全测试(DAST)一直是个棘手的难题。多数DAST扫描器在面对需要复杂登录流程、依赖JavaScript动态渲染的现代单页应用(SPA)时,表现往往不尽人意。扫描结果通常只覆盖了应用未经身份验证的公共部分,漏掉了潜藏在认证后核心功能中的高风险漏洞。手动配置和运行扫描不仅效率低下,而且与DevOps的自动化理念背道而驰。
我们团队遇到的正是这个问题。我们需要一种可靠、可重复、且能完全自动化的方式,在每次代码合并前对应用进行深入的、经过身份验证的DAST扫描。这意味着需要为每次测试动态创建一套完整的、隔离的运行环境,并在测试结束后彻底销毁,以避免资源浪费和环境污染。
这个问题的解决方案需要将基础设施即代码(IaC)、Web服务和浏览器自动化三者结合起来。我们的构想是:使用Pulumi来定义和管理整个临时测试环境,包括运行目标应用的容器、DAST扫描工具的容器以及它们之间的网络。目标应用本身用Go-Fiber构建,它轻量、高性能,非常适合作为这个场景中的被测对象。最关键的一环是,我们用Playwright来模拟真实用户的登录和操作流程,强制所有浏览器流量通过DAST工具的代理,从而“教会”扫描器如何访问那些需要认证的页面和API。
第一步:构建被测目标 (The Target Application)
任何测试都需要一个目标。为了验证这套体系,我们先用Go-Fiber构建一个简单的Web应用。这个应用包含一个公共端点、一个登录端点和一个受保护的端点。认证机制采用JWT。在真实项目中,这个应用会复杂得多,但这里的核心是验证认证和会话管理能否被我们的测试框架正确处理。
// main.go
package main
import (
"log"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
jwtware "github.com/gofiber/jwt/v3"
"github.com/golang-jwt/jwt/v4"
)
// 在生产环境中,这应该从安全的位置加载,例如环境变量或密钥管理服务
const jwtSecret = "a_very_secret_key_that_is_not_so_secret"
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
}
// setupRoutes 配置应用的所有路由
func setupRoutes(app *fiber.App) {
// 公共路由,无需认证
app.Get("/api/public", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "This is a public endpoint."})
})
// 登录路由,用于获取JWT
app.Post("/api/login", func(c *fiber.Ctx) error {
req := new(LoginRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot parse JSON"})
}
// 在真实项目中,这里会验证数据库中的用户名和密码
if req.Username != "admin" || req.Password != "password123" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
// 创建JWT Claims
claims := jwt.MapClaims{
"username": req.Username,
"role": "admin",
"exp": time.Now().Add(time.Hour * 72).Unix(),
}
// 创建Token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 签名并获取完整的编码后字符串
t, err := token.SignedString([]byte(jwtSecret))
if err != nil {
log.Printf("Error signing token: %v", err)
return c.SendStatus(fiber.StatusInternalServerError)
}
return c.JSON(LoginResponse{Token: t})
})
// 创建一个API分组用于受保护的路由
api := app.Group("/api/protected")
// 配置JWT中间件
api.Use(jwtware.New(jwtware.Config{
SigningKey: []byte(jwtSecret),
ErrorHandler: func(c *fiber.Ctx, err error) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Unauthorized",
"details": err.Error(),
})
},
}))
// 受保护的路由
api.Get("/data", func(c *fiber.Ctx) error {
// 中间件已经验证了token的有效性
// 我们可以从token中提取用户信息
user := c.Locals("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
username := claims["username"].(string)
return c.JSON(fiber.Map{
"message": "Welcome " + username,
"data": "This is highly sensitive data.",
})
})
}
func main() {
app := fiber.New()
app.Use(logger.New())
setupRoutes(app)
// 监听在容器内部的8080端口
log.Fatal(app.Listen(":8080"))
}
配套的Dockerfile
也必须简洁高效,以保证CI/CD流水线中的构建速度。
# Dockerfile
# Stage 1: Build the application
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 保证静态编译,生成的可执行文件不依赖外部C库
# -ldflags="-w -s" 减小最终二进制文件的大小
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server .
# Stage 2: Create the final, minimal image
FROM alpine:latest
WORKDIR /root/
# 从builder阶段拷贝编译好的二进制文件
COPY /app/server .
# 暴露应用监听的端口
EXPOSE 8080
# 容器启动时运行的命令
CMD ["./server"]
这个应用现在是自包含的,可以被容器化并部署到任何地方。这是我们进行后续所有工作的基础。
第二步:用Pulumi定义基础设施 (Infrastructure as Code)
接下来是定义整个测试环境。我们的目标是创建一个隔离的、包含Go-Fiber应用和OWASP ZAP扫描器的环境。使用Pulumi和Go SDK,我们可以用同一种语言来管理应用和基础设施,这在维护上是个巨大的优势。
我们将使用Pulumi的Docker Provider,它允许我们在本地Docker引擎或远程Docker主机上编排容器。这对于在CI runner中执行测试来说非常理想。
graph TD subgraph "CI Runner / Local Machine" subgraph "Pulumi-Managed Docker Network (isolated-dast-net)" A[Playwright Script] --> B{OWASP ZAP Proxy}; B --> C[Go-Fiber App]; end end style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#9f9,stroke:#333,stroke-width:2px
这个Pulumi程序会完成以下工作:
- 创建一个专用的Docker网络,确保测试环境与主机或其他容器隔离。
- 构建并启动我们的Go-Fiber应用容器,并将其连接到上述网络。
- 从Docker Hub拉取OWASP ZAP的官方镜像,启动容器,将其也连接到同一网络,并暴露其代理端口和API端口。
// pulumi/main.go
package main
import (
"github.com/pulumi/pulumi-docker/sdk/v4/go/docker"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
// --- 配置 ---
conf := config.New(ctx, "")
appName := conf.Get("appName")
if appName == "" {
appName = "go-fiber-dast-app"
}
zapAppName := "owasp-zap"
// --- 网络 ---
// 创建一个隔离的Docker网络
net, err := docker.NewNetwork(ctx, "dast-network", &docker.NetworkArgs{
Name: pulumi.String("dast-isolated-net"),
})
if err != nil {
return err
}
// --- Go-Fiber 应用 ---
// 构建并推送应用镜像,这里假设Docker上下文在项目根目录
// 在真实的CI/CD中,你可能会使用预先构建好的镜像
appImage, err := docker.NewImage(ctx, appName+"-image", &docker.ImageArgs{
Build: &docker.DockerBuildArgs{
Context: pulumi.String("../"), // 指向包含Dockerfile的项目根目录
},
ImageName: pulumi.Sprintf("%s:latest", appName),
SkipPush: pulumi.Bool(true), // 我们只在本地运行,不需要推送到仓库
})
if err != nil {
return err
}
// 创建并运行Go-Fiber应用容器
appContainer, err := docker.NewContainer(ctx, appName+"-container", &docker.ContainerArgs{
Name: pulumi.String(appName),
Image: appImage.BaseImageName,
NetworksAdvanced: docker.ContainerNetworksAdvancedArray{
&docker.ContainerNetworksAdvancedArgs{
Name: net.Name,
Aliases: pulumi.StringArray{
pulumi.String("app"), // 容器在网络中的别名
},
},
},
// 移除容器时,关联的匿名卷也会被删除
RemoveVolumes: pulumi.Bool(true),
})
if err != nil {
return err
}
// --- OWASP ZAP ---
// 创建并运行ZAP容器
// 我们使用 'bare' 镜像,因为它更小且适合自动化
// '-daemon' 模式启动ZAP
// '-host 0.0.0.0' 允许从网络内任何IP访问
// '-port 8090' 是代理端口
// '-api.key ...' 设置API密钥,这是安全实践
zapApiKey := conf.RequireSecret("zapApiKey") // 从Pulumi配置中读取密钥
zapContainer, err := docker.NewContainer(ctx, zapAppName+"-container", &docker.ContainerArgs{
Name: pulumi.String(zapAppName),
Image: pulumi.String("owasp/zap2docker-bare:latest"),
Command: pulumi.StringArray{
pulumi.String("zap.sh"),
pulumi.String("-daemon"),
pulumi.String("-host"),
pulumi.String("0.0.0.0"),
pulumi.String("-port"),
pulumi.String("8090"), // ZAP代理端口
pulumi.String("-config"),
pulumi.String("api.key=" + zapApiKey),
pulumi.String("-config"),
pulumi.String("api.disablekey=false"),
},
NetworksAdvanced: docker.ContainerNetworksAdvancedArray{
&docker.ContainerNetworksAdvancedArgs{
Name: net.Name,
Aliases: pulumi.StringArray{
pulumi.String("zap"),
},
},
},
Ports: docker.ContainerPortArray{
&docker.ContainerPortArgs{ // ZAP API端口
Internal: pulumi.Int(8080),
External: pulumi.Int(8081),
},
},
RemoveVolumes: pulumi.Bool(true),
})
if err != nil {
return err
}
// --- 输出 ---
// 输出容器名称,以便脚本可以引用
ctx.Export("appContainerName", appContainer.Name)
ctx.Export("zapContainerName", zapContainer.Name)
ctx.Export("networkName", net.Name)
// 输出ZAP API的访问地址和代理地址,供Playwright脚本使用
ctx.Export("zapApiUrl", pulumi.String("http://localhost:8081"))
ctx.Export("zapProxyUrl", pulumi.String("http://localhost:8090"))
return nil
})
}
在运行这个Pulumi程序前,需要设置ZAP的API密钥:pulumi config set --secret zapApiKey 'YourSecretApiKeyHere'
现在,只需执行 pulumi up
,一个包含Go-Fiber应用和ZAP代理的完整、隔离的环境就会被创建出来。应用和ZAP容器可以通过它们在Docker网络中的别名(app
和zap
)相互通信。pulumi destroy
则会一键清理所有资源。这为自动化奠定了坚实的基础。
第三步:Playwright扮演“领航员” (The Playwright Navigator)
这是整个方案的核心。Playwright脚本的任务不是执行功能测试,而是作为ZAP的智能“爬虫”或“领航员”。它会执行一个真实用户会做的所有事情:访问登录页面、提交凭证、获取并使用JWT、然后访问受保护的资源。
这里的关键技巧是,在启动浏览器时,将其配置为使用ZAP容器暴露的HTTP代理。
// scripts/authenticated-scan.ts
import { chromium, Browser, Page } from 'playwright';
import { ZapClient, ReportType } from 'zaproxy';
import * as fs from 'fs';
import * as path from 'path';
// 从环境变量或配置文件中获取这些值
// Pulumi的输出可以被导出为JSON,然后被这个脚本读取
const ZAP_PROXY = process.env.ZAP_PROXY_URL || 'http://127.0.0.1:8090';
const ZAP_API_URL = process.env.ZAP_API_URL || 'http://127.0.0.1:8081';
const ZAP_API_KEY = process.env.ZAP_API_KEY; // 必须设置
const APP_BASE_URL = 'http://app:8080'; // 使用Docker网络内的别名
if (!ZAP_API_KEY) {
console.error('Error: ZAP_API_KEY environment variable is not set.');
process.exit(1);
}
// 初始化ZAP API客户端
const zap = new ZapClient({
proxy: ZAP_API_URL,
apiKey: ZAP_API_KEY,
});
async function runAuthenticatedCrawl() {
console.log('Launching browser with ZAP proxy configured...');
const browser = await chromium.launch({
// 关键配置:将所有流量导向ZAP代理
proxy: {
server: ZAP_PROXY,
},
// 在CI环境中,需要忽略HTTPS错误,因为ZAP会使用自己的根证书
args: ['--ignore-certificate-errors'],
});
const context = await browser.newContext();
const page = await context.newPage();
try {
console.log('Navigating to the application...');
// 这里的URL是容器内的,Playwright通过代理访问
await page.goto(`${APP_BASE_URL}/api/public`, { waitUntil: 'networkidle' });
console.log('Performing login...');
const response = await page.request.post(`${APP_BASE_URL}/api/login`, {
data: {
username: 'admin',
password: 'password123'
}
});
if (!response.ok()) {
throw new Error(`Login failed with status: ${response.status()}`);
}
const loginData = await response.json();
const token = loginData.token;
if (!token) {
throw new Error('JWT token not found in login response.');
}
console.log('Login successful. Token received.');
console.log('Accessing protected endpoint with JWT...');
// 使用获取到的token访问受保护的API
const protectedResponse = await page.request.get(`${APP_BASE_URL}/api/protected/data`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const protectedData = await protectedResponse.json();
console.log('Protected data received:', protectedData.message);
// 至此,所有请求和响应都已被ZAP捕获。
// ZAP的站点树现在包含了公共和私有端点。
} catch (error) {
console.error('An error occurred during the crawl:', error);
} finally {
await browser.close();
console.log('Browser closed.');
}
}
async function performActiveScan() {
// 等待爬虫完成
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('Starting ZAP active scan...');
const targetUrl = `${APP_BASE_URL}/api`;
const scanId = await zap.ascan.scan({ url: targetUrl });
console.log(`Active scan started with ID: ${scanId}`);
// 轮询扫描状态
let status = 0;
while (status < 100) {
await new Promise(resolve => setTimeout(resolve, 5000));
status = parseInt((await zap.ascan.status({ scanId })).status, 10);
console.log(`Scan progress: ${status}%`);
}
console.log('Scan completed.');
}
async function generateReport() {
console.log('Generating DAST report...');
const report = await zap.core.generateReport({
title: 'DAST Scan Report for Go-Fiber App',
template: 'traditional-html', // 可以选择 'xml', 'json', 'md' 等
reportdir: '/zap/wrk/', // ZAP容器内的可写目录
reportfilename: 'dast-report.html',
});
console.log('Report generated successfully inside ZAP container.');
// 在真实CI场景中,下一步是使用 docker cp 命令将报告从ZAP容器中复制出来
// 例如:docker cp owasp-zap:/zap/wrk/dast-report.html .
}
async function main() {
await runAuthenticatedCrawl();
await performActiveScan();
await generateReport();
}
main().catch(err => {
console.error("Script failed:", err);
process.exit(1);
});
这个脚本清晰地分为了三步:
-
runAuthenticatedCrawl
: 启动一个配置了ZAP代理的浏览器,执行登录,然后用获取的token访问受保护资源。所有这些操作都被ZAP忠实地记录下来。 -
performActiveScan
: 在Playwright完成它的“领航”任务后,我们通过ZAP的API,对已经发现的URL(包括那些需要认证的)发起主动扫描。 -
generateReport
: 扫描结束后,生成一份HTML格式的报告。
整合与自动化
现在,我们拥有了所有的组件,可以将它们串联起来形成一个完整的自动化流程。一个简单的bash脚本可以作为这个流程的粘合剂。
#!/bin/bash
set -eo pipefail
# 1. 设置环境变量
# 在CI/CD中,这应该由系统的secret management提供
export ZAP_API_KEY="YourSecretApiKeyHere"
export PULUMI_CONFIG_PASSPHRASE="" # 如果你的Pulumi stack没有加密,则为空
# 2. 启动基础设施
echo "--- Provisioning DAST environment with Pulumi ---"
cd pulumi
pulumi up --yes
# 获取Pulumi的输出并设置为环境变量
export ZAP_PROXY_URL=$(pulumi stack output zapProxyUrl)
export ZAP_API_URL=$(pulumi stack output zapApiUrl)
cd ..
# 确保脚本退出时清理环境
function cleanup {
echo "--- Destroying DAST environment ---"
cd pulumi
pulumi destroy --yes
cd ..
}
trap cleanup EXIT
# 3. 安装Playwright脚本依赖
echo "--- Installing Playwright script dependencies ---"
cd scripts
npm install
cd ..
# 4. 运行Playwright引导的DAST扫描
echo "--- Running authenticated DAST scan ---"
# 我们需要在主机上运行Playwright,因为它需要控制浏览器
# 但脚本内的请求URL是针对Docker网络内部的
# ZAP代理和API则通过端口映射暴露在localhost上
cd scripts
# Playwright 脚本需要 `ts-node` 来直接运行 TypeScript 文件
npx ts-node authenticated-scan.ts
cd ..
# 5. 从ZAP容器中拷贝报告
echo "--- Copying DAST report ---"
mkdir -p reports
docker cp owasp-zap:/zap/wrk/dast-report.html ./reports/dast-report-$(date +%s).html
echo "Report saved to ./reports/ directory"
# 清理工作将由 trap 自动执行
echo "--- DAST Scan Finished Successfully ---"
这个脚本是整个流程的指挥中心。在GitHub Actions或Jenkins中,只需调用这个脚本,就能完成从环境创建、认证扫描、报告生成到环境销毁的全过程。
当前方案的局限性与未来展望
我们构建的这套系统解决了在CI/CD中进行自动化、认证后DAST扫描的核心痛点。然而,任何工程方案都有其适用边界和需要权衡的地方。
首先,这个流程的执行时间可能较长。一次完整的Pulumi部署、Playwright爬取和ZAP主动扫描,可能会耗费十几分钟甚至更久。对于要求快速反馈的PR检查来说,这可能太慢了。一个实际的改进是,不将它作为阻塞合并的检查,而是作为合并到主干或开发分支后的一个异步任务,或者作为每日构建的一部分。
其次,ZAP的配置目前还比较基础。在复杂的应用中,可能需要为ZAP定义更精细的“上下文(Context)”,配置特定的认证脚本和会话处理规则,以应对CSRF令牌、多步骤登录等复杂情况。虽然Playwright处理了最外层的交互,但ZAP内部对会话的理解越深入,扫描效果越好。
未来的迭代方向很明确。第一步是将此流程完全集成到CI/CD平台中,利用平台的制品库(Artifacts)来存储和展示扫描报告,并根据漏洞的严重性决定是否让流水线失败。第二步是引入漏洞管理平台,将ZAP的输出推送到该平台进行去重、分配和跟踪,而不是每次都处理原始报告。最后,可以探索将Playwright的角色进一步扩展,不仅仅是登录和爬取,还可以是有针对性地触发应用中特定的、高风险的业务逻辑,引导ZAP对这些关键路径进行更集中的火力测试。