From 31e20e59997a7d501aacdb0be6bd4728c5af2438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:52:25 -0400 Subject: [PATCH 01/53] feat(agent): add policy audit events Record bounded write attempts and operation-specific outcomes without exposing policy content or blocking request admission on Event Log I/O. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 + crates/now-package-broker/Cargo.toml | 3 + crates/now-package-broker/src/audit.rs | 545 ++++++++++++++++++++ crates/now-package-broker/src/auth.rs | 4 + crates/now-package-broker/src/lib.rs | 2 + crates/now-package-broker/src/server/mod.rs | 22 +- crates/sysevent-codes/src/lib.rs | 340 ++++++++++++ 7 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 crates/now-package-broker/src/audit.rs diff --git a/Cargo.lock b/Cargo.lock index 8e668cbb0..af374ee74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4826,6 +4826,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sysevent", + "sysevent-codes", + "sysevent-winevent", "tempfile", "tokio 1.52.3", "tokio-util", diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 662cbc510..344366322 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -42,6 +42,9 @@ semver = "1" serde = "1" serde_json = "1" sha2 = "0.10" +sysevent = { path = "../sysevent" } +sysevent-codes = { path = "../sysevent-codes" } +sysevent-winevent = { path = "../sysevent-winevent" } tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] } tokio-util = "0.7" tower-service = "0.3" diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs new file mode 100644 index 000000000..309c58e2f --- /dev/null +++ b/crates/now-package-broker/src/audit.rs @@ -0,0 +1,545 @@ +//! Structured audit events for policy management writes and external policy changes. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +#[cfg(all(not(test), not(debug_assertions)))] +use std::sync::atomic::AtomicU64; +use std::sync::atomic::{AtomicBool, Ordering}; + +use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +#[cfg(not(test))] +use sysevent::Severity; +#[cfg(all(not(test), not(debug_assertions)))] +use sysevent::SystemEventSink; +use win_api_wrappers::identity::sid::Sid; + +const INTENT: &str = "PUT /v1/policy"; +const MAX_SID_BYTES: usize = 256; +const MAX_PATH_BYTES: usize = 1024; +const MAX_POLICY_ID_BYTES: usize = 256; +#[cfg(all(not(test), not(debug_assertions)))] +const EVENT_LOG_QUEUE_CAPACITY: usize = 256; + +static RECORDER: std::sync::LazyLock> = std::sync::LazyLock::new(default_recorder); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DenialReason { + AuthenticationFailed, + AdministratorRequired, + RequestRejected, +} + +impl DenialReason { + const fn as_str(self) -> &'static str { + match self { + Self::AuthenticationFailed => "authentication_failed", + Self::AdministratorRequired => "administrator_required", + Self::RequestRejected => "request_rejected", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailureReason { + MonitoringUnavailable, + StaleStoreToken, + PathNotWritable, + InvalidPolicy, + InvalidReceipt, + WarningsNotAcknowledged, + RevisionConflict, + DraftCommitFailed, + SerializationFailed, + PersistenceFailed, + ConditionalPublicationFailed, + ActivationFailed, +} + +impl FailureReason { + const fn as_str(self) -> &'static str { + match self { + Self::MonitoringUnavailable => "monitoring_unavailable", + Self::StaleStoreToken => "stale_store_token", + Self::PathNotWritable => "path_not_writable", + Self::InvalidPolicy => "invalid_policy", + Self::InvalidReceipt => "invalid_receipt", + Self::WarningsNotAcknowledged => "warnings_not_acknowledged", + Self::RevisionConflict => "revision_conflict", + Self::DraftCommitFailed => "draft_commit_failed", + Self::SerializationFailed => "serialization_failed", + Self::PersistenceFailed => "persistence_failed", + Self::ConditionalPublicationFailed => "conditional_publication_failed", + Self::ActivationFailed => "activation_failed", + } + } +} + +trait AuditRecorder: Send + Sync { + fn record(&self, entry: sysevent::Entry); +} + +fn default_recorder() -> Arc { + #[cfg(test)] + { + Arc::new(TestRecorder) + } + #[cfg(all(not(test), debug_assertions))] + { + Arc::new(TracingRecorder) + } + #[cfg(all(not(test), not(debug_assertions)))] + { + match SystemRecorder::new() { + Ok(recorder) => Arc::new(recorder), + Err(error) => { + tracing::error!(%error, "Failed to start the Windows Event Log policy audit worker"); + Arc::new(TracingRecorder) + } + } + } +} + +#[cfg(test)] +std::thread_local! { + static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +struct TestRecorder; + +#[cfg(test)] +impl AuditRecorder for TestRecorder { + fn record(&self, entry: sysevent::Entry) { + TEST_EVENTS.with(|events| events.borrow_mut().push(entry)); + } +} + +#[cfg(test)] +pub(crate) fn take_test_events() -> Vec { + TEST_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) +} + +#[cfg(not(test))] +struct TracingRecorder; + +#[cfg(not(test))] +impl AuditRecorder for TracingRecorder { + fn record(&self, entry: sysevent::Entry) { + trace_entry(&entry); + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +struct SystemRecorder { + sender: std::sync::mpsc::SyncSender, + dropped: AtomicU64, +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl SystemRecorder { + fn new() -> std::io::Result { + let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); + std::thread::Builder::new() + .name("policy-audit-event-log".to_owned()) + .spawn(move || event_log_worker(&receiver)) + .map(|_| Self { + sender, + dropped: AtomicU64::new(0), + }) + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl AuditRecorder for SystemRecorder { + fn record(&self, entry: sysevent::Entry) { + trace_entry(&entry); + if let Err(error) = self.sender.try_send(entry) { + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + if dropped.is_power_of_two() { + tracing::warn!( + dropped, + error = %match error { + std::sync::mpsc::TrySendError::Full(_) => "queue_full", + std::sync::mpsc::TrySendError::Disconnected(_) => "worker_disconnected", + }, + "Dropped policy audit Windows Event Log entries" + ); + } + } + } +} + +#[cfg(not(test))] +fn trace_entry(entry: &sysevent::Entry) { + let code = entry.event_code; + let message = &entry.message; + let fields = &entry.fields; + match entry.severity { + Severity::Critical | Severity::Error => tracing::error!(?code, %message, ?fields, "Policy audit event"), + Severity::Warning => tracing::warn!(?code, %message, ?fields, "Policy audit event"), + Severity::Notice | Severity::Info | Severity::Debug => { + tracing::info!(?code, %message, ?fields, "Policy audit event"); + } + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { + let sink: Arc = match sysevent_winevent::WinEvent::new("Devolutions Agent") { + Ok(event_log) => Arc::new(event_log), + Err(error) => { + tracing::error!(%error, "Failed to initialize the Windows Event Log policy audit sink"); + Arc::new(sysevent::NoopSink) + } + }; + for entry in receiver { + if let Err(error) = sink.emit(entry) { + tracing::warn!(%error, "Failed to emit policy audit event to the Windows Event Log"); + } + } +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct RecordingAudit(parking_lot::Mutex>); + +#[cfg(test)] +impl RecordingAudit { + pub(crate) fn events(&self) -> Vec { + self.0.lock().clone() + } +} + +#[cfg(test)] +impl AuditRecorder for RecordingAudit { + fn record(&self, entry: sysevent::Entry) { + self.0.lock().push(entry); + } +} + +struct WriteAuditState { + actor_sid: String, + actor_exe: String, + path: PathBuf, + terminal_recorded: AtomicBool, + recorder: Arc, +} + +impl Drop for WriteAuditState { + fn drop(&mut self) { + if !self.terminal_recorded.swap(true, Ordering::AcqRel) { + self.record(sysevent_codes::policy_write_denied( + &self.actor_sid, + &self.actor_exe, + INTENT, + &self.path, + DenialReason::RequestRejected.as_str(), + )); + } + } +} + +#[derive(Clone)] +pub(crate) struct WriteAudit(Arc); + +impl WriteAudit { + pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> Self { + Self::begin_with_recorder(actor_sid, actor_exe, path, Arc::clone(&RECORDER)) + } + + fn begin_with_recorder(actor_sid: &Sid, actor_exe: &Path, path: &Path, recorder: Arc) -> Self { + let state = Arc::new(WriteAuditState { + actor_sid: bounded(actor_sid.to_string(), MAX_SID_BYTES), + actor_exe: bounded(actor_exe.display().to_string(), MAX_PATH_BYTES), + path: bounded_path(path), + terminal_recorded: AtomicBool::new(false), + recorder, + }); + state.record(sysevent_codes::policy_write_attempted( + &state.actor_sid, + &state.actor_exe, + INTENT, + &state.path, + )); + Self(state) + } + + #[cfg(test)] + pub(crate) fn begin_recording(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (Self, Arc) { + let recorder = Arc::new(RecordingAudit::default()); + let recorder_sink = Arc::::clone(&recorder); + let audit = Self::begin_with_recorder(actor_sid, actor_exe, path, recorder_sink); + (audit, recorder) + } + + pub(crate) fn denied(&self, reason: DenialReason) { + self.finish(|state| { + sysevent_codes::policy_write_denied( + &state.actor_sid, + &state.actor_exe, + INTENT, + &state.path, + reason.as_str(), + ) + }); + } + + pub(crate) fn failed(&self, operation: PolicyReplacementOperation, reason: FailureReason) { + self.failed_at(operation, &self.0.path.clone(), reason); + } + + pub(crate) fn failed_at(&self, operation: PolicyReplacementOperation, path: &Path, reason: FailureReason) { + let path = bounded_path(path); + let operation_name = operation_name(operation); + let outcome = if reason == FailureReason::StaleStoreToken { + "stale_conflict" + } else { + "failed" + }; + self.finish(|state| { + if operation == PolicyReplacementOperation::Create { + sysevent_codes::policy_create_failed( + &state.actor_sid, + &state.actor_exe, + INTENT, + path, + operation_name, + outcome, + reason.as_str(), + ) + } else { + sysevent_codes::policy_change_failed( + &state.actor_sid, + &state.actor_exe, + INTENT, + path, + operation_name, + outcome, + reason.as_str(), + ) + } + }); + } + + #[expect( + clippy::too_many_arguments, + reason = "the terminal event records operation and both policy identities" + )] + pub(crate) fn succeeded_at( + &self, + path: &Path, + old_id: Option<&str>, + old_revision: Option, + new_id: &str, + new_revision: u32, + operation: PolicyReplacementOperation, + confirmed_overwrite: bool, + ) { + let path = bounded_path(path); + let old_id = bounded(old_id.unwrap_or("").to_owned(), MAX_POLICY_ID_BYTES); + let old_revision = old_revision.map_or_else(|| "none".to_owned(), |revision| revision.to_string()); + let new_id = bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES); + let operation_name = operation_name(operation); + let outcome = if confirmed_overwrite { + "confirmed_overwrite" + } else { + "applied" + }; + self.finish(|state| { + if operation == PolicyReplacementOperation::Create { + sysevent_codes::policy_create_succeeded( + &state.actor_sid, + &state.actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + INTENT, + operation_name, + outcome, + ) + } else { + sysevent_codes::policy_change_succeeded( + &state.actor_sid, + &state.actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + INTENT, + operation_name, + outcome, + ) + } + }); + } + + fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> sysevent::Entry) { + if self + .0 + .terminal_recorded + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.0.record(entry(&self.0)); + } + } +} + +impl WriteAuditState { + fn record(&self, entry: sysevent::Entry) { + self.recorder.record(entry); + } +} + +pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { + RECORDER.record(sysevent_codes::policy_external_change_applied( + bounded_path(path), + bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), + new_revision, + )); +} + +pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { + let reason = match state { + PolicyManagementState::Active => "active", + PolicyManagementState::Missing => "missing", + PolicyManagementState::Invalid => "invalid", + }; + RECORDER.record(sysevent_codes::policy_external_change_rejected( + bounded_path(path), + reason, + )); +} + +fn bounded(mut value: String, max_bytes: usize) -> String { + value = value + .chars() + .map(|character| if character.is_control() { ' ' } else { character }) + .collect(); + if value.len() <= max_bytes { + return value; + } + const SUFFIX: &str = "..."; + let mut end = max_bytes - SUFFIX.len(); + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); + value.push_str(SUFFIX); + value +} + +fn bounded_path(path: &Path) -> PathBuf { + PathBuf::from(bounded(path.display().to_string(), MAX_PATH_BYTES)) +} + +const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { + match operation { + PolicyReplacementOperation::Create => "create", + PolicyReplacementOperation::Update => "update", + PolicyReplacementOperation::Repair => "repair", + PolicyReplacementOperation::ReplaceIdentity => "replace_identity", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_audit() -> (WriteAudit, Arc) { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + } + + #[test] + fn attempt_precedes_denial_and_only_one_terminal_event_is_recorded() { + let (audit, recorder) = test_audit(); + audit.denied(DenialReason::AuthenticationFailed); + audit.failed(PolicyReplacementOperation::Update, FailureReason::InvalidPolicy); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_WRITE_DENIED) + ] + ); + } + + #[test] + fn audit_values_are_bounded_and_fields_are_allowlisted() { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + let long = "é".repeat(MAX_PATH_BYTES); + let (audit, recorder) = WriteAudit::begin_recording(&sid, Path::new(&long), Path::new(&long)); + audit.succeeded_at( + Path::new(&long), + Some(&long), + Some(1), + &long, + 2, + PolicyReplacementOperation::Update, + false, + ); + + let events = recorder.events(); + let entry = &events[1]; + assert!(entry.fields.iter().all(|(name, value)| { + matches!( + name.as_str(), + "actor_sid" + | "actor_exe" + | "intent" + | "path" + | "old_id" + | "old_revision" + | "new_id" + | "new_revision" + | "operation" + | "outcome" + ) && value.len() <= MAX_PATH_BYTES + })); + for forbidden in ["body", "draft", "policy", "receipt", "store_token"] { + assert!(!entry.fields.iter().any(|(name, _)| name == forbidden)); + } + } + + #[test] + fn terminal_event_codes_follow_the_replacement_operation() { + for (operation, failure_code, success_code) in [ + ( + PolicyReplacementOperation::Create, + sysevent_codes::POLICY_CREATE_FAILED, + sysevent_codes::POLICY_CREATE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::Update, + sysevent_codes::POLICY_CHANGE_FAILED, + sysevent_codes::POLICY_CHANGE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::Repair, + sysevent_codes::POLICY_CHANGE_FAILED, + sysevent_codes::POLICY_CHANGE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::ReplaceIdentity, + sysevent_codes::POLICY_CHANGE_FAILED, + sysevent_codes::POLICY_CHANGE_SUCCEEDED, + ), + ] { + let (failed, failed_recorder) = test_audit(); + failed.failed(operation, FailureReason::StaleStoreToken); + assert_eq!(failed_recorder.events()[1].event_code, Some(failure_code)); + + let (succeeded, succeeded_recorder) = test_audit(); + succeeded.succeeded_at(Path::new(r"C:\policy.json"), None, None, "new", 1, operation, true); + assert_eq!(succeeded_recorder.events()[1].event_code, Some(success_code)); + } + } +} diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index d891f6351..d647e750c 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -222,6 +222,10 @@ impl PipeClient { &self.user_sid } + pub(crate) fn executable_path(&self) -> &Path { + &self.executable_path + } + pub(crate) fn is_elevated_administrator(&self) -> bool { self.is_elevated && self.is_administrator } diff --git a/crates/now-package-broker/src/lib.rs b/crates/now-package-broker/src/lib.rs index e1542dda4..f8acf7e70 100644 --- a/crates/now-package-broker/src/lib.rs +++ b/crates/now-package-broker/src/lib.rs @@ -5,6 +5,8 @@ //! //! The broker is only functional on Windows; on other platforms this crate is empty. +#[cfg(windows)] +mod audit; #[cfg(windows)] mod auth; #[cfg(windows)] diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 61664d16c..77755dfd5 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -48,6 +49,7 @@ use responses::{ // The unit value marks the scope in which an authenticated policy management request is dispatched. tokio::task_local! { static POLICY_MANAGEMENT_AUTHENTICATED: (); + static POLICY_WRITE_AUDIT: crate::audit::WriteAudit; } /// How long a per-user manager availability probe stays fresh before it is re-run. @@ -293,6 +295,10 @@ async fn authenticate_policy_management( request: Request, next: Next, ) -> Response { + let write_audit = matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")).then(|| { + let configured_path = PathBuf::from(state.policy_store.management_snapshot().configured_path); + crate::audit::WriteAudit::begin(client.user_sid(), client.executable_path(), &configured_path) + }); let protected = matches!( (request.method(), request.uri().path()), (&Method::GET, "/v1/policy/management") @@ -302,6 +308,9 @@ async fn authenticate_policy_management( ); if protected { if let Err(error) = client.validate_connection(state.skip_signature_validation) { + if let Some(audit) = write_audit { + audit.denied(crate::audit::DenialReason::AuthenticationFailed); + } warn!(error = format!("{error:#}"), "Rejected policy management request"); return ( StatusCode::UNAUTHORIZED, @@ -312,7 +321,12 @@ async fn authenticate_policy_management( ) .into_response(); } - return POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)).await; + let authenticated = POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)); + return if let Some(audit) = write_audit { + POLICY_WRITE_AUDIT.scope(audit, authenticated).await + } else { + authenticated.await + }; } next.run(request).await } @@ -381,7 +395,11 @@ impl PackageBrokerServer for BrokerConnection { request: PolicyReplacementRequest, ) -> Result { require_policy_management_authentication()?; + let audit = POLICY_WRITE_AUDIT + .try_with(Clone::clone) + .map_err(|_| error_response(ErrorCode::InternalError, "policy write audit context is unavailable"))?; if !self.client.is_elevated_administrator() { + audit.denied(crate::audit::DenialReason::AdministratorRequired); return Err(error_response( ErrorCode::AdministratorRequired, "policy replacement requires an elevated Administrator", @@ -389,7 +407,7 @@ impl PackageBrokerServer for BrokerConnection { } self.state .policy_store - .replace(request) + .replace_audited(request, audit) .await .map(|success| PolicyReplacementResponse { response_kind: now_policy_api::PolicyReplacementResponseKind, diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index e2eaad987..1b93a4c80 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -380,6 +380,242 @@ pub fn recording_storage_low(remaining_bytes: u64, threshold_bytes: u64) -> Entr .field("threshold_bytes", threshold_bytes) } +// 8000-8099 **Package Broker / Policy Management** + +/// A policy write was received before any authorization check. +pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; +/// A policy write was denied by caller authorization. +pub const POLICY_WRITE_DENIED: u32 = 8001; +/// A Create operation failed. +pub const POLICY_CREATE_FAILED: u32 = 8002; +/// A Create operation succeeded. +pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; +/// An Update, Repair, or ReplaceIdentity operation failed. +pub const POLICY_CHANGE_FAILED: u32 = 8004; +/// An Update, Repair, or ReplaceIdentity operation succeeded. +pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; +/// An external policy change became active. +pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; +/// An external policy change left the policy unavailable. +pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; + +pub fn policy_write_attempted( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, +) -> Entry { + Entry::new("Policy management write attempted") + .event_code(POLICY_WRITE_ATTEMPTED) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) +} + +pub fn policy_write_denied( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + reason: impl ToString, +) -> Entry { + Entry::new("Policy management write denied") + .event_code(POLICY_WRITE_DENIED) + .severity(Severity::Warning) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("reason", reason) +} + +pub fn policy_create_failed( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + policy_write_failed( + POLICY_CREATE_FAILED, + "Policy creation failed", + actor_sid, + actor_exe, + intent, + path, + operation, + outcome, + reason, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_create_succeeded( + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + policy_write_succeeded( + POLICY_CREATE_SUCCEEDED, + "Policy creation succeeded", + actor_sid, + actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + intent, + operation, + outcome, + ) +} + +pub fn policy_change_failed( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + policy_write_failed( + POLICY_CHANGE_FAILED, + "Policy change failed", + actor_sid, + actor_exe, + intent, + path, + operation, + outcome, + reason, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_change_succeeded( + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + policy_write_succeeded( + POLICY_CHANGE_SUCCEEDED, + "Policy change succeeded", + actor_sid, + actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + intent, + operation, + outcome, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the four outcome events field-compatible" +)] +fn policy_write_failed( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Error) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("operation", operation) + .field("outcome", outcome) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the four outcome events field-compatible" +)] +fn policy_write_succeeded( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("path", path.as_ref().display()) + .field("old_id", old_id) + .field("old_revision", old_revision) + .field("new_id", new_id) + .field("new_revision", new_revision) + .field("intent", intent) + .field("operation", operation) + .field("outcome", outcome) +} + +pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { + Entry::new("External policy change applied") + .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) + .severity(Severity::Notice) + .field("path", path.as_ref().display()) + .field("new_id", new_id) + .field("new_revision", new_revision) +} + +pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { + Entry::new("External policy change rejected") + .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) + .severity(Severity::Warning) + .field("path", path.as_ref().display()) + .field("reason", reason) +} + // 9000-9099 **Diagnostics** pub const DEBUG_OPTIONS_ENABLED: u32 = 9001; @@ -399,3 +635,107 @@ pub fn xmf_not_found(path: impl AsRef, error: impl std::fmt::Display) -> E .field("path", path.as_ref().display()) .field("error_chain", format!("{error:#}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_audit_entries_preserve_catalog_field_order() { + const WRITE: &[&str] = &["actor_sid", "actor_exe", "intent", "path"]; + const DENIED: &[&str] = &["actor_sid", "actor_exe", "intent", "path", "reason"]; + const FAILED: &[&str] = &[ + "actor_sid", + "actor_exe", + "intent", + "path", + "operation", + "outcome", + "reason", + ]; + const SUCCEEDED: &[&str] = &[ + "actor_sid", + "actor_exe", + "path", + "old_id", + "old_revision", + "new_id", + "new_revision", + "intent", + "operation", + "outcome", + ]; + let entries = [ + ( + policy_write_attempted("sid", "exe", "intent", "path"), + POLICY_WRITE_ATTEMPTED, + Severity::Info, + WRITE, + ), + ( + policy_write_denied("sid", "exe", "intent", "path", "reason"), + POLICY_WRITE_DENIED, + Severity::Warning, + DENIED, + ), + ( + policy_create_failed("sid", "exe", "intent", "path", "create", "failed", "reason"), + POLICY_CREATE_FAILED, + Severity::Error, + FAILED, + ), + ( + policy_create_succeeded( + "sid", "exe", "path", "old", "1", "new", 2, "intent", "create", "applied", + ), + POLICY_CREATE_SUCCEEDED, + Severity::Info, + SUCCEEDED, + ), + ( + policy_change_failed("sid", "exe", "intent", "path", "update", "stale_conflict", "reason"), + POLICY_CHANGE_FAILED, + Severity::Error, + FAILED, + ), + ( + policy_change_succeeded( + "sid", + "exe", + "path", + "old", + "1", + "new", + 2, + "intent", + "update", + "confirmed_overwrite", + ), + POLICY_CHANGE_SUCCEEDED, + Severity::Info, + SUCCEEDED, + ), + ( + policy_external_change_applied("path", "new", 2), + POLICY_EXTERNAL_CHANGE_APPLIED, + Severity::Notice, + &["path", "new_id", "new_revision"], + ), + ( + policy_external_change_rejected("path", "invalid"), + POLICY_EXTERNAL_CHANGE_REJECTED, + Severity::Warning, + &["path", "reason"], + ), + ]; + + for (entry, code, severity, expected_fields) in entries { + assert_eq!(entry.event_code, Some(code)); + assert_eq!(entry.severity, severity); + assert_eq!( + entry.fields.iter().map(|(name, _)| name.as_str()).collect::>(), + expected_fields + ); + } + } +} From 27fe9ff179251d163470fa249413a10f33bef446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:52:36 -0400 Subject: [PATCH 02/53] build(dgw,agent): embed policy event catalogs Compile localized message resources for release and production builds using trusted installed Windows SDK tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 63 ++- devolutions-agent/build.rs | 87 ++++ devolutions-agent/devolutions-agent.mc | 468 +++++++++++++++++++++ devolutions-gateway/build.rs | 68 ++- devolutions-gateway/devolutions-gateway.mc | 85 ++++ 5 files changed, 744 insertions(+), 27 deletions(-) create mode 100644 devolutions-agent/devolutions-agent.mc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67dd3762..b832cc5d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,8 +354,6 @@ jobs: $VSINSTALLDIR = $(vswhere.exe -latest -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath) Write-Output "LIBCLANG_PATH=$VSINSTALLDIR\VC\Tools\Llvm\x64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell - Install-Module VsDevShell -Force shell: pwsh - name: Configure Windows (arm) runner @@ -696,9 +694,6 @@ jobs: # NASM is required by aws-lc-rs (used as rustls crypto backend) choco install nasm - # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell - Install-Module VsDevShell -Force - # We need to add the NASM binary folder to the PATH manually. Write-Output "$Env:ProgramFiles\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append shell: pwsh @@ -707,9 +702,31 @@ jobs: id: find_mc if: ${{ matrix.os == 'windows' }} run: | - Enter-VsDevShell - $path = (Get-Command -Type Application mc).Source | Split-Path -Parent + $sdkRoots = @( + $Env:WindowsSdkDir + (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue) + "${Env:ProgramFiles(x86)}\Windows Kits\10" + ) | Where-Object { $_ } | Select-Object -Unique + $candidates = @() + if ($Env:WindowsSdkVerBinPath) { + $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe" + $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe" + } + foreach ($root in $sdkRoots) { + $bin = Join-Path $root "bin" + $candidates += Join-Path $bin "x64\mc.exe" + $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue | + Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\mc.exe" } + } + $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-Not $mc) { + throw "mc.exe was not found in the installed Windows SDK" + } + $path = Split-Path -Parent $mc Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 shell: pwsh - name: Build @@ -975,6 +992,37 @@ jobs: if: ${{ matrix.os == 'windows' }} uses: microsoft/setup-msbuild@v3 + - name: Find mc.exe + id: find_mc + if: ${{ matrix.os == 'windows' }} + run: | + $sdkRoots = @( + $Env:WindowsSdkDir + (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue) + "${Env:ProgramFiles(x86)}\Windows Kits\10" + ) | Where-Object { $_ } | Select-Object -Unique + $candidates = @() + if ($Env:WindowsSdkVerBinPath) { + $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe" + $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe" + } + foreach ($root in $sdkRoots) { + $bin = Join-Path $root "bin" + $candidates += Join-Path $bin "x64\mc.exe" + $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue | + Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\mc.exe" } + } + $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-Not $mc) { + throw "mc.exe was not found in the installed Windows SDK" + } + $path = Split-Path -Parent $mc + Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + shell: pwsh + - name: Build run: | if ($Env:RUNNER_OS -eq "Windows") { @@ -985,6 +1033,7 @@ jobs: $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" + $Env:WindowsSdkVerBinPath = '${{ steps.find_mc.outputs.windows_sdk_ver_bin_path }}' } if ($Env:RUNNER_OS -eq "Linux") { diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index b8d9ad669..5cf58aaa4 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -3,6 +3,9 @@ fn main() { #[cfg(target_os = "windows")] win::embed_version_rc(); + + #[cfg(target_os = "windows")] + win::embed_devolutions_agent_mc(); } fn generate_psu_agent_proto() { @@ -100,4 +103,88 @@ END"#, version_rc } + + pub(super) fn embed_devolutions_agent_mc() { + use std::path::PathBuf; + use std::process::Command; + + let profile = env::var("PROFILE").unwrap_or_default(); + if !matches!(profile.as_str(), "release" | "production") { + return; + } + + let mc_exe = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe is required to embed the Devolutions Agent Event Log catalog; \ + use a Visual Studio developer shell or set WindowsSdkVerBinPath or WindowsSdkDir" + ) + }); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let catalog = manifest_dir.join("devolutions-agent.mc"); + println!("cargo:rerun-if-changed={}", catalog.display()); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let status = Command::new(mc_exe) + .current_dir(&out_dir) + .args(["-um", "-h", ".", "-r", "."]) + .arg(catalog.canonicalize().expect("canonicalize Agent message catalog")) + .status() + .expect("run mc.exe"); + assert!(status.success(), "mc.exe failed with status {status}"); + + let resource = out_dir.join("devolutions-agent.rc"); + assert!(resource.is_file(), "mc.exe did not generate {}", resource.display()); + embed_resource::compile(resource, embed_resource::NONE) + .manifest_required() + .expect("BUG: failed to embed devolutions-agent.rc"); + } + + fn find_mc() -> Option { + if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { + let sdk_bin = std::path::Path::new(&sdk_bin); + for candidate in [sdk_bin.join("mc.exe"), sdk_bin.join("x64").join("mc.exe")] { + if candidate.is_file() { + return Some(candidate); + } + } + } + + if let Some(candidate) = env::var_os("PATH").and_then(|path| { + env::split_paths(&path) + .map(|directory| directory.join("mc.exe")) + .find(|path| path.is_file()) + }) { + return Some(candidate); + } + + let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); + let direct = bin_dir.join("x64").join("mc.exe"); + if direct.is_file() { + return Some(direct); + } + + let mut versions: Vec<_> = fs::read_dir(bin_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + versions.sort_by_key(|path| { + std::cmp::Reverse( + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| { + name.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) + .unwrap_or_default(), + ) + }); + versions + .into_iter() + .map(|directory| directory.join("x64").join("mc.exe")) + .find(|path| path.is_file()) + } } diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc new file mode 100644 index 000000000..37d25f533 --- /dev/null +++ b/devolutions-agent/devolutions-agent.mc @@ -0,0 +1,468 @@ +; Devolutions Agent Windows Event Log message definitions. + +MessageIdTypedef=DWORD + +SeverityNames=( + Success=0x0:STATUS_SEVERITY_SUCCESS + Informational=0x1:STATUS_SEVERITY_INFORMATIONAL + Warning=0x2:STATUS_SEVERITY_WARNING + Error=0x3:STATUS_SEVERITY_ERROR +) + +FacilityNames=( + Application=0x0:FACILITY_APPLICATION +) + +LanguageNames=( + English=0x409:MSG00409 + French=0x40c:MSG0040c + German=0x407:MSG00407 +) + +; 1000-1099 Service / Lifecycle + +MessageId=1000 +SymbolicName=SERVICE_STARTED +Language=English +Service started. Context=%1 Version=%2 +Language=French +Service démarré. Contexte=%1 Version=%2 +Language=German +Dienst gestartet. Kontext=%1 Version=%2 +. + +MessageId=1001 +SymbolicName=SERVICE_STOPPING +Language=English +Service stopping. Context=%1 Reason=%2 +Language=French +Arrêt du service. Contexte=%1 Raison=%2 +Language=German +Dienst wird gestoppt. Kontext=%1 Grund=%2 +. + +MessageId=1010 +SymbolicName=CONFIG_INVALID +Language=English +Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=1020 +SymbolicName=START_FAILED +Language=English +Start failed. Context=%1 Cause=%2 Error=%3 +Language=French +Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +Language=German +Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 +. + +MessageId=1030 +SymbolicName=BOOT_STACKTRACE_WRITTEN +Language=English +Boot stacktrace written. Context=%1 Path=%2 +Language=French +Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +Language=German +Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 +. + +; 2000-2099 Listeners and Networking + +MessageId=2000 +SymbolicName=LISTENER_STARTED +Language=English +Listener started. Context=%1 Address=%2 Proto=%3 +Language=French +Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +Language=German +Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 +. + +MessageId=2001 +SymbolicName=LISTENER_BIND_FAILED +Language=English +Listener bind failed. Context=%1 Address=%2 Error=%3 +Language=French +Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +Language=German +Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 +. + +MessageId=2002 +SymbolicName=LISTENER_STOPPED +Language=English +Listener stopped. Context=%1 Address=%2 Reason=%3 +Language=French +Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +Language=German +Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 +. + +; 3000-3099 TLS / Certificates + +MessageId=3000 +SymbolicName=TLS_CONFIGURED +Language=English +TLS configured. Context=%1 Source=%2 +Language=French +TLS configuré. Contexte=%1 Source=%2 +Language=German +TLS konfiguriert. Kontext=%1 Quelle=%2 +. + +MessageId=3001 +SymbolicName=TLS_VERIFY_STRICT_DISABLED +Language=English +TLS strict verification disabled. Context=%1 Mode=%2 +Language=French +Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +Language=German +Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 +. + +MessageId=3002 +SymbolicName=TLS_CERTIFICATE_REJECTED +Language=English +Certificate rejected. Context=%1 Subject=%2 Reason=%3 +Language=French +Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +Language=German +Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 +. + +MessageId=3003 +SymbolicName=SYSTEM_CERT_SELECTED +Language=English +System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +Language=French +Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +Language=German +Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 +. + +MessageId=3004 +SymbolicName=TLS_KEY_LOAD_FAILED +Language=English +TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=3005 +SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH +Language=English +TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +Language=French +Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +Language=German +TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 +. + +MessageId=3006 +SymbolicName=TLS_NO_SUITABLE_CERTIFICATE +Language=English +No suitable certificate found. Context=%1 Error=%2 Issues=%3 +Language=French +Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +Language=German +Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 +. + +; 4000-4099 Sessions, Tokens and Recording + +MessageId=4000 +SymbolicName=SESSION_OPENED +Language=English +Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +Language=French +Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +Language=German +Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 +. + +MessageId=4001 +SymbolicName=SESSION_CLOSED +Language=English +Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +Language=French +Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +Language=German +Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 +. + +MessageId=4010 +SymbolicName=TOKEN_PROVISIONED +Language=English +Token provisioned. Context=%1 TokenId=%2 +Language=French +Jeton provisionné. Contexte=%1 Jeton=%2 +Language=German +Token bereitgestellt. Kontext=%1 Token=%2 +. + +MessageId=4011 +SymbolicName=TOKEN_REUSED +Language=English +Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +Language=French +Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +Language=German +Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 +. + +MessageId=4012 +SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED +Language=English +Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +Language=French +Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +Language=German +Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 +. + +MessageId=4030 +SymbolicName=RECORDING_STARTED +Language=English +Recording started. Context=%1 Destination=%2 +Language=French +Enregistrement démarré. Contexte=%1 Destination=%2 +Language=German +Aufnahme gestartet. Kontext=%1 Ziel=%2 +. + +MessageId=4031 +SymbolicName=RECORDING_STOPPED +Language=English +Recording stopped. Context=%1 Bytes=%2 Files=%3 +Language=French +Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +Language=German +Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 +. + +MessageId=4032 +SymbolicName=RECORDING_ERROR +Language=English +Recording error. Context=%1 Path=%2 Error=%3 +Language=French +Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 +. + +; 5000-5099 Authentication / Authorization + +MessageId=5001 +SymbolicName=JWT_REJECTED +Language=English +JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +Language=French +JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +Language=German +JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 +. + +MessageId=5002 +SymbolicName=JWT_ANOMALY +Language=English +JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +Language=French +Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +Language=German +JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 +. + +MessageId=5010 +SymbolicName=AUTHORIZATION_DENIED +Language=English +Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +Language=French +Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +Language=German +Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 +. + +MessageId=5090 +SymbolicName=AUTH_SUMMARY +Language=English +Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +Language=French +Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +Language=German +Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 +. + +; 6000-6099 Agent Integration + +MessageId=6000 +SymbolicName=USER_SESSION_PROCESS_STARTED +Language=English +User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +Language=French +Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +Language=German +Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 +. + +MessageId=6001 +SymbolicName=USER_SESSION_PROCESS_TERMINATED +Language=English +User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +Language=French +Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +Language=German +Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 +. + +MessageId=6010 +SymbolicName=UPDATER_TASK_ENABLED +Language=English +Updater task enabled. Context=%1 +Language=French +Tâche de mise à jour activée. Contexte=%1 +Language=German +Update-Aufgabe aktiviert. Kontext=%1 +. + +MessageId=6011 +SymbolicName=UPDATER_ERROR +Language=English +Updater error. Context=%1 Step=%2 Error=%3 +Language=French +Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +Language=German +Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 +. + +MessageId=6020 +SymbolicName=PEDM_ENABLED +Language=English +PEDM enabled. Context=%1 +Language=French +PEDM activé. Contexte=%1 +Language=German +PEDM aktiviert. Kontext=%1 +. + +; 7000-7099 Health + +MessageId=7010 +SymbolicName=RECORDING_STORAGE_LOW +Language=English +Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +Language=French +Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +Language=German +Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 +. + +; 8000-8099 Package Broker / Policy Management + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_CREATE_FAILED +Language=English +Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8003 +SymbolicName=POLICY_CREATE_SUCCEEDED +Language=English +Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8004 +SymbolicName=POLICY_CHANGE_FAILED +Language=English +Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8005 +SymbolicName=POLICY_CHANGE_SUCCEEDED +Language=English +Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + +; 9000-9099 Diagnostics + +MessageId=9001 +SymbolicName=DEBUG_OPTIONS_ENABLED +Language=English +Debug options enabled. Context=%1 Options=%2 +Language=French +Options de débogage activées. Contexte=%1 Options=%2 +Language=German +Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 +. + +MessageId=9002 +SymbolicName=XMF_NOT_FOUND +Language=English +XMF not found. Context=%1 Path=%2 Error=%3 +Language=French +XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 +. diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index d242d5610..478c8fc73 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -94,20 +94,18 @@ END"#, use std::path::PathBuf; use std::process::Command; - // --- gate: only release builds ------------------------------------- + // --- gate: only release and production profiles -------------------- let profile = env::var("PROFILE").unwrap_or_default(); - if profile != "release" { + if !matches!(profile.as_str(), "release" | "production") { return; } - // --- gate: ignore with a warning when mc is not found -------------- - let mc_exe_path = match find_mc() { - Some(path) => path, - None => { - println!("cargo:warning=Did not find mc.exe"); - return; - } - }; + let mc_exe_path = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe is required to embed the Devolutions Gateway Event Log catalog; \ + use a Visual Studio developer shell or set WindowsSdkVerBinPath or WindowsSdkDir" + ) + }); // --- inputs/paths --------------------------------------------------- let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); @@ -163,20 +161,50 @@ END"#, fn find_mc() -> Option { if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { - let p = std::path::Path::new(&sdk_bin).join("mc.exe"); - if p.exists() { - return Some(p); + let sdk_bin = std::path::Path::new(&sdk_bin); + for candidate in [sdk_bin.join("mc.exe"), sdk_bin.join("x64").join("mc.exe")] { + if candidate.is_file() { + return Some(candidate); + } } } - if let Ok(sdk_dir) = env::var("WindowsSdkDir") { - // e.g. C:\Program Files (x86)\Windows Kits\10\ - let candidate = std::path::Path::new(&sdk_dir).join("bin").join("x64").join("mc.exe"); - if candidate.exists() { - return Some(candidate); - } + if let Some(candidate) = env::var_os("PATH").and_then(|path| { + env::split_paths(&path) + .map(|directory| directory.join("mc.exe")) + .find(|path| path.is_file()) + }) { + return Some(candidate); + } + + let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); + let direct = bin_dir.join("x64").join("mc.exe"); + if direct.is_file() { + return Some(direct); } - None + let mut versions: Vec<_> = fs::read_dir(bin_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + versions.sort_by_key(|path| { + std::cmp::Reverse( + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| { + name.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) + .unwrap_or_default(), + ) + }); + versions + .into_iter() + .map(|directory| directory.join("x64").join("mc.exe")) + .find(|path| path.is_file()) } } diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 4a9b99f8a..da060d36f 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -380,6 +380,91 @@ Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . +; ====================================================================== +; 8000-8099 Package Broker / Policy Management +; Emitted by Devolutions Agent only; both catalogs must define every code. +; ====================================================================== + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_CREATE_FAILED +Language=English +Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8003 +SymbolicName=POLICY_CREATE_SUCCEEDED +Language=English +Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8004 +SymbolicName=POLICY_CHANGE_FAILED +Language=English +Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8005 +SymbolicName=POLICY_CHANGE_SUCCEEDED +Language=English +Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + ; ====================================================================== ; 9000-9099 Diagnostics ; ====================================================================== From 07081aa4579973554c0737be2c70ccc44cbe44a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:52:42 -0400 Subject: [PATCH 03/53] test(dgw,agent): enforce event catalog parity Verify every shared event code and policy insertion string across both localized Windows message catalogs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/message_catalog_parity.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 crates/sysevent-codes/tests/message_catalog_parity.rs diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..3c3746894 --- /dev/null +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,117 @@ +//! Verifies that shared event codes and Windows message catalogs stay aligned. + +use std::path::Path; + +const MESSAGE_CATALOGS: &[&str] = &[ + "../../devolutions-gateway/devolutions-gateway.mc", + "../../devolutions-agent/devolutions-agent.mc", +]; + +const POLICY_INSERTION_COUNTS: &[(u32, usize)] = &[ + (sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), + (sysevent_codes::POLICY_WRITE_DENIED, 6), + (sysevent_codes::POLICY_CREATE_FAILED, 8), + (sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), + (sysevent_codes::POLICY_CHANGE_FAILED, 8), + (sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), + (sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), + (sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), +]; + +#[test] +fn every_event_code_is_defined_once_in_every_catalog() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let event_codes = declared_event_codes(); + + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + for (name, code) in &event_codes { + let expected_id = format!("MessageId={code}"); + let expected_name = format!("SymbolicName={name}"); + let positions: Vec<_> = content.match_indices(&expected_id).collect(); + assert_eq!( + positions.len(), + 1, + "{}: expected one {expected_id}, found {}", + path.display(), + positions.len() + ); + + let after_id = &content[positions[0].0..]; + let name_line = after_id.lines().nth(1).unwrap_or_default(); + assert_eq!( + name_line.trim(), + expected_name, + "{}: {expected_id} must be followed by {expected_name}", + path.display() + ); + } + } +} + +#[test] +fn policy_catalog_insertions_match_structured_field_order() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + for &(code, insertion_count) in POLICY_INSERTION_COUNTS { + let block = message_block(&content, code); + let messages: Vec<_> = block + .lines() + .enumerate() + .filter(|(_, line)| line.starts_with("Language=")) + .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) + .collect(); + assert_eq!(messages.len(), 3, "{}: MessageId={code}", path.display()); + + for message in messages { + for insertion in 1..=insertion_count { + assert!( + message.contains(&format!("%{insertion}")), + "{}: MessageId={code} omits %{insertion}", + path.display() + ); + } + assert!( + !message.contains(&format!("%{}", insertion_count + 1)), + "{}: MessageId={code} has an unexpected insertion", + path.display() + ); + } + } + } +} + +fn declared_event_codes() -> Vec<(&'static str, u32)> { + include_str!("../src/lib.rs") + .lines() + .filter_map(|line| line.trim().strip_prefix("pub const ")) + .map(|declaration| { + let (name, value) = declaration + .split_once(": u32 = ") + .unwrap_or_else(|| panic!("event code must use `pub const NAME: u32 = VALUE;`: {declaration}")); + let value = value + .split_once(';') + .unwrap_or_else(|| panic!("event code must contain a semicolon: {declaration}")) + .0 + .parse() + .unwrap_or_else(|error| panic!("event code must be a decimal u32 in `{declaration}`: {error}")); + (name, value) + }) + .collect() +} + +fn message_block(content: &str, code: u32) -> &str { + let marker = format!("MessageId={code}"); + let start = content.find(&marker).unwrap_or_else(|| panic!("missing {marker}")); + let after = &content[start + marker.len()..]; + let end = after.find("\nMessageId=").unwrap_or(after.len()); + &content[start..start + marker.len() + end] +} From 8d964b10d0e4675f770da078f6bdeecfdd2ecfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 20:29:12 -0400 Subject: [PATCH 04/53] fix(dgw,agent): harden policy audit validation Restrict message compiler discovery to trusted SDK paths, keep thread-local audit assertions on one runtime thread, and avoid an unnecessary path allocation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 2 +- devolutions-agent/build.rs | 8 -------- devolutions-gateway/build.rs | 8 -------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 309c58e2f..ab767a512 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -285,7 +285,7 @@ impl WriteAudit { } pub(crate) fn failed(&self, operation: PolicyReplacementOperation, reason: FailureReason) { - self.failed_at(operation, &self.0.path.clone(), reason); + self.failed_at(operation, &self.0.path, reason); } pub(crate) fn failed_at(&self, operation: PolicyReplacementOperation, path: &Path, reason: FailureReason) { diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index 5cf58aaa4..96da29750 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -149,14 +149,6 @@ END"#, } } - if let Some(candidate) = env::var_os("PATH").and_then(|path| { - env::split_paths(&path) - .map(|directory| directory.join("mc.exe")) - .find(|path| path.is_file()) - }) { - return Some(candidate); - } - let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); let direct = bin_dir.join("x64").join("mc.exe"); if direct.is_file() { diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index 478c8fc73..93b484b13 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -169,14 +169,6 @@ END"#, } } - if let Some(candidate) = env::var_os("PATH").and_then(|path| { - env::split_paths(&path) - .map(|directory| directory.join("mc.exe")) - .find(|path| path.is_file()) - }) { - return Some(candidate); - } - let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); let direct = bin_dir.join("x64").join("mc.exe"); if direct.is_file() { From 3619f062769b4b5ffe4f04512bc8d3e434fdfbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 01:47:38 +0900 Subject: [PATCH 05/53] fix(agent): audit legacy policy rejection Distinguish legacy-contract disk rejection without exposing document values. Cover validator9 receipt rejection, conversion observation, no-op reloads, and abandoned audit scopes without duplicating terminal write events. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index ab767a512..2aa37a6ed 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; -use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +use now_policy_api::{InvalidPolicyDiagnostics, PolicyFindingCode, PolicyManagementState, PolicyReplacementOperation}; #[cfg(not(test))] use sysevent::Severity; #[cfg(all(not(test), not(debug_assertions)))] @@ -402,10 +402,24 @@ pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u )); } -pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { +pub(crate) fn external_change_rejected( + path: &Path, + state: PolicyManagementState, + diagnostics: Option<&InvalidPolicyDiagnostics>, +) { let reason = match state { PolicyManagementState::Active => "active", PolicyManagementState::Missing => "missing", + PolicyManagementState::Invalid + if diagnostics.is_some_and(|diagnostics| { + diagnostics + .findings + .iter() + .any(|finding| finding.code == PolicyFindingCode::UnsupportedPolicyFormatVersion) + }) => + { + "legacy_policy_contract" + } PolicyManagementState::Invalid => "invalid", }; RECORDER.record(sysevent_codes::policy_external_change_rejected( @@ -472,6 +486,33 @@ mod tests { ); } + #[test] + fn abandoned_clones_record_one_terminal_denial() { + let (audit, recorder) = test_audit(); + let retained = audit.clone(); + drop(audit); + assert_eq!(recorder.events().len(), 1); + drop(retained); + let events = recorder.events(); + assert_eq!(events.len(), 2); + assert_eq!(events[1].event_code, Some(sysevent_codes::POLICY_WRITE_DENIED)); + assert!( + events[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "request_rejected") + ); + } + + #[test] + fn audit_text_removes_control_characters_before_truncation() { + let value = format!("injected\r\n\t\0{}", "é".repeat(MAX_POLICY_ID_BYTES)); + let bounded = bounded(value, MAX_POLICY_ID_BYTES); + assert!(bounded.len() <= MAX_POLICY_ID_BYTES); + assert!(bounded.ends_with("...")); + assert!(!bounded.chars().any(char::is_control)); + } + #[test] fn audit_values_are_bounded_and_fields_are_allowlisted() { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); From fddc0804981eb8899585a439d1675e264f788eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 01:47:38 +0900 Subject: [PATCH 06/53] fix(dgw,agent): compile localized event messages Terminate every language block and declare UTF-8 input so the message compiler produces separate, correctly encoded EN/FR/DE resources. Require these properties in event catalog parity tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/message_catalog_parity.rs | 48 ++++++++++ devolutions-agent/devolutions-agent.mc | 88 ++++++++++++++++++- devolutions-gateway/devolutions-gateway.mc | 88 ++++++++++++++++++- 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs index 3c3746894..ea9129fd4 100644 --- a/crates/sysevent-codes/tests/message_catalog_parity.rs +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -52,6 +52,54 @@ fn every_event_code_is_defined_once_in_every_catalog() { } } +#[test] +fn every_catalog_message_terminates_each_translation() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = std::fs::read_to_string(&path).expect("read message catalog"); + assert!( + content.starts_with('\u{feff}'), + "{}: mc.exe requires a UTF-8 BOM to avoid decoding translations as ANSI", + path.display() + ); + for (_, code) in declared_event_codes() { + let mut lines = message_block(&content, code).lines(); + let mut languages = Vec::new(); + while let Some(line) = lines.next() { + let Some(language) = line.strip_prefix("Language=") else { + continue; + }; + languages.push(language); + let mut terminated = false; + for text in lines.by_ref() { + if text == "." { + terminated = true; + break; + } + assert!( + !text.starts_with("Language="), + "{}: MessageId={code} {language} lacks a message terminator", + path.display() + ); + } + assert!( + terminated, + "{}: MessageId={code} {language} lacks a message terminator", + path.display() + ); + } + languages.sort_unstable(); + assert_eq!( + languages, + ["English", "French", "German"], + "{}: MessageId={code} must define each translation once", + path.display() + ); + } + } +} + #[test] fn policy_catalog_insertions_match_structured_field_order() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index 37d25f533..179f4f336 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -1,4 +1,4 @@ -; Devolutions Agent Windows Event Log message definitions. +; Devolutions Agent Windows Event Log message definitions. MessageIdTypedef=DWORD @@ -25,8 +25,10 @@ MessageId=1000 SymbolicName=SERVICE_STARTED Language=English Service started. Context=%1 Version=%2 +. Language=French Service démarré. Contexte=%1 Version=%2 +. Language=German Dienst gestartet. Kontext=%1 Version=%2 . @@ -35,8 +37,10 @@ MessageId=1001 SymbolicName=SERVICE_STOPPING Language=English Service stopping. Context=%1 Reason=%2 +. Language=French Arrêt du service. Contexte=%1 Raison=%2 +. Language=German Dienst wird gestoppt. Kontext=%1 Grund=%2 . @@ -45,8 +49,10 @@ MessageId=1010 SymbolicName=CONFIG_INVALID Language=English Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -55,8 +61,10 @@ MessageId=1020 SymbolicName=START_FAILED Language=English Start failed. Context=%1 Cause=%2 Error=%3 +. Language=French Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +. Language=German Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 . @@ -65,8 +73,10 @@ MessageId=1030 SymbolicName=BOOT_STACKTRACE_WRITTEN Language=English Boot stacktrace written. Context=%1 Path=%2 +. Language=French Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +. Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . @@ -77,8 +87,10 @@ MessageId=2000 SymbolicName=LISTENER_STARTED Language=English Listener started. Context=%1 Address=%2 Proto=%3 +. Language=French Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +. Language=German Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 . @@ -87,8 +99,10 @@ MessageId=2001 SymbolicName=LISTENER_BIND_FAILED Language=English Listener bind failed. Context=%1 Address=%2 Error=%3 +. Language=French Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +. Language=German Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 . @@ -97,8 +111,10 @@ MessageId=2002 SymbolicName=LISTENER_STOPPED Language=English Listener stopped. Context=%1 Address=%2 Reason=%3 +. Language=French Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +. Language=German Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 . @@ -109,8 +125,10 @@ MessageId=3000 SymbolicName=TLS_CONFIGURED Language=English TLS configured. Context=%1 Source=%2 +. Language=French TLS configuré. Contexte=%1 Source=%2 +. Language=German TLS konfiguriert. Kontext=%1 Quelle=%2 . @@ -119,8 +137,10 @@ MessageId=3001 SymbolicName=TLS_VERIFY_STRICT_DISABLED Language=English TLS strict verification disabled. Context=%1 Mode=%2 +. Language=French Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +. Language=German Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 . @@ -129,8 +149,10 @@ MessageId=3002 SymbolicName=TLS_CERTIFICATE_REJECTED Language=English Certificate rejected. Context=%1 Subject=%2 Reason=%3 +. Language=French Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +. Language=German Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 . @@ -139,8 +161,10 @@ MessageId=3003 SymbolicName=SYSTEM_CERT_SELECTED Language=English System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +. Language=French Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +. Language=German Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 . @@ -149,8 +173,10 @@ MessageId=3004 SymbolicName=TLS_KEY_LOAD_FAILED Language=English TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -159,8 +185,10 @@ MessageId=3005 SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH Language=English TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +. Language=French Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +. Language=German TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 . @@ -169,8 +197,10 @@ MessageId=3006 SymbolicName=TLS_NO_SUITABLE_CERTIFICATE Language=English No suitable certificate found. Context=%1 Error=%2 Issues=%3 +. Language=French Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +. Language=German Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 . @@ -181,8 +211,10 @@ MessageId=4000 SymbolicName=SESSION_OPENED Language=English Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +. Language=French Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +. Language=German Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 . @@ -191,8 +223,10 @@ MessageId=4001 SymbolicName=SESSION_CLOSED Language=English Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +. Language=French Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +. Language=German Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 . @@ -201,8 +235,10 @@ MessageId=4010 SymbolicName=TOKEN_PROVISIONED Language=English Token provisioned. Context=%1 TokenId=%2 +. Language=French Jeton provisionné. Contexte=%1 Jeton=%2 +. Language=German Token bereitgestellt. Kontext=%1 Token=%2 . @@ -211,8 +247,10 @@ MessageId=4011 SymbolicName=TOKEN_REUSED Language=English Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +. Language=French Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +. Language=German Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 . @@ -221,8 +259,10 @@ MessageId=4012 SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED Language=English Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +. Language=French Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +. Language=German Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 . @@ -231,8 +271,10 @@ MessageId=4030 SymbolicName=RECORDING_STARTED Language=English Recording started. Context=%1 Destination=%2 +. Language=French Enregistrement démarré. Contexte=%1 Destination=%2 +. Language=German Aufnahme gestartet. Kontext=%1 Ziel=%2 . @@ -241,8 +283,10 @@ MessageId=4031 SymbolicName=RECORDING_STOPPED Language=English Recording stopped. Context=%1 Bytes=%2 Files=%3 +. Language=French Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +. Language=German Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 . @@ -251,8 +295,10 @@ MessageId=4032 SymbolicName=RECORDING_ERROR Language=English Recording error. Context=%1 Path=%2 Error=%3 +. Language=French Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 . @@ -263,8 +309,10 @@ MessageId=5001 SymbolicName=JWT_REJECTED Language=English JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +. Language=French JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +. Language=German JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 . @@ -273,8 +321,10 @@ MessageId=5002 SymbolicName=JWT_ANOMALY Language=English JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +. Language=French Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +. Language=German JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 . @@ -283,8 +333,10 @@ MessageId=5010 SymbolicName=AUTHORIZATION_DENIED Language=English Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +. Language=French Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +. Language=German Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 . @@ -293,8 +345,10 @@ MessageId=5090 SymbolicName=AUTH_SUMMARY Language=English Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +. Language=French Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +. Language=German Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 . @@ -305,8 +359,10 @@ MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED Language=English User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +. Language=French Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +. Language=German Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 . @@ -315,8 +371,10 @@ MessageId=6001 SymbolicName=USER_SESSION_PROCESS_TERMINATED Language=English User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +. Language=French Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +. Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . @@ -325,8 +383,10 @@ MessageId=6010 SymbolicName=UPDATER_TASK_ENABLED Language=English Updater task enabled. Context=%1 +. Language=French Tâche de mise à jour activée. Contexte=%1 +. Language=German Update-Aufgabe aktiviert. Kontext=%1 . @@ -335,8 +395,10 @@ MessageId=6011 SymbolicName=UPDATER_ERROR Language=English Updater error. Context=%1 Step=%2 Error=%3 +. Language=French Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +. Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . @@ -345,8 +407,10 @@ MessageId=6020 SymbolicName=PEDM_ENABLED Language=English PEDM enabled. Context=%1 +. Language=French PEDM activé. Contexte=%1 +. Language=German PEDM aktiviert. Kontext=%1 . @@ -357,8 +421,10 @@ MessageId=7010 SymbolicName=RECORDING_STORAGE_LOW Language=English Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +. Language=French Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +. Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . @@ -369,8 +435,10 @@ MessageId=8000 SymbolicName=POLICY_WRITE_ATTEMPTED Language=English Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +. Language=French Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +. Language=German Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 . @@ -379,8 +447,10 @@ MessageId=8001 SymbolicName=POLICY_WRITE_DENIED Language=English Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +. Language=French Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +. Language=German Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 . @@ -389,8 +459,10 @@ MessageId=8002 SymbolicName=POLICY_CREATE_FAILED Language=English Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -399,8 +471,10 @@ MessageId=8003 SymbolicName=POLICY_CREATE_SUCCEEDED Language=English Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -409,8 +483,10 @@ MessageId=8004 SymbolicName=POLICY_CHANGE_FAILED Language=English Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -419,8 +495,10 @@ MessageId=8005 SymbolicName=POLICY_CHANGE_SUCCEEDED Language=English Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -429,8 +507,10 @@ MessageId=8010 SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED Language=English External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +. Language=French Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +. Language=German Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 . @@ -439,8 +519,10 @@ MessageId=8011 SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED Language=English External policy change rejected. Context=%1 Path=%2 Reason=%3 +. Language=French Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +. Language=German Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 . @@ -451,8 +533,10 @@ MessageId=9001 SymbolicName=DEBUG_OPTIONS_ENABLED Language=English Debug options enabled. Context=%1 Options=%2 +. Language=French Options de débogage activées. Contexte=%1 Options=%2 +. Language=German Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 . @@ -461,8 +545,10 @@ MessageId=9002 SymbolicName=XMF_NOT_FOUND Language=English XMF not found. Context=%1 Path=%2 Error=%3 +. Language=French XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 . diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index da060d36f..54c592ce4 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -1,4 +1,4 @@ -; ---------------------------------------------------------------------- +; ---------------------------------------------------------------------- ; Devolutions Gateway - Windows Event Log message definitions (.mc) ; English (0x409), French (0x40c), German (0x407) ; ---------------------------------------------------------------------- @@ -30,8 +30,10 @@ MessageId=1000 SymbolicName=SERVICE_STARTED Language=English Service started. Context=%1 Version=%2 +. Language=French Service démarré. Contexte=%1 Version=%2 +. Language=German Dienst gestartet. Kontext=%1 Version=%2 . @@ -40,8 +42,10 @@ MessageId=1001 SymbolicName=SERVICE_STOPPING Language=English Service stopping. Context=%1 Reason=%2 +. Language=French Arrêt du service. Contexte=%1 Raison=%2 +. Language=German Dienst wird gestoppt. Kontext=%1 Grund=%2 . @@ -50,8 +54,10 @@ MessageId=1010 SymbolicName=CONFIG_INVALID Language=English Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -60,8 +66,10 @@ MessageId=1020 SymbolicName=START_FAILED Language=English Start failed. Context=%1 Cause=%2 Error=%3 +. Language=French Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +. Language=German Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 . @@ -70,8 +78,10 @@ MessageId=1030 SymbolicName=BOOT_STACKTRACE_WRITTEN Language=English Boot stacktrace written. Context=%1 Path=%2 +. Language=French Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +. Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . @@ -84,8 +94,10 @@ MessageId=2000 SymbolicName=LISTENER_STARTED Language=English Listener started. Context=%1 Address=%2 Proto=%3 +. Language=French Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +. Language=German Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 . @@ -94,8 +106,10 @@ MessageId=2001 SymbolicName=LISTENER_BIND_FAILED Language=English Listener bind failed. Context=%1 Address=%2 Error=%3 +. Language=French Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +. Language=German Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 . @@ -104,8 +118,10 @@ MessageId=2002 SymbolicName=LISTENER_STOPPED Language=English Listener stopped. Context=%1 Address=%2 Reason=%3 +. Language=French Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +. Language=German Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 . @@ -118,8 +134,10 @@ MessageId=3000 SymbolicName=TLS_CONFIGURED Language=English TLS configured. Context=%1 Source=%2 +. Language=French TLS configuré. Contexte=%1 Source=%2 +. Language=German TLS konfiguriert. Kontext=%1 Quelle=%2 . @@ -128,8 +146,10 @@ MessageId=3001 SymbolicName=TLS_VERIFY_STRICT_DISABLED Language=English TLS strict verification disabled. Context=%1 Mode=%2 +. Language=French Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +. Language=German Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 . @@ -138,8 +158,10 @@ MessageId=3002 SymbolicName=TLS_CERTIFICATE_REJECTED Language=English Certificate rejected. Context=%1 Subject=%2 Reason=%3 +. Language=French Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +. Language=German Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 . @@ -148,8 +170,10 @@ MessageId=3003 SymbolicName=SYSTEM_CERT_SELECTED Language=English System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +. Language=French Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +. Language=German Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 . @@ -158,8 +182,10 @@ MessageId=3004 SymbolicName=TLS_KEY_LOAD_FAILED Language=English TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -168,8 +194,10 @@ MessageId=3005 SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH Language=English TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +. Language=French Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +. Language=German TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 . @@ -178,8 +206,10 @@ MessageId=3006 SymbolicName=TLS_NO_SUITABLE_CERTIFICATE Language=English No suitable certificate found. Context=%1 Error=%2 Issues=%3 +. Language=French Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +. Language=German Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 . @@ -192,8 +222,10 @@ MessageId=4000 SymbolicName=SESSION_OPENED Language=English Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +. Language=French Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +. Language=German Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 . @@ -202,8 +234,10 @@ MessageId=4001 SymbolicName=SESSION_CLOSED Language=English Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +. Language=French Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +. Language=German Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 . @@ -212,8 +246,10 @@ MessageId=4010 SymbolicName=TOKEN_PROVISIONED Language=English Token provisioned. Context=%1 TokenId=%2 +. Language=French Jeton provisionné. Contexte=%1 Jeton=%2 +. Language=German Token bereitgestellt. Kontext=%1 Token=%2 . @@ -222,8 +258,10 @@ MessageId=4011 SymbolicName=TOKEN_REUSED Language=English Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +. Language=French Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +. Language=German Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 . @@ -232,8 +270,10 @@ MessageId=4012 SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED Language=English Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +. Language=French Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +. Language=German Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 . @@ -242,8 +282,10 @@ MessageId=4030 SymbolicName=RECORDING_STARTED Language=English Recording started. Context=%1 Destination=%2 +. Language=French Enregistrement démarré. Contexte=%1 Destination=%2 +. Language=German Aufnahme gestartet. Kontext=%1 Ziel=%2 . @@ -252,8 +294,10 @@ MessageId=4031 SymbolicName=RECORDING_STOPPED Language=English Recording stopped. Context=%1 Bytes=%2 Files=%3 +. Language=French Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +. Language=German Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 . @@ -262,8 +306,10 @@ MessageId=4032 SymbolicName=RECORDING_ERROR Language=English Recording error. Context=%1 Path=%2 Error=%3 +. Language=French Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 . @@ -276,8 +322,10 @@ MessageId=5001 SymbolicName=JWT_REJECTED Language=English JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +. Language=French JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +. Language=German JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 . @@ -286,8 +334,10 @@ MessageId=5002 SymbolicName=JWT_ANOMALY Language=English JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +. Language=French Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +. Language=German JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 . @@ -296,8 +346,10 @@ MessageId=5010 SymbolicName=AUTHORIZATION_DENIED Language=English Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +. Language=French Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +. Language=German Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 . @@ -306,8 +358,10 @@ MessageId=5090 SymbolicName=AUTH_SUMMARY Language=English Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +. Language=French Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +. Language=German Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 . @@ -320,8 +374,10 @@ MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED Language=English User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +. Language=French Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +. Language=German Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 . @@ -330,8 +386,10 @@ MessageId=6001 SymbolicName=USER_SESSION_PROCESS_TERMINATED Language=English User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +. Language=French Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +. Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . @@ -340,8 +398,10 @@ MessageId=6010 SymbolicName=UPDATER_TASK_ENABLED Language=English Updater task enabled. Context=%1 +. Language=French Tâche de mise à jour activée. Contexte=%1 +. Language=German Update-Aufgabe aktiviert. Kontext=%1 . @@ -350,8 +410,10 @@ MessageId=6011 SymbolicName=UPDATER_ERROR Language=English Updater error. Context=%1 Step=%2 Error=%3 +. Language=French Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +. Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . @@ -360,8 +422,10 @@ MessageId=6020 SymbolicName=PEDM_ENABLED Language=English PEDM enabled. Context=%1 +. Language=French PEDM activé. Contexte=%1 +. Language=German PEDM aktiviert. Kontext=%1 . @@ -374,8 +438,10 @@ MessageId=7010 SymbolicName=RECORDING_STORAGE_LOW Language=English Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +. Language=French Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +. Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . @@ -389,8 +455,10 @@ MessageId=8000 SymbolicName=POLICY_WRITE_ATTEMPTED Language=English Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +. Language=French Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +. Language=German Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 . @@ -399,8 +467,10 @@ MessageId=8001 SymbolicName=POLICY_WRITE_DENIED Language=English Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +. Language=French Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +. Language=German Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 . @@ -409,8 +479,10 @@ MessageId=8002 SymbolicName=POLICY_CREATE_FAILED Language=English Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -419,8 +491,10 @@ MessageId=8003 SymbolicName=POLICY_CREATE_SUCCEEDED Language=English Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -429,8 +503,10 @@ MessageId=8004 SymbolicName=POLICY_CHANGE_FAILED Language=English Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -439,8 +515,10 @@ MessageId=8005 SymbolicName=POLICY_CHANGE_SUCCEEDED Language=English Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -449,8 +527,10 @@ MessageId=8010 SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED Language=English External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +. Language=French Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +. Language=German Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 . @@ -459,8 +539,10 @@ MessageId=8011 SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED Language=English External policy change rejected. Context=%1 Path=%2 Reason=%3 +. Language=French Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +. Language=German Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 . @@ -473,8 +555,10 @@ MessageId=9001 SymbolicName=DEBUG_OPTIONS_ENABLED Language=English Debug options enabled. Context=%1 Options=%2 +. Language=French Options de débogage activées. Contexte=%1 Options=%2 +. Language=German Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 . @@ -483,8 +567,10 @@ MessageId=9002 SymbolicName=XMF_NOT_FOUND Language=English XMF not found. Context=%1 Path=%2 Error=%3 +. Language=French XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 . From 1b9c2f2a09d87ff1aefaf27fbeac13439c416684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 09:17:03 +0900 Subject: [PATCH 07/53] fix(dgw,agent,agent-installer): retain canonical audits Apply policy audit outcomes to the canonical storage contract and record the Agent Event Log source through MSI lifecycle registration. Keep a focused reflection test without retaining policy migration infrastructure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 16 +- crates/now-package-broker/src/audit.rs | 18 +- .../src/policy_store/mod.rs | 363 +++++++++++++++++- .../DevolutionsAgent.Installer.Tests.csproj | 21 + .../EventLogSourceRegistryTests.cs | 33 ++ package/AgentWindowsManaged/Program.cs | 15 +- 6 files changed, 427 insertions(+), 39 deletions(-) create mode 100644 package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj create mode 100644 package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b832cc5d2..82f7f49f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1171,6 +1171,20 @@ jobs: run: dotnet test utils/dotnet/GatewayUtils.sln shell: pwsh + agent-installer-event-log-tests: + name: Agent installer Event Log lifecycle tests + runs-on: windows-2022 + needs: [preflight] + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Tests + run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj + shell: pwsh winapi-sanitizer-tests: name: Windows API sanitizer tests @@ -1415,7 +1429,7 @@ jobs: success: name: Success if: ${{ always() }} - needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] + needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-installer-event-log-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] runs-on: ubuntu-latest steps: diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 2aa37a6ed..210e0964b 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; -use now_policy_api::{InvalidPolicyDiagnostics, PolicyFindingCode, PolicyManagementState, PolicyReplacementOperation}; +use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; #[cfg(not(test))] use sysevent::Severity; #[cfg(all(not(test), not(debug_assertions)))] @@ -402,24 +402,10 @@ pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u )); } -pub(crate) fn external_change_rejected( - path: &Path, - state: PolicyManagementState, - diagnostics: Option<&InvalidPolicyDiagnostics>, -) { +pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { let reason = match state { PolicyManagementState::Active => "active", PolicyManagementState::Missing => "missing", - PolicyManagementState::Invalid - if diagnostics.is_some_and(|diagnostics| { - diagnostics - .findings - .iter() - .any(|finding| finding.code == PolicyFindingCode::UnsupportedPolicyFormatVersion) - }) => - { - "legacy_policy_contract" - } PolicyManagementState::Invalid => "invalid", }; RECORDER.record(sysevent_codes::policy_external_change_rejected( diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 631025078..658a9ac10 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -9,9 +9,9 @@ use chrono::Utc; use now_policy::PolicyDocument; use now_policy_api::{ API_VERSION_STR, ErrorCode, ErrorResponse, ErrorResponseKind, InvalidPolicyDiagnostics, PolicyConfigurationSource, - PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, PolicyReplacementOperation, - PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, ServerContext, - Transport, + PolicyConflictHandling, PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, + PolicyReplacementOperation, PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, + PolicyWriteCapability, ServerContext, Transport, }; mod receipt; @@ -255,7 +255,7 @@ impl PolicyStore { return self.management_snapshot(); } let (_, observation) = self.observe_storage(false); - let management = self.publish_observation(observation); + let management = self.publish_external_observation(observation); tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy"); management } @@ -295,8 +295,28 @@ impl PolicyStore { } pub async fn replace(&self, request: PolicyReplacementRequest) -> Result { + self.replace_inner(request, None).await + } + + pub(crate) async fn replace_audited( + &self, + request: PolicyReplacementRequest, + audit: crate::audit::WriteAudit, + ) -> Result { + self.replace_inner(request, Some(audit)).await + } + + async fn replace_inner( + &self, + request: PolicyReplacementRequest, + audit: Option, + ) -> Result { + let operation = request.operation; let monitoring = self.writer.lock().await; if *monitoring != Monitoring::Available { + if let Some(audit) = &audit { + audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); + } return Err(error_with_management( ErrorCode::BrokerPaused, "policy change monitoring is unavailable", @@ -310,7 +330,11 @@ impl PolicyStore { // Both conflict modes require this exact token. // ConfirmOverwrite records retry intent without retaining token history. if fresh_token != request.expected_store_token { - let management = self.publish_observation(observation); + let audit_path = observation.canonical_path.clone(); + let management = self.publish_external_observation(observation); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the configured policy changed after the supplied store token was observed", @@ -319,6 +343,13 @@ impl PolicyStore { } if observation.write_capability != PolicyWriteCapability::Writable { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PathNotWritable, + ); + } let code = match observation.read_only_reason { Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem, Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat, @@ -329,6 +360,13 @@ impl PolicyStore { let validation = self.validate_draft(&request.draft); if !validation.is_valid { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidPolicy, + ); + } return Err(error_with_validation( ErrorCode::InvalidPolicy, "the submitted draft failed authoritative validation", @@ -345,6 +383,13 @@ impl PolicyStore { &validation.findings, &request.validation_receipt, ) { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidReceipt, + ); + } return Err(error_with_validation( ErrorCode::ValidationFailed, "the validation receipt does not match this draft", @@ -352,6 +397,13 @@ impl PolicyStore { )); } if !validation.findings.is_empty() && !request.warnings_acknowledged { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::WarningsNotAcknowledged, + ); + } return Err(error_with_validation( ErrorCode::WarningConfirmationRequired, "validation warnings must be explicitly acknowledged", @@ -359,21 +411,56 @@ impl PolicyStore { )); } - let revision = plan_revision( + let revision = match plan_revision( request.operation, observation.state, observation.policy.as_ref(), &draft.metadata.id.0, - ) - .map_err(|message| error_response(ErrorCode::Conflict, message))?; - let policy = draft.into_policy_document(revision, Utc::now()).map_err(|_| { - error_response( - ErrorCode::ValidationFailed, - "failed to commit the validated policy draft", - ) - })?; - let bytes = serde_json::to_vec_pretty(&policy) - .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?; + ) { + Ok(revision) => revision, + Err(message) => { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::RevisionConflict, + ); + } + return Err(error_response(ErrorCode::Conflict, message)); + } + }; + let policy = match draft.into_policy_document(revision, Utc::now()) { + Ok(policy) => policy, + Err(_) => { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::DraftCommitFailed, + ); + } + return Err(error_response( + ErrorCode::ValidationFailed, + "failed to commit the validated policy draft", + )); + } + }; + let bytes = match serde_json::to_vec_pretty(&policy) { + Ok(bytes) => bytes, + Err(_) => { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::SerializationFailed, + ); + } + return Err(error_response( + ErrorCode::InternalError, + "failed to serialize the committed policy", + )); + } + }; let persisted = if request.operation == PolicyReplacementOperation::Create { self.storage @@ -388,13 +475,24 @@ impl PolicyStore { tracing::warn!(error = format!("{error:#}"), "Policy persistence failed"); let (_, current) = self.observe_storage(false); if current.fingerprint != observation.fingerprint { - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed before publication; retry with the current store token", management, )); } + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PersistenceFailed, + ); + } return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to persist the policy", @@ -407,12 +505,23 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); if current.fingerprint == observation.fingerprint { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::ConditionalPublicationFailed, + ); + } return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to conditionally persist the policy", )); } - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed during publication; retry with the current store token", @@ -425,7 +534,11 @@ impl PolicyStore { "Published policy failed authoritative reload" ); let (_, current) = self.observe_storage(false); - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); + } return Err(error_with_management( ErrorCode::PolicyActivationFailed, "the policy was published but failed authoritative reload", @@ -434,6 +547,9 @@ impl PolicyStore { } }; + let old_id = observation.policy.as_ref().map(|policy| policy.metadata.id.0.clone()); + let old_revision = observation.policy.as_ref().map(|policy| policy.metadata.revision); + let canonical_path = observation.canonical_path.clone(); let token = token_for(&previous, &persisted.fingerprint); let snapshot = Arc::new(Snapshot { state: PolicyManagementState::Active, @@ -447,6 +563,18 @@ impl PolicyStore { }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; + if let Some(audit) = &audit { + audit.succeeded_at( + &canonical_path, + old_id.as_deref(), + old_revision, + &persisted.policy.metadata.id.0, + persisted.policy.metadata.revision, + operation, + request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, + ); + } + Ok(ReplaceSuccess { policy: persisted.policy, validation, @@ -469,6 +597,26 @@ impl PolicyStore { management } + fn publish_external_observation(&self, observation: Observation) -> PolicyManagementSnapshot { + let policy_changed = self.snapshot().fingerprint != observation.fingerprint; + let management = self.publish_observation(observation); + if policy_changed { + let path = Path::new(&management.configured_path); + match (management.state, management.policy.as_ref()) { + (PolicyManagementState::Active, Some(policy)) => { + crate::audit::external_change_applied(path, &policy.metadata.id.0, policy.metadata.revision); + } + (PolicyManagementState::Missing | PolicyManagementState::Invalid, _) => { + crate::audit::external_change_rejected(path, management.state); + } + (PolicyManagementState::Active, None) => { + crate::audit::external_change_rejected(path, PolicyManagementState::Invalid); + } + } + } + management + } + #[cfg(test)] pub(crate) fn for_tests(policy: Option) -> Arc { let storage = Arc::new(TestStorage::new(policy)); @@ -809,6 +957,7 @@ fn clone_observation(observation: &Observation) -> Observation { mod storage_tests { use now_policy::PolicyDraftDocument; use now_policy_api::{PolicyConflictHandling, PolicyReplacementRequestKind}; + use win_api_wrappers::identity::sid::Sid; use super::*; @@ -853,6 +1002,121 @@ mod storage_tests { } } + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + let sid = + Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); + crate::audit::WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + } + + #[tokio::test] + async fn audited_old_validator_receipt_fails_once_without_publication() { + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::new(TestStorage::new(Some(policy("current", 1)))), + Monitoring::Available, + ); + let mut request = update_request(&store); + let validation = store.validate_draft(&request.draft); + let canonical = validation.canonical_draft.as_ref().expect("canonical draft"); + request.validation_receipt = + store + .receipt_key + .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); + let (audit, recorder) = recording_audit(); + + let error = store + .replace_audited(request, audit) + .await + .expect_err("old validator receipt is rejected"); + + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert_eq!(store.active_policy().expect("unchanged policy").metadata.revision, 1); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "invalid_receipt") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn canonical_external_observations_are_audited_once_per_change() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + crate::audit::take_test_events(); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::take_test_events().is_empty(), + "unchanged policy is not an event" + ); + + storage.set_disk_state(None, true, 2); + let rejected = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(rejected.state, PolicyManagementState::Invalid); + assert!( + store.active_policy().is_none(), + "invalid external policy is not published" + ); + let events = crate::audit::take_test_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + ); + assert!( + events[0] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "invalid") + ); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::take_test_events().is_empty(), + "unchanged invalid policy is not an event" + ); + + storage.set_disk_state(Some(policy("external", 7)), false, 3); + let applied = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(applied.state, PolicyManagementState::Active); + assert_eq!( + store + .active_policy() + .expect("external policy is active") + .metadata + .revision, + 7 + ); + let events = crate::audit::take_test_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) + ); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::take_test_events().is_empty(), + "unchanged external policy is not an event" + ); + } + #[tokio::test] async fn compatible_format_version_is_bound_to_receipts_and_persisted_tokens() { let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); @@ -900,8 +1164,9 @@ mod storage_tests { ); } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { + crate::audit::take_test_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -909,9 +1174,13 @@ mod storage_tests { Monitoring::Available, ); let request = update_request(&store); + let (audit, recorder) = recording_audit(); storage.race_before_next_persist(policy("external", 7)); - let error = store.replace(request).await.expect_err("external replacement wins"); + let error = store + .replace_audited(request, audit) + .await + .expect_err("external replacement wins"); assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); assert_eq!( @@ -926,6 +1195,58 @@ mod storage_tests { .revision, 7 ); + assert_eq!( + crate::audit::take_test_events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] + ); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "outcome" && value == "stale_conflict") + ); + } + + #[tokio::test] + async fn audited_replacement_records_one_success_after_activation() { + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::new(TestStorage::new(Some(policy("current", 1)))), + Monitoring::Available, + ); + let (audit, recorder) = recording_audit(); + + let success = store + .replace_audited(update_request(&store), audit) + .await + .expect("replacement succeeds"); + + assert_eq!(success.policy.metadata.revision, 2); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_SUCCEEDED) + ] + ); } #[tokio::test] diff --git a/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj b/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj new file mode 100644 index 000000000..e54f1240d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj @@ -0,0 +1,21 @@ + + + net48 + latest + false + DevolutionsAgent.Installer.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs new file mode 100644 index 000000000..c6a05b87d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; + +using WixSharp; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class EventLogSourceRegistryTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SourceUsesNativeMsiRegistryLifecycle(bool win64) + { + Type program = System.Reflection.Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreateEventLogSourceRegistryValue", + BindingFlags.Static | BindingFlags.NonPublic); + RegValue value = Assert.IsType(method.Invoke(null, [win64])); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"SYSTEM\CurrentControlSet\Services\EventLog\Application\Devolutions Agent", value.Key); + Assert.Equal("EventMessageFile", value.Name); + Assert.Equal("[INSTALLDIR]DevolutionsAgent.exe", value.Value); + Assert.Equal(win64, value.Win64); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.False(value.ForceCreateOnInstall); + Assert.False(value.ForceDeleteOnUninstall); + Assert.Contains("Type=string", value.AttributesDefinition); + } +} diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index d2a246305..09a32b242 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -348,7 +348,8 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, Feature = Features.PSU_FEATURE, - } + }, + CreateEventLogSourceRegistryValue(project.Platform == Platform.x64), }; List projectProperties = AgentProperties.Properties.Select(x => x.ToWixSharpProperty()).ToList(); @@ -422,6 +423,18 @@ static void Main() } } + internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => + new( + RegistryHive.LocalMachine, + $"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\{Includes.PRODUCT_NAME}", + "EventMessageFile", + $"[{AgentProperties.InstallDir}]{Includes.EXECUTABLE_NAME}") + { + AttributesDefinition = "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + }; + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = From 5d94bfce009b70942583199c0bd0ca554f53a738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 15:51:06 +0900 Subject: [PATCH 08/53] fix(agent): sanitize audit text controls Replace Unicode line, paragraph, and bidirectional controls before audit values reach Windows Event Log insertion strings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 210e0964b..774302ba1 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -417,7 +417,7 @@ pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState fn bounded(mut value: String, max_bytes: usize) -> String { value = value .chars() - .map(|character| if character.is_control() { ' ' } else { character }) + .map(|character| if is_audit_control(character) { ' ' } else { character }) .collect(); if value.len() <= max_bytes { return value; @@ -432,6 +432,14 @@ fn bounded(mut value: String, max_bytes: usize) -> String { value } +fn is_audit_control(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' + ) +} + fn bounded_path(path: &Path) -> PathBuf { PathBuf::from(bounded(path.display().to_string(), MAX_PATH_BYTES)) } @@ -492,11 +500,14 @@ mod tests { #[test] fn audit_text_removes_control_characters_before_truncation() { - let value = format!("injected\r\n\t\0{}", "é".repeat(MAX_POLICY_ID_BYTES)); + let value = format!( + "injected\r\n\t\0\u{061c}\u{200e}\u{200f}\u{2028}\u{2029}\u{202a}\u{202b}\u{202c}\u{202d}\u{202e}\u{2066}\u{2067}\u{2068}\u{2069}{}", + "é".repeat(MAX_POLICY_ID_BYTES) + ); let bounded = bounded(value, MAX_POLICY_ID_BYTES); assert!(bounded.len() <= MAX_POLICY_ID_BYTES); assert!(bounded.ends_with("...")); - assert!(!bounded.chars().any(char::is_control)); + assert!(!bounded.chars().any(is_audit_control)); } #[test] From b1ed7fef064230d7017b5685a2a2842a7ffbcd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 16:58:55 +0900 Subject: [PATCH 09/53] refactor(agent): isolate policy audit event codes Move policy audit event definitions and Agent catalog parity checks out of the shared Gateway event-code crate. Gateway no longer embeds Agent-only policy event messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 9 +- crates/agent-sysevent-codes/Cargo.toml | 13 + crates/agent-sysevent-codes/src/lib.rs | 123 +++++++ .../tests/message_catalog_parity.rs | 48 +++ crates/now-package-broker/Cargo.toml | 2 +- crates/now-package-broker/src/audit.rs | 78 ++-- .../src/policy_store/mod.rs | 18 +- crates/sysevent-codes/src/lib.rs | 340 ------------------ .../tests/message_catalog_parity.rs | 48 --- devolutions-gateway/devolutions-gateway.mc | 101 ------ 10 files changed, 243 insertions(+), 537 deletions(-) create mode 100644 crates/agent-sysevent-codes/Cargo.toml create mode 100644 crates/agent-sysevent-codes/src/lib.rs create mode 100644 crates/agent-sysevent-codes/tests/message_catalog_parity.rs diff --git a/Cargo.lock b/Cargo.lock index af374ee74..a70a49781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,13 @@ dependencies = [ "tokio 1.52.3", ] +[[package]] +name = "agent-sysevent-codes" +version = "0.0.0" +dependencies = [ + "sysevent", +] + [[package]] name = "agent-tunnel" version = "0.0.0" @@ -4804,6 +4811,7 @@ dependencies = [ name = "now-package-broker" version = "0.0.0" dependencies = [ + "agent-sysevent-codes", "anyhow", "async-trait", "axum 0.8.9", @@ -4827,7 +4835,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sysevent", - "sysevent-codes", "sysevent-winevent", "tempfile", "tokio 1.52.3", diff --git a/crates/agent-sysevent-codes/Cargo.toml b/crates/agent-sysevent-codes/Cargo.toml new file mode 100644 index 000000000..beef0008e --- /dev/null +++ b/crates/agent-sysevent-codes/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-sysevent-codes" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +license = "MIT OR Apache-2.0" +publish = false + +[lints] +workspace = true + +[dependencies] +sysevent.path = "../sysevent" diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs new file mode 100644 index 000000000..05620c3b0 --- /dev/null +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -0,0 +1,123 @@ +//! Devolutions Agent-specific Windows Event Log event definitions. + +use std::path::Path; + +use sysevent::{Entry, Severity}; + +pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; +pub const POLICY_WRITE_DENIED: u32 = 8001; +pub const POLICY_CREATE_FAILED: u32 = 8002; +pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; +pub const POLICY_CHANGE_FAILED: u32 = 8004; +pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; +pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; +pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; + +pub fn policy_write_attempted( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: &Path, +) -> Entry { + Entry::new("Policy management write attempted") + .event_code(POLICY_WRITE_ATTEMPTED) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.display()) +} + +pub fn policy_write_denied( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: &Path, + reason: impl ToString, +) -> Entry { + Entry::new("Policy management write denied") + .event_code(POLICY_WRITE_DENIED) + .severity(Severity::Warning) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.display()) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the Create and change failure events field-compatible" +)] +pub fn policy_write_failed( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Error) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("operation", operation) + .field("outcome", outcome) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_write_succeeded( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("path", path.as_ref().display()) + .field("old_id", old_id) + .field("old_revision", old_revision) + .field("new_id", new_id) + .field("new_revision", new_revision) + .field("intent", intent) + .field("operation", operation) + .field("outcome", outcome) +} + +pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { + Entry::new("External policy change applied") + .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) + .severity(Severity::Notice) + .field("path", path.as_ref().display()) + .field("new_id", new_id) + .field("new_revision", new_revision) +} + +pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { + Entry::new("External policy change rejected") + .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) + .severity(Severity::Warning) + .field("path", path.as_ref().display()) + .field("reason", reason) +} diff --git a/crates/agent-sysevent-codes/tests/message_catalog_parity.rs b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..f838bfda0 --- /dev/null +++ b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,48 @@ +use std::path::Path; + +const EVENTS: &[(u32, usize)] = &[ + (agent_sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), + (agent_sysevent_codes::POLICY_WRITE_DENIED, 6), + (agent_sysevent_codes::POLICY_CREATE_FAILED, 8), + (agent_sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), + (agent_sysevent_codes::POLICY_CHANGE_FAILED, 8), + (agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), + (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), + (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), +]; + +#[test] +fn policy_events_match_the_agent_catalog() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../devolutions-agent/devolutions-agent.mc"); + let catalog = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + + for &(code, insertion_count) in EVENTS { + let marker = format!("MessageId={code}"); + let start = catalog + .find(&marker) + .unwrap_or_else(|| panic!("Agent catalog omits {marker}")); + let block = &catalog[start + ..catalog[start..] + .find("\nMessageId=") + .map_or(catalog.len(), |end| start + end)]; + let messages: Vec<_> = block + .lines() + .enumerate() + .filter(|(_, line)| line.starts_with("Language=")) + .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) + .collect(); + assert_eq!(messages.len(), 3, "Agent catalog {marker}"); + for message in messages { + for insertion in 1..=insertion_count { + assert!( + message.contains(&format!("%{insertion}")), + "Agent catalog {marker} omits %{insertion}" + ); + } + assert!( + !message.contains(&format!("%{}", insertion_count + 1)), + "Agent catalog {marker} has an unexpected insertion" + ); + } + } +} diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 344366322..3758faabc 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -43,7 +43,7 @@ serde = "1" serde_json = "1" sha2 = "0.10" sysevent = { path = "../sysevent" } -sysevent-codes = { path = "../sysevent-codes" } +agent-sysevent-codes = { path = "../agent-sysevent-codes" } sysevent-winevent = { path = "../sysevent-winevent" } tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] } tokio-util = "0.7" diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 774302ba1..ea3b169b9 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; +use agent_sysevent_codes as policy_events; use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +use sysevent::Entry; #[cfg(not(test))] use sysevent::Severity; #[cfg(all(not(test), not(debug_assertions)))] @@ -75,7 +77,7 @@ impl FailureReason { } trait AuditRecorder: Send + Sync { - fn record(&self, entry: sysevent::Entry); + fn record(&self, entry: Entry); } fn default_recorder() -> Arc { @@ -101,7 +103,7 @@ fn default_recorder() -> Arc { #[cfg(test)] std::thread_local! { - static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -109,13 +111,13 @@ struct TestRecorder; #[cfg(test)] impl AuditRecorder for TestRecorder { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { TEST_EVENTS.with(|events| events.borrow_mut().push(entry)); } } #[cfg(test)] -pub(crate) fn take_test_events() -> Vec { +pub(crate) fn take_test_events() -> Vec { TEST_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) } @@ -124,7 +126,7 @@ struct TracingRecorder; #[cfg(not(test))] impl AuditRecorder for TracingRecorder { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { trace_entry(&entry); } } @@ -170,7 +172,7 @@ impl AuditRecorder for SystemRecorder { } #[cfg(not(test))] -fn trace_entry(entry: &sysevent::Entry) { +fn trace_entry(entry: &Entry) { let code = entry.event_code; let message = &entry.message; let fields = &entry.fields; @@ -201,18 +203,18 @@ fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { #[cfg(test)] #[derive(Default)] -pub(crate) struct RecordingAudit(parking_lot::Mutex>); +pub(crate) struct RecordingAudit(parking_lot::Mutex>); #[cfg(test)] impl RecordingAudit { - pub(crate) fn events(&self) -> Vec { + pub(crate) fn events(&self) -> Vec { self.0.lock().clone() } } #[cfg(test)] impl AuditRecorder for RecordingAudit { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { self.0.lock().push(entry); } } @@ -228,7 +230,7 @@ struct WriteAuditState { impl Drop for WriteAuditState { fn drop(&mut self) { if !self.terminal_recorded.swap(true, Ordering::AcqRel) { - self.record(sysevent_codes::policy_write_denied( + self.record(policy_events::policy_write_denied( &self.actor_sid, &self.actor_exe, INTENT, @@ -255,7 +257,7 @@ impl WriteAudit { terminal_recorded: AtomicBool::new(false), recorder, }); - state.record(sysevent_codes::policy_write_attempted( + state.record(policy_events::policy_write_attempted( &state.actor_sid, &state.actor_exe, INTENT, @@ -274,13 +276,7 @@ impl WriteAudit { pub(crate) fn denied(&self, reason: DenialReason) { self.finish(|state| { - sysevent_codes::policy_write_denied( - &state.actor_sid, - &state.actor_exe, - INTENT, - &state.path, - reason.as_str(), - ) + policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str()) }); } @@ -298,7 +294,9 @@ impl WriteAudit { }; self.finish(|state| { if operation == PolicyReplacementOperation::Create { - sysevent_codes::policy_create_failed( + policy_events::policy_write_failed( + policy_events::POLICY_CREATE_FAILED, + "Policy creation failed", &state.actor_sid, &state.actor_exe, INTENT, @@ -308,7 +306,9 @@ impl WriteAudit { reason.as_str(), ) } else { - sysevent_codes::policy_change_failed( + policy_events::policy_write_failed( + policy_events::POLICY_CHANGE_FAILED, + "Policy change failed", &state.actor_sid, &state.actor_exe, INTENT, @@ -347,7 +347,9 @@ impl WriteAudit { }; self.finish(|state| { if operation == PolicyReplacementOperation::Create { - sysevent_codes::policy_create_succeeded( + policy_events::policy_write_succeeded( + policy_events::POLICY_CREATE_SUCCEEDED, + "Policy creation succeeded", &state.actor_sid, &state.actor_exe, path, @@ -360,7 +362,9 @@ impl WriteAudit { outcome, ) } else { - sysevent_codes::policy_change_succeeded( + policy_events::policy_write_succeeded( + policy_events::POLICY_CHANGE_SUCCEEDED, + "Policy change succeeded", &state.actor_sid, &state.actor_exe, path, @@ -376,7 +380,7 @@ impl WriteAudit { }); } - fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> sysevent::Entry) { + fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> Entry) { if self .0 .terminal_recorded @@ -389,13 +393,13 @@ impl WriteAudit { } impl WriteAuditState { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { self.recorder.record(entry); } } pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { - RECORDER.record(sysevent_codes::policy_external_change_applied( + RECORDER.record(policy_events::policy_external_change_applied( bounded_path(path), bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), new_revision, @@ -408,7 +412,7 @@ pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState PolicyManagementState::Missing => "missing", PolicyManagementState::Invalid => "invalid", }; - RECORDER.record(sysevent_codes::policy_external_change_rejected( + RECORDER.record(policy_events::policy_external_change_rejected( bounded_path(path), reason, )); @@ -474,8 +478,8 @@ mod tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_WRITE_DENIED) + Some(policy_events::POLICY_WRITE_ATTEMPTED), + Some(policy_events::POLICY_WRITE_DENIED) ] ); } @@ -489,7 +493,7 @@ mod tests { drop(retained); let events = recorder.events(); assert_eq!(events.len(), 2); - assert_eq!(events[1].event_code, Some(sysevent_codes::POLICY_WRITE_DENIED)); + assert_eq!(events[1].event_code, Some(policy_events::POLICY_WRITE_DENIED)); assert!( events[1] .fields @@ -552,23 +556,23 @@ mod tests { for (operation, failure_code, success_code) in [ ( PolicyReplacementOperation::Create, - sysevent_codes::POLICY_CREATE_FAILED, - sysevent_codes::POLICY_CREATE_SUCCEEDED, + policy_events::POLICY_CREATE_FAILED, + policy_events::POLICY_CREATE_SUCCEEDED, ), ( PolicyReplacementOperation::Update, - sysevent_codes::POLICY_CHANGE_FAILED, - sysevent_codes::POLICY_CHANGE_SUCCEEDED, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, ), ( PolicyReplacementOperation::Repair, - sysevent_codes::POLICY_CHANGE_FAILED, - sysevent_codes::POLICY_CHANGE_SUCCEEDED, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, ), ( PolicyReplacementOperation::ReplaceIdentity, - sysevent_codes::POLICY_CHANGE_FAILED, - sysevent_codes::POLICY_CHANGE_SUCCEEDED, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, ), ] { let (failed, failed_recorder) = test_audit(); diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 658a9ac10..0630fd1fb 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1038,8 +1038,8 @@ mod storage_tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_CHANGE_FAILED) + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) ] ); assert!( @@ -1077,7 +1077,7 @@ mod storage_tests { assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, - Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) ); assert!( events[0] @@ -1107,7 +1107,7 @@ mod storage_tests { assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, - Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) ); store.reload_from_disk(ReloadCause::ExternalChange).await; @@ -1200,7 +1200,7 @@ mod storage_tests { .iter() .map(|entry| entry.event_code) .collect::>(), - [Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] + [Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] ); assert_eq!( recorder @@ -1209,8 +1209,8 @@ mod storage_tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_CHANGE_FAILED) + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) ] ); assert!( @@ -1243,8 +1243,8 @@ mod storage_tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_CHANGE_SUCCEEDED) + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED) ] ); } diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index 1b93a4c80..e2eaad987 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -380,242 +380,6 @@ pub fn recording_storage_low(remaining_bytes: u64, threshold_bytes: u64) -> Entr .field("threshold_bytes", threshold_bytes) } -// 8000-8099 **Package Broker / Policy Management** - -/// A policy write was received before any authorization check. -pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; -/// A policy write was denied by caller authorization. -pub const POLICY_WRITE_DENIED: u32 = 8001; -/// A Create operation failed. -pub const POLICY_CREATE_FAILED: u32 = 8002; -/// A Create operation succeeded. -pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; -/// An Update, Repair, or ReplaceIdentity operation failed. -pub const POLICY_CHANGE_FAILED: u32 = 8004; -/// An Update, Repair, or ReplaceIdentity operation succeeded. -pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; -/// An external policy change became active. -pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; -/// An external policy change left the policy unavailable. -pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; - -pub fn policy_write_attempted( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, -) -> Entry { - Entry::new("Policy management write attempted") - .event_code(POLICY_WRITE_ATTEMPTED) - .severity(Severity::Info) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("intent", intent) - .field("path", path.as_ref().display()) -} - -pub fn policy_write_denied( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - reason: impl ToString, -) -> Entry { - Entry::new("Policy management write denied") - .event_code(POLICY_WRITE_DENIED) - .severity(Severity::Warning) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("intent", intent) - .field("path", path.as_ref().display()) - .field("reason", reason) -} - -pub fn policy_create_failed( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - operation: impl ToString, - outcome: impl ToString, - reason: impl ToString, -) -> Entry { - policy_write_failed( - POLICY_CREATE_FAILED, - "Policy creation failed", - actor_sid, - actor_exe, - intent, - path, - operation, - outcome, - reason, - ) -} - -#[expect( - clippy::too_many_arguments, - reason = "the audit event records both policy identities and the operation outcome" -)] -pub fn policy_create_succeeded( - actor_sid: impl ToString, - actor_exe: impl ToString, - path: impl AsRef, - old_id: impl ToString, - old_revision: impl ToString, - new_id: impl ToString, - new_revision: u32, - intent: impl ToString, - operation: impl ToString, - outcome: impl ToString, -) -> Entry { - policy_write_succeeded( - POLICY_CREATE_SUCCEEDED, - "Policy creation succeeded", - actor_sid, - actor_exe, - path, - old_id, - old_revision, - new_id, - new_revision, - intent, - operation, - outcome, - ) -} - -pub fn policy_change_failed( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - operation: impl ToString, - outcome: impl ToString, - reason: impl ToString, -) -> Entry { - policy_write_failed( - POLICY_CHANGE_FAILED, - "Policy change failed", - actor_sid, - actor_exe, - intent, - path, - operation, - outcome, - reason, - ) -} - -#[expect( - clippy::too_many_arguments, - reason = "the audit event records both policy identities and the operation outcome" -)] -pub fn policy_change_succeeded( - actor_sid: impl ToString, - actor_exe: impl ToString, - path: impl AsRef, - old_id: impl ToString, - old_revision: impl ToString, - new_id: impl ToString, - new_revision: u32, - intent: impl ToString, - operation: impl ToString, - outcome: impl ToString, -) -> Entry { - policy_write_succeeded( - POLICY_CHANGE_SUCCEEDED, - "Policy change succeeded", - actor_sid, - actor_exe, - path, - old_id, - old_revision, - new_id, - new_revision, - intent, - operation, - outcome, - ) -} - -#[expect( - clippy::too_many_arguments, - reason = "the shared builder keeps the four outcome events field-compatible" -)] -fn policy_write_failed( - event_code: u32, - message: &'static str, - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - operation: impl ToString, - outcome: impl ToString, - reason: impl ToString, -) -> Entry { - Entry::new(message) - .event_code(event_code) - .severity(Severity::Error) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("intent", intent) - .field("path", path.as_ref().display()) - .field("operation", operation) - .field("outcome", outcome) - .field("reason", reason) -} - -#[expect( - clippy::too_many_arguments, - reason = "the shared builder keeps the four outcome events field-compatible" -)] -fn policy_write_succeeded( - event_code: u32, - message: &'static str, - actor_sid: impl ToString, - actor_exe: impl ToString, - path: impl AsRef, - old_id: impl ToString, - old_revision: impl ToString, - new_id: impl ToString, - new_revision: u32, - intent: impl ToString, - operation: impl ToString, - outcome: impl ToString, -) -> Entry { - Entry::new(message) - .event_code(event_code) - .severity(Severity::Info) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("path", path.as_ref().display()) - .field("old_id", old_id) - .field("old_revision", old_revision) - .field("new_id", new_id) - .field("new_revision", new_revision) - .field("intent", intent) - .field("operation", operation) - .field("outcome", outcome) -} - -pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { - Entry::new("External policy change applied") - .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) - .severity(Severity::Notice) - .field("path", path.as_ref().display()) - .field("new_id", new_id) - .field("new_revision", new_revision) -} - -pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { - Entry::new("External policy change rejected") - .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) - .severity(Severity::Warning) - .field("path", path.as_ref().display()) - .field("reason", reason) -} - // 9000-9099 **Diagnostics** pub const DEBUG_OPTIONS_ENABLED: u32 = 9001; @@ -635,107 +399,3 @@ pub fn xmf_not_found(path: impl AsRef, error: impl std::fmt::Display) -> E .field("path", path.as_ref().display()) .field("error_chain", format!("{error:#}")) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn policy_audit_entries_preserve_catalog_field_order() { - const WRITE: &[&str] = &["actor_sid", "actor_exe", "intent", "path"]; - const DENIED: &[&str] = &["actor_sid", "actor_exe", "intent", "path", "reason"]; - const FAILED: &[&str] = &[ - "actor_sid", - "actor_exe", - "intent", - "path", - "operation", - "outcome", - "reason", - ]; - const SUCCEEDED: &[&str] = &[ - "actor_sid", - "actor_exe", - "path", - "old_id", - "old_revision", - "new_id", - "new_revision", - "intent", - "operation", - "outcome", - ]; - let entries = [ - ( - policy_write_attempted("sid", "exe", "intent", "path"), - POLICY_WRITE_ATTEMPTED, - Severity::Info, - WRITE, - ), - ( - policy_write_denied("sid", "exe", "intent", "path", "reason"), - POLICY_WRITE_DENIED, - Severity::Warning, - DENIED, - ), - ( - policy_create_failed("sid", "exe", "intent", "path", "create", "failed", "reason"), - POLICY_CREATE_FAILED, - Severity::Error, - FAILED, - ), - ( - policy_create_succeeded( - "sid", "exe", "path", "old", "1", "new", 2, "intent", "create", "applied", - ), - POLICY_CREATE_SUCCEEDED, - Severity::Info, - SUCCEEDED, - ), - ( - policy_change_failed("sid", "exe", "intent", "path", "update", "stale_conflict", "reason"), - POLICY_CHANGE_FAILED, - Severity::Error, - FAILED, - ), - ( - policy_change_succeeded( - "sid", - "exe", - "path", - "old", - "1", - "new", - 2, - "intent", - "update", - "confirmed_overwrite", - ), - POLICY_CHANGE_SUCCEEDED, - Severity::Info, - SUCCEEDED, - ), - ( - policy_external_change_applied("path", "new", 2), - POLICY_EXTERNAL_CHANGE_APPLIED, - Severity::Notice, - &["path", "new_id", "new_revision"], - ), - ( - policy_external_change_rejected("path", "invalid"), - POLICY_EXTERNAL_CHANGE_REJECTED, - Severity::Warning, - &["path", "reason"], - ), - ]; - - for (entry, code, severity, expected_fields) in entries { - assert_eq!(entry.event_code, Some(code)); - assert_eq!(entry.severity, severity); - assert_eq!( - entry.fields.iter().map(|(name, _)| name.as_str()).collect::>(), - expected_fields - ); - } - } -} diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs index ea9129fd4..4b65b680f 100644 --- a/crates/sysevent-codes/tests/message_catalog_parity.rs +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -7,17 +7,6 @@ const MESSAGE_CATALOGS: &[&str] = &[ "../../devolutions-agent/devolutions-agent.mc", ]; -const POLICY_INSERTION_COUNTS: &[(u32, usize)] = &[ - (sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), - (sysevent_codes::POLICY_WRITE_DENIED, 6), - (sysevent_codes::POLICY_CREATE_FAILED, 8), - (sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), - (sysevent_codes::POLICY_CHANGE_FAILED, 8), - (sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), - (sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), - (sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), -]; - #[test] fn every_event_code_is_defined_once_in_every_catalog() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -100,43 +89,6 @@ fn every_catalog_message_terminates_each_translation() { } } -#[test] -fn policy_catalog_insertions_match_structured_field_order() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - for catalog in MESSAGE_CATALOGS { - let path = manifest_dir.join(catalog); - let content = - std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - - for &(code, insertion_count) in POLICY_INSERTION_COUNTS { - let block = message_block(&content, code); - let messages: Vec<_> = block - .lines() - .enumerate() - .filter(|(_, line)| line.starts_with("Language=")) - .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) - .collect(); - assert_eq!(messages.len(), 3, "{}: MessageId={code}", path.display()); - - for message in messages { - for insertion in 1..=insertion_count { - assert!( - message.contains(&format!("%{insertion}")), - "{}: MessageId={code} omits %{insertion}", - path.display() - ); - } - assert!( - !message.contains(&format!("%{}", insertion_count + 1)), - "{}: MessageId={code} has an unexpected insertion", - path.display() - ); - } - } - } -} - fn declared_event_codes() -> Vec<(&'static str, u32)> { include_str!("../src/lib.rs") .lines() diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 54c592ce4..470c1de05 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -446,107 +446,6 @@ Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . -; ====================================================================== -; 8000-8099 Package Broker / Policy Management -; Emitted by Devolutions Agent only; both catalogs must define every code. -; ====================================================================== - -MessageId=8000 -SymbolicName=POLICY_WRITE_ATTEMPTED -Language=English -Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 -. -Language=French -Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 -. -Language=German -Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 -. - -MessageId=8001 -SymbolicName=POLICY_WRITE_DENIED -Language=English -Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 -. -Language=French -Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 -. -Language=German -Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 -. - -MessageId=8002 -SymbolicName=POLICY_CREATE_FAILED -Language=English -Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 -. -Language=French -Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 -. -Language=German -Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 -. - -MessageId=8003 -SymbolicName=POLICY_CREATE_SUCCEEDED -Language=English -Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 -. -Language=French -Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 -. -Language=German -Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 -. - -MessageId=8004 -SymbolicName=POLICY_CHANGE_FAILED -Language=English -Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 -. -Language=French -Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 -. -Language=German -Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 -. - -MessageId=8005 -SymbolicName=POLICY_CHANGE_SUCCEEDED -Language=English -Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 -. -Language=French -Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 -. -Language=German -Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 -. - -MessageId=8010 -SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED -Language=English -External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 -. -Language=French -Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 -. -Language=German -Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 -. - -MessageId=8011 -SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED -Language=English -External policy change rejected. Context=%1 Path=%2 Reason=%3 -. -Language=French -Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 -. -Language=German -Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 -. - ; ====================================================================== ; 9000-9099 Diagnostics ; ====================================================================== From 21a0fb41c846c2562b1cf81d160199bd9df5a2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 17:14:40 +0900 Subject: [PATCH 10/53] test(agent): isolate audit recorders Move test-only audit recorders and thread-local capture into an explicit mock module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 101 +++++++++--------- .../src/policy_store/mod.rs | 20 ++-- 2 files changed, 61 insertions(+), 60 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index ea3b169b9..87cef05e9 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -83,7 +83,7 @@ trait AuditRecorder: Send + Sync { fn default_recorder() -> Arc { #[cfg(test)] { - Arc::new(TestRecorder) + Arc::new(mock::TestRecorder) } #[cfg(all(not(test), debug_assertions))] { @@ -101,26 +101,6 @@ fn default_recorder() -> Arc { } } -#[cfg(test)] -std::thread_local! { - static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; -} - -#[cfg(test)] -struct TestRecorder; - -#[cfg(test)] -impl AuditRecorder for TestRecorder { - fn record(&self, entry: Entry) { - TEST_EVENTS.with(|events| events.borrow_mut().push(entry)); - } -} - -#[cfg(test)] -pub(crate) fn take_test_events() -> Vec { - TEST_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) -} - #[cfg(not(test))] struct TracingRecorder; @@ -201,24 +181,6 @@ fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { } } -#[cfg(test)] -#[derive(Default)] -pub(crate) struct RecordingAudit(parking_lot::Mutex>); - -#[cfg(test)] -impl RecordingAudit { - pub(crate) fn events(&self) -> Vec { - self.0.lock().clone() - } -} - -#[cfg(test)] -impl AuditRecorder for RecordingAudit { - fn record(&self, entry: Entry) { - self.0.lock().push(entry); - } -} - struct WriteAuditState { actor_sid: String, actor_exe: String, @@ -266,14 +228,6 @@ impl WriteAudit { Self(state) } - #[cfg(test)] - pub(crate) fn begin_recording(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (Self, Arc) { - let recorder = Arc::new(RecordingAudit::default()); - let recorder_sink = Arc::::clone(&recorder); - let audit = Self::begin_with_recorder(actor_sid, actor_exe, path, recorder_sink); - (audit, recorder) - } - pub(crate) fn denied(&self, reason: DenialReason) { self.finish(|state| { policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str()) @@ -457,13 +411,60 @@ const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { } } +#[cfg(test)] +pub(crate) mod mock { + use super::*; + + std::thread_local! { + static EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + } + + pub(crate) struct TestRecorder; + + impl AuditRecorder for TestRecorder { + fn record(&self, entry: Entry) { + EVENTS.with(|events| events.borrow_mut().push(entry)); + } + } + + pub(crate) fn take_events() -> Vec { + EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) + } + + #[derive(Default)] + pub(crate) struct Recorder(parking_lot::Mutex>); + + impl Recorder { + pub(crate) fn events(&self) -> Vec { + self.0.lock().clone() + } + } + + impl AuditRecorder for Recorder { + fn record(&self, entry: Entry) { + self.0.lock().push(entry); + } + } + + pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (WriteAudit, Arc) { + let recorder = Arc::new(Recorder::default()); + let audit = WriteAudit::begin_with_recorder( + actor_sid, + actor_exe, + path, + Arc::clone(&recorder) as Arc, + ); + (audit, recorder) + } +} + #[cfg(test)] mod tests { use super::*; - fn test_audit() -> (WriteAudit, Arc) { + fn test_audit() -> (WriteAudit, Arc) { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); - WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[test] @@ -518,7 +519,7 @@ mod tests { fn audit_values_are_bounded_and_fields_are_allowlisted() { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); let long = "é".repeat(MAX_PATH_BYTES); - let (audit, recorder) = WriteAudit::begin_recording(&sid, Path::new(&long), Path::new(&long)); + let (audit, recorder) = mock::begin(&sid, Path::new(&long), Path::new(&long)); audit.succeeded_at( Path::new(&long), Some(&long), diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 0630fd1fb..06063d936 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1002,10 +1002,10 @@ mod storage_tests { } } - fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { let sid = Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); - crate::audit::WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + crate::audit::mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[tokio::test] @@ -1058,11 +1058,11 @@ mod storage_tests { Arc::clone(&storage) as Arc, Monitoring::Available, ); - crate::audit::take_test_events(); + crate::audit::mock::take_events(); store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::take_test_events().is_empty(), + crate::audit::mock::take_events().is_empty(), "unchanged policy is not an event" ); @@ -1073,7 +1073,7 @@ mod storage_tests { store.active_policy().is_none(), "invalid external policy is not published" ); - let events = crate::audit::take_test_events(); + let events = crate::audit::mock::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1088,7 +1088,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::take_test_events().is_empty(), + crate::audit::mock::take_events().is_empty(), "unchanged invalid policy is not an event" ); @@ -1103,7 +1103,7 @@ mod storage_tests { .revision, 7 ); - let events = crate::audit::take_test_events(); + let events = crate::audit::mock::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1112,7 +1112,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::take_test_events().is_empty(), + crate::audit::mock::take_events().is_empty(), "unchanged external policy is not an event" ); } @@ -1166,7 +1166,7 @@ mod storage_tests { #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { - crate::audit::take_test_events(); + crate::audit::mock::take_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -1196,7 +1196,7 @@ mod storage_tests { 7 ); assert_eq!( - crate::audit::take_test_events() + crate::audit::mock::take_events() .iter() .map(|entry| entry.event_code) .collect::>(), From cd08d9688ce4396dcf605694de1b6ce52493327b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 23:30:40 +0900 Subject: [PATCH 11/53] test(agent): scope audit fixtures locally Keep test recorders and capture within the audit tests module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 15 +++++--------- .../src/policy_store/mod.rs | 20 +++++++++---------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 87cef05e9..8b3fe1db6 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -83,7 +83,7 @@ trait AuditRecorder: Send + Sync { fn default_recorder() -> Arc { #[cfg(test)] { - Arc::new(mock::TestRecorder) + Arc::new(tests::TestRecorder) } #[cfg(all(not(test), debug_assertions))] { @@ -412,7 +412,7 @@ const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { } #[cfg(test)] -pub(crate) mod mock { +pub(crate) mod tests { use super::*; std::thread_local! { @@ -456,15 +456,10 @@ pub(crate) mod mock { ); (audit, recorder) } -} - -#[cfg(test)] -mod tests { - use super::*; - fn test_audit() -> (WriteAudit, Arc) { + fn test_audit() -> (WriteAudit, Arc) { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); - mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[test] @@ -519,7 +514,7 @@ mod tests { fn audit_values_are_bounded_and_fields_are_allowlisted() { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); let long = "é".repeat(MAX_PATH_BYTES); - let (audit, recorder) = mock::begin(&sid, Path::new(&long), Path::new(&long)); + let (audit, recorder) = begin(&sid, Path::new(&long), Path::new(&long)); audit.succeeded_at( Path::new(&long), Some(&long), diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 06063d936..8819f68df 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1002,10 +1002,10 @@ mod storage_tests { } } - fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { let sid = Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); - crate::audit::mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + crate::audit::tests::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[tokio::test] @@ -1058,11 +1058,11 @@ mod storage_tests { Arc::clone(&storage) as Arc, Monitoring::Available, ); - crate::audit::mock::take_events(); + crate::audit::tests::take_events(); store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::mock::take_events().is_empty(), + crate::audit::tests::take_events().is_empty(), "unchanged policy is not an event" ); @@ -1073,7 +1073,7 @@ mod storage_tests { store.active_policy().is_none(), "invalid external policy is not published" ); - let events = crate::audit::mock::take_events(); + let events = crate::audit::tests::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1088,7 +1088,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::mock::take_events().is_empty(), + crate::audit::tests::take_events().is_empty(), "unchanged invalid policy is not an event" ); @@ -1103,7 +1103,7 @@ mod storage_tests { .revision, 7 ); - let events = crate::audit::mock::take_events(); + let events = crate::audit::tests::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1112,7 +1112,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::mock::take_events().is_empty(), + crate::audit::tests::take_events().is_empty(), "unchanged external policy is not an event" ); } @@ -1166,7 +1166,7 @@ mod storage_tests { #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { - crate::audit::mock::take_events(); + crate::audit::tests::take_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -1196,7 +1196,7 @@ mod storage_tests { 7 ); assert_eq!( - crate::audit::mock::take_events() + crate::audit::tests::take_events() .iter() .map(|entry| entry.event_code) .collect::>(), From 2dc816a13228bfd91409aa1ab92f54b4b3a34188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 23:38:44 +0900 Subject: [PATCH 12/53] refactor(agent): require policy write audits Make policy replacement require its audit lifecycle and keep uninstrumented test calls behind a test-only helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 16 ++ .../src/policy_store/mod.rs | 205 ++++++++---------- .../src/policy_store/receipt.rs | 37 ++-- crates/now-package-broker/src/server/mod.rs | 2 +- 4 files changed, 133 insertions(+), 127 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 8b3fe1db6..b84b59b8c 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -457,6 +457,22 @@ pub(crate) mod tests { (audit, recorder) } + pub(crate) fn noop() -> WriteAudit { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + WriteAudit::begin_with_recorder( + &sid, + Path::new(r"C:\test-client.exe"), + Path::new(r"C:\policy.json"), + Arc::new(NoopRecorder), + ) + } + + struct NoopRecorder; + + impl AuditRecorder for NoopRecorder { + fn record(&self, _: Entry) {} + } + fn test_audit() -> (WriteAudit, Arc) { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 8819f68df..1ad1200e3 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -294,29 +294,15 @@ impl PolicyStore { self.publish_observation(observation); } - pub async fn replace(&self, request: PolicyReplacementRequest) -> Result { - self.replace_inner(request, None).await - } - - pub(crate) async fn replace_audited( + pub(crate) async fn replace( &self, request: PolicyReplacementRequest, audit: crate::audit::WriteAudit, - ) -> Result { - self.replace_inner(request, Some(audit)).await - } - - async fn replace_inner( - &self, - request: PolicyReplacementRequest, - audit: Option, ) -> Result { let operation = request.operation; let monitoring = self.writer.lock().await; if *monitoring != Monitoring::Available { - if let Some(audit) = &audit { - audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); - } + audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); return Err(error_with_management( ErrorCode::BrokerPaused, "policy change monitoring is unavailable", @@ -332,9 +318,7 @@ impl PolicyStore { if fresh_token != request.expected_store_token { let audit_path = observation.canonical_path.clone(); let management = self.publish_external_observation(observation); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the configured policy changed after the supplied store token was observed", @@ -343,13 +327,11 @@ impl PolicyStore { } if observation.write_capability != PolicyWriteCapability::Writable { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::PathNotWritable, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PathNotWritable, + ); let code = match observation.read_only_reason { Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem, Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat, @@ -360,13 +342,11 @@ impl PolicyStore { let validation = self.validate_draft(&request.draft); if !validation.is_valid { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::InvalidPolicy, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidPolicy, + ); return Err(error_with_validation( ErrorCode::InvalidPolicy, "the submitted draft failed authoritative validation", @@ -383,13 +363,11 @@ impl PolicyStore { &validation.findings, &request.validation_receipt, ) { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::InvalidReceipt, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidReceipt, + ); return Err(error_with_validation( ErrorCode::ValidationFailed, "the validation receipt does not match this draft", @@ -397,13 +375,11 @@ impl PolicyStore { )); } if !validation.findings.is_empty() && !request.warnings_acknowledged { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::WarningsNotAcknowledged, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::WarningsNotAcknowledged, + ); return Err(error_with_validation( ErrorCode::WarningConfirmationRequired, "validation warnings must be explicitly acknowledged", @@ -419,26 +395,22 @@ impl PolicyStore { ) { Ok(revision) => revision, Err(message) => { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::RevisionConflict, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::RevisionConflict, + ); return Err(error_response(ErrorCode::Conflict, message)); } }; let policy = match draft.into_policy_document(revision, Utc::now()) { Ok(policy) => policy, Err(_) => { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::DraftCommitFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::DraftCommitFailed, + ); return Err(error_response( ErrorCode::ValidationFailed, "failed to commit the validated policy draft", @@ -448,13 +420,11 @@ impl PolicyStore { let bytes = match serde_json::to_vec_pretty(&policy) { Ok(bytes) => bytes, Err(_) => { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::SerializationFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::SerializationFailed, + ); return Err(error_response( ErrorCode::InternalError, "failed to serialize the committed policy", @@ -477,22 +447,18 @@ impl PolicyStore { if current.fingerprint != observation.fingerprint { let audit_path = current.canonical_path.clone(); let management = self.publish_external_observation(current); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed before publication; retry with the current store token", management, )); } - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::PersistenceFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PersistenceFailed, + ); return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to persist the policy", @@ -505,13 +471,11 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); if current.fingerprint == observation.fingerprint { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::ConditionalPublicationFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::ConditionalPublicationFailed, + ); return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to conditionally persist the policy", @@ -519,9 +483,7 @@ impl PolicyStore { } let audit_path = current.canonical_path.clone(); let management = self.publish_external_observation(current); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed during publication; retry with the current store token", @@ -536,9 +498,7 @@ impl PolicyStore { let (_, current) = self.observe_storage(false); let audit_path = current.canonical_path.clone(); let management = self.publish_external_observation(current); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); return Err(error_with_management( ErrorCode::PolicyActivationFailed, "the policy was published but failed authoritative reload", @@ -563,17 +523,15 @@ impl PolicyStore { }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; - if let Some(audit) = &audit { - audit.succeeded_at( - &canonical_path, - old_id.as_deref(), - old_revision, - &persisted.policy.metadata.id.0, - persisted.policy.metadata.revision, - operation, - request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, - ); - } + audit.succeeded_at( + &canonical_path, + old_id.as_deref(), + old_revision, + &persisted.policy.metadata.id.0, + persisted.policy.metadata.revision, + operation, + request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, + ); Ok(ReplaceSuccess { policy: persisted.policy, @@ -617,6 +575,14 @@ impl PolicyStore { management } + #[cfg(test)] + pub(crate) async fn replace_for_tests( + &self, + request: PolicyReplacementRequest, + ) -> Result { + self.replace(request, crate::audit::tests::noop()).await + } + #[cfg(test)] pub(crate) fn for_tests(policy: Option) -> Arc { let storage = Arc::new(TestStorage::new(policy)); @@ -1025,7 +991,7 @@ mod storage_tests { let (audit, recorder) = recording_audit(); let error = store - .replace_audited(request, audit) + .replace(request, audit) .await .expect_err("old validator receipt is rejected"); @@ -1128,7 +1094,7 @@ mod storage_tests { let mut request = update_request(&store); request.draft["PolicyFormatVersion"] = serde_json::json!("1.7.3"); let error = store - .replace(request.clone()) + .replace_for_tests(request.clone()) .await .expect_err("format version is receipt-bound"); assert_eq!(error.code, ErrorCode::ValidationFailed); @@ -1142,14 +1108,17 @@ mod storage_tests { .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); request.validation_receipt = old_receipt; let error = store - .replace(request.clone()) + .replace_for_tests(request.clone()) .await .expect_err("old validator receipt is rejected"); assert_eq!(error.code, ErrorCode::ValidationFailed); request.validation_receipt = validation.validation_receipt.expect("current receipt"); let before = store.management_snapshot().store_token; - let result = store.replace(request).await.expect("compatible format is writable"); + let result = store + .replace_for_tests(request) + .await + .expect("compatible format is writable"); assert_eq!( serde_json::to_value(&result.policy).expect("serialize committed policy")["PolicyFormatVersion"], "1.7.3" @@ -1178,7 +1147,7 @@ mod storage_tests { storage.race_before_next_persist(policy("external", 7)); let error = store - .replace_audited(request, audit) + .replace(request, audit) .await .expect_err("external replacement wins"); @@ -1231,7 +1200,7 @@ mod storage_tests { let (audit, recorder) = recording_audit(); let success = store - .replace_audited(update_request(&store), audit) + .replace(update_request(&store), audit) .await .expect("replacement succeeds"); @@ -1263,7 +1232,10 @@ mod storage_tests { .fail_concurrent_check .store(true, std::sync::atomic::Ordering::SeqCst); - let error = store.replace(request).await.expect_err("identity check fails"); + let error = store + .replace_for_tests(request) + .await + .expect_err("identity check fails"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); assert_eq!(store.management_snapshot().store_token, previous_token); @@ -1291,7 +1263,10 @@ mod storage_tests { .fail_target_retention .store(true, std::sync::atomic::Ordering::SeqCst); - let error = store.replace(request).await.expect_err("target retention fails"); + let error = store + .replace_for_tests(request) + .await + .expect_err("target retention fails"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); assert_eq!(store.management_snapshot().store_token, previous_token); @@ -1317,7 +1292,10 @@ mod storage_tests { *storage.post_persist_capability.lock() = Some((PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath))); - let success = store.replace(request).await.expect("policy replacement succeeds"); + let success = store + .replace_for_tests(request) + .await + .expect("policy replacement succeeds"); assert_eq!(success.management.write_capability, PolicyWriteCapability::ReadOnly); assert_eq!( @@ -1343,7 +1321,10 @@ mod storage_tests { ); assert_eq!(store.watched_path(), canonical); - let success = store.replace(update_request(&store)).await.expect("replace policy"); + let success = store + .replace_for_tests(update_request(&store)) + .await + .expect("replace policy"); assert_eq!(&*storage.persisted_configured_paths.lock(), &[configured]); assert_eq!(store.watched_path(), canonical); diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index 4aec0b184..3858f66cf 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -199,7 +199,10 @@ mod tests { let mut stale_request = request(&store, PolicyReplacementOperation::Update, raw.clone()); storage.set_disk_state(Some(policy("retargeted", 9)), false, 9); stale_request.conflict_handling = PolicyConflictHandling::ConfirmOverwrite; - let stale_error = store.replace(stale_request).await.expect_err("stale token rejected"); + let stale_error = store + .replace_for_tests(stale_request) + .await + .expect_err("stale token rejected"); assert_eq!(stale_error.code, ErrorCode::StalePolicyStoreToken); assert!(stale_error.management.is_some()); assert_eq!( @@ -208,7 +211,10 @@ mod tests { ); let mut tampered = request(&store, PolicyReplacementOperation::Update, raw); tampered.draft["Metadata"]["Publisher"] = "Tampered".into(); - let receipt_error = store.replace(tampered).await.expect_err("tampered draft rejected"); + let receipt_error = store + .replace_for_tests(tampered) + .await + .expect_err("tampered draft rejected"); assert_eq!(receipt_error.code, ErrorCode::ValidationFailed); } #[tokio::test] @@ -253,12 +259,15 @@ mod tests { ); let mut replacement = request(&store, PolicyReplacementOperation::Create, risky); let error = store - .replace(replacement.clone()) + .replace_for_tests(replacement.clone()) .await .expect_err("warning must be acknowledged"); assert_eq!(error.code, ErrorCode::WarningConfirmationRequired); replacement.warnings_acknowledged = true; - store.replace(replacement).await.expect("acknowledged warning succeeds"); + store + .replace_for_tests(replacement) + .await + .expect("acknowledged warning succeeds"); } #[tokio::test] async fn canonical_sensitive_warnings_accept_the_original_receipt() { @@ -317,13 +326,13 @@ mod tests { let mut changed = replacement.clone(); changed.draft["Rules"][0]["Constraints"]["AllowSkipHashCheck"] = serde_json::json!(false); let error = store - .replace(changed) + .replace_for_tests(changed) .await .expect_err("meaningful option change invalidates receipt"); assert_eq!(error.code, ErrorCode::ValidationFailed); } store - .replace(replacement) + .replace_for_tests(replacement) .await .unwrap_or_else(|error| panic!("{option} via {explicit} failed: {error:?}")); } @@ -334,21 +343,21 @@ mod tests { let create = PolicyStore::for_tests(None); let raw = serde_json::to_value(draft("created")).expect("serialize draft"); let created = create - .replace(request(&create, PolicyReplacementOperation::Create, raw)) + .replace_for_tests(request(&create, PolicyReplacementOperation::Create, raw)) .await .expect("create succeeds"); assert_eq!(created.policy.metadata.revision, 1); let update = PolicyStore::for_tests(Some(policy("current", 7))); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let updated = update - .replace(request(&update, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&update, PolicyReplacementOperation::Update, raw)) .await .expect("update succeeds"); assert_eq!(updated.policy.metadata.revision, 8); let replace = PolicyStore::for_tests(Some(policy("current", 7))); let raw = serde_json::to_value(draft("replacement")).expect("serialize draft"); let replaced = replace - .replace(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw)) + .replace_for_tests(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw)) .await .expect("identity replacement succeeds"); assert_eq!(replaced.policy.metadata.revision, 1); @@ -360,14 +369,14 @@ mod tests { ); let raw = serde_json::to_value(draft("repaired")).expect("serialize draft"); let repaired = repair - .replace(request(&repair, PolicyReplacementOperation::Repair, raw)) + .replace_for_tests(request(&repair, PolicyReplacementOperation::Repair, raw)) .await .expect("repair succeeds"); assert_eq!(repaired.policy.metadata.revision, 1); let wrong_identity = PolicyStore::for_tests(Some(policy("current", 1))); let raw = serde_json::to_value(draft("different")).expect("serialize draft"); let error = wrong_identity - .replace(request(&wrong_identity, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&wrong_identity, PolicyReplacementOperation::Update, raw)) .await .expect_err("update must preserve identity"); assert_eq!(error.code, ErrorCode::Conflict); @@ -377,7 +386,7 @@ mod tests { let store = PolicyStore::for_tests(Some(policy("current", 1))); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let first = request(&store, PolicyReplacementOperation::Update, raw); - let (first, second) = tokio::join!(store.replace(first.clone()), store.replace(first)); + let (first, second) = tokio::join!(store.replace_for_tests(first.clone()), store.replace_for_tests(first)); let outcomes = [first, second]; assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!( @@ -399,7 +408,7 @@ mod tests { storage.fail_persist.store(true, std::sync::atomic::Ordering::SeqCst); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let error = store - .replace(request(&store, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&store, PolicyReplacementOperation::Update, raw)) .await .expect_err("persistence failure"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); @@ -470,7 +479,7 @@ mod tests { ); replacement.expected_store_token = token; let error = store - .replace(replacement) + .replace_for_tests(replacement) .await .expect_err("monitoring failure blocks PUT"); assert_eq!(error.code, ErrorCode::BrokerPaused); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 77755dfd5..b80361696 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -407,7 +407,7 @@ impl PackageBrokerServer for BrokerConnection { } self.state .policy_store - .replace_audited(request, audit) + .replace(request, audit) .await .map(|success| PolicyReplacementResponse { response_kind: now_policy_api::PolicyReplacementResponseKind, From 58e8ca02667a5a4854945fae9f4407cb9cfa417f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 19 Sep 2026 23:55:20 +0900 Subject: [PATCH 13/53] refactor(agent): adopt shared warning contract Adopt the released policy API contract and preserve advisory findings without a broker-specific acknowledgement gate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 14 +++++++------- crates/agent-policy-tester/src/windows.rs | 2 -- crates/now-package-broker/Cargo.toml | 4 ++-- crates/now-package-broker/src/audit.rs | 2 -- crates/now-package-broker/src/policy_store/mod.rs | 14 -------------- .../now-package-broker/src/policy_store/receipt.rs | 14 +++----------- crates/now-package-broker/src/server/mod.rs | 4 +--- 7 files changed, 13 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a70a49781..bdf99a73c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2679,8 +2679,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3215,7 +3215,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4864,9 +4864,9 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b61d66fd334d2dac6150d1ab83f3831ec4b0ee20272fb3386fbe5b5e31c6663" +checksum = "fcd733577077eb870204207836f596ec3fc8fe4876d3652be7f0dee4a52e0dc8" dependencies = [ "chrono", "derive_more", @@ -4881,9 +4881,9 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee165964d3b2dddfa2c6283b820d5cad337277d51365cf77e6b1376668f529d" +checksum = "567491bfc7bf5615d1854cc951172987fe638084b86c6c153ad6d860f0096ae8" dependencies = [ "aide 0.15.1", "async-trait", diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 26ac51f30..db8d93d22 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -307,7 +307,6 @@ async fn assert_redirected_policy_rejected( "ExpectedStoreToken": management["Management"]["StoreToken"], "Operation": "Repair", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": full_policy(), "ValidationReceipt": "invalid" }); @@ -393,7 +392,6 @@ async fn replace_policy( "ExpectedStoreToken": expected_store_token, "Operation": operation, "ConflictHandling": "Reject", - "WarningsAcknowledged": true, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 3758faabc..fca797a41 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -34,8 +34,8 @@ notify = { version = "7", default-features = false } http-body-util = "0.1" mime = "0.3" now-policy = "=0.5.0" -now-policy-api = "=0.6.0" -now-policy-server-template = "=0.6.0" +now-policy-api = "=0.7.0" +now-policy-server-template = "=0.7.0" parking_lot = "0.12" regex = "1" semver = "1" diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index b84b59b8c..598971c4e 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -48,7 +48,6 @@ pub(crate) enum FailureReason { PathNotWritable, InvalidPolicy, InvalidReceipt, - WarningsNotAcknowledged, RevisionConflict, DraftCommitFailed, SerializationFailed, @@ -65,7 +64,6 @@ impl FailureReason { Self::PathNotWritable => "path_not_writable", Self::InvalidPolicy => "invalid_policy", Self::InvalidReceipt => "invalid_receipt", - Self::WarningsNotAcknowledged => "warnings_not_acknowledged", Self::RevisionConflict => "revision_conflict", Self::DraftCommitFailed => "draft_commit_failed", Self::SerializationFailed => "serialization_failed", diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 1ad1200e3..1785c9c67 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -374,19 +374,6 @@ impl PolicyStore { validation, )); } - if !validation.findings.is_empty() && !request.warnings_acknowledged { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::WarningsNotAcknowledged, - ); - return Err(error_with_validation( - ErrorCode::WarningConfirmationRequired, - "validation warnings must be explicitly acknowledged", - validation, - )); - } - let revision = match plan_revision( request.operation, observation.state, @@ -962,7 +949,6 @@ mod storage_tests { expected_store_token: store.management_snapshot().store_token, operation: PolicyReplacementOperation::Update, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: false, draft: raw, validation_receipt: validation.validation_receipt.expect("valid receipt"), } diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index 3858f66cf..300c416fb 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -115,7 +115,6 @@ mod tests { expected_store_token: store.management_snapshot().store_token, operation, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: false, draft: raw, validation_receipt: validation.validation_receipt.expect("valid receipt"), } @@ -218,7 +217,7 @@ mod tests { assert_eq!(receipt_error.code, ErrorCode::ValidationFailed); } #[tokio::test] - async fn store_requires_warning_acknowledgement() { + async fn store_saves_valid_drafts_with_advisory_findings() { let store = PolicyStore::for_tests(None); let mut risky = serde_json::to_value(draft("risky")).expect("serialize draft"); risky["Rules"] = serde_json::Value::Array( @@ -257,17 +256,11 @@ mod tests { serde_json::to_value(round_trip).expect("serialize round-tripped validation result"), serialized ); - let mut replacement = request(&store, PolicyReplacementOperation::Create, risky); - let error = store - .replace_for_tests(replacement.clone()) - .await - .expect_err("warning must be acknowledged"); - assert_eq!(error.code, ErrorCode::WarningConfirmationRequired); - replacement.warnings_acknowledged = true; + let replacement = request(&store, PolicyReplacementOperation::Create, risky); store .replace_for_tests(replacement) .await - .expect("acknowledged warning succeeds"); + .expect("advisory findings do not block a valid draft"); } #[tokio::test] async fn canonical_sensitive_warnings_accept_the_original_receipt() { @@ -318,7 +311,6 @@ mod tests { expected_store_token: store.management_snapshot().store_token, operation: PolicyReplacementOperation::Create, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: true, draft: canonical.clone(), validation_receipt: receipt.clone(), }; diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index b80361696..6ace82de8 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1027,7 +1027,6 @@ mod tests { "ExpectedStoreToken": replacement_state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": replacement_draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), }); @@ -1054,7 +1053,7 @@ mod tests { Method::PUT, "/v1/policy", "Application/JSON; charset=utf-8", - r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","WarningsAcknowledged":false,"ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, + r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, ), ] { let response = route_raw( @@ -1229,7 +1228,6 @@ mod tests { "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt") }); From 48a7e2dbd8a6a1c4cbcc52980d41377fce529f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 17:24:42 -0400 Subject: [PATCH 14/53] test(agent): cover policy management end to end Exercise policy management through a protected standard-user client and a LocalSystem client without weakening executable or path checks. Cover validation and write denial, managed Create, Update, Repair, stale conflicts, confirmed overwrite, restart persistence, durable managed authority, and observable audit outcomes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 + crates/agent-policy-tester/Cargo.toml | 2 + crates/agent-policy-tester/run-as-system.ps1 | 2 +- crates/agent-policy-tester/run-unelevated.ps1 | 139 ++++++ crates/agent-policy-tester/src/windows.rs | 458 +++++++++++++++++- 5 files changed, 577 insertions(+), 26 deletions(-) create mode 100644 crates/agent-policy-tester/run-unelevated.ps1 diff --git a/Cargo.lock b/Cargo.lock index bdf99a73c..0cc021554 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,8 @@ dependencies = [ "serde_json", "tempfile", "tokio 1.52.3", + "win-api-wrappers", + "windows 0.61.3", ] [[package]] diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index ba2f20f77..0c549b5d1 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -12,6 +12,8 @@ fastrand = "2" serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] } +win-api-wrappers = { path = "../win-api-wrappers" } +windows = { version = "0.61", features = ["Win32_Security"] } [lints] workspace = true diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 8520d10ec..14e9028e5 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -76,7 +76,7 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $agentPath 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE } catch { $_ | Out-File $outputPath -Append diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 new file mode 100644 index 000000000..341edc706 --- /dev/null +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -0,0 +1,139 @@ +param( + [ValidateSet("Orchestrate", "Stage", "Run", "Cleanup")] + [string] $Action = "Orchestrate", + [string] $TesterPath, + [string] $StagedTesterPath, + [string] $StagingPath, + [string] $AgentPath, + [string] $TempPath +) + +$ErrorActionPreference = "Stop" + +if ($Action -eq "Run") { + $env:TEMP = $TempPath + $env:TMP = $TempPath + & $StagedTesterPath $AgentPath unelevated + exit $LASTEXITCODE +} + +if ($Action -eq "Cleanup") { + for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $StagingPath); $attempt++) { + try { + Remove-Item -LiteralPath $StagingPath -Recurse -Force + } catch { + if ($attempt -eq 19) { + throw "Failed to remove $StagingPath after 20 attempts: $_" + } + Start-Sleep -Milliseconds 250 + } + } + exit $(if (Test-Path -LiteralPath $StagingPath) { 1 } else { 0 }) +} + +if ($Action -eq "Stage") { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class AgentPolicyUnelevatedTesterNativeDirectory +{ + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + internal int InheritHandle; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateDirectoryW(string path, ref SecurityAttributes securityAttributes); + + public static void Create(string path, byte[] securityDescriptor) + { + GCHandle pinnedDescriptor = GCHandle.Alloc(securityDescriptor, GCHandleType.Pinned); + try + { + SecurityAttributes attributes = new SecurityAttributes + { + Length = Marshal.SizeOf(), + SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), + InheritHandle = 0, + }; + if (!CreateDirectoryW(path, ref attributes)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + pinnedDescriptor.Free(); + } + } +} +'@ + $directorySecurity = [System.Security.AccessControl.DirectorySecurity]::new() + $directorySecurity.SetSecurityDescriptorSddlForm( + 'O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)' + ) + [AgentPolicyUnelevatedTesterNativeDirectory]::Create( + $StagingPath, + $directorySecurity.GetSecurityDescriptorBinaryForm() + ) + if (Get-ChildItem -LiteralPath $StagingPath -Force) { + throw "The atomically protected staged tester directory was not empty" + } + + Copy-Item -LiteralPath $TesterPath -Destination $StagedTesterPath + & icacls.exe $StagedTesterPath /setowner '*S-1-5-18' + if ($LASTEXITCODE -ne 0) { + throw "Failed to set the staged tester owner" + } + & icacls.exe $StagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' '*S-1-5-32-545:(RX)' + if ($LASTEXITCODE -ne 0) { + throw "Failed to protect the staged tester executable" + } + Get-Acl -LiteralPath $StagingPath | Format-List Owner, Sddl + Get-Acl -LiteralPath $StagedTesterPath | Format-List Owner, Sddl + exit 0 +} + +$workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +$testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe" +$agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" +$outputPath = Join-Path $PSScriptRoot "agent-policy-tester-unelevated.out" +$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" +$stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" + +try { + Set-Content -LiteralPath $outputPath -Value "" + New-Item -ItemType Directory -Path $tempPath -Force | Out-Null + + $stageOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Stage -TesterPath $testerPath -StagedTesterPath $stagedTesterPath -StagingPath $stagingPath 2>&1 + $stageExitCode = $LASTEXITCODE + $stageOutput | Out-File $outputPath -Append + if ($stageExitCode -ne 0) { + throw "LocalSystem tester staging failed with exit code $stageExitCode" + } + + $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath 2>&1 + $exitCode = $LASTEXITCODE + $testerOutput | Out-File $outputPath -Append +} catch { + $_ | Out-File $outputPath -Append + $exitCode = 1 +} finally { + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Cleanup -StagingPath $stagingPath 2>&1 + $cleanupExitCode = $LASTEXITCODE + $cleanupOutput | Out-File $outputPath -Append + if ($cleanupExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $cleanupExitCode + } +} + +exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index db8d93d22..855f555fc 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -6,14 +6,21 @@ use anyhow::{Context as _, bail, ensure}; use serde_json::{Value, json}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::ClientOptions; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::process::Process; +use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid}; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); +const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; +const MANAGED_AUTHORITY_MARKER: &str = r"Devolutions\PackageBroker\.package-broker-managed-authority.v1"; +const LEGACY_POLICY_RELATIVE_PATH: &str = r"Devolutions\Agent\package-broker-policy.json"; struct AgentHarness { child: tokio::process::Child, - _data_dir: tempfile::TempDir, + data_dir: tempfile::TempDir, pipe_name: String, policy_path: PathBuf, + program_data: Option, } impl AgentHarness { @@ -34,17 +41,43 @@ impl AgentHarness { Self::start_with_path(agent_path, data_dir, pipe_name, policy_path).await } + async fn start_unelevated(agent_path: &Path) -> anyhow::Result { + let data_dir = tempfile::tempdir().context("create unelevated Agent data directory")?; + let pipe_name = unique_pipe_name(); + let policy_path = data_dir.path().join("policy.json"); + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await + } + + async fn start_managed_default(agent_path: &Path) -> anyhow::Result { + let data_dir = create_data_dir()?; + let pipe_name = unique_pipe_name(); + let policy_path = data_dir.path().join(MANAGED_POLICY_RELATIVE_PATH); + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, true).await + } + async fn start_with_path( agent_path: &Path, data_dir: tempfile::TempDir, pipe_name: String, policy_path: PathBuf, ) -> anyhow::Result { + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await + } + + async fn start_with_options( + agent_path: &Path, + data_dir: tempfile::TempDir, + pipe_name: String, + policy_path: PathBuf, + use_managed_default: bool, + ) -> anyhow::Result { + let policy_path_config = (!use_managed_default).then_some(&policy_path); let config = json!({ + "LogFile": data_dir.path().join("agent-e2e"), "PackageBroker": { "Enabled": true, "PipeName": pipe_name, - "PolicyPath": policy_path, + "PolicyPath": policy_path_config, }, "__debug__": { "skip_broker_signature_validation": true, @@ -53,26 +86,35 @@ impl AgentHarness { std::fs::write(data_dir.path().join("agent.json"), serde_json::to_vec_pretty(&config)?) .context("write Agent configuration")?; - let child = tokio::process::Command::new(agent_path) - .env("DAGENT_CONFIG_PATH", data_dir.path()) - .arg("run") - .kill_on_drop(true) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("start Devolutions Agent")?; + let program_data = use_managed_default.then(|| data_dir.path().to_owned()); + let child = Self::spawn(agent_path, data_dir.path(), program_data.as_deref())?; let mut harness = Self { child, - _data_dir: data_dir, + data_dir, pipe_name, policy_path, + program_data, }; harness.wait_until_ready().await?; Ok(harness) } + fn spawn(agent_path: &Path, data_dir: &Path, program_data: Option<&Path>) -> anyhow::Result { + let mut command = tokio::process::Command::new(agent_path); + command + .env("DAGENT_CONFIG_PATH", data_dir) + .arg("run") + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(program_data) = program_data { + command.env("ProgramData", program_data); + } + command.spawn().context("start Devolutions Agent") + } + async fn wait_until_ready(&mut self) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(20); @@ -89,6 +131,34 @@ impl AgentHarness { } } } + + async fn restart(&mut self, agent_path: &Path) -> anyhow::Result<()> { + self.stop().await?; + self.start_again(agent_path).await + } + + async fn stop(&mut self) -> anyhow::Result<()> { + self.child.start_kill().context("stop Devolutions Agent")?; + self.child.wait().await.context("wait for Devolutions Agent to stop")?; + Ok(()) + } + + async fn start_again(&mut self, agent_path: &Path) -> anyhow::Result<()> { + self.child = Self::spawn(agent_path, self.data_dir.path(), self.program_data.as_deref())?; + self.wait_until_ready().await + } + + fn logs(&self) -> anyhow::Result { + let mut logs = String::new(); + for entry in std::fs::read_dir(self.data_dir.path()).context("read Agent log directory")? { + let entry = entry.context("read Agent log entry")?; + let name = entry.file_name(); + if name.to_string_lossy().starts_with("agent-e2e") { + logs.push_str(&std::fs::read_to_string(entry.path()).context("read Agent log")?); + } + } + Ok(logs) + } } impl Drop for AgentHarness { @@ -108,25 +178,94 @@ impl HttpResponse { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Unelevated, + Elevated, +} + +impl Mode { + fn parse(value: &str) -> anyhow::Result { + match value { + "unelevated" => Ok(Self::Unelevated), + "elevated" => Ok(Self::Elevated), + _ => bail!("unknown mode '{value}'; expected 'unelevated' or 'elevated'"), + } + } +} + pub(crate) async fn run() -> anyhow::Result<()> { - let agent_path = std::env::args_os() - .nth(1) + let mut args = std::env::args_os().skip(1); + let agent_path = args + .next() .map(PathBuf::from) - .context("usage: agent-policy-tester ")?; + .context("usage: agent-policy-tester ")?; + let mode = args + .next() + .and_then(|value| value.into_string().ok()) + .context("test mode must be 'unelevated' or 'elevated'") + .and_then(|value| Mode::parse(&value))?; + ensure!(args.next().is_none(), "unexpected extra command-line arguments"); + verify_process_token(mode)?; ensure!( agent_path.is_file(), "agent executable does not exist: {}", agent_path.display() ); - unavailable_policy_and_method_restrictions(&agent_path).await?; - complete_snapshots_across_reload(&agent_path).await?; - redirected_policy_paths_fail_closed(&agent_path).await?; - management_write_tokens_survive_watcher_reload(&agent_path).await?; + match mode { + Mode::Unelevated => standard_user_management(&agent_path).await?, + Mode::Elevated => { + unavailable_policy_and_method_restrictions(&agent_path).await?; + complete_snapshots_across_reload(&agent_path).await?; + redirected_policy_paths_fail_closed(&agent_path).await?; + management_write_tokens_survive_watcher_reload(&agent_path).await?; + managed_policy_lifecycle(&agent_path).await?; + } + } Ok(()) } +fn verify_process_token(mode: Mode) -> anyhow::Result<()> { + let token = Process::current_process() + .token(TOKEN_QUERY | TOKEN_DUPLICATE) + .context("open tester process token")?; + let administrators = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("construct Administrators SID")?; + let is_administrator = token + .is_member(&administrators) + .context("query tester Administrators membership")?; + let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; + let user = token.sid_and_attributes().context("query tester user SID")?.sid; + match mode { + Mode::Unelevated => { + ensure!(user != system, "unelevated mode requires a standard user account"); + ensure!( + !is_administrator, + "unelevated mode requires disabled Administrators membership" + ); + } + Mode::Elevated => { + ensure!(is_administrator, "elevated mode requires Administrators membership"); + ensure!( + token.is_elevated().context("query tester token elevation")?, + "elevated mode requires an elevated token" + ); + ensure!(user == system, "elevated mode requires the LocalSystem account"); + } + } + Ok(()) +} + +fn unique_pipe_name() -> String { + format!( + r"\\.\pipe\Devolutions.Now.PackageBroker.tests.{}.{}", + std::process::id(), + fastrand::u64(..) + ) +} + async fn request(pipe_name: &str, method: &str, path: &str) -> anyhow::Result { request_with_body(pipe_name, method, path, None, &[]).await } @@ -360,12 +499,7 @@ async fn policy_management(agent: &AgentHarness) -> anyhow::Result { Ok(response.json()?["Management"].clone()) } -async fn replace_policy( - agent: &AgentHarness, - operation: &str, - expected_store_token: Value, - draft: Value, -) -> anyhow::Result { +async fn validate_policy(agent: &AgentHarness, draft: &Value) -> anyhow::Result { let validation_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", @@ -386,12 +520,24 @@ async fn replace_policy( ); let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); + Ok(validation) +} + +async fn replace_policy_response( + agent: &AgentHarness, + operation: &str, + conflict_handling: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let validation = validate_policy(agent, &draft).await?; let replacement_request = json!({ "RequestKind": "PolicyReplacementRequest", "RequestVersion": "1.0", "ExpectedStoreToken": expected_store_token, "Operation": operation, - "ConflictHandling": "Reject", + "ConflictHandling": conflict_handling, + "WarningsAcknowledged": true, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); @@ -403,6 +549,16 @@ async fn replace_policy( &serde_json::to_vec(&replacement_request)?, ) .await?; + Ok(response) +} + +async fn replace_policy( + agent: &AgentHarness, + operation: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let response = replace_policy_response(agent, operation, "Reject", expected_store_token, draft).await?; ensure!(response.status == 200, "{operation} returned HTTP {}", response.status); response.json() } @@ -459,6 +615,258 @@ async fn management_write_tokens_survive_watcher_reload(agent_path: &Path) -> an Ok(()) } +async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { + let agent = AgentHarness::start_unelevated(agent_path).await?; + let management = policy_management(&agent).await?; + ensure!(management["State"] == "Missing", "expected a missing policy"); + + let valid_draft = policy_draft("tests.standard-user", "Test"); + let validation = validate_policy(&agent, &valid_draft).await?; + ensure!( + validation["CanonicalDraft"].is_object() && validation["ValidationReceipt"].is_string(), + "valid draft did not produce a canonical draft and receipt" + ); + + let mut invalid_draft = valid_draft.clone(); + invalid_draft["$schema"] = json!("https://example.com/not-the-policy-draft-schema.json"); + let invalid_request = json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": invalid_draft + }); + let invalid_response = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + Some("application/json"), + &serde_json::to_vec(&invalid_request)?, + ) + .await?; + ensure!( + invalid_response.status == 200, + "invalid draft validation returned HTTP {}", + invalid_response.status + ); + let invalid_validation = invalid_response.json()?["Validation"].clone(); + ensure!(invalid_validation["IsValid"] == false, "invalid draft was accepted"); + ensure!( + invalid_validation.get("CanonicalDraft").is_none(), + "invalid draft returned a canonical draft" + ); + + let denied = replace_policy_response( + &agent, + "Create", + "Reject", + management["StoreToken"].clone(), + valid_draft, + ) + .await?; + ensure!( + denied.status == 403, + "standard-user Create returned HTTP {}", + denied.status + ); + ensure!( + denied.json()?["Code"] == "AdministratorRequired", + "standard-user Create did not require an administrator" + ); + wait_for_log(&agent, "Policy management write denied").await +} + +async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { + let mut agent = AgentHarness::start_managed_default(agent_path).await?; + let initial = policy_management(&agent).await?; + ensure!( + initial["State"] == "Missing", + "managed policy was not initially missing" + ); + ensure!( + initial["Source"] == "DefaultPath" && initial["WriteCapability"] == "Writable", + "isolated managed default path was not writable" + ); + + let created = replace_policy( + &agent, + "Create", + initial["StoreToken"].clone(), + policy_draft("tests.managed-lifecycle", "Create"), + ) + .await?; + ensure!( + created["Policy"]["Metadata"]["Revision"] == 1, + "Create did not assign revision 1" + ); + let authority_marker = agent.data_dir.path().join(MANAGED_AUTHORITY_MARKER); + ensure!( + authority_marker.is_file() && std::fs::metadata(&authority_marker)?.len() == 0, + "Create did not establish durable managed authority" + ); + wait_for_log(&agent, "Policy creation succeeded").await?; + + let updated = replace_policy( + &agent, + "Update", + created["Management"]["StoreToken"].clone(), + policy_draft("tests.managed-lifecycle", "Update"), + ) + .await?; + ensure!( + updated["Policy"]["Metadata"]["Revision"] == 2, + "Update did not increment the revision" + ); + + let secret = "malformed-policy-secret-marker"; + std::fs::write(&agent.policy_path, format!(r#"{{"unterminated":"{secret}"#)) + .context("write malformed external policy")?; + let invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; + let diagnostics = &invalid["InvalidDiagnostics"]; + ensure!( + diagnostics["Findings"] + .as_array() + .is_some_and(|findings| !findings.is_empty()), + "invalid policy did not produce diagnostics" + ); + ensure!( + !diagnostics.to_string().contains(secret), + "invalid policy diagnostics exposed file contents" + ); + wait_for_log(&agent, "External policy change rejected").await?; + + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.managed-repaired", "Repair"), + ) + .await?; + ensure!( + repaired["Policy"]["Metadata"]["Revision"] == 1, + "Repair did not assign revision 1" + ); + + let stale_token = repaired["Management"]["StoreToken"].clone(); + let mut external = empty_policy(); + external["Metadata"]["Id"] = json!("tests.managed-external"); + std::fs::write(&agent.policy_path, serde_json::to_vec_pretty(&external)?).context("write valid external policy")?; + wait_for_management(&agent, |management| { + management["Policy"]["Metadata"]["Id"] == "tests.managed-external" + }) + .await?; + wait_for_log(&agent, "External policy change applied").await?; + + let stale = replace_policy_response( + &agent, + "Update", + "Reject", + stale_token, + policy_draft("tests.managed-external", "Stale"), + ) + .await?; + ensure!(stale.status == 409, "stale Update returned HTTP {}", stale.status); + let stale = stale.json()?; + ensure!( + stale["Code"] == "StalePolicyStoreToken" + && stale["Management"]["Policy"]["Metadata"]["Id"] == "tests.managed-external", + "stale Update did not return the current policy snapshot" + ); + wait_for_log(&agent, "stale_conflict").await?; + + let current_token = stale["Management"]["StoreToken"].clone(); + let confirmed = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + current_token.clone(), + policy_draft("tests.managed-external", "Confirmed overwrite"), + ) + .await?; + ensure!( + confirmed.status == 200, + "exact ConfirmOverwrite returned HTTP {}", + confirmed.status + ); + let confirmed = confirmed.json()?; + ensure!( + confirmed["Policy"]["Metadata"]["Revision"] == 2, + "confirmed Update did not increment the external policy revision" + ); + wait_for_log(&agent, "confirmed_overwrite").await?; + + let reused = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + current_token, + policy_draft("tests.managed-external", "Reused token"), + ) + .await?; + ensure!( + reused.status == 409 && reused.json()?["Code"] == "StalePolicyStoreToken", + "reused ConfirmOverwrite token did not conflict" + ); + + agent.restart(agent_path).await?; + let restarted = request(&agent.pipe_name, "GET", "/v1/policy").await?; + ensure!( + restarted.status == 200, + "policy read after restart returned HTTP {}", + restarted.status + ); + ensure!( + restarted.json()?["Policy"] == confirmed["Policy"], + "restart changed the active managed policy" + ); + ensure!( + authority_marker.is_file() && policy_management(&agent).await?["Source"] == "DefaultPath", + "restart lost durable managed authority" + ); + + agent.stop().await?; + let legacy_path = agent.data_dir.path().join(LEGACY_POLICY_RELATIVE_PATH); + let legacy_dir = legacy_path.parent().context("legacy policy path has no parent")?; + std::fs::create_dir_all(legacy_dir).context("create isolated legacy policy directory")?; + secure_policy_path(legacy_dir, true)?; + std::fs::write(&legacy_path, serde_json::to_vec_pretty(&empty_policy())?) + .context("write isolated legacy policy")?; + secure_policy_path(&legacy_path, false)?; + std::fs::remove_file(&agent.policy_path).context("remove managed policy before authority restart")?; + agent.start_again(agent_path).await?; + let authority = policy_management(&agent).await?; + ensure!( + authority["State"] == "Missing" && authority["Source"] == "DefaultPath", + "durable managed authority allowed legacy policy rollback" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "legacy policy became active after managed authority was established" + ); + Ok(()) +} + +async fn wait_for_management(agent: &AgentHarness, predicate: impl Fn(&Value) -> bool) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let management = policy_management(agent).await?; + if predicate(&management) { + return Ok(management); + } + ensure!(Instant::now() < deadline, "timed out waiting for policy state"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_for_log(agent: &AgentHarness, expected: &str) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if agent.logs()?.contains(expected) { + return Ok(()); + } + ensure!(Instant::now() < deadline, "Agent log did not contain '{expected}'"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow::Result<()> { let agent = AgentHarness::start(agent_path, None).await?; From 87d54221d214262898da794dd3af847ba32a495b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 17:24:52 -0400 Subject: [PATCH 15/53] ci(agent): run policy E2E as both identities Run the protected policy tester under a restricted standard-user token before the existing LocalSystem lifecycle. Keep feature-gated route authorization tests in the same Windows job so the development signature feature cannot hide them from the default workspace suite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82f7f49f7..39a4dc6ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1385,6 +1385,16 @@ jobs: exit $LASTEXITCODE } + - name: Run Agent policy tester as standard user + shell: pwsh + run: | + ./crates/agent-policy-tester/run-unelevated.ps1 + $exitCode = $LASTEXITCODE + Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester-unelevated.out + if ($exitCode -ne 0) { + exit $exitCode + } + - name: Run Agent policy tester as LocalSystem shell: pwsh run: | @@ -1396,6 +1406,10 @@ jobs: exit $exitCode } + - name: Run policy route authorization tests + shell: pwsh + run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature + - name: Show sccache stats if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }} shell: pwsh From a3e3113369e8bc4ba566ee1cea9925582e204ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 21:45:45 -0400 Subject: [PATCH 16/53] test(agent): separate policy client identity Keep the Agent and test server under LocalSystem while a distinct restricted process exercises the named-pipe management endpoints. Coordinate readiness and shutdown through a protected, read-only test directory so authorization regressions cannot pass by inspecting the server token. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/Cargo.toml | 2 +- crates/agent-policy-tester/run-unelevated.ps1 | 93 ++++++- crates/agent-policy-tester/src/windows.rs | 258 ++++++++++++++---- 3 files changed, 299 insertions(+), 54 deletions(-) diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index 0c549b5d1..69f0f8d67 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -13,7 +13,7 @@ serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] } win-api-wrappers = { path = "../win-api-wrappers" } -windows = { version = "0.61", features = ["Win32_Security"] } +windows = { version = "0.61", features = ["Win32_Security", "Win32_System_Threading"] } [lints] workspace = true diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 341edc706..6508507d3 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -1,11 +1,16 @@ param( - [ValidateSet("Orchestrate", "Stage", "Run", "Cleanup")] + [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup")] [string] $Action = "Orchestrate", [string] $TesterPath, [string] $StagedTesterPath, [string] $StagingPath, [string] $AgentPath, - [string] $TempPath + [string] $TempPath, + [string] $ReadyPath, + [string] $StopPath, + [string] $StatusPath, + [string] $ServerOutputPath, + [string] $Nonce ) $ErrorActionPreference = "Stop" @@ -13,10 +18,29 @@ $ErrorActionPreference = "Stop" if ($Action -eq "Run") { $env:TEMP = $TempPath $env:TMP = $TempPath - & $StagedTesterPath $AgentPath unelevated + & $StagedTesterPath $AgentPath standard-client $ReadyPath $Nonce exit $LASTEXITCODE } +if ($Action -eq "Server") { + try { + & $StagedTesterPath $AgentPath standard-server $ReadyPath $StopPath $Nonce 2>&1 | + Out-File -LiteralPath $ServerOutputPath + $exitCode = $LASTEXITCODE + } catch { + $_ | Out-File -LiteralPath $ServerOutputPath -Append + $exitCode = 1 + } finally { + Set-Content -LiteralPath $StatusPath -Value $exitCode + } + exit $exitCode +} + +if ($Action -eq "Signal") { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + exit 0 +} + if ($Action -eq "Cleanup") { for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $StagingPath); $attempt++) { try { @@ -37,7 +61,7 @@ using System; using System.ComponentModel; using System.Runtime.InteropServices; -public static class AgentPolicyUnelevatedTesterNativeDirectory +public static class AgentPolicyStandardUserTesterDirectory { [StructLayout(LayoutKind.Sequential)] private struct SecurityAttributes @@ -77,7 +101,7 @@ public static class AgentPolicyUnelevatedTesterNativeDirectory $directorySecurity.SetSecurityDescriptorSddlForm( 'O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)' ) - [AgentPolicyUnelevatedTesterNativeDirectory]::Create( + [AgentPolicyStandardUserTesterDirectory]::Create( $StagingPath, $directorySecurity.GetSecurityDescriptorBinaryForm() ) @@ -105,7 +129,14 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester-unelevated.out" $stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$readyPath = Join-Path $stagingPath "standard-user-ready.json" +$stopPath = Join-Path $stagingPath "standard-user-stop" +$statusPath = Join-Path $stagingPath "standard-user-server.status" +$serverOutputPath = Join-Path $stagingPath "standard-user-server.out" $tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" +$nonce = [guid]::NewGuid().ToString("N") +$exitCode = 1 +$serverStarted = $false try { Set-Content -LiteralPath $outputPath -Value "" @@ -119,14 +150,64 @@ try { throw "LocalSystem tester staging failed with exit code $stageExitCode" } + $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` + -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 + $serverStartExitCode = $LASTEXITCODE + $serverOutput | Out-File $outputPath -Append + if ($serverStartExitCode -ne 0) { + throw "LocalSystem test server failed to start with exit code $serverStartExitCode" + } + $serverStarted = $true + + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $readyPath)) { + if (Test-Path -LiteralPath $statusPath) { + throw "LocalSystem test server exited before publishing readiness" + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for LocalSystem test server readiness" + } + Start-Sleep -Milliseconds 100 + } + Get-Content -LiteralPath $readyPath | Out-File $outputPath -Append + $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` - -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath 2>&1 + -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath ` + -ReadyPath $readyPath -Nonce $nonce 2>&1 $exitCode = $LASTEXITCODE $testerOutput | Out-File $outputPath -Append } catch { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { + if ($serverStarted) { + $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Signal -StopPath $stopPath 2>&1 + $signalExitCode = $LASTEXITCODE + $signalOutput | Out-File $outputPath -Append + if ($signalExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $signalExitCode + } + + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $serverOutputPath) { + Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append + } + if (Test-Path -LiteralPath $statusPath) { + $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) + if ($serverExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $serverExitCode + } + } elseif ($exitCode -eq 0) { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append + $exitCode = 1 + } + } + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Cleanup -StagingPath $stagingPath 2>&1 $cleanupExitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 855f555fc..933904842 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -1,3 +1,5 @@ +use std::fs::OpenOptions; +use std::io::Write as _; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -9,6 +11,7 @@ use tokio::net::windows::named_pipe::ClientOptions; use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::process::Process; use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid}; +use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; @@ -180,16 +183,18 @@ impl HttpResponse { #[derive(Clone, Copy, PartialEq, Eq)] enum Mode { - Unelevated, + StandardServer, + StandardClient, Elevated, } impl Mode { fn parse(value: &str) -> anyhow::Result { match value { - "unelevated" => Ok(Self::Unelevated), + "standard-server" => Ok(Self::StandardServer), + "standard-client" => Ok(Self::StandardClient), "elevated" => Ok(Self::Elevated), - _ => bail!("unknown mode '{value}'; expected 'unelevated' or 'elevated'"), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), } } } @@ -199,23 +204,33 @@ pub(crate) async fn run() -> anyhow::Result<()> { let agent_path = args .next() .map(PathBuf::from) - .context("usage: agent-policy-tester ")?; + .context("usage: agent-policy-tester [mode arguments]")?; let mode = args .next() .and_then(|value| value.into_string().ok()) - .context("test mode must be 'unelevated' or 'elevated'") + .context("test mode is required") .and_then(|value| Mode::parse(&value))?; - ensure!(args.next().is_none(), "unexpected extra command-line arguments"); - verify_process_token(mode)?; - ensure!( - agent_path.is_file(), - "agent executable does not exist: {}", - agent_path.display() - ); - match mode { - Mode::Unelevated => standard_user_management(&agent_path).await?, + Mode::StandardServer => { + verify_local_system()?; + ensure_agent_path(&agent_path)?; + let ready_path = next_path(&mut args, "ready path")?; + let stop_path = next_path(&mut args, "stop path")?; + let nonce = next_string(&mut args, "coordination nonce")?; + ensure!(args.next().is_none(), "unexpected standard-server arguments"); + standard_user_server(&agent_path, &ready_path, &stop_path, &nonce).await?; + } + Mode::StandardClient => { + let client = verify_standard_user()?; + let ready_path = next_path(&mut args, "ready path")?; + let nonce = next_string(&mut args, "coordination nonce")?; + ensure!(args.next().is_none(), "unexpected standard-client arguments"); + standard_user_management(&ready_path, &nonce, &client).await?; + } Mode::Elevated => { + verify_local_system()?; + ensure_agent_path(&agent_path)?; + ensure!(args.next().is_none(), "unexpected elevated arguments"); unavailable_policy_and_method_restrictions(&agent_path).await?; complete_snapshots_across_reload(&agent_path).await?; redirected_policy_paths_fail_closed(&agent_path).await?; @@ -227,7 +242,21 @@ pub(crate) async fn run() -> anyhow::Result<()> { Ok(()) } -fn verify_process_token(mode: Mode) -> anyhow::Result<()> { +fn ensure_agent_path(agent_path: &Path) -> anyhow::Result<()> { + ensure!( + agent_path.is_file(), + "agent executable does not exist: {}", + agent_path.display() + ); + Ok(()) +} + +struct ProcessIdentity { + pid: u32, + sid: Sid, +} + +fn current_process_identity() -> anyhow::Result<(ProcessIdentity, bool, bool)> { let token = Process::current_process() .token(TOKEN_QUERY | TOKEN_DUPLICATE) .context("open tester process token")?; @@ -236,26 +265,59 @@ fn verify_process_token(mode: Mode) -> anyhow::Result<()> { let is_administrator = token .is_member(&administrators) .context("query tester Administrators membership")?; + let identity = ProcessIdentity { + pid: std::process::id(), + sid: token.sid_and_attributes().context("query tester user SID")?.sid, + }; + Ok(( + identity, + is_administrator, + token.is_elevated().context("query tester token elevation")?, + )) +} + +fn verify_standard_user() -> anyhow::Result { + let (identity, is_administrator, _) = current_process_identity()?; let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; - let user = token.sid_and_attributes().context("query tester user SID")?.sid; - match mode { - Mode::Unelevated => { - ensure!(user != system, "unelevated mode requires a standard user account"); - ensure!( - !is_administrator, - "unelevated mode requires disabled Administrators membership" - ); - } - Mode::Elevated => { - ensure!(is_administrator, "elevated mode requires Administrators membership"); - ensure!( - token.is_elevated().context("query tester token elevation")?, - "elevated mode requires an elevated token" - ); - ensure!(user == system, "elevated mode requires the LocalSystem account"); - } - } - Ok(()) + ensure!( + identity.sid != system, + "standard-client mode requires a non-SYSTEM account" + ); + ensure!( + !is_administrator, + "standard-client mode requires disabled Administrators membership" + ); + Ok(identity) +} + +fn verify_local_system() -> anyhow::Result { + let (identity, is_administrator, is_elevated) = current_process_identity()?; + let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; + ensure!(identity.sid == system, "this mode requires the LocalSystem account"); + ensure!(is_administrator && is_elevated, "LocalSystem token is not elevated"); + Ok(identity) +} + +fn process_sid(pid: u32) -> anyhow::Result { + Process::get_by_pid(pid, PROCESS_QUERY_LIMITED_INFORMATION) + .with_context(|| format!("open process {pid}"))? + .token(TOKEN_QUERY) + .with_context(|| format!("open process {pid} token"))? + .sid_and_attributes() + .with_context(|| format!("query process {pid} SID")) + .map(|identity| identity.sid) +} + +fn next_path(args: &mut impl Iterator, name: &str) -> anyhow::Result { + args.next() + .map(PathBuf::from) + .with_context(|| format!("missing {name}")) +} + +fn next_string(args: &mut impl Iterator, name: &str) -> anyhow::Result { + args.next() + .and_then(|value| value.into_string().ok()) + .with_context(|| format!("missing or non-Unicode {name}")) } fn unique_pipe_name() -> String { @@ -490,7 +552,11 @@ async fn redirected_policy_paths_fail_closed(agent_path: &Path) -> anyhow::Resul } async fn policy_management(agent: &AgentHarness) -> anyhow::Result { - let response = request(&agent.pipe_name, "GET", "/v1/policy/management").await?; + policy_management_by_pipe(&agent.pipe_name).await +} + +async fn policy_management_by_pipe(pipe_name: &str) -> anyhow::Result { + let response = request(pipe_name, "GET", "/v1/policy/management").await?; ensure!( response.status == 200, "GET /v1/policy/management returned HTTP {}", @@ -499,14 +565,14 @@ async fn policy_management(agent: &AgentHarness) -> anyhow::Result { Ok(response.json()?["Management"].clone()) } -async fn validate_policy(agent: &AgentHarness, draft: &Value) -> anyhow::Result { +async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Result { let validation_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", "Draft": draft }); let validation_response = request_with_body( - &agent.pipe_name, + pipe_name, "POST", "/v1/policy/validate", Some("application/json"), @@ -530,7 +596,24 @@ async fn replace_policy_response( expected_store_token: Value, draft: Value, ) -> anyhow::Result { - let validation = validate_policy(agent, &draft).await?; + replace_policy_response_by_pipe( + &agent.pipe_name, + operation, + conflict_handling, + expected_store_token, + draft, + ) + .await +} + +async fn replace_policy_response_by_pipe( + pipe_name: &str, + operation: &str, + conflict_handling: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let validation = validate_policy_by_pipe(pipe_name, &draft).await?; let replacement_request = json!({ "RequestKind": "PolicyReplacementRequest", "RequestVersion": "1.0", @@ -542,7 +625,7 @@ async fn replace_policy_response( "ValidationReceipt": validation["ValidationReceipt"] }); let response = request_with_body( - &agent.pipe_name, + pipe_name, "PUT", "/v1/policy", Some("application/json"), @@ -615,13 +698,94 @@ async fn management_write_tokens_survive_watcher_reload(agent_path: &Path) -> an Ok(()) } -async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { - let agent = AgentHarness::start_unelevated(agent_path).await?; - let management = policy_management(&agent).await?; +async fn standard_user_server( + agent_path: &Path, + ready_path: &Path, + stop_path: &Path, + nonce: &str, +) -> anyhow::Result<()> { + ensure!(!ready_path.exists(), "standard-user readiness path already exists"); + ensure!(!stop_path.exists(), "standard-user stop path already exists"); + + let mut agent = AgentHarness::start_unelevated(agent_path).await?; + let server = verify_local_system()?; + let agent_pid = agent.child.id().context("Agent process has no PID")?; + let child_sid = process_sid(agent_pid)?; + ensure!( + child_sid == server.sid, + "Agent and test server must both run as LocalSystem" + ); + + let readiness = serde_json::to_vec(&json!({ + "Nonce": nonce, + "PipeName": agent.pipe_name, + "ServerPid": server.pid, + "ServerSid": server.sid.to_string(), + "AgentPid": agent_pid, + "AgentSid": child_sid.to_string(), + }))?; + let mut ready_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(ready_path) + .context("create standard-user readiness file")?; + ready_file + .write_all(&readiness) + .context("write standard-user readiness file")?; + ready_file.sync_all().context("flush standard-user readiness file")?; + + let deadline = Instant::now() + Duration::from_secs(90); + while !stop_path.exists() { + if let Some(status) = agent.child.try_wait().context("query Agent status")? { + bail!("Agent exited during standard-user test with {status}"); + } + ensure!( + Instant::now() < deadline, + "timed out waiting for standard-user client completion" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + wait_for_log(&agent, "Policy management write denied").await +} + +async fn standard_user_management(ready_path: &Path, nonce: &str, client: &ProcessIdentity) -> anyhow::Result<()> { + let readiness: Value = + serde_json::from_slice(&std::fs::read(ready_path).context("read standard-user readiness file")?) + .context("parse standard-user readiness file")?; + ensure!(readiness["Nonce"] == nonce, "standard-user readiness nonce mismatch"); + let pipe_name = readiness["PipeName"] + .as_str() + .context("readiness file has no pipe name")?; + let server_pid = readiness["ServerPid"] + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .context("readiness file has no valid server PID")?; + let agent_pid = readiness["AgentPid"] + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .context("readiness file has no valid Agent PID")?; + let system = Sid::from_well_known(WinLocalSystemSid, None) + .context("construct LocalSystem SID")? + .to_string(); + ensure!( + readiness["ServerSid"] == system && readiness["AgentSid"] == system, + "test server and Agent identities were not recorded as LocalSystem" + ); + ensure!( + client.pid != server_pid && client.pid != agent_pid && server_pid != agent_pid, + "standard-user client, test server, and Agent must be distinct processes" + ); + ensure!( + client.sid.to_string() != system, + "standard-user client unexpectedly uses the server identity" + ); + + let management = policy_management_by_pipe(pipe_name).await?; ensure!(management["State"] == "Missing", "expected a missing policy"); let valid_draft = policy_draft("tests.standard-user", "Test"); - let validation = validate_policy(&agent, &valid_draft).await?; + let validation = validate_policy_by_pipe(pipe_name, &valid_draft).await?; ensure!( validation["CanonicalDraft"].is_object() && validation["ValidationReceipt"].is_string(), "valid draft did not produce a canonical draft and receipt" @@ -635,7 +799,7 @@ async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { "Draft": invalid_draft }); let invalid_response = request_with_body( - &agent.pipe_name, + pipe_name, "POST", "/v1/policy/validate", Some("application/json"), @@ -654,8 +818,8 @@ async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { "invalid draft returned a canonical draft" ); - let denied = replace_policy_response( - &agent, + let denied = replace_policy_response_by_pipe( + pipe_name, "Create", "Reject", management["StoreToken"].clone(), @@ -671,7 +835,7 @@ async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { denied.json()?["Code"] == "AdministratorRequired", "standard-user Create did not require an administrator" ); - wait_for_log(&agent, "Policy management write denied").await + Ok(()) } async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { From 6ff4e4bb5e86771323e68472cd54acf147e23a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 23:25:25 -0400 Subject: [PATCH 17/53] ci(agent): handle detached policy tester launch Treat PsExec's detached-process PID as diagnostic output and use bounded, validated readiness as the authoritative launch result. Publish readiness atomically, preserve launch diagnostics, and keep shutdown and cleanup idempotent so orchestration errors remain actionable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 171 ++++++++++++++---- crates/agent-policy-tester/src/windows.rs | 13 +- 2 files changed, 149 insertions(+), 35 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 6508507d3..2ea138f8f 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -1,5 +1,5 @@ param( - [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup")] + [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup", "SelfTest")] [string] $Action = "Orchestrate", [string] $TesterPath, [string] $StagedTesterPath, @@ -15,6 +15,131 @@ param( $ErrorActionPreference = "Stop" +function Test-ExplicitPsExecLaunchFailure { + param([string] $Diagnostics) + + return $Diagnostics -match '(?im)^(Couldn''t install PSEXESVC service:|Error establishing communication with PsExec service|Access is denied\.)' +} + +function Wait-ServerReadiness { + param( + [string] $Path, + [string] $ServerStatusPath, + [string] $ExpectedNonce, + [int] $TimeoutMilliseconds, + [int] $LaunchValue, + [string] $LaunchDiagnostics + ) + + if (Test-ExplicitPsExecLaunchFailure $LaunchDiagnostics) { + throw "LocalSystem test server launch failed (value $LaunchValue): $LaunchDiagnostics" + } + + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while (-not (Test-Path -LiteralPath $Path)) { + if (Test-Path -LiteralPath $ServerStatusPath) { + $status = Get-Content -LiteralPath $ServerStatusPath -Raw + throw "LocalSystem test server exited with status $status before publishing readiness (launch value $LaunchValue): $LaunchDiagnostics" + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for LocalSystem test server readiness (launch value $LaunchValue): $LaunchDiagnostics" + } + Start-Sleep -Milliseconds 100 + } + + $readiness = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($readiness.Nonce -cne $ExpectedNonce) { + throw "LocalSystem test server readiness nonce mismatch" + } + if ([string]::IsNullOrWhiteSpace($readiness.PipeName)) { + throw "LocalSystem test server readiness has no pipe name" + } + if ($readiness.ServerPid -le 0 -or $readiness.AgentPid -le 0 -or $readiness.ServerPid -eq $readiness.AgentPid) { + throw "LocalSystem test server readiness has invalid process identities" + } + if ($readiness.ServerSid -cne 'S-1-5-18' -or $readiness.AgentSid -cne 'S-1-5-18') { + throw "LocalSystem test server readiness has non-SYSTEM identities" + } + + return $readiness +} + +function Remove-StagingPath { + param([string] $Path) + + for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $Path); $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force + } catch { + if ($attempt -eq 19) { + throw "Failed to remove $Path after 20 attempts: $_" + } + Start-Sleep -Milliseconds 250 + } + } + if (Test-Path -LiteralPath $Path) { + throw "Failed to remove $Path" + } +} + +function Invoke-RunnerSelfTests { + $root = Join-Path ([System.IO.Path]::GetTempPath()) "agent-policy-runner-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $root | Out-Null + try { + $ready = Join-Path $root "ready.json" + $status = Join-Path $root "status" + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 200 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + + Remove-Item -LiteralPath $ready + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Missing-readiness simulation unexpectedly succeeded" + } catch { + if ( + $_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or + $_ -notmatch "started detached process" + ) { + throw + } + } + + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 5000 -LaunchValue 6 ` + -LaunchDiagnostics "Couldn't install PSEXESVC service: Access is denied." | Out-Null + throw "Explicit-launch-failure simulation unexpectedly succeeded" + } catch { + if ( + $_ -notmatch "LocalSystem test server launch failed" -or + $_ -notmatch "Couldn't install PSEXESVC service" + ) { + throw + } + } + + Remove-StagingPath -Path (Join-Path $root "already-absent") + } finally { + Remove-StagingPath -Path $root + } +} + +if ($Action -eq "SelfTest") { + Invoke-RunnerSelfTests + exit 0 +} + if ($Action -eq "Run") { $env:TEMP = $TempPath $env:TMP = $TempPath @@ -37,22 +162,15 @@ if ($Action -eq "Server") { } if ($Action -eq "Signal") { - New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + if (-not (Test-Path -LiteralPath $StopPath)) { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + } exit 0 } if ($Action -eq "Cleanup") { - for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $StagingPath); $attempt++) { - try { - Remove-Item -LiteralPath $StagingPath -Recurse -Force - } catch { - if ($attempt -eq 19) { - throw "Failed to remove $StagingPath after 20 attempts: $_" - } - Start-Sleep -Milliseconds 250 - } - } - exit $(if (Test-Path -LiteralPath $StagingPath) { 1 } else { 0 }) + Remove-StagingPath -Path $StagingPath + exit 0 } if ($Action -eq "Stage") { @@ -136,9 +254,10 @@ $serverOutputPath = Join-Path $stagingPath "standard-user-server.out" $tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" $nonce = [guid]::NewGuid().ToString("N") $exitCode = 1 -$serverStarted = $false +$coordinationReady = $false try { + Invoke-RunnerSelfTests Set-Content -LiteralPath $outputPath -Value "" New-Item -ItemType Directory -Path $tempPath -Force | Out-Null @@ -155,22 +274,12 @@ try { -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 $serverStartExitCode = $LASTEXITCODE $serverOutput | Out-File $outputPath -Append - if ($serverStartExitCode -ne 0) { - throw "LocalSystem test server failed to start with exit code $serverStartExitCode" - } - $serverStarted = $true - - $deadline = [DateTime]::UtcNow.AddSeconds(30) - while (-not (Test-Path -LiteralPath $readyPath)) { - if (Test-Path -LiteralPath $statusPath) { - throw "LocalSystem test server exited before publishing readiness" - } - if ([DateTime]::UtcNow -ge $deadline) { - throw "Timed out waiting for LocalSystem test server readiness" - } - Start-Sleep -Milliseconds 100 - } - Get-Content -LiteralPath $readyPath | Out-File $outputPath -Append + "Detached server launch value: $serverStartExitCode" | Out-File $outputPath -Append + $serverLaunchDiagnostics = ($serverOutput | Out-String).Trim() + $readiness = Wait-ServerReadiness -Path $readyPath -ServerStatusPath $statusPath -ExpectedNonce $nonce ` + -TimeoutMilliseconds 30000 -LaunchValue $serverStartExitCode -LaunchDiagnostics $serverLaunchDiagnostics + $coordinationReady = $true + $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath ` @@ -181,7 +290,7 @@ try { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - if ($serverStarted) { + if ($coordinationReady) { $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Signal -StopPath $stopPath 2>&1 $signalExitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 933904842..6d189cf19 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -724,15 +724,20 @@ async fn standard_user_server( "AgentPid": agent_pid, "AgentSid": child_sid.to_string(), }))?; + let ready_temp_path = ready_path.with_extension("tmp"); let mut ready_file = OpenOptions::new() .write(true) .create_new(true) - .open(ready_path) - .context("create standard-user readiness file")?; + .open(&ready_temp_path) + .context("create standard-user readiness temporary file")?; ready_file .write_all(&readiness) - .context("write standard-user readiness file")?; - ready_file.sync_all().context("flush standard-user readiness file")?; + .context("write standard-user readiness temporary file")?; + ready_file + .sync_all() + .context("flush standard-user readiness temporary file")?; + drop(ready_file); + std::fs::rename(&ready_temp_path, ready_path).context("publish standard-user readiness file")?; let deadline = Instant::now() + Duration::from_secs(90); while !stop_path.exists() { From 304f5782a331781d88f10e7626ecddac406b7ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 00:46:35 -0400 Subject: [PATCH 18/53] test(agent): use medium-integrity policy client Launch policy authorization requests from a unique temporary standard-user account instead of a Low-integrity PsExec token. Require the exact account SID and Medium mandatory integrity level, keep credentials out of arguments and logs, and remove the account and profile after the bounded run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 193 +++++++++++++++++- crates/agent-policy-tester/src/windows.rs | 122 ++++++++++- 2 files changed, 296 insertions(+), 19 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 2ea138f8f..8e7a24c4d 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -10,7 +10,8 @@ param( [string] $StopPath, [string] $StatusPath, [string] $ServerOutputPath, - [string] $Nonce + [string] $Nonce, + [string] $ExpectedClientSid ) $ErrorActionPreference = "Stop" @@ -82,6 +83,163 @@ function Remove-StagingPath { } } +function New-RandomSecurePassword { + $alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*" + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + $password = [System.Security.SecureString]::new() + foreach ($byte in $bytes) { + $password.AppendChar($alphabet[$byte % $alphabet.Length]) + } + foreach ($required in "Aa1!".ToCharArray()) { + $password.AppendChar($required) + } + $password.MakeReadOnly() + return $password +} + +function New-StandardUserAccount { + $name = "dgwpol$([guid]::NewGuid().ToString('N').Substring(0, 12))" + $password = New-RandomSecurePassword + try { + $user = New-LocalUser -Name $name -Password $password -AccountNeverExpires ` + -PasswordNeverExpires -UserMayNotChangePassword ` + -Description "Temporary Devolutions Agent policy E2E user" + $usersGroup = Get-LocalGroup -SID "S-1-5-32-545" + $isMember = Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop | + Where-Object { $_.SID.Value -eq $user.SID.Value } + if (-not $isMember) { + Add-LocalGroupMember -Group $usersGroup -Member $user -ErrorAction Stop + } + return [pscustomobject]@{ + Name = $name + Sid = $user.SID.Value + Credential = [System.Management.Automation.PSCredential]::new( + $name, + $password + ) + } + } catch { + Remove-LocalUser -Name $name -ErrorAction SilentlyContinue + $password.Dispose() + throw + } +} + +function Set-StandardUserTempAcl { + param( + [string] $Path, + [string] $UserSid + ) + + New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null + & icacls.exe $Path /inheritance:r /grant:r ` + '*S-1-5-18:(OI)(CI)(F)' ` + '*S-1-5-32-544:(OI)(CI)(F)' ` + "*$($UserSid):(OI)(CI)(M)" + if ($LASTEXITCODE -ne 0) { + throw "Failed to protect the standard-user temporary directory" + } +} + +function Invoke-StandardUserClient { + param( + [System.Management.Automation.PSCredential] $Credential, + [string] $UserSid, + [string] $ClientTempPath, + [string] $ScriptPath, + [string] $TesterExecutablePath, + [string] $AgentExecutablePath, + [string] $ReadinessPath, + [string] $ExpectedNonce + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Command pwsh.exe -CommandType Application).Source + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.LoadUserProfile = $false + $startInfo.UserName = $Credential.UserName + $startInfo.Domain = "." + $startInfo.Password = $Credential.Password + $startInfo.WorkingDirectory = $ClientTempPath + $startInfo.Environment["TEMP"] = $ClientTempPath + $startInfo.Environment["TMP"] = $ClientTempPath + foreach ($argument in @( + "-NoProfile", + "-File", + $ScriptPath, + "-Action", + "Run", + "-StagedTesterPath", + $TesterExecutablePath, + "-AgentPath", + $AgentExecutablePath, + "-TempPath", + $ClientTempPath, + "-ReadyPath", + $ReadinessPath, + "-Nonce", + $ExpectedNonce, + "-ExpectedClientSid", + $UserSid + )) { + $startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "Failed to start the medium-integrity standard-user client" + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(60000)) { + $process.Kill($true) + $process.WaitForExit() + throw "Timed out waiting for the medium-integrity standard-user client" + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout.GetAwaiter().GetResult() + StdErr = $stderr.GetAwaiter().GetResult() + } + } finally { + $process.Dispose() + } +} + +function Remove-StandardUserAccount { + param( + [string] $Name, + [string] $Sid + ) + + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + $profile = Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$Sid'" -ErrorAction Stop + if ($profile) { + $profile | Remove-CimInstance -ErrorAction Stop + } + if (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $Name -ErrorAction Stop + } + if (-not (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue)) { + return + } + } catch { + if ($attempt -eq 19) { + throw + } + } + Start-Sleep -Milliseconds 250 + } + throw "Temporary standard-user account still exists after 20 removal attempts" +} + function Invoke-RunnerSelfTests { $root = Join-Path ([System.IO.Path]::GetTempPath()) "agent-policy-runner-$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $root | Out-Null @@ -143,7 +301,7 @@ if ($Action -eq "SelfTest") { if ($Action -eq "Run") { $env:TEMP = $TempPath $env:TMP = $TempPath - & $StagedTesterPath $AgentPath standard-client $ReadyPath $Nonce + & $StagedTesterPath $AgentPath standard-client $ExpectedClientSid $ReadyPath $Nonce exit $LASTEXITCODE } @@ -251,15 +409,14 @@ $readyPath = Join-Path $stagingPath "standard-user-ready.json" $stopPath = Join-Path $stagingPath "standard-user-stop" $statusPath = Join-Path $stagingPath "standard-user-server.status" $serverOutputPath = Join-Path $stagingPath "standard-user-server.out" -$tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" $nonce = [guid]::NewGuid().ToString("N") $exitCode = 1 $coordinationReady = $false +$clientAccount = $null try { Invoke-RunnerSelfTests Set-Content -LiteralPath $outputPath -Value "" - New-Item -ItemType Directory -Path $tempPath -Force | Out-Null $stageOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Stage -TesterPath $testerPath -StagedTesterPath $stagedTesterPath -StagingPath $stagingPath 2>&1 @@ -269,6 +426,10 @@ try { throw "LocalSystem tester staging failed with exit code $stageExitCode" } + $clientAccount = New-StandardUserAccount + $clientTempPath = Join-Path $stagingPath "standard-user-temp" + Set-StandardUserTempAcl -Path $clientTempPath -UserSid $clientAccount.Sid + $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 @@ -281,11 +442,12 @@ try { $coordinationReady = $true $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append - $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` - -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath ` - -ReadyPath $readyPath -Nonce $nonce 2>&1 - $exitCode = $LASTEXITCODE - $testerOutput | Out-File $outputPath -Append + $client = Invoke-StandardUserClient -Credential $clientAccount.Credential -UserSid $clientAccount.Sid ` + -ClientTempPath $clientTempPath -ScriptPath $PSCommandPath -TesterExecutablePath $stagedTesterPath ` + -AgentExecutablePath $agentPath -ReadinessPath $readyPath -ExpectedNonce $nonce + $client.StdOut | Out-File $outputPath -Append + $client.StdErr | Out-File $outputPath -Append + $exitCode = $client.ExitCode } catch { $_ | Out-File $outputPath -Append $exitCode = 1 @@ -317,6 +479,19 @@ try { } } + if ($clientAccount) { + try { + Remove-StandardUserAccount -Name $clientAccount.Name -Sid $clientAccount.Sid + } catch { + $_ | Out-File $outputPath -Append + if ($exitCode -eq 0) { + $exitCode = 1 + } + } finally { + $clientAccount.Credential.Password.Dispose() + } + } + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Cleanup -StagingPath $stagingPath 2>&1 $cleanupExitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 6d189cf19..8dcb471ad 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -1,5 +1,6 @@ use std::fs::OpenOptions; use std::io::Write as _; +use std::mem::size_of; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -10,13 +11,20 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::ClientOptions; use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::process::Process; -use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid}; -use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Security::{ + GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, TOKEN_DUPLICATE, TOKEN_MANDATORY_LABEL, + TOKEN_QUERY, TokenIntegrityLevel, WinBuiltinAdministratorsSid, WinLocalSystemSid, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION}; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; const MANAGED_AUTHORITY_MARKER: &str = r"Devolutions\PackageBroker\.package-broker-managed-authority.v1"; const LEGACY_POLICY_RELATIVE_PATH: &str = r"Devolutions\Agent\package-broker-policy.json"; +#[cfg(test)] +const SECURITY_MANDATORY_LOW_RID: u32 = 0x1000; +const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x2000; struct AgentHarness { child: tokio::process::Child, @@ -221,7 +229,8 @@ pub(crate) async fn run() -> anyhow::Result<()> { standard_user_server(&agent_path, &ready_path, &stop_path, &nonce).await?; } Mode::StandardClient => { - let client = verify_standard_user()?; + let expected_sid = next_string(&mut args, "expected client SID")?; + let client = verify_standard_user(&expected_sid)?; let ready_path = next_path(&mut args, "ready path")?; let nonce = next_string(&mut args, "coordination nonce")?; ensure!(args.next().is_none(), "unexpected standard-client arguments"); @@ -276,18 +285,85 @@ fn current_process_identity() -> anyhow::Result<(ProcessIdentity, bool, bool)> { )) } -fn verify_standard_user() -> anyhow::Result { +fn verify_standard_user(expected_sid: &str) -> anyhow::Result { let (identity, is_administrator, _) = current_process_identity()?; - let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; - ensure!( - identity.sid != system, - "standard-client mode requires a non-SYSTEM account" - ); + validate_standard_user_token( + &identity.sid.to_string(), + expected_sid, + is_administrator, + current_integrity_level()?, + )?; + Ok(identity) +} + +fn validate_standard_user_token( + actual_sid: &str, + expected_sid: &str, + is_administrator: bool, + integrity_level: u32, +) -> anyhow::Result<()> { + ensure!(actual_sid == expected_sid, "standard-client account SID mismatch"); ensure!( !is_administrator, "standard-client mode requires disabled Administrators membership" ); - Ok(identity) + ensure!( + integrity_level == SECURITY_MANDATORY_MEDIUM_RID, + "standard-client mode requires Medium integrity, got RID {integrity_level:#x}" + ); + Ok(()) +} + +fn current_integrity_level() -> anyhow::Result { + let mut token = HANDLE::default(); + // SAFETY: `GetCurrentProcess` has no preconditions and returns a process pseudo-handle. + let process = unsafe { GetCurrentProcess() }; + // SAFETY: The process pseudo-handle is valid and `token` is a writable output parameter. + unsafe { + OpenProcessToken(process, TOKEN_QUERY, &mut token).context("open current process integrity token")?; + } + let result = integrity_level(token); + // SAFETY: `OpenProcessToken` returned this owned token handle. + unsafe { + CloseHandle(token).context("close current process integrity token")?; + } + result +} + +fn integrity_level(token: HANDLE) -> anyhow::Result { + let mut length = 0; + // SAFETY: A null output buffer with length zero is the documented size query. + let _ = unsafe { GetTokenInformation(token, TokenIntegrityLevel, None, 0, &mut length) }; + ensure!( + usize::try_from(length)? >= size_of::(), + "TokenIntegrityLevel returned an undersized buffer" + ); + + let word_count = usize::try_from(length)?.div_ceil(size_of::()); + let mut buffer = vec![0usize; word_count]; + // SAFETY: The aligned buffer is writable for `length` bytes and the token handle is valid. + unsafe { + GetTokenInformation( + token, + TokenIntegrityLevel, + Some(buffer.as_mut_ptr().cast()), + length, + &mut length, + ) + .context("query token integrity level")?; + } + // SAFETY: A successful TokenIntegrityLevel query initialized a TOKEN_MANDATORY_LABEL. + let label = unsafe { &*buffer.as_ptr().cast::() }; + // SAFETY: The returned token label contains a valid SID. + let sub_authority_count_ptr = unsafe { GetSidSubAuthorityCount(label.Label.Sid) }; + // SAFETY: `GetSidSubAuthorityCount` returns a pointer into the valid label SID. + let sub_authority_count = unsafe { *sub_authority_count_ptr }; + ensure!(sub_authority_count > 0, "integrity SID has no sub-authority"); + // SAFETY: The index is within the validated SID sub-authority count. + let rid_ptr = unsafe { GetSidSubAuthority(label.Label.Sid, u32::from(sub_authority_count - 1)) }; + // SAFETY: `GetSidSubAuthority` returns a pointer into the valid label SID. + let rid = unsafe { *rid_ptr }; + Ok(rid) } fn verify_local_system() -> anyhow::Result { @@ -1173,6 +1249,7 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( if policy == &full { break; } + ensure!(Instant::now() < deadline, "agent did not reload the policy"); tokio::task::yield_now().await; } @@ -1188,3 +1265,28 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_user_token_requires_medium_integrity() { + validate_standard_user_token( + "S-1-5-21-1-2-3-1001", + "S-1-5-21-1-2-3-1001", + false, + SECURITY_MANDATORY_MEDIUM_RID, + ) + .expect("matching standard-user SID at Medium integrity is valid"); + + let error = validate_standard_user_token( + "S-1-5-21-1-2-3-1001", + "S-1-5-21-1-2-3-1001", + false, + SECURITY_MANDATORY_LOW_RID, + ) + .expect_err("Low integrity must not satisfy the standard-user scenario"); + assert!(error.to_string().contains("requires Medium integrity")); + } +} From 421829fec6ab98fe600961eae71cb04a84a4964a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 01:16:00 -0400 Subject: [PATCH 19/53] ci(agent): stop policy server after launch failures Own LocalSystem test-server shutdown as soon as detached launch is attempted, even when readiness validation fails. Signal the unique protected stop marker with a direct fallback, preserve the original scenario error, and bound status collection before cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 8e7a24c4d..e0afd9318 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -287,6 +287,58 @@ function Invoke-RunnerSelfTests { } } + $launchAttempted = $true + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "wrong-nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 200 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Mismatched-readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "readiness nonce mismatch" -or -not $launchAttempted) { + throw + } + } + + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 100 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Invalid-PID readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "readiness has invalid process identities" -or -not $launchAttempted) { + throw + } + } + + Remove-Item -LiteralPath $ready + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Attempted-launch missing-readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or -not $launchAttempted) { + throw + } + } + Remove-StagingPath -Path (Join-Path $root "already-absent") } finally { Remove-StagingPath -Path $root @@ -411,7 +463,8 @@ $statusPath = Join-Path $stagingPath "standard-user-server.status" $serverOutputPath = Join-Path $stagingPath "standard-user-server.out" $nonce = [guid]::NewGuid().ToString("N") $exitCode = 1 -$coordinationReady = $false +$serverLaunchAttempted = $false +$serverLaunchExplicitlyFailed = $false $clientAccount = $null try { @@ -430,6 +483,7 @@ try { $clientTempPath = Join-Path $stagingPath "standard-user-temp" Set-StandardUserTempAcl -Path $clientTempPath -UserSid $clientAccount.Sid + $serverLaunchAttempted = $true $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 @@ -437,9 +491,9 @@ try { $serverOutput | Out-File $outputPath -Append "Detached server launch value: $serverStartExitCode" | Out-File $outputPath -Append $serverLaunchDiagnostics = ($serverOutput | Out-String).Trim() + $serverLaunchExplicitlyFailed = Test-ExplicitPsExecLaunchFailure $serverLaunchDiagnostics $readiness = Wait-ServerReadiness -Path $readyPath -ServerStatusPath $statusPath -ExpectedNonce $nonce ` -TimeoutMilliseconds 30000 -LaunchValue $serverStartExitCode -LaunchDiagnostics $serverLaunchDiagnostics - $coordinationReady = $true $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append $client = Invoke-StandardUserClient -Credential $clientAccount.Credential -UserSid $clientAccount.Sid ` @@ -452,30 +506,41 @@ try { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - if ($coordinationReady) { + if ($serverLaunchAttempted) { $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Signal -StopPath $stopPath 2>&1 $signalExitCode = $LASTEXITCODE $signalOutput | Out-File $outputPath -Append - if ($signalExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $signalExitCode - } - - $deadline = [DateTime]::UtcNow.AddSeconds(30) - while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { - Start-Sleep -Milliseconds 100 + if ($signalExitCode -ne 0 -and -not (Test-Path -LiteralPath $stopPath)) { + try { + New-Item -ItemType File -Path $stopPath -ErrorAction Stop | Out-Null + "Created the stop marker directly after SYSTEM signaling failed" | Out-File $outputPath -Append + } catch { + $_ | Out-File $outputPath -Append + } } - if (Test-Path -LiteralPath $serverOutputPath) { - Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append + if (-not (Test-Path -LiteralPath $stopPath) -and $exitCode -eq 0) { + "Failed to create the LocalSystem test server stop marker" | Out-File $outputPath -Append + $exitCode = 1 } - if (Test-Path -LiteralPath $statusPath) { - $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) - if ($serverExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $serverExitCode + + if (-not $serverLaunchExplicitlyFailed) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $serverOutputPath) { + Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append + } + if (Test-Path -LiteralPath $statusPath) { + $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) + if ($serverExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $serverExitCode + } + } elseif ($exitCode -eq 0) { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append + $exitCode = 1 } - } elseif ($exitCode -eq 0) { - "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append - $exitCode = 1 } } From 6e4d41fda482cb7813f5d0eaed529f2359492d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 02:32:22 +0900 Subject: [PATCH 20/53] test(agent): exercise revised policy lifecycle Cover the current policy contract, receipt invalidation, warnings, and interrupted Repair recovery. Exercise actual installer conversion and managed authority under the hosted LocalSystem gate without accepting skipped privileged tests or incomplete server completion status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 27 +- crates/agent-policy-tester/run-as-system.ps1 | 41 +- .../run-installer-tests.ps1 | 74 +++ crates/agent-policy-tester/run-unelevated.ps1 | 59 ++- crates/agent-policy-tester/src/windows.rs | 288 ++++++++++-- .../InstalledAgentMigrationE2eTests.cs | 438 ++++++++++++++++++ 6 files changed, 885 insertions(+), 42 deletions(-) create mode 100644 crates/agent-policy-tester/run-installer-tests.ps1 create mode 100644 package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39a4dc6ff..2a6b4e920 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1346,12 +1346,14 @@ jobs: name: Agent policy end-to-end test runs-on: windows-2022 needs: [preflight] + env: + AGENT_POLICY_TEST_SHA: ${{ inputs.ref || github.event.pull_request.head.sha || needs.preflight.outputs.ref }} steps: - name: Checkout ${{ github.repository }} uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ env.AGENT_POLICY_TEST_SHA }} - name: Setup Rust cache uses: ./.github/actions/setup-rust-cache @@ -1374,8 +1376,13 @@ jobs: Add-Content -Path $env:GITHUB_PATH -Value $toolsDir - name: Build Agent policy test executables + id: build-policy-executables shell: pwsh run: | + $actualCommit = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $env:AGENT_POLICY_TEST_SHA) { + throw "Agent policy tests must build the requested commit $env:AGENT_POLICY_TEST_SHA, got $actualCommit" + } cargo build --locked -p devolutions-agent --features dev-skip-broker-signature if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE @@ -1385,6 +1392,11 @@ jobs: exit $LASTEXITCODE } + - name: Build installer tests before entering LocalSystem + id: build-installer-tests + shell: pwsh + run: dotnet build package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj --configuration Debug --framework net48 + - name: Run Agent policy tester as standard user shell: pwsh run: | @@ -1396,16 +1408,27 @@ jobs: } - name: Run Agent policy tester as LocalSystem + if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' && steps.build-installer-tests.outcome == 'success' }} shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" - psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath + $dotnetPath = (Get-Command dotnet.exe).Source + psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath -DotnetPath $dotnetPath $exitCode = $LASTEXITCODE Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester.out if ($exitCode -ne 0) { exit $exitCode } + - name: Upload SYSTEM installer test results + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: agent-policy-installer-system-results + path: | + crates/agent-policy-tester/installer-test-results/*.trx + crates/agent-policy-tester/agent-policy-tester.out + - name: Run policy route authorization tests shell: pwsh run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 14e9028e5..2e23d612f 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -1,14 +1,32 @@ +param( + [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe") +) + $ErrorActionPreference = "Stop" $workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path $testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe" $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" -$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" +$stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$stagedAgentPath = Join-Path $stagingPath "devolutions-agent.exe" +$installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\DevolutionsAgent.Installer.Tests.csproj" +$installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" +$stagedInstallerOutput = Join-Path $stagingPath "installer-tests" +$resultsPath = Join-Path $PSScriptRoot "installer-test-results" +$exitCode = 1 +$previousTemp = $env:TEMP +$previousTmp = $env:TMP try { Set-Content -LiteralPath $outputPath -Value "" + if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { + throw "This runner requires LocalSystem" + } + if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { + throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" + } Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -61,11 +79,16 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } + $env:TEMP = Join-Path $stagingPath "scratch" + $env:TMP = $env:TEMP + New-Item -ItemType Directory -Path $env:TEMP | Out-Null Copy-Item -LiteralPath $testerPath -Destination $stagedTesterPath - & icacls.exe $stagedTesterPath /setowner '*S-1-5-18' 2>&1 | Out-File $outputPath -Append + Copy-Item -LiteralPath $agentPath -Destination $stagedAgentPath + Copy-Item -LiteralPath $installerOutput -Destination $stagedInstallerOutput -Recurse + & icacls.exe $stagingPath /setowner '*S-1-5-18' /T /Q 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { - throw "Failed to set the staged tester owner" + throw "Failed to set the staged executable and installer test owners" } & icacls.exe $stagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' 2>&1 | Out-File $outputPath -Append @@ -76,18 +99,28 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $stagedAgentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE + & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` + -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` + -AgentPath $stagedAgentPath -ResultsPath $resultsPath -DotnetPath $DotnetPath ` + 2>&1 | Out-File $outputPath -Append + if ($LASTEXITCODE -ne 0) { + $exitCode = $LASTEXITCODE + } } catch { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { + $env:TEMP = $previousTemp + $env:TMP = $previousTmp for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $stagingPath); $attempt++) { try { Remove-Item -LiteralPath $stagingPath -Recurse -Force } catch { if ($attempt -eq 19) { "Failed to remove $stagingPath after 20 attempts: $_" | Out-File $outputPath -Append + $exitCode = 1 } else { Start-Sleep -Milliseconds 250 } diff --git a/crates/agent-policy-tester/run-installer-tests.ps1 b/crates/agent-policy-tester/run-installer-tests.ps1 new file mode 100644 index 000000000..e2c67c402 --- /dev/null +++ b/crates/agent-policy-tester/run-installer-tests.ps1 @@ -0,0 +1,74 @@ +param( + [Parameter(Mandatory)] [string] $ProjectPath, + [Parameter(Mandatory)] [string] $TestOutputPath, + [Parameter(Mandatory)] [string] $AgentPath, + [Parameter(Mandatory)] [string] $ResultsPath, + [Parameter(Mandatory)] [string] $DotnetPath +) + +$ErrorActionPreference = "Stop" +$exitCode = 1 +$previousAgent = $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE + +try { + if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { + throw "Installer transaction tests must execute as LocalSystem" + } + $testClass = 'DevolutionsAgent.Installer.Tests.PackageBrokerInstallerTests' + $expectedTests = @( + "$testClass.TransactionTestsRunAsLocalSystem" + "$testClass.InstalledAgentConverterUsesAuthoritativeContractBeforePublication" + "$testClass.ConvertedMigrationCommitAndRepeatPreserveOriginalAndEvidence" + "$testClass.RollbackRestoresLegacyArbitrationAndMigrationCanRepeat" + "$testClass.InterruptedPublicationRecoversOnlyOwnedAuthority" + "$testClass.InvalidLegacyPolicyFailsUpgradeAndPreservesSource" + "$testClass.ExistingNewDestinationAndPublicationCollisionArePreserved" + "$testClass.ChangedSourcePreventsDestructiveRollback" + "$testClass.PreexistingAuthorityPreventsLegacyResurrection" + "$testClass.AuthorityCollisionCannotCommitSourceDeletion" + "$testClass.DestinationCollisionCannotCommitSourceDeletion" + "$testClass.UnchangedInputRollbackPreservesLastSurvivingPolicy" + 'DevolutionsAgent.Installer.Tests.InstalledAgentMigrationE2eTests.ConvertedTransactionActivatesAndRetainsManagedAuthority' + ) + $filter = ($expectedTests | ForEach-Object { "FullyQualifiedName=$_" }) -join '|' + New-Item -ItemType Directory -Path $ResultsPath -Force | Out-Null + $trxPath = Join-Path $ResultsPath "installer-system.trx" + if (Test-Path -LiteralPath $trxPath) { + Remove-Item -LiteralPath $trxPath -Force + } + $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = (Resolve-Path -LiteralPath $AgentPath).Path + & $DotnetPath test $ProjectPath --no-build --no-restore --configuration Debug --framework net48 ` + "-p:OutputPath=$TestOutputPath\" -p:AppendTargetFrameworkToOutputPath=false ` + --filter $filter --logger "trx;LogFileName=installer-system.trx" --results-directory $ResultsPath + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "SYSTEM installer tests exited with $exitCode" + } + [xml] $trx = Get-Content -LiteralPath $trxPath -Raw + $counters = $trx.TestRun.ResultSummary.Counters + $results = @($trx.TestRun.Results.UnitTestResult) + if ($trx.TestRun.ResultSummary.outcome -ne 'Completed' -or + [int] $counters.total -ne $expectedTests.Count -or + [int] $counters.executed -ne $expectedTests.Count -or + [int] $counters.passed -ne $expectedTests.Count -or + [int] $counters.notExecuted -ne 0 -or + $results.Count -ne $expectedTests.Count) { + throw "Expected exactly $($expectedTests.Count) executed, passed SYSTEM installer tests and zero skips: $($counters.OuterXml)" + } + foreach ($name in $expectedTests) { + $matching = @($results | Where-Object { $_.testName -eq $name }) + if ($matching.Count -ne 1 -or $matching[0].outcome -ne 'Passed') { + throw "Required SYSTEM installer test did not pass exactly once: $name" + } + } + Write-Output "Verified all 12 installer SystemFacts and the installed-Agent migration E2E: 13 passed, zero skipped" +} catch { + Write-Output $_ + if ($exitCode -eq 0) { + $exitCode = 1 + } +} finally { + $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = $previousAgent +} + +exit $exitCode diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index e0afd9318..5dc9b2b2b 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -22,6 +22,31 @@ function Test-ExplicitPsExecLaunchFailure { return $Diagnostics -match '(?im)^(Couldn''t install PSEXESVC service:|Error establishing communication with PsExec service|Access is denied\.)' } +function Publish-ServerStatus { + param([string] $Path, [int] $ExitCode) + + $temporaryPath = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + [System.IO.File]::WriteAllText($temporaryPath, $ExitCode.ToString([System.Globalization.CultureInfo]::InvariantCulture)) + [System.IO.File]::Move($temporaryPath, $Path) + } finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + } +} + +function Read-ServerStatus { + param([string] $Path) + + $text = [System.IO.File]::ReadAllText($Path) + $value = 0 + if ($text -notmatch '^-?[0-9]+$' -or -not [int]::TryParse($text, [ref] $value)) { + throw "LocalSystem test server published an invalid completion status" + } + return $value +} + function Wait-ServerReadiness { param( [string] $Path, @@ -39,7 +64,7 @@ function Wait-ServerReadiness { $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) while (-not (Test-Path -LiteralPath $Path)) { if (Test-Path -LiteralPath $ServerStatusPath) { - $status = Get-Content -LiteralPath $ServerStatusPath -Raw + $status = Read-ServerStatus -Path $ServerStatusPath throw "LocalSystem test server exited with status $status before publishing readiness (launch value $LaunchValue): $LaunchDiagnostics" } if ([DateTime]::UtcNow -ge $deadline) { @@ -246,6 +271,25 @@ function Invoke-RunnerSelfTests { try { $ready = Join-Path $root "ready.json" $status = Join-Path $root "status" + foreach ($expected in @(0, 1, -1)) { + Publish-ServerStatus -Path $status -ExitCode $expected + if ((Read-ServerStatus -Path $status) -ne $expected) { + throw "Completion-status round trip failed" + } + Remove-Item -LiteralPath $status + } + foreach ($invalid in @("", " ", "failed", "2147483648", "0`n1")) { + [System.IO.File]::WriteAllText($status, $invalid) + try { + Read-ServerStatus -Path $status | Out-Null + throw "Invalid completion status unexpectedly succeeded" + } catch { + if ($_ -notmatch "published an invalid completion status") { + throw + } + } + Remove-Item -LiteralPath $status + } Set-Content -LiteralPath $ready -Value ( @{ Nonce = "nonce" @@ -366,7 +410,7 @@ if ($Action -eq "Server") { $_ | Out-File -LiteralPath $ServerOutputPath -Append $exitCode = 1 } finally { - Set-Content -LiteralPath $StatusPath -Value $exitCode + Publish-ServerStatus -Path $StatusPath -ExitCode $exitCode } exit $exitCode } @@ -533,9 +577,14 @@ try { Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append } if (Test-Path -LiteralPath $statusPath) { - $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) - if ($serverExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $serverExitCode + try { + $serverExitCode = Read-ServerStatus -Path $statusPath + if ($serverExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $serverExitCode + } + } catch { + $_ | Out-File $outputPath -Append + $exitCode = 1 } } elseif ($exitCode -eq 0) { "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 8dcb471ad..1c136183e 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -245,6 +245,7 @@ pub(crate) async fn run() -> anyhow::Result<()> { redirected_policy_paths_fail_closed(&agent_path).await?; management_write_tokens_survive_watcher_reload(&agent_path).await?; managed_policy_lifecycle(&agent_path).await?; + legacy_contract_and_interrupted_repair(&agent_path).await?; } } @@ -662,9 +663,30 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu ); let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); + ensure!( + validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + "unexpected validator contract" + ); + ensure!( + validation["CanonicalDraft"].get("$schema").is_none() + && validation["CanonicalDraft"].get("PolicyVersion").is_none() + && validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], + "canonical draft changed the format version or emitted legacy fields" + ); Ok(validation) } +async fn send_replacement(pipe_name: &str, replacement: &Value) -> anyhow::Result { + request_with_body( + pipe_name, + "PUT", + "/v1/policy", + Some("application/json"), + &serde_json::to_vec(replacement)?, + ) + .await +} + async fn replace_policy_response( agent: &AgentHarness, operation: &str, @@ -700,15 +722,7 @@ async fn replace_policy_response_by_pipe( "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); - let response = request_with_body( - pipe_name, - "PUT", - "/v1/policy", - Some("application/json"), - &serde_json::to_vec(&replacement_request)?, - ) - .await?; - Ok(response) + send_replacement(pipe_name, &replacement_request).await } async fn replace_policy( @@ -827,7 +841,9 @@ async fn standard_user_server( tokio::time::sleep(Duration::from_millis(50)).await; } - wait_for_log(&agent, "Policy management write denied").await + let result = wait_for_log(&agent, "Policy management write denied").await; + agent.stop().await?; + result } async fn standard_user_management(ready_path: &Path, nonce: &str, client: &ProcessIdentity) -> anyhow::Result<()> { @@ -872,12 +888,64 @@ async fn standard_user_management(ready_path: &Path, nonce: &str, client: &Proce "valid draft did not produce a canonical draft and receipt" ); - let mut invalid_draft = valid_draft.clone(); - invalid_draft["$schema"] = json!("https://example.com/not-the-policy-draft-schema.json"); + strict_contract_validation(pipe_name).await?; + + for operation in ["Create", "Update", "Repair", "ReplaceIdentity"] { + let denied = replace_policy_response_by_pipe( + pipe_name, + operation, + "Reject", + management["StoreToken"].clone(), + valid_draft.clone(), + ) + .await?; + ensure!( + denied.status == 403 && denied.json()?["Code"] == "AdministratorRequired", + "standard-user {operation} did not require an administrator" + ); + } + ensure!( + policy_management_by_pipe(pipe_name).await?["StoreToken"] == management["StoreToken"], + "denied writes changed the store token" + ); + Ok(()) +} + +async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { + for version in ["1.0.0", "1.7.3"] { + let mut draft = policy_draft("tests.contract", "Contract"); + draft["PolicyFormatVersion"] = json!(version); + validate_policy_by_pipe(pipe_name, &draft).await?; + } + for (field, value, expected_path) in [ + ( + "$schema", + "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "/$schema", + ), + ("PolicyVersion", "1.0.0", "/PolicyVersion"), + ("PolicyFormatVersion", "2.0.0", "/PolicyFormatVersion"), + ("PolicyFormatVersion", "broken", "/PolicyFormatVersion"), + ] { + let mut draft = policy_draft("tests.contract", "Contract"); + draft[field] = json!(value); + assert_invalid_draft(pipe_name, draft, expected_path).await?; + } + let mut legacy = policy_draft("tests.contract", "Contract"); + legacy + .as_object_mut() + .context("draft is not an object")? + .remove("PolicyFormatVersion"); + legacy["PolicyVersion"] = json!("1.0.0"); + legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); + assert_invalid_draft(pipe_name, legacy, "/PolicyVersion").await +} + +async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str) -> anyhow::Result<()> { let invalid_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", - "Draft": invalid_draft + "Draft": draft }); let invalid_response = request_with_body( pipe_name, @@ -895,32 +963,25 @@ async fn standard_user_management(ready_path: &Path, nonce: &str, client: &Proce let invalid_validation = invalid_response.json()?["Validation"].clone(); ensure!(invalid_validation["IsValid"] == false, "invalid draft was accepted"); ensure!( - invalid_validation.get("CanonicalDraft").is_none(), - "invalid draft returned a canonical draft" - ); - - let denied = replace_policy_response_by_pipe( - pipe_name, - "Create", - "Reject", - management["StoreToken"].clone(), - valid_draft, - ) - .await?; - ensure!( - denied.status == 403, - "standard-user Create returned HTTP {}", - denied.status + invalid_validation.get("CanonicalDraft").is_none() + && invalid_validation.get("ValidationReceipt").is_none() + && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + "invalid draft returned a canonical draft, receipt, or wrong validator version" ); ensure!( - denied.json()?["Code"] == "AdministratorRequired", - "standard-user Create did not require an administrator" + invalid_validation["Findings"] + .as_array() + .is_some_and(|findings| findings + .iter() + .any(|finding| finding["Severity"] == "Error" && finding["Path"] == expected_path)), + "invalid draft did not report {expected_path}: {invalid_validation}" ); Ok(()) } async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { let mut agent = AgentHarness::start_managed_default(agent_path).await?; + strict_contract_validation(&agent.pipe_name).await?; let initial = policy_management(&agent).await?; ensure!( initial["State"] == "Missing", @@ -1051,6 +1112,7 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { "reused ConfirmOverwrite token did not conflict" ); + let confirmed = warnings_identity_and_receipts(&mut agent, agent_path, &confirmed).await?; agent.restart(agent_path).await?; let restarted = request(&agent.pipe_name, "GET", "/v1/policy").await?; ensure!( @@ -1089,6 +1151,150 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { Ok(()) } +async fn warnings_identity_and_receipts( + agent: &mut AgentHarness, + agent_path: &Path, + current: &Value, +) -> anyhow::Result { + let mut draft = policy_draft("tests.replaced-identity", "Compatible contract"); + draft["PolicyFormatVersion"] = json!("1.7.3"); + draft["Enforcement"]["AuditMode"] = json!(true); + let validation = validate_policy_by_pipe(&agent.pipe_name, &draft).await?; + ensure!( + validation["Findings"].as_array().is_some_and(|findings| findings + .iter() + .any(|finding| finding["Code"] == "AuditModeEnabled" && finding["Severity"] == "Warning")), + "audit-mode draft did not produce its warning" + ); + let mut replacement = json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": current["Management"]["StoreToken"], + "Operation": "ReplaceIdentity", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": validation["CanonicalDraft"], + "ValidationReceipt": validation["ValidationReceipt"] + }); + let warning = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + warning.status == 409 && warning.json()?["Code"] == "WarningConfirmationRequired", + "unacknowledged warnings were not rejected" + ); + replacement["WarningsAcknowledged"] = json!(true); + replacement["Draft"]["Metadata"]["Publisher"] = json!("Tampered after validation"); + let tampered = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + tampered.status == 422 && tampered.json()?["Code"] == "ValidationFailed", + "receipt authorized a different draft" + ); + replacement["Draft"] = validation["CanonicalDraft"].clone(); + agent.restart(agent_path).await?; + let restarted = policy_management(agent).await?; + replacement["ExpectedStoreToken"] = restarted["StoreToken"].clone(); + let expired = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + expired.status == 422 && expired.json()?["Code"] == "ValidationFailed", + "pre-restart receipt remained valid in a new validator instance" + ); + ensure!( + restarted["Policy"] == current["Policy"] + && policy_management(agent).await?["StoreToken"] == restarted["StoreToken"], + "rejected requests changed the policy or exact store token" + ); + let replaced = replace_policy(agent, "ReplaceIdentity", restarted["StoreToken"].clone(), draft).await?; + ensure!( + replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" + && replaced["Policy"]["Metadata"]["Revision"] == 1 + && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3" + && replaced["Policy"].get("$schema").is_none() + && replaced["Policy"].get("PolicyVersion").is_none(), + "ReplaceIdentity did not preserve the compatible contract and reset revision" + ); + wait_for_log(agent, "Policy change succeeded").await?; + wait_for_log(agent, "replace_identity").await?; + wait_for_log(agent, "invalid_receipt").await?; + wait_for_log(agent, "warnings_not_acknowledged").await?; + Ok(replaced) +} + +async fn legacy_contract_and_interrupted_repair(agent_path: &Path) -> anyhow::Result<()> { + let mut legacy = empty_policy(); + legacy + .as_object_mut() + .context("policy is not an object")? + .remove("PolicyFormatVersion"); + legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); + legacy["PolicyVersion"] = json!("1.0.0"); + let mut mixed = legacy.clone(); + mixed["PolicyFormatVersion"] = json!("1.0.0"); + for original in [ + serde_json::to_vec(&legacy)?, + serde_json::to_vec(&mixed)?, + b"malformed-policy-secret-marker".to_vec(), + ] { + for marker_staging in [false, true] { + let data_dir = create_data_dir()?; + let policy_path = data_dir.path().join("policy.json"); + std::fs::write(&policy_path, &original)?; + secure_policy_path(&policy_path, false)?; + let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; + let new_path = data_dir.path().join(format!("{prefix}.new")); + std::fs::write(&new_path, b"partial replacement")?; + secure_policy_path(&new_path, false)?; + let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); + if marker_staging { + std::fs::write(&marker_path, br#"{"Version":"#)?; + secure_policy_path(&marker_path, false)?; + } + let mut agent = + AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; + let mut invalid = policy_management(&agent).await?; + ensure!( + invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", + "interrupted Repair did not retain a repairable invalid original: {invalid}" + ); + ensure!( + std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), + "recovery changed the original or retained prepublication remnants" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "legacy, mixed, or malformed policy became active" + ); + if original.starts_with(b"{") { + ensure!( + invalid["InvalidDiagnostics"]["Findings"] + .as_array() + .is_some_and(|findings| findings + .iter() + .any(|finding| finding["Code"] == "UnsupportedPolicyFormatVersion")), + "legacy contract did not produce its strict diagnostic" + ); + std::fs::write(&agent.policy_path, serde_json::to_vec(&empty_policy())?)?; + wait_for_management(&agent, |management| management["State"] == "Active").await?; + std::fs::write(&agent.policy_path, &original)?; + invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; + wait_for_log(&agent, "legacy_policy_contract").await?; + } + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.interrupted-repair", "Recovered"), + ) + .await?; + agent.restart(agent_path).await?; + ensure!( + policy_management(&agent).await?["Policy"] == repaired["Policy"], + "repaired policy did not survive restart" + ); + agent.stop().await?; + } + } + Ok(()) +} + async fn wait_for_management(agent: &AgentHarness, predicate: impl Fn(&Value) -> bool) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -1270,6 +1476,26 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( mod tests { use super::*; + #[test] + fn policy_fixtures_use_the_current_contract() { + for policy in [full_policy(), empty_policy(), policy_draft("tests.contract", "Test")] { + assert_eq!(policy["PolicyFormatVersion"], "1.0.0"); + assert!(policy.get("$schema").is_none()); + assert!(policy.get("PolicyVersion").is_none()); + } + } + + #[test] + fn standard_user_token_rejects_wrong_identity_and_administrators() { + for (actual_sid, administrator, integrity) in [ + ("S-1-5-21-1-2-3-1002", false, SECURITY_MANDATORY_MEDIUM_RID), + ("S-1-5-21-1-2-3-1001", true, SECURITY_MANDATORY_MEDIUM_RID), + ("S-1-5-21-1-2-3-1001", false, 0x3000), + ] { + assert!(validate_standard_user_token(actual_sid, "S-1-5-21-1-2-3-1001", administrator, integrity).is_err()); + } + } + #[test] fn standard_user_token_requires_medium_integrity() { validate_standard_user_token( diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs new file mode 100644 index 000000000..e76e4153d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs @@ -0,0 +1,438 @@ +using DevolutionsAgent.Actions; +using DevolutionsAgent.Resources; +using Microsoft.Deployment.WindowsInstaller; +using Microsoft.Win32.SafeHandles; +using Newtonsoft.Json.Linq; +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class InstalledAgentMigrationE2eTests +{ + private const string CurrentPolicy = + """{"PolicyFormatVersion":"1.7.3","PolicyType":"PackageBrokerPolicy","Metadata":{"Id":"installer-e2e","Publisher":"Test","Revision":17,"PublishedAt":"2026-01-01T00:00:00Z"},"Enforcement":{"DefaultDecision":"Deny","RulePrecedence":"PriorityThenDeny"},"Rules":[]}"""; + private readonly ITestOutputHelper output; + + public InstalledAgentMigrationE2eTests(ITestOutputHelper output) => this.output = output; + + [Fact] + public void AgentJobTerminatesChildWhenScopeThrows() + { + using Process child = Process.Start(new ProcessStartInfo( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"WindowsPowerShell\v1.0\powershell.exe"), + "-NoProfile -NonInteractive -Command Start-Sleep -Seconds 60") + { + UseShellExecute = false, + CreateNoWindow = true, + }); + try + { + InvalidOperationException failure = new("simulate a failed Agent assertion"); + void FailWithAssignedChild() + { + using AgentJob job = new(); + job.Assign(child); + throw failure; + } + Assert.Same(failure, Assert.Throws(FailWithAssignedChild)); + Assert.True(child.WaitForExit(10000), "Job disposal left the child running"); + } + finally + { + if (!child.HasExited) + { + child.Kill(); + Assert.True(child.WaitForExit(10000), "Cleanup did not stop the child"); + } + } + } + + [PackageBrokerInstallerTests.SystemFact] + public void ConvertedTransactionActivatesAndRetainsManagedAuthority() + { + Assert.True(WindowsIdentity.GetCurrent().IsSystem); + string agent = Environment.GetEnvironmentVariable("DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE"); + Assert.False(string.IsNullOrWhiteSpace(agent), "The SYSTEM runner must supply the built Agent executable"); + Assert.True(File.Exists(agent), agent); + Assert.Equal(Includes.EXECUTABLE_NAME, Path.GetFileName(agent), ignoreCase: true); + using PackageBrokerPolicyActions.PinnedPath installedAgent = + PackageBrokerPolicyActions.PinPathWithoutReparse( + agent, leafIsDirectory: false, allowMissingLeaf: false, + leafAccess: WinAPI.GENERIC_READ | WinAPI.READ_CONTROL, verifyTrustedAncestors: true); + Assert.True( + PackageBrokerPolicyActions.TryVerifyLegacyPolicySourceSecurity( + File.GetAccessControl(agent), out string agentSecurityDiagnostic), + agentSecurityDiagnostic); + string root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + $"dgw-installer-e2e-{Guid.NewGuid():N}"); + PackageBrokerPolicyActions.CreateDirectoryWithSecurity(root, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); + try + { + PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(Directory.GetAccessControl(root)); + string vendor = Path.Combine(root, "Devolutions"); + string legacyDirectory = Path.Combine(vendor, "Agent"); + string managedDirectory = Path.Combine(vendor, "PackageBroker"); + foreach (string directory in new[] { vendor, legacyDirectory, managedDirectory }) + { + PackageBrokerPolicyActions.CreateDirectoryWithSecurity( + directory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); + } + string source = Path.Combine(legacyDirectory, "package-broker-policy.json"); + string destination = Path.Combine(managedDirectory, "package-broker-policy.json"); + string marker = Path.Combine(managedDirectory, ".installer-e2e.migration"); + string authority = Path.Combine(managedDirectory, ".package-broker-managed-authority.v1"); + byte[] original = Encoding.UTF8.GetBytes(CurrentPolicy.Replace( + "\"PolicyFormatVersion\":", + "\"$schema\":\"https://devolutions.net/schemas/now-policy.schema.1.0.json\",\"PolicyVersion\":")); + File.WriteAllBytes(source, original); + FileSecurity security = new(); + security.SetSecurityDescriptorSddlForm(Includes.PROGRAM_DATA_PACKAGE_BROKER_FILE_SDDL); + File.SetAccessControl(source, security); + + int conversions = 0; + void Migrate() + { + Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.MigrateLegacyPolicy( + output.WriteLine, source, destination, marker, input => + { + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(authority)); + Assert.Equal(original, input); + byte[] converted = PackageBrokerPolicyActions.ConvertWithInstalledAgent( + Path.GetDirectoryName(agent), input); + Assert.Equal(CurrentPolicy, Encoding.UTF8.GetString(converted)); + conversions++; + return converted; + })); + Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); + Assert.True(File.Exists(authority)); + Assert.Empty(File.ReadAllBytes(authority)); + AssertPreserved(); + foreach (string path in new[] { destination, authority, marker, marker + ".original" }) + { + PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(File.GetAccessControl(path)); + } + } + void AssertPreserved() + { + Assert.Equal(original, File.ReadAllBytes(source)); + Assert.Equal(original, File.ReadAllBytes(marker + ".original")); + Assert.True(File.Exists(marker)); + } + + Migrate(); + Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.RollbackLegacyPolicy( + output.WriteLine, source, destination, marker)); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(authority)); + AssertPreserved(); + Migrate(); + Assert.Equal(2, conversions); + PackageBrokerPolicyActions.CommitLegacyPolicy(output.WriteLine, source, destination, marker); + AssertPreserved(); + + using AgentProcess running = new(agent, root, output); + running.Start(); + JToken active = AssertActive(running, destination); + running.Stop(); + running.Start(); + Assert.True(JToken.DeepEquals(active, AssertActive(running, destination))); + AssertPreserved(); + running.Stop(); + File.Delete(destination); + running.Start(); + JObject management = running.Get("/v1/policy/management", 200)["Management"] as JObject; + Assert.NotNull(management); + Assert.Equal("DefaultPath", (string)management["Source"]); + Assert.Equal("Missing", (string)management["State"]); + Assert.Equal("active policy is unavailable", (string)running.Get("/v1/policy", 404)["Message"]); + Assert.True(File.Exists(authority)); + AssertPreserved(); + } + finally + { + for (int attempt = 0; ; attempt++) + { + try + { + Directory.Delete(root, recursive: true); + break; + } + catch (IOException) when (attempt < 19) + { + Thread.Sleep(250); + } + } + } + } + + private static JToken AssertActive(AgentProcess agent, string destination) + { + JObject response = agent.Get("/v1/policy", 200); + JToken policy = response["Policy"]; + Assert.NotNull(policy); + Assert.Equal("1.7.3", (string)policy["PolicyFormatVersion"]); + Assert.Equal("PackageBrokerPolicy", (string)policy["PolicyType"]); + Assert.Null(policy["PolicyVersion"]); + Assert.Null(policy["$schema"]); + Assert.Equal("installer-e2e", (string)policy["Metadata"]["Id"]); + Assert.Equal("Test", (string)policy["Metadata"]["Publisher"]); + Assert.Equal(17, (int)policy["Metadata"]["Revision"]); + Assert.Equal( + JObject.Parse(CurrentPolicy)["Metadata"]["PublishedAt"], + policy["Metadata"]["PublishedAt"]); + Assert.True(JToken.DeepEquals(JObject.Parse(CurrentPolicy)["Enforcement"], policy["Enforcement"])); + Assert.True(JToken.DeepEquals(new JArray(), policy["Rules"])); + Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); + JObject management = agent.Get("/v1/policy/management", 200)["Management"] as JObject; + Assert.NotNull(management); + Assert.Equal("DefaultPath", (string)management["Source"]); + Assert.Equal("Active", (string)management["State"]); + return policy; + } + + private sealed class AgentProcess : IDisposable + { + private readonly string executable; + private readonly string root; + private readonly ITestOutputHelper output; + private readonly string pipeName = $"Devolutions.Now.PackageBroker.installer-e2e.{Guid.NewGuid():N}"; + private readonly AgentJob job; + private Process process; + private Task stdout; + private Task stderr; + + internal AgentProcess(string executable, string root, ITestOutputHelper output) + { + this.executable = executable; + this.root = root; + this.output = output; + JObject config = new() + { + ["LogFile"] = Path.Combine(root, "agent-installer-e2e"), + ["PackageBroker"] = new JObject + { + ["Enabled"] = true, + ["PipeName"] = @"\\.\pipe\" + pipeName, + }, + ["__debug__"] = new JObject { ["skip_broker_signature_validation"] = true }, + }; + File.WriteAllText(Path.Combine(root, "agent.json"), config.ToString()); + job = new AgentJob(); + } + + internal void Start() + { + Assert.Null(process); + stdout = null; + stderr = null; + ProcessStartInfo start = new(executable, "run") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + start.EnvironmentVariables["DAGENT_CONFIG_PATH"] = root; + start.EnvironmentVariables["ProgramData"] = root; + process = Process.Start(start); + job.Assign(process); + stdout = process.StandardOutput.ReadToEndAsync(); + stderr = process.StandardError.ReadToEndAsync(); + Stopwatch timer = Stopwatch.StartNew(); + while (true) + { + Assert.False(process.HasExited, "Agent exited before its broker became ready"); + try + { + Get("/v1/health", 200); + return; + } + catch (TimeoutException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) + { + Thread.Sleep(50); + } + catch (IOException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) + { + Thread.Sleep(50); + } + } + } + + internal JObject Get(string path, int expectedStatus) + { + using NamedPipeClientStream pipe = new( + ".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + pipe.Connect(1000); + Task request = Exchange(pipe, path); + if (Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(10))).GetAwaiter().GetResult() != request) + { + throw new TimeoutException($"timed out reading {path}"); + } + string response = request.GetAwaiter().GetResult(); + int headerEnd = response.IndexOf("\r\n\r\n", StringComparison.Ordinal); + Assert.True(headerEnd > 0, response); + Assert.Equal(expectedStatus.ToString(), response.Split(' ')[1]); + return JObject.Parse(response.Substring(headerEnd + 4)); + } + + private static async Task Exchange(NamedPipeClientStream pipe, string path) + { + byte[] request = Encoding.ASCII.GetBytes( + $"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); + await pipe.WriteAsync(request, 0, request.Length).ConfigureAwait(false); + await pipe.FlushAsync().ConfigureAwait(false); + using MemoryStream response = new(); + await pipe.CopyToAsync(response).ConfigureAwait(false); + return Encoding.UTF8.GetString(response.ToArray()); + } + + internal void Stop() + { + if (process == null) + { + return; + } + try + { + if (!process.HasExited) + { + try + { + process.Kill(); + } + catch (InvalidOperationException) when (process.HasExited) + { + } + catch (Win32Exception) when (process.WaitForExit(10000)) + { + } + } + Assert.True(process.WaitForExit(10000), "Agent did not stop"); + if (stdout != null) + { + output.WriteLine(stdout.GetAwaiter().GetResult()); + output.WriteLine(stderr.GetAwaiter().GetResult()); + } + foreach (string log in Directory.GetFiles(root, "agent-installer-e2e*")) + { + output.WriteLine(File.ReadAllText(log)); + } + } + finally + { + if (process.HasExited) + { + process.Dispose(); + process = null; + } + } + } + + public void Dispose() + { + job.Dispose(); + Stop(); + } + } + + private sealed class AgentJob : IDisposable + { + private const uint JobObjectLimitKillOnJobClose = 0x2000; + private const int JobObjectExtendedLimitInformation = 9; + private readonly SafeFileHandle handle; + + internal AgentJob() + { + handle = CreateJobObjectW(IntPtr.Zero, null); + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + ExtendedLimitInformation limits = new() + { + BasicLimitInformation = new BasicLimitInformation { LimitFlags = JobObjectLimitKillOnJobClose }, + }; + if (!SetInformationJobObject( + handle, JobObjectExtendedLimitInformation, ref limits, Marshal.SizeOf())) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + } + + internal void Assign(Process process) + { + if (!AssignProcessToJobObject(handle, process.Handle)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + public void Dispose() => handle.Dispose(); + + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimitInformation + { + internal long PerProcessUserTimeLimit; + internal long PerJobUserTimeLimit; + internal uint LimitFlags; + internal UIntPtr MinimumWorkingSetSize; + internal UIntPtr MaximumWorkingSetSize; + internal uint ActiveProcessLimit; + internal UIntPtr Affinity; + internal uint PriorityClass; + internal uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + internal ulong ReadOperationCount; + internal ulong WriteOperationCount; + internal ulong OtherOperationCount; + internal ulong ReadTransferCount; + internal ulong WriteTransferCount; + internal ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedLimitInformation + { + internal BasicLimitInformation BasicLimitInformation; + internal IoCounters IoInfo; + internal UIntPtr ProcessMemoryLimit; + internal UIntPtr JobMemoryLimit; + internal UIntPtr PeakProcessMemoryUsed; + internal UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, int informationClass, ref ExtendedLimitInformation information, int length); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + } +} From 8059127ae47b7444fcc74e66a302ade4b6617436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 02:56:10 +0900 Subject: [PATCH 21/53] fix(agent): restore NuGet test imports for SYSTEM Pass the restored package root explicitly to the LocalSystem test invocation so generated imports discover the test SDK and produce its required privileged-test result. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++++++- crates/agent-policy-tester/run-as-system.ps1 | 11 +++++++++-- .../agent-policy-tester/run-installer-tests.ps1 | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a6b4e920..b11ead0cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1413,7 +1413,15 @@ jobs: run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" $dotnetPath = (Get-Command dotnet.exe).Source - psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath -DotnetPath $dotnetPath + $nuGetPackagesPath = ( + & $dotnetPath nuget locals global-packages --list | + Select-String '^global-packages:\s*(.+)$' + ).Matches[0].Groups[1].Value.Trim() + if ([string]::IsNullOrWhiteSpace($nuGetPackagesPath) -or -not (Test-Path -LiteralPath $nuGetPackagesPath)) { + throw "Could not resolve the restored NuGet package root for LocalSystem" + } + psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath ` + -DotnetPath $dotnetPath -NuGetPackagesPath $nuGetPackagesPath $exitCode = $LASTEXITCODE Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester.out if ($exitCode -ne 0) { diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 2e23d612f..ceeb8f71c 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -1,5 +1,6 @@ param( - [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe") + [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe"), + [Parameter(Mandatory)] [string] $NuGetPackagesPath ) $ErrorActionPreference = "Stop" @@ -15,6 +16,7 @@ $installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\ $installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" $stagedInstallerOutput = Join-Path $stagingPath "installer-tests" $resultsPath = Join-Path $PSScriptRoot "installer-test-results" +$stagedResultsPath = Join-Path $stagingPath "installer-test-results" $exitCode = 1 $previousTemp = $env:TEMP $previousTmp = $env:TMP @@ -27,6 +29,7 @@ try { if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" } + $NuGetPackagesPath = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -79,6 +82,9 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } + if (Test-Path -LiteralPath $resultsPath) { + Remove-Item -LiteralPath $resultsPath -Recurse -Force + } $env:TEMP = Join-Path $stagingPath "scratch" $env:TMP = $env:TEMP New-Item -ItemType Directory -Path $env:TEMP | Out-Null @@ -103,7 +109,8 @@ public static class AgentPolicyTesterNativeDirectory $exitCode = $LASTEXITCODE & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` - -AgentPath $stagedAgentPath -ResultsPath $resultsPath -DotnetPath $DotnetPath ` + -AgentPath $stagedAgentPath -ResultsPath $stagedResultsPath -ArtifactResultsPath $resultsPath ` + -DotnetPath $DotnetPath -NuGetPackagesPath $NuGetPackagesPath ` 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/run-installer-tests.ps1 b/crates/agent-policy-tester/run-installer-tests.ps1 index e2c67c402..1e96f8090 100644 --- a/crates/agent-policy-tester/run-installer-tests.ps1 +++ b/crates/agent-policy-tester/run-installer-tests.ps1 @@ -3,17 +3,26 @@ param( [Parameter(Mandatory)] [string] $TestOutputPath, [Parameter(Mandatory)] [string] $AgentPath, [Parameter(Mandatory)] [string] $ResultsPath, - [Parameter(Mandatory)] [string] $DotnetPath + [Parameter(Mandatory)] [string] $ArtifactResultsPath, + [Parameter(Mandatory)] [string] $DotnetPath, + [Parameter(Mandatory)] [string] $NuGetPackagesPath ) $ErrorActionPreference = "Stop" $exitCode = 1 $previousAgent = $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE +$previousNuGetPackages = $env:NUGET_PACKAGES try { if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { throw "Installer transaction tests must execute as LocalSystem" } + $nuGetPackageRoot = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + if (-not $nuGetPackageRoot.EndsWith($separator, [System.StringComparison]::Ordinal)) { + $nuGetPackageRoot += $separator + } + $env:NUGET_PACKAGES = $nuGetPackageRoot $testClass = 'DevolutionsAgent.Installer.Tests.PackageBrokerInstallerTests' $expectedTests = @( "$testClass.TransactionTestsRunAsLocalSystem" @@ -39,6 +48,7 @@ try { $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = (Resolve-Path -LiteralPath $AgentPath).Path & $DotnetPath test $ProjectPath --no-build --no-restore --configuration Debug --framework net48 ` "-p:OutputPath=$TestOutputPath\" -p:AppendTargetFrameworkToOutputPath=false ` + "-p:NuGetPackageRoot=$env:NUGET_PACKAGES" ` --filter $filter --logger "trx;LogFileName=installer-system.trx" --results-directory $ResultsPath $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { @@ -61,6 +71,8 @@ try { throw "Required SYSTEM installer test did not pass exactly once: $name" } } + New-Item -ItemType Directory -Path $ArtifactResultsPath -Force | Out-Null + Copy-Item -LiteralPath $trxPath -Destination $ArtifactResultsPath -Force Write-Output "Verified all 12 installer SystemFacts and the installed-Agent migration E2E: 13 passed, zero skipped" } catch { Write-Output $_ @@ -69,6 +81,7 @@ try { } } finally { $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = $previousAgent + $env:NUGET_PACKAGES = $previousNuGetPackages } exit $exitCode From c10b4bd774cab5be89276979211220b91b741046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 03:08:51 +0900 Subject: [PATCH 22/53] fix(agent): run installer tests as LocalSystem Preserve the restored NuGet imports and the product Agent filename when running the privileged installer lifecycle tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-as-system.ps1 | 2 +- .../InstalledAgentMigrationE2eTests.cs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index ceeb8f71c..be1938f88 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -11,7 +11,7 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" $stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" -$stagedAgentPath = Join-Path $stagingPath "devolutions-agent.exe" +$stagedAgentPath = Join-Path $stagingPath "DevolutionsAgent.exe" $installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\DevolutionsAgent.Installer.Tests.csproj" $installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" $stagedInstallerOutput = Join-Path $stagingPath "installer-tests" diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs index e76e4153d..1d6c0b2a8 100644 --- a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs +++ b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs @@ -27,6 +27,14 @@ public sealed class InstalledAgentMigrationE2eTests public InstalledAgentMigrationE2eTests(ITestOutputHelper output) => this.output = output; + [Theory] + [InlineData("DevolutionsAgent.exe")] + [InlineData("devolutionsagent.exe")] + public void AgentExecutableNameComparisonIsCaseInsensitive(string fileName) + { + Assert.True(IsExpectedAgentExecutableName(fileName)); + } + [Fact] public void AgentJobTerminatesChildWhenScopeThrows() { @@ -67,7 +75,7 @@ public void ConvertedTransactionActivatesAndRetainsManagedAuthority() string agent = Environment.GetEnvironmentVariable("DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE"); Assert.False(string.IsNullOrWhiteSpace(agent), "The SYSTEM runner must supply the built Agent executable"); Assert.True(File.Exists(agent), agent); - Assert.Equal(Includes.EXECUTABLE_NAME, Path.GetFileName(agent), ignoreCase: true); + Assert.True(IsExpectedAgentExecutableName(Path.GetFileName(agent)), agent); using PackageBrokerPolicyActions.PinnedPath installedAgent = PackageBrokerPolicyActions.PinPathWithoutReparse( agent, leafIsDirectory: false, allowMissingLeaf: false, @@ -91,6 +99,7 @@ public void ConvertedTransactionActivatesAndRetainsManagedAuthority() PackageBrokerPolicyActions.CreateDirectoryWithSecurity( directory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); } + string source = Path.Combine(legacyDirectory, "package-broker-policy.json"); string destination = Path.Combine(managedDirectory, "package-broker-policy.json"); string marker = Path.Combine(managedDirectory, ".installer-e2e.migration"); @@ -205,6 +214,9 @@ private static JToken AssertActive(AgentProcess agent, string destination) return policy; } + private static bool IsExpectedAgentExecutableName(string fileName) => + string.Equals(Includes.EXECUTABLE_NAME, fileName, StringComparison.OrdinalIgnoreCase); + private sealed class AgentProcess : IDisposable { private readonly string executable; From 4b13e3f4e411d2cc61dd3f6e89470e04586bee22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 03:23:43 +0900 Subject: [PATCH 23/53] fix(agent): authenticate installer E2E client Run migration lifecycle probes through the staged trusted test client instead of the hosted .NET test process, whose executable image cannot be retained by the package broker. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-as-system.ps1 | 3 ++ crates/agent-policy-tester/src/windows.rs | 26 +++++++++- .../InstalledAgentMigrationE2eTests.cs | 49 ++++++++++--------- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index be1938f88..00d872422 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -20,6 +20,7 @@ $stagedResultsPath = Join-Path $stagingPath "installer-test-results" $exitCode = 1 $previousTemp = $env:TEMP $previousTmp = $env:TMP +$previousInstallerTester = $env:AGENT_POLICY_TESTER_E2E_EXE try { Set-Content -LiteralPath $outputPath -Value "" @@ -107,6 +108,7 @@ public static class AgentPolicyTesterNativeDirectory Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append & $stagedTesterPath $stagedAgentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE + $env:AGENT_POLICY_TESTER_E2E_EXE = $stagedTesterPath & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` -AgentPath $stagedAgentPath -ResultsPath $stagedResultsPath -ArtifactResultsPath $resultsPath ` @@ -121,6 +123,7 @@ public static class AgentPolicyTesterNativeDirectory } finally { $env:TEMP = $previousTemp $env:TMP = $previousTmp + $env:AGENT_POLICY_TESTER_E2E_EXE = $previousInstallerTester for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $stagingPath); $attempt++) { try { Remove-Item -LiteralPath $stagingPath -Recurse -Force diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 1c136183e..c4bae6c78 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -194,6 +194,7 @@ enum Mode { StandardServer, StandardClient, Elevated, + Probe, } impl Mode { @@ -202,7 +203,8 @@ impl Mode { "standard-server" => Ok(Self::StandardServer), "standard-client" => Ok(Self::StandardClient), "elevated" => Ok(Self::Elevated), - _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), + "probe" => Ok(Self::Probe), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', 'elevated', or 'probe'"), } } } @@ -247,6 +249,23 @@ pub(crate) async fn run() -> anyhow::Result<()> { managed_policy_lifecycle(&agent_path).await?; legacy_contract_and_interrupted_repair(&agent_path).await?; } + Mode::Probe => { + verify_local_system()?; + let pipe_name = next_string(&mut args, "pipe name")?; + let path = next_string(&mut args, "request path")?; + ensure!(args.next().is_none(), "unexpected probe arguments"); + probe(&pipe_name, &path).await?; + } + } + + async fn probe(pipe_name: &str, path: &str) -> anyhow::Result<()> { + let response = request(pipe_name, "GET", path).await?; + let body = response.json()?; + let mut stdout = std::io::stdout().lock(); + serde_json::to_writer(&mut stdout, &json!({ "Status": response.status, "Body": body }))?; + stdout.write_all(b"\n")?; + stdout.flush()?; + Ok(()) } Ok(()) @@ -1496,6 +1515,11 @@ mod tests { } } + #[test] + fn probe_mode_is_only_available_to_a_local_system_client() { + assert!(matches!(Mode::parse("probe"), Ok(Mode::Probe))); + } + #[test] fn standard_user_token_requires_medium_integrity() { validate_standard_user_token( diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs index 1d6c0b2a8..0db6e859d 100644 --- a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs +++ b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs @@ -7,7 +7,6 @@ using System.ComponentModel; using System.Diagnostics; using System.IO; -using System.IO.Pipes; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; @@ -220,6 +219,7 @@ private static bool IsExpectedAgentExecutableName(string fileName) => private sealed class AgentProcess : IDisposable { private readonly string executable; + private readonly string tester; private readonly string root; private readonly ITestOutputHelper output; private readonly string pipeName = $"Devolutions.Now.PackageBroker.installer-e2e.{Guid.NewGuid():N}"; @@ -231,6 +231,9 @@ private sealed class AgentProcess : IDisposable internal AgentProcess(string executable, string root, ITestOutputHelper output) { this.executable = executable; + tester = Environment.GetEnvironmentVariable("AGENT_POLICY_TESTER_E2E_EXE"); + Assert.False(string.IsNullOrWhiteSpace(tester), "The SYSTEM runner must supply the policy tester executable"); + Assert.True(File.Exists(tester), tester); this.root = root; this.output = output; JObject config = new() @@ -288,30 +291,30 @@ internal void Start() internal JObject Get(string path, int expectedStatus) { - using NamedPipeClientStream pipe = new( - ".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); - pipe.Connect(1000); - Task request = Exchange(pipe, path); - if (Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(10))).GetAwaiter().GetResult() != request) + ProcessStartInfo start = new(tester, $"\"{tester}\" probe \"\\\\.\\pipe\\{pipeName}\" \"{path}\"") { - throw new TimeoutException($"timed out reading {path}"); + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using Process client = Process.Start(start); + Task stdout = client.StandardOutput.ReadToEndAsync(); + Task stderr = client.StandardError.ReadToEndAsync(); + if (!client.WaitForExit(10000)) + { + client.Kill(); + throw new TimeoutException($"timed out probing {path}"); } - string response = request.GetAwaiter().GetResult(); - int headerEnd = response.IndexOf("\r\n\r\n", StringComparison.Ordinal); - Assert.True(headerEnd > 0, response); - Assert.Equal(expectedStatus.ToString(), response.Split(' ')[1]); - return JObject.Parse(response.Substring(headerEnd + 4)); - } - - private static async Task Exchange(NamedPipeClientStream pipe, string path) - { - byte[] request = Encoding.ASCII.GetBytes( - $"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - await pipe.WriteAsync(request, 0, request.Length).ConfigureAwait(false); - await pipe.FlushAsync().ConfigureAwait(false); - using MemoryStream response = new(); - await pipe.CopyToAsync(response).ConfigureAwait(false); - return Encoding.UTF8.GetString(response.ToArray()); + string standardOutput = stdout.GetAwaiter().GetResult(); + string standardError = stderr.GetAwaiter().GetResult(); + if (client.ExitCode != 0) + { + throw new IOException($"policy tester probe failed for {path}: {standardError}"); + } + JObject response = JObject.Parse(standardOutput); + Assert.Equal(expectedStatus, (int)response["Status"]); + return response["Body"] as JObject; } internal void Stop() From 4300382963afa7186e2fdabf453dfafbeb1e9002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 09:39:08 +0900 Subject: [PATCH 24/53] test(agent): retain canonical policy E2E Remove installer migration and retired-policy compatibility coverage while preserving the canonical policy management lifecycle and its security boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 27 +- crates/agent-policy-tester/run-as-system.ps1 | 42 +- .../run-installer-tests.ps1 | 87 ---- crates/agent-policy-tester/src/windows.rs | 211 ++------ .../InstalledAgentMigrationE2eTests.cs | 453 ------------------ 5 files changed, 57 insertions(+), 763 deletions(-) delete mode 100644 crates/agent-policy-tester/run-installer-tests.ps1 delete mode 100644 package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b11ead0cc..87550f9d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1392,11 +1392,6 @@ jobs: exit $LASTEXITCODE } - - name: Build installer tests before entering LocalSystem - id: build-installer-tests - shell: pwsh - run: dotnet build package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj --configuration Debug --framework net48 - - name: Run Agent policy tester as standard user shell: pwsh run: | @@ -1408,35 +1403,17 @@ jobs: } - name: Run Agent policy tester as LocalSystem - if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' && steps.build-installer-tests.outcome == 'success' }} + if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' }} shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" - $dotnetPath = (Get-Command dotnet.exe).Source - $nuGetPackagesPath = ( - & $dotnetPath nuget locals global-packages --list | - Select-String '^global-packages:\s*(.+)$' - ).Matches[0].Groups[1].Value.Trim() - if ([string]::IsNullOrWhiteSpace($nuGetPackagesPath) -or -not (Test-Path -LiteralPath $nuGetPackagesPath)) { - throw "Could not resolve the restored NuGet package root for LocalSystem" - } - psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath ` - -DotnetPath $dotnetPath -NuGetPackagesPath $nuGetPackagesPath + psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath $exitCode = $LASTEXITCODE Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester.out if ($exitCode -ne 0) { exit $exitCode } - - name: Upload SYSTEM installer test results - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: agent-policy-installer-system-results - path: | - crates/agent-policy-tester/installer-test-results/*.trx - crates/agent-policy-tester/agent-policy-tester.out - - name: Run policy route authorization tests shell: pwsh run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 00d872422..f26337f00 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -1,8 +1,3 @@ -param( - [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe"), - [Parameter(Mandatory)] [string] $NuGetPackagesPath -) - $ErrorActionPreference = "Stop" $workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path @@ -11,16 +6,7 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" $stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" -$stagedAgentPath = Join-Path $stagingPath "DevolutionsAgent.exe" -$installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\DevolutionsAgent.Installer.Tests.csproj" -$installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" -$stagedInstallerOutput = Join-Path $stagingPath "installer-tests" -$resultsPath = Join-Path $PSScriptRoot "installer-test-results" -$stagedResultsPath = Join-Path $stagingPath "installer-test-results" $exitCode = 1 -$previousTemp = $env:TEMP -$previousTmp = $env:TMP -$previousInstallerTester = $env:AGENT_POLICY_TESTER_E2E_EXE try { Set-Content -LiteralPath $outputPath -Value "" @@ -30,7 +16,6 @@ try { if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" } - $NuGetPackagesPath = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -83,19 +68,10 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } - if (Test-Path -LiteralPath $resultsPath) { - Remove-Item -LiteralPath $resultsPath -Recurse -Force - } - $env:TEMP = Join-Path $stagingPath "scratch" - $env:TMP = $env:TEMP - New-Item -ItemType Directory -Path $env:TEMP | Out-Null - Copy-Item -LiteralPath $testerPath -Destination $stagedTesterPath - Copy-Item -LiteralPath $agentPath -Destination $stagedAgentPath - Copy-Item -LiteralPath $installerOutput -Destination $stagedInstallerOutput -Recurse - & icacls.exe $stagingPath /setowner '*S-1-5-18' /T /Q 2>&1 | Out-File $outputPath -Append + & icacls.exe $stagedTesterPath /setowner '*S-1-5-18' 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { - throw "Failed to set the staged executable and installer test owners" + throw "Failed to set the staged tester owner" } & icacls.exe $stagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' 2>&1 | Out-File $outputPath -Append @@ -106,24 +82,12 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $stagedAgentPath elevated 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE - $env:AGENT_POLICY_TESTER_E2E_EXE = $stagedTesterPath - & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` - -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` - -AgentPath $stagedAgentPath -ResultsPath $stagedResultsPath -ArtifactResultsPath $resultsPath ` - -DotnetPath $DotnetPath -NuGetPackagesPath $NuGetPackagesPath ` - 2>&1 | Out-File $outputPath -Append - if ($LASTEXITCODE -ne 0) { - $exitCode = $LASTEXITCODE - } } catch { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - $env:TEMP = $previousTemp - $env:TMP = $previousTmp - $env:AGENT_POLICY_TESTER_E2E_EXE = $previousInstallerTester for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $stagingPath); $attempt++) { try { Remove-Item -LiteralPath $stagingPath -Recurse -Force diff --git a/crates/agent-policy-tester/run-installer-tests.ps1 b/crates/agent-policy-tester/run-installer-tests.ps1 deleted file mode 100644 index 1e96f8090..000000000 --- a/crates/agent-policy-tester/run-installer-tests.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -param( - [Parameter(Mandatory)] [string] $ProjectPath, - [Parameter(Mandatory)] [string] $TestOutputPath, - [Parameter(Mandatory)] [string] $AgentPath, - [Parameter(Mandatory)] [string] $ResultsPath, - [Parameter(Mandatory)] [string] $ArtifactResultsPath, - [Parameter(Mandatory)] [string] $DotnetPath, - [Parameter(Mandatory)] [string] $NuGetPackagesPath -) - -$ErrorActionPreference = "Stop" -$exitCode = 1 -$previousAgent = $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE -$previousNuGetPackages = $env:NUGET_PACKAGES - -try { - if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { - throw "Installer transaction tests must execute as LocalSystem" - } - $nuGetPackageRoot = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path - $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() - if (-not $nuGetPackageRoot.EndsWith($separator, [System.StringComparison]::Ordinal)) { - $nuGetPackageRoot += $separator - } - $env:NUGET_PACKAGES = $nuGetPackageRoot - $testClass = 'DevolutionsAgent.Installer.Tests.PackageBrokerInstallerTests' - $expectedTests = @( - "$testClass.TransactionTestsRunAsLocalSystem" - "$testClass.InstalledAgentConverterUsesAuthoritativeContractBeforePublication" - "$testClass.ConvertedMigrationCommitAndRepeatPreserveOriginalAndEvidence" - "$testClass.RollbackRestoresLegacyArbitrationAndMigrationCanRepeat" - "$testClass.InterruptedPublicationRecoversOnlyOwnedAuthority" - "$testClass.InvalidLegacyPolicyFailsUpgradeAndPreservesSource" - "$testClass.ExistingNewDestinationAndPublicationCollisionArePreserved" - "$testClass.ChangedSourcePreventsDestructiveRollback" - "$testClass.PreexistingAuthorityPreventsLegacyResurrection" - "$testClass.AuthorityCollisionCannotCommitSourceDeletion" - "$testClass.DestinationCollisionCannotCommitSourceDeletion" - "$testClass.UnchangedInputRollbackPreservesLastSurvivingPolicy" - 'DevolutionsAgent.Installer.Tests.InstalledAgentMigrationE2eTests.ConvertedTransactionActivatesAndRetainsManagedAuthority' - ) - $filter = ($expectedTests | ForEach-Object { "FullyQualifiedName=$_" }) -join '|' - New-Item -ItemType Directory -Path $ResultsPath -Force | Out-Null - $trxPath = Join-Path $ResultsPath "installer-system.trx" - if (Test-Path -LiteralPath $trxPath) { - Remove-Item -LiteralPath $trxPath -Force - } - $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = (Resolve-Path -LiteralPath $AgentPath).Path - & $DotnetPath test $ProjectPath --no-build --no-restore --configuration Debug --framework net48 ` - "-p:OutputPath=$TestOutputPath\" -p:AppendTargetFrameworkToOutputPath=false ` - "-p:NuGetPackageRoot=$env:NUGET_PACKAGES" ` - --filter $filter --logger "trx;LogFileName=installer-system.trx" --results-directory $ResultsPath - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0) { - throw "SYSTEM installer tests exited with $exitCode" - } - [xml] $trx = Get-Content -LiteralPath $trxPath -Raw - $counters = $trx.TestRun.ResultSummary.Counters - $results = @($trx.TestRun.Results.UnitTestResult) - if ($trx.TestRun.ResultSummary.outcome -ne 'Completed' -or - [int] $counters.total -ne $expectedTests.Count -or - [int] $counters.executed -ne $expectedTests.Count -or - [int] $counters.passed -ne $expectedTests.Count -or - [int] $counters.notExecuted -ne 0 -or - $results.Count -ne $expectedTests.Count) { - throw "Expected exactly $($expectedTests.Count) executed, passed SYSTEM installer tests and zero skips: $($counters.OuterXml)" - } - foreach ($name in $expectedTests) { - $matching = @($results | Where-Object { $_.testName -eq $name }) - if ($matching.Count -ne 1 -or $matching[0].outcome -ne 'Passed') { - throw "Required SYSTEM installer test did not pass exactly once: $name" - } - } - New-Item -ItemType Directory -Path $ArtifactResultsPath -Force | Out-Null - Copy-Item -LiteralPath $trxPath -Destination $ArtifactResultsPath -Force - Write-Output "Verified all 12 installer SystemFacts and the installed-Agent migration E2E: 13 passed, zero skipped" -} catch { - Write-Output $_ - if ($exitCode -eq 0) { - $exitCode = 1 - } -} finally { - $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = $previousAgent - $env:NUGET_PACKAGES = $previousNuGetPackages -} - -exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index c4bae6c78..d5284fb76 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -20,8 +20,6 @@ use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken, PRO const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; -const MANAGED_AUTHORITY_MARKER: &str = r"Devolutions\PackageBroker\.package-broker-managed-authority.v1"; -const LEGACY_POLICY_RELATIVE_PATH: &str = r"Devolutions\Agent\package-broker-policy.json"; #[cfg(test)] const SECURITY_MANDATORY_LOW_RID: u32 = 0x1000; const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x2000; @@ -194,7 +192,6 @@ enum Mode { StandardServer, StandardClient, Elevated, - Probe, } impl Mode { @@ -203,8 +200,7 @@ impl Mode { "standard-server" => Ok(Self::StandardServer), "standard-client" => Ok(Self::StandardClient), "elevated" => Ok(Self::Elevated), - "probe" => Ok(Self::Probe), - _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', 'elevated', or 'probe'"), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), } } } @@ -247,25 +243,8 @@ pub(crate) async fn run() -> anyhow::Result<()> { redirected_policy_paths_fail_closed(&agent_path).await?; management_write_tokens_survive_watcher_reload(&agent_path).await?; managed_policy_lifecycle(&agent_path).await?; - legacy_contract_and_interrupted_repair(&agent_path).await?; + interrupted_malformed_repair(&agent_path).await?; } - Mode::Probe => { - verify_local_system()?; - let pipe_name = next_string(&mut args, "pipe name")?; - let path = next_string(&mut args, "request path")?; - ensure!(args.next().is_none(), "unexpected probe arguments"); - probe(&pipe_name, &path).await?; - } - } - - async fn probe(pipe_name: &str, path: &str) -> anyhow::Result<()> { - let response = request(pipe_name, "GET", path).await?; - let body = response.json()?; - let mut stdout = std::io::stdout().lock(); - serde_json::to_writer(&mut stdout, &json!({ "Status": response.status, "Body": body }))?; - stdout.write_all(b"\n")?; - stdout.flush()?; - Ok(()) } Ok(()) @@ -687,10 +666,8 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu "unexpected validator contract" ); ensure!( - validation["CanonicalDraft"].get("$schema").is_none() - && validation["CanonicalDraft"].get("PolicyVersion").is_none() - && validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], - "canonical draft changed the format version or emitted legacy fields" + validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], + "canonical draft changed the format version" ); Ok(validation) } @@ -936,28 +913,12 @@ async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { draft["PolicyFormatVersion"] = json!(version); validate_policy_by_pipe(pipe_name, &draft).await?; } - for (field, value, expected_path) in [ - ( - "$schema", - "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "/$schema", - ), - ("PolicyVersion", "1.0.0", "/PolicyVersion"), - ("PolicyFormatVersion", "2.0.0", "/PolicyFormatVersion"), - ("PolicyFormatVersion", "broken", "/PolicyFormatVersion"), - ] { + for (value, expected_path) in [("2.0.0", "/PolicyFormatVersion"), ("broken", "/PolicyFormatVersion")] { let mut draft = policy_draft("tests.contract", "Contract"); - draft[field] = json!(value); + draft["PolicyFormatVersion"] = json!(value); assert_invalid_draft(pipe_name, draft, expected_path).await?; } - let mut legacy = policy_draft("tests.contract", "Contract"); - legacy - .as_object_mut() - .context("draft is not an object")? - .remove("PolicyFormatVersion"); - legacy["PolicyVersion"] = json!("1.0.0"); - legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); - assert_invalid_draft(pipe_name, legacy, "/PolicyVersion").await + Ok(()) } async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str) -> anyhow::Result<()> { @@ -1022,11 +983,6 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { created["Policy"]["Metadata"]["Revision"] == 1, "Create did not assign revision 1" ); - let authority_marker = agent.data_dir.path().join(MANAGED_AUTHORITY_MARKER); - ensure!( - authority_marker.is_file() && std::fs::metadata(&authority_marker)?.len() == 0, - "Create did not establish durable managed authority" - ); wait_for_log(&agent, "Policy creation succeeded").await?; let updated = replace_policy( @@ -1143,30 +1099,6 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { restarted.json()?["Policy"] == confirmed["Policy"], "restart changed the active managed policy" ); - ensure!( - authority_marker.is_file() && policy_management(&agent).await?["Source"] == "DefaultPath", - "restart lost durable managed authority" - ); - - agent.stop().await?; - let legacy_path = agent.data_dir.path().join(LEGACY_POLICY_RELATIVE_PATH); - let legacy_dir = legacy_path.parent().context("legacy policy path has no parent")?; - std::fs::create_dir_all(legacy_dir).context("create isolated legacy policy directory")?; - secure_policy_path(legacy_dir, true)?; - std::fs::write(&legacy_path, serde_json::to_vec_pretty(&empty_policy())?) - .context("write isolated legacy policy")?; - secure_policy_path(&legacy_path, false)?; - std::fs::remove_file(&agent.policy_path).context("remove managed policy before authority restart")?; - agent.start_again(agent_path).await?; - let authority = policy_management(&agent).await?; - ensure!( - authority["State"] == "Missing" && authority["Source"] == "DefaultPath", - "durable managed authority allowed legacy policy rollback" - ); - ensure!( - request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, - "legacy policy became active after managed authority was established" - ); Ok(()) } @@ -1175,7 +1107,7 @@ async fn warnings_identity_and_receipts( agent_path: &Path, current: &Value, ) -> anyhow::Result { - let mut draft = policy_draft("tests.replaced-identity", "Compatible contract"); + let mut draft = policy_draft("tests.replaced-identity", "Canonical contract"); draft["PolicyFormatVersion"] = json!("1.7.3"); draft["Enforcement"]["AuditMode"] = json!(true); let validation = validate_policy_by_pipe(&agent.pipe_name, &draft).await?; @@ -1225,10 +1157,8 @@ async fn warnings_identity_and_receipts( ensure!( replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" && replaced["Policy"]["Metadata"]["Revision"] == 1 - && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3" - && replaced["Policy"].get("$schema").is_none() - && replaced["Policy"].get("PolicyVersion").is_none(), - "ReplaceIdentity did not preserve the compatible contract and reset revision" + && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3", + "ReplaceIdentity did not preserve the canonical contract and reset revision" ); wait_for_log(agent, "Policy change succeeded").await?; wait_for_log(agent, "replace_identity").await?; @@ -1237,79 +1167,49 @@ async fn warnings_identity_and_receipts( Ok(replaced) } -async fn legacy_contract_and_interrupted_repair(agent_path: &Path) -> anyhow::Result<()> { - let mut legacy = empty_policy(); - legacy - .as_object_mut() - .context("policy is not an object")? - .remove("PolicyFormatVersion"); - legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); - legacy["PolicyVersion"] = json!("1.0.0"); - let mut mixed = legacy.clone(); - mixed["PolicyFormatVersion"] = json!("1.0.0"); - for original in [ - serde_json::to_vec(&legacy)?, - serde_json::to_vec(&mixed)?, - b"malformed-policy-secret-marker".to_vec(), - ] { - for marker_staging in [false, true] { - let data_dir = create_data_dir()?; - let policy_path = data_dir.path().join("policy.json"); - std::fs::write(&policy_path, &original)?; - secure_policy_path(&policy_path, false)?; - let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; - let new_path = data_dir.path().join(format!("{prefix}.new")); - std::fs::write(&new_path, b"partial replacement")?; - secure_policy_path(&new_path, false)?; - let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); - if marker_staging { - std::fs::write(&marker_path, br#"{"Version":"#)?; - secure_policy_path(&marker_path, false)?; - } - let mut agent = - AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; - let mut invalid = policy_management(&agent).await?; - ensure!( - invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", - "interrupted Repair did not retain a repairable invalid original: {invalid}" - ); - ensure!( - std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), - "recovery changed the original or retained prepublication remnants" - ); - ensure!( - request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, - "legacy, mixed, or malformed policy became active" - ); - if original.starts_with(b"{") { - ensure!( - invalid["InvalidDiagnostics"]["Findings"] - .as_array() - .is_some_and(|findings| findings - .iter() - .any(|finding| finding["Code"] == "UnsupportedPolicyFormatVersion")), - "legacy contract did not produce its strict diagnostic" - ); - std::fs::write(&agent.policy_path, serde_json::to_vec(&empty_policy())?)?; - wait_for_management(&agent, |management| management["State"] == "Active").await?; - std::fs::write(&agent.policy_path, &original)?; - invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; - wait_for_log(&agent, "legacy_policy_contract").await?; - } - let repaired = replace_policy( - &agent, - "Repair", - invalid["StoreToken"].clone(), - policy_draft("tests.interrupted-repair", "Recovered"), - ) - .await?; - agent.restart(agent_path).await?; - ensure!( - policy_management(&agent).await?["Policy"] == repaired["Policy"], - "repaired policy did not survive restart" - ); - agent.stop().await?; +async fn interrupted_malformed_repair(agent_path: &Path) -> anyhow::Result<()> { + let original = b"malformed-policy-secret-marker"; + for marker_staging in [false, true] { + let data_dir = create_data_dir()?; + let policy_path = data_dir.path().join("policy.json"); + std::fs::write(&policy_path, original)?; + secure_policy_path(&policy_path, false)?; + let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; + let new_path = data_dir.path().join(format!("{prefix}.new")); + std::fs::write(&new_path, b"partial replacement")?; + secure_policy_path(&new_path, false)?; + let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); + if marker_staging { + std::fs::write(&marker_path, br#"{"Version":"#)?; + secure_policy_path(&marker_path, false)?; } + let mut agent = AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; + let invalid = policy_management(&agent).await?; + ensure!( + invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", + "interrupted Repair did not retain a repairable invalid original: {invalid}" + ); + ensure!( + std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), + "recovery changed the original or retained prepublication remnants" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "malformed policy became active" + ); + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.interrupted-repair", "Recovered"), + ) + .await?; + agent.restart(agent_path).await?; + ensure!( + policy_management(&agent).await?["Policy"] == repaired["Policy"], + "repaired policy did not survive restart" + ); + agent.stop().await?; } Ok(()) } @@ -1499,8 +1399,6 @@ mod tests { fn policy_fixtures_use_the_current_contract() { for policy in [full_policy(), empty_policy(), policy_draft("tests.contract", "Test")] { assert_eq!(policy["PolicyFormatVersion"], "1.0.0"); - assert!(policy.get("$schema").is_none()); - assert!(policy.get("PolicyVersion").is_none()); } } @@ -1515,11 +1413,6 @@ mod tests { } } - #[test] - fn probe_mode_is_only_available_to_a_local_system_client() { - assert!(matches!(Mode::parse("probe"), Ok(Mode::Probe))); - } - #[test] fn standard_user_token_requires_medium_integrity() { validate_standard_user_token( diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs deleted file mode 100644 index 0db6e859d..000000000 --- a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs +++ /dev/null @@ -1,453 +0,0 @@ -using DevolutionsAgent.Actions; -using DevolutionsAgent.Resources; -using Microsoft.Deployment.WindowsInstaller; -using Microsoft.Win32.SafeHandles; -using Newtonsoft.Json.Linq; -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Security.AccessControl; -using System.Security.Principal; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; - -namespace DevolutionsAgent.Installer.Tests; - -public sealed class InstalledAgentMigrationE2eTests -{ - private const string CurrentPolicy = - """{"PolicyFormatVersion":"1.7.3","PolicyType":"PackageBrokerPolicy","Metadata":{"Id":"installer-e2e","Publisher":"Test","Revision":17,"PublishedAt":"2026-01-01T00:00:00Z"},"Enforcement":{"DefaultDecision":"Deny","RulePrecedence":"PriorityThenDeny"},"Rules":[]}"""; - private readonly ITestOutputHelper output; - - public InstalledAgentMigrationE2eTests(ITestOutputHelper output) => this.output = output; - - [Theory] - [InlineData("DevolutionsAgent.exe")] - [InlineData("devolutionsagent.exe")] - public void AgentExecutableNameComparisonIsCaseInsensitive(string fileName) - { - Assert.True(IsExpectedAgentExecutableName(fileName)); - } - - [Fact] - public void AgentJobTerminatesChildWhenScopeThrows() - { - using Process child = Process.Start(new ProcessStartInfo( - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), - @"WindowsPowerShell\v1.0\powershell.exe"), - "-NoProfile -NonInteractive -Command Start-Sleep -Seconds 60") - { - UseShellExecute = false, - CreateNoWindow = true, - }); - try - { - InvalidOperationException failure = new("simulate a failed Agent assertion"); - void FailWithAssignedChild() - { - using AgentJob job = new(); - job.Assign(child); - throw failure; - } - Assert.Same(failure, Assert.Throws(FailWithAssignedChild)); - Assert.True(child.WaitForExit(10000), "Job disposal left the child running"); - } - finally - { - if (!child.HasExited) - { - child.Kill(); - Assert.True(child.WaitForExit(10000), "Cleanup did not stop the child"); - } - } - } - - [PackageBrokerInstallerTests.SystemFact] - public void ConvertedTransactionActivatesAndRetainsManagedAuthority() - { - Assert.True(WindowsIdentity.GetCurrent().IsSystem); - string agent = Environment.GetEnvironmentVariable("DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE"); - Assert.False(string.IsNullOrWhiteSpace(agent), "The SYSTEM runner must supply the built Agent executable"); - Assert.True(File.Exists(agent), agent); - Assert.True(IsExpectedAgentExecutableName(Path.GetFileName(agent)), agent); - using PackageBrokerPolicyActions.PinnedPath installedAgent = - PackageBrokerPolicyActions.PinPathWithoutReparse( - agent, leafIsDirectory: false, allowMissingLeaf: false, - leafAccess: WinAPI.GENERIC_READ | WinAPI.READ_CONTROL, verifyTrustedAncestors: true); - Assert.True( - PackageBrokerPolicyActions.TryVerifyLegacyPolicySourceSecurity( - File.GetAccessControl(agent), out string agentSecurityDiagnostic), - agentSecurityDiagnostic); - string root = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - $"dgw-installer-e2e-{Guid.NewGuid():N}"); - PackageBrokerPolicyActions.CreateDirectoryWithSecurity(root, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); - try - { - PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(Directory.GetAccessControl(root)); - string vendor = Path.Combine(root, "Devolutions"); - string legacyDirectory = Path.Combine(vendor, "Agent"); - string managedDirectory = Path.Combine(vendor, "PackageBroker"); - foreach (string directory in new[] { vendor, legacyDirectory, managedDirectory }) - { - PackageBrokerPolicyActions.CreateDirectoryWithSecurity( - directory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); - } - - string source = Path.Combine(legacyDirectory, "package-broker-policy.json"); - string destination = Path.Combine(managedDirectory, "package-broker-policy.json"); - string marker = Path.Combine(managedDirectory, ".installer-e2e.migration"); - string authority = Path.Combine(managedDirectory, ".package-broker-managed-authority.v1"); - byte[] original = Encoding.UTF8.GetBytes(CurrentPolicy.Replace( - "\"PolicyFormatVersion\":", - "\"$schema\":\"https://devolutions.net/schemas/now-policy.schema.1.0.json\",\"PolicyVersion\":")); - File.WriteAllBytes(source, original); - FileSecurity security = new(); - security.SetSecurityDescriptorSddlForm(Includes.PROGRAM_DATA_PACKAGE_BROKER_FILE_SDDL); - File.SetAccessControl(source, security); - - int conversions = 0; - void Migrate() - { - Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.MigrateLegacyPolicy( - output.WriteLine, source, destination, marker, input => - { - Assert.False(File.Exists(destination)); - Assert.False(File.Exists(authority)); - Assert.Equal(original, input); - byte[] converted = PackageBrokerPolicyActions.ConvertWithInstalledAgent( - Path.GetDirectoryName(agent), input); - Assert.Equal(CurrentPolicy, Encoding.UTF8.GetString(converted)); - conversions++; - return converted; - })); - Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); - Assert.True(File.Exists(authority)); - Assert.Empty(File.ReadAllBytes(authority)); - AssertPreserved(); - foreach (string path in new[] { destination, authority, marker, marker + ".original" }) - { - PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(File.GetAccessControl(path)); - } - } - void AssertPreserved() - { - Assert.Equal(original, File.ReadAllBytes(source)); - Assert.Equal(original, File.ReadAllBytes(marker + ".original")); - Assert.True(File.Exists(marker)); - } - - Migrate(); - Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.RollbackLegacyPolicy( - output.WriteLine, source, destination, marker)); - Assert.False(File.Exists(destination)); - Assert.False(File.Exists(authority)); - AssertPreserved(); - Migrate(); - Assert.Equal(2, conversions); - PackageBrokerPolicyActions.CommitLegacyPolicy(output.WriteLine, source, destination, marker); - AssertPreserved(); - - using AgentProcess running = new(agent, root, output); - running.Start(); - JToken active = AssertActive(running, destination); - running.Stop(); - running.Start(); - Assert.True(JToken.DeepEquals(active, AssertActive(running, destination))); - AssertPreserved(); - running.Stop(); - File.Delete(destination); - running.Start(); - JObject management = running.Get("/v1/policy/management", 200)["Management"] as JObject; - Assert.NotNull(management); - Assert.Equal("DefaultPath", (string)management["Source"]); - Assert.Equal("Missing", (string)management["State"]); - Assert.Equal("active policy is unavailable", (string)running.Get("/v1/policy", 404)["Message"]); - Assert.True(File.Exists(authority)); - AssertPreserved(); - } - finally - { - for (int attempt = 0; ; attempt++) - { - try - { - Directory.Delete(root, recursive: true); - break; - } - catch (IOException) when (attempt < 19) - { - Thread.Sleep(250); - } - } - } - } - - private static JToken AssertActive(AgentProcess agent, string destination) - { - JObject response = agent.Get("/v1/policy", 200); - JToken policy = response["Policy"]; - Assert.NotNull(policy); - Assert.Equal("1.7.3", (string)policy["PolicyFormatVersion"]); - Assert.Equal("PackageBrokerPolicy", (string)policy["PolicyType"]); - Assert.Null(policy["PolicyVersion"]); - Assert.Null(policy["$schema"]); - Assert.Equal("installer-e2e", (string)policy["Metadata"]["Id"]); - Assert.Equal("Test", (string)policy["Metadata"]["Publisher"]); - Assert.Equal(17, (int)policy["Metadata"]["Revision"]); - Assert.Equal( - JObject.Parse(CurrentPolicy)["Metadata"]["PublishedAt"], - policy["Metadata"]["PublishedAt"]); - Assert.True(JToken.DeepEquals(JObject.Parse(CurrentPolicy)["Enforcement"], policy["Enforcement"])); - Assert.True(JToken.DeepEquals(new JArray(), policy["Rules"])); - Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); - JObject management = agent.Get("/v1/policy/management", 200)["Management"] as JObject; - Assert.NotNull(management); - Assert.Equal("DefaultPath", (string)management["Source"]); - Assert.Equal("Active", (string)management["State"]); - return policy; - } - - private static bool IsExpectedAgentExecutableName(string fileName) => - string.Equals(Includes.EXECUTABLE_NAME, fileName, StringComparison.OrdinalIgnoreCase); - - private sealed class AgentProcess : IDisposable - { - private readonly string executable; - private readonly string tester; - private readonly string root; - private readonly ITestOutputHelper output; - private readonly string pipeName = $"Devolutions.Now.PackageBroker.installer-e2e.{Guid.NewGuid():N}"; - private readonly AgentJob job; - private Process process; - private Task stdout; - private Task stderr; - - internal AgentProcess(string executable, string root, ITestOutputHelper output) - { - this.executable = executable; - tester = Environment.GetEnvironmentVariable("AGENT_POLICY_TESTER_E2E_EXE"); - Assert.False(string.IsNullOrWhiteSpace(tester), "The SYSTEM runner must supply the policy tester executable"); - Assert.True(File.Exists(tester), tester); - this.root = root; - this.output = output; - JObject config = new() - { - ["LogFile"] = Path.Combine(root, "agent-installer-e2e"), - ["PackageBroker"] = new JObject - { - ["Enabled"] = true, - ["PipeName"] = @"\\.\pipe\" + pipeName, - }, - ["__debug__"] = new JObject { ["skip_broker_signature_validation"] = true }, - }; - File.WriteAllText(Path.Combine(root, "agent.json"), config.ToString()); - job = new AgentJob(); - } - - internal void Start() - { - Assert.Null(process); - stdout = null; - stderr = null; - ProcessStartInfo start = new(executable, "run") - { - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = root, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - start.EnvironmentVariables["DAGENT_CONFIG_PATH"] = root; - start.EnvironmentVariables["ProgramData"] = root; - process = Process.Start(start); - job.Assign(process); - stdout = process.StandardOutput.ReadToEndAsync(); - stderr = process.StandardError.ReadToEndAsync(); - Stopwatch timer = Stopwatch.StartNew(); - while (true) - { - Assert.False(process.HasExited, "Agent exited before its broker became ready"); - try - { - Get("/v1/health", 200); - return; - } - catch (TimeoutException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) - { - Thread.Sleep(50); - } - catch (IOException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) - { - Thread.Sleep(50); - } - } - } - - internal JObject Get(string path, int expectedStatus) - { - ProcessStartInfo start = new(tester, $"\"{tester}\" probe \"\\\\.\\pipe\\{pipeName}\" \"{path}\"") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - using Process client = Process.Start(start); - Task stdout = client.StandardOutput.ReadToEndAsync(); - Task stderr = client.StandardError.ReadToEndAsync(); - if (!client.WaitForExit(10000)) - { - client.Kill(); - throw new TimeoutException($"timed out probing {path}"); - } - string standardOutput = stdout.GetAwaiter().GetResult(); - string standardError = stderr.GetAwaiter().GetResult(); - if (client.ExitCode != 0) - { - throw new IOException($"policy tester probe failed for {path}: {standardError}"); - } - JObject response = JObject.Parse(standardOutput); - Assert.Equal(expectedStatus, (int)response["Status"]); - return response["Body"] as JObject; - } - - internal void Stop() - { - if (process == null) - { - return; - } - try - { - if (!process.HasExited) - { - try - { - process.Kill(); - } - catch (InvalidOperationException) when (process.HasExited) - { - } - catch (Win32Exception) when (process.WaitForExit(10000)) - { - } - } - Assert.True(process.WaitForExit(10000), "Agent did not stop"); - if (stdout != null) - { - output.WriteLine(stdout.GetAwaiter().GetResult()); - output.WriteLine(stderr.GetAwaiter().GetResult()); - } - foreach (string log in Directory.GetFiles(root, "agent-installer-e2e*")) - { - output.WriteLine(File.ReadAllText(log)); - } - } - finally - { - if (process.HasExited) - { - process.Dispose(); - process = null; - } - } - } - - public void Dispose() - { - job.Dispose(); - Stop(); - } - } - - private sealed class AgentJob : IDisposable - { - private const uint JobObjectLimitKillOnJobClose = 0x2000; - private const int JobObjectExtendedLimitInformation = 9; - private readonly SafeFileHandle handle; - - internal AgentJob() - { - handle = CreateJobObjectW(IntPtr.Zero, null); - if (handle.IsInvalid) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - ExtendedLimitInformation limits = new() - { - BasicLimitInformation = new BasicLimitInformation { LimitFlags = JobObjectLimitKillOnJobClose }, - }; - if (!SetInformationJobObject( - handle, JobObjectExtendedLimitInformation, ref limits, Marshal.SizeOf())) - { - int error = Marshal.GetLastWin32Error(); - handle.Dispose(); - throw new Win32Exception(error); - } - } - - internal void Assign(Process process) - { - if (!AssignProcessToJobObject(handle, process.Handle)) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - } - - public void Dispose() => handle.Dispose(); - - [StructLayout(LayoutKind.Sequential)] - private struct BasicLimitInformation - { - internal long PerProcessUserTimeLimit; - internal long PerJobUserTimeLimit; - internal uint LimitFlags; - internal UIntPtr MinimumWorkingSetSize; - internal UIntPtr MaximumWorkingSetSize; - internal uint ActiveProcessLimit; - internal UIntPtr Affinity; - internal uint PriorityClass; - internal uint SchedulingClass; - } - - [StructLayout(LayoutKind.Sequential)] - private struct IoCounters - { - internal ulong ReadOperationCount; - internal ulong WriteOperationCount; - internal ulong OtherOperationCount; - internal ulong ReadTransferCount; - internal ulong WriteTransferCount; - internal ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - private struct ExtendedLimitInformation - { - internal BasicLimitInformation BasicLimitInformation; - internal IoCounters IoInfo; - internal UIntPtr ProcessMemoryLimit; - internal UIntPtr JobMemoryLimit; - internal UIntPtr PeakProcessMemoryUsed; - internal UIntPtr PeakJobMemoryUsed; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string name); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetInformationJobObject( - SafeFileHandle job, int informationClass, ref ExtendedLimitInformation information, int length); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); - } -} From 9f6744a5255457b4e167f93758383f7334401386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 00:25:08 +0900 Subject: [PATCH 25/53] test(agent): cover final policy contract Exercise the final canonical policy rule shapes and validation semantics through the end-to-end policy management harness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/src/windows.rs | 75 +++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index d5284fb76..bc5f51cb4 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -481,7 +481,49 @@ fn policy_draft(id: &str, publisher: &str) -> Value { "PolicyFormatVersion": "1.0.0", "Metadata": { "Id": id, "Publisher": publisher }, "Enforcement": { "DefaultDecision": "Deny" }, - "Rules": [] + "Rules": [ + { + "Id": "allow.exact", + "Priority": 100, + "Decision": "Allow", + "Match": { + "Operations": ["Install"], + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Exact": ["Microsoft.PowerToys"] }, + "Version": { "Exact": ["0.86.0"] }, + "ExecutionElevation": ["Elevated"], + "Interactive": true, + "SkipHashCheck": false + }, + "Constraints": { + "AllowInteractive": true, + "AllowSkipHashCheck": false + } + }, + { + "Id": "allow.pattern", + "Priority": 101, + "Decision": "Allow", + "Match": { + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Patterns": ["Contoso.*"] }, + "Version": { + "Range": { + "MinVersion": "1.0.0", + "MaxVersion": "2.0.0", + "IncludePrerelease": false + } + }, + "ExecutionElevation": ["Standard"], + "HasCustomParameters": false + }, + "Constraints": { + "AllowCustomParameters": false + } + } + ] }) } @@ -662,7 +704,7 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); ensure!( - validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + validation["ValidatorVersion"] == "now-package-broker-policy-validator/10", "unexpected validator contract" ); ensure!( @@ -911,13 +953,38 @@ async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { for version in ["1.0.0", "1.7.3"] { let mut draft = policy_draft("tests.contract", "Contract"); draft["PolicyFormatVersion"] = json!(version); - validate_policy_by_pipe(pipe_name, &draft).await?; + let validation = validate_policy_by_pipe(pipe_name, &draft).await?; + let canonical = &validation["CanonicalDraft"]; + ensure!( + canonical["Rules"][0]["Match"]["Managers"] == json!(["Winget"]) + && canonical["Rules"][0]["Match"]["SourceNames"] == json!(["winget"]) + && canonical["Rules"][0]["Match"]["PackageIdentifiers"]["Exact"] == json!(["Microsoft.PowerToys"]) + && canonical["Rules"][0]["Match"]["Version"]["Exact"] == json!(["0.86.0"]) + && canonical["Rules"][0]["Match"]["ExecutionElevation"] == json!(["Elevated"]) + && canonical["Rules"][1]["Match"]["PackageIdentifiers"]["Patterns"] == json!(["Contoso.*"]) + && canonical["Rules"][1]["Match"]["Version"]["Range"]["MinVersion"] == "1.0.0" + && canonical["Rules"][1]["Match"]["ExecutionElevation"] == json!(["Standard"]), + "canonical draft did not preserve final rule shapes" + ); + ensure!( + canonical["Rules"][0]["Match"].get("PreRelease").is_none() + && canonical["Rules"][1]["Match"].get("Interactive").is_none(), + "canonical draft did not omit absent optional characteristics" + ); } for (value, expected_path) in [("2.0.0", "/PolicyFormatVersion"), ("broken", "/PolicyFormatVersion")] { let mut draft = policy_draft("tests.contract", "Contract"); draft["PolicyFormatVersion"] = json!(value); assert_invalid_draft(pipe_name, draft, expected_path).await?; } + let mut invalid_interval = policy_draft("tests.contract", "Contract"); + invalid_interval["Metadata"]["ValidFrom"] = json!("2026-01-01T00:00:00Z"); + invalid_interval["Metadata"]["ValidUntil"] = json!("2026-01-01T00:00:00Z"); + assert_invalid_draft(pipe_name, invalid_interval, "/Metadata/ValidUntil").await?; + + let mut duplicate_rule = policy_draft("tests.contract", "Contract"); + duplicate_rule["Rules"][1]["Id"] = duplicate_rule["Rules"][0]["Id"].clone(); + assert_invalid_draft(pipe_name, duplicate_rule, "/Rules/1/Id").await?; Ok(()) } @@ -945,7 +1012,7 @@ async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str ensure!( invalid_validation.get("CanonicalDraft").is_none() && invalid_validation.get("ValidationReceipt").is_none() - && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/10", "invalid draft returned a canonical draft, receipt, or wrong validator version" ); ensure!( From 8c553f585a2947a21ffd3fd815b7b3ec65aac84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 05:33:00 +0900 Subject: [PATCH 26/53] test(agent): cover policy review gaps Exercise standard-user read access, stale overwrite rejection, and LocalSystem shutdown verification after failed readiness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 128 ++++++++++++------ crates/agent-policy-tester/src/windows.rs | 22 ++- 2 files changed, 108 insertions(+), 42 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 5dc9b2b2b..b6615393c 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -108,6 +108,64 @@ function Remove-StagingPath { } } +function Complete-ServerShutdown { + param( + [bool] $ServerLaunchAttempted, + [bool] $ServerLaunchExplicitlyFailed, + [string] $StopPath, + [string] $StatusPath, + [string] $ServerOutputPath, + [string] $OutputPath, + [int] $ExitCode, + [scriptblock] $SignalServer + ) + + if (-not $ServerLaunchAttempted) { + return $ExitCode + } + + $signal = & $SignalServer + $signal.Output | Out-File $OutputPath -Append + if ($signal.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $StopPath)) { + try { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + "Created the stop marker directly after LocalSystem signaling did not confirm it" | Out-File $OutputPath -Append + } catch { + $_ | Out-File $OutputPath -Append + } + } + if (-not (Test-Path -LiteralPath $StopPath)) { + "Failed to create the LocalSystem test server stop marker" | Out-File $OutputPath -Append + $ExitCode = 1 + } + + if (-not $ServerLaunchExplicitlyFailed) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $StatusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $ServerOutputPath) { + Get-Content -LiteralPath $ServerOutputPath | Out-File $OutputPath -Append + } + if (Test-Path -LiteralPath $StatusPath) { + try { + $serverExitCode = Read-ServerStatus -Path $StatusPath + if ($serverExitCode -ne 0 -and $ExitCode -eq 0) { + $ExitCode = $serverExitCode + } + } catch { + $_ | Out-File $OutputPath -Append + $ExitCode = 1 + } + } else { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $OutputPath -Append + $ExitCode = 1 + } + } + + return $ExitCode +} + function New-RandomSecurePassword { $alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*" $bytes = [byte[]]::new(32) @@ -271,6 +329,7 @@ function Invoke-RunnerSelfTests { try { $ready = Join-Path $root "ready.json" $status = Join-Path $root "status" + $serverOutput = Join-Path $root "server.out" foreach ($expected in @(0, 1, -1)) { Publish-ServerStatus -Path $status -ExitCode $expected if ((Read-ServerStatus -Path $status) -ne $expected) { @@ -383,6 +442,27 @@ function Invoke-RunnerSelfTests { } } + $stop = Join-Path $root "stop" + $shutdownOutput = Join-Path $root "shutdown.out" + $signalState = [pscustomobject]@{ Count = 0 } + [System.IO.File]::WriteAllText($status, "invalid") + $readinessFailureExitCode = Complete-ServerShutdown ` + -ServerLaunchAttempted $true -ServerLaunchExplicitlyFailed $false ` + -StopPath $stop -StatusPath $status -ServerOutputPath $serverOutput -OutputPath $shutdownOutput ` + -ExitCode 1 -SignalServer { + $signalState.Count++ + New-Item -ItemType File -Path $stop | Out-Null + [pscustomobject]@{ ExitCode = 0; Output = @("Simulated LocalSystem signal") } + } + if ( + $readinessFailureExitCode -ne 1 -or + $signalState.Count -ne 1 -or + -not (Test-Path -LiteralPath $stop) -or + (Get-Content -LiteralPath $shutdownOutput -Raw) -notmatch "published an invalid completion status" + ) { + throw "Readiness failure did not signal and verify LocalSystem server shutdown" + } + Remove-StagingPath -Path (Join-Path $root "already-absent") } finally { Remove-StagingPath -Path $root @@ -550,48 +630,14 @@ try { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - if ($serverLaunchAttempted) { - $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` - -Action Signal -StopPath $stopPath 2>&1 - $signalExitCode = $LASTEXITCODE - $signalOutput | Out-File $outputPath -Append - if ($signalExitCode -ne 0 -and -not (Test-Path -LiteralPath $stopPath)) { - try { - New-Item -ItemType File -Path $stopPath -ErrorAction Stop | Out-Null - "Created the stop marker directly after SYSTEM signaling failed" | Out-File $outputPath -Append - } catch { - $_ | Out-File $outputPath -Append - } + $exitCode = Complete-ServerShutdown ` + -ServerLaunchAttempted $serverLaunchAttempted -ServerLaunchExplicitlyFailed $serverLaunchExplicitlyFailed ` + -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -OutputPath $outputPath ` + -ExitCode $exitCode -SignalServer { + $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Signal -StopPath $stopPath 2>&1 + [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $signalOutput } } - if (-not (Test-Path -LiteralPath $stopPath) -and $exitCode -eq 0) { - "Failed to create the LocalSystem test server stop marker" | Out-File $outputPath -Append - $exitCode = 1 - } - - if (-not $serverLaunchExplicitlyFailed) { - $deadline = [DateTime]::UtcNow.AddSeconds(30) - while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { - Start-Sleep -Milliseconds 100 - } - if (Test-Path -LiteralPath $serverOutputPath) { - Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append - } - if (Test-Path -LiteralPath $statusPath) { - try { - $serverExitCode = Read-ServerStatus -Path $statusPath - if ($serverExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $serverExitCode - } - } catch { - $_ | Out-File $outputPath -Append - $exitCode = 1 - } - } elseif ($exitCode -eq 0) { - "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append - $exitCode = 1 - } - } - } if ($clientAccount) { try { diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index bc5f51cb4..aea53e539 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -918,6 +918,13 @@ async fn standard_user_management(ready_path: &Path, nonce: &str, client: &Proce let management = policy_management_by_pipe(pipe_name).await?; ensure!(management["State"] == "Missing", "expected a missing policy"); + let missing_policy = request(pipe_name, "GET", "/v1/policy").await?; + ensure!( + missing_policy.status == 404 + && missing_policy.json()?["Code"] == "NotFound" + && missing_policy.json()?["Message"] == "active policy is unavailable", + "standard-user missing policy read did not return the canonical not-found response" + ); let valid_draft = policy_draft("tests.standard-user", "Test"); let validation = validate_policy_by_pipe(pipe_name, &valid_draft).await?; @@ -1107,7 +1114,7 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { &agent, "Update", "Reject", - stale_token, + stale_token.clone(), policy_draft("tests.managed-external", "Stale"), ) .await?; @@ -1120,6 +1127,19 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { ); wait_for_log(&agent, "stale_conflict").await?; + let stale_confirm = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + stale_token, + policy_draft("tests.managed-external", "Stale confirmed overwrite"), + ) + .await?; + ensure!( + stale_confirm.status == 409 && stale_confirm.json()?["Code"] == "StalePolicyStoreToken", + "stale ConfirmOverwrite did not return a store token conflict" + ); + let current_token = stale["Management"]["StoreToken"].clone(); let confirmed = replace_policy_response( &agent, From 5eb089efe40e8feab28225da698b0cc096a69d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 20 Sep 2026 17:50:53 +0900 Subject: [PATCH 27/53] fix(agent): accept advisory policy findings Remove retired warning acknowledgment requests while retaining receipt and conflict enforcement in policy management E2E coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/src/windows.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index aea53e539..c7511892d 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -756,7 +756,6 @@ async fn replace_policy_response_by_pipe( "ExpectedStoreToken": expected_store_token, "Operation": operation, "ConflictHandling": conflict_handling, - "WarningsAcknowledged": true, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); @@ -1210,16 +1209,9 @@ async fn warnings_identity_and_receipts( "ExpectedStoreToken": current["Management"]["StoreToken"], "Operation": "ReplaceIdentity", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); - let warning = send_replacement(&agent.pipe_name, &replacement).await?; - ensure!( - warning.status == 409 && warning.json()?["Code"] == "WarningConfirmationRequired", - "unacknowledged warnings were not rejected" - ); - replacement["WarningsAcknowledged"] = json!(true); replacement["Draft"]["Metadata"]["Publisher"] = json!("Tampered after validation"); let tampered = send_replacement(&agent.pipe_name, &replacement).await?; ensure!( @@ -1245,12 +1237,11 @@ async fn warnings_identity_and_receipts( replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" && replaced["Policy"]["Metadata"]["Revision"] == 1 && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3", - "ReplaceIdentity did not preserve the canonical contract and reset revision" + "advisory findings did not permit the canonical identity replacement" ); wait_for_log(agent, "Policy change succeeded").await?; wait_for_log(agent, "replace_identity").await?; wait_for_log(agent, "invalid_receipt").await?; - wait_for_log(agent, "warnings_not_acknowledged").await?; Ok(replaced) } From d1fe413895679e1f76c0e14bf7a16ef3e70dfda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 01:22:26 -0400 Subject: [PATCH 28/53] feat(agent,agent-installer): add policy consent helper Install one protected NativeAOT helper that authenticates retained UniGetUI and Agent process identities before forwarding bounded policy replacement requests. Preserve protocol 2.0 conflict and uncertainty semantics while integrating transactional discovery, signing, and packaging. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 31 + .github/workflows/package.yml | 7 +- ci/package-agent-windows.ps1 | 10 +- crates/now-package-broker/src/auth.rs | 83 +++ crates/now-package-broker/src/server/mod.rs | 7 +- ...DevolutionsAgentPolicyConsent.Tests.csproj | 22 + .../AgentPolicyConsent.Tests/ProtocolTests.cs | 445 ++++++++++++ package/AgentPolicyConsent/BrokerClient.cs | 250 +++++++ .../DevolutionsAgentPolicyConsent.csproj | 23 + package/AgentPolicyConsent/PeerTrust.cs | 682 ++++++++++++++++++ .../PolicyConsentContract.cs | 13 + package/AgentPolicyConsent/Program.cs | 173 +++++ package/AgentPolicyConsent/Protocol.cs | 240 ++++++ package/AgentPolicyConsent/app.manifest | 11 + .../PolicyConsentDiscoveryTests.cs | 45 ++ .../DevolutionsAgent.csproj | 1 + package/AgentWindowsManaged/Program.cs | 55 ++ .../AgentWindowsManaged/Resources/Includes.cs | 12 + 18 files changed, 2106 insertions(+), 4 deletions(-) create mode 100644 package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj create mode 100644 package/AgentPolicyConsent.Tests/ProtocolTests.cs create mode 100644 package/AgentPolicyConsent/BrokerClient.cs create mode 100644 package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj create mode 100644 package/AgentPolicyConsent/PeerTrust.cs create mode 100644 package/AgentPolicyConsent/PolicyConsentContract.cs create mode 100644 package/AgentPolicyConsent/Program.cs create mode 100644 package/AgentPolicyConsent/Protocol.cs create mode 100644 package/AgentPolicyConsent/app.manifest create mode 100644 package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87550f9d6..a381ea780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -887,6 +887,9 @@ jobs: $DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe" echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT + $DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe" + echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT + $DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe" echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT } @@ -1052,6 +1055,28 @@ jobs: DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }} TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }} + - name: Build NativeAOT policy consent helper + if: ${{ matrix.os == 'windows' }} + run: | + $Rid = "win-${{ matrix.arch }}" + $Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj ` + --configuration Release ` + --runtime $Rid ` + --output $Output ` + -p:Version=${{ needs.preflight.outputs.version }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) { + throw "NativeAOT policy consent helper was not produced" + } + if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) { + throw "NativeAOT policy consent helper exceeds 8 MiB" + } + shell: pwsh + - name: Package if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | @@ -1065,6 +1090,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}" $Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}" $Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}" + $Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}" $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" @@ -1186,6 +1212,11 @@ jobs: run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj shell: pwsh + - name: Policy consent helper tests + run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release + shell: pwsh + + winapi-sanitizer-tests: name: Windows API sanitizer tests runs-on: windows-2022 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 27ee014f9..af56e0b9f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -322,7 +322,7 @@ jobs: run: | $IncludePattern = @(switch ('${{ matrix.project }}') { 'devolutions-gateway' { @('DevolutionsGateway.exe') } - 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } + 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } 'jetsocat' { @('jetsocat.exe', 'jetsocat') } }) $ExcludePattern = "*.pdb" @@ -495,6 +495,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1 $Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1 $Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1 + $Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1 $Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe' $Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll' $MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch @@ -508,6 +509,7 @@ jobs: Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}" Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}" Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}" + Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}" Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}" Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}" Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}" @@ -534,7 +536,8 @@ jobs: @((Join-Path $ArchRoot DesktopAgent), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1), - (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object { + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1), + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object { Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null } } diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 4a37cc512..10f0e7219 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -11,6 +11,8 @@ param( [parameter(Mandatory = $true)] [string] $SessionExe, [parameter(Mandatory = $true)] + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] [string] $Architecture, [string] $Outfile @@ -98,6 +100,9 @@ function New-AgentMsi() { # The path to the devolutions-session.exe file. [string] $SessionExe, [parameter(Mandatory = $true)] + # The path to the DevolutionsAgentPolicyConsent.exe file. + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] # Architecture: x64 or arm64 [string] $Architecture, @@ -120,6 +125,7 @@ function New-AgentMsi() { $PedmDll = Convert-Path -Path $PedmDll $PedmMsix = Convert-Path -Path $PedmMsix $SessionExe = Convert-Path -Path $SessionExe + $PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper if ($Outfile) { $Outfile = Convert-Path -Path $Outfile } @@ -137,6 +143,7 @@ function New-AgentMsi() { $myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe' # The session is a service that gets launched on demand. $mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe' + $myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe' Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe" @@ -145,6 +152,7 @@ function New-AgentMsi() { Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe + Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper # The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`. # After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. @@ -184,4 +192,4 @@ function New-AgentMsi() { Pop-Location } -New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile +New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index d647e750c..f293a0306 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{ use crate::policy_security::RetainedExecutableSecurity; const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000); +const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe"; const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS( PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0, ); @@ -289,6 +290,20 @@ impl PipeClient { Ok(()) } + pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> { + self.validate_connection(skip_signature_validation)?; + // Dev builds cannot enforce helper identity when signature validation is explicitly disabled. + if signature_validation_skipped(skip_signature_validation) { + return Ok(()); + } + let agent = std::env::current_exe().context("failed to query Agent executable path")?; + let executable_file = self + .executable_file + .as_deref() + .context("policy consent helper executable handle is not retained")?; + Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent) + } + fn validate_process_instance(&self) -> anyhow::Result<()> { let Some(process) = &self.process else { return Ok(()); @@ -304,6 +319,29 @@ impl PipeClient { ) } + fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> { + if !client + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME)) + { + bail!("policy replacement requires the Agent policy consent helper"); + } + let expected = agent + .parent() + .context("Agent executable has no installation directory")? + .join(POLICY_CONSENT_HELPER_NAME); + if !crate::policy_security::windows_paths_equal(client, &expected) { + bail!("policy consent helper is not the installed Agent helper path"); + } + let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?; + let retained_id = + file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?; + if !same_file(&expected_id, &retained_id) { + bail!("policy consent helper does not match the installed helper"); + } + Ok(()) + } + /// Validate that the request's `effective_user` denotes the authenticated pipe client user. /// /// The name is resolved to a SID and compared against the SID captured at connect, @@ -539,6 +577,51 @@ mod tests { .expect_err("a recycled PID with a different creation time must be rejected"); } + #[test] + fn policy_consent_helper_requires_exact_agent_sibling_path() { + let current_executable = std::env::current_exe().expect("current executable"); + let current_file = open_executable_file(¤t_executable).expect("open current executable"); + let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe"); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err(), + "path text alone must not authorize a different retained image" + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\UniGetUI.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + } + + #[test] + fn policy_consent_helper_accepts_exact_retained_sibling() { + let temp = tempfile::tempdir().expect("temp directory"); + let agent = temp.path().join("DevolutionsAgent.exe"); + let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME); + std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor"); + std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture"); + let retained = open_executable_file(&helper).expect("retain helper fixture"); + + PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent) + .expect("exact retained sibling must be accepted"); + } + #[test] fn exited_process_cannot_supply_executable_identity() { let mut child = std::process::Command::new("powershell.exe") diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 6ace82de8..77f835d87 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -307,7 +307,12 @@ async fn authenticate_policy_management( | (&Method::PUT, "/v1/policy") ); if protected { - if let Err(error) = client.validate_connection(state.skip_signature_validation) { + let authentication = if matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")) { + client.validate_policy_write(state.skip_signature_validation) + } else { + client.validate_connection(state.skip_signature_validation) + }; + if let Err(error) = authentication { if let Some(audit) = write_audit { audit.denied(crate::audit::DenialReason::AuthenticationFailed); } diff --git a/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj new file mode 100644 index 000000000..24bdd8852 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + win-x64 + true + false + enable + enable + + + + + + all + + + + + + diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs new file mode 100644 index 000000000..36456f288 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -0,0 +1,445 @@ +using System.Buffers.Binary; +using System.Text.Json; +using DevolutionsAgentPolicyConsent; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace DevolutionsAgentPolicyConsent.Tests; + +public sealed class ProtocolTests +{ + private const string RequestId = "0123456789abcdef0123456789abcdef"; + + [Fact] + public void ArgumentsRequireExactBoundIdentity() + { + Arguments parsed = Protocol.ParseArguments( + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + "--session", "1", + ]); + + Assert.Equal(42, parsed.ParentProcessId); + Assert.Equal((uint)1, parsed.SessionId); + } + + public sealed class TrustPolicyTests + { + [Fact] + public void CurrentAndTransitionSignersAreAccepted() + { + Assert.True(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256)); + Assert.True(PeerLease.IsAllowedSigner(PeerLease.TransitionUiSignerSpkiSha256)); + } + + [Fact] + public void LookalikeSignerIsRejected() + { + Assert.False(PeerLease.IsAllowedSigner(new string('0', 64))); + } + + [Fact] + public void SignerMatchingIsCaseSensitive() + { + Assert.False(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256.ToUpperInvariant())); + } + + [Theory] + [InlineData("3.3.7")] + [InlineData("2026.2.7")] + public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) + { + Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); + } + + [Theory] + [InlineData("Lookalike", "UniGetUI.dll", "2026.2.7")] + [InlineData("UniGetUI", "malware.exe", "2026.2.7")] + [InlineData("UniGetUI", "UniGetUI.dll", "3.3.6")] + public void ProductBindingRejectsLookalikes(string product, string originalFilename, string version) + { + Assert.False(PeerLease.IsSupportedUiIdentity(product, originalFilename, version)); + } + + [Theory] + [InlineData(41, 638900000000000000, 1)] + [InlineData(42, 638900000000000001, 1)] + [InlineData(42, 638900000000000000, 2)] + public void ProcessIdentityRejectsPidReuseAndSessionMismatch(int pid, long created, uint session) + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.False(PeerLease.MatchesProcessIdentity(expected, pid, created, session)); + } + + [Fact] + public void ProcessIdentityAcceptsExactRetainedInstance() + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.True(PeerLease.MatchesProcessIdentity(expected, 42, 638900000000000000, 1)); + } + + [Fact] + public void BrokerServerRequiresExactAgentSiblingPath() + { + Assert.True(BrokerServerLease.IsExpectedPath( + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe", + @"c:\PROGRAM FILES\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.False(BrokerServerLease.IsExpectedPath( + @"C:\Users\Alice\DevolutionsAgent.exe", + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe")); + } + + [Fact] + public void AuthenticodeSignerComesFromRetainedImageHandle() + { + string path = Path.Combine(AppContext.BaseDirectory, "testhost.exe"); + using SafeFileHandle image = OpenImage(path); + using var certificate = PeerLease.VerifyAuthenticodeSigner(path, image, "test host"); + Assert.NotEmpty(certificate.RawData); + } + + [Fact] + public void ProcessImageMappingAcceptsOnlyCurrentMappedImage() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + + string processPath = PeerLease.ImagePath(process); + using SafeFileHandle processImage = OpenImage(processPath); + PeerLease.VerifyImageMapping(process, processImage); + + using SafeFileHandle differentImage = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + Assert.Throws(() => PeerLease.VerifyImageMapping(process, differentImage)); + } + + [Fact] + public void BrokerServerRejectsNonSystemProcessToken() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryLimitedInformation, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + Assert.False(PeerLease.IsLocalSystemProcess(process)); + } + + [Fact] + public void FileIdentityRejectsDifferentImage() + { + using SafeFileHandle first = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle same = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle different = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + + Assert.True(PeerLease.SameFile(first, same)); + Assert.False(PeerLease.SameFile(first, different)); + } + + private static SafeFileHandle OpenImage(string path) + { + SafeFileHandle image = Native.CreateFile( + path, + PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + 0, + IntPtr.Zero); + Assert.False(image.IsInvalid); + return image; + } + } + + [Theory] + [InlineData("--extra")] + [InlineData("--pipe")] + public void ArgumentsRejectUnknownOrDuplicateNames(string name) + { + string[] args = + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + name, "1", + ]; + + Assert.Throws(() => Protocol.ParseArguments(args)); + } + + [Fact] + public async Task FrameUsesBigEndianLengthAndRoundTrips() + { + byte[] body = [1, 2, 3, 4]; + using MemoryStream stream = new(); + await Protocol.WriteFrameAsync(stream, body, body.Length, CancellationToken.None); + Assert.Equal((uint)body.Length, BinaryPrimitives.ReadUInt32BigEndian(stream.GetBuffer())); + stream.Position = 0; + Assert.Equal(body, await Protocol.ReadFrameAsync(stream, body.Length, CancellationToken.None)); + } + + [Fact] + public async Task OversizedFrameIsRejectedBeforeBodyRead() + { + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, 128); + using MemoryStream stream = new(header); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(stream, 127, CancellationToken.None)); + } + + [Fact] + public async Task ZeroLengthAndTruncatedFramesAreRejected() + { + using MemoryStream empty = new(new byte[4]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(empty, 128, CancellationToken.None)); + + using MemoryStream truncated = new([0, 0, 0, 2, 1]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(truncated, 128, CancellationToken.None)); + } + + [Fact] + public async Task FrameReadHonorsCancellation() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromMilliseconds(25)); + await Assert.ThrowsAnyAsync( + () => Protocol.ReadFrameAsync(new BlockingStream(), 128, cancellation.Token)); + } + + [Fact] + public void RequestRejectsUnknownJsonMembers() + { + string json = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{},"command":"cmd.exe"}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ElevationRequest)); + } + + [Fact] + public void OfficialRequestContainsOnlyPolicyReplacementFields() + { + using JsonDocument draft = JsonDocument.Parse("""{"Metadata":{"Id":"tests.policy"}}"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "ConfirmOverwrite", + "token", + "receipt", + true, + draft.RootElement.Clone()); + + using JsonDocument official = JsonDocument.Parse(BrokerClient.CreateOfficialRequest(request)); + string[] names = official.RootElement.EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal( + [ + "RequestKind", + "RequestVersion", + "ExpectedStoreToken", + "Operation", + "ConflictHandling", + "WarningsAcknowledged", + "Draft", + "ValidationReceipt", + ], names); + } + + [Fact] + public void RequestRequiresEveryMemberAndObjectDraft() + { + string missingAcknowledgement = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; + Assert.Throws( + () => JsonSerializer.Deserialize(missingAcknowledgement, ProtocolJsonContext.Default.ElevationRequest)); + + using JsonDocument draft = JsonDocument.Parse("[]"); + ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", false, draft.RootElement); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData("Delete", "Reject")] + [InlineData("Update", "Overwrite")] + public void RequestRejectsUnknownOperations(string operation, string conflictHandling) + { + using JsonDocument draft = JsonDocument.Parse("{}"); + ElevationRequest request = new( + "2.0", + RequestId, + operation, + conflictHandling, + "a", + "b", + false, + draft.RootElement); + + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Fact] + public void CommittedResponseContainsOnlyStoreToken() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Committed", + null, + null, + "new-token", + null, + null, + null); + + Protocol.ValidateResponse(response); + string json = JsonSerializer.Serialize(response, ProtocolJsonContext.Default.ElevationResponse); + Assert.DoesNotContain("payload", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("message", json, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Active", "policy.id")] + [InlineData("Missing", null)] + [InlineData("Invalid", null)] + public void StaleRejectionCarriesBoundedConflictContext(string state, string? policyId) + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "current-token", + state, + policyId); + + Protocol.ValidateResponse(response); + } + + [Fact] + public void UnknownResponseCannotClaimCommitOrConflict() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Unknown", + null, + "Timeout", + "claimed-token", + null, + null, + null); + + Assert.Throws(() => Protocol.ValidateResponse(response)); + } + + [Fact] + public void ResponseRequiresExplicitNullableMembers() + { + string missingConflictFields = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","disposition":"Unknown","brokerStatusCode":null,"brokerErrorCode":"Timeout","committedStoreToken":null}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(missingConflictFields, ProtocolJsonContext.Default.ElevationResponse)); + } + + [Fact] + public void MaximumStaleResponseFitsExactWireBudget() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "T" + new string('"', 511), + "Active", + "P" + new string('"', 2047)); + + Protocol.ValidateResponse(response); + byte[] body = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + Assert.InRange(body.Length, 1, Protocol.MaxResponseBodyBytes); + } + + [Fact] + public void BrokerSuccessMapsToCompactCommittedAcknowledgement() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 200, + """{"ResponseKind":"PolicyReplacementResponse","ResponseVersion":"1.0","Management":{"StoreToken":"new-token"}}""")); + + Assert.Equal("Committed", response.Disposition); + Assert.Equal("new-token", response.CommittedStoreToken); + Assert.Null(response.BrokerStatusCode); + } + + [Fact] + public void BrokerStaleErrorMapsExactConflictContext() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 409, + """{"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Code":"StalePolicyStoreToken","Management":{"StoreToken":"current-token","State":"Active","Policy":{"Metadata":{"Id":"policy.id"}}}}""")); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal(409, response.BrokerStatusCode); + Assert.Equal("current-token", response.ConflictStoreToken); + Assert.Equal("Active", response.ConflictState); + Assert.Equal("policy.id", response.ConflictPolicyId); + } + + [Fact] + public void EmptyBrokerResponseMapsToUnknown() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse(503, string.Empty)); + + Assert.Equal("Unknown", response.Disposition); + Assert.Equal(503, response.BrokerStatusCode); + Assert.Equal("EmptyResponse", response.BrokerErrorCode); + } + + private static byte[] HttpResponse(int status, string body) => + System.Text.Encoding.UTF8.GetBytes( + $"HTTP/1.1 {status} Test\r\nContent-Length: {System.Text.Encoding.UTF8.GetByteCount(body)}\r\n\r\n{body}"); + + private sealed class BlockingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + } +} diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs new file mode 100644 index 000000000..bdcbd5120 --- /dev/null +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -0,0 +1,250 @@ +using System.Buffers; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class BrokerClient +{ + private const string PipeName = "Devolutions.Now.PackageBroker.v1"; + private const int MaximumHeaderBytes = 64 * 1024; + private const int MaximumBrokerResponseBytes = 50_606_928; + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + + internal static async Task ReplaceAsync( + ElevationRequest request, + CancellationToken cancellationToken) + { + byte[] body = CreateOfficialRequest(request); + try + { + using NamedPipeClientStream pipe = new( + ".", + PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + System.Security.Principal.TokenImpersonationLevel.Anonymous); + + using CancellationTokenSource connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectTimeout.CancelAfter(ConnectTimeout); + await pipe.ConnectAsync(connectTimeout.Token); + using BrokerServerLease broker = BrokerServerLease.Open(pipe.SafePipeHandle); + + byte[] headers = Encoding.ASCII.GetBytes( + $"PUT /v1/policy HTTP/1.1\r\nHost: now-package-broker\r\nConnection: close\r\n" + + $"Content-Type: application/json\r\nAccept: application/json\r\nContent-Length: {body.Length}\r\n\r\n"); + await pipe.WriteAsync(headers, cancellationToken); + await pipe.WriteAsync(body, cancellationToken); + await pipe.FlushAsync(cancellationToken); + + byte[] response = await ReadBoundedAsync( + pipe, + MaximumBrokerResponseBytes + MaximumHeaderBytes, + cancellationToken); + return ParseResponse(request.RequestId, response); + } + catch (OperationCanceledException) + { + return Unknown(request.RequestId, null, "Timeout"); + } + catch (IOException) + { + return Unknown(request.RequestId, null, "BrokerUnavailable"); + } + catch (JsonException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (BrokerResponseException error) + { + return Unknown(request.RequestId, error.StatusCode, "InvalidResponse"); + } + catch (InvalidOperationException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (ProtocolException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + } + + internal static byte[] CreateOfficialRequest(ElevationRequest request) + { + ArrayBufferWriter buffer = new(); + using Utf8JsonWriter writer = new(buffer); + writer.WriteStartObject(); + writer.WriteString("RequestKind", "PolicyReplacementRequest"); + writer.WriteString("RequestVersion", "1.0"); + writer.WriteString("ExpectedStoreToken", request.ExpectedStoreToken); + writer.WriteString("Operation", request.Operation); + writer.WriteString("ConflictHandling", request.ConflictHandling); + writer.WriteBoolean("WarningsAcknowledged", request.WarningsAcknowledged); + writer.WritePropertyName("Draft"); + request.Draft.WriteTo(writer); + writer.WriteString("ValidationReceipt", request.ValidationReceipt); + writer.WriteEndObject(); + writer.Flush(); + if (buffer.WrittenCount > 16_777_216) + { + throw new ProtocolException("official policy request exceeds broker limit"); + } + return buffer.WrittenSpan.ToArray(); + } + + internal static ElevationResponse ParseResponse(string requestId, byte[] response) + { + ReadOnlySpan delimiter = "\r\n\r\n"u8; + int headerEnd = response.AsSpan().IndexOf(delimiter); + if (headerEnd < 0 || headerEnd > MaximumHeaderBytes) + { + throw new BrokerResponseException(null); + } + + string statusLine = Encoding.ASCII.GetString(response.AsSpan(0, headerEnd)).Split("\r\n", 2)[0]; + string[] statusParts = statusLine.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (statusParts.Length < 2 || !int.TryParse(statusParts[1], out int status)) + { + throw new BrokerResponseException(null); + } + + ReadOnlyMemory body = response.AsMemory(headerEnd + delimiter.Length); + if (body.IsEmpty) + { + return Unknown(requestId, status, "EmptyResponse"); + } + + try + { + return ParseResponseBody(requestId, status, body); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ProtocolException) + { + throw new BrokerResponseException(status, error); + } + } + + private static ElevationResponse ParseResponseBody( + string requestId, + int status, + ReadOnlyMemory body) + { + using JsonDocument document = JsonDocument.Parse(body); + JsonElement payload = document.RootElement; + if (status is >= 200 and <= 299) + { + RequireString(payload, "ResponseKind", "PolicyReplacementResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string token = RequireNestedString(payload, "Management", "StoreToken"); + ElevationResponse committed = new( + Protocol.Version, + requestId, + "Committed", + null, + null, + token, + null, + null, + null); + Protocol.ValidateResponse(committed); + return committed; + } + + RequireString(payload, "ResponseKind", "ErrorResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string code = RequireString(payload, "Code"); + string? conflictToken = null; + string? conflictState = null; + string? conflictPolicyId = null; + if (code == "StalePolicyStoreToken") + { + JsonElement management = RequireObject(payload, "Management"); + conflictToken = RequireString(management, "StoreToken"); + conflictState = RequireString(management, "State"); + if (conflictState == "Active") + { + JsonElement policy = RequireObject(management, "Policy"); + JsonElement metadata = RequireObject(policy, "Metadata"); + conflictPolicyId = RequireString(metadata, "Id"); + } + } + ElevationResponse rejected = new( + Protocol.Version, + requestId, + "Rejected", + status, + Truncate(code, 64), + null, + conflictToken, + conflictState, + conflictPolicyId); + Protocol.ValidateResponse(rejected); + return rejected; + } + + private static void RequireString(JsonElement payload, string property, string expected) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() != expected) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + } + + private static string RequireString(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() is not { } result) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return result; + } + + private static string RequireNestedString(JsonElement payload, string parent, string property) => + RequireString(RequireObject(payload, parent), property); + + private static JsonElement RequireObject(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return value; + } + + private static async Task ReadBoundedAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + using MemoryStream response = new(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return response.ToArray(); + } + if (response.Length + read > maximum) + { + throw new InvalidOperationException("broker response exceeds limit"); + } + response.Write(buffer, 0, read); + } + } + + private static ElevationResponse Unknown(string requestId, int? statusCode, string errorCode) => + new(Protocol.Version, requestId, "Unknown", statusCode, errorCode, null, null, null, null); + + private static string Truncate(string value, int maximum) => + value.Length <= maximum ? value : value[..maximum]; + + private sealed class BrokerResponseException(int? statusCode, Exception? innerException = null) + : Exception("broker response contract mismatch", innerException) + { + internal int? StatusCode { get; } = statusCode; + } +} diff --git a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj new file mode 100644 index 000000000..5e5e414e4 --- /dev/null +++ b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj @@ -0,0 +1,23 @@ + + + Exe + net10.0-windows + win-x64 + true + true + true + true + false + enable + enable + true + app.manifest + DevolutionsAgentPolicyConsent + DevolutionsAgentPolicyConsent + Devolutions Agent Policy Consent + Devolutions Inc. + + + + + diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs new file mode 100644 index 000000000..846d151d1 --- /dev/null +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -0,0 +1,682 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace DevolutionsAgentPolicyConsent; + +internal sealed class PeerLease : IDisposable +{ + // SHA-256 digests of accepted UniGetUI signer SPKIs. Keep both keys during certificate rollover. + internal const string TransitionUiSignerSpkiSha256 = + PolicyConsentContract.TransitionUiSignerSpkiSha256; + internal const string CurrentUiSignerSpkiSha256 = + PolicyConsentContract.CurrentUiSignerSpkiSha256; + + internal const uint ProcessQueryLimitedInformation = 0x1000; + internal const uint ProcessQueryInformation = 0x0400; + internal const uint Synchronize = 0x0010_0000; + internal const uint GenericRead = 0x8000_0000; + internal const uint FileExecute = 0x20; + internal const uint FileShareRead = 0x1; + internal const uint OpenExisting = 3; + internal const int ProcessImageFileMapping = 44; + internal const uint StillActive = 259; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + private readonly int processId; + private readonly long createdUtcTicks; + private readonly uint sessionId; + + private PeerLease( + SafeProcessHandle process, + SafeFileHandle image, + int processId, + long createdUtcTicks, + uint sessionId) + { + this.process = process; + this.image = image; + this.processId = processId; + this.createdUtcTicks = createdUtcTicks; + this.sessionId = sessionId; + } + + internal static PeerLease Open(Arguments arguments) + { + SafeProcessHandle process = Native.OpenProcess( + ProcessQueryInformation | ProcessQueryLimitedInformation | Synchronize, + false, + arguments.ParentProcessId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + long created = CreationTime(process); + uint session = SessionId(arguments.ParentProcessId); + if (!MatchesProcessIdentity( + arguments, + arguments.ParentProcessId, + created, + session)) + { + throw new InvalidOperationException("parent process identity mismatch"); + } + + string path = ImagePath(process); + SafeFileHandle image = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + VerifyImageMapping(process, image); + VerifyImageMetadata(path, image); + using X509Certificate2 signer = VerifyAuthenticodeSigner(path, image, "parent image"); + VerifySigner(signer); + VerifyImageMapping(process, image); + EnsureActive(process); + return new PeerLease(process, image, arguments.ParentProcessId, created, session); + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal void VerifyConnectedServer(int serverProcessId) + { + Arguments expected = new(string.Empty, processId, createdUtcTicks, sessionId); + if (!MatchesProcessIdentity(expected, serverProcessId, CreationTime(process), SessionId(serverProcessId))) + { + throw new InvalidOperationException("connected server process identity mismatch"); + } + VerifyImageMapping(process, image); + EnsureActive(process); + } + + internal static bool IsAllowedSigner(string digest) => + FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || + FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + + internal static bool IsSupportedUiIdentity(string? productName, string? originalFilename, string? productVersion) => + string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && + string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && + productVersion is not null && + Version.TryParse(productVersion.Split('+', '-', StringSplitOptions.TrimEntries)[0], out Version? parsed) && + parsed >= new Version(3, 3, 7); + + internal static bool MatchesProcessIdentity( + Arguments expected, + int processId, + long createdUtcTicks, + uint sessionId) => + processId == expected.ParentProcessId && + createdUtcTicks == expected.ParentCreatedUtcTicks && + sessionId == expected.SessionId; + + public void Dispose() + { + image.Dispose(); + process.Dispose(); + } + + private static void VerifyImageMetadata(string path, SafeFileHandle retainedImage) + { + VerifyPathIdentity(path, retainedImage); + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException("parent image is a reparse point"); + } + + FileVersionInfo version = FileVersionInfo.GetVersionInfo(path); + if (!IsSupportedUiIdentity(version.ProductName, version.OriginalFilename, version.ProductVersion)) + { + throw new InvalidOperationException("parent image product identity mismatch"); + } + VerifyPathIdentity(path, retainedImage); + } + + private static void VerifyPathIdentity(string path, SafeFileHandle retainedImage) + { + using SafeFileHandle reopened = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (reopened.IsInvalid) + { + throw new Win32Exception(); + } + if (!SameFile(retainedImage, reopened)) + { + throw new InvalidOperationException("parent image path no longer identifies the retained image"); + } + } + + internal static bool SameFile(SafeFileHandle left, SafeFileHandle right) + { + if (!Native.GetFileInformationByHandle(left, out Native.ByHandleFileInformation leftInfo) || + !Native.GetFileInformationByHandle(right, out Native.ByHandleFileInformation rightInfo)) + { + throw new Win32Exception(); + } + return leftInfo.VolumeSerialNumber == rightInfo.VolumeSerialNumber && + leftInfo.FileIndexHigh == rightInfo.FileIndexHigh && + leftInfo.FileIndexLow == rightInfo.FileIndexLow; + } + + internal static bool IsLocalSystemProcess(SafeProcessHandle process) + { + if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) + { + throw new Win32Exception(); + } + using (token) + { + _ = Native.GetTokenInformation(token, 1, IntPtr.Zero, 0, out uint length); + if (length == 0) + { + throw new Win32Exception(); + } + + IntPtr information = Marshal.AllocHGlobal(checked((int)length)); + try + { + if (!Native.GetTokenInformation(token, 1, information, length, out _)) + { + throw new Win32Exception(); + } + IntPtr sid = Marshal.ReadIntPtr(information); + return Native.IsWellKnownSid(sid, 22); + } + finally + { + Marshal.FreeHGlobal(information); + } + } + } + + internal static X509Certificate2 VerifyAuthenticodeSigner( + string path, + SafeFileHandle image, + string subject) + { + Guid action = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + Native.WinTrustFileInfo file = new(path, image.DangerousGetHandle()); + IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + bool fileInitialized = false; + bool dataInitialized = false; + try + { + Marshal.StructureToPtr(file, filePointer, false); + fileInitialized = true; + Native.WinTrustData data = new(filePointer); + Marshal.StructureToPtr(data, dataPointer, false); + dataInitialized = true; + int status = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + if (status != 0) + { + throw new InvalidOperationException($"{subject} Authenticode validation failed (0x{status:X8})"); + } + data = Marshal.PtrToStructure(dataPointer); + IntPtr providerData = Native.WTHelperProvDataFromStateData(data.StateData); + IntPtr providerSigner = providerData == IntPtr.Zero + ? IntPtr.Zero + : Native.WTHelperGetProvSignerFromChain(providerData, 0, false, 0); + if (providerSigner == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode signer is unavailable"); + } + + Native.CryptProviderSigner signer = Marshal.PtrToStructure(providerSigner); + if (signer.CertificateChainCount == 0 || signer.CertificateChain == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode certificate chain is empty"); + } + Native.CryptProviderCertificate certificate = + Marshal.PtrToStructure(signer.CertificateChain); +#pragma warning disable SYSLIB0057 // WinVerifyTrust returns the certificate context for the exact retained image. + return new X509Certificate2(certificate.CertificateContext); +#pragma warning restore SYSLIB0057 + } + finally + { + if (dataInitialized) + { + Native.WinTrustData data = Marshal.PtrToStructure(dataPointer); + if (data.StateData != IntPtr.Zero) + { + data.StateAction = 2; + Marshal.StructureToPtr(data, dataPointer, true); + _ = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + } + Marshal.DestroyStructure(dataPointer); + } + if (fileInitialized) + { + Marshal.DestroyStructure(filePointer); + } + Marshal.FreeHGlobal(dataPointer); + Marshal.FreeHGlobal(filePointer); + } + } + + private static void VerifySigner(X509Certificate2 certificate) + { + byte[] subjectPublicKeyInfo; + using (RSA? rsa = certificate.GetRSAPublicKey()) + { + if (rsa is not null) + { + subjectPublicKeyInfo = rsa.ExportSubjectPublicKeyInfo(); + } + else + { + using ECDsa? ecdsa = certificate.GetECDsaPublicKey(); + subjectPublicKeyInfo = ecdsa?.ExportSubjectPublicKeyInfo() + ?? throw new InvalidOperationException("unsupported parent signer key"); + } + } + + string digest = Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo)).ToLowerInvariant(); + if (!IsAllowedSigner(digest)) + { + throw new InvalidOperationException("parent image signer is not authorized"); + } + } + + private static bool FixedTimeEqualsHex(string candidate, string expected) + { + if (candidate.Length != 64 || + expected.Length != 64 || + candidate.AsSpan().IndexOfAnyExcept("0123456789abcdef") >= 0) + { + return false; + } + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(candidate), + Convert.FromHexString(expected)); + } + catch (FormatException) + { + return false; + } + } + + internal static void VerifyImageMapping(SafeProcessHandle process, SafeFileHandle image) + { + IntPtr fileHandle = image.DangerousGetHandle(); + int status = Native.NtQueryInformationProcess( + process.DangerousGetHandle(), + ProcessImageFileMapping, + ref fileHandle, + IntPtr.Size, + out _); + if (status != 0) + { + throw new InvalidOperationException($"parent image mapping mismatch (0x{status:X8})"); + } + } + + internal static string ImagePath(SafeProcessHandle process) + { + int capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(capacity); + int length = capacity; + if (Native.QueryFullProcessImageName(process, 0, path, ref length)) + { + return path.ToString(); + } + if (Marshal.GetLastWin32Error() != 122) + { + throw new Win32Exception(); + } + capacity *= 2; + } + throw new InvalidOperationException("parent image path is too long"); + } + + private static long CreationTime(SafeProcessHandle process) + { + if (!Native.GetProcessTimes(process, out long created, out _, out _, out _)) + { + throw new Win32Exception(); + } + return DateTime.FromFileTimeUtc(created).Ticks; + } + + private static uint SessionId(int processId) + { + if (!Native.ProcessIdToSessionId(processId, out uint sessionId)) + { + throw new Win32Exception(); + } + return sessionId; + } + + internal static void EnsureActive(SafeProcessHandle process) + { + if (!Native.GetExitCodeProcess(process, out uint exitCode)) + { + throw new Win32Exception(); + } + if (exitCode != StillActive) + { + throw new InvalidOperationException("parent process exited"); + } + } +} + +internal sealed class BrokerServerLease : IDisposable +{ + private const string AgentExecutableName = "DevolutionsAgent.exe"; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + + private BrokerServerLease(SafeProcessHandle process, SafeFileHandle image) + { + this.process = process; + this.image = image; + } + + internal static BrokerServerLease Open(SafePipeHandle pipe) + { + if (!Native.GetNamedPipeServerProcessId(pipe, out int processId)) + { + throw new Win32Exception(); + } + + SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryLimitedInformation | PeerLease.Synchronize, + false, + processId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + if (!PeerLease.IsLocalSystemProcess(process)) + { + throw new InvalidOperationException("broker server is not running as LocalSystem"); + } + string helperPath = Environment.ProcessPath + ?? throw new InvalidOperationException("helper executable path is unavailable"); + string expectedPath = Path.Combine( + Path.GetDirectoryName(helperPath) + ?? throw new InvalidOperationException("helper installation directory is unavailable"), + AgentExecutableName); + string serverPath = PeerLease.ImagePath(process); + if (!IsExpectedPath(serverPath, expectedPath)) + { + throw new InvalidOperationException("broker server is not the installed Agent"); + } + + SafeFileHandle image = Native.CreateFile( + expectedPath, + PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + 0, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + using X509Certificate2 _ = + PeerLease.VerifyAuthenticodeSigner(expectedPath, image, "broker server"); + PeerLease.EnsureActive(process); + return new BrokerServerLease(process, image); + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal static bool IsExpectedPath(string actual, string expected) => + string.Equals( + Path.GetFullPath(actual), + Path.GetFullPath(expected), + StringComparison.OrdinalIgnoreCase); + + public void Dispose() + { + image.Dispose(); + process.Dispose(); + } +} + +internal static partial class Native +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal readonly struct WinTrustFileInfo + { + internal readonly uint StructSize; + [MarshalAs(UnmanagedType.LPWStr)] + internal readonly string FilePath; + internal readonly IntPtr FileHandle; + internal readonly IntPtr KnownSubject; + + internal WinTrustFileInfo(string filePath, IntPtr fileHandle) + { + StructSize = checked((uint)Marshal.SizeOf()); + FilePath = filePath; + FileHandle = fileHandle; + KnownSubject = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct WinTrustData + { + internal uint StructSize; + internal IntPtr PolicyCallbackData; + internal IntPtr SipClientData; + internal uint UiChoice; + internal uint RevocationChecks; + internal uint UnionChoice; + internal IntPtr FileInfo; + internal uint StateAction; + internal IntPtr StateData; + internal IntPtr UrlReference; + internal uint ProviderFlags; + internal uint UiContext; + internal IntPtr SignatureSettings; + + internal WinTrustData(IntPtr fileInfo) + { + StructSize = checked((uint)Marshal.SizeOf()); + PolicyCallbackData = IntPtr.Zero; + SipClientData = IntPtr.Zero; + UiChoice = 2; + RevocationChecks = 0; + UnionChoice = 1; + FileInfo = fileInfo; + StateAction = 1; + StateData = IntPtr.Zero; + UrlReference = IntPtr.Zero; + ProviderFlags = 0x1000 | 0x2000; + UiContext = 0; + SignatureSettings = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderSigner + { + internal readonly uint StructSize; + internal readonly NativeFileTime VerifyAsOf; + internal readonly uint CertificateChainCount; + internal readonly IntPtr CertificateChain; + internal readonly uint SignerType; + internal readonly IntPtr SignerInfo; + internal readonly uint Error; + internal readonly uint CounterSignerCount; + internal readonly IntPtr CounterSigners; + internal readonly IntPtr ChainContext; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct NativeFileTime + { + internal readonly uint LowDateTime; + internal readonly uint HighDateTime; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct ByHandleFileInformation + { + internal readonly uint FileAttributes; + internal readonly NativeFileTime CreationTime; + internal readonly NativeFileTime LastAccessTime; + internal readonly NativeFileTime LastWriteTime; + internal readonly uint VolumeSerialNumber; + internal readonly uint FileSizeHigh; + internal readonly uint FileSizeLow; + internal readonly uint NumberOfLinks; + internal readonly uint FileIndexHigh; + internal readonly uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderCertificate + { + internal readonly uint StructSize; + internal readonly IntPtr CertificateContext; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool QueryFullProcessImageName( + SafeProcessHandle process, + uint flags, + [Out] StringBuilder path, + ref int size); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetProcessTimes( + SafeProcessHandle process, + out long creation, + out long exit, + out long kernel, + out long user); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ProcessIdToSessionId(int processId, out uint sessionId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken( + SafeProcessHandle process, + uint desiredAccess, + out SafeAccessTokenHandle token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetTokenInformation( + SafeAccessTokenHandle token, + int informationClass, + IntPtr information, + uint informationLength, + out uint returnLength); + + [LibraryImport("advapi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsWellKnownSid(IntPtr sid, int wellKnownSidType); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeProcessHandle OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inherit, int processId); + + [LibraryImport("ntdll.dll")] + internal static partial int NtQueryInformationProcess( + IntPtr process, + int informationClass, + ref IntPtr information, + int informationLength, + out int returnLength); + + [LibraryImport("wintrust.dll", SetLastError = true)] + internal static partial int WinVerifyTrust(IntPtr window, ref Guid action, IntPtr data); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperProvDataFromStateData(IntPtr stateData); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperGetProvSignerFromChain( + IntPtr providerData, + uint signerIndex, + [MarshalAs(UnmanagedType.Bool)] bool counterSigner, + uint counterSignerIndex); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out int processId); +} diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs new file mode 100644 index 000000000..01789bbf5 --- /dev/null +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -0,0 +1,13 @@ +namespace DevolutionsAgentPolicyConsent +{ + internal static class PolicyConsentContract + { + internal const string ProtocolVersion = "2.0"; + internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; + internal const string ProductName = "Devolutions Agent Policy Consent"; + internal const string CurrentUiSignerSpkiSha256 = + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; + internal const string TransitionUiSignerSpkiSha256 = + "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; + } +} diff --git a/package/AgentPolicyConsent/Program.cs b/package/AgentPolicyConsent/Program.cs new file mode 100644 index 000000000..2ec726a4f --- /dev/null +++ b/package/AgentPolicyConsent/Program.cs @@ -0,0 +1,173 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Program +{ + private const int Success = 0; + private const int InvalidArguments = 10; + private const int ConnectionFailure = 11; + private const int PeerAuthenticationFailure = 12; + private const int ProtocolFailure = 13; + private const int UnexpectedFailure = 14; + + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return InvalidArguments; + } + + Arguments arguments; + try + { + arguments = Protocol.ParseArguments(args); + } + catch (ProtocolException) + { + return InvalidArguments; + } + + using CancellationTokenSource handshake = new(Protocol.ConnectTimeout); + PeerLease peer; + try + { + peer = await OpenPeerAsync(arguments, handshake.Token); + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch + { + return PeerAuthenticationFailure; + } + + using (peer) + using (NamedPipeClientStream pipe = new( + ".", + arguments.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous)) + { + try + { + await pipe.ConnectAsync(handshake.Token); + if (!Native.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out int serverProcessId)) + { + return PeerAuthenticationFailure; + } + peer.VerifyConnectedServer(serverProcessId); + byte[] body = await Protocol.ReadFrameAsync(pipe, Protocol.MaxRequestBodyBytes, handshake.Token); + ElevationRequest request = JsonSerializer.Deserialize(body, ProtocolJsonContext.Default.ElevationRequest) + ?? throw new ProtocolException("request is null"); + Protocol.ValidateRequest(request); + peer.VerifyConnectedServer(serverProcessId); + + using CancellationTokenSource exchange = new(Protocol.ExchangeTimeout); + using CancellationTokenSource brokerCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + using CancellationTokenSource monitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + Task monitor = MonitorHostAsync(pipe, brokerCancellation, monitorCancellation.Token); + ElevationResponse response = await BrokerClient.ReplaceAsync(request, brokerCancellation.Token); + monitorCancellation.Cancel(); + await IgnoreCancellationAsync(monitor); + Protocol.ValidateResponse(response); + + byte[] responseBody = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + using CancellationTokenSource responseWrite = new(Protocol.ResponseWriteTimeout); + await Protocol.WriteFrameAsync( + pipe, + responseBody, + Protocol.MaxResponseBodyBytes, + responseWrite.Token); + return Success; + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch (IOException) + { + return ConnectionFailure; + } + catch (ProtocolException) + { + return ProtocolFailure; + } + catch (JsonException) + { + return ProtocolFailure; + } + catch + { + return UnexpectedFailure; + } + } + } + + private static async Task OpenPeerAsync(Arguments arguments, CancellationToken cancellationToken) + { + Task open = Task.Run(() => PeerLease.Open(arguments)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw; + } + } + + private static async Task MonitorHostAsync( + Stream pipe, + CancellationTokenSource brokerCancellation, + CancellationToken cancellationToken) + { + byte[] unexpected = new byte[1]; + try + { + int read = await pipe.ReadAsync(unexpected, cancellationToken); + if (read is 0 or 1) + { + brokerCancellation.Cancel(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException) + { + brokerCancellation.Cancel(); + } + } + + private static async Task IgnoreCancellationAsync(Task task) + { + try + { + await task; + } + catch (OperationCanceledException) + { + } + } +} diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs new file mode 100644 index 000000000..04184f3dc --- /dev/null +++ b/package/AgentPolicyConsent/Protocol.cs @@ -0,0 +1,240 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Protocol +{ + internal const string Version = PolicyConsentContract.ProtocolVersion; + internal const string PipePrefix = "UniGetUI.PolicyElevation."; + internal const int MaxRequestBodyBytes = 16_793_054; + internal const int MaxResponseBodyBytes = 15_618; + internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan ExchangeTimeout = TimeSpan.FromMinutes(2); + internal static readonly TimeSpan ResponseWriteTimeout = TimeSpan.FromSeconds(10); + + internal static Arguments ParseArguments(string[] args) + { + if (args.Length != 10) + { + throw new ProtocolException("invalid argument count"); + } + + Dictionary values = new(StringComparer.Ordinal); + for (int index = 0; index < args.Length; index += 2) + { + if (!values.TryAdd(args[index], args[index + 1])) + { + throw new ProtocolException("duplicate argument"); + } + } + + string protocol = Required(values, "--protocol"); + string pipe = Required(values, "--pipe"); + if (protocol != Version || + !pipe.StartsWith(PipePrefix, StringComparison.Ordinal) || + !IsLowerHex(pipe.AsSpan(PipePrefix.Length), 32) || + !int.TryParse( + Required(values, "--parent-pid"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int parentPid) || + parentPid <= 0 || + !long.TryParse( + Required(values, "--parent-created"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out long parentCreated) || + parentCreated <= 0 || + !uint.TryParse( + Required(values, "--session"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint session) || + values.Count != 5) + { + throw new ProtocolException("invalid argument value"); + } + + return new Arguments(pipe, parentPid, parentCreated, session); + } + + internal static void ValidateRequest(ElevationRequest request) + { + if (request.ProtocolVersion != Version || + !IsLowerHex(request.RequestId.AsSpan(), 32) || + request.Operation is not ("Update" or "ReplaceIdentity" or "Create" or "Repair") || + request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || + !IsCredential(request.ExpectedStoreToken, 512) || + !IsCredential(request.ValidationReceipt, 2048) || + request.Draft.ValueKind != JsonValueKind.Object) + { + throw new ProtocolException("invalid request"); + } + } + + internal static void ValidateResponse(ElevationResponse response) + { + if (response.ProtocolVersion != Version || + !IsLowerHex(response.RequestId.AsSpan(), 32) || + response.Disposition is not ("Committed" or "Rejected" or "Unknown") || + !IsOptionalCredential(response.BrokerErrorCode, 64)) + { + throw new ProtocolException("invalid response"); + } + + bool hasConflict = + response.ConflictStoreToken is not null || + response.ConflictState is not null || + response.ConflictPolicyId is not null; + switch (response.Disposition) + { + case "Committed" when + response.BrokerStatusCode is null && + response.BrokerErrorCode is null && + IsCredential(response.CommittedStoreToken, 512) && + !hasConflict: + return; + case "Rejected" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null: + ValidateConflict(response, hasConflict); + return; + case "Unknown" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null && + !hasConflict: + return; + default: + throw new ProtocolException("invalid response shape"); + } + } + + internal static async Task ReadFrameAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + byte[] header = new byte[4]; + await ReadExactlyAsync(stream, header, cancellationToken); + uint length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length == 0 || length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] body = GC.AllocateUninitializedArray(checked((int)length)); + await ReadExactlyAsync(stream, body, cancellationToken); + return body; + } + + internal static async Task WriteFrameAsync( + Stream stream, + ReadOnlyMemory body, + int maximum, + CancellationToken cancellationToken) + { + if (body.IsEmpty || body.Length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)body.Length)); + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) + { + int offset = 0; + while (offset < buffer.Length) + { + int read = await stream.ReadAsync(buffer[offset..], cancellationToken); + if (read == 0) + { + throw new EndOfStreamException("unexpected end of elevation frame"); + } + offset += read; + } + } + + private static string Required(Dictionary values, string key) => + values.TryGetValue(key, out string? value) && value.Length != 0 + ? value + : throw new ProtocolException("missing argument"); + + private static bool IsLowerHex(ReadOnlySpan value, int length) => + value.Length == length && value.IndexOfAnyExcept("0123456789abcdef") < 0; + + internal static bool IsCredential(string? value, int maximum) => + value is not null && + value.Length is > 0 && + value.Length <= maximum && + IsAsciiAlphaNumeric(value[0]) && + value.AsSpan(1).IndexOfAnyExceptInRange('!', '~') < 0; + + private static bool IsOptionalCredential(string? value, int maximum) => + value is null || IsCredential(value, maximum); + + private static void ValidateConflict(ElevationResponse response, bool hasConflict) + { + if (response.BrokerErrorCode != "StalePolicyStoreToken") + { + if (hasConflict) + { + throw new ProtocolException("non-stale response carries conflict fields"); + } + return; + } + + if (!IsCredential(response.ConflictStoreToken, 512) || + response.ConflictState is not ("Active" or "Missing" or "Invalid") || + (response.ConflictState == "Active" + ? !IsCredential(response.ConflictPolicyId, 2048) + : response.ConflictPolicyId is not null)) + { + throw new ProtocolException("invalid stale conflict"); + } + } + + private static bool IsAsciiAlphaNumeric(char value) => + value is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z'; +} + +internal sealed record Arguments(string PipeName, int ParentProcessId, long ParentCreatedUtcTicks, uint SessionId); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationRequest( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Operation, + [property: JsonRequired] string ConflictHandling, + [property: JsonRequired] string ExpectedStoreToken, + [property: JsonRequired] string ValidationReceipt, + [property: JsonRequired] bool WarningsAcknowledged, + [property: JsonRequired] JsonElement Draft); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationResponse( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Disposition, + [property: JsonRequired] int? BrokerStatusCode, + [property: JsonRequired] string? BrokerErrorCode, + [property: JsonRequired] string? CommittedStoreToken, + [property: JsonRequired] string? ConflictStoreToken, + [property: JsonRequired] string? ConflictState, + [property: JsonRequired] string? ConflictPolicyId); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ElevationRequest))] +[JsonSerializable(typeof(ElevationResponse))] +internal sealed partial class ProtocolJsonContext : JsonSerializerContext; + +internal sealed class ProtocolException(string message) : Exception(message); diff --git a/package/AgentPolicyConsent/app.manifest b/package/AgentPolicyConsent/app.manifest new file mode 100644 index 000000000..d303ea813 --- /dev/null +++ b/package/AgentPolicyConsent/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs new file mode 100644 index 000000000..a17e10bef --- /dev/null +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -0,0 +1,45 @@ +using System; +using System.Reflection; + +using WixSharp; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class PolicyConsentDiscoveryTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DiscoveryIsTransactionalAndArchitectureCorrect(bool win64) + { + RegValue value = CreateDiscoveryValue("ProtocolVersion", "2.0", win64); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"Software\Devolutions\Agent\PolicyConsentHelper", value.Key); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Equal(win64, value.Win64); + } + + [Fact] + public void DiscoveryPublishesTheFixedHelperIdentity() + { + RegValue value = CreateDiscoveryValue( + "ExecutablePath", + "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", + true); + + Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", value.Value); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + } + + private static RegValue CreateDiscoveryValue(string name, string value, bool win64) + { + Type program = Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreatePolicyConsentRegistryValue", + BindingFlags.Static | BindingFlags.NonPublic); + return Assert.IsType(method.Invoke(null, [name, value, win64])); + } +} diff --git a/package/AgentWindowsManaged/DevolutionsAgent.csproj b/package/AgentWindowsManaged/DevolutionsAgent.csproj index 527dd4bd4..6df8d0103 100644 --- a/package/AgentWindowsManaged/DevolutionsAgent.csproj +++ b/package/AgentWindowsManaged/DevolutionsAgent.csproj @@ -6,6 +6,7 @@ latest + diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 09a32b242..83ded8e51 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -77,6 +77,10 @@ private static string ResolveArtifact(string varName, string defaultPath = null) private static string DevolutionsAgentExePath => ResolveArtifact("DAGENT_EXECUTABLE", "..\\..\\target\\debug\\devolutions-agent.exe"); + private static string DevolutionsAgentPolicyConsentPath => ResolveArtifact( + "DAGENT_POLICY_CONSENT_HELPER", + "..\\AgentPolicyConsent\\bin\\Release\\net10.0-windows\\win-x64\\publish\\DevolutionsAgentPolicyConsent.exe"); + private static string DevolutionsDesktopAgentPath { // ReSharper disable once ArrangeAccessorOwnerBody @@ -123,6 +127,12 @@ private static Version DevolutionsAgentVersion } } + // The MSI version drops the "20" prefix; discovery restores the calendar-year product version. + private static Version DevolutionsAgentProductVersion => new( + DevolutionsAgentVersion.Major + 2000, + DevolutionsAgentVersion.Minor, + DevolutionsAgentVersion.Build); + private static WixSharp.Platform TargetPlatform { get @@ -298,6 +308,10 @@ static void Main() new (Features.AGENT_FEATURE, DevolutionsMultiPwshExe) { TargetFileName = "multi-pwsh.exe" + }, + new (Features.AGENT_FEATURE, DevolutionsAgentPolicyConsentPath) + { + TargetFileName = Includes.POLICY_CONSENT_EXECUTABLE_NAME } }, Dirs = new[] @@ -325,6 +339,30 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, }, + CreatePolicyConsentRegistryValue( + "ProtocolVersion", + Includes.POLICY_CONSENT_PROTOCOL_VERSION, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ExecutableName", + Includes.POLICY_CONSENT_EXECUTABLE_NAME, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ExecutablePath", + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ProductName", + Includes.POLICY_CONSENT_PRODUCT_NAME, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ProductVersion", + DevolutionsAgentProductVersion.ToString(), + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "CurrentUiSignerSpkiSha256", + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, + Use64BitRegistryView(project.Platform)), new (RegistryHive.LocalMachine, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", Includes.SERVICE_NAME, $"[{AgentProperties.InstallDir}]{Includes.DESKTOP_DIRECTORY_NAME}\\{Includes.DESKTOP_EXECUTABLE_NAME}") { Win64 = project.Platform == Platform.x64, @@ -435,6 +473,23 @@ internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, }; + internal static RegValue CreatePolicyConsentRegistryValue(string name, string value, bool win64) => + new( + RegistryHive.LocalMachine, + $"Software\\{Includes.VENDOR_NAME}\\{Includes.SHORT_NAME}\\PolicyConsentHelper", + name, + value) + { + AttributesDefinition = "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + Feature = Features.AGENT_FEATURE, + }; + + // Discovery follows the consumer's native registry view. + internal static bool Use64BitRegistryView(Platform? platform) => + platform is Platform.x64 or Platform.arm64; + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 5ee9e12ae..284f8bef2 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -18,6 +18,18 @@ internal static class Includes internal static readonly string EXECUTABLE_NAME = "DevolutionsAgent.exe"; + internal static readonly string POLICY_CONSENT_EXECUTABLE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ExecutableName; + + internal static readonly string POLICY_CONSENT_PRODUCT_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProductName; + + internal static readonly string POLICY_CONSENT_PROTOCOL_VERSION = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProtocolVersion; + + internal static readonly string POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256 = + DevolutionsAgentPolicyConsent.PolicyConsentContract.CurrentUiSignerSpkiSha256; + internal static readonly string DESKTOP_DIRECTORY_NAME = "desktop"; internal static readonly string DESKTOP_EXECUTABLE_NAME = "DevolutionsDesktopAgent.exe"; From 089c76bf9563b8cd44395937cfa17543a0468eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 16:10:01 -0400 Subject: [PATCH 29/53] fix(agent): enforce signer revocation checks Require fresh whole-chain WinTrust revocation status before authorizing the UniGetUI parent or Agent broker. Bound broker trust retrieval to the existing exchange timeout and reject all pre-dispatch authentication failures. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/package-agent-windows.ps1 | 2 +- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 23 ++++++++++ package/AgentPolicyConsent/BrokerClient.cs | 44 ++++++++++++++++++- package/AgentPolicyConsent/PeerTrust.cs | 13 ++++-- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 10f0e7219..246cfe245 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -100,7 +100,7 @@ function New-AgentMsi() { # The path to the devolutions-session.exe file. [string] $SessionExe, [parameter(Mandatory = $true)] - # The path to the DevolutionsAgentPolicyConsent.exe file. + # The path to the signed DevolutionsAgentPolicyConsent.exe file. [string] $PolicyConsentHelper, [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 36456f288..e5e0a21ca 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -101,6 +101,29 @@ public void AuthenticodeSignerComesFromRetainedImageHandle() Assert.NotEmpty(certificate.RawData); } + [Fact] + public void AuthenticodeRequiresFreshWholeChainRevocation() + { + Native.WinTrustData data = new(IntPtr.Zero); + + Assert.Equal(Native.WtdRevokeWholeChain, data.RevocationChecks); + Assert.Equal( + Native.WtdRevocationCheckChain | Native.WtdDisableMd2Md4, + data.ProviderFlags); + Assert.Equal(0u, data.ProviderFlags & Native.WtdCacheOnlyUrlRetrieval); + } + + [Theory] + [InlineData(0, true)] + [InlineData(unchecked((int)0x800B010C), false)] + [InlineData(unchecked((int)0x80092012), false)] + [InlineData(unchecked((int)0x80092013), false)] + [InlineData(unchecked((int)0x800B010E), false)] + public void AuthenticodeFailsClosedForIndeterminateStatus(int status, bool accepted) + { + Assert.Equal(accepted, PeerLease.IsAuthenticodeStatusAccepted(status)); + } + [Fact] public void ProcessImageMappingAcceptsOnlyCurrentMappedImage() { diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index bdcbd5120..4a1d5affc 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -29,7 +29,7 @@ internal static async Task ReplaceAsync( using CancellationTokenSource connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); connectTimeout.CancelAfter(ConnectTimeout); await pipe.ConnectAsync(connectTimeout.Token); - using BrokerServerLease broker = BrokerServerLease.Open(pipe.SafePipeHandle); + using BrokerServerLease broker = await OpenBrokerServerAsync(pipe, cancellationToken); byte[] headers = Encoding.ASCII.GetBytes( $"PUT /v1/policy HTTP/1.1\r\nHost: now-package-broker\r\nConnection: close\r\n" + @@ -60,6 +60,10 @@ internal static async Task ReplaceAsync( { return Unknown(request.RequestId, error.StatusCode, "InvalidResponse"); } + catch (BrokerAuthenticationException) + { + return Rejected(request.RequestId, "Unauthorized"); + } catch (InvalidOperationException) { return Unknown(request.RequestId, null, "InvalidResponse"); @@ -239,6 +243,41 @@ private static async Task ReadBoundedAsync(Stream stream, int maximum, C private static ElevationResponse Unknown(string requestId, int? statusCode, string errorCode) => new(Protocol.Version, requestId, "Unknown", statusCode, errorCode, null, null, null, null); + private static ElevationResponse Rejected(string requestId, string errorCode) => + new(Protocol.Version, requestId, "Rejected", null, errorCode, null, null, null, null); + + private static async Task OpenBrokerServerAsync( + NamedPipeClientStream pipe, + CancellationToken cancellationToken) + { + Task open = Task.Run(() => BrokerServerLease.Open(pipe.SafePipeHandle)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch (Exception error) + { + DisposeLateResult(open); + throw new BrokerAuthenticationException(error); + } + } + + private static void DisposeLateResult(Task open) + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + private static string Truncate(string value, int maximum) => value.Length <= maximum ? value : value[..maximum]; @@ -247,4 +286,7 @@ private sealed class BrokerResponseException(int? statusCode, Exception? innerEx { internal int? StatusCode { get; } = statusCode; } + + private sealed class BrokerAuthenticationException(Exception innerException) + : Exception("broker server authentication failed", innerException); } diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 846d151d1..502742540 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -242,7 +242,7 @@ internal static X509Certificate2 VerifyAuthenticodeSigner( Marshal.StructureToPtr(data, dataPointer, false); dataInitialized = true; int status = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); - if (status != 0) + if (!IsAuthenticodeStatusAccepted(status)) { throw new InvalidOperationException($"{subject} Authenticode validation failed (0x{status:X8})"); } @@ -289,6 +289,8 @@ internal static X509Certificate2 VerifyAuthenticodeSigner( } } + internal static bool IsAuthenticodeStatusAccepted(int status) => status == 0; + private static void VerifySigner(X509Certificate2 certificate) { byte[] subjectPublicKeyInfo; @@ -494,6 +496,11 @@ public void Dispose() internal static partial class Native { + internal const uint WtdRevokeWholeChain = 1; + internal const uint WtdRevocationCheckChain = 0x0000_0040; + internal const uint WtdCacheOnlyUrlRetrieval = 0x0000_1000; + internal const uint WtdDisableMd2Md4 = 0x0000_2000; + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal readonly struct WinTrustFileInfo { @@ -535,13 +542,13 @@ internal WinTrustData(IntPtr fileInfo) PolicyCallbackData = IntPtr.Zero; SipClientData = IntPtr.Zero; UiChoice = 2; - RevocationChecks = 0; + RevocationChecks = WtdRevokeWholeChain; UnionChoice = 1; FileInfo = fileInfo; StateAction = 1; StateData = IntPtr.Zero; UrlReference = IntPtr.Zero; - ProviderFlags = 0x1000 | 0x2000; + ProviderFlags = WtdRevocationCheckChain | WtdDisableMd2Md4; UiContext = 0; SignatureSettings = IntPtr.Zero; } From 2ace003af74ec93072fc8c0b5f908282121beffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 10 Sep 2026 00:57:16 -0400 Subject: [PATCH 30/53] fix(agent): bind broker trust to running image Authenticate the connected Agent through its retained mapped image and an allowed Devolutions signer before sending policy data. Pin the protected installation directory chain against reparse and untrusted-writer replacement, including custom install locations, and record the evidence behind both UniGetUI signer pins. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 44 ++++ package/AgentPolicyConsent/PeerTrust.cs | 236 +++++++++++++++++- .../PolicyConsentContract.cs | 20 ++ 3 files changed, 288 insertions(+), 12 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index e5e0a21ca..07a87266e 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Security.AccessControl; using System.Text.Json; using DevolutionsAgentPolicyConsent; using Microsoft.Win32.SafeHandles; @@ -41,6 +42,46 @@ public void LookalikeSignerIsRejected() Assert.False(PeerLease.IsAllowedSigner(new string('0', 64))); } + [Fact] + public void AgentSignerRequiresKnownDevolutionsCertificate() + { + Assert.All( + PolicyConsentContract.DevolutionsSignerSha1Thumbprints, + thumbprint => Assert.True(PeerLease.IsAllowedDevolutionsSigner(thumbprint))); + Assert.False(PeerLease.IsAllowedDevolutionsSigner(new string('0', 40))); + } + + [Theory] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x1200A9;;;BU)", PeerLease.FileTamperRights, true)] + [InlineData("O:BUG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;GW;;;BU)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.ParentDirectoryTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.AncestorDirectoryTamperRights, true)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x40;;;BU)", PeerLease.AncestorDirectoryTamperRights, false)] + public void ProtectedAgentPathRequiresTrustedOwnerAndWriters( + string sddl, + int tamperRights, + bool accepted) + { + RawSecurityDescriptor descriptor = new(sddl); + if (accepted) + { + PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights); + } + else + { + Assert.Throws( + () => PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights)); + } + } + + [Fact] + public void ProtectedAgentPathRejectsReparsePoints() + { + Assert.True(PeerLease.IsReparsePoint(PeerLease.FileAttributeReparsePoint)); + Assert.False(PeerLease.IsReparsePoint(0)); + } + [Fact] public void SignerMatchingIsCaseSensitive() { @@ -90,6 +131,9 @@ public void BrokerServerRequiresExactAgentSiblingPath() Assert.False(BrokerServerLease.IsExpectedPath( @"C:\Users\Alice\DevolutionsAgent.exe", @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.True(BrokerServerLease.IsExpectedPath( + @"D:\Managed Apps\Agent\DevolutionsAgent.exe", + @"d:\managed apps\Agent\DevolutionsAgent.exe")); } [Fact] diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 502742540..9fc0eb204 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -2,8 +2,10 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; using System.Text; namespace DevolutionsAgentPolicyConsent; @@ -21,8 +23,14 @@ internal sealed class PeerLease : IDisposable internal const uint Synchronize = 0x0010_0000; internal const uint GenericRead = 0x8000_0000; internal const uint FileExecute = 0x20; + internal const uint FileReadAttributes = 0x80; + internal const uint ReadControl = 0x0002_0000; internal const uint FileShareRead = 0x1; + internal const uint FileShareWrite = 0x2; internal const uint OpenExisting = 3; + internal const uint FileAttributeReparsePoint = 0x400; + internal const uint FileFlagBackupSemantics = 0x0200_0000; + internal const uint FileFlagOpenReparsePoint = 0x0020_0000; internal const int ProcessImageFileMapping = 44; internal const uint StillActive = 259; @@ -122,6 +130,10 @@ internal static bool IsAllowedSigner(string digest) => FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + internal static bool IsAllowedDevolutionsSigner(string thumbprint) => + PolicyConsentContract.DevolutionsSignerSha1Thumbprints.Any( + expected => FixedTimeEqualsHex(thumbprint, expected, 40)); + internal static bool IsSupportedUiIdentity(string? productName, string? originalFilename, string? productVersion) => string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && @@ -315,10 +327,10 @@ private static void VerifySigner(X509Certificate2 certificate) } } - private static bool FixedTimeEqualsHex(string candidate, string expected) + private static bool FixedTimeEqualsHex(string candidate, string expected, int length = 64) { - if (candidate.Length != 64 || - expected.Length != 64 || + if (candidate.Length != length || + expected.Length != length || candidate.AsSpan().IndexOfAnyExcept("0123456789abcdef") >= 0) { return false; @@ -350,6 +362,107 @@ internal static void VerifyImageMapping(SafeProcessHandle process, SafeFileHandl } } + internal static void VerifyProtectedPath(SafeFileHandle handle, string subject, int tamperRights) + { + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (IsReparsePoint(information.FileAttributes)) + { + throw new InvalidOperationException($"{subject} is a reparse point"); + } + + uint error = Native.GetSecurityInfo( + handle, + 1, + 0x1 | 0x4, + out _, + out _, + out _, + out _, + out IntPtr securityDescriptor); + if (error != 0) + { + throw new Win32Exception(checked((int)error)); + } + + try + { + int length = checked((int)Native.GetSecurityDescriptorLength(securityDescriptor)); + byte[] bytes = new byte[length]; + Marshal.Copy(securityDescriptor, bytes, 0, length); + VerifyTrustedSecurityDescriptor(new RawSecurityDescriptor(bytes, 0), subject, tamperRights); + } + finally + { + _ = Native.LocalFree(securityDescriptor); + } + } + + internal static bool IsReparsePoint(uint attributes) => + (attributes & FileAttributeReparsePoint) != 0; + + internal const int FileTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0100 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int ParentDirectoryTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0040 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int AncestorDirectoryTamperRights = + 0x0000_0040 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000; + + internal static void VerifyTrustedSecurityDescriptor( + RawSecurityDescriptor descriptor, + string subject, + int tamperRights) + { + if (descriptor.Owner is not SecurityIdentifier owner || !IsTrustedWriter(owner)) + { + throw new InvalidOperationException($"{subject} has an untrusted owner"); + } + if (!descriptor.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclPresent) || + descriptor.DiscretionaryAcl is not { Count: > 0 } dacl) + { + throw new InvalidOperationException($"{subject} has no protective DACL"); + } + + foreach (GenericAce generic in dacl) + { + if (generic.AceFlags.HasFlag(AceFlags.InheritOnly) || !IsAccessAllowedAce(generic.AceType)) + { + continue; + } + if (generic is not QualifiedAce ace || ace is not KnownAce known) + { + throw new InvalidOperationException($"{subject} has an unsupported access-allowed entry"); + } + if ((known.AccessMask & tamperRights) == 0) + { + continue; + } + if (ace.SecurityIdentifier is null || !IsTrustedWriter(ace.SecurityIdentifier)) + { + throw new InvalidOperationException($"{subject} grants write access to an untrusted principal"); + } + } + } + + private static bool IsAccessAllowedAce(AceType type) => + type is AceType.AccessAllowed or + AceType.AccessAllowedCompound or + AceType.AccessAllowedObject or + AceType.AccessAllowedCallback or + AceType.AccessAllowedCallbackObject; + + private static bool IsTrustedWriter(SecurityIdentifier sid) => + sid.IsWellKnown(WellKnownSidType.LocalSystemSid) || + sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid) || + string.Equals( + sid.Value, + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + StringComparison.Ordinal); + internal static string ImagePath(SafeProcessHandle process) { int capacity = 260; @@ -407,11 +520,16 @@ internal sealed class BrokerServerLease : IDisposable private readonly SafeProcessHandle process; private readonly SafeFileHandle image; + private readonly List directories; - private BrokerServerLease(SafeProcessHandle process, SafeFileHandle image) + private BrokerServerLease( + SafeProcessHandle process, + SafeFileHandle image, + List directories) { this.process = process; this.image = image; + this.directories = directories; } internal static BrokerServerLease Open(SafePipeHandle pipe) @@ -422,7 +540,9 @@ internal static BrokerServerLease Open(SafePipeHandle pipe) } SafeProcessHandle process = Native.OpenProcess( - PeerLease.ProcessQueryLimitedInformation | PeerLease.Synchronize, + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, false, processId); if (process.IsInvalid) @@ -449,12 +569,15 @@ internal static BrokerServerLease Open(SafePipeHandle pipe) } SafeFileHandle image = Native.CreateFile( - expectedPath, - PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + serverPath, + PeerLease.GenericRead | + PeerLease.FileExecute | + PeerLease.ReadControl | + PeerLease.Synchronize, PeerLease.FileShareRead, IntPtr.Zero, PeerLease.OpenExisting, - 0, + PeerLease.FileFlagOpenReparsePoint, IntPtr.Zero); if (image.IsInvalid) { @@ -463,10 +586,37 @@ internal static BrokerServerLease Open(SafePipeHandle pipe) try { - using X509Certificate2 _ = - PeerLease.VerifyAuthenticodeSigner(expectedPath, image, "broker server"); - PeerLease.EnsureActive(process); - return new BrokerServerLease(process, image); + PeerLease.VerifyImageMapping(process, image); + PeerLease.VerifyProtectedPath(image, "broker server image", PeerLease.FileTamperRights); + using X509Certificate2 signer = + PeerLease.VerifyAuthenticodeSigner(serverPath, image, "broker server"); + string thumbprint = signer.GetCertHashString(HashAlgorithmName.SHA1).ToLowerInvariant(); + if (!PeerLease.IsAllowedDevolutionsSigner(thumbprint)) + { + throw new InvalidOperationException("broker server signer is not authorized"); + } + List? directories = RetainProtectedDirectories( + Path.GetDirectoryName(expectedPath) + ?? throw new InvalidOperationException("Agent installation directory is unavailable")); + try + { + PeerLease.VerifyImageMapping(process, image); + PeerLease.EnsureActive(process); + if (!Native.GetNamedPipeServerProcessId(pipe, out int confirmedProcessId) || + confirmedProcessId != processId) + { + throw new InvalidOperationException("broker server process changed during authentication"); + } + return new BrokerServerLease(process, image, directories); + } + catch + { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } + throw; + } } catch { @@ -489,9 +639,54 @@ internal static bool IsExpectedPath(string actual, string expected) => public void Dispose() { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } image.Dispose(); process.Dispose(); } + + private static List RetainProtectedDirectories(string installationDirectory) + { + List handles = []; + try + { + int tamperRights = PeerLease.ParentDirectoryTamperRights; + for (DirectoryInfo? directory = new(Path.GetFullPath(installationDirectory)); + directory is not null; + directory = directory.Parent) + { + SafeFileHandle handle = Native.CreateFile( + directory.FullName, + PeerLease.FileReadAttributes | PeerLease.ReadControl | PeerLease.Synchronize, + PeerLease.FileShareRead | PeerLease.FileShareWrite, + IntPtr.Zero, + PeerLease.OpenExisting, + PeerLease.FileFlagBackupSemantics | PeerLease.FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception(); + } + handles.Add(handle); + PeerLease.VerifyProtectedPath( + handle, + $"Agent installation directory '{directory.FullName}'", + tamperRights); + tamperRights = PeerLease.AncestorDirectoryTamperRights; + } + return handles; + } + catch + { + foreach (SafeFileHandle handle in handles) + { + handle.Dispose(); + } + throw; + } + } } internal static partial class Native @@ -649,6 +844,23 @@ internal static partial bool GetTokenInformation( [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool IsWellKnownSid(IntPtr sid, int wellKnownSidType); + [LibraryImport("advapi32.dll", SetLastError = true)] + internal static partial uint GetSecurityInfo( + SafeFileHandle handle, + uint objectType, + uint securityInformation, + out IntPtr owner, + out IntPtr group, + out IntPtr dacl, + out IntPtr sacl, + out IntPtr securityDescriptor); + + [LibraryImport("advapi32.dll")] + internal static partial uint GetSecurityDescriptorLength(IntPtr securityDescriptor); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr memory); + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] internal static partial SafeFileHandle CreateFile( string fileName, diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs index 01789bbf5..697d606d3 100644 --- a/package/AgentPolicyConsent/PolicyConsentContract.cs +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -5,9 +5,29 @@ internal static class PolicyConsentContract internal const string ProtocolVersion = "2.0"; internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; internal const string ProductName = "Devolutions Agent Policy Consent"; + + // UniGetUI 2026.2.7: subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; + // issuer CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE; + // serial 73D3C33603FF8BB44224F25E, SHA-1 8DB5A43BB8AFE4D2FFB92DA9007D8997A4CC4E13, + // valid 2023-10-30T17:51:18Z through 2026-10-30T17:51:18Z. internal const string CurrentUiSignerSpkiSha256 = "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; + + // UniGetUI 3.3.7: subject CN="Open Source Developer, Martí Climent López", + // O=Open Source Developer, C=ES; issuer CN=Certum Code Signing 2021 CA, + // O=Asseco Data Systems S.A., C=PL; + // serial 1AC2CAA58AF100E402D9812002C08B30, SHA-1 28949703053434989162B12C101497DE35FE4E8E, + // valid 2025-06-24T18:02:38Z through 2026-06-24T18:02:37Z. + // Remove this transition pin when the minimum supported UniGetUI version postdates its last signed release. internal const string TransitionUiSignerSpkiSha256 = "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; + + // Keep synchronized with devolutions-agent-shared/src/windows/code_signing.rs. + internal static readonly string[] DevolutionsSignerSha1Thumbprints = + [ + "3f5202a9432d54293bdfe6f7e46adb0a6f8b3ba6", + "8db5a43bb8afe4d2ffb92da9007d8997a4cc4e13", + "50f753333811ff11f1920274afde3ffd4468b210", + ]; } } From 7160af7520b8fd1b2f905c4fe94743bd7da1683f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 10 Sep 2026 01:52:23 -0400 Subject: [PATCH 31/53] fix(agent,agent-installer): correct helper packaging Preserve timeout responses during broker authentication, accept prerelease UniGetUI versions, and suppress the helper console window. Mark ARM64 discovery registry components as 64-bit so WiX can package them beneath ProgramFiles64Folder. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package/AgentPolicyConsent.Tests/ProtocolTests.cs | 1 + package/AgentPolicyConsent/BrokerClient.cs | 5 +++++ .../AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj | 2 +- package/AgentPolicyConsent/PeerTrust.cs | 2 +- package/AgentWindowsManaged/Program.cs | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 07a87266e..f3bdbb5ff 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -91,6 +91,7 @@ public void SignerMatchingIsCaseSensitive() [Theory] [InlineData("3.3.7")] [InlineData("2026.2.7")] + [InlineData("2026.2.7-preview")] public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) { Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index 4a1d5affc..1dfe80305 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -255,6 +255,11 @@ private static async Task OpenBrokerServerAsync( { return await open.WaitAsync(cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + DisposeLateResult(open); + throw; + } catch (Exception error) { DisposeLateResult(open); diff --git a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj index 5e5e414e4..a8257d4dc 100644 --- a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj +++ b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj @@ -1,6 +1,6 @@ - Exe + WinExe net10.0-windows win-x64 true diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 9fc0eb204..2948e109e 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -138,7 +138,7 @@ internal static bool IsSupportedUiIdentity(string? productName, string? original string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && productVersion is not null && - Version.TryParse(productVersion.Split('+', '-', StringSplitOptions.TrimEntries)[0], out Version? parsed) && + Version.TryParse(productVersion.Split(['+', '-'], StringSplitOptions.TrimEntries)[0], out Version? parsed) && parsed >= new Version(3, 3, 7); internal static bool MatchesProcessIdentity( diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 83ded8e51..707b1e611 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -480,7 +480,7 @@ internal static RegValue CreatePolicyConsentRegistryValue(string name, string va name, value) { - AttributesDefinition = "Type=string", + AttributesDefinition = win64 ? "Type=string; Component:Win64=yes" : "Type=string", Win64 = win64, RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, Feature = Features.AGENT_FEATURE, From 04371ab02f09cc1a1c4ebcb5a45ee7b2ca410b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 10 Sep 2026 02:04:59 -0400 Subject: [PATCH 32/53] fix(agent): validate policy credentials Apply the canonical policy API safe-ASCII character set to helper credentials before a privileged request is dispatched. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 18 +++++++++++++++--- package/AgentPolicyConsent/Protocol.cs | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index f3bdbb5ff..9b4aafaee 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -356,6 +356,18 @@ public void RequestRejectsUnknownOperations(string operation, string conflictHan Assert.Throws(() => Protocol.ValidateRequest(request)); } + [Theory] + [InlineData("a", true)] + [InlineData("A0._~:-", true)] + [InlineData("a/b", false)] + [InlineData("a b", false)] + [InlineData("a\"b", false)] + [InlineData("-token", false)] + public void CredentialsMatchOfficialPolicyApiCharacterRules(string value, bool accepted) + { + Assert.Equal(accepted, Protocol.IsCredential(value, 512)); + } + [Fact] public void CommittedResponseContainsOnlyStoreToken() { @@ -424,7 +436,7 @@ public void ResponseRequiresExplicitNullableMembers() } [Fact] - public void MaximumStaleResponseFitsExactWireBudget() + public void MaximumValidStaleResponseFitsWireBudget() { ElevationResponse response = new( "2.0", @@ -433,9 +445,9 @@ public void MaximumStaleResponseFitsExactWireBudget() 409, "StalePolicyStoreToken", null, - "T" + new string('"', 511), + "T" + new string('~', 511), "Active", - "P" + new string('"', 2047)); + "P" + new string('~', 2047)); Protocol.ValidateResponse(response); byte[] body = JsonSerializer.SerializeToUtf8Bytes( diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs index 04184f3dc..a9057db90 100644 --- a/package/AgentPolicyConsent/Protocol.cs +++ b/package/AgentPolicyConsent/Protocol.cs @@ -172,7 +172,7 @@ value is not null && value.Length is > 0 && value.Length <= maximum && IsAsciiAlphaNumeric(value[0]) && - value.AsSpan(1).IndexOfAnyExceptInRange('!', '~') < 0; + value.AsSpan(1).IndexOfAnyExcept("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~:-") < 0; private static bool IsOptionalCredential(string? value, int maximum) => value is null || IsCredential(value, maximum); From a17351d4d0498a21d22f7a117fd2586244dee3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 10:02:23 +0900 Subject: [PATCH 33/53] fix(agent,agent-installer): require current UI signer Accept only current-signed UniGetUI hosts from version 2026.2.7. Remove transition signer discovery while retaining protected helper authorization and ARM64 installer coverage. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 9 +++-- package/AgentPolicyConsent/PeerTrust.cs | 8 +--- .../PolicyConsentContract.cs | 12 +----- .../PolicyConsentDiscoveryTests.cs | 37 +++++++++++++++++-- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 9b4aafaee..e1d0a0a4d 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -30,10 +30,11 @@ public void ArgumentsRequireExactBoundIdentity() public sealed class TrustPolicyTests { [Fact] - public void CurrentAndTransitionSignersAreAccepted() + public void OnlyTheCurrentSignerIsAccepted() { Assert.True(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256)); - Assert.True(PeerLease.IsAllowedSigner(PeerLease.TransitionUiSignerSpkiSha256)); + Assert.False(PeerLease.IsAllowedSigner( + "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab")); } [Fact] @@ -89,10 +90,9 @@ public void SignerMatchingIsCaseSensitive() } [Theory] - [InlineData("3.3.7")] [InlineData("2026.2.7")] [InlineData("2026.2.7-preview")] - public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) + public void ProductBindingSupportsCurrentSignedHosts(string version) { Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); } @@ -101,6 +101,7 @@ public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string versi [InlineData("Lookalike", "UniGetUI.dll", "2026.2.7")] [InlineData("UniGetUI", "malware.exe", "2026.2.7")] [InlineData("UniGetUI", "UniGetUI.dll", "3.3.6")] + [InlineData("UniGetUI", "UniGetUI.dll", "2026.2.6")] public void ProductBindingRejectsLookalikes(string product, string originalFilename, string version) { Assert.False(PeerLease.IsSupportedUiIdentity(product, originalFilename, version)); diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 2948e109e..812652fae 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -12,9 +12,6 @@ namespace DevolutionsAgentPolicyConsent; internal sealed class PeerLease : IDisposable { - // SHA-256 digests of accepted UniGetUI signer SPKIs. Keep both keys during certificate rollover. - internal const string TransitionUiSignerSpkiSha256 = - PolicyConsentContract.TransitionUiSignerSpkiSha256; internal const string CurrentUiSignerSpkiSha256 = PolicyConsentContract.CurrentUiSignerSpkiSha256; @@ -127,8 +124,7 @@ internal void VerifyConnectedServer(int serverProcessId) } internal static bool IsAllowedSigner(string digest) => - FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || - FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256); internal static bool IsAllowedDevolutionsSigner(string thumbprint) => PolicyConsentContract.DevolutionsSignerSha1Thumbprints.Any( @@ -139,7 +135,7 @@ internal static bool IsSupportedUiIdentity(string? productName, string? original string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && productVersion is not null && Version.TryParse(productVersion.Split(['+', '-'], StringSplitOptions.TrimEntries)[0], out Version? parsed) && - parsed >= new Version(3, 3, 7); + parsed >= new Version(2026, 2, 7); internal static bool MatchesProcessIdentity( Arguments expected, diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs index 697d606d3..d4d0958cd 100644 --- a/package/AgentPolicyConsent/PolicyConsentContract.cs +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -6,22 +6,14 @@ internal static class PolicyConsentContract internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; internal const string ProductName = "Devolutions Agent Policy Consent"; - // UniGetUI 2026.2.7: subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; + // Supported UniGetUI hosts are version 2026.2.7 or newer and must carry this signer: + // subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; // issuer CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE; // serial 73D3C33603FF8BB44224F25E, SHA-1 8DB5A43BB8AFE4D2FFB92DA9007D8997A4CC4E13, // valid 2023-10-30T17:51:18Z through 2026-10-30T17:51:18Z. internal const string CurrentUiSignerSpkiSha256 = "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; - // UniGetUI 3.3.7: subject CN="Open Source Developer, Martí Climent López", - // O=Open Source Developer, C=ES; issuer CN=Certum Code Signing 2021 CA, - // O=Asseco Data Systems S.A., C=PL; - // serial 1AC2CAA58AF100E402D9812002C08B30, SHA-1 28949703053434989162B12C101497DE35FE4E8E, - // valid 2025-06-24T18:02:38Z through 2026-06-24T18:02:37Z. - // Remove this transition pin when the minimum supported UniGetUI version postdates its last signed release. - internal const string TransitionUiSignerSpkiSha256 = - "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; - // Keep synchronized with devolutions-agent-shared/src/windows/code_signing.rs. internal static readonly string[] DevolutionsSignerSha1Thumbprints = [ diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index a17e10bef..83a7a1210 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -20,23 +20,52 @@ public void DiscoveryIsTransactionalAndArchitectureCorrect(bool win64) Assert.Equal(@"Software\Devolutions\Agent\PolicyConsentHelper", value.Key); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); Assert.Equal(win64, value.Win64); + Assert.Equal( + win64 ? "Type=string; Component:Win64=yes" : "Type=string", + value.AttributesDefinition); + } + + [Theory] + [InlineData(Platform.x86, false)] + [InlineData(Platform.x64, true)] + [InlineData(Platform.arm64, true)] + public void DiscoveryUsesTheConsumerNativeRegistryView(Platform platform, bool expected) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "Use64BitRegistryView", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.Equal(expected, Assert.IsType(method.Invoke(null, [platform]))); } [Fact] public void DiscoveryPublishesTheFixedHelperIdentity() { - RegValue value = CreateDiscoveryValue( + RegValue executablePath = CreateDiscoveryValue( "ExecutablePath", "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", true); + RegValue signer = CreateDiscoveryValue( + "CurrentUiSignerSpkiSha256", + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + true); - Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", value.Value); - Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", executablePath.Value); + Assert.Equal( + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + signer.Value); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, executablePath.RegistryKeyAction); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, signer.RegistryKeyAction); } private static RegValue CreateDiscoveryValue(string name, string value, bool win64) { - Type program = Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); MethodInfo method = program.GetMethod( "CreatePolicyConsentRegistryValue", BindingFlags.Static | BindingFlags.NonPublic); From 85ca00618d232bcd283e984410d78a0c2dc84c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 01:36:11 +0900 Subject: [PATCH 34/53] fix(agent,agent-installer): discover broker pipe Publish the configured broker pipe through the protected helper discovery key so consent writes work with custom local pipe names. Reject malformed discovery and oversized requests before dispatch. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + devolutions-agent/Cargo.toml | 1 + devolutions-agent/src/service.rs | 63 +++++++++++++++++-- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 50 +++++++++++++++ package/AgentPolicyConsent/BrokerClient.cs | 49 +++++++++++++-- .../PolicyConsentContract.cs | 1 + package/AgentPolicyConsent/Protocol.cs | 6 +- .../PolicyConsentDiscoveryTests.cs | 6 ++ package/AgentWindowsManaged/Program.cs | 4 ++ .../AgentWindowsManaged/Resources/Includes.cs | 3 + 10 files changed, 173 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cc021554..b29f1820d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1791,6 +1791,7 @@ dependencies = [ "uuid", "win-api-wrappers", "windows 0.61.3", + "windows-registry 0.5.3", "x509-parser", ] diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index 2be961bb6..059b57741 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -93,6 +93,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls- thiserror = "2" uuid = { version = "1.17", features = ["v4"] } win-api-wrappers = { path = "../crates/win-api-wrappers" } +windows-registry = "0.5" [target.'cfg(windows)'.dependencies.windows] version = "0.61" diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 6739ea391..46a59f4fe 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -21,10 +21,16 @@ use now_package_broker::pipe::DEFAULT_PIPE_NAME; use now_package_broker::task::{BrokerTask, BrokerTaskConfig}; use tokio::runtime::{self, Runtime}; use tokio::sync::mpsc; +#[cfg(windows)] +use windows_registry::{Key, LOCAL_MACHINE}; pub(crate) const SERVICE_NAME: &str = "devolutions-agent"; pub(crate) const DISPLAY_NAME: &str = "Devolutions Agent"; pub(crate) const DESCRIPTION: &str = "Devolutions Agent service"; +#[cfg(windows)] +const POLICY_CONSENT_DISCOVERY_KEY: &str = r"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; +#[cfg(windows)] +const POLICY_CONSENT_BROKER_PIPE_NAME_VALUE: &str = "BrokerPipeName"; struct TasksCtx { /// Spawned service tasks @@ -226,12 +232,19 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { ); } + let pipe_name = conf + .package_broker + .pipe_name + .clone() + .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()); + if let Err(error) = publish_policy_consent_broker_pipe_name(&pipe_name) { + warn!( + error = format!("{error:#}"), + "Policy consent helper cannot discover the configured broker pipe" + ); + } let broker_config = BrokerTaskConfig { - pipe_name: conf - .package_broker - .pipe_name - .clone() - .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()), + pipe_name, policy_path: conf.package_broker.policy_path.clone(), // The bypass only takes effect in builds with the development-only // `dev-skip-broker-signature` cargo feature, never in shipped builds. @@ -271,3 +284,43 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { service_event_tx, }) } + +#[cfg(windows)] +fn publish_policy_consent_broker_pipe_name(pipe_name: &str) -> anyhow::Result<()> { + let key = LOCAL_MACHINE + .options() + .read() + .write() + .open(POLICY_CONSENT_DISCOVERY_KEY) + .context("open policy consent helper discovery key")?; + publish_policy_consent_broker_pipe_name_to(&key, pipe_name) +} + +#[cfg(windows)] +fn publish_policy_consent_broker_pipe_name_to(key: &Key, pipe_name: &str) -> anyhow::Result<()> { + key.set_string(POLICY_CONSENT_BROKER_PIPE_NAME_VALUE, pipe_name) + .context("publish configured policy consent helper broker pipe") +} + +#[cfg(all(test, windows))] +mod tests { + use windows_registry::CURRENT_USER; + + use super::*; + + #[test] + fn configured_broker_pipe_is_published_to_writable_discovery_key() { + let path = format!(r"Software\Devolutions\Agent\Tests\{}", uuid::Uuid::new_v4().as_simple()); + let key = CURRENT_USER.create(&path).expect("create temporary discovery key"); + let pipe_name = r"\\.\pipe\custom-broker"; + + publish_policy_consent_broker_pipe_name_to(&key, pipe_name).expect("publish configured broker pipe"); + + assert_eq!( + key.get_string(POLICY_CONSENT_BROKER_PIPE_NAME_VALUE) + .expect("read published broker pipe"), + pipe_name + ); + CURRENT_USER.remove_tree(&path).expect("remove temporary discovery key"); + } +} diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index e1d0a0a4d..bbb7bbb5d 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -338,6 +338,56 @@ public void RequestRequiresEveryMemberAndObjectDraft() Assert.Throws(() => Protocol.ValidateRequest(request)); } + [Fact] + public void RequestRejectsExplicitNullRequiredMembers() + { + string nullRequestId = + """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{}}"""; + + ElevationRequest request = JsonSerializer.Deserialize( + nullRequestId, + ProtocolJsonContext.Default.ElevationRequest) + ?? throw new InvalidOperationException("request should deserialize"); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData(@"\\.\pipe\Devolutions.Now.PackageBroker.v1", "Devolutions.Now.PackageBroker.v1")] + [InlineData("custom-broker", "custom-broker")] + public void BrokerPipeDiscoveryNormalizesLocalPipeNames(string configured, string expected) + { + Assert.Equal(expected, BrokerClient.NormalizeBrokerPipeName(configured)); + } + + [Theory] + [InlineData("")] + [InlineData(@"\\server\pipe\broker")] + [InlineData(@"\\.\pipe\")] + public void BrokerPipeDiscoveryRejectsNonlocalOrEmptyNames(string configured) + { + Assert.ThrowsAny(() => BrokerClient.NormalizeBrokerPipeName(configured)); + } + + [Fact] + public async Task OversizedOfficialRequestIsRejectedBeforeBrokerConnection() + { + using JsonDocument draft = JsonDocument.Parse($$"""{"padding":"{{new string('x', Protocol.MaxRequestBodyBytes)}}" }"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "Reject", + "token", + "receipt", + false, + draft.RootElement.Clone()); + + ElevationResponse response = await BrokerClient.ReplaceAsync(request, CancellationToken.None); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal("InvalidRequest", response.BrokerErrorCode); + } + [Theory] [InlineData("Delete", "Reject")] [InlineData("Update", "Overwrite")] diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index 1dfe80305..c2594e4b0 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.IO.Pipes; +using Microsoft.Win32; using System.Text; using System.Text.Json; @@ -7,7 +8,8 @@ namespace DevolutionsAgentPolicyConsent; internal static class BrokerClient { - private const string PipeName = "Devolutions.Now.PackageBroker.v1"; + private const string BrokerPipeNameValue = "BrokerPipeName"; + private const string DiscoveryKey = @"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; private const int MaximumHeaderBytes = 64 * 1024; private const int MaximumBrokerResponseBytes = 50_606_928; private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); @@ -16,12 +18,12 @@ internal static async Task ReplaceAsync( ElevationRequest request, CancellationToken cancellationToken) { - byte[] body = CreateOfficialRequest(request); try { + byte[] body = CreateOfficialRequest(request); using NamedPipeClientStream pipe = new( ".", - PipeName, + ReadBrokerPipeName(), PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.WriteThrough, System.Security.Principal.TokenImpersonationLevel.Anonymous); @@ -64,14 +66,34 @@ internal static async Task ReplaceAsync( { return Rejected(request.RequestId, "Unauthorized"); } + catch (BrokerDiscoveryException) + { + return Rejected(request.RequestId, "BrokerUnavailable"); + } catch (InvalidOperationException) { return Unknown(request.RequestId, null, "InvalidResponse"); } catch (ProtocolException) { - return Unknown(request.RequestId, null, "InvalidResponse"); + return Rejected(request.RequestId, "InvalidRequest"); + } + } + + internal static string NormalizeBrokerPipeName(string pipeName) + { + const string LocalPipePrefix = @"\\.\pipe\"; + if (pipeName.StartsWith(LocalPipePrefix, StringComparison.OrdinalIgnoreCase)) + { + pipeName = pipeName[LocalPipePrefix.Length..]; + } + if (pipeName.Length is 0 or > 256 || + pipeName.IndexOf('\0') >= 0 || + pipeName.Contains('\\')) + { + throw new BrokerDiscoveryException(); } + return pipeName; } internal static byte[] CreateOfficialRequest(ElevationRequest request) @@ -246,6 +268,17 @@ private static ElevationResponse Unknown(string requestId, int? statusCode, stri private static ElevationResponse Rejected(string requestId, string errorCode) => new(Protocol.Version, requestId, "Rejected", null, errorCode, null, null, null, null); + private static string ReadBrokerPipeName() + { + using RegistryKey machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using RegistryKey? discovery = machine.OpenSubKey(DiscoveryKey); + if (discovery?.GetValue(BrokerPipeNameValue) is not string pipeName) + { + throw new BrokerDiscoveryException(); + } + return NormalizeBrokerPipeName(pipeName); + } + private static async Task OpenBrokerServerAsync( NamedPipeClientStream pipe, CancellationToken cancellationToken) @@ -294,4 +327,12 @@ private sealed class BrokerResponseException(int? statusCode, Exception? innerEx private sealed class BrokerAuthenticationException(Exception innerException) : Exception("broker server authentication failed", innerException); + + private sealed class BrokerDiscoveryException : InvalidOperationException + { + internal BrokerDiscoveryException() + : base("broker pipe discovery is unavailable") + { + } + } } diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs index d4d0958cd..5cba60549 100644 --- a/package/AgentPolicyConsent/PolicyConsentContract.cs +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -5,6 +5,7 @@ internal static class PolicyConsentContract internal const string ProtocolVersion = "2.0"; internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; internal const string ProductName = "Devolutions Agent Policy Consent"; + internal const string DefaultBrokerPipeName = @"\\.\pipe\Devolutions.Now.PackageBroker.v1"; // Supported UniGetUI hosts are version 2026.2.7 or newer and must carry this signer: // subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs index a9057db90..dcf07ed21 100644 --- a/package/AgentPolicyConsent/Protocol.cs +++ b/package/AgentPolicyConsent/Protocol.cs @@ -63,7 +63,8 @@ internal static Arguments ParseArguments(string[] args) internal static void ValidateRequest(ElevationRequest request) { - if (request.ProtocolVersion != Version || + if (request.RequestId is null || + request.ProtocolVersion != Version || !IsLowerHex(request.RequestId.AsSpan(), 32) || request.Operation is not ("Update" or "ReplaceIdentity" or "Create" or "Repair") || request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || @@ -77,7 +78,8 @@ request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || internal static void ValidateResponse(ElevationResponse response) { - if (response.ProtocolVersion != Version || + if (response.RequestId is null || + response.ProtocolVersion != Version || !IsLowerHex(response.RequestId.AsSpan(), 32) || response.Disposition is not ("Committed" or "Rejected" or "Unknown") || !IsOptionalCredential(response.BrokerErrorCode, 64)) diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index 83a7a1210..f90219f71 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -52,13 +52,19 @@ public void DiscoveryPublishesTheFixedHelperIdentity() "CurrentUiSignerSpkiSha256", "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", true); + RegValue brokerPipe = CreateDiscoveryValue( + "BrokerPipeName", + @"\\.\pipe\Devolutions.Now.PackageBroker.v1", + true); Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", executablePath.Value); Assert.Equal( "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", signer.Value); + Assert.Equal(@"\\.\pipe\Devolutions.Now.PackageBroker.v1", brokerPipe.Value); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, executablePath.RegistryKeyAction); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, signer.RegistryKeyAction); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, brokerPipe.RegistryKeyAction); } private static RegValue CreateDiscoveryValue(string name, string value, bool win64) diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 707b1e611..f279de32f 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -359,6 +359,10 @@ static void Main() "ProductVersion", DevolutionsAgentProductVersion.ToString(), Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "BrokerPipeName", + Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME, + Use64BitRegistryView(project.Platform)), CreatePolicyConsentRegistryValue( "CurrentUiSignerSpkiSha256", Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 284f8bef2..0474decf9 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -27,6 +27,9 @@ internal static class Includes internal static readonly string POLICY_CONSENT_PROTOCOL_VERSION = DevolutionsAgentPolicyConsent.PolicyConsentContract.ProtocolVersion; + internal static readonly string POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.DefaultBrokerPipeName; + internal static readonly string POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256 = DevolutionsAgentPolicyConsent.PolicyConsentContract.CurrentUiSignerSpkiSha256; From 95cce81d7a31effa0756952328de02f356adaa0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 02:01:04 +0900 Subject: [PATCH 35/53] docs(agent-installer): add helper package step Document the required NativeAOT consent helper publish and packaging argument for local Agent MSI builds. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/README.md b/ci/README.md index 264a9500d..1f36ee324 100644 --- a/ci/README.md +++ b/ci/README.md @@ -14,8 +14,8 @@ This folder contains PowerShell scripts for CI, building, and packaging. | Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` | | Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` | | Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) | -| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1` | -| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1 -Generate`
`package-assembled.ps1 agent` | +| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe` | +| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe`
`package-assembled.ps1 agent` | | Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. | | Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. | | PEDM module | Windows | `build.ps1 pedm` | From 2c2354ca539020e2995cd13352e4027292049461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 02:24:08 +0900 Subject: [PATCH 36/53] fix(agent): require local consent parent Reject remote-provider parent images before retaining them for policy consent. Document every required local Agent package artifact argument. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/README.md | 22 +++++++++++++++++-- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 11 ++++++++++ package/AgentPolicyConsent/PeerTrust.cs | 19 ++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/ci/README.md b/ci/README.md index 1f36ee324..bac41e802 100644 --- a/ci/README.md +++ b/ci/README.md @@ -14,14 +14,32 @@ This folder contains PowerShell scripts for CI, building, and packaging. | Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` | | Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` | | Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) | -| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe` | -| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe`
`package-assembled.ps1 agent` | +| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1` with the arguments below | +| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate` with the arguments below
`package-assembled.ps1 agent` | | Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. | | Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. | | PEDM module | Windows | `build.ps1 pedm` | | PowerShell module | Windows | `copy-ps-module.ps1` | | Desktop Agent | Windows | `..\dotnet\DesktopAgent\build.ps1` | +## Agent Windows package arguments + +Pass every staged artifact to `package-agent-windows.ps1`. + +```powershell +.\package-agent-windows.ps1 ` + -Exe ` + -UpdaterExe ` + -PedmDll ` + -PedmMsix ` + -SessionExe ` + -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe ` + -Architecture x64 ` + -Outfile +``` + +For an assembled package, replace `-Outfile ` with `-Generate`. + ## What is the difference between _Windows (regular)_ and _Windows (assembled)_? _Windows (regular)_ is the "normal" build process where the MSI is built by WiX but not signed. This is used in _ci.yaml_. _Windows (assembled)_ is a two-step process where the `-Generate` flag is used to build supporting files for the MSI, including DLLs, language transforms, and _cmd_ scripts. The MSI is assembled in second step using _package-assembled.ps1_. The two-step approach is described [here](https://github.com/oleg-shilo/wixsharp/wiki/Developer's-Guide#compiling-wix-project). diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index bbb7bbb5d..d3f6dceb9 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -124,6 +124,17 @@ public void ProcessIdentityAcceptsExactRetainedInstance() Assert.True(PeerLease.MatchesProcessIdentity(expected, 42, 638900000000000000, 1)); } + [Theory] + [InlineData(@"C:\Program Files\UniGetUI\UniGetUI.exe", true)] + [InlineData(@"\\server\share\UniGetUI.exe", false)] + [InlineData(@"\Device\Mup\server\share\UniGetUI.exe", false)] + [InlineData(@"\Device\WebDavRedirector\server\share\UniGetUI.exe", false)] + [InlineData(@"relative\UniGetUI.exe", false)] + public void ParentImageMustUseAFixedLocalVolume(string path, bool expected) + { + Assert.Equal(expected, PeerLease.IsSupportedLocalImagePath(path)); + } + [Fact] public void BrokerServerRequiresExactAgentSiblingPath() { diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 812652fae..038f0c7a6 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -28,6 +28,7 @@ internal sealed class PeerLease : IDisposable internal const uint FileAttributeReparsePoint = 0x400; internal const uint FileFlagBackupSemantics = 0x0200_0000; internal const uint FileFlagOpenReparsePoint = 0x0020_0000; + internal const uint DriveFixed = 3; internal const int ProcessImageFileMapping = 44; internal const uint StillActive = 259; @@ -76,6 +77,10 @@ internal static PeerLease Open(Arguments arguments) } string path = ImagePath(process); + if (!IsSupportedLocalImagePath(path)) + { + throw new InvalidOperationException("parent image is not on a supported local volume"); + } SafeFileHandle image = Native.CreateFile( path, GenericRead | FileExecute | Synchronize, @@ -200,6 +205,17 @@ internal static bool SameFile(SafeFileHandle left, SafeFileHandle right) leftInfo.FileIndexLow == rightInfo.FileIndexLow; } + internal static bool IsSupportedLocalImagePath(string path) + { + if (!Path.IsPathFullyQualified(path) || + path.StartsWith(@"\\", StringComparison.Ordinal) || + Path.GetPathRoot(path) is not { Length: 3 } root) + { + return false; + } + return Native.GetDriveType(root) == DriveFixed; + } + internal static bool IsLocalSystemProcess(SafeProcessHandle process) { if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) @@ -814,6 +830,9 @@ internal static partial bool GetProcessTimes( [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + [LibraryImport("kernel32.dll", EntryPoint = "GetDriveTypeW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint GetDriveType(string rootPathName); + [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetFileInformationByHandle( From c81c1b937d41fa701afade89da33cda01e750393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 02:45:05 +0900 Subject: [PATCH 37/53] fix(agent): resolve retained consent paths Resolve the retained UniGetUI image path before trusting its local volume. Keep already-correct package inputs absolute while the MSI build changes directories. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/package-agent-windows.ps1 | 5 +- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 8 +++ package/AgentPolicyConsent/PeerTrust.cs | 49 ++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 246cfe245..bb97553a5 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -45,8 +45,9 @@ function Set-FileNameAndCopy { # If the name is already correct, return the original path without copying if ($currName -ieq $NewName) { - Write-Host "Using $Path without copying" - return $Path + $resolvedPath = (Resolve-Path -LiteralPath $Path).Path + Write-Host "Using $resolvedPath without copying" + return $resolvedPath } # Copy to a temporary directory. diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index d3f6dceb9..1d5d4573e 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -135,6 +135,14 @@ public void ParentImageMustUseAFixedLocalVolume(string path, bool expected) Assert.Equal(expected, PeerLease.IsSupportedLocalImagePath(path)); } + [Theory] + [InlineData(@"\\?\C:\Program Files\UniGetUI\UniGetUI.exe", @"C:\Program Files\UniGetUI\UniGetUI.exe")] + [InlineData(@"\\?\UNC\server\share\UniGetUI.exe", @"\\server\share\UniGetUI.exe")] + public void RetainedImagePathRemovesOnlyExtendedPrefix(string input, string expected) + { + Assert.Equal(expected, PeerLease.NormalizeFinalPath(input)); + } + [Fact] public void BrokerServerRequiresExactAgentSiblingPath() { diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 038f0c7a6..52c5fa2e0 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -97,8 +97,13 @@ internal static PeerLease Open(Arguments arguments) try { VerifyImageMapping(process, image); - VerifyImageMetadata(path, image); - using X509Certificate2 signer = VerifyAuthenticodeSigner(path, image, "parent image"); + string retainedPath = FinalPath(image); + if (!IsSupportedLocalImagePath(retainedPath)) + { + throw new InvalidOperationException("parent image is not on a supported local volume"); + } + VerifyImageMetadata(retainedPath, image); + using X509Certificate2 signer = VerifyAuthenticodeSigner(retainedPath, image, "parent image"); VerifySigner(signer); VerifyImageMapping(process, image); EnsureActive(process); @@ -216,6 +221,39 @@ internal static bool IsSupportedLocalImagePath(string path) return Native.GetDriveType(root) == DriveFixed; } + internal static string FinalPath(SafeFileHandle image) + { + uint capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(checked((int)capacity)); + uint length = Native.GetFinalPathNameByHandle(image, path, capacity, 0); + if (length == 0) + { + throw new Win32Exception(); + } + if (length < capacity) + { + return NormalizeFinalPath(path.ToString()); + } + capacity = length + 1; + } + throw new InvalidOperationException("parent image final path is too long"); + } + + internal static string NormalizeFinalPath(string path) + { + const string ExtendedPrefix = @"\\?\"; + const string ExtendedUncPrefix = @"\\?\UNC\"; + if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + { + return @"\\" + path[ExtendedUncPrefix.Length..]; + } + return path.StartsWith(ExtendedPrefix, StringComparison.Ordinal) + ? path[ExtendedPrefix.Length..] + : path; + } + internal static bool IsLocalSystemProcess(SafeProcessHandle process) { if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) @@ -833,6 +871,13 @@ internal static partial bool GetProcessTimes( [LibraryImport("kernel32.dll", EntryPoint = "GetDriveTypeW", StringMarshalling = StringMarshalling.Utf16)] internal static partial uint GetDriveType(string rootPathName); + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder path, + uint length, + uint flags); + [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetFileInformationByHandle( From db757ad1872cdd47a93e468c3d8566b17a6a397f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 03:08:14 +0900 Subject: [PATCH 38/53] fix(agent): align policy pipe deadline Keep the bounded broker connection lifetime aligned with the consent helper's two-minute policy replacement exchange. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 6a4c51b00..92f9e0415 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -37,10 +37,11 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// /// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all /// endpoints respond without blocking on package operations (execution is asynchronous, -/// tracked via the operation tracker), so a healthy exchange completes well within this -/// deadline. Without it, idle clients holding their connection open without sending a -/// request would each pin a connection slot indefinitely and could exhaust the pool. -const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); +/// tracked via the operation tracker), except policy replacement, which validates and +/// persists a bounded document. Keep this aligned with the consent helper's bounded +/// exchange stage. Without it, idle clients holding their connection open without sending +/// a request would each pin a connection slot indefinitely and could exhaust the pool. +const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); /// Start the named pipe server and accept connections until shutdown. pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> { @@ -200,6 +201,11 @@ mod tests { use super::*; + #[test] + fn connection_deadline_matches_consent_exchange_timeout() { + assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(120)); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timed_out_capture_keeps_its_permit_until_blocking_work_finishes() { let permits = Arc::new(Semaphore::new(1)); From 6466a19e3e80812b64f59f3abb855babcbb193ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 03:24:22 +0900 Subject: [PATCH 39/53] fix(agent): limit consent pipe timeout Keep ordinary pipe capture and requests bounded to 30 seconds. Allow the two-minute exchange only after the exact consent helper is authorized. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 81 +++++++++++++++------------ 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 92f9e0415..0fefac5d4 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -36,12 +36,15 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// Deadline for serving a single pipe connection, from accept to response completion. /// /// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all -/// endpoints respond without blocking on package operations (execution is asynchronous, -/// tracked via the operation tracker), except policy replacement, which validates and -/// persists a bounded document. Keep this aligned with the consent helper's bounded -/// exchange stage. Without it, idle clients holding their connection open without sending -/// a request would each pin a connection slot indefinitely and could exhaust the pool. -const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); +/// ordinary endpoints respond without blocking on package operations (execution is +/// asynchronous and tracked via the operation tracker). Without this deadline, idle +/// clients holding their connection open without sending a request would each pin a +/// connection slot indefinitely and could exhaust the pool. +const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); + +/// The bounded policy-replacement exchange lifetime used only after the exact installed +/// consent helper has passed write authorization. +const POLICY_CONSENT_CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); /// Start the named pipe server and accept connections until shutdown. pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> { @@ -74,39 +77,42 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke Ok(()) => { let state = Arc::clone(&state); tokio::spawn(async move { - let serve = async move { - // Keep blocking unauthenticated capture off the accept loop and - // retain the connection slot until the work actually completes. - let capture = spawn_bounded_capture(permit, move || { - let client = PipeClient::from_connected_pipe(&server); - (server, client) - }); - let (_permit, server, client) = match capture.await { - Ok((permit, (server, Ok(client)))) => (permit, server, client), - Ok((_permit, (_server, Err(error)))) => { - warn!(error = format!("{error:#}"), "Rejected named pipe client"); - return; - } - Err(error) => { - error!( - error = format!("{error:#}"), - "Named pipe client identity capture task failed" - ); - return; - } - }; - - info!("Client connected to named pipe"); - let router = build_router_for_client(state, client); - serve_connection(server, router).await; - info!("Client disconnected from named pipe"); + // Keep blocking unauthenticated capture off the accept loop and + // retain the connection slot until the work actually completes. + let capture = spawn_bounded_capture(permit, move || { + let client = PipeClient::from_connected_pipe(&server); + (server, client) + }); + let (_permit, server, client) = match tokio::time::timeout(CONNECTION_DEADLINE, capture).await { + Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client), + Ok(Ok((_permit, (_server, Err(error))))) => { + warn!(error = format!("{error:#}"), "Rejected named pipe client"); + return; + } + Ok(Err(error)) => { + error!( + error = format!("{error:#}"), + "Named pipe client identity capture task failed" + ); + return; + } + Err(_) => { + warn!("Closed named pipe connection: client identity capture deadline exceeded"); + return; + } }; - // Enforce a deadline so idle or slow clients cannot pin - // a connection slot indefinitely. - if tokio::time::timeout(CONNECTION_DEADLINE, serve).await.is_err() { + let deadline = if client.validate_policy_write(state.skip_signature_validation).is_ok() { + POLICY_CONSENT_CONNECTION_DEADLINE + } else { + CONNECTION_DEADLINE + }; + info!("Client connected to named pipe"); + let router = build_router_for_client(state, client); + if tokio::time::timeout(deadline, serve_connection(server, router)).await.is_err() { warn!("Closed named pipe connection: deadline exceeded"); } + info!("Client disconnected from named pipe"); }); } Err(error) => { @@ -202,8 +208,9 @@ mod tests { use super::*; #[test] - fn connection_deadline_matches_consent_exchange_timeout() { - assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(120)); + fn connection_deadlines_preserve_short_untrusted_and_long_authorized_bounds() { + assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(30)); + assert_eq!(POLICY_CONSENT_CONNECTION_DEADLINE, Duration::from_secs(120)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 0fadc51ad4fd607d41cf1ea1d948e06a60582cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 03:45:02 +0900 Subject: [PATCH 40/53] fix(agent): extend authorized policy writes Keep unauthenticated and ordinary pipe connections at 30 seconds. Extend only a successfully authorized consent-helper policy write to the two-minute exchange limit. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 40 ++++++++++--- crates/now-package-broker/src/server/mod.rs | 66 ++++++++++++++++++++- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 0fefac5d4..1d83b0b76 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -20,7 +20,7 @@ use windows::Win32::Security::Authorization::SET_ACCESS; use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_GENERIC_WRITE}; use crate::auth::PipeClient; -use crate::server::{BrokerState, build_router_for_client, serve_connection}; +use crate::server::{BrokerState, build_router_for_client_with_policy_write_deadline, serve_connection}; /// Default pipe name for the package broker. pub const DEFAULT_PIPE_NAME: &str = r"\\.\pipe\Devolutions.Now.PackageBroker.v1"; @@ -102,15 +102,37 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke } }; - let deadline = if client.validate_policy_write(state.skip_signature_validation).is_ok() { - POLICY_CONSENT_CONNECTION_DEADLINE - } else { - CONNECTION_DEADLINE - }; info!("Client connected to named pipe"); - let router = build_router_for_client(state, client); - if tokio::time::timeout(deadline, serve_connection(server, router)).await.is_err() { - warn!("Closed named pipe connection: deadline exceeded"); + let (policy_write_deadline, mut policy_write_authorized) = tokio::sync::watch::channel(false); + let router = build_router_for_client_with_policy_write_deadline( + state, + client, + policy_write_deadline, + ); + let serve = serve_connection(server, router); + tokio::pin!(serve); + let connection_deadline = tokio::time::sleep(CONNECTION_DEADLINE); + tokio::pin!(connection_deadline); + let policy_deadline = tokio::time::sleep(POLICY_CONSENT_CONNECTION_DEADLINE); + tokio::pin!(policy_deadline); + let mut policy_write_is_authorized = false; + loop { + tokio::select! { + () = &mut serve => break, + () = &mut connection_deadline, if !policy_write_is_authorized => { + warn!("Closed named pipe connection: deadline exceeded"); + break; + } + () = &mut policy_deadline, if policy_write_is_authorized => { + warn!("Closed named pipe policy replacement: deadline exceeded"); + break; + } + result = policy_write_authorized.changed(), if !policy_write_is_authorized => { + if result.is_ok() && *policy_write_authorized.borrow_and_update() { + policy_write_is_authorized = true; + } + } + } } info!("Client disconnected from named pipe"); }); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 77f835d87..4a2c97029 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -26,6 +26,7 @@ use now_policy_api::{ use now_policy_server_template::{ MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer, }; +use tokio::sync::watch; use tracing::{info, trace, warn}; use win_api_wrappers::identity::sid::Sid; @@ -113,6 +114,23 @@ struct EvaluatedRequest { /// Build the axum router for a single authenticated pipe client. pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router { + build_router_for_client_with_optional_policy_write_deadline(state, client, None) +} + +/// Build the router and signal when the exact helper policy-write authorization completes. +pub(crate) fn build_router_for_client_with_policy_write_deadline( + state: Arc, + client: PipeClient, + policy_write_deadline: watch::Sender, +) -> axum::Router { + build_router_for_client_with_optional_policy_write_deadline(state, client, Some(policy_write_deadline)) +} + +fn build_router_for_client_with_optional_policy_write_deadline( + state: Arc, + client: PipeClient, + policy_write_deadline: Option>, +) -> axum::Router { let server: SharedPackageBrokerServer = Arc::new(BrokerConnection { state: Arc::clone(&state), client: client.clone(), @@ -120,6 +138,7 @@ pub(crate) fn build_router_for_client(state: Arc, client: PipeClien axum::Router::from(now_policy_server_template::api_router_from_shared(server)) .layer(middleware::from_fn(reject_duplicate_policy_json_members)) .layer(middleware::from_fn_with_state(state, authenticate_policy_management)) + .layer(Extension(policy_write_deadline)) .layer(Extension(client)) } @@ -292,6 +311,7 @@ fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), serde_json::Error> async fn authenticate_policy_management( State(state): State>, Extension(client): Extension, + Extension(policy_write_deadline): Extension>>, request: Request, next: Next, ) -> Response { @@ -307,7 +327,8 @@ async fn authenticate_policy_management( | (&Method::PUT, "/v1/policy") ); if protected { - let authentication = if matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")) { + let policy_write = matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")); + let authentication = if policy_write { client.validate_policy_write(state.skip_signature_validation) } else { client.validate_connection(state.skip_signature_validation) @@ -326,6 +347,9 @@ async fn authenticate_policy_management( ) .into_response(); } + if policy_write && let Some(policy_write_deadline) = policy_write_deadline { + let _ = policy_write_deadline.send(true); + } let authenticated = POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)); return if let Some(audit) = write_audit { POLICY_WRITE_AUDIT.scope(audit, authenticated).await @@ -1075,6 +1099,46 @@ mod tests { } } + #[cfg(feature = "dev-skip-broker-signature")] + #[tokio::test] + async fn authorized_policy_write_extends_only_its_connection_deadline() { + let client = PipeClient::test_with_authority(true, true).expect("create elevated test client"); + let state = shared_state(None); + let draft = serde_json::json!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": "replacement", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [] + }); + let validation = state.policy_store.validate_draft(&draft); + let replacement = serde_json::json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, + "Operation": "Create", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": draft, + "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), + }); + let (deadline, mut extended) = watch::channel(false); + let mut router = build_router_for_client_with_policy_write_deadline(state, client, deadline); + let response = router + .call( + Request::builder() + .method(Method::PUT) + .uri("/v1/policy") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&replacement).expect("serialize request"))) + .expect("valid request"), + ) + .await + .expect("router is infallible"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(*extended.borrow_and_update()); + } + #[cfg(feature = "dev-skip-broker-signature")] async fn route_json( state: Arc, From db8482d8a86590f549ca0a89fe3f81d902b009b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:17:01 +0900 Subject: [PATCH 41/53] fix(agent): recover interrupted policy probes Retire only verified protected probe remnants after an interrupted capability check, and require retained Agent ancestor handles to remain expected directories. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 132 ++++++++++++++++-- crates/now-package-broker/src/server/mod.rs | 1 + .../AgentPolicyConsent.Tests/ProtocolTests.cs | 2 + package/AgentPolicyConsent/PeerTrust.cs | 18 +++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 972029cf2..52b802742 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -34,10 +34,11 @@ use windows::Win32::Storage::FileSystem::{ CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ, - FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, - FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo, - FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_DELETE_ON_CLOSE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, + FILE_GENERIC_READ, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, + FILE_SHARE_DELETE, FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, + FileRenameInfo, FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, + SetFileInformationByHandle, }; #[cfg(test)] use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; @@ -544,6 +545,11 @@ impl std::error::Error for UnsupportedAtomicSemantics {} /// Filesystem names known to support atomic same-directory handle renames. /// Conservative by design: an unrecognized filesystem is treated as unsupported. const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"]; +const PROBE_SOURCE_NAME: &str = ".package-broker-write-probe-a.tmp"; +const PROBE_TARGET_NAME: &str = ".package-broker-write-probe-b.tmp"; +const PROBE_TOMBSTONE_NAME: &str = ".package-broker-write-probe-old.tmp"; +const PROBE_SOURCE_CONTENT: &[u8] = b"probe-source"; +const PROBE_TARGET_CONTENT: &[u8] = b"probe-target"; /// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`]. /// @@ -559,11 +565,12 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { } let dir_handle = open_directory_no_reparse(dir)?; - let source_path = dir.join(".package-broker-write-probe-a.tmp"); - let target_path = dir.join(".package-broker-write-probe-b.tmp"); - let tombstone_path = dir.join(".package-broker-write-probe-old.tmp"); - let source = create_probe_file(&source_path, b"probe-source", false)?; - let target = match create_probe_file(&target_path, b"probe-target", true) { + recover_interrupted_write_capability_probe(&dir_handle, dir)?; + let source_path = dir.join(PROBE_SOURCE_NAME); + let target_path = dir.join(PROBE_TARGET_NAME); + let tombstone_path = dir.join(PROBE_TOMBSTONE_NAME); + let source = create_probe_file(&source_path, PROBE_SOURCE_CONTENT, false)?; + let target = match create_probe_file(&target_path, PROBE_TARGET_CONTENT, true) { Ok(target) => target, Err(error) => { return match cleanup_probe_file(source, &source_path, "write-capability probe source") { @@ -595,7 +602,7 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { source_published = true; let replaced = std::fs::read(&target_path).context("read write-capability probe result")?; ensure!( - replaced == b"probe-source", + replaced == PROBE_SOURCE_CONTENT, "atomic replacement did not take effect on this filesystem" ); delete_file_handle(&target).context("probe POSIX tombstone unlink")?; @@ -620,6 +627,59 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { probe_result.and(source_cleanup).and(target_cleanup) } +/// Retire a trusted remnant from a capability probe interrupted before its +/// delete-on-close handles were released. +/// +/// The fixed names are only reclaimable when the entry is a non-reparse, +/// single-link, managed-policy file containing one of the probe's exact payloads. +/// This preserves fail-closed collision handling for untrusted lookalikes. +fn recover_interrupted_write_capability_probe(dir: &File, dir_path: &Path) -> anyhow::Result<()> { + verify_directory_handle_type(dir, "write-capability probe directory")?; + + for (name, expected_contents) in [ + (PROBE_SOURCE_NAME, &[PROBE_SOURCE_CONTENT][..]), + (PROBE_TARGET_NAME, &[PROBE_TARGET_CONTENT, PROBE_SOURCE_CONTENT][..]), + (PROBE_TOMBSTONE_NAME, &[PROBE_TARGET_CONTENT][..]), + ] { + let path = dir_path.join(name); + let file = match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("failed to open interrupted write probe {}", path.display())); + } + }; + + policy_security::verify_policy_file_path(&file, &path) + .with_context(|| format!("interrupted write probe {} is unsafe", path.display()))?; + policy_security::verify_managed_policy_file_security(&file) + .with_context(|| format!("interrupted write probe {} has unsafe security", path.display()))?; + ensure!( + policy_security::file_link_count(&file)? == 1, + "interrupted write probe {} has multiple hard links", + path.display() + ); + let content = read_file_from_start(&file) + .with_context(|| format!("failed to read interrupted write probe {}", path.display()))?; + ensure!( + expected_contents.contains(&content.as_slice()), + "interrupted write probe {} has unexpected content", + path.display() + ); + delete_file_handle(&file) + .with_context(|| format!("failed to retire interrupted write probe {}", path.display()))?; + drop(file); + ensure_path_absent(&path, "interrupted write probe")?; + } + Ok(()) +} + fn verify_no_replace_collision( source: &File, target: &File, @@ -775,7 +835,7 @@ fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> any } .0, ) - .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) + .custom_flags((FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) .open(path) .with_context(|| format!("failed to create write-capability probe {}", path.display()))?; if let Err(error) = file @@ -4148,7 +4208,7 @@ mod tests { // ─── probe_write_capability / volume_filesystem_name ────────────────────── // - // No elevation required: these never touch `admin_only_security_attributes`. + // No elevation required: ordinary probes use inherited directory security. #[test] fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() { @@ -4171,6 +4231,54 @@ mod tests { assert!(leftover.is_empty(), "probe left files behind: {leftover:?}"); } + #[test] + fn probe_file_is_removed_when_its_handle_closes() { + let dir = temp_dir(); + let path = dir.path().join(PROBE_SOURCE_NAME); + let file = create_probe_file(&path, PROBE_SOURCE_CONTENT, false).unwrap(); + + drop(file); + + assert!(!path.exists(), "delete-on-close probe file survived its handle"); + } + + #[test] + fn interrupted_trusted_probe_remnant_is_recovered() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let path = dir.path().join(PROBE_SOURCE_NAME); + let mut file = match create_secure_transaction_file(&path) { + Ok(file) => file, + Err(error) => { + tracing::warn!( + error = %format!("{error:#}"), + "Skipping administrator-owned probe recovery fixture" + ); + return; + } + }; + file.write_all(PROBE_SOURCE_CONTENT).unwrap(); + file.sync_all().unwrap(); + drop(file); + + recover_interrupted_write_capability_probe(&dir_file, dir.path()).unwrap(); + + assert!(!path.exists(), "trusted interrupted probe remnant was not retired"); + } + + #[test] + fn interrupted_untrusted_probe_remnant_is_not_removed() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let path = dir.path().join(PROBE_SOURCE_NAME); + std::fs::write(&path, PROBE_SOURCE_CONTENT).unwrap(); + + assert!(recover_interrupted_write_capability_probe(&dir_file, dir.path()).is_err()); + assert!(path.exists(), "untrusted probe collision must remain fail-closed"); + } + #[test] fn occupied_no_replace_probe_preserves_both_retained_files() { let dir = temp_dir(); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 4a2c97029..fbba6157c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -113,6 +113,7 @@ struct EvaluatedRequest { } /// Build the axum router for a single authenticated pipe client. +#[cfg(test)] pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router { build_router_for_client_with_optional_policy_write_deadline(state, client, None) } diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 1d5d4573e..389538c87 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -81,6 +81,8 @@ public void ProtectedAgentPathRejectsReparsePoints() { Assert.True(PeerLease.IsReparsePoint(PeerLease.FileAttributeReparsePoint)); Assert.False(PeerLease.IsReparsePoint(0)); + Assert.True(PeerLease.IsDirectory(PeerLease.FileAttributeDirectory)); + Assert.False(PeerLease.IsDirectory(0)); } [Fact] diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 52c5fa2e0..82fefd51a 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -25,6 +25,7 @@ internal sealed class PeerLease : IDisposable internal const uint FileShareRead = 0x1; internal const uint FileShareWrite = 0x2; internal const uint OpenExisting = 3; + internal const uint FileAttributeDirectory = 0x10; internal const uint FileAttributeReparsePoint = 0x400; internal const uint FileFlagBackupSemantics = 0x0200_0000; internal const uint FileFlagOpenReparsePoint = 0x0020_0000; @@ -453,6 +454,9 @@ internal static void VerifyProtectedPath(SafeFileHandle handle, string subject, internal static bool IsReparsePoint(uint attributes) => (attributes & FileAttributeReparsePoint) != 0; + internal static bool IsDirectory(uint attributes) => + (attributes & FileAttributeDirectory) != 0; + internal const int FileTamperRights = 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0100 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; @@ -724,6 +728,20 @@ private static List RetainProtectedDirectories(string installati handle, $"Agent installation directory '{directory.FullName}'", tamperRights); + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (!PeerLease.IsDirectory(information.FileAttributes)) + { + throw new InvalidOperationException( + $"Agent installation directory '{directory.FullName}' is not a directory"); + } + if (!IsExpectedPath(PeerLease.FinalPath(handle), directory.FullName)) + { + throw new InvalidOperationException( + $"Agent installation directory '{directory.FullName}' resolved to an unexpected path"); + } tamperRights = PeerLease.AncestorDirectoryTamperRights; } return handles; From e31c8edb9d03b1e8f76825bc21aaae1d7d0f4a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:32:18 +0900 Subject: [PATCH 42/53] fix(agent): match Unicode policy sources Use Unicode-aware literal matching for source names so a policy deny uses the same case semantics as PowerShell repository lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/evaluator/matching.rs | 7 +++- .../now-package-broker/src/evaluator/tests.rs | 40 ++++++++++++++++++- .../src/evaluator/wildcard.rs | 18 +++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs index 4ee5301a9..782070c21 100644 --- a/crates/now-package-broker/src/evaluator/matching.rs +++ b/crates/now-package-broker/src/evaluator/matching.rs @@ -9,7 +9,7 @@ use now_policy_api::PackageRequest; use super::RequestFlags; use super::constraints::constraints_pass; -use super::wildcard::wildcard_any; +use super::wildcard::{literal_case_insensitive_match, wildcard_any}; pub(super) fn rule_matches( rule: &PolicyRule, @@ -130,7 +130,10 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool { - allowed.is_empty() || allowed.iter().any(|source| source.as_ref().eq_ignore_ascii_case(value)) + allowed.is_empty() + || allowed + .iter() + .any(|source| literal_case_insensitive_match(value, source.as_ref())) } fn package_identifiers_match( diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index 8b6776a08..e146f53f6 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use chrono::Utc; use now_policy::{ Decision, PackageIdentifier, PackageIdentifierCondition, PolicyDocument, PolicyEnforcement, PolicyFormatVersion, - PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, + PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, SourceName, }; use now_policy_api::{self as api, PackageRequest}; @@ -128,6 +128,44 @@ fn deny_unmatched_package() { assert_eq!(result.rule_id, ""); } +#[test] +fn unicode_case_equivalent_source_deny_outranks_allow() { + let policy = make_policy( + Decision::Deny, + vec![ + PolicyRule { + id: ResourceId::from("allow-any"), + enabled: true, + priority: 100, + decision: Decision::Allow, + reason: None, + match_criteria: PolicyMatch::default(), + constraints: None, + }, + PolicyRule { + id: ResourceId::from("deny-corp"), + enabled: true, + priority: 100, + decision: Decision::Deny, + reason: None, + match_criteria: PolicyMatch { + source_names: BTreeSet::from([SourceName::parse("CÖRP").expect("valid source")]), + ..Default::default() + }, + constraints: None, + }, + ], + ); + let mut request = make_request(api::Operation::Install, "Example.Package"); + request.manager = api::ManagerName::PowerShell; + request.source.name = "cörp".to_owned(); + + let result = evaluate(&policy, &request); + + assert_eq!(result.decision, Decision::Deny); + assert_eq!(result.rule_id, "deny-corp"); +} + #[test] fn disabled_rules_are_ignored() { let policy = make_policy( diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index 68e99df85..4e75e6954 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -10,6 +10,18 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } +/// Match an exact source name using Unicode-aware, case-insensitive semantics. +/// +/// Source names are literals, so escaping before enabling case-insensitive regex +/// matching preserves a literal `*` rather than applying wildcard semantics. +pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool { + let regex_pattern = format!("^{}$", regex::escape(expected)); + regex::RegexBuilder::new(®ex_pattern) + .case_insensitive(true) + .build() + .is_ok_and(|re| re.is_match(value)) +} + fn wildcard_match(value: &str, pattern: &str) -> bool { // Convert glob pattern to regex: escape everything except *, which becomes .* let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); @@ -47,4 +59,10 @@ mod tests { assert!(wildcard_any("Contoso.Tools+", &patterns)); assert!(!wildcard_any("Contoso.Toolss", &patterns)); } + + #[test] + fn literal_match_uses_unicode_case_insensitive_semantics() { + assert!(literal_case_insensitive_match("cörp", "CÖRP")); + assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); + } } From 11b4733c5cd778e65d2190193437cdd0be0a54fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:43:08 +0900 Subject: [PATCH 43/53] fix(agent): normalize policy source names Normalize source names before ordinal matching so policy evaluation uses the same canonical repository identity as PowerShell. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/now-package-broker/Cargo.toml | 1 + .../src/evaluator/wildcard.rs | 23 +++++++++++-------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b29f1820d..74e389f1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4844,6 +4844,7 @@ dependencies = [ "tokio-util", "tower-service", "tracing", + "unicode-normalization", "uuid", "widestring 1.2.1", "win-api-wrappers", diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index fca797a41..5b407647b 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -49,6 +49,7 @@ tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parki tokio-util = "0.7" tower-service = "0.3" tracing = "0.1" +unicode-normalization = "0.1" uuid = { version = "1.23", features = ["v4"] } widestring = "1.2" win-api-wrappers = { path = "../win-api-wrappers" } diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index 4e75e6954..d8a201467 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -2,6 +2,9 @@ use std::collections::BTreeSet; +use unicode_normalization::UnicodeNormalization as _; +use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; + pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } @@ -10,16 +13,17 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } -/// Match an exact source name using Unicode-aware, case-insensitive semantics. +/// Match an exact source name using the PowerShell repository identity semantics. /// -/// Source names are literals, so escaping before enabling case-insensitive regex -/// matching preserves a literal `*` rather than applying wildcard semantics. +/// PowerShell resolves repository names after canonical Unicode normalization with +/// ordinal case-insensitive comparison. +/// Source names are literals, so this deliberately does not apply wildcard semantics. pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool { - let regex_pattern = format!("^{}$", regex::escape(expected)); - regex::RegexBuilder::new(®ex_pattern) - .case_insensitive(true) - .build() - .is_ok_and(|re| re.is_match(value)) + let value: Vec = value.nfc().collect::().encode_utf16().collect(); + let expected: Vec = expected.nfc().collect::().encode_utf16().collect(); + + // SAFETY: The binding marshals both valid UTF-8 strings as bounded UTF-16. + unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL } } fn wildcard_match(value: &str, pattern: &str) -> bool { @@ -62,7 +66,8 @@ mod tests { #[test] fn literal_match_uses_unicode_case_insensitive_semantics() { - assert!(literal_case_insensitive_match("cörp", "CÖRP")); + assert!(literal_case_insensitive_match("CO\u{0308}RP", "CÖRP")); + assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery")); assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); } } From cc47854d99b994e6b9b9f0174839e0c2bd3a88aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:50:56 +0900 Subject: [PATCH 44/53] fix(agent): reject ambiguous policy sources Reject source spellings containing default-ignorable characters before PowerShell can resolve them to a different policy identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/evaluator/mod.rs | 6 ++++++ .../now-package-broker/src/evaluator/tests.rs | 8 +++++++- .../src/evaluator/wildcard.rs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index d0376caf8..9d09208c5 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -113,6 +113,12 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci } } +/// Whether a source spelling has a stable identity across package-manager lookup and +/// policy evaluation. +pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool { + !wildcard::has_default_ignorable_code_point(source_name) +} + pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation { if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated { Elevation::Elevated diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index e146f53f6..ec0993278 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -9,7 +9,7 @@ use now_policy::{ }; use now_policy_api::{self as api, PackageRequest}; -use super::evaluate; +use super::{evaluate, source_name_is_unambiguous}; fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocument { PolicyDocument { @@ -166,6 +166,12 @@ fn unicode_case_equivalent_source_deny_outranks_allow() { assert_eq!(result.rule_id, "deny-corp"); } +#[test] +fn default_ignorable_source_spelling_is_rejected_before_evaluation() { + assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery")); + assert!(source_name_is_unambiguous("PSGallery")); +} + #[test] fn disabled_rules_are_ignored() { let policy = make_policy( diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index d8a201467..fc586e9a8 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -1,10 +1,14 @@ //! Case-insensitive wildcard matching helpers. use std::collections::BTreeSet; +use std::sync::LazyLock; use unicode_normalization::UnicodeNormalization as _; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; +static DEFAULT_IGNORABLE_CODE_POINT: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\p{Default_Ignorable_Code_Point}").expect("valid Unicode property regex")); + pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } @@ -26,6 +30,14 @@ pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> boo unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL } } +/// Default-ignorable characters are rejected before matching and command building. +/// +/// PowerShell repository lookup ignores them, while an opaque package request would +/// otherwise preserve them for `-Repository` and make policy identity ambiguous. +pub(super) fn has_default_ignorable_code_point(value: &str) -> bool { + DEFAULT_IGNORABLE_CODE_POINT.is_match(value) +} + fn wildcard_match(value: &str, pattern: &str) -> bool { // Convert glob pattern to regex: escape everything except *, which becomes .* let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); @@ -70,4 +82,10 @@ mod tests { assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery")); assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); } + + #[test] + fn default_ignorable_source_characters_are_detected() { + assert!(has_default_ignorable_code_point("PS\u{00AD}Gallery")); + assert!(!has_default_ignorable_code_point("CÖRP")); + } } From e8f56e65e660f2a5f131d7e91b00deee6624c17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:51:02 +0900 Subject: [PATCH 45/53] fix(agent): validate package source identity Reject default-ignorable source spellings before policy evaluation and command construction can disagree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/server/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index fbba6157c..46fa5dfa5 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -754,6 +754,17 @@ impl BrokerState { reason = "the shared API contract requires ErrorResponse values" )] fn evaluate_request(&self, request: &PackageRequest) -> Result { + if !evaluator::source_name_is_unambiguous(&request.source.name) { + warn!( + request_id = %request.request_id, + "Rejecting request: package source name contains default-ignorable characters" + ); + return Err(error_response( + ErrorCode::ValidationFailed, + "package source name contains unsupported default-ignorable characters", + )); + } + // SECURITY: Pre/post operation commands are raw command strings executed via // cmd.exe with the execution token, and the policy schema cannot restrict // their content yet. Running them elevated would grant arbitrary elevated From a92258d1d2066991690d65b12c63e8ecaf4b9be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:56:29 +0900 Subject: [PATCH 46/53] fix(agent): bound pipe connections from accept Carry the ordinary pipe deadline through client capture and serving so unauthenticated clients cannot reserve a connection slot twice as long. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 1d83b0b76..3ebe2c346 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -76,6 +76,7 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke match result { Ok(()) => { let state = Arc::clone(&state); + let connection_deadline = tokio::time::Instant::now() + CONNECTION_DEADLINE; tokio::spawn(async move { // Keep blocking unauthenticated capture off the accept loop and // retain the connection slot until the work actually completes. @@ -83,7 +84,8 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke let client = PipeClient::from_connected_pipe(&server); (server, client) }); - let (_permit, server, client) = match tokio::time::timeout(CONNECTION_DEADLINE, capture).await { + let (_permit, server, client) = + match tokio::time::timeout_at(connection_deadline, capture).await { Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client), Ok(Ok((_permit, (_server, Err(error))))) => { warn!(error = format!("{error:#}"), "Rejected named pipe client"); @@ -111,25 +113,23 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke ); let serve = serve_connection(server, router); tokio::pin!(serve); - let connection_deadline = tokio::time::sleep(CONNECTION_DEADLINE); - tokio::pin!(connection_deadline); - let policy_deadline = tokio::time::sleep(POLICY_CONSENT_CONNECTION_DEADLINE); - tokio::pin!(policy_deadline); let mut policy_write_is_authorized = false; + let mut deadline = connection_deadline; loop { tokio::select! { () = &mut serve => break, - () = &mut connection_deadline, if !policy_write_is_authorized => { - warn!("Closed named pipe connection: deadline exceeded"); - break; - } - () = &mut policy_deadline, if policy_write_is_authorized => { - warn!("Closed named pipe policy replacement: deadline exceeded"); + () = tokio::time::sleep_until(deadline) => { + if policy_write_is_authorized { + warn!("Closed named pipe policy replacement: deadline exceeded"); + } else { + warn!("Closed named pipe connection: deadline exceeded"); + } break; } result = policy_write_authorized.changed(), if !policy_write_is_authorized => { if result.is_ok() && *policy_write_authorized.borrow_and_update() { policy_write_is_authorized = true; + deadline = tokio::time::Instant::now() + POLICY_CONSENT_CONNECTION_DEADLINE; } } } From 25155a54efb9f198f7bbeec7f14f63986f3667c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 06:48:31 +0900 Subject: [PATCH 47/53] fix(agent): reject padded policy sources Reject noncanonical source spellings before policy matching so PowerShell repository trimming cannot bypass a source-specific rule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/evaluator/mod.rs | 2 +- crates/now-package-broker/src/evaluator/tests.rs | 2 ++ crates/now-package-broker/src/server/mod.rs | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index 9d09208c5..729c8abc6 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -116,7 +116,7 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci /// Whether a source spelling has a stable identity across package-manager lookup and /// policy evaluation. pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool { - !wildcard::has_default_ignorable_code_point(source_name) + source_name == source_name.trim() && !wildcard::has_default_ignorable_code_point(source_name) } pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation { diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index ec0993278..3e845a5e3 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -169,6 +169,8 @@ fn unicode_case_equivalent_source_deny_outranks_allow() { #[test] fn default_ignorable_source_spelling_is_rejected_before_evaluation() { assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery")); + assert!(!source_name_is_unambiguous("PSGallery ")); + assert!(!source_name_is_unambiguous(" PSGallery")); assert!(source_name_is_unambiguous("PSGallery")); } diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 46fa5dfa5..0dcc9b9d3 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -757,11 +757,11 @@ impl BrokerState { if !evaluator::source_name_is_unambiguous(&request.source.name) { warn!( request_id = %request.request_id, - "Rejecting request: package source name contains default-ignorable characters" + "Rejecting request: package source name has ambiguous spelling" ); return Err(error_response( ErrorCode::ValidationFailed, - "package source name contains unsupported default-ignorable characters", + "package source name has unsupported leading, trailing, or default-ignorable characters", )); } From 3ac7208bd6e4ce2584fa68887ce1befc9bc3e24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 06:57:41 +0900 Subject: [PATCH 48/53] fix(agent): preserve manager source identity Apply PowerShell source canonicalization only to PowerShell so other package managers retain their own source identity semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/evaluator/matching.rs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs index 782070c21..3bd0dbf80 100644 --- a/crates/now-package-broker/src/evaluator/matching.rs +++ b/crates/now-package-broker/src/evaluator/matching.rs @@ -21,7 +21,7 @@ pub(super) fn rule_matches( operations_match(request.operation, &m.operations) && managers_match(request.manager, &m.managers) - && source_names_match(&request.source.name, &m.source_names) + && source_names_match(request.manager, &request.source.name, &m.source_names) && package_identifiers_match(&request.package.id, &m.package_identifiers) && versions_match(effective_version, &m.version) && scopes_match(request.options.scope, &m.scopes) @@ -129,11 +129,18 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool { +fn source_names_match( + manager: now_policy_api::ManagerName, + value: &str, + allowed: &BTreeSet, +) -> bool { allowed.is_empty() - || allowed - .iter() - .any(|source| literal_case_insensitive_match(value, source.as_ref())) + || allowed.iter().any(|source| match manager { + now_policy_api::ManagerName::PowerShell | now_policy_api::ManagerName::PowerShell7 => { + literal_case_insensitive_match(value, source.as_ref()) + } + _ => source.as_ref().eq_ignore_ascii_case(value), + }) } fn package_identifiers_match( @@ -265,6 +272,20 @@ mod tests { })); } + #[test] + fn non_powershell_source_names_remain_canonically_distinct() { + let mut request = request(); + request.manager = api::ManagerName::Scoop; + request.source.name = "CO\u{0308}RP".to_owned(); + let flags = RequestFlags::from_request(&request); + let rule = rule(PolicyMatch { + source_names: BTreeSet::from([now_policy::SourceName::parse("CÖRP").expect("valid source")]), + ..Default::default() + }); + + assert!(!rule_matches(&rule, &request, &flags, "1.2.3")); + } + #[test] fn absent_scope_or_architecture_in_request_fails_when_rule_restricts_them() { let mut request = request(); From ee9613efeaff5f369ab4c458cc08e22fad00c137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 07:17:31 +0900 Subject: [PATCH 49/53] fix(agent): validate policy source spelling Reject policy source spellings that cannot safely match package requests before they can create unusable source-specific rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/validation.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs index 2f0771679..362b0998e 100644 --- a/crates/now-package-broker/src/policy_store/validation.rs +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -10,6 +10,8 @@ use now_policy_api::{ API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult, }; +use crate::evaluator; + pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/10"; const MAX_RULES: usize = 1024; const MAX_RULE_PRIORITY: u32 = i32::MAX as u32; @@ -419,6 +421,17 @@ fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) { if let Some(reason) = &rule.reason { check_string_len(reason, 0, 512, &format!("{base}/Reason"), findings); } + for (source_index, source_name) in rule.match_criteria.source_names.iter().enumerate() { + if !evaluator::source_name_is_unambiguous(source_name.as_ref()) { + findings.push(rule_finding( + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::InvalidFieldValue, + format!("{base}/Match/SourceNames/{source_index}"), + "SourceNames must not contain leading, trailing, or default-ignorable characters", + )); + } + } if let Some(PackageIdentifierCondition::Patterns(patterns)) = &rule.match_criteria.package_identifiers { check_patterns( index, @@ -709,6 +722,18 @@ mod tests { assert_eq!(canonical.pointer("/Rules/0/Match/Interactive"), Some(&json!(false))); } + #[test] + fn source_names_reject_ambiguous_spellings() { + for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] { + let mut raw = draft(); + raw["Rules"] = json!([rule("deny", json!({ "SourceNames": [source_name] }))]); + + let result = validate_draft(&raw); + + assert!(!result.is_valid, "{source_name:?} must be rejected"); + } + } + #[test] fn shared_contract_rejects_invalid_rule_shapes() { let cases = [ From 0a279f2f381e9a40a3bfaa1974f5e8fed9101a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 07:20:48 +0900 Subject: [PATCH 50/53] test(agent): cover policy source spelling guard Exercise ambiguous SourceNames with a valid PowerShell rule so the regression protects the shared policy-validation predicate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/validation.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs index 362b0998e..c24cf8da7 100644 --- a/crates/now-package-broker/src/policy_store/validation.rs +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -726,12 +726,28 @@ mod tests { fn source_names_reject_ambiguous_spellings() { for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] { let mut raw = draft(); - raw["Rules"] = json!([rule("deny", json!({ "SourceNames": [source_name] }))]); + raw["Rules"] = json!([rule( + "deny", + json!({ "Managers": ["PowerShell"], "SourceNames": [source_name] }) + )]); let result = validate_draft(&raw); assert!(!result.is_valid, "{source_name:?} must be rejected"); + assert!( + result + .findings + .iter() + .any(|finding| finding.path == "/Rules/0/Match/SourceNames/0") + ); } + + let mut valid = draft(); + valid["Rules"] = json!([rule( + "deny", + json!({ "Managers": ["PowerShell"], "SourceNames": ["PSGallery"] }) + )]); + assert!(validate_draft(&valid).is_valid); } #[test] From bed86900aefb763bce4a8df8b95c7bcf4cadef53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 10:54:40 +0900 Subject: [PATCH 51/53] fix(agent,agent-installer): publish helper discovery in both views Publish consent-helper discovery and configured broker-pipe values to the 32-bit registry view so supported x86 consumers find the protected helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-agent/src/service.rs | 22 ++++++++- .../PolicyConsentDiscoveryTests.cs | 26 ++++++++++ package/AgentWindowsManaged/Program.cs | 48 ++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 46a59f4fe..cd3f84b1d 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -31,6 +31,8 @@ pub(crate) const DESCRIPTION: &str = "Devolutions Agent service"; const POLICY_CONSENT_DISCOVERY_KEY: &str = r"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; #[cfg(windows)] const POLICY_CONSENT_BROKER_PIPE_NAME_VALUE: &str = "BrokerPipeName"; +#[cfg(all(windows, target_pointer_width = "64"))] +const KEY_WOW64_32KEY: u32 = 0x0200; struct TasksCtx { /// Spawned service tasks @@ -293,7 +295,25 @@ fn publish_policy_consent_broker_pipe_name(pipe_name: &str) -> anyhow::Result<() .write() .open(POLICY_CONSENT_DISCOVERY_KEY) .context("open policy consent helper discovery key")?; - publish_policy_consent_broker_pipe_name_to(&key, pipe_name) + publish_policy_consent_broker_pipe_name_to(&key, pipe_name)?; + + // A 32-bit UniGetUI process reads HKLM\Software through WOW6432Node, while the + // 64-bit Agent service naturally opens the native view. Keep configured pipe + // discovery synchronized with the MSI's dual-view contract. + #[cfg(target_pointer_width = "64")] + { + let wow64_key = LOCAL_MACHINE + .options() + .read() + .write() + .access(KEY_WOW64_32KEY) + .open(POLICY_CONSENT_DISCOVERY_KEY) + .context("open 32-bit policy consent helper discovery key")?; + publish_policy_consent_broker_pipe_name_to(&wow64_key, pipe_name) + .context("publish configured policy consent helper broker pipe to 32-bit registry view")?; + } + + Ok(()) } #[cfg(windows)] diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index f90219f71..c7c38d3a6 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Reflection; using WixSharp; @@ -41,6 +42,31 @@ public void DiscoveryUsesTheConsumerNativeRegistryView(Platform platform, bool e Assert.Equal(expected, Assert.IsType(method.Invoke(null, [platform]))); } + [Theory] + [InlineData(Platform.x86, 0)] + [InlineData(Platform.x64, 7)] + [InlineData(Platform.arm64, 7)] + public void NativeAgentMsiPublishesDiscoveryFor32BitConsumers(Platform platform, int expectedCount) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreatePolicyConsentRegistryValuesFor32BitConsumers", + BindingFlags.Static | BindingFlags.NonPublic); + + RegValue[] values = Assert.IsAssignableFrom>( + method.Invoke(null, [platform, new Version(2026, 3, 0)])) + .ToArray(); + + Assert.Equal(expectedCount, values.Length); + Assert.All(values, value => + { + Assert.False(value.Win64); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + }); + } + [Fact] public void DiscoveryPublishesTheFixedHelperIdentity() { diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index f279de32f..7df0ddab1 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -393,6 +393,9 @@ static void Main() }, CreateEventLogSourceRegistryValue(project.Platform == Platform.x64), }; + project.RegValues = project.RegValues + .Concat(CreatePolicyConsentRegistryValuesFor32BitConsumers(project.Platform, DevolutionsAgentProductVersion)) + .ToArray(); List projectProperties = AgentProperties.Properties.Select(x => x.ToWixSharpProperty()).ToList(); @@ -490,10 +493,53 @@ internal static RegValue CreatePolicyConsentRegistryValue(string name, string va Feature = Features.AGENT_FEATURE, }; - // Discovery follows the consumer's native registry view. + // 64-bit Agent MSIs publish discovery in both native and WOW6432Node views so a + // 32-bit UniGetUI consumer can discover the same protected helper. internal static bool Use64BitRegistryView(Platform? platform) => platform is Platform.x64 or Platform.arm64; + internal static IEnumerable CreatePolicyConsentRegistryValuesFor32BitConsumers( + Platform? platform, + Version productVersion) + { + if (!Use64BitRegistryView(platform)) + { + return []; + } + + return + [ + CreatePolicyConsentRegistryValue( + "ProtocolVersion", + Includes.POLICY_CONSENT_PROTOCOL_VERSION, + false), + CreatePolicyConsentRegistryValue( + "ExecutableName", + Includes.POLICY_CONSENT_EXECUTABLE_NAME, + false), + CreatePolicyConsentRegistryValue( + "ExecutablePath", + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", + false), + CreatePolicyConsentRegistryValue( + "ProductName", + Includes.POLICY_CONSENT_PRODUCT_NAME, + false), + CreatePolicyConsentRegistryValue( + "ProductVersion", + productVersion.ToString(), + false), + CreatePolicyConsentRegistryValue( + "BrokerPipeName", + Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME, + false), + CreatePolicyConsentRegistryValue( + "CurrentUiSignerSpkiSha256", + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, + false), + ]; + } + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = From c13d2a12287d808e96213bf70cf2e7d99f425f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 20 Sep 2026 17:59:47 +0900 Subject: [PATCH 52/53] fix(agent): align helper policy contract Remove the obsolete warning acknowledgement field so helper replacement requests match the released policy API after the parent rebase. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/server/mod.rs | 1 - .../AgentPolicyConsent.Tests/ProtocolTests.cs | 19 ++++++++----------- package/AgentPolicyConsent/BrokerClient.cs | 1 - package/AgentPolicyConsent/Protocol.cs | 1 - 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 0dcc9b9d3..9d5fcef8c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1129,7 +1129,6 @@ mod tests { "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), }); diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 389538c87..5ce35de96 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -311,7 +311,7 @@ await Assert.ThrowsAnyAsync( public void RequestRejectsUnknownJsonMembers() { string json = - $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{},"command":"cmd.exe"}"""; + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{},"command":"cmd.exe"}"""; Assert.Throws( () => JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ElevationRequest)); @@ -328,7 +328,6 @@ public void OfficialRequestContainsOnlyPolicyReplacementFields() "ConfirmOverwrite", "token", "receipt", - true, draft.RootElement.Clone()); using JsonDocument official = JsonDocument.Parse(BrokerClient.CreateOfficialRequest(request)); @@ -340,22 +339,22 @@ public void OfficialRequestContainsOnlyPolicyReplacementFields() "ExpectedStoreToken", "Operation", "ConflictHandling", - "WarningsAcknowledged", "Draft", "ValidationReceipt", ], names); } [Fact] - public void RequestRequiresEveryMemberAndObjectDraft() + public void RequestRejectsLegacyAcknowledgementMember() { - string missingAcknowledgement = - $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; + string legacyAcknowledgement = string.Concat("Warnings", "Acknowledged"); + string requestJson = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{},"{{legacyAcknowledgement}}":false}"""; Assert.Throws( - () => JsonSerializer.Deserialize(missingAcknowledgement, ProtocolJsonContext.Default.ElevationRequest)); + () => JsonSerializer.Deserialize(requestJson, ProtocolJsonContext.Default.ElevationRequest)); using JsonDocument draft = JsonDocument.Parse("[]"); - ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", false, draft.RootElement); + ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", draft.RootElement); Assert.Throws(() => Protocol.ValidateRequest(request)); } @@ -363,7 +362,7 @@ public void RequestRequiresEveryMemberAndObjectDraft() public void RequestRejectsExplicitNullRequiredMembers() { string nullRequestId = - """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{}}"""; + """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; ElevationRequest request = JsonSerializer.Deserialize( nullRequestId, @@ -400,7 +399,6 @@ public async Task OversizedOfficialRequestIsRejectedBeforeBrokerConnection() "Reject", "token", "receipt", - false, draft.RootElement.Clone()); ElevationResponse response = await BrokerClient.ReplaceAsync(request, CancellationToken.None); @@ -422,7 +420,6 @@ public void RequestRejectsUnknownOperations(string operation, string conflictHan conflictHandling, "a", "b", - false, draft.RootElement); Assert.Throws(() => Protocol.ValidateRequest(request)); diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index c2594e4b0..ac6c0773f 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -106,7 +106,6 @@ internal static byte[] CreateOfficialRequest(ElevationRequest request) writer.WriteString("ExpectedStoreToken", request.ExpectedStoreToken); writer.WriteString("Operation", request.Operation); writer.WriteString("ConflictHandling", request.ConflictHandling); - writer.WriteBoolean("WarningsAcknowledged", request.WarningsAcknowledged); writer.WritePropertyName("Draft"); request.Draft.WriteTo(writer); writer.WriteString("ValidationReceipt", request.ValidationReceipt); diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs index dcf07ed21..e26288560 100644 --- a/package/AgentPolicyConsent/Protocol.cs +++ b/package/AgentPolicyConsent/Protocol.cs @@ -214,7 +214,6 @@ internal sealed record ElevationRequest( [property: JsonRequired] string ConflictHandling, [property: JsonRequired] string ExpectedStoreToken, [property: JsonRequired] string ValidationReceipt, - [property: JsonRequired] bool WarningsAcknowledged, [property: JsonRequired] JsonElement Draft); [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] From 70bd15e50a5a35281058b1cd045b90586c3ccd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 15:37:18 +0900 Subject: [PATCH 53/53] refactor(agent-installer): deduplicate helper registry values Centralize the PolicyConsentHelper discovery values so native and WOW6432Node registry views stay synchronized without changing installer behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyConsentDiscoveryTests.cs | 62 +++++++++++-- package/AgentWindowsManaged/Program.cs | 89 +++++++------------ 2 files changed, 89 insertions(+), 62 deletions(-) diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index c7c38d3a6..81d027f3b 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -43,17 +43,22 @@ public void DiscoveryUsesTheConsumerNativeRegistryView(Platform platform, bool e } [Theory] - [InlineData(Platform.x86, 0)] - [InlineData(Platform.x64, 7)] - [InlineData(Platform.arm64, 7)] - public void NativeAgentMsiPublishesDiscoveryFor32BitConsumers(Platform platform, int expectedCount) + [InlineData(Platform.x86, 7)] + [InlineData(Platform.x64, 14)] + [InlineData(Platform.arm64, 14)] + public void AgentMsiPublishesDiscoveryInRequiredRegistryViews( + Platform platform, + int expectedCount) { Type program = System.Reflection.Assembly .Load("DevolutionsAgent") .GetType("DevolutionsAgent.Program", throwOnError: true); MethodInfo method = program.GetMethod( - "CreatePolicyConsentRegistryValuesFor32BitConsumers", - BindingFlags.Static | BindingFlags.NonPublic); + "CreatePolicyConsentRegistryValues", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + types: [typeof(Platform?), typeof(Version)], + modifiers: null); RegValue[] values = Assert.IsAssignableFrom>( method.Invoke(null, [platform, new Version(2026, 3, 0)])) @@ -62,9 +67,52 @@ public void NativeAgentMsiPublishesDiscoveryFor32BitConsumers(Platform platform, Assert.Equal(expectedCount, values.Length); Assert.All(values, value => { - Assert.False(value.Win64); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); }); + Assert.Equal( + new[] + { + "ProtocolVersion", + "ExecutableName", + "ExecutablePath", + "ProductName", + "ProductVersion", + "BrokerPipeName", + "CurrentUiSignerSpkiSha256", + }, + values.Take(7).Select(value => value.Name)); + Assert.Equal( + new[] + { + "2.0", + "DevolutionsAgentPolicyConsent.exe", + "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", + "Devolutions Agent Policy Consent", + "2026.3.0", + @"\\.\pipe\Devolutions.Now.PackageBroker.v1", + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + }, + values.Take(7).Select(value => value.Value)); + Assert.All(values.Take(7), value => + { + Assert.Equal(platform != Platform.x86, value.Win64); + Assert.Equal( + platform == Platform.x86 ? "Type=string" : "Type=string; Component:Win64=yes", + value.AttributesDefinition); + }); + + if (platform == Platform.x86) + { + return; + } + + Assert.Equal(values.Take(7).Select(value => value.Name), values.Skip(7).Select(value => value.Name)); + Assert.Equal(values.Take(7).Select(value => value.Value), values.Skip(7).Select(value => value.Value)); + Assert.All(values.Skip(7), value => + { + Assert.False(value.Win64); + Assert.Equal("Type=string", value.AttributesDefinition); + }); } [Fact] diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 7df0ddab1..c48f23579 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -339,34 +339,6 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, }, - CreatePolicyConsentRegistryValue( - "ProtocolVersion", - Includes.POLICY_CONSENT_PROTOCOL_VERSION, - Use64BitRegistryView(project.Platform)), - CreatePolicyConsentRegistryValue( - "ExecutableName", - Includes.POLICY_CONSENT_EXECUTABLE_NAME, - Use64BitRegistryView(project.Platform)), - CreatePolicyConsentRegistryValue( - "ExecutablePath", - $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", - Use64BitRegistryView(project.Platform)), - CreatePolicyConsentRegistryValue( - "ProductName", - Includes.POLICY_CONSENT_PRODUCT_NAME, - Use64BitRegistryView(project.Platform)), - CreatePolicyConsentRegistryValue( - "ProductVersion", - DevolutionsAgentProductVersion.ToString(), - Use64BitRegistryView(project.Platform)), - CreatePolicyConsentRegistryValue( - "BrokerPipeName", - Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME, - Use64BitRegistryView(project.Platform)), - CreatePolicyConsentRegistryValue( - "CurrentUiSignerSpkiSha256", - Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, - Use64BitRegistryView(project.Platform)), new (RegistryHive.LocalMachine, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", Includes.SERVICE_NAME, $"[{AgentProperties.InstallDir}]{Includes.DESKTOP_DIRECTORY_NAME}\\{Includes.DESKTOP_EXECUTABLE_NAME}") { Win64 = project.Platform == Platform.x64, @@ -394,7 +366,7 @@ static void Main() CreateEventLogSourceRegistryValue(project.Platform == Platform.x64), }; project.RegValues = project.RegValues - .Concat(CreatePolicyConsentRegistryValuesFor32BitConsumers(project.Platform, DevolutionsAgentProductVersion)) + .Concat(CreatePolicyConsentRegistryValues(project.Platform, DevolutionsAgentProductVersion)) .ToArray(); List projectProperties = AgentProperties.Properties.Select(x => x.ToWixSharpProperty()).ToList(); @@ -498,47 +470,54 @@ internal static RegValue CreatePolicyConsentRegistryValue(string name, string va internal static bool Use64BitRegistryView(Platform? platform) => platform is Platform.x64 or Platform.arm64; - internal static IEnumerable CreatePolicyConsentRegistryValuesFor32BitConsumers( + internal static IEnumerable CreatePolicyConsentRegistryValues( Platform? platform, Version productVersion) { - if (!Use64BitRegistryView(platform)) + bool nativeRegistryViewIs64Bit = Use64BitRegistryView(platform); + IEnumerable nativeRegistryValues = CreatePolicyConsentRegistryValues( + productVersion, + nativeRegistryViewIs64Bit); + + if (!nativeRegistryViewIs64Bit) { - return []; + return nativeRegistryValues; } - return + return nativeRegistryValues.Concat(CreatePolicyConsentRegistryValues(productVersion, false)); + } + + private static IEnumerable CreatePolicyConsentRegistryValues( + Version productVersion, + bool win64) => + CreatePolicyConsentDiscoveryValues(productVersion) + .Select(value => CreatePolicyConsentRegistryValue(value.Name, value.Value, win64)); + + private static IEnumerable<(string Name, string Value)> CreatePolicyConsentDiscoveryValues( + Version productVersion) => [ - CreatePolicyConsentRegistryValue( + ( "ProtocolVersion", - Includes.POLICY_CONSENT_PROTOCOL_VERSION, - false), - CreatePolicyConsentRegistryValue( + Includes.POLICY_CONSENT_PROTOCOL_VERSION), + ( "ExecutableName", - Includes.POLICY_CONSENT_EXECUTABLE_NAME, - false), - CreatePolicyConsentRegistryValue( + Includes.POLICY_CONSENT_EXECUTABLE_NAME), + ( "ExecutablePath", - $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", - false), - CreatePolicyConsentRegistryValue( + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}"), + ( "ProductName", - Includes.POLICY_CONSENT_PRODUCT_NAME, - false), - CreatePolicyConsentRegistryValue( + Includes.POLICY_CONSENT_PRODUCT_NAME), + ( "ProductVersion", - productVersion.ToString(), - false), - CreatePolicyConsentRegistryValue( + productVersion.ToString()), + ( "BrokerPipeName", - Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME, - false), - CreatePolicyConsentRegistryValue( + Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME), + ( "CurrentUiSignerSpkiSha256", - Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, - false), + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256), ]; - } private static void Project_UnhandledException(ExceptionEventArgs e) {