diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs new file mode 100644 index 00000000..5b2a81d4 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService; + +/// +/// Elides secret-bearing values inside opaque connection-config JSON blobs before +/// they leave the management boundary (responses AND audit afterState), mirroring +/// the SmtpConfigPublicShape/SmsConfigPublicShape pattern for structured entities. +/// A property is secret-bearing when its name contains one of the fragments below +/// (case-insensitive) and its value is a JSON string. MergeSentinels lets a client +/// round-trip a scrubbed config through update without wiping stored secrets. +/// +internal static class ConfigSecretScrubber +{ + public const string Sentinel = "***REDACTED***"; + + private static readonly string[] SecretNameFragments = + ["password", "secret", "token", "apikey", "credential", "passphrase"]; + + private static bool IsSecretName(string propertyName) + { + foreach (var fragment in SecretNameFragments) + { + if (propertyName.Contains(fragment, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Replaces every secret-bearing string value (at any depth) with . + /// Null/empty/parse-failure inputs are returned unchanged with hadSecrets=false. + /// + public static (string? scrubbed, bool hadSecrets) Scrub(string? json) + { + if (string.IsNullOrEmpty(json)) + { + return (json, false); + } + + JsonNode? root; + try + { + root = JsonNode.Parse(json); + } + catch (JsonException) + { + return (json, false); + } + + if (root is null) + { + return (json, false); + } + + var hadSecrets = ScrubNode(root, propertyIsSecret: false); + if (!hadSecrets) + { + return (json, false); + } + + return (root.ToJsonString(), true); + } + + private static bool ScrubNode(JsonNode? node, bool propertyIsSecret) + { + switch (node) + { + case JsonObject obj: + { + var had = false; + // Snapshot keys so we can reassign values while iterating. + foreach (var name in obj.Select(kvp => kvp.Key).ToList()) + { + var child = obj[name]; + if (child is JsonValue && IsSecretName(name) && child.GetValueKind() == JsonValueKind.String) + { + obj[name] = Sentinel; + had = true; + } + else + { + had |= ScrubNode(child, IsSecretName(name)); + } + } + + return had; + } + + case JsonArray arr: + { + var had = false; + for (var i = 0; i < arr.Count; i++) + { + var child = arr[i]; + if (propertyIsSecret && child is JsonValue && child.GetValueKind() == JsonValueKind.String) + { + arr[i] = Sentinel; + had = true; + } + else + { + had |= ScrubNode(child, propertyIsSecret); + } + } + + return had; + } + + default: + return false; + } + } + + /// + /// Walks the incoming config; wherever a string value equals , + /// substitutes the value at the same path in (if absent, the + /// Sentinel is left in place — an update would store it verbatim, which is inert). + /// Null/empty/parse-failure inputs pass through unchanged. + /// + public static string? MergeSentinels(string? incoming, string? stored) + { + if (string.IsNullOrEmpty(incoming)) + { + return incoming; + } + + JsonNode? incomingRoot; + try + { + incomingRoot = JsonNode.Parse(incoming); + } + catch (JsonException) + { + return incoming; + } + + if (incomingRoot is null) + { + return incoming; + } + + JsonNode? storedRoot = null; + if (!string.IsNullOrEmpty(stored)) + { + try + { + storedRoot = JsonNode.Parse(stored); + } + catch (JsonException) + { + storedRoot = null; + } + } + + MergeNode(incomingRoot, storedRoot); + return incomingRoot.ToJsonString(); + } + + private static void MergeNode(JsonNode? incoming, JsonNode? stored) + { + switch (incoming) + { + case JsonObject obj: + { + var storedObj = stored as JsonObject; + foreach (var name in obj.Select(kvp => kvp.Key).ToList()) + { + var child = obj[name]; + JsonNode? storedChild = null; + storedObj?.TryGetPropertyValue(name, out storedChild); + + if (child is JsonValue + && child.GetValueKind() == JsonValueKind.String + && child.GetValue() == Sentinel) + { + if (storedChild is not null) + { + obj[name] = storedChild.DeepClone(); + } + } + else + { + MergeNode(child, storedChild); + } + } + + break; + } + + case JsonArray arr: + { + var storedArr = stored as JsonArray; + for (var i = 0; i < arr.Count; i++) + { + var child = arr[i]; + var storedChild = storedArr is not null && i < storedArr.Count ? storedArr[i] : null; + + if (child is JsonValue + && child.GetValueKind() == JsonValueKind.String + && child.GetValue() == Sentinel) + { + if (storedChild is not null) + { + arr[i] = storedChild.DeepClone(); + } + } + else + { + MergeNode(child, storedChild); + } + } + + break; + } + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj index 56a0bf81..47acc975 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj @@ -8,6 +8,9 @@ + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs new file mode 100644 index 00000000..75e65e75 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs @@ -0,0 +1,45 @@ +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +public class ConfigSecretScrubberTests +{ + [Fact] + public void Scrub_RedactsPasswordFields_AtAnyDepth() + { + var json = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Username":"u","Password":"p"},"CertificatePassword":"cp"}"""; + var (scrubbed, had) = ConfigSecretScrubber.Scrub(json); + Assert.True(had); + Assert.DoesNotContain("\"p\"", scrubbed); + Assert.DoesNotContain("\"cp\"", scrubbed); + Assert.Contains(ConfigSecretScrubber.Sentinel, scrubbed); + Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched + } + + [Fact] + public void Scrub_NoSecrets_ReturnsFalseFlag() + { + var (_, had) = ConfigSecretScrubber.Scrub("""{"Endpoint":"opc.tcp://x"}"""); + Assert.False(had); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-json{")] + public void Scrub_NullEmptyOrMalformed_PassesThrough(string? input) + { + var (scrubbed, had) = ConfigSecretScrubber.Scrub(input); + Assert.Equal(input, scrubbed); + Assert.False(had); + } + + [Fact] + public void MergeSentinels_RestoresStoredSecrets_KeepsEdits() + { + var stored = """{"Endpoint":"old","UserIdentity":{"Password":"real"}}"""; + var incoming = $$$"""{"Endpoint":"new","UserIdentity":{"Password":"{{{ConfigSecretScrubber.Sentinel}}}"}}"""; + var merged = ConfigSecretScrubber.MergeSentinels(incoming, stored)!; + Assert.Contains("\"new\"", merged); + Assert.Contains("\"real\"", merged); + Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged); + } +}