fix(audit-2026-06-11/H2): reject inline CLI secrets; true no-echo prompt

pic admins create/set --password and pic login --token accepted secrets
on argv, where they leak into shell history, ps aux, and
/proc/<pid>/cmdline. Now only --password -/--token - (stdin) is allowed;
an inline value is rejected with guidance. PICLOUD_TOKEN env still covers
CI for login.

Also (M2): the interactive admins password prompt used read_line, which
echoes despite a 'no echo' claim — switched to rpassword::prompt_password
(already a dep via login). Removed the now-dead ReadLine trait + Write
import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-12 18:34:45 +02:00
parent 3ac1022c33
commit bdcc9a606d
4 changed files with 75 additions and 34 deletions

View File

@@ -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/<pid>/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<String> {
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/<pid>/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<String> {
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<String> {
}
}
trait ReadLine {
fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize>;
}
impl<T: std::io::BufRead> ReadLine for T {
fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> {
std::io::BufRead::read_line(self, buf)
}
}
pub async fn create(
username: &str,
password: Option<&str>,