ZAP baseline 进流水线:登录态、规则和边界¶
前四天把代码扫描、规则、依赖、镜像都过了一遍,今天补最后一块——跑起来的应用。
DAST 是最容易被跳过的一环,原因不复杂:SAST 指到源码目录就完事,DAST 得先有一个能跑的环境、一套还活着的登录态、一个不会把测试环境打挂的范围,最后还得有人看得懂报告。我这边第一次接的时候,扫完报告里全是登录页的 Cookie 告警,业务 URL 一个没有,差点就把它当成「应用挺安全」。
一、baseline 和 full 不是同一个东西¶
先把这个分清楚,不然后面配置全错。
| Baseline | Full / Active | |
|---|---|---|
| 做什么 | 爬一遍 + 被动扫描,只看响应头和响应体 | 主动发攻击 payload |
| 速度 | 分钟级 | 几十分钟到几小时 |
| 误报 | 低 | 高 |
| 跑在哪 | 每次部署到 staging 后,甚至 PR | nightly,专用测试环境 |
| 能不能碰生产 | 也不建议,但风险小得多 | 绝对不行 |
baseline 是 passive only,不主动打 payload,所以它能发现的是缺安全响应头、Cookie 属性、报错泄露这类「看得见」的问题。注入、越权这些得靠 full scan,而且必须是我们自己拥有、可以随便造脏数据的环境。
如果服务有 OpenAPI 文档,还有第三条路 zap-api-scan.py,直接按 spec 生成请求形状,比让爬虫猜接口靠谱得多。这条我还没试,先记着。
二、第一道坎:扫到的是登录页¶
第一次 baseline 报告出来,告警集中在「Cookie No HttpOnly Flag」「X-Frame-Options Header Not Set」这几条,全是登录页的。Sites tree 里除了 /login 和静态资源什么都没有——爬虫根本没进去。
DAST 自动化的难点从来不是扫描器,是登录态。按鉴权方式选:
| 鉴权 | 做法 |
|---|---|
| Bearer / JWT | 用 ZAP_AUTH_HEADER_VALUE 给所有请求加 Authorization 头,最快 |
| Session Cookie | 建 context,配表单认证,导出 .context 供 CI 复用 |
| SSO / 验证码 / 前端加密 | 别让扫描器自己登,手工拿有效会话注入,或导入 HAR |
| SPA | 用 Automation Framework 的 browser-based auth + loggedInRegex |
我们这边接口是 Bearer,先用环境变量这条最短的路:
表单登录那套得在 Automation Framework 的 plan 里定义 context:认证方法、登录页 URL、loggedInRegex / loggedOutRegex、用户。官方和 WSO2 的文档都强调,配完要先确认登录后的页面确实出现在 Sites tree 里,再去看告警——否则结果不过是「未认证状态下的结果」,跟业务安全没关系。
顺带一提,这跟之前 AppScan 录不上登录是同一类问题,解法思路也差不多:绕开登录动作,直接给扫描器一个有效会话。
三、rules.tsv 才是真正的门禁¶
ZAP 的 baseline 脚本能直接生成一份规则配置模板:
docker run --rm -v "$(pwd):/zap/wrk/:rw" -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://staging.internal:8080 \
-g rules.tsv -r baseline-report.html
生成出来的 rules.tsv 每行一条规则,三个状态:
# 把 WARN 改成 IGNORE 忽略,改成 FAIL 让命中时失败
10010 WARN (Cookie No HttpOnly Flag)
10011 WARN (Cookie Without Secure Flag)
10012 WARN (Password Autocomplete in Browser)
10020 WARN (X-Frame-Options Header Scanner)
10021 WARN (X-Content-Type-Options Header Missing)
10202 WARN (Absence of Anti-CSRF Tokens)
90022 WARN (Application Error Disclosure)
第一次全留 WARN,先跑几轮看报告,再挑跟业务相关的规则提升为 FAIL。别一上来全设 FAIL——那和前面几天反复说的「首次全量即阻断」是同一个错误,结局一样是被人加个 when: never。
几个官方支持的细节,挺省事:
# 每行 tab 后面可以追加自己的说明
10038 WARN (Content Security Policy Header Not Set) 用 nonce,规则只认 hash,人工复核过
# 按 URL 正则把某些路径排除在特定规则之外
10012 OUTOFSCOPE https://staging.internal/test.html
* OUTOFSCOPE .*\.js
# 已知在修的问题放 progress file,报告里标成 IN-PROGRESS,只看新增
-c rules.tsv 用回改好的配置:
docker run --rm -v "$(pwd):/zap/wrk/:rw" -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://staging.internal:8080 \
-c rules.tsv -r baseline-report.html
还有一类误报要用 -z 透传 ZAP 参数,官方文档给的例子是忽略某些表单的 anti-CSRF 检查:
search 和 login 这两个表单本来就没有 CSRF token,规则却会报。这种「规则没错、业务也不需要」的情况,与其加一堆抑制,不如精确到表单 ID。
四、边界:范围、限速、脏数据¶
这一段比配置重要,因为出事都出在这。
- 只扫授权域名。第三方(支付、地图、埋点、CDN)全部排除。主动扫描会攻击范围内的所有东西,一个写宽的 include 正则可能把攻击打到没授权的系统上。
- 排除 logout。爬虫点到登出链接,会话当场就没了,后半段扫描全是 302,报告看起来像「应用很干净」。
- 排除会发真实邮件、短信、支付、消息的端点。这不是误报问题,是事故。
- 专用测试账号 + 专用环境。active scan 会造脏数据,扫描完要清理,报告里注明账号和时间段便于复现。
- 别扫生产。baseline 也不扫。生产上跑被动爬取同样会触发限流、产生脏数据、惊动告警。
范围划好之后才谈得上把它做成门禁。
五、能直接改的 job¶
zap-baseline:
stage: dast
needs: [deploy-staging]
image: docker:stable
services: [docker:dind]
variables:
TARGET: "http://staging.internal:8080"
script:
- >
docker run --rm
-v "$(pwd):/zap/wrk/:rw"
-e ZAP_AUTH_HEADER_VALUE="Bearer $STAGING_TOKEN"
ghcr.io/zaproxy/zaproxy:stable
zap-baseline.py -t "$TARGET" -c .zap/rules.tsv -r zap-report.html
artifacts:
when: always
paths: [zap-report.html]
only: [merge_requests, main]
GitHub Actions 那边有官方的 zaproxy/action-baseline,传 target、rules_file_name、cmd_options 即可,nightly 换 action-full-scan。额外参数(cmd_options 里传什么、限速怎么设)跟镜像版本关系很大,写之前对着当前版本的 -h 核一遍,别照搬别人的字符串。
跑全量主动扫描时,用 Automation Framework 的 plan 把步骤写死:
# zap.yaml
env:
contexts:
- name: staging
urls: ["${TARGET_URL}"]
excludePaths: [".*/logout.*"]
parameters:
failOnError: true
progressToStdout: true
jobs:
- type: spider
parameters: { maxDuration: 5 }
- type: passiveScan-wait
parameters: { maxDuration: 10 }
- type: activeScan
parameters: { maxScanDurationInMins: 60 }
- type: report
parameters:
template: traditional-html
reportDir: /zap/wrk
reportFile: zap-report
- type: exitStatus
parameters: { warnLevel: Medium, errorLevel: High }
exitStatus 这个 job 才是把告警等级翻译成退出码的地方,少了它,plan 跑完也不会让流水线红。maxDuration / maxScanDurationInMins 必须设,否则 nightly 能一直挂到天亮。运行方式是 zap.sh -cmd -autorun zap.yaml,${TARGET_URL} 从环境变量读,凭据放 CI secret。
六、这五天摆在哪¶
汇到 GitHub Security 页的那些 SARIF 记得给各自独立的 category,前天那篇写过,这里不再重复。
七、验收:别只看「扫描完成」¶
1. Sites tree / URL 列表里出现登录后的业务接口,而不是只有 /login
2. 抽查 10 条业务请求,状态码 200 且响应体是真实数据,不是登录页 HTML
3. 把一条规则从 WARN 改成 FAIL,故意构造命中,确认 job 红
4. 把 logout 加进排除规则再跑一次,对比后半段 302/401 占比
5. 扫描结束后检查测试账号产生的数据是否已清理
第 1 条和第 2 条是一票否决项。会话没带进去,后面所有结论都不成立——我第一次那版报告就是这么废掉的。
坑位表¶
| 现象 | 根因 | 处理 |
|---|---|---|
| 结果只有登录页告警 | 未认证,爬虫没进去 | 注入有效会话 / context 认证 |
| 后半段全 302 | 爬虫点到 logout,会话失效 | excludePaths 排除登出 |
| 扫到第三方域名 | 范围写宽了 | 收紧 include,排除 CDN / 埋点 / 支付 |
| 第一次就全 FAIL 然后红一片 | 规则没裁剪 | 先全 WARN,跑几轮再提升 |
| plan 跑完不拦流水线 | 缺 exitStatus job |
补上并设 warnLevel / errorLevel |
| active scan 挂一整夜 | 没设时长上限 | maxDuration / maxScanDurationInMins |
| 生产被告警刷屏 | 扫了不该扫的环境 | 只扫自有 staging,专用账号 |
这轮先到这¶
五篇下来,工具其实都能装上,卡住的地方几乎都不是工具能力,而是「结果没人认领」「首次全量就阻断」「状态没回写」这类接缝。10 月那篇写的是同一件事的骨架,这回逐块拆开对了一遍参数。
还有另一半没碰:这些工具看的都是「代码里有没有已知坏味道」,看不出人一起涌进来时连接够不够用、大表上的 SQL 还跑不跑得动。另开了一篇记压测场景和生成代码的验收清单。
没做完的还挺多:ZAP API scan 没试,CodeQL 多语言并行没验,可达性判断要等运行时数据。等月底攒够误报率的数字再回来调阈值。