diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md b/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md new file mode 100644 index 00000000000..6b84451a0ee --- /dev/null +++ b/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md @@ -0,0 +1,201 @@ +--- +name: openclaw-secret-scanning-maintainer +description: Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content. +--- + +# OpenClaw Secret Scanning Maintainer + +**Maintainer-only.** This skill requires repo admin / maintainer permissions to edit or delete other users' comments and resolve secret scanning alerts. + +Use this skill when processing alerts from `https://github.com/openclaw/openclaw/security/secret-scanning`. + +**Language rule:** All notification comments and replacement comments MUST be written in English. + +## Script + +All mechanical operations (API calls, temp file management, security enforcements) are handled by: + +``` +$REPO_ROOT/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs +``` + +The script enforces: + +- `hide_secret=true` on all alert fetches (no plaintext secrets in stdout) +- `mktemp` with random UUIDs for all temp files +- `-F body=@file` for all body uploads (no inline shell quoting) +- Notification templates branched by location type +- Never prints `.secret` or `.body` to stdout + +## Overall Flow + +Supports single or multiple alerts. For multiple alerts, process in ascending order. + +For each alert: + +1. **Identify** — `fetch-alert` + `fetch-content` to get metadata and body +2. **Decide** — Agent reads the body file, identifies all secrets, produces redacted version +3. **Redact** — `redact-body` for issue/PR body; skip for comments (delete directly) +4. **Purge** — `delete-comment` + `recreate-comment` for comments; cannot purge body history +5. **Notify** — `notify` posts the right template per location type +6. **Resolve** — `resolve` closes the alert +7. **Summary** — `summary` prints formatted results + +## Step 1: Identify + +```bash +# List all open alerts +node secret-scanning.mjs list-open + +# Fetch specific alert metadata + locations +node secret-scanning.mjs fetch-alert + +# Fetch content for each location (saves body to temp file) +node secret-scanning.mjs fetch-content '' +``` + +The `fetch-content` output includes: + +- `body_file`: path to temp file with full body content +- `author`: who posted it +- `issue_number` / `pr_number`: where it is +- `edit_history_count`: number of existing edits +- `type`: location type for routing + +### Location type routing + +| type | Flow | +| ----------------------------- | ------------------------ | +| `issue_comment` | Comment: delete+recreate | +| `pull_request_comment` | Comment: delete+recreate | +| `pull_request_review_comment` | Comment: delete+recreate | +| `issue_body` | Body: redact in place | +| `pull_request_body` | Body: redact in place | +| `commit` | Notify only | +| _other_ | Skip and report | + +## Step 2: Decide (Agent) + +The agent reads the body file from `fetch-content` output and: + +1. Identifies ALL secrets in the content (there may be more than the alert flagged) +2. Replaces each secret with `[REDACTED ]` — **no partial values, no prefix/suffix** +3. Saves the redacted content to a new temp file + +This is the only step that requires semantic understanding. Everything else is mechanical. + +## Step 3: Redact + +### For comments (issue_comment / PR comments) + +**Do NOT redact.** Skip directly to Step 4 (delete + recreate). PATCHing before DELETE creates an unnecessary edit history revision. + +### For issue_body / pull_request_body + +```bash +node secret-scanning.mjs redact-body +``` + +## Step 4: Purge Edit History + +### Comments — Delete and Recreate + +```bash +# Delete original (all edit history gone) +node secret-scanning.mjs delete-comment + +# Recreate with redacted content +# Agent prepares the body file with maintainer header + redacted content +node secret-scanning.mjs recreate-comment +``` + +The recreated comment should follow this format: + +``` +> **Note from maintainer (@):** The original comment by @ has been removed due to secret leakage. Below is the redacted version of the original content. + +--- + + +``` + +### issue_body / pull_request_body — Cannot Purge + +Editing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API. + +**Output to maintainer terminal only (never in public comments):** + +``` +⚠️ Issue/PR body edit history still contains plaintext secrets. +Contact GitHub Support to purge: https://support.github.com/contact +Request purge of issue/PR #{NUMBER} userContentEdits. +``` + +> **CRITICAL:** Do NOT mention edit history or the "edited" button in any public comment or resolution_comment. + +### Commits + +Cannot clean. Notify author to delete branch or force-push (for unmerged PRs). + +## Step 5: Notify + +```bash +node secret-scanning.mjs notify +``` + +Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"` + +The script picks the right template: + +- **comment types**: "your comment … removed and replaced" +- **body types**: "your issue/PR description … redacted in place" +- **commit**: "code you committed" + +## Step 6: Resolve + +```bash +node secret-scanning.mjs resolve +# or with custom resolution: +node secret-scanning.mjs resolve revoked "Custom comment" +``` + +Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to redact + notify. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked". + +## Step 7: Summary + +After processing, create a JSON results file and pass it to the summary command: + +```bash +node secret-scanning.mjs summary /tmp/results.json +``` + +The script outputs a block delimited by `---BEGIN SUMMARY---` and `---END SUMMARY---`. **You MUST output the content between these markers verbatim to the user. Do NOT rephrase, reformat, abbreviate, or create your own summary.** The script already includes full URLs for every alert and location. + +The JSON format: + +```json +[ + { + "number": 72, + "secret_type": "Discord Bot Token", + "location_label": "Issue #63101 comment", + "location_url": "https://github.com/openclaw/openclaw/issues/63101#issuecomment-xxx", + "actions": "Deleted+Recreated+Notified", + "history_cleared": true + } +] +``` + +For unsupported types, add `"skipped": true, "unsupported_type": ""`. + +## Safety Rules + +- **Agent reads content, identifies secrets, produces redaction.** Script handles all API calls. +- **Never include any portion of a secret** in public comments, redaction markers, or terminal output. +- **Never include alert URLs or numbers** in public comments. +- **For comments, skip PATCH — go directly to DELETE + recreate.** +- **Never mention edit history, "edited" button, or commit SHAs** in any public content. +- **Ask for confirmation** before deleting any comment. +- **One alert at a time** unless user requests batch. +- **All public comments in English.** +- **Skip unsupported location types** and report in summary. diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs new file mode 100644 index 00000000000..759461a5031 --- /dev/null +++ b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs @@ -0,0 +1,531 @@ +#!/usr/bin/env node +// Secret scanning alert handler for OpenClaw maintainers. +// Usage: node secret-scanning.mjs [options] + +import { execFileSync, spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const REPO = "openclaw/openclaw"; +const REPO_URL = `https://github.com/${REPO}`; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function fail(message) { + console.error(`error: ${message}`); + process.exit(1); +} + +function tmpFile(purpose) { + const filePath = path.join(os.tmpdir(), `secretscan-${purpose}-${crypto.randomUUID()}`); + // 预创建文件,限制权限为 owner-only + fs.writeFileSync(filePath, "", { mode: 0o600 }); + return filePath; +} + +function gh(args, { json = true, allowFailure = false } = {}) { + const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); + if (proc.status !== 0 && !allowFailure) { + fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`); + } + if (!json) return proc.stdout; + try { + return JSON.parse(proc.stdout); + } catch { + return proc.stdout; + } +} + +function ghGraphQL(query) { + return gh(["api", "graphql", "-f", `query=${query}`]); +} + +// ─── Commands ─────────────────────────────────────────────────────────────── + +/** + * fetch-alert + * Fetch alert metadata + locations. Never exposes .secret. + */ +function cmdFetchAlert(alertNumber) { + if (!alertNumber) fail("Usage: fetch-alert "); + + const alert = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]); + + const locations = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}/locations`, "--paginate", "--slurp"]); + // --paginate + --slurp 确保多页结果合并为一个 JSON 数组 + const flatLocations = Array.isArray(locations?.[0]) ? locations.flat() : Array.isArray(locations) ? locations : []; + + const result = { + number: alert.number, + state: alert.state, + secret_type: alert.secret_type, + secret_type_display_name: alert.secret_type_display_name, + validity: alert.validity, + html_url: alert.html_url, + locations: flatLocations.map((loc) => ({ + type: loc.type, + details: loc.details, + })), + }; + + console.log(JSON.stringify(result, null, 2)); +} + +/** + * fetch-content + * Fetch the content and metadata for a specific location. + * Saves full body to a temp file. Prints metadata + file path to stdout. + */ +function cmdFetchContent(locationJson) { + if (!locationJson) fail("Usage: fetch-content ''"); + const location = JSON.parse(locationJson); + const type = location.type; + const details = location.details; + + if ( + type === "issue_comment" || + type === "pull_request_comment" || + type === "pull_request_review_comment" + ) { + // 从 url 中提取 comment ID + const commentUrl = + details.issue_comment_url || + details.pull_request_comment_url || + details.pull_request_review_comment_url; + if (!commentUrl) fail(`No comment URL in location details`); + + const comment = gh(["api", commentUrl]); + const bodyFile = tmpFile("body.md"); + fs.writeFileSync(bodyFile, comment.body || ""); + + // 获取编辑历史 + const nodeId = comment.node_id; + const typeName = + type === "pull_request_review_comment" ? "PullRequestReviewComment" : "IssueComment"; + const gql = ghGraphQL(`{ + node(id: "${nodeId}") { + ... on ${typeName} { + userContentEdits(first: 50) { + totalCount + } + } + } + }`); + const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0; + + // 提取 issue number(从 html_url) + const htmlUrl = comment.html_url || details.html_url || ""; + const issueMatch = htmlUrl.match(/\/(issues|pull)\/(\d+)/); + const issueNumber = issueMatch ? issueMatch[2] : null; + + console.log( + JSON.stringify( + { + type, + comment_id: comment.id, + node_id: nodeId, + author: comment.user?.login, + issue_number: issueNumber, + html_url: htmlUrl, + edit_history_count: editCount, + body_file: bodyFile, + }, + null, + 2, + ), + ); + } else if (type === "issue_body") { + const issueUrl = details.issue_body_url || details.issue_url; + if (!issueUrl) fail("No issue URL in location details"); + + const issue = gh(["api", issueUrl]); + const bodyFile = tmpFile("body.md"); + fs.writeFileSync(bodyFile, issue.body || ""); + + const nodeId = issue.node_id; + const number = issue.number; + const gql = ghGraphQL(`{ + node(id: "${nodeId}") { + ... on Issue { + userContentEdits(first: 50) { + totalCount + } + } + } + }`); + const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0; + + console.log( + JSON.stringify( + { + type, + issue_number: number, + node_id: nodeId, + author: issue.user?.login, + html_url: issue.html_url, + edit_history_count: editCount, + body_file: bodyFile, + }, + null, + 2, + ), + ); + } else if (type === "pull_request_body") { + const prUrl = details.pull_request_body_url || details.pull_request_url; + if (!prUrl) fail("No PR URL in location details"); + + const pr = gh(["api", prUrl]); + const bodyFile = tmpFile("body.md"); + fs.writeFileSync(bodyFile, pr.body || ""); + + const nodeId = pr.node_id; + const number = pr.number; + const gql = ghGraphQL(`{ + node(id: "${nodeId}") { + ... on PullRequest { + userContentEdits(first: 50) { + totalCount + } + } + } + }`); + const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0; + + console.log( + JSON.stringify( + { + type, + pr_number: number, + node_id: nodeId, + author: pr.user?.login, + merged: pr.merged, + state: pr.state, + html_url: pr.html_url, + edit_history_count: editCount, + body_file: bodyFile, + }, + null, + 2, + ), + ); + } else if (type === "commit") { + console.log( + JSON.stringify( + { + type, + commit_sha: details.commit_sha, + path: details.path, + start_line: details.start_line, + end_line: details.end_line, + html_url: details.html_url || details.commit_url || details.blob_url || null, + // commit 没有 body 文件 + body_file: null, + }, + null, + 2, + ), + ); + } else { + console.log( + JSON.stringify( + { + type, + unsupported: true, + details, + }, + null, + 2, + ), + ); + } +} + +/** + * redact-body + * PATCH the issue or PR body with redacted content from a file. + */ +function cmdRedactBody(kind, number, bodyFile) { + if (!kind || !number || !bodyFile) { + fail("Usage: redact-body "); + } + if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`); + + const endpoint = + kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`; + + gh(["api", endpoint, "-X", "PATCH", "-F", `body=@${bodyFile}`]); + console.log(JSON.stringify({ ok: true, kind, number: Number(number) })); +} + +/** + * delete-comment + * Delete a comment (and all its edit history). + */ +function cmdDeleteComment(commentId) { + if (!commentId) fail("Usage: delete-comment "); + gh(["api", `repos/${REPO}/issues/comments/${commentId}`, "-X", "DELETE"], { json: false }); + console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) })); +} + +/** + * recreate-comment + * Create a new comment from a file. + */ +function cmdRecreateComment(issueNumber, bodyFile) { + if (!issueNumber || !bodyFile) fail("Usage: recreate-comment "); + if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`); + + const result = gh([ + "api", + `repos/${REPO}/issues/${issueNumber}/comments`, + "-X", + "POST", + "-F", + `body=@${bodyFile}`, + ]); + + console.log( + JSON.stringify({ + ok: true, + comment_id: result.id, + html_url: result.html_url, + }), + ); +} + +/** + * notify + * Post a notification comment with the correct template for the location type. + */ +function cmdNotify(issueNumber, author, locationType, secretTypes) { + if (!issueNumber || !author || !locationType || !secretTypes) { + fail("Usage: notify "); + } + + const types = secretTypes.split(",").map((s) => s.trim()); + const typeList = types.map((t, i) => `${i + 1}. **${t}**`).join("\n"); + + let locationDesc; + let actionDesc; + if ( + locationType === "issue_comment" || + locationType === "pull_request_comment" || + locationType === "pull_request_review_comment" + ) { + locationDesc = "your comment"; + actionDesc = "The affected comment has been removed and replaced with a redacted version."; + } else if (locationType === "issue_body") { + locationDesc = "your issue description"; + actionDesc = "The affected content has been redacted in place."; + } else if (locationType === "pull_request_body") { + locationDesc = "your pull request description"; + actionDesc = "The affected content has been redacted in place."; + } else if (locationType === "commit") { + locationDesc = "code you committed"; + actionDesc = ""; + } else { + locationDesc = "your content"; + actionDesc = ""; + } + + const body = [ + `@${author} :warning: **Security Notice: Secret Leakage Detected**`, + "", + `GitHub Secret Scanning detected the following exposed secret types in ${locationDesc}:`, + "", + typeList, + "", + actionDesc, + "", + "**Please rotate these credentials immediately.**", + "", + "These secrets were publicly exposed and should be considered compromised.", + ] + .filter((line) => line !== undefined) + .join("\n"); + + const bodyFile = tmpFile("notify.md"); + fs.writeFileSync(bodyFile, body); + + const result = gh([ + "api", + `repos/${REPO}/issues/${issueNumber}/comments`, + "-X", + "POST", + "-F", + `body=@${bodyFile}`, + ]); + + console.log( + JSON.stringify({ + ok: true, + comment_id: result.id, + html_url: result.html_url, + }), + ); +} + +/** + * resolve [resolution] [comment] + * Close a secret scanning alert. + */ +function cmdResolve(alertNumber, resolution, comment) { + if (!alertNumber) fail("Usage: resolve [resolution] [comment]"); + + const res = resolution || "revoked"; + const resComment = comment || "Content redacted and author notified to rotate credentials."; + + const result = gh([ + "api", + `repos/${REPO}/secret-scanning/alerts/${alertNumber}`, + "-X", + "PATCH", + "-f", + `state=resolved`, + "-f", + `resolution=${res}`, + "-f", + `resolution_comment=${resComment}`, + ]); + + console.log( + JSON.stringify({ + ok: true, + number: result.number, + state: result.state, + resolution: result.resolution, + resolved_at: result.resolved_at, + }), + ); +} + +/** + * list-open + * List all open secret scanning alerts. + */ +function cmdListOpen() { + const alerts = gh([ + "api", + `repos/${REPO}/secret-scanning/alerts?hide_secret=true&state=open`, + "--paginate", + "--slurp", + ]); + + // --slurp 将分页结果合并为 [[page1], [page2], ...] 需要 flat + const flat = Array.isArray(alerts?.[0]) ? alerts.flat() : Array.isArray(alerts) ? alerts : []; + const rows = flat.map((a) => ({ + number: a.number, + secret_type_display_name: a.secret_type_display_name, + html_url: a.html_url, + first_location_html_url: a.first_location_detected?.html_url || null, + })); + + console.log(JSON.stringify(rows, null, 2)); +} + +/** + * summary + * Print a formatted summary table from a JSON results file. + */ +function cmdSummary(jsonFile) { + if (!jsonFile) fail("Usage: summary "); + if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`); + + const results = JSON.parse(fs.readFileSync(jsonFile, "utf8")); + const lines = []; + + lines.push("---BEGIN SUMMARY---"); + lines.push(""); + lines.push("## Secret Scanning Results"); + lines.push(""); + lines.push("| Alert | Type | Location | Actions | Edit History |"); + lines.push("|-------|------|----------|---------|--------------|"); + + const needsPurge = []; + + for (const r of results) { + const alertLink = `#${r.number} ${REPO_URL}/security/secret-scanning/${r.number}`; + const locationLink = r.location_url + ? `${r.location_label} ${r.location_url}` + : r.location_label; + const history = r.history_cleared ? "Cleared" : "⚠️ History remains"; + + lines.push( + `| ${alertLink} | ${r.secret_type} | ${locationLink} | ${r.actions} | ${history} |`, + ); + + if (!r.history_cleared && r.location_url) { + needsPurge.push(r); + } + } + + if (needsPurge.length > 0) { + lines.push(""); + lines.push("Issues requiring GitHub Support to purge edit history:"); + for (const r of needsPurge) { + lines.push(`- ${r.location_label} ${r.location_url} — ${r.secret_type}`); + } + lines.push( + `Contact: https://support.github.com/contact — request purge of userContentEdits for the above issues.`, + ); + } + + const skipped = results.filter((r) => r.skipped); + if (skipped.length > 0) { + lines.push(""); + lines.push( + "⚠️ The following alerts were skipped because their location type is not supported:", + ); + for (const r of skipped) { + lines.push( + `- Alert #${r.number}: unsupported type "${r.unsupported_type}" — ${REPO_URL}/security/secret-scanning/${r.number}`, + ); + } + lines.push("Please update the skill to define handling for these types."); + } + + lines.push(""); + lines.push("---END SUMMARY---"); + + console.log(lines.join("\n")); +} + +// ─── Dispatch ─────────────────────────────────────────────────────────────── + +const [command, ...args] = process.argv.slice(2); + +const commands = { + "fetch-alert": () => cmdFetchAlert(args[0]), + "fetch-content": () => cmdFetchContent(args[0]), + "redact-body": () => cmdRedactBody(args[0], args[1], args[2]), + "delete-comment": () => cmdDeleteComment(args[0]), + "recreate-comment": () => cmdRecreateComment(args[0], args[1]), + notify: () => cmdNotify(args[0], args[1], args[2], args[3]), + resolve: () => cmdResolve(args[0], args[1], args[2]), + "list-open": () => cmdListOpen(), + summary: () => cmdSummary(args[0]), +}; + +if (!command || !commands[command]) { + console.error( + [ + "Usage: node secret-scanning.mjs [args]", + "", + "Commands:", + " fetch-alert Fetch alert metadata + locations", + " fetch-content '' Fetch content for a location", + " redact-body PATCH body with redacted file", + " delete-comment Delete a comment", + " recreate-comment Create replacement comment", + " notify Post notification", + " resolve [resolution] [comment] Close alert", + " list-open List open alerts", + " summary Print formatted summary", + ].join("\n"), + ); + process.exit(1); +} + +commands[command]();