mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 11:28:55 +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:
@@ -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]",
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user