diff --git a/src/ModularPipelines/Console/ModuleOutputBuffer.cs b/src/ModularPipelines/Console/ModuleOutputBuffer.cs index df990b54f9..92f99339d6 100644 --- a/src/ModularPipelines/Console/ModuleOutputBuffer.cs +++ b/src/ModularPipelines/Console/ModuleOutputBuffer.cs @@ -813,10 +813,13 @@ public void WriteTo(ILogger logger) public string? FormatException() => _obfuscatedException is null ? null - : secretObfuscator.Obfuscate(_obfuscatedException.ToString(), null); + : Obfuscate(_obfuscatedException.ToString()); private string Format(object? state, Exception? logException) - => secretObfuscator.Obfuscate(_rawFormattedMessage.Value, null) ?? string.Empty; + => Obfuscate(_rawFormattedMessage.Value); + + private string Obfuscate(string value) + => secretObfuscator.Obfuscate(value, null); private string FormatTyped(TState state, Exception? logException) => Format(state!, logException); diff --git a/src/ModularPipelines/Engine/ISecretObfuscator.cs b/src/ModularPipelines/Engine/ISecretObfuscator.cs index 06dcbd92ac..4abfaafad0 100644 --- a/src/ModularPipelines/Engine/ISecretObfuscator.cs +++ b/src/ModularPipelines/Engine/ISecretObfuscator.cs @@ -5,6 +5,15 @@ namespace ModularPipelines.Engine; /// public interface ISecretObfuscator { + /// + /// Gets whether any secrets are currently registered for global masking. + /// + /// + /// This is a performance hint only. Callers must not use it to bypass safety or masking + /// behavior that custom implementations may provide. + /// + bool HasSecrets => true; + /// /// Obfuscates sensitive information in the provided input. /// @@ -12,4 +21,4 @@ public interface ISecretObfuscator /// An options object that may contain sensitive properties. /// The input with sensitive information obfuscated. string Obfuscate(string? input, object? optionsObject); -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Engine/SecretObfuscator.cs b/src/ModularPipelines/Engine/SecretObfuscator.cs index 1092cb0c71..3e709839e3 100644 --- a/src/ModularPipelines/Engine/SecretObfuscator.cs +++ b/src/ModularPipelines/Engine/SecretObfuscator.cs @@ -34,6 +34,8 @@ internal class SecretObfuscator : ISecretObfuscator, IInitializer public int Order => int.MaxValue; + public bool HasSecrets => GetRegistrationState().HasSecrets; + public SecretObfuscator( ISecretProvider secretProvider, IOptions maskingOptions) @@ -78,6 +80,12 @@ public string Obfuscate(string? input, object? optionsObject) caseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } + internal SecretRegistrationState GetRegistrationState() + { + var cache = GetRegisteredSecretCache(_maskingOptions.Value.CaseInsensitive); + return new SecretRegistrationState(cache.Version, cache.SearchValues is not null); + } + internal SecretCache GetSecretCache( object? optionsObject, SecretMaskingOptions options, @@ -257,4 +265,6 @@ internal sealed record SecretCache( string[] Secrets, IReadOnlySet ExactSecrets, SearchValues? SearchValues); + + internal readonly record struct SecretRegistrationState(long Version, bool HasSecrets); } diff --git a/src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs b/src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs index 74e9d64be6..055171d403 100644 --- a/src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs +++ b/src/ModularPipelines/Logging/FormattedLogValuesObfuscator.cs @@ -30,10 +30,35 @@ public FormattedLogValuesObfuscator(ISecretObfuscator secretObfuscator) } public object TryObfuscateValues(object state) + { + // HasSecrets is only a hint; custom obfuscators may still apply policy-based masking. + if (_secretObfuscator is not SecretObfuscator builtInObfuscator) + { + return TryObfuscateValues(state, skipOrdinaryValues: false); + } + + var registrationState = builtInObfuscator.GetRegistrationState(); + var skipOrdinaryValues = !registrationState.HasSecrets; + var obfuscatedState = TryObfuscateValues(state, skipOrdinaryValues); + + if (!skipOrdinaryValues) + { + return obfuscatedState; + } + + // A secret may be registered while the zero-secret fast path traverses the state. + var currentRegistrationState = builtInObfuscator.GetRegistrationState(); + return currentRegistrationState.Version != registrationState.Version + && currentRegistrationState.HasSecrets + ? TryObfuscateValues(state, skipOrdinaryValues: false) + : obfuscatedState; + } + + private object TryObfuscateValues(object state, bool skipOrdinaryValues) { if (state is not IReadOnlyList> values) { - return ObfuscateValue(state); + return skipOrdinaryValues ? state : ObfuscateValue(state); } KeyValuePair[]? obfuscatedValues = null; @@ -46,7 +71,20 @@ public object TryObfuscateValues(object state) continue; } - var obfuscatedValue = ObfuscateValue(property.Value); + object obfuscatedValue; + if (property.Value is PreObfuscatedLogValue preObfuscatedValue) + { + obfuscatedValue = preObfuscatedValue.Value; + } + else if (!skipOrdinaryValues) + { + obfuscatedValue = ObfuscateValue(property.Value); + } + else + { + continue; + } + if (ReferenceEquals(obfuscatedValue, property.Value)) { continue; @@ -69,7 +107,7 @@ private object ObfuscateValue(object value) string originalValue; try { - originalValue = value.ToString() ?? string.Empty; + originalValue = value as string ?? value.ToString() ?? string.Empty; } catch (Exception) { @@ -77,7 +115,8 @@ private object ObfuscateValue(object value) } var obfuscatedValue = _secretObfuscator.Obfuscate(originalValue, null); - return obfuscatedValue.Equals(originalValue, StringComparison.Ordinal) + return ReferenceEquals(obfuscatedValue, originalValue) + || obfuscatedValue.Equals(originalValue, StringComparison.Ordinal) ? value : obfuscatedValue; } diff --git a/test/ModularPipelines.UnitTests/Console/ModuleOutputBufferTests.cs b/test/ModularPipelines.UnitTests/Console/ModuleOutputBufferTests.cs index c3751aae66..47ade244ff 100644 --- a/test/ModularPipelines.UnitTests/Console/ModuleOutputBufferTests.cs +++ b/test/ModularPipelines.UnitTests/Console/ModuleOutputBufferTests.cs @@ -203,6 +203,7 @@ public async Task BufferedLogEvent_FormatsOnceAndObfuscatesEveryTime() { var formatterCalls = 0; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => value?.Replace("secret", "***") ?? string.Empty); @@ -236,6 +237,7 @@ public async Task BufferedLogEvent_ReobfuscatesMessageWithCurrentSecrets() const string secret = "late-registered-secret"; var redactSecret = false; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(() => redactSecret); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => redactSecret @@ -268,6 +270,7 @@ public async Task BufferedLogEvent_ReobfuscatesExceptionWithCurrentSecrets() const string secret = "late-registered-secret"; var redactSecret = false; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(() => redactSecret); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => redactSecret diff --git a/test/ModularPipelines.UnitTests/Logging/FormattedLogValuesObfuscatorTests.cs b/test/ModularPipelines.UnitTests/Logging/FormattedLogValuesObfuscatorTests.cs index 893db56a1c..17ff3d3189 100644 --- a/test/ModularPipelines.UnitTests/Logging/FormattedLogValuesObfuscatorTests.cs +++ b/test/ModularPipelines.UnitTests/Logging/FormattedLogValuesObfuscatorTests.cs @@ -1,12 +1,84 @@ using Microsoft.Extensions.Logging; using ModularPipelines.Engine; using ModularPipelines.Logging; +using ModularPipelines.Options; using Moq; namespace ModularPipelines.UnitTests.Logging; public class FormattedLogValuesObfuscatorTests { + [Test] + public async Task TryObfuscateValues_DoesNotInspectStateWhenNoSecretsAreRegistered() + { + var state = new CountingToStringState(); + var secretObfuscator = CreateBuiltInObfuscator(); + + var obfuscatedState = new FormattedLogValuesObfuscator(secretObfuscator) + .TryObfuscateValues(state); + + await Assert.That(obfuscatedState).IsSameReferenceAs(state); + await Assert.That(state.ToStringCalls).IsEqualTo(0); + } + + [Test] + public async Task TryObfuscateValues_PreservesCustomMaskingWhenHintIsFalse() + { + const string secret = "policy-secret"; + var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(false); + secretObfuscator + .Setup(x => x.Obfuscate(It.IsAny(), null)) + .Returns((string? value, object? _) => + (value ?? string.Empty).Replace(secret, "********", StringComparison.Ordinal)); + var state = new[] + { + new KeyValuePair("PolicyValue", secret), + }; + + var obfuscatedState = new FormattedLogValuesObfuscator(secretObfuscator.Object) + .TryObfuscateValues(state); + var value = ((IReadOnlyList>) obfuscatedState)[0].Value; + + await Assert.That(value).IsEqualTo("********"); + } + + [Test] + public async Task TryObfuscateValues_RetriesWhenSecretIsRegisteredDuringFastPath() + { + const string secret = "dynamic-secret"; + var version = 0L; + IReadOnlyList secrets = []; + var secretProvider = new Mock(); + secretProvider.SetupGet(x => x.Version).Returns(() => version); + secretProvider.Setup(x => x.GetSnapshot()) + .Returns(() => new SecretSnapshot(version, secrets)); + var secretObfuscator = CreateBuiltInObfuscator(secretProvider.Object); + var values = new[] { new KeyValuePair("Value", secret) }; + var registered = false; + var state = new Mock>>(); + state.SetupGet(x => x.Count).Returns(() => + { + if (!registered) + { + registered = true; + secrets = [secret]; + version += 2; + } + + return values.Length; + }); + state.Setup(x => x[0]).Returns(values[0]); + state.Setup(x => x.GetEnumerator()) + .Returns(() => ((IEnumerable>) values).GetEnumerator()); + + var obfuscatedState = new FormattedLogValuesObfuscator(secretObfuscator) + .TryObfuscateValues(state.Object); + var value = ((IReadOnlyList>) obfuscatedState)[0].Value; + + await Assert.That(value).IsEqualTo("**********"); + } + [Test] public async Task TryObfuscateValues_MasksSecretsInOriginalFormat() { @@ -17,6 +89,7 @@ public async Task TryObfuscateValues_MasksSecretsInOriginalFormat() var state = logger.Invocations.Single(x => x.Method.Name == nameof(ILogger.Log)).Arguments[2]; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => (value ?? string.Empty).Replace(secret, "********", StringComparison.Ordinal)); @@ -43,6 +116,7 @@ public async Task TryObfuscateValues_PreservesUnmaskedStructuredValueTypes() var state = logger.Invocations.Single(x => x.Method.Name == nameof(ILogger.Log)).Arguments[2]; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => value == "secret" ? "********" : value ?? string.Empty); @@ -66,6 +140,7 @@ public async Task TryObfuscateValues_MasksValueTypeSecrets() var state = logger.Invocations.Single(x => x.Method.Name == nameof(ILogger.Log)).Arguments[2]; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => value == secret.ToString() ? "********" : value ?? string.Empty); @@ -82,6 +157,7 @@ public async Task TryObfuscateValues_MasksCustomStructuredLogStates() { var state = new ModuleCompletionLogState("secret", TimeSpan.FromSeconds(1), "(none)", 0, 0); var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => value == "secret" ? "********" : value ?? string.Empty); @@ -98,6 +174,7 @@ public async Task TryObfuscateValues_MasksUnstructuredState() { const string secret = "plain-state-secret"; var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => @@ -114,6 +191,7 @@ public async Task TryObfuscateValues_PreservesStateWhenToStringThrows() { var state = new ThrowingToStringState(); var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); var obfuscatedState = new FormattedLogValuesObfuscator(secretObfuscator.Object) .TryObfuscateValues(state); @@ -128,6 +206,29 @@ public async Task TryObfuscateValues_PreservesStateWhenToStringThrows() public async Task TryObfuscateValues_DoesNotRescanPreObfuscatedValues() { var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(true); + var state = new[] + { + new KeyValuePair( + "CommandOutput", + new PreObfuscatedLogValue("already-masked")), + }; + + var obfuscatedState = new FormattedLogValuesObfuscator(secretObfuscator.Object) + .TryObfuscateValues(state); + var value = ((IReadOnlyList>) obfuscatedState)[0].Value; + + await Assert.That(value).IsEqualTo("already-masked"); + secretObfuscator.Verify( + x => x.Obfuscate(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task TryObfuscateValues_UnwrapsPreObfuscatedValuesWithoutSecrets() + { + var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(false); var state = new[] { new KeyValuePair( @@ -149,4 +250,26 @@ private sealed class ThrowingToStringState { public override string ToString() => throw new InvalidOperationException("Cannot format state."); } + + private sealed class CountingToStringState + { + public int ToStringCalls { get; private set; } + + public override string ToString() + { + ToStringCalls++; + return "state"; + } + } + + private static SecretObfuscator CreateBuiltInObfuscator(ISecretProvider? secretProvider = null) + { + secretProvider ??= Mock.Of(provider => + provider.Version == 0 && + provider.GetSnapshot() == new SecretSnapshot(0, Array.Empty())); + + return new SecretObfuscator( + secretProvider, + Microsoft.Extensions.Options.Options.Create(new SecretMaskingOptions())); + } } diff --git a/test/ModularPipelines.UnitTests/Logging/PipelineLevelLoggerTests.cs b/test/ModularPipelines.UnitTests/Logging/PipelineLevelLoggerTests.cs index 7831be28de..c1171d3939 100644 --- a/test/ModularPipelines.UnitTests/Logging/PipelineLevelLoggerTests.cs +++ b/test/ModularPipelines.UnitTests/Logging/PipelineLevelLoggerTests.cs @@ -144,6 +144,26 @@ public async Task Log_ObfuscatesStateMessageAndExceptionBeforeDelegating() await Assert.That(underlyingLogger.Exception?.ToString()).DoesNotContain(secret); } + [Test] + public async Task Log_WrapsExceptionWhenNoSecretsAreRegistered() + { + var underlyingLogger = new RecordingLogger(); + var originalException = new InvalidOperationException("Failure"); + var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(false); + secretObfuscator + .Setup(x => x.Obfuscate(It.IsAny(), null)) + .Returns((string? value, object? _) => value ?? string.Empty); + var pipelineLevelLogger = new PipelineLevelLogger( + underlyingLogger, + secretObfuscator.Object, + new FormattedLogValuesObfuscator(secretObfuscator.Object)); + + pipelineLevelLogger.LogError(originalException, "Failure"); + + await Assert.That(underlyingLogger.Exception).IsNotSameReferenceAs(originalException); + } + [Test] public async Task Log_PreservesSanitizedExceptionDiagnostics() { @@ -199,6 +219,20 @@ await Assert.That(underlyingLogger.Exception?.ToString()) } } + [Test] + public async Task Log_GuardsHostileExceptionDiagnosticsWithoutSecrets() + { + var underlyingLogger = new RecordingLogger(); + var pipelineLevelLogger = CreateLogger(underlyingLogger, hasSecrets: false); + + await Assert.That( + () => pipelineLevelLogger.LogError(new ThrowingDiagnosticException(), "Failure")) + .ThrowsNothing(); + + await Assert.That(underlyingLogger.Exception?.Message) + .IsEqualTo(LoggingConstants.SecretMask); + } + [Test] public async Task Log_GuardsHostileStructuredTraversal() { @@ -220,6 +254,27 @@ await Assert.That(() => pipelineLevelLogger.Log( } } + [Test] + public async Task Log_GuardsHostileStructuredTraversalWithoutSecrets() + { + var underlyingLogger = new RecordingLogger(); + var pipelineLevelLogger = CreateLogger(underlyingLogger, hasSecrets: false); + + await Assert.That(() => pipelineLevelLogger.Log( + LogLevel.Information, + new EventId(3, "HostileState"), + new ThrowingCountStructuredState(), + null, + static (_, _) => throw new InvalidOperationException("Cannot format state."))) + .ThrowsNothing(); + + using (Assert.Multiple()) + { + await Assert.That(underlyingLogger.State).IsEqualTo(LoggingConstants.SecretMask); + await Assert.That(underlyingLogger.Message).IsEqualTo(LoggingConstants.SecretMask); + } + } + [Test] public async Task Log_PreservesEverySanitizedAggregateExceptionBranch() { @@ -315,9 +370,11 @@ await Assert.That( private static PipelineLevelLogger CreateLogger( ILogger logger, - Func? obfuscate = null) + Func? obfuscate = null, + bool hasSecrets = true) { var secretObfuscator = new Mock(); + secretObfuscator.SetupGet(x => x.HasSecrets).Returns(hasSecrets); secretObfuscator .Setup(x => x.Obfuscate(It.IsAny(), null)) .Returns((string? value, object? _) => obfuscate?.Invoke(value) ?? value ?? string.Empty); diff --git a/test/ModularPipelines.UnitTests/Logging/SecretObfuscatorCachingTests.cs b/test/ModularPipelines.UnitTests/Logging/SecretObfuscatorCachingTests.cs index c67a79e1d4..45729b24ef 100644 --- a/test/ModularPipelines.UnitTests/Logging/SecretObfuscatorCachingTests.cs +++ b/test/ModularPipelines.UnitTests/Logging/SecretObfuscatorCachingTests.cs @@ -8,6 +8,25 @@ namespace ModularPipelines.UnitTests.Logging; public class SecretObfuscatorCachingTests { + [Test] + public async Task HasSecrets_TracksDynamicSecretRegistration() + { + var optionsProvider = new Mock(); + optionsProvider.Setup(x => x.GetOptions()).Returns([]); + var secretProvider = new SecretProvider( + optionsProvider.Object, + Mock.Of(), + Microsoft.Extensions.Options.Options.Create(new SecretMaskingOptions()), + Mock.Of>()); + var obfuscator = CreateObfuscator(secretProvider); + + await Assert.That(obfuscator.HasSecrets).IsFalse(); + + secretProvider.AddSecret("dynamic-secret"); + + await Assert.That(obfuscator.HasSecrets).IsTrue(); + } + [Test] public async Task ReusesSecretSnapshotUntilProviderChanges() {