From 13e1bcc36900488a7ab28938f4b90731fd6a8133 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 14 Apr 2026 13:16:44 -0700 Subject: [PATCH 01/11] GPA service to use host-date-time for signed http requests --- proxy_agent/src/key_keeper/key.rs | 22 ++++++- proxy_agent_shared/src/hyper_client.rs | 53 ++++++++++++++++ proxy_agent_shared/src/misc_helpers.rs | 86 +++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index e8c2b61d..7a2bf662 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -36,9 +36,9 @@ use hyper::Uri; use proxy_agent_shared::hyper_client; use proxy_agent_shared::logger::LoggerLevel; use serde_derive::{Deserialize, Serialize}; -use std::ffi::OsString; use std::fmt::{Display, Formatter}; use std::{collections::HashMap, path::PathBuf}; +use std::{ffi::OsString, time::Duration}; const AUDIT_MODE: &str = "audit"; const ENFORCE_MODE: &str = "enforce"; @@ -729,7 +729,11 @@ impl Display for KeyAction { const STATUS_URL: &str = "/secure-channel/status"; const KEY_URL: &str = "/secure-channel/key"; +const HOST_DATE_TIME_SYNC_MAX_AGE: Duration = Duration::from_secs(60); +/// Get the current status of the key from the secure channel. +/// This function will perform a GET request to the secure channel status endpoint. +/// If the host time sync is stale, it will use `get_and_sync_host_time` to update the host time. pub async fn get_status(host: &str, port: u16) -> Result { let endpoint = hyper_client::HostEndpoint::new(host, port, STATUS_URL); let mut headers = HashMap::new(); @@ -737,8 +741,22 @@ pub async fn get_status(host: &str, port: u16) -> Result { hyper_client::METADATA_HEADER.to_string(), "True ".to_string(), ); + let status: KeyStatus = - hyper_client::get(&endpoint, &headers, None, None, logger::write_warning).await?; + if proxy_agent_shared::misc_helpers::host_time_sync_is_stale(HOST_DATE_TIME_SYNC_MAX_AGE) { + hyper_client::get_and_sync_host_time( + &endpoint, + &headers, + None, + None, + logger::write_warning, + ) + .await? + .0 + } else { + hyper_client::get(&endpoint, &headers, None, None, logger::write_warning).await? + }; + status.validate()?; Ok(status) diff --git a/proxy_agent_shared/src/hyper_client.rs b/proxy_agent_shared/src/hyper_client.rs index 8fe933a6..5740ec5c 100644 --- a/proxy_agent_shared/src/hyper_client.rs +++ b/proxy_agent_shared/src/hyper_client.rs @@ -142,6 +142,59 @@ where read_response_body(response).await } +/// Performs a GET request and updates the shared host-time sync state from +/// `x-ms-azure-host-date` response header if available and parseable. +/// +/// Returns `Ok(true)` when sync state was updated, `Ok(false)` when header is +/// missing/invalid, and `Err` on transport/server status failure. +pub async fn get_and_sync_host_time( + endpoint: &HostEndpoint, + headers: &HashMap, + key_guid: Option, + key: Option, + log_fun: F, +) -> Result<(T, bool)> +where + T: DeserializeOwned, + F: Fn(String) + Send + 'static, +{ + let request = build_request(Method::GET, endpoint, headers, None, key_guid, key)?; + let response = send_request(&endpoint.host, endpoint.port, request, log_fun).await?; + + let status = response.status(); + if !status.is_success() { + return Err(Error::Hyper(HyperErrorType::ServerError( + endpoint.to_string(), + status, + ))); + } + + let host_time_synced = sync_host_time_from_headers(response.headers()); + let body = read_response_body(response).await?; + Ok((body, host_time_synced)) +} + +/// Try to sync host time from a response header map. +/// Returns true when sync is updated successfully. +pub fn sync_host_time_from_headers(headers: &hyper::HeaderMap) -> bool { + // first try custom date header + if let Some(host_date) = headers.get(DATE_HEADER) { + if let Ok(host_date_rfc1123) = host_date.to_str() { + return misc_helpers::sync_host_utc_time_from_rfc1123_string(host_date_rfc1123); + } + } + + // fallback to standard HTTP date header + if let Some(host_date) = headers.get(hyper::header::DATE) { + if let Ok(host_date_rfc1123) = host_date.to_str() { + return misc_helpers::sync_host_utc_time_from_rfc1123_string(host_date_rfc1123); + } + } + + // return false if no valid date header found + false +} + pub async fn read_response_body( mut response: hyper::Response, ) -> Result diff --git a/proxy_agent_shared/src/misc_helpers.rs b/proxy_agent_shared/src/misc_helpers.rs index 9378c1b0..5f022dbf 100644 --- a/proxy_agent_shared/src/misc_helpers.rs +++ b/proxy_agent_shared/src/misc_helpers.rs @@ -11,6 +11,8 @@ use std::{ fs::{self, File}, path::{Path, PathBuf}, process::Command, + sync::RwLock, + time::Instant, }; use thread_id; use time::{format_description, OffsetDateTime, PrimitiveDateTime}; @@ -48,6 +50,14 @@ static RFC1123_FORMAT: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| RwLock::new(None)); + pub fn get_date_time_string_with_milliseconds() -> String { let time_str = OffsetDateTime::now_utc() .format(&*ISO8601_MILLIS_FORMAT) @@ -63,11 +73,56 @@ pub fn get_date_time_string() -> String { } pub fn get_date_time_rfc1123_string() -> String { - OffsetDateTime::now_utc() + get_current_utc_time_synced() .format(&*RFC1123_FORMAT) .expect("Failed to format RFC1123 date") } +/// Update host-time sync state from a RFC1123 datetime string. +/// Returns true when sync state is updated successfully, false otherwise. +pub fn sync_host_utc_time_from_rfc1123_string(host_utc_rfc1123: &str) -> bool { + let Ok(parsed_host_utc) = PrimitiveDateTime::parse(host_utc_rfc1123, &*RFC1123_FORMAT) else { + return false; + }; + + let Ok(mut state) = HOST_TIME_SYNC_STATE.write() else { + return false; + }; + + *state = Some(HostTimeSyncState { + synced_host_utc: parsed_host_utc.assume_utc(), + synced_instant: Instant::now(), + }); + true +} + +/// Returns true when current host-time sync state is older than `max_age`. +/// If there is no host-time sync state yet, this returns true. +pub fn host_time_sync_is_stale(max_age: std::time::Duration) -> bool { + let Ok(state) = HOST_TIME_SYNC_STATE.read() else { + return true; + }; + state + .as_ref() + .is_none_or(|synced| synced.synced_instant.elapsed() > max_age) +} + +fn get_current_utc_time_synced() -> OffsetDateTime { + let Ok(state) = HOST_TIME_SYNC_STATE.read() else { + return OffsetDateTime::now_utc(); + }; + + let Some(synced) = state.as_ref() else { + return OffsetDateTime::now_utc(); + }; + + let elapsed = synced.synced_instant.elapsed(); + match time::Duration::try_from(elapsed) { + Ok(elapsed_time) => synced.synced_host_utc + elapsed_time, + Err(_) => OffsetDateTime::now_utc(), + } +} + pub fn get_date_time_unix_nano() -> i128 { OffsetDateTime::now_utc().unix_timestamp_nanos() } @@ -471,6 +526,7 @@ mod tests { use std::env; use std::fs; use std::path::PathBuf; + use std::time::Duration; #[derive(Serialize, Deserialize)] struct TestStruct { @@ -823,4 +879,32 @@ mod tests { "Should fail to parse invalid datetime string" ); } + + #[test] + fn sync_host_utc_time_from_rfc1123_string_test() { + let host_time = "Mon, 01 Jan 2024 00:00:00 GMT"; + assert!( + super::sync_host_utc_time_from_rfc1123_string(host_time), + "Expected valid host RFC1123 time to update sync state" + ); + + assert!( + !super::host_time_sync_is_stale(Duration::from_secs(3600)), + "Sync state should not be stale right after update" + ); + + std::thread::sleep(Duration::from_millis(1)); + assert!( + super::host_time_sync_is_stale(Duration::from_millis(0)), + "Sync state should be stale when max_age is zero" + ); + } + + #[test] + fn sync_host_utc_time_from_rfc1123_string_invalid_input_test() { + assert!( + !super::sync_host_utc_time_from_rfc1123_string("invalid-rfc1123"), + "Expected invalid host RFC1123 time to fail" + ); + } } From 2832d232709574f50b5c9c74fb7d8757abef8b9c Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 14 Apr 2026 13:52:27 -0700 Subject: [PATCH 02/11] add logging --- proxy_agent/src/key_keeper/key.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index 7a2bf662..2efb711f 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -729,7 +729,7 @@ impl Display for KeyAction { const STATUS_URL: &str = "/secure-channel/status"; const KEY_URL: &str = "/secure-channel/key"; -const HOST_DATE_TIME_SYNC_MAX_AGE: Duration = Duration::from_secs(60); +const HOST_DATE_TIME_SYNC_MAX_AGE: Duration = Duration::from_secs(60 * 15); /// Get the current status of the key from the secure channel. /// This function will perform a GET request to the secure channel status endpoint. @@ -744,15 +744,16 @@ pub async fn get_status(host: &str, port: u16) -> Result { let status: KeyStatus = if proxy_agent_shared::misc_helpers::host_time_sync_is_stale(HOST_DATE_TIME_SYNC_MAX_AGE) { - hyper_client::get_and_sync_host_time( + let (key_status, host_time_synced) = hyper_client::get_and_sync_host_time( &endpoint, &headers, None, None, logger::write_warning, ) - .await? - .0 + .await?; + logger::write(format!("Host time synced: {host_time_synced}",)); + key_status } else { hyper_client::get(&endpoint, &headers, None, None, logger::write_warning).await? }; From 5a56e16c924a2dbd93fa26aebf178f5e6d76a24f Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Wed, 15 Apr 2026 12:42:57 -0700 Subject: [PATCH 03/11] fix typo --- .github/actions/spelling/expect.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 8a5c2865..eaaeca46 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -206,6 +206,7 @@ openprocess oneshot opencode opensource +parseable PERCPU pgpkey pgrep From d6d01d6ffe6e33fbb0ff8325aa4aee982d7abe47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:50:28 -0700 Subject: [PATCH 04/11] Bump rand from 0.8.5 to 0.8.6 (#339) Bumps [rand](https://github.com/rust-random/rand) from 0.8.5 to 0.8.6. - [Release notes](https://github.com/rust-random/rand/releases) - [Changelog](https://github.com/rust-random/rand/blob/0.8.6/CHANGELOG.md) - [Commits](https://github.com/rust-random/rand/compare/0.8.5...0.8.6) --- updated-dependencies: - dependency-name: rand dependency-version: 0.8.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1df43fca..cec20c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -980,9 +980,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", From 1bf4b3f64d98138a581a13c90cf576f7416c2895 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:06:24 +0000 Subject: [PATCH 05/11] Bump openssl from 0.10.73 to 0.10.78 (#338) Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.73 to 0.10.78. - [Release notes](https://github.com/rust-openssl/rust-openssl/releases) - [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.73...openssl-v0.10.78) --- updated-dependencies: - dependency-name: openssl dependency-version: 0.10.78 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zhidong Peng --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cec20c17..ce02cf76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,9 +824,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags", "cfg-if", @@ -859,9 +859,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", From 7e627743faffd4c13a3bbb432c90c4fd390a77f7 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 23 Apr 2026 21:21:27 +0000 Subject: [PATCH 06/11] resolve comments Co-authored-by: Copilot --- proxy_agent/src/key_keeper/key.rs | 45 ++++++---- proxy_agent_shared/src/hyper_client.rs | 119 ++++++++++++++++++------- proxy_agent_shared/src/misc_helpers.rs | 19 +++- 3 files changed, 130 insertions(+), 53 deletions(-) diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index 2efb711f..cc850e55 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -729,11 +729,11 @@ impl Display for KeyAction { const STATUS_URL: &str = "/secure-channel/status"; const KEY_URL: &str = "/secure-channel/key"; -const HOST_DATE_TIME_SYNC_MAX_AGE: Duration = Duration::from_secs(60 * 15); +const HOST_DATE_TIME_DRIFT_MAX_AGE: Duration = Duration::from_secs(60 * 15); /// Get the current status of the key from the secure channel. -/// This function will perform a GET request to the secure channel status endpoint. -/// If the host time sync is stale, it will use `get_and_sync_host_time` to update the host time. +/// This function will perform a single GET request to the secure channel status endpoint. +/// If the host time sync is stale, it will sync the host time from the response headers. pub async fn get_status(host: &str, port: u16) -> Result { let endpoint = hyper_client::HostEndpoint::new(host, port, STATUS_URL); let mut headers = HashMap::new(); @@ -742,22 +742,31 @@ pub async fn get_status(host: &str, port: u16) -> Result { "True ".to_string(), ); - let status: KeyStatus = - if proxy_agent_shared::misc_helpers::host_time_sync_is_stale(HOST_DATE_TIME_SYNC_MAX_AGE) { - let (key_status, host_time_synced) = hyper_client::get_and_sync_host_time( - &endpoint, - &headers, - None, - None, - logger::write_warning, - ) - .await?; - logger::write(format!("Host time synced: {host_time_synced}",)); - key_status - } else { - hyper_client::get(&endpoint, &headers, None, None, logger::write_warning).await? - }; + let request = hyper_client::build_request(Method::GET, &endpoint, &headers, None, None, None)?; + + let response = hyper_client::send_request( + &endpoint.host, + endpoint.port, + request, + logger::write_warning, + ) + .await?; + + if !response.status().is_success() { + return Err(proxy_agent_shared::error::Error::Hyper( + proxy_agent_shared::error::HyperErrorType::ServerError( + endpoint.to_string(), + response.status(), + ), + ))?; + } + + if proxy_agent_shared::misc_helpers::host_time_sync_is_stale(HOST_DATE_TIME_DRIFT_MAX_AGE) { + let host_time_synced = hyper_client::sync_host_time_from_headers(response.headers()); + logger::write(format!("Host time synced: {host_time_synced}")); + } + let status: KeyStatus = hyper_client::read_response_body(response).await?; status.validate()?; Ok(status) diff --git a/proxy_agent_shared/src/hyper_client.rs b/proxy_agent_shared/src/hyper_client.rs index 5740ec5c..71175448 100644 --- a/proxy_agent_shared/src/hyper_client.rs +++ b/proxy_agent_shared/src/hyper_client.rs @@ -142,39 +142,10 @@ where read_response_body(response).await } -/// Performs a GET request and updates the shared host-time sync state from -/// `x-ms-azure-host-date` response header if available and parseable. -/// -/// Returns `Ok(true)` when sync state was updated, `Ok(false)` when header is -/// missing/invalid, and `Err` on transport/server status failure. -pub async fn get_and_sync_host_time( - endpoint: &HostEndpoint, - headers: &HashMap, - key_guid: Option, - key: Option, - log_fun: F, -) -> Result<(T, bool)> -where - T: DeserializeOwned, - F: Fn(String) + Send + 'static, -{ - let request = build_request(Method::GET, endpoint, headers, None, key_guid, key)?; - let response = send_request(&endpoint.host, endpoint.port, request, log_fun).await?; - - let status = response.status(); - if !status.is_success() { - return Err(Error::Hyper(HyperErrorType::ServerError( - endpoint.to_string(), - status, - ))); - } - - let host_time_synced = sync_host_time_from_headers(response.headers()); - let body = read_response_body(response).await?; - Ok((body, host_time_synced)) -} - /// Try to sync host time from a response header map. +/// The function first looks for a custom date header (x-ms-azure-host-date) and +/// if not found, falls back to the standard HTTP date header. +/// if custom date header is present but invalid, it will not fall back to standard date header /// Returns true when sync is updated successfully. pub fn sync_host_time_from_headers(headers: &hyper::HeaderMap) -> bool { // first try custom date header @@ -592,7 +563,7 @@ mod tests { use crate::{ host_clients::{imds_client::ImdsClient, wire_server_client::WireServerClient}, logger::logger_manager, - server_mock, + misc_helpers, server_mock, }; use tokio_util::sync::CancellationToken; @@ -661,4 +632,86 @@ mod tests { cancellation_token.cancel(); } + + #[test] + fn sync_host_time_from_headers_tests() { + // should return false when no date headers are present + let headers = hyper::HeaderMap::new(); + assert!( + !super::sync_host_time_from_headers(&headers), + "should return false when no date headers are present" + ); + + // should return true with valid custom date header + let mut headers = hyper::HeaderMap::new(); + headers.insert( + super::DATE_HEADER, + "Wed, 23 Apr 2025 12:00:00 GMT".parse().unwrap(), + ); + assert!( + super::sync_host_time_from_headers(&headers), + "should return true with valid custom date header" + ); + + // should return true with valid standard Date header + let mut headers = hyper::HeaderMap::new(); + headers.insert( + hyper::header::DATE, + "Wed, 23 Apr 2025 12:00:00 GMT".parse().unwrap(), + ); + assert!( + super::sync_host_time_from_headers(&headers), + "should return true with valid standard Date header" + ); + + // when both headers are present but invalid, should return false without panic (to_str() succeeds but RFC1123 parse fails) + let mut headers = hyper::HeaderMap::new(); + headers.insert(super::DATE_HEADER, "not-a-valid-date".parse().unwrap()); + headers.insert(hyper::header::DATE, "also-not-valid".parse().unwrap()); + assert!( + !super::sync_host_time_from_headers(&headers), + "should return false when both headers have invalid dates" + ); + + // When the custom header is present but has an invalid date string, + // the function returns false immediately (does not fall back to standard Date header) + // because to_str() succeeds but the RFC1123 parse fails. + let mut headers = hyper::HeaderMap::new(); + headers.insert(super::DATE_HEADER, "not-a-valid-date".parse().unwrap()); + headers.insert( + hyper::header::DATE, + "Wed, 23 Apr 2025 12:00:00 GMT".parse().unwrap(), + ); + assert!( + !super::sync_host_time_from_headers(&headers), + "should return false without falling back when custom header has invalid date" + ); + + // when both headers are present and valid, should return true (sync from custom header takes precedence) + // and not fallback to standard Date header + let mut headers = hyper::HeaderMap::new(); + let custom_host_time = "Wed, 23 Apr 2025 12:00:00 GMT"; + headers.insert(super::DATE_HEADER, custom_host_time.parse().unwrap()); + headers.insert( + hyper::header::DATE, + "Thu, 24 Apr 2025 12:00:00 GMT".parse().unwrap(), + ); + // Both are valid; the function should return true + assert!( + super::sync_host_time_from_headers(&headers), + "should return true when both headers are present" + ); + // verify the sync time is from the custom header, not the standard Date header (which is 1 day later) + let sync_time = misc_helpers::parse_rfc1123_to_offset_datetime( + &misc_helpers::get_date_time_rfc1123_string(), + ) + .unwrap(); + let expected_sync_time = + misc_helpers::parse_rfc1123_to_offset_datetime(custom_host_time).unwrap(); + let diff = sync_time - expected_sync_time; + assert!( + diff < time::Duration::seconds(5), + "sync time should be close to the custom header time" + ); + } } diff --git a/proxy_agent_shared/src/misc_helpers.rs b/proxy_agent_shared/src/misc_helpers.rs index 5f022dbf..9d121c0d 100644 --- a/proxy_agent_shared/src/misc_helpers.rs +++ b/proxy_agent_shared/src/misc_helpers.rs @@ -81,7 +81,7 @@ pub fn get_date_time_rfc1123_string() -> String { /// Update host-time sync state from a RFC1123 datetime string. /// Returns true when sync state is updated successfully, false otherwise. pub fn sync_host_utc_time_from_rfc1123_string(host_utc_rfc1123: &str) -> bool { - let Ok(parsed_host_utc) = PrimitiveDateTime::parse(host_utc_rfc1123, &*RFC1123_FORMAT) else { + let Ok(parsed_host_utc) = parse_rfc1123_to_offset_datetime(host_utc_rfc1123) else { return false; }; @@ -90,12 +90,22 @@ pub fn sync_host_utc_time_from_rfc1123_string(host_utc_rfc1123: &str) -> bool { }; *state = Some(HostTimeSyncState { - synced_host_utc: parsed_host_utc.assume_utc(), + synced_host_utc: parsed_host_utc, synced_instant: Instant::now(), }); true } +pub fn parse_rfc1123_to_offset_datetime(rfc1123_str: &str) -> Result { + PrimitiveDateTime::parse(rfc1123_str, &*RFC1123_FORMAT) + .map(|dt| dt.assume_utc()) + .map_err(|e| { + Error::ParseDateTimeStringError(format!( + "Failed to parse RFC1123 datetime string '{rfc1123_str}': {e}" + )) + }) +} + /// Returns true when current host-time sync state is older than `max_age`. /// If there is no host-time sync state yet, this returns true. pub fn host_time_sync_is_stale(max_age: std::time::Duration) -> bool { @@ -898,6 +908,11 @@ mod tests { super::host_time_sync_is_stale(Duration::from_millis(0)), "Sync state should be stale when max_age is zero" ); + + // reset sync state to None for other tests + let _ = super::HOST_TIME_SYNC_STATE + .write() + .map(|mut state| *state = None); } #[test] From 1cbe24a7975758d9125742f8669c4bcc9deee200 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 23 Apr 2026 21:35:18 +0000 Subject: [PATCH 07/11] fix spelling --- .github/actions/spelling/expect.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index eaaeca46..6e010cc7 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -302,6 +302,7 @@ testurl tgid THH thiserror +Thu timedout timeup tlsv From 79f879b5312cd7a6f05699431b22aa7d149b89f0 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Mon, 27 Apr 2026 18:06:11 +0000 Subject: [PATCH 08/11] cmdline to take the first 4 arguments Co-authored-by: Copilot --- proxy_agent/src/proxy.rs | 4 +++- proxy_agent/src/proxy/windows.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index 9278e634..50a72e1c 100644 --- a/proxy_agent/src/proxy.rs +++ b/proxy_agent/src/proxy.rs @@ -112,10 +112,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; only take the first 4 arguments + // to avoid capturing credentials that may appear in later args bytes .split(|&b| b == 0) .filter(|s| !s.is_empty()) + .take(4) .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..d213323a 100644 --- a/proxy_agent/src/proxy/windows.rs +++ b/proxy_agent/src/proxy/windows.rs @@ -292,7 +292,15 @@ 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 4 arguments to avoid capturing credentials + // that may appear in later command-line arguments + let truncated_cmd = cmd + .split_whitespace() + .take(4) + .collect::>() + .join(" "); + + Ok(truncated_cmd) } #[allow(dead_code)] From 6a204e02c863c25865a495949d4b482f90084d01 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Mon, 27 Apr 2026 11:11:45 -0700 Subject: [PATCH 09/11] fix in common code path Co-authored-by: Copilot --- proxy_agent/src/proxy.rs | 14 ++++++++++---- proxy_agent/src/proxy/windows.rs | 10 +--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index 50a72e1c..9cfcdadb 100644 --- a/proxy_agent/src/proxy.rs +++ b/proxy_agent/src/proxy.rs @@ -112,12 +112,10 @@ 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; only take the first 4 arguments - // to avoid capturing credentials that may appear in later args + // cmdline is null-separated, convert to space-separated string bytes .split(|&b| b == 0) .filter(|s| !s.is_empty()) - .take(4) .map(|s| String::from_utf8_lossy(s).into_owned()) .collect::>() .join(" ") @@ -206,8 +204,16 @@ impl Process { cmd = process_info.1; } + // Only keep the first 4 arguments to avoid capturing credentials + // that may appear in later command-line arguments + let truncated_cmd = cmd + .split_whitespace() + .take(4) + .collect::>() + .join(" "); + // redact the secrets in the command line - let cmd = proxy_agent_shared::secrets_redactor::redact_secrets_string(cmd); + let cmd = proxy_agent_shared::secrets_redactor::redact_secrets_string(truncated_cmd); let process_name = process_full_path .file_name() diff --git a/proxy_agent/src/proxy/windows.rs b/proxy_agent/src/proxy/windows.rs index d213323a..acc770eb 100644 --- a/proxy_agent/src/proxy/windows.rs +++ b/proxy_agent/src/proxy/windows.rs @@ -292,15 +292,7 @@ pub fn get_process_cmd(handler: isize) -> Result { std::slice::from_raw_parts(cmd_buffer.Buffer, (cmd_buffer.Length / 2) as usize) }); - // Only keep the first 4 arguments to avoid capturing credentials - // that may appear in later command-line arguments - let truncated_cmd = cmd - .split_whitespace() - .take(4) - .collect::>() - .join(" "); - - Ok(truncated_cmd) + Ok(cmd) } #[allow(dead_code)] From 143ef66d9ed7eba99ade5e986ecbe5d6af7218a0 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Mon, 27 Apr 2026 20:40:43 +0000 Subject: [PATCH 10/11] respecting double-quoted strings as an arg Co-authored-by: Copilot --- proxy_agent/src/proxy.rs | 15 +++---- proxy_agent/src/proxy/windows.rs | 67 +++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index 9cfcdadb..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(" ") @@ -204,16 +207,8 @@ impl Process { cmd = process_info.1; } - // Only keep the first 4 arguments to avoid capturing credentials - // that may appear in later command-line arguments - let truncated_cmd = cmd - .split_whitespace() - .take(4) - .collect::>() - .join(" "); - // redact the secrets in the command line - let cmd = proxy_agent_shared::secrets_redactor::redact_secrets_string(truncated_cmd); + let cmd = proxy_agent_shared::secrets_redactor::redact_secrets_string(cmd); let process_name = process_full_path .file_name() diff --git a/proxy_agent/src/proxy/windows.rs b/proxy_agent/src/proxy/windows.rs index acc770eb..cf29c20f 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,30 @@ 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" + ); + } } From 9c8aebd4a53b5347da59e2bc305125f283834a2e Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Mon, 27 Apr 2026 20:46:35 +0000 Subject: [PATCH 11/11] fix formatting --- .github/actions/spelling/expect.txt | 1 + proxy_agent/src/proxy/windows.rs | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) 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/windows.rs b/proxy_agent/src/proxy/windows.rs index cf29c20f..3667c635 100644 --- a/proxy_agent/src/proxy/windows.rs +++ b/proxy_agent/src/proxy/windows.rs @@ -424,23 +424,28 @@ mod 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"); - + 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 + 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"#, + result, r#""C:\Program Files\app.exe" arg1 "arg with spaces" arg3"#, "should treat quoted strings with whitespace as single args" ); }