OpenClaw 密钥泄露告警处置实战:面向 GitHub Secret Scanning 的维护者工作流与配套脚本设计
当公共仓库的 Issue、PR 或评论中误贴了 Token 等敏感凭证时,维护者需要在最短时间内完成“识别—决策—脱敏—清除历史—通知—关闭告警—汇总”的闭环,同时避免在处置过程中二次泄露凭证。本文以 OpenClaw 仓库内置的维护者技能 SKILL.md 为主线,完整拆解这套告警处置流程:每一步的命令行操作、七种位置类型的分流路由、脱敏与通知模板的设计取舍,以及配套脚本 secret-scanning.mjs 中落实的安全约束。读完后,你可以掌握一套可复用、可审计、且“机械操作全部脚本化、语义判断留给 Agent”的密钥泄露处置方案。
技能定位:这是一项维护者专属能力
该技能位于仓库的 .agents/skills/ 目录下,元数据声明如下:
- name:
openclaw-secret-scanning-maintainer - description: Triage, redact, clean up, and resolve OpenClaw GitHub Secret Scanning alerts in issues or PRs.
文档开头即标注了两条硬性前提:
- 仅限维护者使用(Maintainer-only)。删除或编辑他人评论、关闭 Secret Scanning 告警都需要仓库管理员/维护者权限;
- 语言规则:所有对外发布的通知评论和替换评论必须使用英文书写。
所有机械性操作(API 调用、临时文件管理、安全强制项)都集中在一个 Node.js 脚本中:
$REPO_ROOT/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs
脚本对目标仓库做了硬编码——secret-scanning.mjs#L14-L15 中定义了 REPO = "openclaw/openclaw",因此该脚本默认面向 OpenClaw 官方仓库的告警页面使用。所有 GitHub API 调用通过 execPlainGh 发出,该工具函数来自仓库的 plain-gh 封装:它用 execFileSync 同步执行 gh CLI(避免 shell 插值带来的注入面),支持通过 OPENCLAW_GH_BIN 环境变量显式指定 gh 二进制路径,并会规范化子进程颜色环境变量、按需从 gh auth token 提取凭证注入到子进程环境(见 plain-gh.mjs#L40-L76)。
脚本内置的五条安全强制项
原文档强调脚本本身承担了“安全护栏”职责,逐条对应到源码可以验证:
| 强制项 | 源码证据 |
|---|---|
所有告警拉取均带 hide_secret=true,stdout 不出现明文密钥 |
cmdFetchAlert 请求 ?hide_secret=true(secret-scanning.mjs#L265),list-open 同理(#L805) |
| 所有临时文件使用随机 UUID 命名 | tmpFile() 以 secretscan-<purpose>-<randomUUID> 落盘到系统临时目录(#L24-L29) |
| 临时文件权限收敛为仅属主可读 | 预创建文件时 mode: 0o600(#L27) |
所有正文上传使用 -F body=@file,不做内联 shell 引号拼接 |
如 redact-body-if-needed 与 notify 中的 "-F", "body=@${bodyFile}"(#L566、#L744-L751) |
绝不把 .secret 或 .body 打到 stdout |
告警拉取只输出脱敏元数据;正文内容一律写入临时文件,仅打印文件路径(body_file) |
这套设计的核心思想是:密钥的明文内容在整条流水线中只存在于临时文件里,永远不经过 stdout、命令行参数或 shell 引号,从而降低在终端回显、日志采集或 Agent 上下文中二次泄露的风险。
总体流程:七步闭环
流程支持单条或多条告警(多条时按编号升序处理),每条告警走同一条流水线:
- Identify(识别) —
fetch-alert+fetch-content获取告警元数据与正文; - Decide(决策) — Agent 读取正文文件,判断是否仍有明文密钥,必要时产出脱敏版本;
- Redact(脱敏) — 对 issue/PR 正文执行
redact-body-if-needed;评论类直接跳过,进入删除重建; - Purge(清除历史) — 评论执行
delete-comment+recreate-comment;正文的编辑历史无法通过 API 清除; - Notify(通知) —
notify按位置类型选择对应模板,除非当前正文已被作者自行脱敏; - Resolve(关闭) —
resolve关闭告警; - Summary(汇总) —
summary输出格式化结果。
其中只有第 2 步需要语义理解(Agent 读内容、识别人类写法的凭证并脱敏),其余全部是机械操作。
Step 1:Identify — 拉取告警与定位内容
# 列出所有 open 状态的告警
node secret-scanning.mjs list-open
# 拉取指定告警的元数据 + 位置列表
node secret-scanning.mjs fetch-alert <NUMBER>
# 拉取每个位置的正文内容(正文写入临时文件)
node secret-scanning.mjs fetch-content '<location-json>'
从源码看,list-open 使用 --paginate --slurp 分页拉取并对 [[page1],[page2],...] 结构做 flat() 归一化(#L803-L820);fetch-alert 会把多页 locations 合并为一个 JSON 数组后输出(#L267-L294),输出字段包括 number、state、secret_type、secret_type_display_name、validity、html_url 和归一化后的 locations。
fetch-content 接收一条 location JSON,按 type 分流到不同实现,输出的元数据包括:
body_file:完整正文内容的临时文件路径;author:作者;issue_number/pr_number:所在位置;edit_history_count:已有编辑历史条数(通过 GraphQL 查询userContentEdits.totalCount获得);type:用于路由的位置类型;- 对
discussion_comment,额外输出comment_node_id、discussion_node_id,且当原评论是回复时还包含reply_to_node_id(#L335-L352)。
值得注意的实现细节:GitHub 的 REST API 并不直接暴露 discussion 评论,脚本对 discussion_comment 采用 GraphQL 全量分页查询——先按 discussion 编号取顶层评论,再对每条顶层评论的 replies 继续分页,直到 databaseId 匹配到目标评论(#L149-L241)。评论类的 edit_history_count 也是通过 GraphQL 按 node_id 查询 userContentEdits 得到(#L375-L384)。
位置类型路由表
| type | 处置流程 |
|---|---|
issue_comment |
评论:删除 + 重建 |
pull_request_comment |
评论:删除 + 重建 |
pull_request_review_comment |
评论:删除 + 重建 |
discussion_comment |
讨论评论:删除 + 重建(GraphQL) |
issue_body |
正文:就地脱敏 |
pull_request_body |
正文:就地脱敏 |
commit |
仅通知 |
| 其他 | 跳过并在汇总中报告 |
Step 2:Decide — 唯一需要语义理解的步骤
Agent 读取 fetch-content 输出的正文文件后需要完成四件事:
- 识别内容中的全部密钥(实际泄露可能比告警标记的更多);
- 判断当前正文中是否仍存在明文凭证;
- 将每个残留密钥替换为
[REDACTED <secret_type>]——不允许保留任何部分值、前缀或后缀; - 把脱敏后的内容保存到一个新的临时文件。
这里有一条关键的静默分支:对于 issue_body 和 pull_request_body,如果作者已经自行把当前正文脱敏、不再有明文凭证,则不要发布公开通知评论,而是用仅维护者可见的 resolution comment 直接关闭告警:
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Current issue/PR body is already redacted; no public notification posted."
文档解释了动机:避免制造一个新的“指向历史敏感内容”的公开锚点。脚本侧对这条分支有对应的实现——decideBodyRedaction 通过比较当前正文与脱敏版正文是否相同来产出 notify_required 标志(#L68-L74),后文的 redact-body-if-needed 和 notify 都依赖它。
Step 3:Redact — 正文就地脱敏,评论类跳过
评论类(issue_comment / 各类 PR 评论)不要脱敏,直接进入 Step 4 的删除 + 重建。原因:先 PATCH 再 DELETE 会白白多产生一条包含旧内容的编辑历史版本。
正文类(issue_body / pull_request_body) 使用条件脱敏命令:
node secret-scanning.mjs redact-body-if-needed <issue|pr> <NUMBER> <current-body-file> <redacted-body-file> <result-file>
其中 <current-body-file> 使用 fetch-content 输出的 body_file。该命令的行为(#L540-L575):
- 读取两个文件并逐字节比较;
- 仅当脱敏版与当前正文不同时才执行 PATCH(目标端点为
repos/<repo>/issues/<n>或repos/<repo>/pulls/<n>,同样走-F body=@file); - 将
{ ok, kind, number, body_changed, notify_required, redacted, reason? }以0o600权限写入<result-file>,reason在跳过时为current_body_already_redacted。
此外脚本还保留了一个无条件覆盖命令 redact-body <issue|pr> <number> <redacted-body-file>(#L521-L534),供需要强制替换正文的场景使用。
Step 4:Purge — 清除编辑历史
评论:删除 + 重建
删除原评论会连同其全部编辑历史一起消失,因此评论类选择“删除后重建”:
# 删除原评论(编辑历史随之消失)
node secret-scanning.mjs delete-comment <COMMENT_ID>
# 用脱敏内容重建
node secret-scanning.mjs recreate-comment <ISSUE_NUMBER> <body-file>
讨论评论走 GraphQL(REST 不支持),命令参数改用 node ID:
# 删除原讨论评论
node secret-scanning.mjs delete-discussion-comment <COMMENT_NODE_ID>
# 用脱敏内容重建;第三个可选参数用于保持回复线程位置
node secret-scanning.mjs recreate-discussion-comment <DISCUSSION_NODE_ID> <body-file> [REPLY_TO_NODE_ID]
这些 ID 直接取自 fetch-content 对 discussion_comment 的输出:comment_node_id 与 discussion_node_id;当原评论是一条回复时,reply_to_node_id 非空,传入它可以让脱敏版替换评论留在原来的回复线程内(重建时以 addDiscussionComment(input: { ..., replyToId: ... }) 实现,#L243-L252)。
重建评论必须遵循固定格式(注意全英文要求):
> **Note:** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
---
<redacted original content>
正文:编辑历史无法通过 API 清除
编辑 issue/PR 正文会产生一条含编辑前明文的历史版本,这部分无法通过 API 清除。文档明确禁止公开建议作者“删除重建 issue”或“关闭重开 PR”——那会把更多注意力引向历史内容,清除指引只能保留给维护者。以下提示只能输出到维护者终端,绝不能出现在公开评论里:
⚠️ Issue/PR body edit history still contains plaintext secrets.
Contact GitHub Support to purge: (GitHub 官方支持联系页)
Request purge of issue/PR #{NUMBER} userContentEdits.
文档用 CRITICAL 级别强调:任何公开评论或 resolution_comment 中都不得提及“edit history”或“edited”按钮。
提交(commit)类
已推送提交无法就地清理,流程上只通知作者删除分支或 force-push(针对未合并的 PR)。
Step 5:Notify — 按位置类型选择通知模板
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID|BODY_REDACTION_RESULT_FILE]
参数规则:
- 非 discussion 类型,
<TARGET>是 issue/PR 编号; discussion_comment时,<TARGET>是fetch-content返回的discussion_node_id;回复型位置传入reply_to_node_id,让通知留在同一线程;issue_body/pull_request_body必须传入redact-body-if-needed产出的<result-file>,脚本据此跳过不必要的通知——loadBodyRedactionResult对正文类型强制要求该文件存在、且含布尔notify_required字段,否则直接报错退出(#L77-L93)。当notify_required为false时,notify输出{ ok: true, skipped: true, reason: "current_body_already_redacted" }并返回,不发帖。
<SECRET_TYPES> 为逗号分隔列表,例如 "Discord Bot Token,Feishu App Secret"。脚本按位置类型选择措辞模板(#L685-L725),实际发出的通知正文结构为:
- 首行固定声明:
> **Note:** This is an automated message sent by the OpenClaw maintainer team. **NO_REPLY.** @<author> :warning: **Security Notice: Secret Leakage Detected**- 按编号列出检测到的密钥类型(加粗);
- 位置描述:评论类为 “your comment … removed and replaced”,正文类为 “your issue/pull request description … redacted in place”,commit 类为 “code you committed”;
- 结尾统一要求:请立即轮换这些凭证,并说明“这些密钥已公开暴露,应视为已泄露”。
对 discussion 评论,通知同样通过 GraphQL 创建(#L728-L738);其余类型写入临时文件后经 POST repos/<repo>/issues/<n>/comments 发出。
Step 6:Resolve — 关闭告警
node secret-scanning.mjs resolve <ALERT_NUMBER>
# 或自定义 resolution:
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Custom comment"
实现上即 PATCH repos/<repo>/secret-scanning/alerts/<n>,带 state=resolved、resolution(默认 revoked)、resolution_comment(默认为 “Content redacted and author notified to rotate credentials.”),成功后打印 number、state、resolution、resolved_at(#L766-L796)。
文档对 revoked 的语义解释值得注意:维护者无法确认用户是否真的完成了凭证轮换,维护者的责任是“移除当前明文暴露 + 在公开通知有用时才通知”。因此 revoked 的含义是“该密钥应被视为已泄露”,而不是“我已确认其被吊销”。
Step 7:Summary — 结果汇总与强制原文回显
处理完成后,把每条告警的结果写成一个 JSON 文件并交给汇总命令:
node secret-scanning.mjs summary /tmp/results.json
JSON 结构示例(location_url 为对应评论/位置的完整链接):
[
{
"number": 72,
"secret_type": "Discord Bot Token",
"location_label": "Issue #63101 comment",
"location_url": "<对应评论/位置的完整 URL>",
"actions": "Deleted+Recreated+Notified",
"history_cleared": true
}
]
不支持的位置类型需追加 "skipped": true, "unsupported_type": "<type>"。
summary 命令(#L826-L889)会输出一段以 ---BEGIN SUMMARY--- 与 ---END SUMMARY--- 包裹的 Markdown 表格,列为 Alert / Type / Location / Actions / Edit History,并自动附加两类提示:
- 对
history_cleared: false且有位置链接的条目,汇总出“需要联系 GitHub Support 清除userContentEdits编辑历史”的清单; - 对被跳过的不支持类型,列出告警编号并要求更新技能以定义相应处置。
文档对 Agent 提出强制要求:标记之间的内容必须逐字原样输出给用户,禁止改写、重排、缩写或自造汇总,因为脚本已为每条告警和位置附上了完整 URL。
安全规则清单(Safety Rules)
原文档末尾给出的安全规则是整套流程的操作底线,完整继承如下:
- 分工边界:Agent 只负责读内容、识别密钥、产出脱敏版;所有 API 调用由脚本执行;
- 任何公开评论、脱敏标记、终端输出中,绝不允许出现密钥的任何部分;
- 公开评论中绝不包含告警 URL 或告警编号;
- 评论类跳过 PATCH,直接 DELETE + 重建;
- 公开内容中绝不提及编辑历史、“edited”按钮或 commit SHA;
- 删除任何评论前必须先请求确认;
- 默认一次只处理一条告警,除非用户要求批量;
- 所有公开评论使用英文;
- 不支持的位置类型直接跳过,并在 summary 中报告。
适用前提与边界
结合技能文档与源码,使用这套方案的前提包括:
- 操作者具备目标仓库(脚本内固定为
openclaw/openclaw)的 maintainer/admin 权限,且本地ghCLI 已完成认证(可通过OPENCLAW_GH_BIN指定显式gh二进制,见 plain-gh.mjs#L99-L109); - 告警来源为 GitHub 仓库的 Secret Scanning 告警,位置类型落在路由表范围内;
- 对
issue_body/pull_request_body泄露,正文编辑历史中的明文需另行通过 GitHub Support 清除,脚本会在 summary 中自动列出待清除清单; - 该流程与仓库的安全事件响应文档 incident-response.md 属于互补关系:后者定义漏洞报告的分诊与披露流程,而本文描述的密钥扫描技能专注于“凭证已暴露在公开内容中”这一特定告警类型的清理与通知。
这套方案的参考价值在于其职责切分:语义判断(什么是密钥、怎么替换)留给 Agent,机械操作与安全风险(API 调用、临时文件、历史清除、通知措辞、静默分支)全部由带强制约束的脚本固化——这正是可被其他仓库直接借鉴的告警处置工程范式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00