feat(secrets-cli): reference auditor — classify every ${secret:} the target app needs

This commit is contained in:
Joseph Doherty
2026-07-19 09:11:21 -04:00
parent 9bc1e5852e
commit 38d33cf4b7
2 changed files with 309 additions and 0 deletions
@@ -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;
}
}
}