跳转至

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,先用环境变量这条最短的路:

export ZAP_AUTH_HEADER_VALUE="Bearer $STAGING_TOKEN"

表单登录那套得在 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 检查:

-z "-config rules.csrf.ignorelist=search,login"

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。

六、这五天摆在哪

flowchart LR A[PR] --> B[SonarQube 门禁] A --> C[Semgrep 增量] A --> D[Dependency-Check] E[nightly] --> F[CodeQL 全量] G[镜像构建] --> H[Trivy] H --> I[部署 staging] I --> J[ZAP baseline] J --> K[ZAP full nightly]

汇到 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 多语言并行没验,可达性判断要等运行时数据。等月底攒够误报率的数字再回来调阈值。

相关阅读