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