feat(management): ConfigSecretScrubber for connection-config secret elision
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every secret-bearing string value (at any depth) with <see cref="Sentinel"/>.
|
||||
/// Null/empty/parse-failure inputs are returned unchanged with hadSecrets=false.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the incoming config; wherever a string value equals <see cref="Sentinel"/>,
|
||||
/// substitutes the value at the same path in <paramref name="stored"/> (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.
|
||||
/// </summary>
|
||||
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<string>() == 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<string>() == Sentinel)
|
||||
{
|
||||
if (storedChild is not null)
|
||||
{
|
||||
arr[i] = storedChild.DeepClone();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
MergeNode(child, storedChild);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -8,6 +8,9 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.ManagementService.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user