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)]));
+}