fix(secrets): CLI set/rotate read the value from stdin or a masked prompt (scadaproj#35)

`secret set`/`rotate` took the plaintext as a positional argument (shell
history, process list), and Program handed argv to
Host.CreateApplicationBuilder, whose command-line config provider crashed on
a value like "-p=Hunter2Secret" with the value in the unhandled
FormatException on stderr.

- SetCommandLine: `set|rotate <name> [-|--value-stdin]`; no value reads piped
  stdin or prompts twice (masked) at a TTY; one trailing newline dropped;
  empty refused. The positional <value> still works and warns on stderr.
- host built from HostApplicationBuilderSettings without argv; startup
  failures print `secret: startup failed: <msg>` and exit 1
- unexpected trailing arguments reported by position, not echoed
- tests: SetCommandLineTests incl. two child-process runs of the built CLI;
  the -p=Hunter2Secret probe fails (leaks) on the old Program.cs
- docs: README, clustered-secrets runbook, CHANGELOG
- code-reviews: Secrets-017 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:59:28 -05:00
parent e1487fbf10
commit 5f5d684c44
8 changed files with 515 additions and 75 deletions
+12
View File
@@ -51,6 +51,18 @@ Fixes six scadaproj#15 code-review findings (`code-reviews/Secrets/findings.md`)
failure is logged as a warning (`ILogger<SecretEditor>` / `ILogger<ConfirmDeleteModal>`, when the host
registers logging).
### Changed
- **CLI `set`/`rotate` take the value on stdin or a masked prompt** (scadaproj#35, Secrets-017).
`secret set <name> -` / `--value-stdin` reads stdin (one trailing newline dropped); `secret set <name>`
reads piped stdin, or prompts twice (masked) at a terminal. The positional `<value>` still works but
prints a deprecation warning to stderr. The CLI host is no longer built from argv, so a value starting
with `-` and containing `=` no longer crashes it with the value in an unhandled `FormatException`,
and argv can no longer override configuration (`--Secrets:SqlitePath=…`, `--environment …`: use
environment variables). A startup failure now prints `secret: startup failed: <message>` and exits 1
instead of an unhandled stack trace. An unexpected trailing argument is reported by position, not
echoed.
## 0.6.5 — unreleased (NOT published to the feed)
### Changed
+4
View File
@@ -16,6 +16,10 @@ plaintext back on demand — from application code, from configuration, or from
| `ZB.MOM.WW.Secrets.Replicator.Grpc` | Central-hosted `secrets_hub.v1` gRPC hub, pull-only (sites sync from central; central never accepts a write over this channel) — for topologies where sites talk only to central, never a shared DB. |
A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate` / `rewrap-all`) ships in the repo (not packed).
`set` and `rotate` take the value on **stdin** (`printf '%s' "$PW" | secret set db/pw -`) or, at a
terminal, from a masked prompt (`secret set db/pw`). The old `secret set <name> <value>` form still
works but warns, because a value on the command line lands in shell history and the process list.
The CLI reads its configuration from `appsettings.json` and the environment only — never from argv.
### Interactive console
@@ -78,7 +78,7 @@ secret, or an environment variable. Never commit it.
### Verify
```bash
secret set smoke/test hello --description "delete me" # on node A
echo hello | secret set smoke/test - --description "delete me" # on node A
secret get smoke/test # on node B — expect: hello
secret rm smoke/test
```
@@ -18,18 +18,36 @@ using ZB.MOM.WW.Secrets.MasterKey;
if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(CancellationToken.None);
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables();
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
// argv is deliberately NOT handed to the host builder (scadaproj#35). Host.CreateApplicationBuilder(args)
// feeds it to the command-line configuration provider, which turned a secret value containing '='
// into a configuration key and crashed on one starting with '-' — with the whole argument, i.e. the
// secret, in the unhandled FormatException's message. Configuration comes from appsettings.json and
// the environment only.
IHost host;
try
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings());
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables();
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
using IHost host = builder.Build();
host = builder.Build();
// Ensure the schema exists before any store operation. Resolved through the ISecretsStoreMigrator
// seam so the CLI provisions whichever store the app configured (local SQLite or shared SQL Server).
await host.Services.GetRequiredService<ISecretsStoreMigrator>()
.MigrateAsync(CancellationToken.None);
// Ensure the schema exists before any store operation. Resolved through the ISecretsStoreMigrator
// seam so the CLI provisions whichever store the app configured (local SQLite or shared SQL Server).
await host.Services.GetRequiredService<ISecretsStoreMigrator>()
.MigrateAsync(CancellationToken.None);
}
catch (Exception ex)
{
// No argv reaches configuration, so this message cannot carry a secret value; print it without
// the stack trace an unhandled crash would dump into CI logs and deploy transcripts.
Console.Error.WriteLine($"secret: startup failed: {ex.Message}");
return 1;
}
using IHost ownedHost = host;
var commands = new SecretCommands(
host.Services.GetRequiredService<ISecretStore>(),
@@ -50,19 +68,21 @@ try
switch (args[0])
{
case "set":
{
if (args.Length < 3)
return Usage("set requires <name> and <value>.");
(SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
return await commands.SetAsync(args[1], args[2], contentType, description, actor, ct);
}
case "rotate":
{
if (args.Length < 3)
return Usage("rotate requires <name> and <value>.");
(SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
return await commands.RotateAsync(args[1], args[2], contentType, description, actor, ct);
// The value comes from stdin or a masked prompt; the positional form still works but
// warns (scadaproj#35). See SetCommandLine for the accepted forms.
if (args.Length < 2)
return Usage($"{args[0]} requires <name>.");
SetCommandLine line = SetCommandLine.Parse(args);
string value = line.ReadValue(
Console.In,
Console.IsInputRedirected,
label => AnsiConsole.Prompt(new TextPrompt<string>(label).Secret().AllowEmpty()),
Console.Error);
return args[0] == "set"
? await commands.SetAsync(line.Name, value, line.ContentType, line.Description, actor, ct)
: await commands.RotateAsync(line.Name, value, line.ContentType, line.Description, actor, ct);
}
case "get":
@@ -134,48 +154,6 @@ catch (ArgumentException ex)
// --- helpers ---------------------------------------------------------------
// Parses the optional flags shared by `set` and `rotate`:
// [--content-type text|connection-string|json|binary-base64] [--description "..."]
static (SecretContentType contentType, string? description) ParseSetOptions(string[] args, int startIndex)
{
SecretContentType contentType = SecretContentType.Text;
string? description = null;
for (int i = startIndex; i < args.Length; i++)
{
switch (args[i])
{
case "--content-type":
if (i + 1 >= args.Length)
throw new ArgumentException("--content-type requires a value.");
contentType = ParseContentType(args[++i]);
break;
case "--description":
if (i + 1 >= args.Length)
throw new ArgumentException("--description requires a value.");
description = args[++i];
break;
default:
throw new ArgumentException($"Unknown option '{args[i]}'.");
}
}
return (contentType, description);
}
// Maps the CLI content-type token (kebab-case) to the enum.
static SecretContentType ParseContentType(string value) => value switch
{
"text" => SecretContentType.Text,
"connection-string" => SecretContentType.ConnectionString,
"json" => SecretContentType.Json,
"binary-base64" => SecretContentType.BinaryBase64,
_ => throw new ArgumentException(
$"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
};
// Reads the value that must follow an option flag at args[i], advancing i past it.
static string RequireValue(string[] args, ref int i)
{
@@ -206,14 +184,19 @@ int Usage(string? error = null)
"""
Usage: secret <command> [args]
set <name> <value> [--content-type text|connection-string|json|binary-base64] [--description "..."]
rotate <name> <value> [--content-type ...] [--description "..."]
set <name> [-|--value-stdin] [--content-type text|connection-string|json|binary-base64] [--description "..."]
rotate <name> [-|--value-stdin] [--content-type ...] [--description "..."]
get <name>
list [--include-deleted]
rm <name>
rewrap-all --old-key-env <VAR>|--old-key-file <path> [--old-key-id <id>]
[--new-key-env <VAR>|--new-key-file <path>] [--new-key-id <id>]
set/rotate read the value from stdin when it is piped (or with `-` / --value-stdin), and
otherwise prompt for it, masked, twice. One trailing newline on stdin is dropped. The old
`set <name> <value>` form still works but prints a warning: a value on the command line is
kept in shell history and visible in the process list.
rewrap-all rotates the master KEK: it re-wraps every stored secret's data key from the old
KEK to the new one (bodies are never re-encrypted, no plaintext is exposed). The new KEK
defaults to the configured Secrets:MasterKey provider. Supply key material only by env-var
@@ -0,0 +1,218 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli;
/// <summary>Where a <c>set</c>/<c>rotate</c> invocation takes the secret value from.</summary>
public enum SecretValueSource
{
/// <summary>
/// No value on the command line: read standard input when it is redirected, otherwise prompt
/// (masked) at the terminal.
/// </summary>
Implicit,
/// <summary>Read standard input, requested explicitly with <c>-</c> or <c>--value-stdin</c>.</summary>
Stdin,
/// <summary>
/// The legacy positional <c>&lt;value&gt;</c>. Still accepted, with a warning: it lands in shell
/// history and is visible in the process list for the duration of the run.
/// </summary>
Argument,
}
/// <summary>
/// Parsing and value acquisition for the headless <c>set</c> and <c>rotate</c> verbs (scadaproj#35).
/// </summary>
/// <remarks>
/// <para>
/// Accepted forms, for <c>set</c> and <c>rotate</c> alike:
/// </para>
/// <code>
/// secret set &lt;name&gt; [options] # value from piped stdin, else a masked prompt
/// secret set &lt;name&gt; - [options] # value from stdin
/// secret set &lt;name&gt; --value-stdin [options]
/// secret set &lt;name&gt; &lt;value&gt; [options] # DEPRECATED: warns, value is exposed on argv
/// </code>
/// <para>
/// The third argument is a positional value unless it is exactly <c>-</c> or one of the option
/// flags, so a value that happens to equal <c>--content-type</c>, <c>--description</c> or
/// <c>--value-stdin</c> must be supplied through stdin instead.
/// </para>
/// </remarks>
public sealed record SetCommandLine(
string Name,
SecretValueSource Source,
string? ArgumentValue,
SecretContentType ContentType,
string? Description)
{
/// <summary>The flag that requests the value from standard input.</summary>
public const string ValueStdinFlag = "--value-stdin";
/// <summary>Printed to stderr whenever the deprecated positional value form is used.</summary>
public const string ArgumentValueWarning =
"warning: the secret value was passed on the command line, where it is kept in shell history "
+ "and visible in the process list. Pipe it on stdin (`secret set <name> -`) or omit it to be "
+ "prompted; the positional form will be removed in a future release.";
private static readonly HashSet<string> Flags =
new(StringComparer.Ordinal) { "--content-type", "--description", ValueStdinFlag };
/// <summary>
/// Parses <c>&lt;verb&gt; &lt;name&gt; [&lt;value&gt;|-] [options]</c>. The value itself is
/// never part of an exception message.
/// </summary>
/// <param name="args">The full argument vector, verb first.</param>
/// <returns>The parsed command line.</returns>
/// <exception cref="ArgumentException">A missing name, an unknown option, or conflicting value sources.</exception>
public static SetCommandLine Parse(string[] args)
{
ArgumentNullException.ThrowIfNull(args);
if (args.Length < 2 || Flags.Contains(args[1]))
{
throw new ArgumentException($"{(args.Length > 0 ? args[0] : "set")} requires <name>.");
}
string name = args[1];
SecretValueSource source = SecretValueSource.Implicit;
string? argumentValue = null;
int optionsStart = 2;
if (args.Length > 2 && !Flags.Contains(args[2]))
{
optionsStart = 3;
if (args[2] == "-")
{
source = SecretValueSource.Stdin;
}
else
{
source = SecretValueSource.Argument;
argumentValue = args[2];
}
}
SecretContentType contentType = SecretContentType.Text;
string? description = null;
for (int i = optionsStart; i < args.Length; i++)
{
switch (args[i])
{
case "--content-type":
contentType = ParseContentType(RequireValue(args, ref i));
break;
case "--description":
description = RequireValue(args, ref i);
break;
case ValueStdinFlag:
if (source == SecretValueSource.Argument)
{
throw new ArgumentException(
$"{ValueStdinFlag} cannot be combined with a value on the command line.");
}
source = SecretValueSource.Stdin;
break;
default:
// Deliberately not echoed: a mistyped invocation can put the secret value here.
throw new ArgumentException(
$"Unexpected argument at position {i}; expected --content-type, --description or {ValueStdinFlag}.");
}
}
return new SetCommandLine(name, source, argumentValue, contentType, description);
}
/// <summary>
/// Obtains the secret value from the source this command line names.
/// </summary>
/// <param name="stdin">Standard input.</param>
/// <param name="stdinRedirected">Whether standard input is redirected (piped or a file).</param>
/// <param name="prompt">Masked terminal prompt: takes a label, returns what was typed.</param>
/// <param name="stderr">Where the deprecation warning goes.</param>
/// <returns>The plaintext value.</returns>
/// <exception cref="ArgumentException">Stdin was empty, or the two prompted entries differ.</exception>
public string ReadValue(TextReader stdin, bool stdinRedirected, Func<string, string> prompt, TextWriter stderr)
{
ArgumentNullException.ThrowIfNull(stdin);
ArgumentNullException.ThrowIfNull(prompt);
ArgumentNullException.ThrowIfNull(stderr);
switch (Source)
{
case SecretValueSource.Argument:
stderr.WriteLine(ArgumentValueWarning);
return ArgumentValue!;
case SecretValueSource.Stdin:
return ReadStdin(stdin);
default:
if (stdinRedirected)
{
return ReadStdin(stdin);
}
string first = prompt("Secret value:");
string second = prompt("Confirm value:");
if (!string.Equals(first, second, StringComparison.Ordinal))
{
throw new ArgumentException("The two entries do not match; nothing was written.");
}
if (first.Length == 0)
{
throw new ArgumentException("An empty value was entered; nothing was written.");
}
return first;
}
}
// The whole of stdin, less ONE trailing line terminator — what `echo`/`printf '%s\n'`/a heredoc
// append, and never part of the secret. Any other whitespace is the operator's and is kept.
private static string ReadStdin(TextReader stdin)
{
string value = stdin.ReadToEnd();
if (value.EndsWith("\r\n", StringComparison.Ordinal))
{
value = value[..^2];
}
else if (value.EndsWith('\n'))
{
value = value[..^1];
}
if (value.Length == 0)
{
throw new ArgumentException("No secret value was read from stdin; nothing was written.");
}
return value;
}
private static string RequireValue(string[] args, ref int i)
{
if (i + 1 >= args.Length)
{
throw new ArgumentException($"{args[i]} requires a value.");
}
return args[++i];
}
// Maps the CLI content-type token (kebab-case) to the enum.
private static SecretContentType ParseContentType(string value) => value switch
{
"text" => SecretContentType.Text,
"connection-string" => SecretContentType.ConnectionString,
"json" => SecretContentType.Json,
"binary-base64" => SecretContentType.BinaryBase64,
_ => throw new ArgumentException(
$"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
};
}
@@ -0,0 +1,224 @@
using System.Diagnostics;
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
namespace ZB.MOM.WW.Secrets.Tests.Cli;
/// <summary>
/// scadaproj#35 (Secrets-017): <c>secret set</c>/<c>rotate</c> take the value from stdin or a masked
/// prompt; the positional form still works but warns; and argv never reaches configuration.
/// </summary>
public sealed class SetCommandLineTests
{
private static string NoPrompt(string label) =>
throw new InvalidOperationException("the prompt must not be used here");
// ---------- parsing ----------
[Fact]
public void A_bare_name_reads_the_value_implicitly()
{
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "--content-type", "json"]);
Assert.Equal("db/pw", line.Name);
Assert.Equal(SecretValueSource.Implicit, line.Source);
Assert.Null(line.ArgumentValue);
Assert.Equal(SecretContentType.Json, line.ContentType);
}
[Theory]
[InlineData("-")]
[InlineData("--value-stdin")]
public void Dash_and_the_flag_both_select_stdin(string marker)
{
SetCommandLine line = SetCommandLine.Parse(["rotate", "db/pw", marker, "--description", "d"]);
Assert.Equal(SecretValueSource.Stdin, line.Source);
Assert.Equal("d", line.Description);
}
[Fact]
public void The_positional_value_still_parses_for_compatibility()
{
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "hunter2", "--content-type", "text"]);
Assert.Equal(SecretValueSource.Argument, line.Source);
Assert.Equal("hunter2", line.ArgumentValue);
}
[Fact]
public void A_positional_value_and_the_stdin_flag_conflict()
{
Assert.Throws<ArgumentException>(() => SetCommandLine.Parse(["set", "db/pw", "v", "--value-stdin"]));
}
[Fact]
public void An_unexpected_argument_is_rejected_without_echoing_it()
{
ArgumentException ex = Assert.Throws<ArgumentException>(
() => SetCommandLine.Parse(["set", "db/pw", "-", "Hunter2Secret"]));
Assert.DoesNotContain("Hunter2Secret", ex.Message, StringComparison.Ordinal);
}
// ---------- value acquisition ----------
[Fact]
public void The_positional_value_is_used_but_warns()
{
var stderr = new StringWriter();
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "hunter2"]);
string value = line.ReadValue(TextReader.Null, stdinRedirected: false, NoPrompt, stderr);
Assert.Equal("hunter2", value);
Assert.Contains("warning:", stderr.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain("hunter2", stderr.ToString(), StringComparison.Ordinal);
}
[Theory]
[InlineData("s3cret\n", "s3cret")]
[InlineData("s3cret\r\n", "s3cret")]
[InlineData("s3cret", "s3cret")]
[InlineData(" two lines\nkept \n", " two lines\nkept ")]
public void Stdin_drops_exactly_one_trailing_newline(string piped, string expected)
{
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "-"]);
string value = line.ReadValue(new StringReader(piped), stdinRedirected: true, NoPrompt, TextWriter.Null);
Assert.Equal(expected, value);
}
[Fact]
public void Implicit_source_reads_piped_stdin_without_prompting()
{
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw"]);
Assert.Equal("v", line.ReadValue(new StringReader("v\n"), stdinRedirected: true, NoPrompt, TextWriter.Null));
}
[Fact]
public void Implicit_source_at_a_terminal_prompts_twice_and_requires_a_match()
{
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw"]);
var answers = new Queue<string>(["typed", "typed", "typed", "typo"]);
var labels = new List<string>();
string Prompt(string label)
{
labels.Add(label);
return answers.Dequeue();
}
Assert.Equal("typed", line.ReadValue(TextReader.Null, stdinRedirected: false, Prompt, TextWriter.Null));
Assert.Equal(2, labels.Count);
Assert.Throws<ArgumentException>(
() => line.ReadValue(TextReader.Null, stdinRedirected: false, Prompt, TextWriter.Null));
}
[Fact]
public void Empty_stdin_is_refused()
{
SetCommandLine line = SetCommandLine.Parse(["set", "db/pw", "--value-stdin"]);
Assert.Throws<ArgumentException>(
() => line.ReadValue(new StringReader("\n"), stdinRedirected: true, NoPrompt, TextWriter.Null));
}
// ---------- the real executable ----------
[Fact]
public async Task A_dash_prefixed_positional_value_no_longer_crashes_the_host_or_leaks()
{
// THE REGRESSION. Before the fix the CLI handed argv to Host.CreateApplicationBuilder, whose
// command-line configuration provider threw an unhandled FormatException for "-p=..." with the
// whole argument — the secret — in the message on stderr.
using var rig = new CliRig();
CliResult set = await rig.RunAsync(["set", "db/pw", "-p=Hunter2Secret"]);
Assert.DoesNotContain("Hunter2Secret", set.Stderr, StringComparison.Ordinal);
Assert.DoesNotContain("FormatException", set.Stderr, StringComparison.Ordinal);
Assert.Equal(0, set.ExitCode);
Assert.Contains("warning:", set.Stderr, StringComparison.Ordinal);
CliResult get = await rig.RunAsync(["get", "db/pw"]);
Assert.Equal("-p=Hunter2Secret", get.Stdout.Trim());
}
[Fact]
public async Task A_value_piped_on_stdin_is_stored()
{
using var rig = new CliRig();
CliResult set = await rig.RunAsync(["set", "db/pw", "--value-stdin"], stdin: "pw=with=equals\n");
Assert.Equal(0, set.ExitCode);
Assert.DoesNotContain("warning:", set.Stderr, StringComparison.Ordinal);
CliResult rotate = await rig.RunAsync(["rotate", "db/pw"], stdin: "rotated\n");
Assert.Equal(0, rotate.ExitCode);
CliResult get = await rig.RunAsync(["get", "db/pw"]);
Assert.Equal("rotated", get.Stdout.Trim());
}
private sealed record CliResult(int ExitCode, string Stdout, string Stderr);
// Runs the built CLI against a throwaway store with a throwaway key, from an empty working
// directory so no stray appsettings.json is picked up.
private sealed class CliRig : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-cli-{Guid.NewGuid():N}");
private readonly string _key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
public CliRig() => Directory.CreateDirectory(_dir);
public async Task<CliResult> RunAsync(string[] args, string? stdin = null)
{
string dll = Path.Combine(AppContext.BaseDirectory, "ZB.MOM.WW.Secrets.Cli.dll");
var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet")
{
WorkingDirectory = _dir,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
psi.ArgumentList.Add(dll);
foreach (string arg in args)
{
psi.ArgumentList.Add(arg);
}
psi.Environment["Secrets__SqlitePath"] = Path.Combine(_dir, "store", "secrets.db");
psi.Environment["Secrets__MasterKey__Source"] = "Environment";
psi.Environment["Secrets__MasterKey__EnvVarName"] = "ZB_SECRETS_CLI_TEST_KEY";
psi.Environment["ZB_SECRETS_CLI_TEST_KEY"] = _key;
using Process process = Process.Start(psi)!;
if (stdin is not null)
{
await process.StandardInput.WriteAsync(stdin);
}
process.StandardInput.Close();
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await process.WaitForExitAsync(timeout.Token);
return new CliResult(process.ExitCode, await stdout, await stderr);
}
public void Dispose()
{
try
{
Directory.Delete(_dir, recursive: true);
}
catch (IOException)
{
// Best effort: a child process may still be releasing the SQLite file on Windows.
}
}
}
}
+5 -6
View File
@@ -40,7 +40,7 @@ code-reviews/
## Summary
10 of 10 libraries reviewed. 25 pending findings across all libraries.
10 of 10 libraries reviewed. 24 pending findings across all libraries.
Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health,
Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also
@@ -50,9 +50,9 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
|----------|---------------|
| Critical | 0 |
| High | 0 |
| Medium | 6 |
| Medium | 5 |
| Low | 19 |
| **Total** | **25** |
| **Total** | **24** |
## Library Status
@@ -65,7 +65,7 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
| [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 7 |
| [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/3 | 7 | 7 |
| [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 5 |
| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/13 | 14 | 19 |
| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/13 | 13 | 19 |
| [Telemetry](Telemetry/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 11 |
| [Theme](Theme/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 8 |
@@ -84,7 +84,7 @@ _None open._
_None open._
### Medium (6)
### Medium (5)
| ID | Library | Title |
|----|---------|-------|
@@ -93,7 +93,6 @@ _None open._
| LocalDb-002 | [LocalDb](LocalDb/findings.md) | Snapshot streams tables by name and rows by `pk_json` text, so FK children land before parents and are dead-lettered for good |
| LocalDb-003 | [LocalDb](LocalDb/findings.md) | `INSERT OR REPLACE` apply silently deletes a different row on a secondary `UNIQUE` conflict, so the pair diverges |
| LocalDb-004 | [LocalDb](LocalDb/findings.md) | `FailClosedOnDrift` is bypassed on the snapshot path, so a future-stamped snapshot row moves the local HLC forward permanently |
| Secrets-017 | [Secrets](Secrets/findings.md) | `secret set`/`rotate` take the plaintext on argv; the host builder re-parses argv as configuration and can crash with the value in the error |
### Low (19)
+3 -3
View File
@@ -10,7 +10,7 @@
| Last reviewed | 2026-11-09 |
| Reviewer | Claude (scadaproj#15) |
| Commit reviewed | `91fb2b4` |
| Open findings | 14 |
| Open findings | 13 |
## Summary
@@ -570,7 +570,7 @@ _Unresolved._
| Severity | Medium |
| Priority | P2 |
| Category | Security & secret handling |
| Status | Open |
| Status | Resolved |
| Location | `ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs:21` (values consumed at `:57`, `:65`) |
**Description**
@@ -604,7 +604,7 @@ Even without the crash, every `secret set` leaves the plaintext in `~/.zsh_histo
**Resolution**
_Unresolved._
Resolved 2026-11-09 in the commit tagged `(scadaproj#35)` (Secrets `0.6.6`, unpublished). All three recommendations: (1) new `SetCommandLine` parses `set|rotate <name> [-|--value-stdin] [options]`; with no value it reads piped stdin, or prompts twice with a masked Spectre `TextPrompt.Secret()` at a terminal; stdin drops exactly one trailing newline and an empty value is refused. The positional `<value>` form is kept for compatibility and prints a deprecation warning (never the value) to stderr. (2) The host is built with `Host.CreateApplicationBuilder(new HostApplicationBuilderSettings())`, so argv never reaches the command-line configuration provider. (3) Host build + migration are wrapped and print `secret: startup failed: <message>` with exit 1. Unexpected trailing arguments are reported by position, not echoed. Tests `Cli/SetCommandLineTests`: parsing/acquisition unit tests plus two that run the built CLI as a child process — `A_dash_prefixed_positional_value_no_longer_crashes_the_host_or_leaks` (the finding's `-p=Hunter2Secret` probe) and `A_value_piped_on_stdin_is_stored`; against the old `Program.cs` the first fails on the `FormatException` stderr containing `Hunter2Secret`, the second on the missing stdin support.
### Secrets-018 — Bundle import aborts mid-run on one bad entry, leaving earlier rows applied but reporting only "Import failed"