feat(secrets-cli): reference auditor — classify every ${secret:} the target app needs
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>The resolvability of a single <c>${secret:NAME}</c> reference against the target's store.</summary>
|
||||
public enum ReferenceStatus
|
||||
{
|
||||
/// <summary>The referenced secret exists and decrypts under the session's KEK.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>
|
||||
/// The referenced secret exists, but the session is degraded (no KEK) so decryptability could not
|
||||
/// be verified — presence is all that could be established.
|
||||
/// </summary>
|
||||
PresentUnverified,
|
||||
|
||||
/// <summary>No row exists for the referenced secret — a fail-closed expansion would throw at startup.</summary>
|
||||
Missing,
|
||||
|
||||
/// <summary>A row exists but is soft-deleted (tombstoned) — treated as absent by the resolver.</summary>
|
||||
Tombstoned,
|
||||
|
||||
/// <summary>The row exists but does not decrypt under the session's KEK (wrong or rotated KEK, or tampering).</summary>
|
||||
Undecryptable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One audited secret reference: the referenced name, its resolvability <see cref="Status"/>, and every
|
||||
/// configuration key path that references it.
|
||||
/// </summary>
|
||||
/// <param name="SecretName">The referenced secret name, exactly as it appears in the token (trimmed).</param>
|
||||
/// <param name="Status">The reference's resolvability against the target's store.</param>
|
||||
/// <param name="ConfigPaths">The configuration key paths that reference this name, sorted (ordinal).</param>
|
||||
public sealed record ReferenceFinding(
|
||||
string SecretName, ReferenceStatus Status, IReadOnlyList<string> ConfigPaths);
|
||||
|
||||
/// <summary>Scans a target app's composed configuration for <c>${secret:NAME}</c> tokens and checks each against the store.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Walks every configuration value, collects each distinct <c>${secret:NAME}</c> 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.
|
||||
/// </summary>
|
||||
/// <param name="session">The open store session whose target configuration is audited.</param>
|
||||
/// <param name="ct">A token to cancel the store lookups.</param>
|
||||
/// <returns>One finding per distinct referenced secret name.</returns>
|
||||
public async Task<IReadOnlyList<ReferenceFinding>> 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<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, string?> 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<string>? paths))
|
||||
{
|
||||
paths = new SortedSet<string>(StringComparer.Ordinal);
|
||||
pathsByName[name] = paths;
|
||||
}
|
||||
|
||||
paths.Add(entry.Key);
|
||||
}
|
||||
}
|
||||
|
||||
var findings = new List<ReferenceFinding>(pathsByName.Count);
|
||||
foreach ((string name, SortedSet<string> 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<ReferenceStatus> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises <see cref="ReferenceAuditor"/> 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).
|
||||
/// </summary>
|
||||
public sealed class ReferenceAuditorTests : IDisposable
|
||||
{
|
||||
private readonly string _dir =
|
||||
Path.Combine(Path.GetTempPath(), $"zb-secrets-audit-{Guid.NewGuid():N}");
|
||||
private readonly List<string> _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<SecretsSession> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user