Redact common credential patterns from published run output (#1595)

* Redact common credential patterns from published run output

* Handle color codes and escape sequences ahead of redacted values

Vendor-prefixed formats no longer require a leading word boundary, so a
value that follows an ANSI SGR terminator or a serialized JSON escape is
still matched. AWS key ids keep a boundary but also accept those cases.
sanitizeContent goes back to GitHub-only redaction for inbound content,
and the failure annotation is redacted like the tracking comment.

* Coerce non-string text content before redacting tool results

No-Verification-Needed: one-line coercion in a formatting helper plus regression test
This commit is contained in:
Ashwin Bhat 2026-08-06 10:17:25 -07:00 committed by GitHub
parent c038e4dcde
commit 0aee57ab82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 296 additions and 20 deletions

View File

@ -2,6 +2,7 @@
import { readFileSync, existsSync } from "fs"; import { readFileSync, existsSync } from "fs";
import { exit } from "process"; import { exit } from "process";
import { redactSecrets } from "../github/utils/sanitizer";
export type ToolUse = { export type ToolUse = {
type: string; type: string;
@ -163,8 +164,9 @@ export function formatResultContent(content: any): string {
typeof parsedContent[0] === "object" && typeof parsedContent[0] === "object" &&
parsedContent[0]?.type === "text" parsedContent[0]?.type === "text"
) { ) {
// Extract the text field from the first item // Extract the text field from the first item. Tool output is arbitrary,
contentStr = parsedContent[0]?.text || ""; // so `text` is not guaranteed to be a string.
contentStr = String(parsedContent[0]?.text || "");
} else { } else {
contentStr = String(content).trim(); contentStr = String(content).trim();
} }
@ -172,6 +174,10 @@ export function formatResultContent(content: any): string {
contentStr = String(content).trim(); 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 // Truncate very long results
if (contentStr.length > 3000) { if (contentStr.length > 3000) {
contentStr = contentStr.substring(0, 2997) + "..."; contentStr = contentStr.substring(0, 2997) + "...";
@ -420,7 +426,9 @@ export function formatTurnsFromData(data: Turn[]): string {
// Generate markdown // Generate markdown
const markdown = formatGroupedContent(groupedContent); 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 { function main(): void {
@ -447,14 +455,8 @@ function main(): void {
const fileContent = readFileSync(jsonFile, "utf-8"); const fileContent = readFileSync(jsonFile, "utf-8");
const data: Turn[] = JSON.parse(fileContent); 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) // Print to stdout (so it can be captured by shell)
console.log(markdown); console.log(formatTurnsFromData(data));
} catch (error) { } catch (error) {
console.error(`Error processing file: ${error}`); console.error(`Error processing file: ${error}`);
exit(1); exit(1);

View File

@ -34,6 +34,7 @@ import { collectActionInputsPresence } from "./collect-inputs";
import { updateCommentLink } from "./update-comment-link"; import { updateCommentLink } from "./update-comment-link";
import { formatTurnsFromData } from "./format-turns"; import { formatTurnsFromData } from "./format-turns";
import type { Turn } from "./format-turns"; import type { Turn } from "./format-turns";
import { redactSecrets } from "../github/utils/sanitizer";
// Base-action imports (used directly instead of subprocess) // Base-action imports (used directly instead of subprocess)
import { setupWorkloadIdentity } from "../../base-action/src/workload-identity"; import { setupWorkloadIdentity } from "../../base-action/src/workload-identity";
import type { WorkloadIdentityHandle } 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<void> {
fallback += fallback +=
"Failed to format output (please report). Here's the raw JSON:\n\n"; "Failed to format output (please report). Here's the raw JSON:\n\n";
fallback += "```json\n"; fallback += "```json\n";
fallback += readFileSync(executionFile, "utf-8"); fallback += redactSecrets(readFileSync(executionFile, "utf-8"));
fallback += "\n```\n"; fallback += "\n```\n";
await appendFile(summaryFile, fallback); await appendFile(summaryFile, fallback);
} catch { } catch {
@ -317,7 +318,7 @@ async function run() {
prepareSuccess = false; prepareSuccess = false;
prepareError = errorMessage; prepareError = errorMessage;
} }
core.setFailed(`Action failed with error: ${errorMessage}`); core.setFailed(`Action failed with error: ${redactSecrets(errorMessage)}`);
} finally { } finally {
// Phase 4: Cleanup (always runs) // Phase 4: Cleanup (always runs)

View File

@ -1,4 +1,5 @@
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
import { redactSecrets } from "../utils/sanitizer";
export type ExecutionDetails = { export type ExecutionDetails = {
total_cost_usd?: number; 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 // Build the new body with blank line between header and separator
let newBody = `${header}${links}`; 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) { if (actionFailed && errorDetails) {
newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``; newBody += `\n\n\`\`\`\n${redactSecrets(errorDetails)}\n\`\`\``;
} }
newBody += `\n\n---\n`; newBody += `\n\n---\n`;

View File

@ -76,40 +76,82 @@ export function sanitizeContent(content: string): string {
return content; 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 { export function redactGitHubTokens(content: string): string {
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) // GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace( content = content.replace(
/\bghp_[A-Za-z0-9]{36}\b/g, /ghp_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]", "[REDACTED_GITHUB_TOKEN]",
); );
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) // GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace( content = content.replace(
/\bgho_[A-Za-z0-9]{36}\b/g, /gho_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]", "[REDACTED_GITHUB_TOKEN]",
); );
// GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) // GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace( content = content.replace(
/\bghu_[A-Za-z0-9]{36}\b/g, /ghu_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]", "[REDACTED_GITHUB_TOKEN]",
); );
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) // GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace( content = content.replace(
/\bghs_[A-Za-z0-9]{36}\b/g, /ghs_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]", "[REDACTED_GITHUB_TOKEN]",
); );
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) // GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace( content = content.replace(
/\bghr_[A-Za-z0-9]{36}\b/g, /ghr_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]", "[REDACTED_GITHUB_TOKEN]",
); );
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars) // GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
content = content.replace( 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]", "[REDACTED_GITHUB_TOKEN]",
); );

View File

@ -467,6 +467,15 @@ describe("formatResultContent non-string input", () => {
expect(typeof result).toBe("string"); expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0); 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", () => { describe("system_other handling", () => {
@ -515,3 +524,106 @@ describe("system_other handling", () => {
expect(result).toContain("## 🚀 System Initialization"); 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);
});
});

View File

@ -8,6 +8,7 @@ import {
sanitizeContent, sanitizeContent,
stripHtmlComments, stripHtmlComments,
redactGitHubTokens, redactGitHubTokens,
redactSecrets,
} from "../src/github/utils/sanitizer"; } from "../src/github/utils/sanitizer";
describe("stripInvisibleCharacters", () => { 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", () => { 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", () => { it("should redact tokens as part of full sanitization", () => {
const content = ` const content = `
<!-- Hidden comment with token: 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 --> <!-- Hidden comment with token: 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 -->