【免费下载链接】plannotator
Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.
本文基于 Plannotator 仓库 scripts/dast/README.md 及其配套实现展开。Plannotator 是一个用于可视化标注与评审编码 Agent 计划(plan)和代码 diff 的开源工具,本文讲解其安全工程中至关重要的一环:在完全隔离的 Docker 内网中,对一次性 annotate 会话运行 OWASP ZAP 被动基线扫描(passive baseline scan)的完整方案。读完本文,你将掌握该工作流的隔离网络设计、路由守卫与扫描钩子(hook)的实现、ZAP 大响应缓存调优、报告证据校验(fail-closed)机制,以及如何手动触发该扫描并正确解读其产物。
工作流总览:为什么 DAST 必须“隔离”且“一次性”
Plannotator 的 ZAP DAST 工作流对一次性(disposable)annotate 会话运行被动基线扫描。所谓“一次性”,是指整个被测目标由工作流动态创建、仅存在于 GitHub Actions runner 内,扫描结束即销毁。所谓“隔离”,体现在两个层面:
- 网络隔离:应用与 ZAP 两个容器都挂在通过
docker network create --internal创建的 Docker 内网中,该网络无外部出口,因此应用和扫描器都无法触达生产环境或任何外部服务; - 凭据隔离:仓库、云服务、npm、模型提供商(model-provider)以及用户凭据一律不传入两个容器。
正如 target.ts 中实现的强制校验所示,被测目标入口只有在检测到PLANNOTATOR_DAST_ISOLATED=1时才允许启动,否则直接抛出异常拒绝运行。这个环境变量是一道确认开关(acknowledgement):它只应在隔离的、一次性的环境中设置,绝不能在隔离环境之外设置;该工作流是受支持的执行路径,而target.ts本身不是生产服务器。
工作流刻意采用定时/手动触发而非随每个 PR 运行,原因在于被动基线扫描的定位是周期性的安全健康检查,而不是每次提交的快速回归门禁。手动触发命令为:
gh workflow run dast.yml --repo backnotprop/plannotator工作流定义位于 .github/workflows/dast.yml:通过schedule中的cron: "47 16 * * 0"(每周日 16:47 UTC)自动运行,同时开放workflow_dispatch支持手动触发;权限被压缩为contents: read,并使用concurrency保证同一 ref 下不会并发重复扫描。
运行环境与运行时固定(pinning)
为保证扫描结果的可复现性与供应链安全,工作流对两个运行时镜像做了摘要级固定(digest pinning),而非仅固定 tag。见 dast.yml:
| 运行时 | 镜像 | 版本 | 用途 |
|---|---|---|---|
| Bun 目标 | oven/bun:1.3.11-slim@sha256:4782...42d9 | 1.3.11 | 运行一次性 annotate 目标服务器 |
| ZAP | ghcr.io/zaproxy/zaproxy:stable@sha256:781a...81ef | 2.17.0 | 被动基线扫描器 |
工作流在启动前执行一系列“Pull and verify pinned runtime images”校验(dast.yml):
- 拉取镜像(失败重试 3 次,每次间隔递增 5 秒);
- 通过
docker image inspect --format '{{index .RepoDigests 0}}'核对实际 RepoDigest 与固定摘要一致; - 在
--network none下运行镜像验证版本:bun --version必须等于 1.3.11,Bun 用户 UID 必须为 1000; - 验证 ZAP 镜像的
Config.User为zap,且zap.sh -version输出等于 2.17.0; - 将验证结果写入
$GITHUB_STEP_SUMMARY。
这些校验的意义在于:摘要固定防止 tag 漂移,版本与用户校验则保证容器内运行身份、工具链版本与后续步骤的假设一致(例如后续以--user 1000:1000运行)。
隔离网络与容器加固
创建 internal 网络并验证无外联
工作流创建名为plannotator-dast-${{ github.run_id }}-${{ github.run_attempt }}的 internal 网络(dast.yml),并通过docker network inspect --format '{{.Internal}}'断言Internal=true。随后用一个探针容器实际验证隔离性:在网络上向保留地址http://192.0.2.1发起 2 秒超时请求,若请求成功(说明有外联能力)则直接让步骤失败。
目标容器加固
被测目标以如下加固参数启动(dast.yml):
--user 1000:1000 --read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 2 --tmpfs /tmp:rw,nosuid,nodev,size=128m并以只读方式挂载$GITHUB_WORKSPACE:/workspace:ro,设置HOME=/tmp/home、PLANNOTATOR_DATA_DIR=/tmp/plannotator,同时注入PLANNOTATOR_DAST_ISOLATED=1,最后执行bun run scripts/dast/target.ts。容器的数据目录指向 tmpfs,叠加只读根文件系统,进一步保证了“零落盘、零凭据”。
目标就绪健康检查
启动后工作流以最多 60 次、每秒一次的轮询执行健康检查(dast.yml),验证:
- 扫描端口
19432上/、/api/plan、/api/ai/capabilities均返回 200,/api/definitely-missing、/robots.txt返回 404; - 哨兵端口
19433上/返回 200; - 对
19432/api/approve发起 POST 必须返回 405(证明状态变更方法被守卫拦截); - 上游真实应用端口
19434在内网中不可达(fetch必须失败),防止扫描器绕过守卫直连上游。
任一检查失败且容器仍在运行时继续重试;容器若退出则直接输出docker logs并失败。
一次性 annotate 目标:target.ts 的结构与语义
启动闸门与环境“纵深防御”默认值
scripts/dast/target.ts 是扫描目标的实现,结构非常清晰:
- 闸门:缺少
PLANNOTATOR_DAST_ISOLATED=1立即抛错(L9-L14); - 纵深防御环境变量(L20-L35):这些是针对测试目标的加固默认值,真正的出站边界是 Docker 内网,应用层配置则是第二道防线——即使某个功能被意外触达,也无法调用 Agent、分享内容、持久化评审数据、打开浏览器或安装可选运行时:
Object.assign(process.env, { PLANNOTATOR_REMOTE: "0", PLANNOTATOR_PORT: String(APP_PORT), PLANNOTATOR_AI: "disabled", PLANNOTATOR_SHARE: "disabled", PLANNOTATOR_JINA: "0", PLANNOTATOR_ANNOTATE_HISTORY: "0", PLANNOTATOR_GUIDE_HISTORY: "0", PLANNOTATOR_TODO_PROVIDER: "off", PLANNOTATOR_GLIMPSE: "0", PLANNOTATOR_SKIP_BROWSER_OPEN: "1", PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL: "1", PLANNOTATOR_SKIP_SEM_INSTALL: "1", PLANNOTATOR_FILE_BROWSER_MAX_FILES: "64", BROWSER: "true", });这些变量在 packages/shared/config.ts 中都有对应的解析逻辑,例如PLANNOTATOR_AI对应resolveAIEnabled(值为disabled时阻止 provider 运行时初始化并隐藏对应 UI,见 config.ts),PLANNOTATOR_SHARE对应resolveSharingEnabled(隐藏全部分享 UI,见 config.ts),PLANNOTATOR_ANNOTATE_HISTORY对应resolveAnnotateHistory(值为"1"或"true"时启用,见 config.ts)。
启动真实 annotate 服务器
目标调用packages/server/annotate.ts导出的startAnnotateServer启动真实的应用服务器(target.ts),注入一段无任何凭据与用户数据的纯文本 fixture 文档dast-fixture.md,并指定mode: "annotate-last"、origin: "claude-code"、sharingEnabled: false、gate: false、project: "dast-fixture"。这样 ZAP 被动规则检查的是真实 Plannotator UI 与 API 响应,而不是一个玩具桩。
只读路由守卫(scanTarget)
真正的设计亮点是守卫层:一个基于Bun.serve的反向代理式只读守卫(target.ts):
const forwardedPaths = new Set([ "/", "/api/plan", "/api/ai/capabilities", "/api/definitely-missing", ]); const readOnlyMethods = new Set(["GET", "HEAD"]);守卫只放行GET/HEAD且路径命中上述四个白名单的请求,并代理到127.0.0.1:APP_PORT(redirect: "manual",响应头附加X-Plannotator-DAST-Guard: forwarded);其他任何路径或非只读方法一律返回小体积响应:GET/HEAD 返回 404,其他方法返回 405,并带X-Plannotator-DAST-Guard: blocked头。
设计动因写得很清楚:ZAP 基线蜘蛛会请求/robots.txt、/sitemap.xml等爬虫元数据路径,应用的浏览器回退(browser fallback)会为这些路径正确返回 SPA,但把 20+ MiB 的 bundle 喂给静态蜘蛛既浪费又误导。同时,守卫使被动工作流不可能触达任何状态变更的应用端点——这正是“被动”语义的强制保证。
探测器健康哨兵(sentinel)
目标还同时启动一个哨兵服务(target.ts),监听19433端口:/healthz返回纯文本ok,其余路径返回一个刻意缺失防点击劫持头的简单 HTML 页面。其作用是验证“扫描器本身工作正常”——该页面应当触发 ZAP 规则 10020(anti-clickjacking header missing)。它独立于应用报告,永不进入产品构建,属于扫描可信性(detector health)控制。
ZAP 扫描钩子:种子路由、规则裁剪与蜘蛛收敛
扫描通过--hook /zap/hooks.py挂载 scripts/dast/zap-hooks.py,包含三个确定性钩子:
zap_access_target:种子真实只读路由
for path in ("", "/api/plan", "/api/ai/capabilities", "/api/definitely-missing"): response = zap.urlopen(target.rstrip("/") + path) if response.startswith("ZAP Error"): raise RuntimeError(f"ZAP failed to seed {path or '/'}: {response}")它通过 ZAP 的urlopen主动访问四个路由,让被动规则检查 SPA 首页、plan JSON、AI 能力响应(disabled-AI 形态)以及未知 API 的 404 响应——全程不调用任何状态变更端点。种子失败(返回ZAP Error)会直接令扫描失败。
zap_tuned:裁剪两条规则
zap.pscan.disable_scanners("10003,10109")- 规则 10003(Vulnerable JS Library):依赖 CVE 的检测职责由 Trivy(仓库依赖)与 Grype(发布依赖)负责,ZAP 重复检测既冗余;更重要的是,该规则试图把 20+ MiB 的单文件 bundle 整体存入 alert 证据,超过 ZAP 的 alert 列上限,会把冗余检查变成引擎错误;
- 规则 10109(Modern App Detection):同样因证据体积原因被禁用,它属于 informational 级别,不是漏洞判定。
除这两条外,其余全部被动 Web 规则仍然接收完整响应。
zap_spider:收敛传统蜘蛛
if target.rstrip("/").endswith(":19432"): return zap, target.rstrip("/") + "/api/definitely-missing" return zap, target传统蜘蛛会把单文件 bundle 内部的字符串误判为链接,因此把蜘蛛目标收敛到一个小体积、只读的 API 404 路径(/api/definitely-missing),而不是应用主页(favicon 路径由 SPA 回退处理、会返回完整 bundle,同样不适合蜘蛛)。种子路由(zap_access_target)负责把真实 UI/API 响应送入被动扫描器;将蜘蛛留在应用源上也保证探测器哨兵(19433)的发现不会混入应用报告。
Browser/AJAX 爬取:评估后推迟
README 明确说明,浏览器/AJAX 爬取被评估后有意推迟:ZAP 固定镜像未携带 WebDriver,而 internal 网络又正确阻止了运行时下载 WebDriver。这一取舍保证了扫描的确定性与离线性,同时仍能被动检查真实 Plannotator UI 与 API 响应。
大响应缓存调优:32 MiB → 128 MiB 的派生模板
Plannotator 的单文件 UI bundle 超过 20 MiB,远超 ZAP 自带 HSQLDB 模板默认的32 MiB响应缓存(一旦计入扫描元数据即超限)。工作流在运行时从摘要固定镜像中提取该模板,只修改缓存上限后只读挂载(dast.yml):
docker run --rm --network none --entrypoint cat "$ZAP_IMAGE" /zap/db/zapdb.script > security-results/zap/zapdb.script grep -c '^SET FILES CACHE SIZE 32000$' security-results/zap/zapdb.script # 必须恰好匹配 1 次 sed -i 's/^SET FILES CACHE SIZE 32000$/SET FILES CACHE SIZE 131072/' security-results/zap/zapdb.script grep -qx 'SET FILES CACHE SIZE 131072' security-results/zap/zapdb.script chmod 444 security-results/zap/zapdb.script流程为:提取模板 →验证预期设置存在(grep -c必须等于 1,防止镜像升级后设置漂移导致 sed 落空)→ 仅修改缓存为 128 MiB(131072KiB)→ 校验修改结果 → 只读挂载(chmod 444)。扫描参数中同时通过-z "-Xmx2048m ... -config database.response.bodysize=33554432"将响应体上限配置为 32 MiB。
校验器(validator)对缓存/磁盘耗尽可能fail-closed:如果 ZAP 报告缓存耗尽(Data cache size limit is reached)、磁盘耗尽(No space left on device)或响应体截断(greater than the configured response body length),工作流都会失败(详见下文报告校验)。
两次扫描:探测器健康扫描 + 应用扫描
工作流在同一 internal 网络上先后执行两次zap-baseline.py:
第一次:哨兵扫描(detector health),目标http://plannotator-dast-target:19433(dast.yml),参数-m 0 -T 5,产出sentinel.json/sentinel.html/sentinel.md,用于验证 ZAP 探测器能正常发现受控 fixture(规则 10020)。
第二次:应用扫描,目标http://plannotator-dast-target:19432(dast.yml),参数-m 1 -T 10,产出plannotator.json/plannotator.html/plannotator.md。
两次扫描共享以下加固与调优参数(dast.yml):
--read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 512 --tmpfs /home/zap/.ZAP /home/zap/.cache /home/zap/.config /home/zap/.mozilla(按用途分配 64M~1G 不等) --volume security-results/zap:/zap/wrk:rw --volume zapdb.script:/zap/db/zapdb.script:ro --volume zap-hooks.py:/zap/hooks.py:ro zap-baseline.py --autooff -t <target> -m <1|0> -T <10|5> -I -z "-Xmx<1024m|2048m> -silent -config database.response.bodysize=33554432" --hook /zap/hooks.py -J <json> -r <html> -w <md> -l WARN关键参数说明:
--autooff:关闭主动扫描,仅保留被动规则;-m 1:分钟级最大爬取耗时(应用扫描 1 分钟,哨兵扫描 0);-T 5/10:超时(秒);-I:跳过该 URL 的 spider;-l WARN:日志级别;-J/-r/-w:分别输出 JSON、HTML、Markdown 三种报告。
扫描退出码按 ZAP 惯例处理:0表示无告警、1表示有告警、2表示异常;退出码 ≥ 3 视为扫描器自身失败,直接使步骤失败。两次扫描的输出分别重命名为sentinel-zap.log与plannotator-zap.log保留。
报告证据校验:fail-closed 的 validator
扫描本身是“monitor-only”(发现仅监控,不阻断),真正的门禁是扫描后运行的证据校验器 scripts/dast/report.ts。它以--name value形式接收十个必填参数(report.ts),在 dast.yml 中的实际调用为:
bun run scripts/dast/report.ts \ --report security-results/zap/plannotator.json \ --sentinel-report security-results/zap/sentinel.json \ --scan-log security-results/zap/plannotator.log \ --zap-log security-results/zap/plannotator-zap.log \ --sentinel-zap-log security-results/zap/sentinel-zap.log \ --minimum-urls 8 \ --expected-host plannotator-dast-target \ --sentinel-rule 10020 \ --summary security-results/zap/summary.md \ --evidence security-results/zap/evidence.json校验器执行以下检查,任一项失败都会让工作流失败:
- 报告结构校验:
parseZapReport要求 JSON 可解析、顶层含site数组、每个 site 含alerts数组(report.ts); - 目标主机白名单校验:
assertExpectedHost要求报告必须包含期望主机plannotator-dast-target,且不允许出现白名单之外的主机——任何越界主机都会抛错“outside the allowlist”(report.ts),从根本上防止扫描“悄悄打到别处”; - 探测器健康校验:
assertAlert要求哨兵报告必须包含规则10020(report.ts),否则说明探测器失效,结论不可信; - 覆盖率下限:从扫描日志解析
Total of N URLs并取最大值,要求 ≥--minimum-urls(8),同时校验该数字是安全正整数(report.ts)——防止空覆盖率的“假通过”; - 引擎健康日志:
assertHealthyZapLog用正则扫描引擎日志,命中以下任一模式即失败(report.ts):ERROR、OutOfMemoryError、响应体超限、ZAP 启动失败、无 URL、ClientSpiderTask失败、数据缓存上限、磁盘无空间; - 风险汇总:
summarizeAlerts按riskdesc首词将告警归类为 high/medium/low/informational/unknown,生成 Markdown 摘要写入summary.md(report.ts)。
校验通过后还会生成机器可读的evidence.json(report.ts),记录 schema 版本、commit(GITHUB_SHA)、workflow run(GITHUB_RUN_ID)、扫描模式、目标形态(disposable annotate session、outbound blocked、AI/sharing/persistence 均 disabled)、扫描器版本与镜像、响应体上限(33554432 字节)、数据库缓存(131072 KiB)、覆盖情况、探测器健康状态、被禁用规则及其理由、告警汇总以及执行模式(monitor-findings-fail-closed-health)——即“发现仅监控,健康失败即阻断”。
这些校验逻辑均有对应的单元测试覆盖,见 scripts/dast/report.test.ts,包括:目标主机/受控规则校验、白名单外主机拒绝、覆盖率不足拒绝、各类引擎错误模式拒绝、风险汇总正确性、畸形/空报告拒绝等。工作流在扫描前也会先运行bun test scripts/dast/report.test.ts确保校验器本身正确。
证据保留、摘要输出与清理
- 每次运行保留以下产物30 天(
actions/upload-artifact的retention-days: 30,见 dast.yml):人类可读的 HTML 报告、机器可读的 JSON 报告、Markdown 报告、目标/扫描器日志(plannotator.log、plannotator-zap.log、sentinel.log、sentinel-zap.log、target.log)以及小体积证据清单(evidence.json、summary.md)。if-no-files-found: error保证产物缺失也会失败; summary.md被追加到$GITHUB_STEP_SUMMARY,在 Actions 页面直接呈现“URLs crawled / Alert rules / High / Medium / Low / Informational / Unclassified / Enforcement / Target”汇总;- 清理步骤在
if: always()下执行:无论扫描成败都抓取目标日志到target.log、强制删除目标容器并删除 internal 网络(dast.yml),确保隔离环境不留残余。
监控优先的定位与安全边界总结
该工作流的设计哲学可以归纳为三点:
- 发现监控、健康阻断:扫描发现的告警本身不阻断发布(monitor-only),但“目标缺失、覆盖为空、报告畸形、扫描器失败、受控被动 fixture 未被检测到”等工程可信性问题一律 fail-closed——这是“扫描结果可信”与“扫描结果好坏”两个维度的严格分离;
- 隔离是物理边界,应用配置是纵深防御:internal 网络提供真正的出站隔离,
target.ts中的PLANNOTATOR_DAST_ISOLATED=1闸门与全套 disabled 环境变量则是即使隔离失效也不至于泄露凭据或触达外部能力的第二、第三道防线; - 确定性优先于覆盖广度:通过种子路由、蜘蛛收敛、规则裁剪与离线决策(推迟 Browser/AJAX 爬取),在“确定性、离线、被动”约束下最大化真实 UI/API 的被动检查覆盖面。
对安全工程实践者而言,这套方案最值得借鉴的并非“跑一次 ZAP”,而是:如何在扫描前就通过镜像摘要固定、网络隔离、只读守卫、探测器哨兵把“扫描环境的可信性”变成可验证的事实,再用 fail-closed 的证据校验器把这份事实固化进 CI 门禁。
附:本仓库相关的纵深资料——扫描钩子实现见 scripts/dast/zap-hooks.py,目标服务器见 scripts/dast/target.ts,证据校验器见 scripts/dast/report.ts 及其测试 scripts/dast/report.test.ts,完整工作流见 .github/workflows/dast.yml。
【免费下载链接】plannotator
Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.
相关推荐
Activepieces DAST 安全实践:用 OWASP ZAP 对 Staging 环境做全量主动漏洞扫描
Activepieces DAST 安全实践:用 OWASP ZAP 对 Staging 环境做全量主动漏洞扫描 本文围绕 Activepieces 仓库中 .
工作流自动化低代码AI 应用人工智能AI AgentMCP 服务后端前端DBeaver终极指南:如何用免费工具统一管理100+数据库系统
DBeaver终极指南:如何用免费工具统一管理100+数据库系统 在当今数据驱动的时代,数据库管理已成为开发者和数据分析师的日常必备技能。面对MySQL、Pos
数据库客户端桌面应用数据库终极指南:如何实现多设备同步zsh历史记录 - zsh-histdb数据库同步教程 🚀
终极指南:如何实现多设备同步zsh历史记录 zsh histdb数据库同步教程 🚀 你是否厌倦了在不同电脑上重复输入相同的命令?想要在所有设备上都能快速访问完
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考