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:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user