mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-07 03:38:30 +08:00
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:
parent
c038e4dcde
commit
0aee57ab82
@ -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);
|
||||
|
||||
@ -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<void> {
|
||||
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)
|
||||
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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]",
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 = `
|
||||
<!-- Hidden comment with token: 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 -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user