From 01cb1f56527e03086aa1b46e01f818f85381e6be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 28 Sep 2026 17:27:28 -0400 Subject: [PATCH] feat(secrets): SecretReferenceScanner derives a site's secret refs from its data-connection configs --- .../Types/Secrets/SecretReferenceScanner.cs | 64 +++++++++++++++++++ .../Secrets/SecretReferenceScannerTests.cs | 35 ++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs new file mode 100644 index 00000000..fff25a39 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets; + +/// One secret a site needs, and which of its data connections reference it. +public sealed record SecretReference(string Name, IReadOnlyList ReferencedBy); + +/// +/// Derives the secrets a site needs from its data-connection configs: every JSON string VALUE of the +/// exact form secret:<name>, at any depth, in primary or backup configuration. Protocol-agnostic +/// so a new adapter adopting secret: refs needs no change here. Bootstrap secrets are excluded +/// (design §3.1). Unparseable JSON yields nothing rather than throwing — the deploy gate owns config validity. +/// +public static class SecretReferenceScanner +{ + public const string Prefix = "secret:"; + private static readonly string[] BootstrapPrefixes = ["SB-GRPC-PSK-"]; + + /// + /// Scans every connection's primary and backup configuration JSON for secret:<name> + /// string values and returns the distinct names found, ordinal-sorted, each with the ordinal-sorted, + /// de-duplicated list of connection names that referenced it. + /// + public static IReadOnlyList Scan(IEnumerable connections) + { + var found = new SortedDictionary>(StringComparer.Ordinal); + foreach (var dc in connections) + foreach (var json in new[] { dc.PrimaryConfigurationJson, dc.BackupConfigurationJson }) + foreach (var name in NamesIn(json)) + { + if (!found.TryGetValue(name, out var by)) found[name] = by = new SortedSet(StringComparer.Ordinal); + by.Add(dc.Name); + } + return found.Select(kv => new SecretReference(kv.Key, kv.Value.ToList())).ToList(); + } + + private static IEnumerable NamesIn(string? json) + { + if (string.IsNullOrWhiteSpace(json)) yield break; + JsonDocument doc; + try { doc = JsonDocument.Parse(json); } catch (JsonException) { yield break; } + using (doc) + { + var stack = new Stack(); stack.Push(doc.RootElement); + while (stack.Count > 0) + { + var e = stack.Pop(); + switch (e.ValueKind) + { + case JsonValueKind.Object: foreach (var p in e.EnumerateObject()) stack.Push(p.Value); break; + case JsonValueKind.Array: foreach (var i in e.EnumerateArray()) stack.Push(i); break; + case JsonValueKind.String: + var s = e.GetString()!.Trim(); + if (!s.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) break; + var name = s[Prefix.Length..].Trim(); + if (name.Length == 0 || BootstrapPrefixes.Any(b => name.StartsWith(b, StringComparison.OrdinalIgnoreCase))) break; + yield return name; + break; + } + } + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs new file mode 100644 index 00000000..3b1556c1 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs @@ -0,0 +1,35 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Secrets; + +/// +/// derives, per site, the distinct secret:<name> +/// references found anywhere in its data connections' primary/backup JSON (design §3.1). +/// +public class SecretReferenceScannerTests +{ + private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null) + => new(name, "MxGateway", primary, backup, 3); + + [Fact] + public void FindsNestedRefs_CaseInsensitive_AcrossPrimaryAndBackup() + { + var refs = SecretReferenceScanner.Scan([ + Dc("GW1", """{"Endpoint":"http://gw:5000","ApiKey":"SECRET: mxgw-key "}"""), + Dc("GW2", """{"a":{"b":["x","secret:mxgw-key"]}}""", """{"ApiKey":"secret:backup-key"}"""), + ]); + Assert.Equal(["backup-key", "mxgw-key"], refs.Select(r => r.Name)); + Assert.Equal(["GW1", "GW2"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy); + } + + [Theory] + [InlineData("""{"ApiKey":"${secret:node-local}"}""")] + [InlineData("""{"Note":"see secret:x in docs"}""")] + [InlineData("""{"ApiKey":"secret:"}""")] + [InlineData("""{"Port":5000}""")] + [InlineData("not json")] + [InlineData("""{"Psk":"secret:SB-GRPC-PSK-site-a"}""")] + public void IgnoresNonReferences(string json) + => Assert.Empty(SecretReferenceScanner.Scan([Dc("C", json)])); +}