From 54eadc2f72a6e4673b0b6b4568bf3df417fa3659 Mon Sep 17 00:00:00 2001 From: Juwan Date: Wed, 19 Aug 2026 08:23:11 +0800 Subject: [PATCH] fix(security): unify secret redaction in public comment outputs (#1693) Ensure all public issue, PR, and inline comments apply redactSecrets() in addition to sanitizeContent() before submitting payloads to the GitHub API. This aligns public comment output with error log and step-summary redaction policies, preventing potential leakage of Anthropic API keys, AWS credentials, Slack tokens, JWTs, and GitHub tokens. --- .../post-buffered-inline-comments.ts | 3 +- src/mcp/github-comment-server.ts | 4 +- src/mcp/github-inline-comment-server.ts | 6 +- test/public-comment-redaction.test.ts | 64 +++++++++++++++++++ test/sanitizer.test.ts | 25 ++++++++ 5 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 test/public-comment-redaction.test.ts diff --git a/src/entrypoints/post-buffered-inline-comments.ts b/src/entrypoints/post-buffered-inline-comments.ts index 763dae7f..9283baf6 100644 --- a/src/entrypoints/post-buffered-inline-comments.ts +++ b/src/entrypoints/post-buffered-inline-comments.ts @@ -11,6 +11,7 @@ */ import { readFileSync } from "fs"; import { createOctokit } from "../github/api/client"; +import { redactSecrets } from "../github/utils/sanitizer"; const BUFFER_PATH = "/tmp/inline-comments-buffer.jsonl"; @@ -120,7 +121,7 @@ async function postComment( owner, repo, pull_number, - body: c.body, + body: redactSecrets(c.body), path: c.path, side: c.side || "RIGHT", commit_id: c.commit_id || headSha, diff --git a/src/mcp/github-comment-server.ts b/src/mcp/github-comment-server.ts index ef6728c9..8195eed9 100644 --- a/src/mcp/github-comment-server.ts +++ b/src/mcp/github-comment-server.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { GITHUB_API_URL } from "../github/api/config"; import { Octokit } from "@octokit/rest"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; -import { sanitizeContent } from "../github/utils/sanitizer"; +import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer"; // Get repository information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -55,7 +55,7 @@ server.tool( const isPullRequestReviewComment = eventName === "pull_request_review_comment"; - const sanitizedBody = sanitizeContent(body); + const sanitizedBody = redactSecrets(sanitizeContent(body)); const result = await updateClaudeComment(octokit, { owner, diff --git a/src/mcp/github-inline-comment-server.ts b/src/mcp/github-inline-comment-server.ts index a023d911..129fc40e 100644 --- a/src/mcp/github-inline-comment-server.ts +++ b/src/mcp/github-inline-comment-server.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { appendFileSync } from "fs"; import { z } from "zod"; import { createOctokit } from "../github/api/client"; -import { sanitizeContent } from "../github/utils/sanitizer"; +import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer"; import { removeBufferedComment } from "./inline-comment-buffer"; // Get repository and PR information from environment variables @@ -98,8 +98,8 @@ server.tool( const repo = REPO_NAME; const pull_number = parseInt(PR_NUMBER, 10); - // Sanitize the comment body to remove any potential GitHub tokens - const sanitizedBody = sanitizeContent(body); + // Sanitize the comment body to remove potential prompt injections and redact secrets + const sanitizedBody = redactSecrets(sanitizeContent(body)); // Validate that either line or both startLine and line are provided if (!line && !startLine) { diff --git a/test/public-comment-redaction.test.ts b/test/public-comment-redaction.test.ts new file mode 100644 index 00000000..4ab47086 --- /dev/null +++ b/test/public-comment-redaction.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { redactSecrets, sanitizeContent } from "../src/github/utils/sanitizer"; + +describe("Public Comment Output Sanitization & Redaction", () => { + it("redacts all credential types from public comment output", () => { + const rawComment = [ + "Here is the summary of the work done:", + "- GitHub Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + "- Anthropic Key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890", + "- AWS Access Key: AKIAIOSFODNN7EXAMPLE", + "- Slack Bot Token: xoxb-1234567890-abcdefghijkl-mnopqrstuvwx", + "- JWT Bearer: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "", + "Invisible\u200Bzero-width chars", + "![Image Alt Injection](https://example.com/pic.png)", + ].join("\n"); + + const sanitizedOutput = redactSecrets(sanitizeContent(rawComment)); + + // Ensure all secret types are redacted + expect(sanitizedOutput).not.toContain( + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + ); + expect(sanitizedOutput).not.toContain( + "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890", + ); + expect(sanitizedOutput).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(sanitizedOutput).not.toContain( + "xoxb-1234567890-abcdefghijkl-mnopqrstuvwx", + ); + expect(sanitizedOutput).not.toContain( + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + ); + + expect(sanitizedOutput).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(sanitizedOutput).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(sanitizedOutput).toContain("[REDACTED_AWS_KEY_ID]"); + expect(sanitizedOutput).toContain("[REDACTED_SLACK_TOKEN]"); + expect(sanitizedOutput).toContain("[REDACTED_JWT]"); + + // Ensure prompt injection / invisible chars / hidden tags are also sanitized + expect(sanitizedOutput).not.toContain( + "", + ); + expect(sanitizedOutput).not.toContain("\u200B"); + expect(sanitizedOutput).not.toContain("Image Alt Injection"); + expect(sanitizedOutput).toContain("![](https://example.com/pic.png)"); + }); + + it("ensures public comments have the same secret redaction coverage as logs/errors", () => { + const errorDetails = + "Error: failed to connect with sk-ant-abcdefghijklmnopqrstuvwxyz123456 and AKIAIOSFODNN7EXAMPLE"; + const commentBody = + "Report: encountered sk-ant-abcdefghijklmnopqrstuvwxyz123456 and AKIAIOSFODNN7EXAMPLE"; + + const redactedError = redactSecrets(errorDetails); + const redactedComment = redactSecrets(sanitizeContent(commentBody)); + + expect(redactedError).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(redactedError).toContain("[REDACTED_AWS_KEY_ID]"); + expect(redactedComment).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(redactedComment).toContain("[REDACTED_AWS_KEY_ID]"); + }); +}); diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index ef68a496..7b935e51 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -518,3 +518,28 @@ describe("stripHtmlComments (legacy)", () => { ); }); }); + +describe("outbound comment sanitization and redaction", () => { + it("should sanitize content and redact all credential types for public comments", () => { + const rawComment = + "Done! Configured AWS AKIAIOSFODNN7EXAMPLE, Anthropic sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890, Slack xoxb-1234567890-abcdefghijkl-mnopqrstuvwx, and GitHub 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 "; + const sanitizedAndRedacted = redactSecrets(sanitizeContent(rawComment)); + + expect(sanitizedAndRedacted).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(sanitizedAndRedacted).not.toContain( + "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890", + ); + expect(sanitizedAndRedacted).not.toContain( + "xoxb-1234567890-abcdefghijkl-mnopqrstuvwx", + ); + expect(sanitizedAndRedacted).not.toContain( + "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", + ); + expect(sanitizedAndRedacted).not.toContain("secret note"); + + expect(sanitizedAndRedacted).toContain("[REDACTED_AWS_KEY_ID]"); + expect(sanitizedAndRedacted).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(sanitizedAndRedacted).toContain("[REDACTED_SLACK_TOKEN]"); + expect(sanitizedAndRedacted).toContain("[REDACTED_GITHUB_TOKEN]"); + }); +});