237 lines
7.7 KiB
C#
237 lines
7.7 KiB
C#
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"];
|
|
|
|
internal 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>
|
|
/// <param name="json">The connection-config JSON blob to scrub, or null/empty.</param>
|
|
/// <returns>The scrubbed JSON (or the original input if unchanged) and whether any secrets were found.</returns>
|
|
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>
|
|
/// <param name="incoming">The client-submitted config JSON, possibly containing sentinel values.</param>
|
|
/// <param name="stored">The previously stored config JSON to source real secret values from.</param>
|
|
/// <returns>The incoming JSON with sentinel values replaced by the corresponding stored values.</returns>
|
|
/// <remarks>
|
|
/// <b>Array limitation (arch-review R2 N9):</b> sentinel restoration inside arrays
|
|
/// pairs incoming[i] with stored[i] BY INDEX. A client that reorders an array of
|
|
/// credentialed objects while round-tripping sentinels grafts the wrong stored
|
|
/// secret into each slot — a silent misconfiguration (never a disclosure). Clients
|
|
/// must not reorder arrays that carry sentinels (the CLI round-trip does not);
|
|
/// key-based matching is a possible future refinement if a config type ever nests
|
|
/// credentialed arrays.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|