fix(secrets-cli): audit flow — cipher guard, shared actor helper, summary + branch coverage

This commit is contained in:
Joseph Doherty
2026-07-19 09:55:16 -04:00
parent d1ec0fe5da
commit e8b34be255
2 changed files with 52 additions and 6 deletions
@@ -35,6 +35,9 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow
ArgumentNullException.ThrowIfNull(console); ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(session);
ISecretCipher cipher = session.Cipher
?? throw new InvalidOperationException("flow requires an unlocked KEK session");
var auditor = new ReferenceAuditor(); var auditor = new ReferenceAuditor();
IReadOnlyList<ReferenceFinding> findings = await auditor.AuditAsync(session, ct).ConfigureAwait(false); IReadOnlyList<ReferenceFinding> findings = await auditor.AuditAsync(session, ct).ConfigureAwait(false);
if (findings.Count == 0) if (findings.Count == 0)
@@ -72,7 +75,7 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow
continue; continue;
} }
await SeedAsync(console, session, new SecretName(finding.SecretName), ct).ConfigureAwait(false); await SeedAsync(console, session, cipher, new SecretName(finding.SecretName), ct).ConfigureAwait(false);
console.MarkupLineInterpolated($"[green]Sealed '{finding.SecretName}'.[/]"); console.MarkupLineInterpolated($"[green]Sealed '{finding.SecretName}'.[/]");
} }
@@ -88,7 +91,7 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow
// Prompts for a masked value + content type + optional description and seals the secret under a fresh // Prompts for a masked value + content type + optional description and seals the secret under a fresh
// per-secret DEK. Mirrors SetSecretFlow's seal-and-stamp shape without coupling to it. // per-secret DEK. Mirrors SetSecretFlow's seal-and-stamp shape without coupling to it.
private static async Task SeedAsync( private static async Task SeedAsync(
IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct) IAnsiConsole console, SecretsSession session, ISecretCipher cipher, SecretName name, CancellationToken ct)
{ {
string value = console.Prompt( string value = console.Prompt(
new TextPrompt<string>($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret()); new TextPrompt<string>($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
@@ -97,12 +100,11 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow
string description = console.Prompt( string description = console.Prompt(
new TextPrompt<string>(" Description [grey](optional)[/]:").AllowEmpty()); new TextPrompt<string>(" Description [grey](optional)[/]:").AllowEmpty());
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName; StoredSecret row = cipher.Encrypt(name, value, contentType) with
StoredSecret row = session.Cipher!.Encrypt(name, value, contentType) with
{ {
Description = string.IsNullOrWhiteSpace(description) ? null : description, Description = string.IsNullOrWhiteSpace(description) ? null : description,
CreatedBy = actor, CreatedBy = FlowPrompts.Actor,
UpdatedBy = actor, UpdatedBy = FlowPrompts.Actor,
}; };
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); // revives a tombstone: overwrite clears IsDeleted. await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); // revives a tombstone: overwrite clears IsDeleted.
} }
@@ -160,6 +160,50 @@ public sealed class ReferenceAuditFlowTests : IDisposable
Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal); Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal); Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
// The closing re-audit summary reflects the now-all-Ok state.
Assert.Contains("Audit complete: 3 Ok.", console.Output, StringComparison.Ordinal);
}
[Fact]
public async Task Undecryptable_reference_is_offered_as_overwrite_and_reseeded()
{
KeyValuePair<string, string?>[] config = [new("Api:Bad", "${secret:svc/bad}")];
// Seal svc/bad under a DIFFERENT KEK on the same store, so the audited session cannot decrypt it.
SecretsSession writer = await NewSessionAsync(config);
await SeedAsync(writer, "svc/bad", "OLD-UNREADABLE-VALUE");
SecretsSession session = await NewSessionAsync(config); // fresh random KEK → svc/bad is Undecryptable
TestConsole console = NewConsole();
console.Input.PushTextWithEnter("y"); // overwrite confirm (defaults false for Undecryptable)
console.Input.PushTextWithEnter("NEW-READABLE-SENTINEL"); // fresh value (masked)
console.Input.PushKey(ConsoleKey.Enter); // content-type Text
console.Input.PushKey(ConsoleKey.Enter); // description empty
await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
Assert.Contains("Undecryptable", console.Output, StringComparison.Ordinal); // rendered in the table
StoredSecret? row = await session.Store.GetAsync(new SecretName("svc/bad"), CancellationToken.None);
Assert.Equal("NEW-READABLE-SENTINEL", session.Cipher!.Decrypt(row!)); // now decrypts under the session KEK
Assert.DoesNotContain("NEW-READABLE-SENTINEL", console.Output, StringComparison.Ordinal);
}
[Fact]
public async Task Invalid_name_reference_renders_hint_and_is_not_prompted()
{
KeyValuePair<string, string?>[] config = [new("Api:Broken", "${secret:REPLACE ME}")];
SecretsSession session = await NewSessionAsync(config);
TestConsole console = NewConsole(); // push NO input: an InvalidName reference must never prompt
await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
Assert.Contains("InvalidName", console.Output, StringComparison.Ordinal); // table status
Assert.Contains("not a valid secret name", console.Output, StringComparison.Ordinal); // fix-the-token hint
Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt
Assert.Contains("Audit complete: 1 InvalidName.", console.Output, StringComparison.Ordinal);
} }
[Fact] [Fact]