w4c-workflows-api/Services/Audit/SecretRedactor.cs

68 lines
2.9 KiB
C#
Raw Permalink Normal View History

2026-09-11 22:02:46 +00:00
using System.Text.RegularExpressions;
namespace w4c_workflows.Services.Audit;
/// <summary>
/// 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.
/// </summary>
public static class SecretRedactor
{
/// <summary>Text that replaces a redacted value.</summary>
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(
@"(?<key>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(
@"(?<scheme>\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(
@"(?<scheme>\b(?:Bearer|Basic)\s+)[A-Za-z0-9._\-+/=]{8,}",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
Timeout);
2026-09-13 08:35:17 +00:00
/// <summary>
/// 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.
/// </summary>
public static string? Redact(string? text, IEnumerable<string>? knownSecrets = null)
2026-09-11 22:02:46 +00:00
{
if (string.IsNullOrEmpty(text))
return text;
// Auth schemes run first so a following "Bearer <token>" 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}");
2026-09-13 08:35:17 +00:00
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);
}
}
2026-09-11 22:02:46 +00:00
return redacted;
}
}