namespace ZB.MOM.WW.ScadaBridge.NotificationService; /// /// NS-009: Scrubs SMTP credential secrets out of free text (typically exception /// messages echoed back by an SMTP server) before that text is written to a log. /// MailKit authentication exceptions can contain server responses that quote the /// supplied credentials; this prevents a password, client secret, or OAuth2 token /// from leaking into the operational logs. /// /// Public so the central Notification Outbox's EmailNotificationDeliveryAdapter /// can share this exact redaction logic rather than carry a divergent copy. /// /// public static class CredentialRedactor { private const string Mask = "***REDACTED***"; /// /// Returns with every secret component of the supplied /// colon-delimited credential string masked. /// /// The text to scrub (e.g. an exception message). /// /// The credential string in use — Basic Auth user:pass or OAuth2 /// tenantId:clientId:clientSecret. May be null. /// /// /// NS-025: minimum length for a colon-separated SECRET component to be /// considered worth masking. Twelve characters is the standard heuristic /// for "long enough to be a password / client secret"; shorter components /// (e.g. a 4-char user name like root, or a 7-char "from" alias) /// would mask too much unrelated diagnostic text if treated as secrets. /// private const int MinSecretLength = 12; public static string Scrub(string? text, string? credentials) { if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials)) { return text ?? string.Empty; } var result = text; // NS-025: redact only the obviously-secret slots — the LAST // colon-separated component (the password in Basic, the client // secret in OAuth2) and the whole packed string — not the user // name / tenant id / client id. A short user name like "root" or // a sender alias like "smtp" no longer becomes a global redaction // token that eats unrelated path / error text. var secretsToRedact = new List(); // The full packed credential is always the most-sensitive shape. secretsToRedact.Add(credentials); // The trailing colon-component is the password / clientSecret slot. // Only redact it if it's plausibly secret-shaped (>= MinSecretLength). var parts = credentials.Split(':'); if (parts.Length >= 2) { var lastComponent = parts[^1]; if (lastComponent.Length >= MinSecretLength) { secretsToRedact.Add(lastComponent); } } // Order longest first so a secret that is a substring of the packed // string is still fully masked. var ordered = secretsToRedact .Distinct() .OrderByDescending(s => s.Length); foreach (var part in ordered) { result = result.Replace(part, Mask, StringComparison.Ordinal); } return result; } }