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
6 changed files with 296 additions and 20 deletions
+12 -10
View File
@@ -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);
+3 -2
View File
@@ -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)
+5 -2
View File
@@ -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`;
+48 -6
View File
@@ -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]",
);