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