diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index ef5d3cc..83ee096 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -58,6 +58,41 @@ export function sanitizeContent(content: string): string { content = stripMarkdownLinkTitles(content); content = stripHiddenAttributes(content); content = normalizeHtmlEntities(content); + content = redactGitHubTokens(content); + return content; +} + +export function redactGitHubTokens(content: string): string { + // GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) + content = content.replace( + /\bghp_[A-Za-z0-9]{36}\b/g, + "[REDACTED_GITHUB_TOKEN]", + ); + + // GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) + content = content.replace( + /\bgho_[A-Za-z0-9]{36}\b/g, + "[REDACTED_GITHUB_TOKEN]", + ); + + // GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) + content = content.replace( + /\bghs_[A-Za-z0-9]{36}\b/g, + "[REDACTED_GITHUB_TOKEN]", + ); + + // GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) + content = content.replace( + /\bghr_[A-Za-z0-9]{36}\b/g, + "[REDACTED_GITHUB_TOKEN]", + ); + + // GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars) + content = content.replace( + /\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g, + "[REDACTED_GITHUB_TOKEN]", + ); + return content; } diff --git a/src/mcp/github-comment-server.ts b/src/mcp/github-comment-server.ts index 18ab6a2..ef6728c 100644 --- a/src/mcp/github-comment-server.ts +++ b/src/mcp/github-comment-server.ts @@ -6,6 +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"; // Get repository information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -54,11 +55,13 @@ server.tool( const isPullRequestReviewComment = eventName === "pull_request_review_comment"; + const sanitizedBody = sanitizeContent(body); + const result = await updateClaudeComment(octokit, { owner, repo, commentId, - body, + body: sanitizedBody, isPullRequestReviewComment, }); diff --git a/src/mcp/github-inline-comment-server.ts b/src/mcp/github-inline-comment-server.ts index a432466..703cda2 100644 --- a/src/mcp/github-inline-comment-server.ts +++ b/src/mcp/github-inline-comment-server.ts @@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { createOctokit } from "../github/api/client"; +import { sanitizeContent } from "../github/utils/sanitizer"; // Get repository and PR information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -81,6 +82,9 @@ server.tool( const octokit = createOctokit(githubToken).rest; + // Sanitize the comment body to remove any potential GitHub tokens + const sanitizedBody = sanitizeContent(body); + // Validate that either line or both startLine and line are provided if (!line && !startLine) { throw new Error( @@ -104,7 +108,7 @@ server.tool( owner, repo, pull_number, - body, + body: sanitizedBody, path, side: side || "RIGHT", commit_id: commit_id || pr.data.head.sha, diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index f28366a..a89353b 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -7,6 +7,7 @@ import { normalizeHtmlEntities, sanitizeContent, stripHtmlComments, + redactGitHubTokens, } from "../src/github/utils/sanitizer"; describe("stripInvisibleCharacters", () => { @@ -242,6 +243,109 @@ describe("sanitizeContent", () => { }); }); +describe("redactGitHubTokens", () => { + it("should redact personal access tokens (ghp_)", () => { + const token = "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"; + expect(redactGitHubTokens(`Token: ${token}`)).toBe( + "Token: [REDACTED_GITHUB_TOKEN]", + ); + expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe( + "Here's a token: [REDACTED_GITHUB_TOKEN] in text", + ); + }); + + it("should redact OAuth tokens (gho_)", () => { + const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a"; + expect(redactGitHubTokens(`OAuth: ${token}`)).toBe( + "OAuth: [REDACTED_GITHUB_TOKEN]", + ); + }); + + it("should redact installation tokens (ghs_)", () => { + const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"; + expect(redactGitHubTokens(`Install token: ${token}`)).toBe( + "Install token: [REDACTED_GITHUB_TOKEN]", + ); + }); + + it("should redact refresh tokens (ghr_)", () => { + const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667"; + expect(redactGitHubTokens(`Refresh: ${token}`)).toBe( + "Refresh: [REDACTED_GITHUB_TOKEN]", + ); + }); + + it("should redact fine-grained tokens (github_pat_)", () => { + const token = + "github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH"; + expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe( + "Fine-grained: [REDACTED_GITHUB_TOKEN]", + ); + }); + + it("should handle tokens in code blocks", () => { + const content = `\`\`\`bash +export GITHUB_TOKEN=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 +\`\`\``; + const expected = `\`\`\`bash +export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN] +\`\`\``; + expect(redactGitHubTokens(content)).toBe(expected); + }); + + it("should handle multiple tokens in one text", () => { + const content = + "Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a"; + expect(redactGitHubTokens(content)).toBe( + "Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]", + ); + }); + + it("should handle tokens in URLs", () => { + const content = + "https://api.github.com/user?access_token=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"; + expect(redactGitHubTokens(content)).toBe( + "https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]", + ); + }); + + it("should not redact partial matches or invalid tokens", () => { + const content = + "This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890"; + expect(redactGitHubTokens(content)).toBe(content); + }); + + it("should preserve normal text", () => { + const content = "Normal text with no tokens"; + expect(redactGitHubTokens(content)).toBe(content); + }); + + it("should handle edge cases", () => { + expect(redactGitHubTokens("")).toBe(""); + expect(redactGitHubTokens("ghp_")).toBe("ghp_"); + expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short"); + }); +}); + +describe("sanitizeContent with token redaction", () => { + it("should redact tokens as part of full sanitization", () => { + const content = ` + + Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a + And invisible chars: test\u200Btoken + `; + + const sanitized = sanitizeContent(content); + + expect(sanitized).not.toContain("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"); + expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a"); + expect(sanitized).not.toContain("World")).toBe(