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;
|
|
|
|
|
}
|
|
|
|
|
}
|