diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 6e010cc7..b82055f4 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -207,6 +207,7 @@ oneshot opencode opensource parseable +peekable PERCPU pgpkey pgrep diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index 9278e634..b0be815c 100644 --- a/proxy_agent/src/proxy.rs +++ b/proxy_agent/src/proxy.rs @@ -77,6 +77,7 @@ pub struct User { const UNDEFINED: &str = "undefined"; const EMPTY: &str = "empty"; +const MAX_CMD_ARGS: usize = 4; async fn get_user( logon_id: u64, @@ -112,10 +113,12 @@ fn get_process_info(process_id: u32) -> (PathBuf, String) { let cmdline_path = format!("/proc/{}/cmdline", process_id); let process_cmd_line = match std::fs::read(&cmdline_path) { Ok(bytes) => { - // cmdline is null-separated, convert to space-separated string + // cmdline is null-separated; take only the first few arguments + // to avoid capturing credentials in later args bytes .split(|&b| b == 0) .filter(|s| !s.is_empty()) + .take(MAX_CMD_ARGS) .map(|s| String::from_utf8_lossy(s).into_owned()) .collect::>() .join(" ") diff --git a/proxy_agent/src/proxy/windows.rs b/proxy_agent/src/proxy/windows.rs index acc770eb..3667c635 100644 --- a/proxy_agent/src/proxy/windows.rs +++ b/proxy_agent/src/proxy/windows.rs @@ -292,7 +292,46 @@ pub fn get_process_cmd(handler: isize) -> Result { std::slice::from_raw_parts(cmd_buffer.Buffer, (cmd_buffer.Length / 2) as usize) }); - Ok(cmd) + // Only keep the first few arguments to avoid capturing credentials + // that may appear in later command-line arguments + Ok(truncate_cmd_args(&cmd, super::MAX_CMD_ARGS)) +} + +/// Truncate a command line string to at most `max_args` arguments, +/// respecting double-quoted strings that may contain whitespace. +fn truncate_cmd_args(cmd: &str, max_args: usize) -> String { + let mut args = Vec::new(); + let mut chars = cmd.chars().peekable(); + + while args.len() < max_args { + // Skip whitespace between arguments + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + if chars.peek().is_none() { + break; + } + + let mut arg = String::new(); + if chars.peek() == Some(&'"') { + // Quoted argument — consume until closing quote + arg.push(chars.next().unwrap()); // opening " + for c in chars.by_ref() { + arg.push(c); + if c == '"' { + break; + } + } + } else { + // Unquoted argument — consume until whitespace + while chars.peek().is_some_and(|c| !c.is_whitespace()) { + arg.push(chars.next().unwrap()); + } + } + args.push(arg); + } + + args.join(" ") } #[allow(dead_code)] @@ -379,4 +418,35 @@ mod tests { ); assert!(!cmd.is_empty(), "process cmd should not be empty"); } + + #[test] + fn truncate_cmd_args_tests() { + // no arguments + let result = super::truncate_cmd_args("", 4); + assert_eq!(result, "", "empty input should return empty string"); + + // fewer than max args + let result = super::truncate_cmd_args("app.exe arg1", 4); + assert_eq!( + result, "app.exe arg1", + "should return all args when fewer than max" + ); + + // exactly max args + let result = super::truncate_cmd_args("app.exe arg1 arg2 arg3 --secret=password", 4); + assert_eq!( + result, "app.exe arg1 arg2 arg3", + "should truncate to first 4 args" + ); + + // more than max args and quoted arg with spaces + let result = super::truncate_cmd_args( + r#""C:\Program Files\app.exe" arg1 "arg with spaces" arg3 --secret=password"#, + 4, + ); + assert_eq!( + result, r#""C:\Program Files\app.exe" arg1 "arg with spaces" arg3"#, + "should treat quoted strings with whitespace as single args" + ); + } }