diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs
new file mode 100644
index 0000000..a11428e
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs
@@ -0,0 +1,122 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+/// The resolvability of a single ${secret:NAME} reference against the target's store.
+public enum ReferenceStatus
+{
+ /// The referenced secret exists and decrypts under the session's KEK.
+ Ok,
+
+ ///
+ /// The referenced secret exists, but the session is degraded (no KEK) so decryptability could not
+ /// be verified — presence is all that could be established.
+ ///
+ PresentUnverified,
+
+ /// No row exists for the referenced secret — a fail-closed expansion would throw at startup.
+ Missing,
+
+ /// A row exists but is soft-deleted (tombstoned) — treated as absent by the resolver.
+ Tombstoned,
+
+ /// The row exists but does not decrypt under the session's KEK (wrong or rotated KEK, or tampering).
+ Undecryptable,
+}
+
+///
+/// One audited secret reference: the referenced name, its resolvability , and every
+/// configuration key path that references it.
+///
+/// The referenced secret name, exactly as it appears in the token (trimmed).
+/// The reference's resolvability against the target's store.
+/// The configuration key paths that reference this name, sorted (ordinal).
+public sealed record ReferenceFinding(
+ string SecretName, ReferenceStatus Status, IReadOnlyList ConfigPaths);
+
+/// Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.
+public sealed class ReferenceAuditor
+{
+ // Pinned to SecretReferenceExpander.TokenPattern — MUST stay identical so the auditor sees exactly
+ // the references the expander would resolve at startup. Do NOT diverge from the expander's regex.
+ private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);
+
+ ///
+ /// Walks every configuration value, collects each distinct ${secret:NAME} reference with the
+ /// config key paths that use it, and classifies each name against the session's store. Findings are
+ /// ordered by name (ordinal); each finding's config paths are likewise sorted and de-duplicated.
+ ///
+ /// The open store session whose target configuration is audited.
+ /// A token to cancel the store lookups.
+ /// One finding per distinct referenced secret name.
+ public async Task> AuditAsync(SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ // name -> ordered, de-duplicated set of config key paths. SortedDictionary keeps findings ordered
+ // by name; each SortedSet keeps that name's paths deterministic.
+ var pathsByName = new SortedDictionary>(StringComparer.Ordinal);
+ foreach (KeyValuePair entry in session.Target.Configuration.AsEnumerable())
+ {
+ if (entry.Value is not { } value)
+ {
+ continue;
+ }
+
+ foreach (Match match in TokenPattern.Matches(value))
+ {
+ string name = match.Groups[1].Value.Trim();
+ if (!pathsByName.TryGetValue(name, out SortedSet? paths))
+ {
+ paths = new SortedSet(StringComparer.Ordinal);
+ pathsByName[name] = paths;
+ }
+
+ paths.Add(entry.Key);
+ }
+ }
+
+ var findings = new List(pathsByName.Count);
+ foreach ((string name, SortedSet paths) in pathsByName)
+ {
+ ReferenceStatus status = await ClassifyAsync(session, name, ct).ConfigureAwait(false);
+ findings.Add(new ReferenceFinding(name, status, [.. paths]));
+ }
+
+ return findings;
+ }
+
+ // Present ⇒ tombstone-check ⇒ (degraded ⇒ presence-only) ⇒ decrypt-probe. The decrypted plaintext is
+ // discarded immediately (never stored in a finding); only the success/failure signal is kept.
+ private static async Task ClassifyAsync(
+ SecretsSession session, string name, CancellationToken ct)
+ {
+ StoredSecret? row = await session.Store.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
+ if (row is null)
+ {
+ return ReferenceStatus.Missing;
+ }
+
+ if (row.IsDeleted)
+ {
+ return ReferenceStatus.Tombstoned;
+ }
+
+ if (session.Cipher is not { } cipher)
+ {
+ return ReferenceStatus.PresentUnverified;
+ }
+
+ try
+ {
+ _ = cipher.Decrypt(row);
+ return ReferenceStatus.Ok;
+ }
+ catch (SecretDecryptionException)
+ {
+ return ReferenceStatus.Undecryptable;
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs
new file mode 100644
index 0000000..6542255
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs
@@ -0,0 +1,187 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against real temp-dir SQLite sessions: distinct-reference
+/// collation (one finding per name, every config path listed), the four full-session classifications
+/// (present/decryptable → Ok, absent → Missing, tombstoned → Tombstoned, wrong-KEK → Undecryptable),
+/// and the degraded-session presence-only downgrade (present → PresentUnverified).
+///
+public sealed class ReferenceAuditorTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-audit-{Guid.NewGuid():N}");
+ private readonly List _envVars = [];
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public ReferenceAuditorTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ foreach (string name in _envVars)
+ {
+ Environment.SetEnvironmentVariable(name, null);
+ }
+
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string SetTempKeyEnv()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
+ Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key));
+ _envVars.Add(name);
+ return name;
+ }
+
+ private static IConfigurationRoot Config(params (string Key, string Value)[] pairs) =>
+ new ConfigurationBuilder()
+ .AddInMemoryCollection(pairs.ToDictionary(p => p.Key, p => (string?)p.Value))
+ .Build();
+
+ private StoreTarget Target(MasterKeyOptions masterKey, IConfigurationRoot config) =>
+ new(
+ "test",
+ AppSettingsPath: null,
+ new SecretsOptions { SqlitePath = DbPath, MasterKey = masterKey },
+ SqlServer: null,
+ config);
+
+ private MasterKeyOptions EnvKey() => new()
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = SetTempKeyEnv(),
+ };
+
+ private static MasterKeyOptions UnsetEnvKey() => new()
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ };
+
+ private Task Open(MasterKeyOptions masterKey, IConfigurationRoot config) =>
+ new SecretsSessionFactory().OpenAsync(Target(masterKey, config), overrideKek: null, CancellationToken.None);
+
+ [Fact]
+ public async Task Finds_all_distinct_secret_references_with_their_config_paths()
+ {
+ IConfigurationRoot config = Config(
+ ("Data:Primary:ConnectionString", "Server=db;Password=${secret:db/pw}"),
+ ("Data:Replica:ConnectionString", "Server=db2;Password=${secret:db/pw}"),
+ ("PlainValue", "not a reference"));
+
+ SecretsSession session = await Open(EnvKey(), config);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ ReferenceFinding finding = Assert.Single(findings);
+ Assert.Equal("db/pw", finding.SecretName);
+ Assert.Equal(
+ ["Data:Primary:ConnectionString", "Data:Replica:ConnectionString"],
+ finding.ConfigPaths);
+ }
+
+ [Fact]
+ public async Task Classifies_present_decryptable_secret_as_Ok()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ var name = new SecretName("api/key");
+ await session.Store.UpsertAsync(
+ session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Ok, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Classifies_absent_secret_as_Missing()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Missing, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Classifies_tombstoned_secret_as_Tombstoned()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ var name = new SecretName("api/key");
+ await session.Store.UpsertAsync(
+ session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
+ Assert.True(await session.Store.DeleteAsync(name, actor: "test", CancellationToken.None));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Tombstoned, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Classifies_wrong_kek_row_as_Undecryptable()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ // Seal a row under a DIFFERENT KEK (KEK-A) and persist it into the session's store; the
+ // session's own cipher (KEK-B) then cannot unwrap it → Undecryptable.
+ byte[] keyA = new byte[32];
+ RandomNumberGenerator.Fill(keyA);
+ var cipherA = new AesGcmEnvelopeCipher(new LiteralMasterKeyProvider(Convert.ToBase64String(keyA)));
+
+ var name = new SecretName("api/key");
+ await session.Store.UpsertAsync(
+ cipherA.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Undecryptable, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Degraded_session_reports_PresenceOnly_status()
+ {
+ // Seed a present row and a tombstoned row through a FULL session first.
+ var present = new SecretName("svc/token");
+ var gone = new SecretName("old/key");
+ SecretsSession seed = await Open(EnvKey(), new ConfigurationBuilder().Build());
+ await seed.Store.UpsertAsync(
+ seed.Cipher!.Encrypt(present, "tok", SecretContentType.Text), CancellationToken.None);
+ await seed.Store.UpsertAsync(
+ seed.Cipher!.Encrypt(gone, "old", SecretContentType.Text), CancellationToken.None);
+ Assert.True(await seed.Store.DeleteAsync(gone, actor: "test", CancellationToken.None));
+
+ // Re-open the SAME store degraded (KEK unresolvable) and audit references to all three.
+ SecretsSession degraded = await Open(UnsetEnvKey(), Config(
+ ("A:Token", "${secret:svc/token}"),
+ ("B:Old", "${secret:old/key}"),
+ ("C:Missing", "${secret:no/such}")));
+ Assert.False(degraded.KekAvailable);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(degraded, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.PresentUnverified, findings.Single(f => f.SecretName == "svc/token").Status);
+ Assert.Equal(ReferenceStatus.Tombstoned, findings.Single(f => f.SecretName == "old/key").Status);
+ Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "no/such").Status);
+ }
+}