diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts index c18ab49f..88dd587c 100644 --- a/src/entrypoints/format-turns.ts +++ b/src/entrypoints/format-turns.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "fs"; import { exit } from "process"; +import { redactSecrets } from "../github/utils/sanitizer"; export type ToolUse = { type: string; @@ -163,8 +164,9 @@ export function formatResultContent(content: any): string { typeof parsedContent[0] === "object" && parsedContent[0]?.type === "text" ) { - // Extract the text field from the first item - contentStr = parsedContent[0]?.text || ""; + // Extract the text field from the first item. Tool output is arbitrary, + // so `text` is not guaranteed to be a string. + contentStr = String(parsedContent[0]?.text || ""); } else { contentStr = String(content).trim(); } @@ -172,6 +174,10 @@ export function formatResultContent(content: any): string { contentStr = String(content).trim(); } + // Redact before truncating so a credential cannot be split at the cut and + // slip past the final redaction pass. + contentStr = redactSecrets(contentStr); + // Truncate very long results if (contentStr.length > 3000) { contentStr = contentStr.substring(0, 2997) + "..."; @@ -420,7 +426,9 @@ export function formatTurnsFromData(data: Turn[]): string { // Generate markdown const markdown = formatGroupedContent(groupedContent); - return markdown; + // Runtime output may contain credentials that are not registered as + // workflow secrets, so redact known formats before this gets published. + return redactSecrets(markdown); } function main(): void { @@ -447,14 +455,8 @@ function main(): void { const fileContent = readFileSync(jsonFile, "utf-8"); const data: Turn[] = JSON.parse(fileContent); - // Group turns naturally - const groupedContent = groupTurnsNaturally(data); - - // Generate markdown - const markdown = formatGroupedContent(groupedContent); - // Print to stdout (so it can be captured by shell) - console.log(markdown); + console.log(formatTurnsFromData(data)); } catch (error) { console.error(`Error processing file: ${error}`); exit(1); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 3a091a67..b02c374a 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -34,6 +34,7 @@ import { collectActionInputsPresence } from "./collect-inputs"; import { updateCommentLink } from "./update-comment-link"; import { formatTurnsFromData } from "./format-turns"; import type { Turn } from "./format-turns"; +import { redactSecrets } from "../github/utils/sanitizer"; // Base-action imports (used directly instead of subprocess) import { setupWorkloadIdentity } from "../../base-action/src/workload-identity"; import type { WorkloadIdentityHandle } from "../../base-action/src/workload-identity"; @@ -137,7 +138,7 @@ async function writeStepSummary(executionFile: string): Promise { fallback += "Failed to format output (please report). Here's the raw JSON:\n\n"; fallback += "```json\n"; - fallback += readFileSync(executionFile, "utf-8"); + fallback += redactSecrets(readFileSync(executionFile, "utf-8")); fallback += "\n```\n"; await appendFile(summaryFile, fallback); } catch { @@ -317,7 +318,7 @@ async function run() { prepareSuccess = false; prepareError = errorMessage; } - core.setFailed(`Action failed with error: ${errorMessage}`); + core.setFailed(`Action failed with error: ${redactSecrets(errorMessage)}`); } finally { // Phase 4: Cleanup (always runs) diff --git a/src/github/operations/comment-logic.ts b/src/github/operations/comment-logic.ts index 03b5d86c..db542141 100644 --- a/src/github/operations/comment-logic.ts +++ b/src/github/operations/comment-logic.ts @@ -1,4 +1,5 @@ import { GITHUB_SERVER_URL } from "../api/config"; +import { redactSecrets } from "../utils/sanitizer"; export type ExecutionDetails = { total_cost_usd?: number; @@ -181,9 +182,11 @@ export function updateCommentBody(input: CommentUpdateInput): string { // Build the new body with blank line between header and separator let newBody = `${header}${links}`; - // Add error details if available + // Add error details if available. The message may embed runtime credentials + // (e.g. a token in a git remote URL) that are not registered as workflow + // secrets, so redact known formats before posting. if (actionFailed && errorDetails) { - newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``; + newBody += `\n\n\`\`\`\n${redactSecrets(errorDetails)}\n\`\`\``; } newBody += `\n\n---\n`; diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index 47f60abb..ac8b863c 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -76,40 +76,82 @@ export function sanitizeContent(content: string): string { return content; } +/** + * Redact well-known credential formats (GitHub, Anthropic, AWS, Slack, JWTs) + * from arbitrary text. Callers don't need to know which vendor a value belongs to. + * + * Vendor-prefixed formats are matched without a leading word boundary: the + * prefix already anchors them, and runtime output frequently puts a word + * character directly against the value (e.g. an ANSI color code ending in `m`, + * or a serialized JSON escape such as `\n`). + */ +export function redactSecrets(content: string): string { + content = redactGitHubTokens(content); + + // Anthropic API keys: sk-ant-... + content = content.replace( + /sk-ant-[A-Za-z0-9_-]{20,}/g, + "[REDACTED_ANTHROPIC_KEY]", + ); + + // AWS access key ids: AKIA/ASIA followed by 16 uppercase alphanumerics. All + // uppercase alphanumeric, so keep a leading boundary to avoid matching inside + // larger blobs; also treat a JSON escape or ANSI color code as a boundary. + content = content.replace( + /(?:\b|(?<=\\(?:[nrtbf"\\/]|u[0-9a-fA-F]{4}))|(?<=\[[0-9;]*m))(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + "[REDACTED_AWS_KEY_ID]", + ); + + // Slack tokens: xoxb-, xoxp-, xoxa-, xoxs-, xoxr- + content = content.replace( + /xox[abpsr]-[A-Za-z0-9-]{10,}/g, + "[REDACTED_SLACK_TOKEN]", + ); + + // JWT-shaped strings: three base64url segments, the first two starting + // with eyJ (base64 of `{"`). + content = content.replace( + /eyJ[A-Za-z0-9_-]{10,2000}\.eyJ[A-Za-z0-9_-]{10,4000}\.[A-Za-z0-9_-]{10,2000}\b/g, + "[REDACTED_JWT]", + ); + + 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, + /ghp_[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, + /gho_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]", ); // GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( - /\bghu_[A-Za-z0-9]{36}\b/g, + /ghu_[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, + /ghs_[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, + /ghr_[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, + /github_pat_[A-Za-z0-9_]{11,221}\b/g, "[REDACTED_GITHUB_TOKEN]", ); diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts index 7b59bbe4..28fc0db9 100644 --- a/test/format-turns.test.ts +++ b/test/format-turns.test.ts @@ -467,6 +467,15 @@ describe("formatResultContent non-string input", () => { expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); }); + + test("handles a text content block whose text field is not a string", () => { + expect(() => + formatResultContent('[{"type":"text","text":{"foo":"bar"}}]'), + ).not.toThrow(); + expect(formatResultContent('[{"type":"text","text":123}]')).toContain( + "123", + ); + }); }); describe("system_other handling", () => { @@ -515,3 +524,106 @@ describe("system_other handling", () => { expect(result).toContain("## 🚀 System Initialization"); }); }); + +describe("credential redaction", () => { + test("redacts credentials embedded in tool results", () => { + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "Bash", + input: { command: "cat .env" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: + "GITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", + }, + ], + }, + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(result).toContain("[REDACTED_AWS_KEY_ID]"); + expect(result).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"); + expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE"); + }); + + test("redacts credentials embedded in multi-line tool inputs", () => { + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "toolu_2", + name: "Write", + input: { + file_path: ".env", + content: + "AWS_ACCESS_KEY_ID=x\nGITHUB_TOKEN=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\n", + }, + }, + ], + }, + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(result).not.toContain("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"); + }); + + test("redacts credentials wrapped in ANSI color codes", () => { + const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh"; + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "toolu_3", + name: "Bash", + input: { command: "node print-config.js" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "toolu_3", + content: `apiKey: \x1b[32m${key}\x1b[39m\nregion: us-east-1`, + }, + ], + }, + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(result).not.toContain(key); + }); +}); diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index 2cb7e301..ef68a496 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -8,6 +8,7 @@ import { sanitizeContent, stripHtmlComments, redactGitHubTokens, + redactSecrets, } from "../src/github/utils/sanitizer"; describe("stripInvisibleCharacters", () => { @@ -368,7 +369,122 @@ export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN] }); }); +describe("redactSecrets", () => { + it("should still redact GitHub tokens", () => { + expect( + redactSecrets("Token: ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"), + ).toBe("Token: [REDACTED_GITHUB_TOKEN]"); + }); + + it("should redact Anthropic API keys (sk-ant-)", () => { + const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh"; + expect(redactSecrets(`ANTHROPIC_API_KEY=${key}`)).toBe( + "ANTHROPIC_API_KEY=[REDACTED_ANTHROPIC_KEY]", + ); + }); + + it("should not redact sk- strings that are not sk-ant-", () => { + const content = + "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 and sk-ant-short"; + expect(redactSecrets(content)).toBe(content); + }); + + it("should redact AWS access key ids", () => { + expect(redactSecrets("aws_access_key_id = AKIAIOSFODNN7EXAMPLE")).toBe( + "aws_access_key_id = [REDACTED_AWS_KEY_ID]", + ); + expect(redactSecrets("temp creds ASIAIOSFODNN7EXAMPLE end")).toBe( + "temp creds [REDACTED_AWS_KEY_ID] end", + ); + }); + + it("should not redact AWS-like strings that do not fit the format", () => { + const content = + "AKIAtest AKIA123 AKIAIOSFODNN7EXAMPLEXYZ akiaiosfodnn7example"; + expect(redactSecrets(content)).toBe(content); + }); + + it("should redact Slack tokens", () => { + expect(redactSecrets("token=xoxb-1234567890-abcdefghijkl")).toBe( + "token=[REDACTED_SLACK_TOKEN]", + ); + expect(redactSecrets("xoxp-1234567890-1234567890-abc")).toBe( + "[REDACTED_SLACK_TOKEN]", + ); + }); + + it("should not redact xox strings that do not fit the format", () => { + const content = "xoxo-1234567890abc xoxz-1234567890abc xoxb-short"; + expect(redactSecrets(content)).toBe(content); + }); + + it("should redact JWT-shaped strings", () => { + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + expect(redactSecrets(`Authorization: Bearer ${jwt}`)).toBe( + "Authorization: Bearer [REDACTED_JWT]", + ); + }); + + it("should redact tokens that follow a JSON escape sequence", () => { + const serialized = JSON.stringify({ + content: + "line one\nGITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\tsk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWx", + }); + const redacted = redactSecrets(serialized); + expect(redacted).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(redacted).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(redacted).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"); + }); + + it("should redact tokens preceded by ANSI color codes", () => { + const ghp = "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"; + const anthropic = + "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh"; + expect(redactSecrets(`\x1b[31m${ghp}\x1b[0m`)).toBe( + "\x1b[31m[REDACTED_GITHUB_TOKEN]\x1b[0m", + ); + expect(redactSecrets(`key=\x1b[32m${anthropic}\x1b[39m`)).toBe( + "key=\x1b[32m[REDACTED_ANTHROPIC_KEY]\x1b[39m", + ); + expect(redactSecrets(`\x1b[1mAKIAIOSFODNN7EXAMPLE\x1b[0m`)).toBe( + "\x1b[1m[REDACTED_AWS_KEY_ID]\x1b[0m", + ); + }); + + it("should redact tokens that follow other JSON escapes", () => { + const ghp = "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"; + const serialized = JSON.stringify({ + colored: `\x1b[31m${ghp}`, + formfeed: `\f${ghp}`, + quoted: `"AKIAIOSFODNN7EXAMPLE"`, + }); + const redacted = redactSecrets(serialized); + expect(redacted).not.toContain(ghp); + expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(redacted).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(redacted).toContain("[REDACTED_AWS_KEY_ID]"); + }); + + it("should not redact base64 blobs that are not JWTs", () => { + // Long base64 without dots, and two-segment strings, are left alone + const content = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9eyJzdWIiOiIxMjM0NTY3ODkw " + + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 " + + "aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgYSBqd3Q="; + expect(redactSecrets(content)).toBe(content); + }); +}); + describe("sanitizeContent with token redaction", () => { + it("should only redact GitHub tokens from inbound content", () => { + const content = + "docs example key AKIAIOSFODNN7EXAMPLE and token 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"; + expect(sanitizeContent(content)).toBe( + "docs example key AKIAIOSFODNN7EXAMPLE and token [REDACTED_GITHUB_TOKEN]", + ); + }); + it("should redact tokens as part of full sanitization", () => { const content = `