Files
multica/server/pkg/redact/redact.go
Rusty Raven 5f767d671a fix(redact): cover credential formats that leaked unredacted (#5274)
The GitHub-token rule only matched classic tokens (ghp_/gho_/ghu_/ghs_/ghr_),
even though its comment claims to cover fine-grained tokens. Add coverage for
GitHub fine-grained PATs (github_pat_), Slack app-level (xapp-) / config (xoxe-),
Google API keys (AIza...), and Stripe live secret/restricted keys (sk_live_/rk_live_).
Publishable Stripe keys (pk_live_) are intentionally NOT redacted (public).

Adds regression tests for every new shape, including a positive test that
pk_live_ stays unredacted.
2026-07-13 11:59:28 +08:00

120 lines
4.6 KiB
Go

// Package redact provides functions for detecting and masking secrets
// in agent output before it reaches the database or WebSocket broadcast.
package redact
import (
"os"
"os/user"
"regexp"
"strings"
)
// secretPattern pairs a compiled regex with its replacement text.
type secretPattern struct {
re *regexp.Regexp
replacement string
}
// Patterns are checked in order; first match wins per position.
var patterns = []secretPattern{
// AWS access key IDs (always start with AKIA)
{regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), "[REDACTED AWS KEY]"},
// AWS secret access keys (40 char base64-ish, preceded by a common separator)
{regexp.MustCompile(`(?i)(?:aws_secret_access_key|secret_?access_?key)\s*[=:]\s*[A-Za-z0-9/+=]{40}`), "[REDACTED AWS SECRET]"},
// PEM private keys (multi-line)
{regexp.MustCompile(`(?s)-----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----`), "[REDACTED PRIVATE KEY]"},
// GitHub tokens (classic PAT, OAuth, user-to-server, server-to-server, refresh)
{regexp.MustCompile(`\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b`), "[REDACTED GITHUB TOKEN]"},
// GitHub fine-grained personal access tokens use the github_pat_ prefix,
// which the classic ghp_/gho_/... pattern above does not cover. Without
// this line a fine-grained PAT emitted in agent output leaks unredacted
// to the database and WebSocket broadcast.
{regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{20,255}\b`), "[REDACTED GITHUB TOKEN]"},
// OpenAI / Anthropic API keys
{regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}\b`), "[REDACTED API KEY]"},
// Slack bot/user/legacy tokens. The char class includes 'e' so the
// newer xoxe- config/refresh tokens are covered alongside xoxb/p/o/r/a/s.
{regexp.MustCompile(`\bxox[bporase]-[A-Za-z0-9\-]{10,}\b`), "[REDACTED SLACK TOKEN]"},
// Slack app-level tokens use the xapp- prefix, which the xox*- rule above
// does not match. Without this an app-level token echoed in agent output
// leaks unredacted to the DB / WebSocket broadcast.
{regexp.MustCompile(`\bxapp-[A-Za-z0-9-]{10,}\b`), "[REDACTED SLACK TOKEN]"},
// GitLab personal access tokens
{regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{20,}\b`), "[REDACTED GITLAB TOKEN]"},
// Google API keys always start with the AIza prefix and are 39 chars total
// (AIza + 35). Not covered by any rule above, so they would otherwise leak.
{regexp.MustCompile(`\bAIza[0-9A-Za-z_\-]{35}\b`), "[REDACTED GOOGLE API KEY]"},
// Stripe secret / restricted live keys (sk_live_ / rk_live_). The sk-
// rule above only matches the hyphen form used by OpenAI/Anthropic; Stripe
// uses an underscore, so live keys are not covered without this. Publishable
// keys (pk_live_) are intentionally excluded — they are not secret.
{regexp.MustCompile(`\b(?:sk|rk)_live_[0-9A-Za-z]{16,}\b`), "[REDACTED STRIPE KEY]"},
// JWT tokens (three base64url segments)
{regexp.MustCompile(`\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`), "[REDACTED JWT]"},
// Generic "Bearer <token>" in output
{regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b`), "Bearer [REDACTED]"},
// Connection strings with embedded passwords
{regexp.MustCompile(`(?i)(?:postgres|mysql|mongodb|redis|amqp)(?:ql)?://[^:\s]+:[^@\s]+@`), "[REDACTED CONNECTION STRING]@"},
// Generic key=value patterns for common secret env var names
{regexp.MustCompile(`(?i)(?:API_KEY|API_SECRET|SECRET_KEY|SECRET|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|DB_URL|REDIS_URL|PASSWORD|TOKEN)\s*[=:]\s*\S+`), "[REDACTED CREDENTIAL]"},
}
// InputMap returns a copy of m with all string values passed through Text.
// Non-string values are preserved as-is.
func InputMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
if s, ok := v.(string); ok {
out[k] = Text(s)
} else {
out[k] = v
}
}
return out
}
// homeDir is resolved once at init for path redaction.
var homeDir string
var username string
func init() {
homeDir, _ = os.UserHomeDir()
if u, err := user.Current(); err == nil {
username = u.Username
}
}
// Text scans the input string for known secret patterns and replaces
// matches with safe placeholders. It also masks the local user's home
// directory path to prevent leaking the username.
func Text(s string) string {
for _, p := range patterns {
s = p.re.ReplaceAllString(s, p.replacement)
}
// Redact home directory paths (e.g. /Users/john/ → /Users/****/).
if homeDir != "" && username != "" {
masked := strings.Replace(homeDir, username, "****", 1)
s = strings.ReplaceAll(s, homeDir, masked)
}
return s
}