diff --git a/crates/picloud-cli/src/cmds/admins.rs b/crates/picloud-cli/src/cmds/admins.rs index 94943d7..2618f97 100644 --- a/crates/picloud-cli/src/cmds/admins.rs +++ b/crates/picloud-cli/src/cmds/admins.rs @@ -3,7 +3,7 @@ //! All endpoints require `Capability::InstanceManageUsers` (owner / //! admin). Member accounts get a 403. -use std::io::{IsTerminal, Read, Write}; +use std::io::{IsTerminal, Read}; use anyhow::{anyhow, Context, Result}; use picloud_shared::InstanceRole; @@ -44,33 +44,32 @@ pub async fn ls(mode: OutputMode) -> Result<()> { /// Resolve the password the user wants to set: /// * `Some("-")` → read from stdin (no echo when interactive). -/// * `Some(other)` → use the inline value (handy for non-interactive -/// callers, but logs to shell history; warn when interactive). +/// * `Some(other)` → **rejected**. Audit 2026-06-11 (H2) — an inline +/// password lands in shell history, `ps aux`, and +/// `/proc//cmdline`, readable by any other UID on the host +/// while the process runs. /// * `None` → require explicit `--password`. fn resolve_password(password_arg: Option<&str>) -> Result { match password_arg { None => Err(anyhow!( - "missing --password; pass --password - to read from stdin" + "missing --password; pass --password - to read the password from stdin" )), Some("-") => read_password_from_stdin(), - Some(inline) => Ok(inline.to_string()), + Some(_) => Err(anyhow!( + "passing the password inline is not allowed — it leaks into shell history, \ + `ps aux`, and /proc//cmdline. Use `--password -` to read it from stdin \ + (interactive prompt, or piped: `printf %s \"$PW\" | pic admins create … --password -`)." + )), } } fn read_password_from_stdin() -> Result { let stdin = std::io::stdin(); if stdin.is_terminal() { - // Prompt without echoing — rpassword would be cleaner, but the - // CLI already avoids extra crates. Print a one-line prompt so - // the user knows we're waiting for input. - eprint!("Password: "); - std::io::stderr().flush().ok(); - let mut buf = String::new(); - stdin - .lock() - .read_line(&mut buf) - .context("read password from stdin")?; - Ok(buf.trim_end_matches(['\n', '\r']).to_string()) + // True no-echo prompt. Audit 2026-06-11 (M2) — the previous + // `read_line` path echoed the password despite a "no echo" + // claim; `rpassword` is already a dependency via the login flow. + rpassword::prompt_password("Password: ").context("read password from stdin") } else { // Piped input — read everything, strip trailing newline. let mut buf = String::new(); @@ -82,16 +81,6 @@ fn read_password_from_stdin() -> Result { } } -trait ReadLine { - fn read_line(&mut self, buf: &mut String) -> std::io::Result; -} - -impl ReadLine for T { - fn read_line(&mut self, buf: &mut String) -> std::io::Result { - std::io::BufRead::read_line(self, buf) - } -} - pub async fn create( username: &str, password: Option<&str>, diff --git a/crates/picloud-cli/src/cmds/login.rs b/crates/picloud-cli/src/cmds/login.rs index cbde247..4d65526 100644 --- a/crates/picloud-cli/src/cmds/login.rs +++ b/crates/picloud-cli/src/cmds/login.rs @@ -26,7 +26,21 @@ pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> { let token_from_env = std::env::var("PICLOUD_TOKEN") .ok() .filter(|s| !s.is_empty()); - let bearer_token = token_arg.map(str::to_string).or(token_from_env); + // Audit 2026-06-11 (H2) — an inline `--token ` lands in shell + // history, `ps aux`, and /proc//cmdline. Accept only `--token -` + // (stdin) or the PICLOUD_TOKEN env var (the CI path); reject inline. + let token_from_arg = match token_arg { + Some("-") => Some(read_token_from_stdin()?), + Some(_) => { + anyhow::bail!( + "passing the token inline is not allowed — it leaks into shell history, \ + `ps aux`, and /proc//cmdline. Use `--token -` to read it from stdin, \ + or set the PICLOUD_TOKEN environment variable." + ) + } + None => None, + }; + let bearer_token = token_from_arg.or(token_from_env); let (token, username, role) = match bearer_token { Some(t) => login_with_bearer(&url, &t).await?, @@ -46,6 +60,24 @@ pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> { Ok(()) } +/// Read a bearer token from stdin. Interactive sessions get a no-echo +/// prompt (a token is a secret); piped input reads one line. Audit +/// 2026-06-11 (H2) — backs `pic login --token -`. +fn read_token_from_stdin() -> Result { + use std::io::IsTerminal; + if io::stdin().is_terminal() { + let t = rpassword::prompt_password("Token: ").context("read token from stdin")?; + Ok(t.trim().to_string()) + } else { + let mut buf = String::new(); + io::stdin() + .lock() + .read_line(&mut buf) + .context("read token from stdin")?; + Ok(buf.trim().to_string()) + } +} + async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> { let username = prompt_line("Username: ")?; if username.is_empty() { diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index a0b2186..f2068a9 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -119,9 +119,10 @@ struct LoginArgs { #[arg(long)] url: Option, - /// Skip the username + password exchange and persist this bearer - /// directly (validated against `/auth/me` first). Also reads - /// `PICLOUD_TOKEN`. + /// Skip the username + password exchange and persist a bearer + /// directly (validated against `/auth/me` first). Pass `--token -` + /// to read it from stdin; an inline value is rejected (it leaks into + /// shell history / `ps`). For CI, set `PICLOUD_TOKEN` instead. #[arg(long)] token: Option, } @@ -551,9 +552,9 @@ enum AdminsCmd { /// `--instance-role`, defaults to `admin`. Create { username: String, - /// Password. `-` reads from stdin (one line interactive, or the - /// full input piped). Passing the password inline puts it in - /// shell history — pipe instead when scripting. + /// Password. Use `--password -` to read from stdin (interactive + /// no-echo prompt, or piped). An inline value is rejected — it + /// would leak into shell history, `ps`, and /proc. #[arg(long)] password: Option, /// `owner`, `admin`, or `member`. Defaults to `admin`. @@ -571,7 +572,8 @@ enum AdminsCmd { id: String, #[arg(long)] username: Option, - /// `-` reads from stdin. Inline value lands in shell history. + /// Use `--password -` to read from stdin. An inline value is + /// rejected (it leaks into shell history, `ps`, and /proc). #[arg(long)] password: Option, /// `true` to (re)activate, `false` to deactivate (deactivation diff --git a/crates/picloud-cli/tests/admins.rs b/crates/picloud-cli/tests/admins.rs index c8cb5a8..345a213 100644 --- a/crates/picloud-cli/tests/admins.rs +++ b/crates/picloud-cli/tests/admins.rs @@ -119,6 +119,24 @@ fn create_without_password_errors() { .stderr(predicate::str::contains("missing --password")); } +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_with_inline_password_is_rejected() { + // Audit 2026-06-11 (H2) — an inline password on argv leaks into + // shell history / ps / proc; only `--password -` (stdin) is allowed. + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let username = common::unique_username("inlinepw"); + + common::pic_as(&env) + .args(["admins", "create", &username, "--password", "hunter2"]) + .assert() + .failure() + .stderr(predicate::str::contains("inline is not allowed")); +} + #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn member_cannot_list_admins() {