{"kind":"Skill","metadata":{"namespace":"community","name":"openclaw-secret-scanning-maintainer","version":"0.1.0"},"spec":{"description":"Triage, redact, clean up, and resolve OpenClaw GitHub Secret Scanning alerts in issues or PRs.","files":{"SKILL.md":"---\nname: openclaw-secret-scanning-maintainer\ndescription: Triage, redact, clean up, and resolve OpenClaw GitHub Secret Scanning alerts in issues or PRs.\n---\n\n# OpenClaw Secret Scanning Maintainer\n\n**Maintainer-only.** This skill requires repo admin / maintainer permissions to edit or delete other users' comments and resolve secret scanning alerts.\n\nUse this skill when processing alerts from `https://github.com/openclaw/openclaw/security/secret-scanning`.\n\n**Language rule:** All notification comments and replacement comments MUST be written in English.\n\n## Script\n\nAll mechanical operations (API calls, temp file management, security enforcements) are handled by:\n\n```\n$REPO_ROOT/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs\n```\n\nThe script enforces:\n\n- `hide_secret=true` on all alert fetches (no plaintext secrets in stdout)\n- `mktemp` with random UUIDs for all temp files\n- `-F body=@file` for all body uploads (no inline shell quoting)\n- Notification templates branched by location type\n- Never prints `.secret` or `.body` to stdout\n\n## Overall Flow\n\nSupports single or multiple alerts. For multiple alerts, process in ascending order.\n\nFor each alert:\n\n1. **Identify** — `fetch-alert` + `fetch-content` to get metadata and body\n2. **Decide** — Agent reads the body file, identifies whether plaintext secrets remain, and produces a redacted version only when needed\n3. **Redact** — `redact-body-if-needed` for issue/PR body; skip for comments (delete directly)\n4. **Purge** — `delete-comment` + `recreate-comment` for comments; cannot purge body history\n5. **Notify** — `notify` posts the right template per location type, unless the current issue/PR body is already redacted\n6. **Resolve** — `resolve` closes the alert\n7. **Summary** — `summary` prints formatted results\n\n## Step 1: Identify\n\n```bash\n# List all open alerts\nnode secret-scanning.mjs list-open\n\n# Fetch specific alert metadata + locations\nnode secret-scanning.mjs fetch-alert \u003cNUMBER\u003e\n\n# Fetch content for each location (saves body to temp file)\nnode secret-scanning.mjs fetch-content '\u003clocation-json\u003e'\n```\n\nThe `fetch-content` output includes:\n\n- `body_file`: path to temp file with full body content\n- `author`: who posted it\n- `issue_number` / `pr_number`: where it is\n- `edit_history_count`: number of existing edits\n- `type`: location type for routing\n- For `discussion_comment`, it also includes `comment_node_id`, `discussion_node_id`, and `reply_to_node_id` when the original comment was a reply.\n\n### Location type routing\n\n| type                          | Flow                                          |\n| ----------------------------- | --------------------------------------------- |\n| `issue_comment`               | Comment: delete+recreate                      |\n| `pull_request_comment`        | Comment: delete+recreate                      |\n| `pull_request_review_comment` | Comment: delete+recreate                      |\n| `discussion_comment`          | Discussion comment: delete+recreate (GraphQL) |\n| `issue_body`                  | Body: redact in place                         |\n| `pull_request_body`           | Body: redact in place                         |\n| `commit`                      | Notify only                                   |\n| _other_                       | Skip and report                               |\n\n## Step 2: Decide (Agent)\n\nThe agent reads the body file from `fetch-content` output and:\n\n1. Identifies ALL secrets in the content (there may be more than the alert flagged)\n2. Determines whether any plaintext credential remains in the current body\n3. Replaces each remaining secret with `[REDACTED \u003csecret_type\u003e]` — **no partial values, no prefix/suffix**\n4. Saves the redacted content to a new temp file\n\nThis is the only step that requires semantic understanding. Everything else is mechanical.\n\nFor `issue_body` and `pull_request_body`: if the current body has already been redacted by the author and no plaintext credential remains, **do not post a public notification comment**. Resolve the alert with a maintainer-only resolution comment such as:\n\n```bash\nnode secret-scanning.mjs resolve \u003cALERT_NUMBER\u003e revoked \"Current issue/PR body is already redacted; no public notification posted.\"\n```\n\nThis avoids creating a fresh public pointer to historical sensitive content.\n\n## Step 3: Redact\n\n### For comments (issue_comment / PR comments)\n\n**Do NOT redact.** Skip directly to Step 4 (delete + recreate). PATCHing before DELETE creates an unnecessary edit history revision.\n\n### For issue_body / pull_request_body\n\n```bash\nnode secret-scanning.mjs redact-body-if-needed \u003cissue|pr\u003e \u003cNUMBER\u003e \u003ccurrent-body-file\u003e \u003credacted-body-file\u003e \u003cresult-file\u003e\n```\n\nUse the `body_file` from `fetch-content` as `\u003ccurrent-body-file\u003e`. The command writes `notify_required` to `\u003cresult-file\u003e` and only PATCHes the body when the redacted file differs from the current body.\n\n## Step 4: Purge Edit History\n\n### Comments — Delete and Recreate\n\nFor issue/PR comments:\n\n```bash\n# Delete original (all edit history gone)\nnode secret-scanning.mjs delete-comment \u003cCOMMENT_ID\u003e\n\n# Recreate with redacted content\nnode secret-scanning.mjs recreate-comment \u003cISSUE_NUMBER\u003e \u003cbody-file\u003e\n```\n\nFor discussion comments (uses GraphQL):\n\n```bash\n# Delete original\nnode secret-scanning.mjs delete-discussion-comment \u003cCOMMENT_NODE_ID\u003e\n\n# Recreate with redacted content\nnode secret-scanning.mjs recreate-discussion-comment \u003cDISCUSSION_NODE_ID\u003e \u003cbody-file\u003e [REPLY_TO_NODE_ID]\n```\n\nThe `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands. When the original discussion comment was a reply, it also includes `reply_to_node_id`; pass that optional third argument so the redacted replacement stays in the original thread.\n\nThe recreated comment should follow this format:\n\n```\n\u003e **Note:** The original comment by @\u003cAUTHOR\u003e has been removed due to secret leakage. Below is the redacted version of the original content.\n\n---\n\n\u003credacted original content\u003e\n```\n\n### issue_body / pull_request_body — Cannot Purge Edit History\n\nEditing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API.\n\nDo not advise authors publicly to delete/recreate issues or close/reopen PRs. That can draw attention to historical content. Keep purge guidance maintainer-only.\n\n**Output to maintainer terminal only (never in public comments):**\n\n```\n⚠️ Issue/PR body edit history still contains plaintext secrets.\nContact GitHub Support to purge: https://support.github.com/contact\nRequest purge of issue/PR #{NUMBER} userContentEdits.\n```\n\n\u003e **CRITICAL:** Do NOT mention edit history or the \"edited\" button in any public comment or resolution_comment.\n\n### Commits\n\nCannot clean. Notify author to delete branch or force-push (for unmerged PRs).\n\n## Step 5: Notify\n\n```bash\nnode secret-scanning.mjs notify \u003cTARGET\u003e \u003cAUTHOR\u003e \u003cLOCATION_TYPE\u003e \u003cSECRET_TYPES\u003e [REPLY_TO_NODE_ID|BODY_REDACTION_RESULT_FILE]\n```\n\n- For non-discussion types, `\u003cTARGET\u003e` is the issue/PR number.\n- For `discussion_comment`, `\u003cTARGET\u003e` is the `discussion_node_id` returned by `fetch-content`.\n- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.\n- For `issue_body` and `pull_request_body`, pass the `\u003cresult-file\u003e` from `redact-body-if-needed`. The script skips notification when `notify_required` is `false` and refuses body notifications without this file.\n\nSecret types are comma-separated: `\"Discord Bot Token,Feishu App Secret\"`\n\nThe script picks the right template:\n\n- **comment types**: \"your comment … removed and replaced\"\n- **body types**: \"your issue/PR description … redacted in place\"\n- **commit**: \"code you committed\"\n\nFor `issue_body` and `pull_request_body`, only notify when the current body still contained plaintext and maintainers redacted it. If the user already redacted the current body, skip this step and resolve silently.\n\n## Step 6: Resolve\n\n```bash\nnode secret-scanning.mjs resolve \u003cALERT_NUMBER\u003e\n# or with custom resolution:\nnode secret-scanning.mjs resolve \u003cALERT_NUMBER\u003e revoked \"Custom comment\"\n```\n\nResolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to remove current plaintext exposure and notify only when public notification is useful. The `revoked` means \"this secret should be considered leaked\", not \"I confirmed it was revoked\".\n\n## Step 7: Summary\n\nAfter processing, create a JSON results file and pass it to the summary command:\n\n```bash\nnode secret-scanning.mjs summary /tmp/results.json\n```\n\nThe 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.\n\nThe JSON format:\n\n```json\n[\n  {\n    \"number\": 72,\n    \"secret_type\": \"Discord Bot Token\",\n    \"location_label\": \"Issue #63101 comment\",\n    \"location_url\": \"https://github.com/openclaw/openclaw/issues/63101#issuecomment-xxx\",\n    \"actions\": \"Deleted+Recreated+Notified\",\n    \"history_cleared\": true\n  }\n]\n```\n\nFor unsupported types, add `\"skipped\": true, \"unsupported_type\": \"\u003ctype\u003e\"`.\n\n## Safety Rules\n\n- **Agent reads content, identifies secrets, produces redaction.** Script handles all API calls.\n- **Never include any portion of a secret** in public comments, redaction markers, or terminal output.\n- **Never include alert URLs or numbers** in public comments.\n- **For comments, skip PATCH — go directly to DELETE + recreate.**\n- **Never mention edit history, \"edited\" button, or commit SHAs** in any public content.\n- **Ask for confirmation** before deleting any comment.\n- **One alert at a time** unless user requests batch.\n- **All public comments in English.**\n- **Skip unsupported location types** and report in summary.\n","scripts/secret-scanning.mjs":"#!/usr/bin/env node\n// Secret scanning alert handler for OpenClaw maintainers.\n// Usage: node secret-scanning.mjs \u003ccommand\u003e [options]\n\nimport { execFileSync, spawnSync } from \"node:child_process\";\nimport crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\n\nconst REPO = \"openclaw/openclaw\";\nconst REPO_URL = `https://github.com/${REPO}`;\n\n// ─── Helpers ────────────────────────────────────────────────────────────────\n\nfunction fail(message) {\n  console.error(`error: ${message}`);\n  process.exit(1);\n}\n\nfunction tmpFile(purpose) {\n  const filePath = path.join(os.tmpdir(), `secretscan-${purpose}-${crypto.randomUUID()}`);\n  // 预创建文件，限制权限为 owner-only\n  fs.writeFileSync(filePath, \"\", { mode: 0o600 });\n  return filePath;\n}\n\nfunction gh(args, { json = true, allowFailure = false } = {}) {\n  const proc = spawnSync(\"gh\", args, { encoding: \"utf8\", maxBuffer: 10 * 1024 * 1024 });\n  if (proc.status !== 0 \u0026\u0026 !allowFailure) {\n    fail(`gh ${args.slice(0, 3).join(\" \")} failed:\\n${(proc.stderr || proc.stdout || \"\").trim()}`);\n  }\n  if (proc.status !== 0) {\n    return {\n      gh_failed: true,\n      status: proc.status,\n      stdout: proc.stdout,\n      stderr: proc.stderr,\n    };\n  }\n  if (!json) return proc.stdout;\n  try {\n    return JSON.parse(proc.stdout);\n  } catch {\n    return proc.stdout;\n  }\n}\n\nfunction ghGraphQL(query, options = {}) {\n  return gh([\"api\", \"graphql\", \"-f\", `query=${query}`], options);\n}\n\nfunction isBodyLocationType(locationType) {\n  return locationType === \"issue_body\" || locationType === \"pull_request_body\";\n}\n\nexport function decideBodyRedaction(currentBody, redactedBody) {\n  const bodyChanged = String(currentBody) !== String(redactedBody);\n  return {\n    body_changed: bodyChanged,\n    notify_required: bodyChanged,\n  };\n}\n\nexport function loadBodyRedactionResult(locationType, resultFile) {\n  if (!isBodyLocationType(locationType)) {\n    return { notify_required: true };\n  }\n  if (!resultFile) {\n    fail(\"Body notifications require a redaction result file from redact-body-if-needed\");\n  }\n  if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);\n\n  const result = JSON.parse(fs.readFileSync(resultFile, \"utf8\"));\n  if (typeof result.notify_required !== \"boolean\") {\n    fail(`Invalid redaction result file: missing boolean notify_required in ${resultFile}`);\n  }\n  return result;\n}\n\nfunction failOnGraphQLFailure(result, message) {\n  if (result?.gh_failed) {\n    const details = (\n      result.stderr ||\n      result.stdout ||\n      `gh exited with status ${result.status}`\n    ).trim();\n    fail(`${message}: ${details}`);\n  }\n  if (Array.isArray(result?.errors) \u0026\u0026 result.errors.length \u003e 0) {\n    fail(`${message}: ${JSON.stringify(result.errors)}`);\n  }\n}\n\nfunction escapeGraphQLString(value) {\n  return String(value)\n    .replace(/\\\\/g, \"\\\\\\\\\")\n    .replace(/\"/g, '\\\\\"')\n    .replace(/\\r/g, \"\\\\r\")\n    .replace(/\\n/g, \"\\\\n\");\n}\n\nfunction formatGraphQLAfterClause(cursor) {\n  return cursor ? `, after: \"${escapeGraphQLString(cursor)}\"` : \"\";\n}\n\nfunction findDiscussionCommentNode(nodes, discussionCommentDbId) {\n  return nodes.find((node) =\u003e String(node.databaseId) === String(discussionCommentDbId)) || null;\n}\n\nfunction fetchDiscussionReplyPage(commentNodeId, cursor) {\n  const afterClause = formatGraphQLAfterClause(cursor);\n  return ghGraphQL(`{\n    node(id: \"${escapeGraphQLString(commentNodeId)}\") {\n      ... on DiscussionComment {\n        replies(first: 100${afterClause}) {\n          pageInfo { hasNextPage endCursor }\n          nodes {\n            id\n            databaseId\n            author { login }\n            body\n            url\n            replyTo { id }\n            userContentEdits(first: 50) {\n              totalCount\n            }\n          }\n        }\n      }\n    }\n  }}`);\n}\n\nfunction fetchDiscussionComment(discussionNumber, discussionCommentDbId) {\n  const [owner, name] = REPO.split(\"/\");\n  let discussionId = null;\n  let cursor = null;\n  let hasNextPage = true;\n\n  while (hasNextPage) {\n    const afterClause = formatGraphQLAfterClause(cursor);\n    const gql = ghGraphQL(\n      `{\n        repository(owner: \"${owner}\", name: \"${name}\") {\n          discussion(number: ${discussionNumber}) {\n            id\n            comments(first: 50${afterClause}) {\n              pageInfo { hasNextPage endCursor }\n              nodes {\n                id\n                databaseId\n                author { login }\n                body\n                url\n                replyTo { id }\n                userContentEdits(first: 50) {\n                  totalCount\n                }\n                replies(first: 100) {\n                  pageInfo { hasNextPage endCursor }\n                  nodes {\n                    id\n                    databaseId\n                    author { login }\n                    body\n                    url\n                    replyTo { id }\n                    userContentEdits(first: 50) {\n                      totalCount\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n      }`,\n      { allowFailure: true },\n    );\n    failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);\n\n    const discussion = gql?.data?.repository?.discussion;\n    if (!discussion)\n      fail(\n        `Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,\n      );\n\n    discussionId = discussion.id;\n\n    for (const topLevelComment of discussion.comments.nodes) {\n      if (String(topLevelComment.databaseId) === String(discussionCommentDbId)) {\n        return { discussionId, comment: topLevelComment };\n      }\n\n      let reply = findDiscussionCommentNode(topLevelComment.replies.nodes, discussionCommentDbId);\n      let replyCursor = topLevelComment.replies.pageInfo.endCursor;\n      let hasMoreReplies = topLevelComment.replies.pageInfo.hasNextPage;\n\n      while (!reply \u0026\u0026 hasMoreReplies) {\n        const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);\n        failOnGraphQLFailure(\n          replyPage,\n          `Failed to fetch replies for discussion comment ${topLevelComment.id}`,\n        );\n        const replies = replyPage?.data?.node?.replies;\n        if (!replies)\n          fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);\n\n        reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);\n        hasMoreReplies = replies.pageInfo.hasNextPage;\n        replyCursor = replies.pageInfo.endCursor;\n      }\n\n      if (reply) return { discussionId, comment: reply };\n    }\n\n    hasNextPage = discussion.comments.pageInfo.hasNextPage;\n    cursor = discussion.comments.pageInfo.endCursor;\n  }\n\n  return { discussionId, comment: null };\n}\n\nfunction createDiscussionComment(discussionNodeId, body, replyToNodeId) {\n  const replyToClause = replyToNodeId ? `, replyToId: \"${escapeGraphQLString(replyToNodeId)}\"` : \"\";\n  const result = ghGraphQL(\n    `mutation { addDiscussionComment(input: { discussionId: \"${escapeGraphQLString(discussionNodeId)}\"${replyToClause}, body: \"${escapeGraphQLString(body)}\" }) { comment { id url } } }`,\n  );\n  if (result?.errors) {\n    fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`);\n  }\n  return result?.data?.addDiscussionComment?.comment;\n}\n\n// ─── Commands ───────────────────────────────────────────────────────────────\n\n/**\n * fetch-alert \u003cnumber\u003e\n * Fetch alert metadata + locations. Never exposes .secret.\n */\nfunction cmdFetchAlert(alertNumber) {\n  if (!alertNumber) fail(\"Usage: fetch-alert \u003cnumber\u003e\");\n\n  const alert = gh([\"api\", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]);\n\n  const locations = gh([\n    \"api\",\n    `repos/${REPO}/secret-scanning/alerts/${alertNumber}/locations`,\n    \"--paginate\",\n    \"--slurp\",\n  ]);\n  // --paginate + --slurp 确保多页结果合并为一个 JSON 数组\n  const flatLocations = Array.isArray(locations?.[0])\n    ? locations.flat()\n    : Array.isArray(locations)\n      ? locations\n      : [];\n\n  const result = {\n    number: alert.number,\n    state: alert.state,\n    secret_type: alert.secret_type,\n    secret_type_display_name: alert.secret_type_display_name,\n    validity: alert.validity,\n    html_url: alert.html_url,\n    locations: flatLocations.map((loc) =\u003e ({\n      type: loc.type,\n      details: loc.details,\n    })),\n  };\n\n  console.log(JSON.stringify(result, null, 2));\n}\n\n/**\n * fetch-content \u003clocation-json\u003e\n * Fetch the content and metadata for a specific location.\n * Saves full body to a temp file. Prints metadata + file path to stdout.\n */\nfunction cmdFetchContent(locationJson) {\n  if (!locationJson) fail(\"Usage: fetch-content '\u003clocation-json\u003e'\");\n  const location = JSON.parse(locationJson);\n  const type = location.type;\n  const details = location.details;\n\n  if (type === \"discussion_comment\") {\n    const commentUrl = details.discussion_comment_url;\n    if (!commentUrl) fail(\"No discussion_comment_url in location details\");\n\n    const urlMatch = commentUrl.match(/discussions\\/(\\d+)#discussioncomment-(\\d+)/);\n    if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);\n    const discussionNumber = urlMatch[1];\n    const discussionCommentDbId = urlMatch[2];\n\n    const { discussionId, comment } = fetchDiscussionComment(\n      discussionNumber,\n      discussionCommentDbId,\n    );\n    if (!comment)\n      fail(\n        `Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,\n      );\n\n    const bodyFile = tmpFile(\"body.md\");\n    fs.writeFileSync(bodyFile, comment.body || \"\");\n\n    console.log(\n      JSON.stringify(\n        {\n          type,\n          comment_node_id: comment.id,\n          discussion_node_id: discussionId,\n          reply_to_node_id: comment.replyTo?.id ?? null,\n          discussion_number: Number(discussionNumber),\n          discussion_comment_db_id: Number(discussionCommentDbId),\n          author: comment.author?.login,\n          html_url: comment.url || commentUrl,\n          edit_history_count: comment.userContentEdits?.totalCount ?? 0,\n          body_file: bodyFile,\n        },\n        null,\n        2,\n      ),\n    );\n  } else if (\n    type === \"issue_comment\" ||\n    type === \"pull_request_comment\" ||\n    type === \"pull_request_review_comment\"\n  ) {\n    // Extract comment ID from URL\n    const commentUrl =\n      details.issue_comment_url ||\n      details.pull_request_comment_url ||\n      details.pull_request_review_comment_url;\n    if (!commentUrl) fail(`No comment URL in location details`);\n\n    const comment = gh([\"api\", commentUrl]);\n    const bodyFile = tmpFile(\"body.md\");\n    fs.writeFileSync(bodyFile, comment.body || \"\");\n\n    // Fetch edit history\n    const nodeId = comment.node_id;\n    const typeName =\n      type === \"pull_request_review_comment\" ? \"PullRequestReviewComment\" : \"IssueComment\";\n    const gql = ghGraphQL(`{\n      node(id: \"${nodeId}\") {\n        ... on ${typeName} {\n          userContentEdits(first: 50) {\n            totalCount\n          }\n        }\n      }\n    }`);\n    const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;\n\n    // Extract issue number from html_url\n    const htmlUrl = comment.html_url || details.html_url || \"\";\n    const issueMatch = htmlUrl.match(/\\/(issues|pull)\\/(\\d+)/);\n    const issueNumber = issueMatch ? issueMatch[2] : null;\n\n    console.log(\n      JSON.stringify(\n        {\n          type,\n          comment_id: comment.id,\n          node_id: nodeId,\n          author: comment.user?.login,\n          issue_number: issueNumber,\n          html_url: htmlUrl,\n          edit_history_count: editCount,\n          body_file: bodyFile,\n        },\n        null,\n        2,\n      ),\n    );\n  } else if (type === \"issue_body\") {\n    const issueUrl = details.issue_body_url || details.issue_url;\n    if (!issueUrl) fail(\"No issue URL in location details\");\n\n    const issue = gh([\"api\", issueUrl]);\n    const bodyFile = tmpFile(\"body.md\");\n    fs.writeFileSync(bodyFile, issue.body || \"\");\n\n    const nodeId = issue.node_id;\n    const number = issue.number;\n    const gql = ghGraphQL(`{\n      node(id: \"${nodeId}\") {\n        ... on Issue {\n          userContentEdits(first: 50) {\n            totalCount\n          }\n        }\n      }\n    }`);\n    const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;\n\n    console.log(\n      JSON.stringify(\n        {\n          type,\n          issue_number: number,\n          node_id: nodeId,\n          author: issue.user?.login,\n          html_url: issue.html_url,\n          edit_history_count: editCount,\n          body_file: bodyFile,\n        },\n        null,\n        2,\n      ),\n    );\n  } else if (type === \"pull_request_body\") {\n    const prUrl = details.pull_request_body_url || details.pull_request_url;\n    if (!prUrl) fail(\"No PR URL in location details\");\n\n    const pr = gh([\"api\", prUrl]);\n    const bodyFile = tmpFile(\"body.md\");\n    fs.writeFileSync(bodyFile, pr.body || \"\");\n\n    const nodeId = pr.node_id;\n    const number = pr.number;\n    const gql = ghGraphQL(`{\n      node(id: \"${nodeId}\") {\n        ... on PullRequest {\n          userContentEdits(first: 50) {\n            totalCount\n          }\n        }\n      }\n    }`);\n    const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;\n\n    console.log(\n      JSON.stringify(\n        {\n          type,\n          pr_number: number,\n          node_id: nodeId,\n          author: pr.user?.login,\n          merged: pr.merged,\n          state: pr.state,\n          html_url: pr.html_url,\n          edit_history_count: editCount,\n          body_file: bodyFile,\n        },\n        null,\n        2,\n      ),\n    );\n  } else if (type === \"commit\") {\n    console.log(\n      JSON.stringify(\n        {\n          type,\n          commit_sha: details.commit_sha,\n          path: details.path,\n          start_line: details.start_line,\n          end_line: details.end_line,\n          html_url: details.html_url || details.commit_url || details.blob_url || null,\n          // No body file for commits\n          body_file: null,\n        },\n        null,\n        2,\n      ),\n    );\n  } else {\n    console.log(\n      JSON.stringify(\n        {\n          type,\n          unsupported: true,\n          details,\n        },\n        null,\n        2,\n      ),\n    );\n  }\n}\n\n/**\n * redact-body \u003cissue|pr\u003e \u003cnumber\u003e \u003credacted-body-file\u003e\n * PATCH the issue or PR body with redacted content from a file.\n */\nfunction cmdRedactBody(kind, number, bodyFile) {\n  if (!kind || !number || !bodyFile) {\n    fail(\"Usage: redact-body \u003cissue|pr\u003e \u003cnumber\u003e \u003credacted-body-file\u003e\");\n  }\n  if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);\n\n  const endpoint =\n    kind === \"pr\" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;\n\n  gh([\"api\", endpoint, \"-X\", \"PATCH\", \"-F\", `body=@${bodyFile}`]);\n  console.log(JSON.stringify({ ok: true, kind, number: Number(number) }));\n}\n\n/**\n * redact-body-if-needed \u003cissue|pr\u003e \u003cnumber\u003e \u003ccurrent-body-file\u003e \u003credacted-body-file\u003e \u003cresult-file\u003e\n * PATCH only when the agent-produced redacted body differs from the current body.\n */\nfunction cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile, resultFile) {\n  if (!kind || !number || !currentBodyFile || !redactedBodyFile || !resultFile) {\n    fail(\n      \"Usage: redact-body-if-needed \u003cissue|pr\u003e \u003cnumber\u003e \u003ccurrent-body-file\u003e \u003credacted-body-file\u003e \u003cresult-file\u003e\",\n    );\n  }\n  if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);\n  if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);\n\n  const currentBody = fs.readFileSync(currentBodyFile, \"utf8\");\n  const redactedBody = fs.readFileSync(redactedBodyFile, \"utf8\");\n  const decision = decideBodyRedaction(currentBody, redactedBody);\n  const result = {\n    ok: true,\n    kind,\n    number: Number(number),\n    ...decision,\n  };\n\n  if (decision.body_changed) {\n    const endpoint =\n      kind === \"pr\" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;\n    gh([\"api\", endpoint, \"-X\", \"PATCH\", \"-F\", `body=@${redactedBodyFile}`]);\n    result.redacted = true;\n  } else {\n    result.redacted = false;\n    result.reason = \"current_body_already_redacted\";\n  }\n\n  fs.writeFileSync(resultFile, `${JSON.stringify(result, null, 2)}\\n`, { mode: 0o600 });\n  console.log(JSON.stringify(result));\n}\n\n/**\n * delete-comment \u003ccomment-id\u003e\n * Delete a comment (and all its edit history).\n */\nfunction cmdDeleteComment(commentId) {\n  if (!commentId) fail(\"Usage: delete-comment \u003ccomment-id\u003e\");\n  gh([\"api\", `repos/${REPO}/issues/comments/${commentId}`, \"-X\", \"DELETE\"], { json: false });\n  console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));\n}\n\n/**\n * delete-discussion-comment \u003cnode-id\u003e\n * Delete a discussion comment via GraphQL (and all its edit history).\n */\nfunction cmdDeleteDiscussionComment(nodeId) {\n  if (!nodeId) fail(\"Usage: delete-discussion-comment \u003cnode-id\u003e\");\n  const result = ghGraphQL(\n    `mutation { deleteDiscussionComment(input: { id: \"${nodeId}\" }) { comment { id } } }`,\n  );\n  if (result?.errors) {\n    fail(`Failed to delete discussion comment: ${JSON.stringify(result.errors)}`);\n  }\n  console.log(JSON.stringify({ ok: true, deleted_node_id: nodeId }));\n}\n\n/**\n * recreate-discussion-comment \u003cdiscussion-node-id\u003e \u003cbody-file\u003e [reply-to-node-id]\n * Create a new discussion comment via GraphQL.\n */\nfunction cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {\n  if (!discussionNodeId || !bodyFile)\n    fail(\"Usage: recreate-discussion-comment \u003cdiscussion-node-id\u003e \u003cbody-file\u003e [reply-to-node-id]\");\n  if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);\n\n  const body = fs.readFileSync(bodyFile, \"utf8\");\n  const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);\n  console.log(\n    JSON.stringify({\n      ok: true,\n      node_id: newComment?.id,\n      html_url: newComment?.url,\n    }),\n  );\n}\n\n/**\n * recreate-comment \u003cissue-number\u003e \u003cbody-file\u003e\n * Create a new comment from a file.\n */\nfunction cmdRecreateComment(issueNumber, bodyFile) {\n  if (!issueNumber || !bodyFile) fail(\"Usage: recreate-comment \u003cissue-number\u003e \u003cbody-file\u003e\");\n  if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);\n\n  const result = gh([\n    \"api\",\n    `repos/${REPO}/issues/${issueNumber}/comments`,\n    \"-X\",\n    \"POST\",\n    \"-F\",\n    `body=@${bodyFile}`,\n  ]);\n\n  console.log(\n    JSON.stringify({\n      ok: true,\n      comment_id: result.id,\n      html_url: result.html_url,\n    }),\n  );\n}\n\n/**\n * notify \u003ctarget\u003e \u003cauthor\u003e \u003clocation-type\u003e \u003csecret-types\u003e [reply-to-node-id]\n * Post a notification comment with the correct template for the location type.\n * target = issue/PR number for non-discussion types, discussion node ID for discussion_comment.\n */\nfunction cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {\n  if (!target || !author || !locationType || !secretTypes) {\n    fail(\n      \"Usage: notify \u003ctarget\u003e \u003cauthor\u003e \u003clocation-type\u003e \u003csecret-types-comma-sep\u003e [reply-to-node-id]\",\n    );\n  }\n\n  const types = secretTypes.split(\",\").map((s) =\u003e s.trim());\n  const typeList = types.map((t, i) =\u003e `${i + 1}. **${t}**`).join(\"\\n\");\n  const redactionResult = loadBodyRedactionResult(locationType, replyToNodeId);\n  if (isBodyLocationType(locationType) \u0026\u0026 !redactionResult.notify_required) {\n    console.log(\n      JSON.stringify({\n        ok: true,\n        skipped: true,\n        reason: \"current_body_already_redacted\",\n      }),\n    );\n    return;\n  }\n\n  let locationDesc;\n  let actionDesc;\n  if (\n    locationType === \"issue_comment\" ||\n    locationType === \"pull_request_comment\" ||\n    locationType === \"pull_request_review_comment\" ||\n    locationType === \"discussion_comment\"\n  ) {\n    locationDesc = \"your comment\";\n    actionDesc = \"The affected comment has been removed and replaced with a redacted version.\";\n  } else if (locationType === \"issue_body\") {\n    locationDesc = \"your issue description\";\n    actionDesc = \"The affected content has been redacted in place.\";\n  } else if (locationType === \"pull_request_body\") {\n    locationDesc = \"your pull request description\";\n    actionDesc = \"The affected content has been redacted in place.\";\n  } else if (locationType === \"commit\") {\n    locationDesc = \"code you committed\";\n    actionDesc = \"\";\n  } else {\n    locationDesc = \"your content\";\n    actionDesc = \"\";\n  }\n\n  const body = [\n    `\u003e **Note:** This is an automated message sent by the OpenClaw maintainer team. **NO_REPLY.**`,\n    \"\",\n    `@${author} :warning: **Security Notice: Secret Leakage Detected**`,\n    \"\",\n    `GitHub Secret Scanning detected the following exposed secret types in ${locationDesc}:`,\n    \"\",\n    typeList,\n    \"\",\n    actionDesc,\n    \"\",\n    \"**Please rotate these credentials immediately.**\",\n    \"\",\n    \"These secrets were publicly exposed and should be considered compromised.\",\n  ]\n    .filter((line) =\u003e line !== undefined)\n    .join(\"\\n\");\n\n  // Discussion comments must be notified via GraphQL\n  if (locationType === \"discussion_comment\") {\n    const newComment = createDiscussionComment(target, body, replyToNodeId);\n    console.log(\n      JSON.stringify({\n        ok: true,\n        node_id: newComment?.id,\n        html_url: newComment?.url,\n      }),\n    );\n    return;\n  }\n\n  // Issue/PR comments via REST\n  const bodyFile = tmpFile(\"notify.md\");\n  fs.writeFileSync(bodyFile, body);\n\n  const result = gh([\n    \"api\",\n    `repos/${REPO}/issues/${target}/comments`,\n    \"-X\",\n    \"POST\",\n    \"-F\",\n    `body=@${bodyFile}`,\n  ]);\n\n  console.log(\n    JSON.stringify({\n      ok: true,\n      comment_id: result.id,\n      html_url: result.html_url,\n    }),\n  );\n}\n\n/**\n * resolve \u003calert-number\u003e [resolution] [comment]\n * Close a secret scanning alert.\n */\nfunction cmdResolve(alertNumber, resolution, comment) {\n  if (!alertNumber) fail(\"Usage: resolve \u003calert-number\u003e [resolution] [comment]\");\n\n  const res = resolution || \"revoked\";\n  const resComment = comment || \"Content redacted and author notified to rotate credentials.\";\n\n  const result = gh([\n    \"api\",\n    `repos/${REPO}/secret-scanning/alerts/${alertNumber}`,\n    \"-X\",\n    \"PATCH\",\n    \"-f\",\n    `state=resolved`,\n    \"-f\",\n    `resolution=${res}`,\n    \"-f\",\n    `resolution_comment=${resComment}`,\n  ]);\n\n  console.log(\n    JSON.stringify({\n      ok: true,\n      number: result.number,\n      state: result.state,\n      resolution: result.resolution,\n      resolved_at: result.resolved_at,\n    }),\n  );\n}\n\n/**\n * list-open\n * List all open secret scanning alerts.\n */\nfunction cmdListOpen() {\n  const alerts = gh([\n    \"api\",\n    `repos/${REPO}/secret-scanning/alerts?hide_secret=true\u0026state=open`,\n    \"--paginate\",\n    \"--slurp\",\n  ]);\n\n  // --slurp 将分页结果合并为 [[page1], [page2], ...] 需要 flat\n  const flat = Array.isArray(alerts?.[0]) ? alerts.flat() : Array.isArray(alerts) ? alerts : [];\n  const rows = flat.map((a) =\u003e ({\n    number: a.number,\n    secret_type_display_name: a.secret_type_display_name,\n    html_url: a.html_url,\n    first_location_html_url: a.first_location_detected?.html_url || null,\n  }));\n\n  console.log(JSON.stringify(rows, null, 2));\n}\n\n/**\n * summary \u003cjson-file\u003e\n * Print a formatted summary table from a JSON results file.\n */\nfunction cmdSummary(jsonFile) {\n  if (!jsonFile) fail(\"Usage: summary \u003cjson-file\u003e\");\n  if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`);\n\n  const results = JSON.parse(fs.readFileSync(jsonFile, \"utf8\"));\n  const lines = [];\n\n  lines.push(\"---BEGIN SUMMARY---\");\n  lines.push(\"\");\n  lines.push(\"## Secret Scanning Results\");\n  lines.push(\"\");\n  lines.push(\"| Alert | Type | Location | Actions | Edit History |\");\n  lines.push(\"|-------|------|----------|---------|--------------|\");\n\n  const needsPurge = [];\n\n  for (const r of results) {\n    const alertLink = `#${r.number} ${REPO_URL}/security/secret-scanning/${r.number}`;\n    const locationLink = r.location_url\n      ? `${r.location_label} ${r.location_url}`\n      : r.location_label;\n    const history = r.history_cleared ? \"Cleared\" : \"⚠️ History remains\";\n\n    lines.push(`| ${alertLink} | ${r.secret_type} | ${locationLink} | ${r.actions} | ${history} |`);\n\n    if (!r.history_cleared \u0026\u0026 r.location_url) {\n      needsPurge.push(r);\n    }\n  }\n\n  if (needsPurge.length \u003e 0) {\n    lines.push(\"\");\n    lines.push(\"Issues requiring GitHub Support to purge edit history:\");\n    for (const r of needsPurge) {\n      lines.push(`- ${r.location_label} ${r.location_url} — ${r.secret_type}`);\n    }\n    lines.push(\n      `Contact: https://support.github.com/contact — request purge of userContentEdits for the above issues.`,\n    );\n  }\n\n  const skipped = results.filter((r) =\u003e r.skipped);\n  if (skipped.length \u003e 0) {\n    lines.push(\"\");\n    lines.push(\n      \"⚠️ The following alerts were skipped because their location type is not supported:\",\n    );\n    for (const r of skipped) {\n      lines.push(\n        `- Alert #${r.number}: unsupported type \"${r.unsupported_type}\" — ${REPO_URL}/security/secret-scanning/${r.number}`,\n      );\n    }\n    lines.push(\"Please update the skill to define handling for these types.\");\n  }\n\n  lines.push(\"\");\n  lines.push(\"---END SUMMARY---\");\n\n  console.log(lines.join(\"\\n\"));\n}\n\n// ─── Dispatch ───────────────────────────────────────────────────────────────\n\nconst args = [];\n\nexport const commands = {\n  \"fetch-alert\": () =\u003e cmdFetchAlert(args[0]),\n  \"fetch-content\": () =\u003e cmdFetchContent(args[0]),\n  \"redact-body\": () =\u003e cmdRedactBody(args[0], args[1], args[2]),\n  \"redact-body-if-needed\": () =\u003e cmdRedactBodyIfNeeded(args[0], args[1], args[2], args[3], args[4]),\n  \"delete-comment\": () =\u003e cmdDeleteComment(args[0]),\n  \"delete-discussion-comment\": () =\u003e cmdDeleteDiscussionComment(args[0]),\n  \"recreate-comment\": () =\u003e cmdRecreateComment(args[0], args[1]),\n  \"recreate-discussion-comment\": () =\u003e cmdRecreateDiscussionComment(args[0], args[1], args[2]),\n  notify: () =\u003e cmdNotify(args[0], args[1], args[2], args[3], args[4]),\n  resolve: () =\u003e cmdResolve(args[0], args[1], args[2]),\n  \"list-open\": () =\u003e cmdListOpen(),\n  summary: () =\u003e cmdSummary(args[0]),\n};\n\nfunction main(argv = process.argv.slice(2)) {\n  const [command, ...commandArgs] = argv;\n  args.length = 0;\n  args.push(...commandArgs);\n\n  if (!command || !commands[command]) {\n    console.error(\n      [\n        \"Usage: node secret-scanning.mjs \u003ccommand\u003e [args]\",\n        \"\",\n        \"Commands:\",\n        \"  fetch-alert \u003cnumber\u003e             Fetch alert metadata + locations\",\n        \"  fetch-content '\u003clocation-json\u003e'   Fetch content for a location\",\n        \"  redact-body \u003cissue|pr\u003e \u003cn\u003e \u003cfile\u003e PATCH body with redacted file\",\n        \"  redact-body-if-needed \u003cissue|pr\u003e \u003cn\u003e \u003ccurrent-file\u003e \u003credacted-file\u003e \u003cresult-file\u003e PATCH body only if redaction changed it\",\n        \"  delete-comment \u003ccomment-id\u003e       Delete a comment\",\n        \"  delete-discussion-comment \u003cnode-id\u003e Delete a discussion comment (GraphQL)\",\n        \"  recreate-comment \u003cissue-n\u003e \u003cfile\u003e Create replacement comment\",\n        \"  recreate-discussion-comment \u003cdisc-node-id\u003e \u003cfile\u003e [reply-to-node-id] Create discussion comment (GraphQL)\",\n        \"  notify \u003ctarget\u003e \u003cauthor\u003e \u003ctype\u003e \u003ctypes\u003e [reply-to-node-id|body-result-file] Post notification\",\n        \"  resolve \u003cn\u003e [resolution] [comment] Close alert\",\n        \"  list-open                          List open alerts\",\n        \"  summary \u003cjson-file\u003e               Print formatted summary\",\n      ].join(\"\\n\"),\n    );\n    process.exit(1);\n  }\n\n  commands[command]();\n}\n\nif (process.argv[1] \u0026\u0026 import.meta.url === pathToFileURL(process.argv[1]).href) {\n  main();\n}\n"},"import":{"commit_sha":"424c6d0a5f4665b803ad6768d08b0be7659deaf4","imported_at":"2026-05-18T20:13:36Z","license_text":"MIT License\n\nCopyright (c) 2025 Peter Steinberger\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n","owner":"openclaw","repo":"openclaw/openclaw","source_url":"https://github.com/openclaw/openclaw/tree/424c6d0a5f4665b803ad6768d08b0be7659deaf4/.agents/skills/openclaw-secret-scanning-maintainer"}},"content_hash":[227,54,144,216,79,104,16,110,156,11,149,28,148,171,20,183,60,130,180,12,212,180,61,199,53,12,136,196,155,110,17,41],"trust_level":"unsigned","yanked":false}
