diff --git a/DecSm.Atom.Tests/ApiSurfaceTests/PublicApiSurfaceTests.VerifyPublicApiSurface.verified.txt b/DecSm.Atom.Tests/ApiSurfaceTests/PublicApiSurfaceTests.VerifyPublicApiSurface.verified.txt index 9da7b44a..956e11bf 100644 --- a/DecSm.Atom.Tests/ApiSurfaceTests/PublicApiSurfaceTests.VerifyPublicApiSurface.verified.txt +++ b/DecSm.Atom.Tests/ApiSurfaceTests/PublicApiSurfaceTests.VerifyPublicApiSurface.verified.txt @@ -1913,6 +1913,9 @@ }, { Name: SanitizeForLogging + }, + { + Name: SanitizeSecrets } ] }, diff --git a/DecSm.Atom/Logging/MaskingAnsiConsoleOutput.cs b/DecSm.Atom/Logging/MaskingAnsiConsoleOutput.cs index 26a6168d..f7c45000 100644 --- a/DecSm.Atom/Logging/MaskingAnsiConsoleOutput.cs +++ b/DecSm.Atom/Logging/MaskingAnsiConsoleOutput.cs @@ -63,12 +63,7 @@ public override void Write(char value) => public override void Write(string? value) { if (value is { Length: > 0 }) - { - var masker = ServiceStaticAccessor.Service; - - if (masker is not null) - value = masker.MaskMatchingSecrets(value); - } + value = ServiceStaticAccessor.Service?.MaskMatchingSecrets(value) ?? value; _innerWriter.Write(value); } diff --git a/DecSm.Atom/Logging/SpectreLogger.cs b/DecSm.Atom/Logging/SpectreLogger.cs index 8efefa88..1f065502 100644 --- a/DecSm.Atom/Logging/SpectreLogger.cs +++ b/DecSm.Atom/Logging/SpectreLogger.cs @@ -31,7 +31,6 @@ public IDisposable BeginScope(TState state) /// /// This method formats log messages based on their level, with colors and prefixes. It filters out Trace and Debug /// messages unless verbose logging is enabled. It also handles special formatting for process output and exceptions. - /// Secrets are not masked here; the custom Spectre console output handles masking. /// public void Log( LogLevel logLevel, @@ -121,6 +120,9 @@ public void Log( if (message is "(null)") return; + // If the message contains any secrets, we want to mask them + message = ServiceStaticAccessor.Service?.MaskMatchingSecrets(message) ?? message; + message = message.EscapeMarkup(); if (processOutput) @@ -131,7 +133,7 @@ public void Log( : "dim"; var columns = new Columns(new Text(" "), - new Markup($"[{messageStyle}]{message.EscapeMarkup()}[/]").LeftJustified()).Collapse(); + new Markup($"[{messageStyle}]{message}[/]").LeftJustified()).Collapse(); ServiceStaticAccessor.Service?.Write(columns); diff --git a/DecSm.Atom/Params/ParamService.cs b/DecSm.Atom/Params/ParamService.cs index 935a1482..8fbb6a1e 100644 --- a/DecSm.Atom/Params/ParamService.cs +++ b/DecSm.Atom/Params/ParamService.cs @@ -77,7 +77,7 @@ public interface IParamService GetParam(paramName, defaultValue); /// - /// Replaces known secret values in the provided text with a mask ("*****"). + /// Replaces known secret values in the provided text with a mask (e.g. "*****"). /// /// The text to scan and mask. /// The text with any resolved secret values replaced by a mask. @@ -157,10 +157,7 @@ public IDisposable CreateOverrideSourcesScope(ParamSource sources) => /// public string MaskMatchingSecrets(string text) => - _knownSecrets.Aggregate(text, - (current, knownSecret) => knownSecret is { Length: > 0 } - ? current.Replace(knownSecret, "*****", StringComparison.OrdinalIgnoreCase) - : current); + text.SanitizeSecrets(_knownSecrets); /// public T? GetParam<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( diff --git a/DecSm.Atom/Util/StringUtil.cs b/DecSm.Atom/Util/StringUtil.cs index 60eac13a..b0b418a1 100644 --- a/DecSm.Atom/Util/StringUtil.cs +++ b/DecSm.Atom/Util/StringUtil.cs @@ -91,5 +91,35 @@ public int GetLevenshteinDistance(string? compareTo) return @string[..maxLength] + "..."; } + + /// + /// Sanitizes a string by replacing specified secret substrings with asterisks or a fixed mask, ensuring that the + /// secrets are not exposed in logs or outputs. + /// + /// + /// A list of secret substrings to be sanitized from the input string. Secrets that are null or empty + /// will be ignored. + /// + /// + /// The sanitized string with secrets replaced, or null if the input string was null or empty, or if no + /// valid secrets were provided. If the input string is shorter than the shortest valid secret, it will be returned + /// unchanged. + /// + [return: NotNullIfNotNull(nameof(@string))] + public string? SanitizeSecrets(List secrets) + { + var validSecrets = secrets + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + + return @string is null or "" || validSecrets.Count is 0 || @string.Length < validSecrets.Min(s => s.Length) + ? @string + : validSecrets.Aggregate(@string, + static (current, secret) => current.Replace(secret, + secret.Length < 5 + ? new('*', secret.Length) + : "*****", + StringComparison.OrdinalIgnoreCase)); + } } }