fix(secrets-cli): audit flow — cipher guard, shared actor helper, summary + branch coverage
This commit is contained in:
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
+44
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user