首页
/ OpenClaw 密钥泄露告警处置实战:面向 GitHub Secret Scanning 的维护者工作流与配套脚本设计

OpenClaw 密钥泄露告警处置实战:面向 GitHub Secret Scanning 的维护者工作流与配套脚本设计

2026-09-06 11:39:33作者:龚格成

当公共仓库的 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.

文档开头即标注了两条硬性前提:

  1. 仅限维护者使用(Maintainer-only)。删除或编辑他人评论、关闭 Secret Scanning 告警都需要仓库管理员/维护者权限;
  2. 语言规则:所有对外发布的通知评论和替换评论必须使用英文书写。

所有机械性操作(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=truesecret-scanning.mjs#L265),list-open 同理(#L805)
所有临时文件使用随机 UUID 命名 tmpFile()secretscan-<purpose>-<randomUUID> 落盘到系统临时目录(#L24-L29)
临时文件权限收敛为仅属主可读 预创建文件时 mode: 0o600(#L27)
所有正文上传使用 -F body=@file,不做内联 shell 引号拼接 redact-body-if-needednotify 中的 "-F", "body=@${bodyFile}"(#L566、#L744-L751)
绝不把 .secret.body 打到 stdout 告警拉取只输出脱敏元数据;正文内容一律写入临时文件,仅打印文件路径(body_file

这套设计的核心思想是:密钥的明文内容在整条流水线中只存在于临时文件里,永远不经过 stdout、命令行参数或 shell 引号,从而降低在终端回显、日志采集或 Agent 上下文中二次泄露的风险。

总体流程:七步闭环

流程支持单条或多条告警(多条时按编号升序处理),每条告警走同一条流水线:

  1. Identify(识别)fetch-alert + fetch-content 获取告警元数据与正文;
  2. Decide(决策) — Agent 读取正文文件,判断是否仍有明文密钥,必要时产出脱敏版本;
  3. Redact(脱敏) — 对 issue/PR 正文执行 redact-body-if-needed;评论类直接跳过,进入删除重建;
  4. Purge(清除历史) — 评论执行 delete-comment + recreate-comment;正文的编辑历史无法通过 API 清除;
  5. Notify(通知)notify 按位置类型选择对应模板,除非当前正文已被作者自行脱敏;
  6. Resolve(关闭)resolve 关闭告警;
  7. 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),输出字段包括 numberstatesecret_typesecret_type_display_namevalidityhtml_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_iddiscussion_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 输出的正文文件后需要完成四件事:

  1. 识别内容中的全部密钥(实际泄露可能比告警标记的更多);
  2. 判断当前正文中是否仍存在明文凭证;
  3. 将每个残留密钥替换为 [REDACTED <secret_type>]——不允许保留任何部分值、前缀或后缀
  4. 把脱敏后的内容保存到一个新的临时文件。

这里有一条关键的静默分支:对于 issue_bodypull_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-needednotify 都依赖它。

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-contentdiscussion_comment 的输出:comment_node_iddiscussion_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_requiredfalse 时,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=resolvedresolution(默认 revoked)、resolution_comment(默认为 “Content redacted and author notified to rotate credentials.”),成功后打印 numberstateresolutionresolved_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 中报告。

适用前提与边界

结合技能文档与源码,使用这套方案的前提包括:

  1. 操作者具备目标仓库(脚本内固定为 openclaw/openclaw)的 maintainer/admin 权限,且本地 gh CLI 已完成认证(可通过 OPENCLAW_GH_BIN 指定显式 gh 二进制,见 plain-gh.mjs#L99-L109);
  2. 告警来源为 GitHub 仓库的 Secret Scanning 告警,位置类型落在路由表范围内;
  3. issue_body/pull_request_body 泄露,正文编辑历史中的明文需另行通过 GitHub Support 清除,脚本会在 summary 中自动列出待清除清单;
  4. 该流程与仓库的安全事件响应文档 incident-response.md 属于互补关系:后者定义漏洞报告的分诊与披露流程,而本文描述的密钥扫描技能专注于“凭证已暴露在公开内容中”这一特定告警类型的清理与通知。

这套方案的参考价值在于其职责切分:语义判断(什么是密钥、怎么替换)留给 Agent,机械操作与安全风险(API 调用、临时文件、历史清除、通知措辞、静默分支)全部由带强制约束的脚本固化——这正是可被其他仓库直接借鉴的告警处置工程范式。

登录后查看全文
热门项目推荐
相关项目推荐