using System.Text.RegularExpressions; namespace w4c_workflows.Services.Audit; /// /// Scrubs credential-shaped text before it reaches the audit trail or logs. /// The rules are deliberately conservative: they replace the whole matched /// value, keep the field name for context, and are bounded by a regex timeout so /// a hostile input cannot stall the worker. /// public static class SecretRedactor { /// Text that replaces a redacted value. public const string Placeholder = "***"; private static readonly TimeSpan Timeout = TimeSpan.FromMilliseconds(100); // key = value / key: value / "key": "value" for common secret field names. private static readonly Regex KeyedSecret = new( @"(?authorization|api[-_]?key|apikey|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|passwd|secret|token)""?\s*[:=]\s*(?:""[^""]*""|'[^']*'|[^\s,;&]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled, Timeout); // scheme://user:password@host — strip the userinfo from URLs. private static readonly Regex UrlUserInfo = new( @"(?\b[a-z][a-z0-9+.\-]*://)[^/\s:@]+:[^/\s:@]+@", RegexOptions.IgnoreCase | RegexOptions.Compiled, Timeout); // Authorization header values that are not key=value shaped. private static readonly Regex AuthScheme = new( @"(?\b(?:Bearer|Basic)\s+)[A-Za-z0-9._\-+/=]{8,}", RegexOptions.IgnoreCase | RegexOptions.Compiled, Timeout); /// /// Returns the text with credential-shaped substrings replaced. When the /// caller knows the actual secret values (e.g. a resolved credential used in a /// request URL), passing them redacts those literals too — this is what stops /// a token embedded in a path or query from surviving in a persisted error. /// public static string? Redact(string? text, IEnumerable? knownSecrets = null) { if (string.IsNullOrEmpty(text)) return text; // Auth schemes run first so a following "Bearer " cannot survive // as the value of a keyed field. var redacted = AuthScheme.Replace(text, m => $"{m.Groups["scheme"].Value}{Placeholder}"); redacted = UrlUserInfo.Replace(redacted, m => $"{m.Groups["scheme"].Value}{Placeholder}@"); redacted = KeyedSecret.Replace(redacted, m => $"{m.Groups["key"].Value}={Placeholder}"); if (knownSecrets != null) { // Longest first so a secret that contains another is fully removed. foreach (var secret in knownSecrets .Where(s => !string.IsNullOrWhiteSpace(s) && s.Length >= 6) .Distinct(StringComparer.Ordinal) .OrderByDescending(s => s.Length)) { redacted = redacted.Replace(secret, Placeholder, StringComparison.Ordinal); } } return redacted; } }