diff --git a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index a1f2ae3..39252fb 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -95,7 +95,11 @@ public static Command Create(ICredentialService? credentialService = null) var passwordOption = new Option( "--password", - "SQL Server password (bypasses credential store)"); + "SQL Server password (bypasses credential store). Visible in process listings — prefer --password-stdin."); + + var passwordStdinOption = new Option( + "--password-stdin", + "Read the SQL Server password from stdin (avoids process-listing exposure). Mutually exclusive with --password."); var configOption = new Option( "--config", @@ -118,6 +122,7 @@ public static Command Create(ICredentialService? credentialService = null) timeoutOption, loginOption, passwordOption, + passwordStdinOption, configOption }; @@ -137,7 +142,8 @@ public static Command Create(ICredentialService? credentialService = null) var trustCert = ctx.ParseResult.GetValueForOption(trustCertOption); var timeout = ctx.ParseResult.GetValueForOption(timeoutOption); var login = ctx.ParseResult.GetValueForOption(loginOption); - var password = ctx.ParseResult.GetValueForOption(passwordOption); + var passwordInline = ctx.ParseResult.GetValueForOption(passwordOption); + var passwordStdin = ctx.ParseResult.GetValueForOption(passwordStdinOption); var configPath = ctx.ParseResult.GetValueForOption(configOption); // Load analyzer config @@ -148,10 +154,20 @@ public static Command Create(ICredentialService? credentialService = null) server ??= env.GetValueOrDefault("PLANVIEW_SERVER"); database ??= env.GetValueOrDefault("PLANVIEW_DATABASE"); login ??= env.GetValueOrDefault("PLANVIEW_LOGIN"); - password ??= env.GetValueOrDefault("PLANVIEW_PASSWORD"); if (!trustCert && env.GetValueOrDefault("PLANVIEW_TRUST_CERT")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) trustCert = true; + // Resolve password from --password-stdin, --password, or PLANVIEW_PASSWORD + // (in that order). --stdin for plan XML conflicts with --password-stdin. + if (!PasswordResolver.TryResolve( + passwordInline, passwordStdin, stdin, + env.GetValueOrDefault("PLANVIEW_PASSWORD"), + out var password)) + { + Environment.ExitCode = 1; + return; + } + if (server != null) { await RunLiveAsync(file, server, database, query, outputDir, estimated, diff --git a/src/PlanViewer.Cli/Commands/CredentialCommand.cs b/src/PlanViewer.Cli/Commands/CredentialCommand.cs index 9503c37..e94e1f1 100644 --- a/src/PlanViewer.Cli/Commands/CredentialCommand.cs +++ b/src/PlanViewer.Cli/Commands/CredentialCommand.cs @@ -36,6 +36,9 @@ private static Command CreateAddCommand(ICredentialService credentialService) string password; if (!string.IsNullOrEmpty(passwordArg)) { + Console.Error.WriteLine( + "Warning: --password is visible in process listings and shell history. " + + "Prefer piping the password into stdin (e.g. `echo hunter2 | planview credential add ...`)."); password = passwordArg; } else if (Console.IsInputRedirected) diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index d805a05..15baf18 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -72,7 +72,11 @@ public static Command Create(ICredentialService? credentialService = null) "--login", "SQL Server login (bypasses credential store)"); var passwordOption = new Option( - "--password", "SQL Server password"); + "--password", "SQL Server password. Visible in process listings — prefer --password-stdin."); + + var passwordStdinOption = new Option( + "--password-stdin", + "Read the SQL Server password from stdin (avoids process-listing exposure). Mutually exclusive with --password."); var queryIdOption = new Option( "--query-id", "Filter by Query Store query ID"); @@ -93,7 +97,7 @@ public static Command Create(ICredentialService? credentialService = null) { serverOption, databaseOption, topOption, orderByOption, hoursBackOption, outputDirOption, outputOption, compactOption, warningsOnlyOption, configOption, - authOption, trustCertOption, loginOption, passwordOption, + authOption, trustCertOption, loginOption, passwordOption, passwordStdinOption, queryIdOption, planIdOption, queryHashOption, planHashOption, moduleOption }; @@ -112,7 +116,8 @@ public static Command Create(ICredentialService? credentialService = null) var auth = ctx.ParseResult.GetValueForOption(authOption); var trustCert = ctx.ParseResult.GetValueForOption(trustCertOption); var login = ctx.ParseResult.GetValueForOption(loginOption); - var password = ctx.ParseResult.GetValueForOption(passwordOption); + var passwordInline = ctx.ParseResult.GetValueForOption(passwordOption); + var passwordStdin = ctx.ParseResult.GetValueForOption(passwordStdinOption); var filterQueryId = ctx.ParseResult.GetValueForOption(queryIdOption); var filterPlanId = ctx.ParseResult.GetValueForOption(planIdOption); var filterQueryHash = ctx.ParseResult.GetValueForOption(queryHashOption); @@ -122,10 +127,19 @@ public static Command Create(ICredentialService? credentialService = null) // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); login ??= env.GetValueOrDefault("PLANVIEW_LOGIN"); - password ??= env.GetValueOrDefault("PLANVIEW_PASSWORD"); if (!trustCert && env.GetValueOrDefault("PLANVIEW_TRUST_CERT")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) trustCert = true; + // Resolve password from --password-stdin, --password, or PLANVIEW_PASSWORD + if (!PasswordResolver.TryResolve( + passwordInline, passwordStdin, stdinAlreadyClaimed: false, + env.GetValueOrDefault("PLANVIEW_PASSWORD"), + out var password)) + { + Environment.ExitCode = 1; + return; + } + if (top < 1) { Console.Error.WriteLine("--top must be >= 1"); diff --git a/src/PlanViewer.Cli/PasswordResolver.cs b/src/PlanViewer.Cli/PasswordResolver.cs new file mode 100644 index 0000000..ed50bb1 --- /dev/null +++ b/src/PlanViewer.Cli/PasswordResolver.cs @@ -0,0 +1,63 @@ +namespace PlanViewer.Cli; + +/// +/// Resolves a SQL Server password from the available CLI inputs: +/// 1. --password-stdin (reads one line from redirected stdin) +/// 2. --password (inline CLI arg; emits a stderr warning because it's +/// visible in process listings, shell history, and audit logs) +/// 3. PLANVIEW_PASSWORD environment variable (from the process environment or +/// a .env file, already looked up by the caller) +/// +internal static class PasswordResolver +{ + /// + /// Returns true with a resolved password (which may be null if no source provided + /// one). Returns false on user error (mutual-exclusion violation or stdin not + /// redirected when --password-stdin was requested). The caller is responsible + /// for setting Environment.ExitCode on failure. + /// + public static bool TryResolve( + string? inlinePassword, + bool passwordFromStdin, + bool stdinAlreadyClaimed, + string? envPassword, + out string? password) + { + password = null; + + if (passwordFromStdin && !string.IsNullOrEmpty(inlinePassword)) + { + Console.Error.WriteLine("--password and --password-stdin are mutually exclusive."); + return false; + } + + if (passwordFromStdin && stdinAlreadyClaimed) + { + Console.Error.WriteLine("--password-stdin can't be combined with --stdin (both read from stdin)."); + return false; + } + + if (passwordFromStdin) + { + if (!Console.IsInputRedirected) + { + Console.Error.WriteLine("--password-stdin requires stdin to be redirected (pipe the password into the command)."); + return false; + } + password = Console.In.ReadLine()?.TrimEnd('\r', '\n') ?? ""; + return true; + } + + if (!string.IsNullOrEmpty(inlinePassword)) + { + Console.Error.WriteLine( + "Warning: --password is visible in process listings and shell history. " + + "Prefer --password-stdin, the PLANVIEW_PASSWORD env var, or the credential store."); + password = inlinePassword; + return true; + } + + password = envPassword; + return true; + } +} diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index c3cbac7..d8b5062 100644 --- a/src/PlanViewer.Cli/Program.cs +++ b/src/PlanViewer.Cli/Program.cs @@ -23,4 +23,8 @@ if (credentialService != null) root.Add(CredentialCommand.Create(credentialService)); -return await root.InvokeAsync(args); +// System.CommandLine's InvokeAsync returns 0 for successful dispatch even when a +// handler set Environment.ExitCode = 1 to signal a validation error. Honor either +// signal so scripts can tell success from failure. +var code = await root.InvokeAsync(args); +return code != 0 ? code : Environment.ExitCode;