fix(secrets-cli): reference auditor — InvalidName containment, comment-key parity, name normalization

This commit is contained in:
Joseph Doherty
2026-07-19 09:27:13 -04:00
parent 21f6a0df92
commit e60f2d5dad
2 changed files with 124 additions and 21 deletions
@@ -24,6 +24,12 @@ public enum ReferenceStatus
/// <summary>The row exists but does not decrypt under the session's KEK (wrong or rotated KEK, or tampering).</summary>
Undecryptable,
/// <summary>
/// The token names a value that is not a valid <see cref="SecretName"/> (illegal characters, an
/// unfilled placeholder like <c>REPLACE ME</c>, and so on), so it cannot be looked up at all.
/// </summary>
InvalidName,
}
/// <summary>
@@ -45,55 +51,99 @@ public sealed class ReferenceAuditor
/// <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.
/// config key paths that use it, and classifies each name against the session's store. References are
/// keyed by their <em>normalized</em> <see cref="SecretName.Value"/> so case/whitespace variants of one
/// secret collapse to a single finding whose name matches what a <c>get</c> would resolve; a token that
/// is not a valid name is reported once as <see cref="ReferenceStatus.InvalidName"/> under its raw text
/// rather than aborting the whole audit. 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>
/// <returns>One finding per distinct referenced secret.</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);
// group key -> collected reference. The key is the normalized SecretName.Value for a valid name
// (so variants collapse), or the raw trimmed token text for an invalid one. SortedDictionary keeps
// findings ordered by name; each group's SortedSet keeps its paths deterministic.
var groups = new SortedDictionary<string, ReferenceGroup>(StringComparer.Ordinal);
foreach (KeyValuePair<string, string?> entry in session.Target.Configuration.AsEnumerable())
{
if (entry.Value is not { } value)
// Mirror SecretReferenceExpander.IsCommentKey: an entry whose leaf key segment starts with '_'
// (the "_comment" convention) may legitimately document the ${secret:...} syntax with a literal
// example token and is never resolved — so the auditor must not report it either.
if (entry.Value is not { } value || IsCommentKey(entry.Key))
{
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);
string raw = match.Groups[1].Value.Trim();
ReferenceGroup group = ResolveGroup(groups, raw);
group.Paths.Add(entry.Key);
}
}
var findings = new List<ReferenceFinding>(pathsByName.Count);
foreach ((string name, SortedSet<string> paths) in pathsByName)
var findings = new List<ReferenceFinding>(groups.Count);
foreach ((string key, ReferenceGroup group) in groups)
{
ReferenceStatus status = await ClassifyAsync(session, name, ct).ConfigureAwait(false);
findings.Add(new ReferenceFinding(name, status, [.. paths]));
ReferenceStatus status = group.Name is { } name
? await ClassifyAsync(session, name, ct).ConfigureAwait(false)
: ReferenceStatus.InvalidName;
findings.Add(new ReferenceFinding(key, status, [.. group.Paths]));
}
return findings;
}
// Finds (or creates) the group a raw token belongs to. A valid name groups under its normalized value
// and carries the parsed SecretName; an invalid token (illegal chars, an unfilled placeholder, ...)
// groups under its raw text with no name, so one bad token becomes one InvalidName finding instead of
// throwing and aborting the entire audit.
private static ReferenceGroup ResolveGroup(
SortedDictionary<string, ReferenceGroup> groups, string raw)
{
string key;
SecretName? name;
try
{
var parsed = new SecretName(raw);
key = parsed.Value;
name = parsed;
}
catch (ArgumentException)
{
key = raw;
name = null;
}
if (!groups.TryGetValue(key, out ReferenceGroup? group))
{
group = new ReferenceGroup(name);
groups[key] = group;
}
return group;
}
// A configuration key is a comment/documentation key when its leaf segment (after the last ':') starts
// with '_'. Duplicated from SecretReferenceExpander.IsCommentKey — keep the two in lockstep.
private static bool IsCommentKey(string key)
{
int lastSeparator = key.LastIndexOf(':');
string leaf = lastSeparator >= 0 ? key[(lastSeparator + 1)..] : key;
return leaf.StartsWith('_');
}
// 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)
SecretsSession session, SecretName name, CancellationToken ct)
{
StoredSecret? row = await session.Store.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
if (row is null)
{
return ReferenceStatus.Missing;
@@ -119,4 +169,13 @@ public sealed class ReferenceAuditor
return ReferenceStatus.Undecryptable;
}
}
// A collated reference: the config key paths that use it, plus the parsed name (null when the token is
// not a valid SecretName — an InvalidName finding).
private sealed class ReferenceGroup(SecretName? name)
{
public SortedSet<string> Paths { get; } = new(StringComparer.Ordinal);
public SecretName? Name { get; } = name;
}
}