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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user