From 7d16f3ee8fd9f3e1967d38e27924ce6e14d359da 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/41] 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 8136ade6a..f02a0c47d 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 590b52eb8d7d3cd98c50a60f5981a1c681a34659 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/41] 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 70a94a8d9..19b11c8a9 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 @@ -735,9 +733,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 @@ -746,9 +741,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 @@ -1014,6 +1031,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") { @@ -1024,6 +1072,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 c90ca09e0780d37cd5557b5f0bbff3e573baf842 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/41] 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 35987bc90cb437d3f3700bfcd269fe54c40527da 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/41] 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 03e1d3100ef2072886f580e3b414463a6dece637 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/41] 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 86434e6d0f22a5f1bfde9199337495c8bb9fe061 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/41] 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 06625d5dcb1cdd612ffee53e4c4ff3770bd0ecd5 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/41] 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 19b11c8a9..d3cb5836f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1210,6 +1210,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 @@ -1454,7 +1468,7 @@ jobs: success: name: Success if: ${{ always() }} - needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, gateway-service-account-tests, 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, gateway-service-account-tests, 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 a7869c709e499b75900141f29fa55edf70c99ba1 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/41] 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 f0e671149727e8cd53fe0cd9cb0af154bffb595b 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/41] 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 f02a0c47d..b4e058208 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 0e3ff3b541d1fb8d105006c2396bd6963645c55f 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/41] 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 2e3becf80dede9d92c34b0a327ab36d007ec9173 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/41] 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 e6349c4c9e9b5c682993d7355b85e4c523ac30a2 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/41] 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 3e25250f479c756577691c2d90de65b084834e51 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/41] 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 b4e058208..a7782c1fa 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 63be2943379dc0fb02eed5f1b3f735bb831abb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:02:33 +0900 Subject: [PATCH 14/41] test(dgw,agent): check event catalogs from the testsuite The parity checks scraped the crate sources as text and hardcoded each event's insertion-string count, so they exercised nothing in either crate and drifted from the builders they mirrored. Move them into the testsuite, which already owns this area, and derive them from per-crate DECLARED_CODES manifests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 + crates/agent-sysevent-codes/src/lib.rs | 15 + .../tests/message_catalog_parity.rs | 48 --- crates/sysevent-codes/src/lib.rs | 42 +++ .../tests/message_catalog_parity.rs | 117 ------- testsuite/Cargo.toml | 2 + testsuite/tests/sysevent/message_catalog.rs | 297 ++++++++++++++++++ testsuite/tests/sysevent/mod.rs | 1 + 8 files changed, 359 insertions(+), 165 deletions(-) delete mode 100644 crates/agent-sysevent-codes/tests/message_catalog_parity.rs delete mode 100644 crates/sysevent-codes/tests/message_catalog_parity.rs create mode 100644 testsuite/tests/sysevent/message_catalog.rs diff --git a/Cargo.lock b/Cargo.lock index a7782c1fa..c0610dfee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7623,6 +7623,7 @@ dependencies = [ name = "testsuite" version = "0.0.0" dependencies = [ + "agent-sysevent-codes", "agent-tunnel", "agent-tunnel-libsql", "agent-tunnel-proto", @@ -7656,6 +7657,7 @@ dependencies = [ "serde", "serde_json", "sysevent", + "sysevent-codes", "sysevent-syslog", "sysevent-winevent", "tempfile", diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index 05620c3b0..16626543d 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -121,3 +121,18 @@ pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToSt .field("path", path.as_ref().display()) .field("reason", reason) } + +/// Every declared Agent event code, paired with its symbolic name. +/// +/// The Agent Windows message catalog is checked against this inventory, so a new event code has to +/// be registered here in addition to `devolutions-agent.mc`. +pub static DECLARED_CODES: &[(&str, u32)] = &[ + ("POLICY_WRITE_ATTEMPTED", POLICY_WRITE_ATTEMPTED), + ("POLICY_WRITE_DENIED", POLICY_WRITE_DENIED), + ("POLICY_CREATE_FAILED", POLICY_CREATE_FAILED), + ("POLICY_CREATE_SUCCEEDED", POLICY_CREATE_SUCCEEDED), + ("POLICY_CHANGE_FAILED", POLICY_CHANGE_FAILED), + ("POLICY_CHANGE_SUCCEEDED", POLICY_CHANGE_SUCCEEDED), + ("POLICY_EXTERNAL_CHANGE_APPLIED", POLICY_EXTERNAL_CHANGE_APPLIED), + ("POLICY_EXTERNAL_CHANGE_REJECTED", POLICY_EXTERNAL_CHANGE_REJECTED), +]; diff --git a/crates/agent-sysevent-codes/tests/message_catalog_parity.rs b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs deleted file mode 100644 index f838bfda0..000000000 --- a/crates/agent-sysevent-codes/tests/message_catalog_parity.rs +++ /dev/null @@ -1,48 +0,0 @@ -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/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index e2eaad987..627eb60bf 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -399,3 +399,45 @@ 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:#}")) } + +/// Every declared event code, paired with its symbolic name. +/// +/// The Windows message catalogs are checked against this inventory, so a new event code has to be +/// registered here in addition to `devolutions-gateway.mc` and `devolutions-agent.mc`. +pub static DECLARED_CODES: &[(&str, u32)] = &[ + ("SERVICE_STARTED", SERVICE_STARTED), + ("SERVICE_STOPPING", SERVICE_STOPPING), + ("CONFIG_INVALID", CONFIG_INVALID), + ("START_FAILED", START_FAILED), + ("BOOT_STACKTRACE_WRITTEN", BOOT_STACKTRACE_WRITTEN), + ("LISTENER_STARTED", LISTENER_STARTED), + ("LISTENER_BIND_FAILED", LISTENER_BIND_FAILED), + ("LISTENER_STOPPED", LISTENER_STOPPED), + ("TLS_CONFIGURED", TLS_CONFIGURED), + ("TLS_VERIFY_STRICT_DISABLED", TLS_VERIFY_STRICT_DISABLED), + ("TLS_CERTIFICATE_REJECTED", TLS_CERTIFICATE_REJECTED), + ("SYSTEM_CERT_SELECTED", SYSTEM_CERT_SELECTED), + ("TLS_KEY_LOAD_FAILED", TLS_KEY_LOAD_FAILED), + ("TLS_CERTIFICATE_NAME_MISMATCH", TLS_CERTIFICATE_NAME_MISMATCH), + ("TLS_NO_SUITABLE_CERTIFICATE", TLS_NO_SUITABLE_CERTIFICATE), + ("SESSION_OPENED", SESSION_OPENED), + ("SESSION_CLOSED", SESSION_CLOSED), + ("TOKEN_PROVISIONED", TOKEN_PROVISIONED), + ("TOKEN_REUSED", TOKEN_REUSED), + ("TOKEN_REUSE_LIMIT_EXCEEDED", TOKEN_REUSE_LIMIT_EXCEEDED), + ("RECORDING_STARTED", RECORDING_STARTED), + ("RECORDING_STOPPED", RECORDING_STOPPED), + ("RECORDING_ERROR", RECORDING_ERROR), + ("JWT_REJECTED", JWT_REJECTED), + ("JWT_ANOMALY", JWT_ANOMALY), + ("AUTHORIZATION_DENIED", AUTHORIZATION_DENIED), + ("AUTH_SUMMARY", AUTH_SUMMARY), + ("USER_SESSION_PROCESS_STARTED", USER_SESSION_PROCESS_STARTED), + ("USER_SESSION_PROCESS_TERMINATED", USER_SESSION_PROCESS_TERMINATED), + ("UPDATER_TASK_ENABLED", UPDATER_TASK_ENABLED), + ("UPDATER_ERROR", UPDATER_ERROR), + ("PEDM_ENABLED", PEDM_ENABLED), + ("RECORDING_STORAGE_LOW", RECORDING_STORAGE_LOW), + ("DEBUG_OPTIONS_ENABLED", DEBUG_OPTIONS_ENABLED), + ("XMF_NOT_FOUND", XMF_NOT_FOUND), +]; diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs deleted file mode 100644 index 4b65b680f..000000000 --- a/crates/sysevent-codes/tests/message_catalog_parity.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! 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", -]; - -#[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 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() - ); - } - } -} - -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] -} diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 1e3cad10e..a40691073 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -31,6 +31,7 @@ typed-builder = "0.21" tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } [dev-dependencies] +agent-sysevent-codes.path = "../crates/agent-sysevent-codes" agent-tunnel = { path = "../crates/agent-tunnel", features = ["test-utils"] } agent-tunnel-libsql = { path = "../crates/agent-tunnel-libsql" } agent-tunnel-proto = { path = "../crates/agent-tunnel-proto", features = ["serde"] } @@ -57,6 +58,7 @@ rustls-pemfile = "2" rustls-pki-types = "1" serde_json = "1" sysevent.path = "../crates/sysevent" +sysevent-codes.path = "../crates/sysevent-codes" tempfile = "3" test-utils.path = "../crates/test-utils" tokio-rustls = { version = "0.26", features = ["ring"] } diff --git a/testsuite/tests/sysevent/message_catalog.rs b/testsuite/tests/sysevent/message_catalog.rs new file mode 100644 index 000000000..7a8846a24 --- /dev/null +++ b/testsuite/tests/sysevent/message_catalog.rs @@ -0,0 +1,297 @@ +//! Verifies that declared event codes and the Windows message catalogs stay aligned. + +use std::path::{Path, PathBuf}; + +use sysevent::Entry; + +const GATEWAY_CATALOG: &str = "devolutions-gateway/devolutions-gateway.mc"; +const AGENT_CATALOG: &str = "devolutions-agent/devolutions-agent.mc"; + +/// A declared code set, with the catalogs that must define each of its codes. +struct DeclaredCodeSet { + codes_crate: &'static str, + codes: &'static [(&'static str, u32)], + catalogs: &'static [&'static str], +} + +const DECLARED_CODE_SETS: &[DeclaredCodeSet] = &[ + DeclaredCodeSet { + codes_crate: "sysevent-codes", + codes: sysevent_codes::DECLARED_CODES, + catalogs: &[GATEWAY_CATALOG, AGENT_CATALOG], + }, + DeclaredCodeSet { + codes_crate: "agent-sysevent-codes", + codes: agent_sysevent_codes::DECLARED_CODES, + catalogs: &[AGENT_CATALOG], + }, +]; + +#[test] +fn every_event_code_is_defined_once_in_every_catalog() { + for declared in DECLARED_CODE_SETS { + let DeclaredCodeSet { + codes_crate, + codes, + catalogs, + } = declared; + assert!(!codes.is_empty(), "{codes_crate} declares no event code"); + + for catalog in *catalogs { + let path = catalog_path(catalog); + let content = read(&path); + + for (name, code) in *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, + "{codes_crate}: {}: 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, + "{codes_crate}: {}: {expected_id} must be followed by {expected_name}", + path.display() + ); + } + } + } +} + +#[test] +fn every_catalog_message_terminates_each_translation() { + for declared in DECLARED_CODE_SETS { + let DeclaredCodeSet { + codes_crate, + codes, + catalogs, + } = declared; + + for catalog in *catalogs { + let path = catalog_path(catalog); + let content = read(&path); + assert!( + content.starts_with('\u{feff}'), + "{}: mc.exe requires a UTF-8 BOM to avoid decoding translations as ANSI", + path.display() + ); + + for (name, code) in *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"], + "{codes_crate}: {}: MessageId={code} {name} must define each translation once", + path.display() + ); + } + } + } +} + +#[test] +fn agent_policy_messages_insert_the_message_then_every_field() { + let path = catalog_path(AGENT_CATALOG); + let catalog = read(&path); + + for (code, entry) in agent_policy_events() { + assert_eq!( + entry.event_code, + Some(code), + "the builder for MessageId={code} must declare that event code" + ); + + // The Windows Event Log sink passes the message text as the first insertion string, then + // one string per field, so a message needs exactly one insertion per field plus one. + let count = u32::try_from(entry.fields.len() + 1).expect("the entry has few fields"); + let expected: Vec = (1..=count).collect(); + + let messages = catalog_messages(&catalog, code); + assert_eq!(messages.len(), 3, "{}: MessageId={code} translations", path.display()); + for message in messages { + assert_eq!( + insertions(message), + expected, + "{}: MessageId={code} must use %1 as the message and %2..%{count} as its fields: {message}", + path.display() + ); + } + } +} + +/// The Agent policy events, each paired with the entry its builder produces. +fn agent_policy_events() -> Vec<(u32, Entry)> { + let path = Path::new("C:\\ProgramData\\Devolutions\\Agent\\policy.json"); + + vec![ + ( + agent_sysevent_codes::POLICY_WRITE_ATTEMPTED, + agent_sysevent_codes::policy_write_attempted("S-1-5-18", "devolutions-agent.exe", "policy_write", path), + ), + ( + agent_sysevent_codes::POLICY_WRITE_DENIED, + agent_sysevent_codes::policy_write_denied( + "S-1-5-18", + "devolutions-agent.exe", + "policy_write", + path, + "request_rejected", + ), + ), + ( + agent_sysevent_codes::POLICY_CREATE_FAILED, + agent_sysevent_codes::policy_write_failed( + agent_sysevent_codes::POLICY_CREATE_FAILED, + "Policy creation failed", + "S-1-5-18", + "devolutions-agent.exe", + "policy_write", + path, + "create", + "failed", + "invalid_draft", + ), + ), + ( + agent_sysevent_codes::POLICY_CREATE_SUCCEEDED, + agent_sysevent_codes::policy_write_succeeded( + agent_sysevent_codes::POLICY_CREATE_SUCCEEDED, + "Policy creation succeeded", + "S-1-5-18", + "devolutions-agent.exe", + path, + "00000000-0000-0000-0000-000000000000", + "0", + "11111111-1111-1111-1111-111111111111", + 1, + "policy_write", + "create", + "succeeded", + ), + ), + ( + agent_sysevent_codes::POLICY_CHANGE_FAILED, + agent_sysevent_codes::policy_write_failed( + agent_sysevent_codes::POLICY_CHANGE_FAILED, + "Policy change failed", + "S-1-5-18", + "devolutions-agent.exe", + "policy_write", + path, + "change", + "stale_conflict", + "stale_store_token", + ), + ), + ( + agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED, + agent_sysevent_codes::policy_write_succeeded( + agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED, + "Policy change succeeded", + "S-1-5-18", + "devolutions-agent.exe", + path, + "11111111-1111-1111-1111-111111111111", + "1", + "22222222-2222-2222-2222-222222222222", + 2, + "policy_write", + "change", + "succeeded", + ), + ), + ( + agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, + agent_sysevent_codes::policy_external_change_applied(path, "22222222-2222-2222-2222-222222222222", 2), + ), + ( + agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, + agent_sysevent_codes::policy_external_change_rejected(path, "invalid"), + ), + ] +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +fn catalog_path(catalog: &str) -> PathBuf { + // The testsuite sits at the repository root, next to the product crates holding the catalogs. + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("the testsuite sits in the repository root") + .join(catalog) +} + +/// The message of each translation inside the `MessageId=` block. +fn catalog_messages(catalog: &str, code: u32) -> Vec<&str> { + let mut messages = Vec::new(); + let mut lines = message_block(catalog, code).lines(); + while let Some(line) = lines.next() { + if line.starts_with("Language=") { + messages.push(lines.next().unwrap_or_default()); + } + } + messages +} + +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] +} + +/// Insertion indices (`%1`, `%2`, ...) referenced by a catalog message, in ascending order. +fn insertions(message: &str) -> Vec { + let mut indices = Vec::new(); + let mut remaining = message; + + while let Some(percent) = remaining.find('%') { + remaining = &remaining[percent + 1..]; + let trailing = remaining.trim_start_matches(|character: char| character.is_ascii_digit()); + if trailing.len() == remaining.len() { + continue; + } + let (index, rest) = remaining.split_at(remaining.len() - trailing.len()); + indices.push(index.parse().expect("a catalog insertion index is a number")); + remaining = rest; + } + + indices.sort_unstable(); + indices +} diff --git a/testsuite/tests/sysevent/mod.rs b/testsuite/tests/sysevent/mod.rs index 1315fc9ae..65ffb4fdc 100644 --- a/testsuite/tests/sysevent/mod.rs +++ b/testsuite/tests/sysevent/mod.rs @@ -1,4 +1,5 @@ //! Integration tests for system-wide logging with fake sink implementations +mod message_catalog; mod syslog; mod winevent; From 2241298ad665767274899d334ce2c11488b0deb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:02:43 +0900 Subject: [PATCH 15/41] test(agent-installer): document the Event Log source test Record why the assertion exists: the broker writes audit events through the Devolutions Agent Windows Event Log source, so the installer has to declare that source and point it at the executable carrying the message table, or a release build silently loses the audit trail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EventLogSourceRegistryTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs index c6a05b87d..d04923b84 100644 --- a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs +++ b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs @@ -9,11 +9,25 @@ namespace DevolutionsAgent.Installer.Tests; public sealed class EventLogSourceRegistryTests { + // The Agent's policy audit trail is only readable if the installer declares the Windows Event + // Log source, so this test pins that declaration against regression. + // + // The broker's audit sink writes under the source name "Devolutions Agent" (WinEvent::new in + // now-package-broker/src/audit.rs). Windows resolves that source to the messages compiled into + // DevolutionsAgent.exe only from a registry source key named exactly after the runtime source + // name, with EventMessageFile pointing at the executable carrying the message table. A + // regression here fails nothing at build time: the sink degrades to a no-op on initialization + // failure and only logs a tracing error, so the release build would silently lose those events. + // + // This asserts the declared value only. It does not build or install an MSI, so it does not + // validate the WiX pipeline or the [INSTALLDIR] substitution. [Theory] [InlineData(true)] [InlineData(false)] public void SourceUsesNativeMsiRegistryLifecycle(bool win64) { + // The installer is an application, referenced for its build output only, so it is loaded by + // name and the helper is reached through reflection. Type program = System.Reflection.Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); MethodInfo method = program.GetMethod( "CreateEventLogSourceRegistryValue", @@ -21,13 +35,18 @@ public void SourceUsesNativeMsiRegistryLifecycle(bool win64) RegValue value = Assert.IsType(method.Invoke(null, [win64])); Assert.Equal(RegistryHive.LocalMachine, value.Root); + // The key has to match the source name used by the audit sink, or the message is unresolved. Assert.Equal(@"SYSTEM\CurrentControlSet\Services\EventLog\Application\Devolutions Agent", value.Key); Assert.Equal("EventMessageFile", value.Name); Assert.Equal("[INSTALLDIR]DevolutionsAgent.exe", value.Value); + // 64-bit readers only see the source if the value lands in the matching registry view. Assert.Equal(win64, value.Win64); + // Registered on install and removed on uninstall, without clobbering a source that an + // administrator or another product owns. Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); Assert.False(value.ForceCreateOnInstall); Assert.False(value.ForceDeleteOnUninstall); + // EventMessageFile has to be REG_SZ, since a REG_MULTI_SZ value is not read as a path. Assert.Contains("Type=string", value.AttributesDefinition); } } From ff404e64f487b0450de558f6af75865bb8d3d398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:42:30 +0900 Subject: [PATCH 16/41] refactor(dgw,agent): make the event code tables independent Each product now owns its event code table and its message catalog, and each binary embeds only the messages it can emit. The codes both products share, the service lifecycle block and the Agent Integration block, are declared and translated in both places, so the two products reuse the same ranges without sharing a crate or a catalog. The Agent Integration block moves from `sysevent-codes` to `agent-sysevent-codes`. Nothing has ever emitted those codes, so no message is lost. The catalog check now requires an exact match, in both directions, between a product's declared codes and its own catalog. That is what keeps a Gateway-only message out of the Agent binary, and an Agent-only message out of the Gateway binary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 124 +++++++- crates/sysevent-codes/src/lib.rs | 60 +--- devolutions-agent/devolutions-agent.mc | 316 +------------------- devolutions-gateway/devolutions-gateway.mc | 64 ---- testsuite/tests/sysevent/message_catalog.rs | 256 +++++++++++----- 5 files changed, 305 insertions(+), 515 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index 16626543d..82e28ddc8 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -1,9 +1,116 @@ -//! Devolutions Agent-specific Windows Event Log event definitions. +//! Devolutions Agent Windows Event Log event definitions. +//! +//! This crate is the Agent's event code table. It is independent from the Gateway's +//! (`sysevent-codes`): the two crates share no items, each product's catalog holds exactly the +//! codes declared here, and a block may be reused by both products. Lifecycle and Agent +//! Integration codes below are duplicated from the Gateway table on purpose, so the Agent never +//! ships a message it cannot emit. use std::path::Path; use sysevent::{Entry, Severity}; +// 1000-1099 **Service/Lifecycle** + +/// Fired after the Agent service started. +pub const SERVICE_STARTED: u32 = 1000; +/// Graceful stop received. +pub const SERVICE_STOPPING: u32 = 1001; +/// Failed to init config. +pub const CONFIG_INVALID: u32 = 1010; +/// Top-level start failure (often transient). +pub const START_FAILED: u32 = 1020; +/// A boot crash trace was persisted. +pub const BOOT_STACKTRACE_WRITTEN: u32 = 1030; + +pub fn service_started(version: impl ToString) -> Entry { + Entry::new("Service started") + .event_code(SERVICE_STARTED) + .severity(Severity::Info) + .field("version", version) +} + +pub fn service_stopping(reason: impl ToString) -> Entry { + Entry::new("Service stopping") + .event_code(SERVICE_STOPPING) + .severity(Severity::Info) + .field("reason", reason) +} + +pub fn config_invalid(error: impl std::fmt::Display, path: impl AsRef) -> Entry { + Entry::new("Configuration invalid") + .event_code(CONFIG_INVALID) + .severity(Severity::Critical) + .field("path", path.as_ref().display()) + .field("error_chain", format!("{error:#}")) + .field("reason_code", "invalid_config") +} + +pub fn start_failed(error: impl std::fmt::Display, cause: impl ToString) -> Entry { + Entry::new("Start failed") + .event_code(START_FAILED) + .severity(Severity::Error) + .field("cause", cause) // e.g. "bind", "dependency", "tls", "io" + .field("error_chain", format!("{error:#}")) +} + +pub fn boot_stacktrace_written(path: &Path) -> Entry { + Entry::new("Boot stacktrace written") + .event_code(BOOT_STACKTRACE_WRITTEN) + .severity(Severity::Warning) + .field("path", path.display()) +} + +// 6000-6099 **Agent Integration** + +/// `DevolutionsSession.exe` started in session; include session id & kind (console/remote). +pub const USER_SESSION_PROCESS_STARTED: u32 = 6000; +/// Exit code; who triggered. +pub const USER_SESSION_PROCESS_TERMINATED: u32 = 6001; +pub const UPDATER_TASK_ENABLED: u32 = 6010; +pub const UPDATER_ERROR: u32 = 6011; +pub const PEDM_ENABLED: u32 = 6020; + +pub fn user_session_process_started(session_id: u32, kind: impl ToString, exe: impl ToString) -> Entry { + Entry::new("User session process started") + .event_code(USER_SESSION_PROCESS_STARTED) + .severity(Severity::Info) + .field("session_id", session_id) + .field("kind", kind) // "console","remote" + .field("exe", exe) +} + +pub fn user_session_process_terminated(session_id: u32, exit_code: i32, by: impl ToString) -> Entry { + Entry::new("User session process terminated") + .event_code(USER_SESSION_PROCESS_TERMINATED) + .severity(Severity::Info) + .field("session_id", session_id) + .field("exit_code", exit_code) + .field("by", by) // "user","service","timeout" +} + +pub fn updater_task_enabled() -> Entry { + Entry::new("Updater task enabled") + .event_code(UPDATER_TASK_ENABLED) + .severity(Severity::Info) +} + +pub fn updater_error(step: impl ToString, error: impl std::fmt::Display) -> Entry { + Entry::new("Updater error") + .event_code(UPDATER_ERROR) + .severity(Severity::Error) + .field("step", step) // "download","verify","apply","rollback" + .field("error_chain", format!("{error:#}")) +} + +pub fn pedm_enabled() -> Entry { + Entry::new("PEDM enabled") + .event_code(PEDM_ENABLED) + .severity(Severity::Info) +} + +// 8000-8099 **Package Broker / Policy Management** + pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; pub const POLICY_WRITE_DENIED: u32 = 8001; pub const POLICY_CREATE_FAILED: u32 = 8002; @@ -124,9 +231,20 @@ pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToSt /// Every declared Agent event code, paired with its symbolic name. /// -/// The Agent Windows message catalog is checked against this inventory, so a new event code has to -/// be registered here in addition to `devolutions-agent.mc`. +/// `devolutions-agent.mc` is checked against this inventory, and the check is an exact match in +/// both directions, so adding a code here means adding its messages to the catalog, and a +/// Gateway-only code must never appear there. pub static DECLARED_CODES: &[(&str, u32)] = &[ + ("SERVICE_STARTED", SERVICE_STARTED), + ("SERVICE_STOPPING", SERVICE_STOPPING), + ("CONFIG_INVALID", CONFIG_INVALID), + ("START_FAILED", START_FAILED), + ("BOOT_STACKTRACE_WRITTEN", BOOT_STACKTRACE_WRITTEN), + ("USER_SESSION_PROCESS_STARTED", USER_SESSION_PROCESS_STARTED), + ("USER_SESSION_PROCESS_TERMINATED", USER_SESSION_PROCESS_TERMINATED), + ("UPDATER_TASK_ENABLED", UPDATER_TASK_ENABLED), + ("UPDATER_ERROR", UPDATER_ERROR), + ("PEDM_ENABLED", PEDM_ENABLED), ("POLICY_WRITE_ATTEMPTED", POLICY_WRITE_ATTEMPTED), ("POLICY_WRITE_DENIED", POLICY_WRITE_DENIED), ("POLICY_CREATE_FAILED", POLICY_CREATE_FAILED), diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index 627eb60bf..d404b3f6c 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -320,54 +320,6 @@ pub fn auth_summary( .field("by_reason", by_reason_json) } -// 6000-6099 **Agent Integration** - -/// `DevolutionsSession.exe` started in session; include session id & kind (console/remote). -pub const USER_SESSION_PROCESS_STARTED: u32 = 6000; -/// Exit code; who triggered. -pub const USER_SESSION_PROCESS_TERMINATED: u32 = 6001; -pub const UPDATER_TASK_ENABLED: u32 = 6010; -pub const UPDATER_ERROR: u32 = 6011; -pub const PEDM_ENABLED: u32 = 6020; - -pub fn user_session_process_started(session_id: u32, kind: impl ToString, exe: impl ToString) -> Entry { - Entry::new("User session process started") - .event_code(USER_SESSION_PROCESS_STARTED) - .severity(Severity::Info) - .field("session_id", session_id) - .field("kind", kind) // "console","remote" - .field("exe", exe) -} - -pub fn user_session_process_terminated(session_id: u32, exit_code: i32, by: impl ToString) -> Entry { - Entry::new("User session process terminated") - .event_code(USER_SESSION_PROCESS_TERMINATED) - .severity(Severity::Info) - .field("session_id", session_id) - .field("exit_code", exit_code) - .field("by", by) // "user","service","timeout" -} - -pub fn updater_task_enabled() -> Entry { - Entry::new("Updater task enabled") - .event_code(UPDATER_TASK_ENABLED) - .severity(Severity::Info) -} - -pub fn updater_error(step: impl ToString, error: impl std::fmt::Display) -> Entry { - Entry::new("Updater error") - .event_code(UPDATER_ERROR) - .severity(Severity::Error) - .field("step", step) // "download","verify","apply","rollback" - .field("error_chain", format!("{error:#}")) -} - -pub fn pedm_enabled() -> Entry { - Entry::new("PEDM enabled") - .event_code(PEDM_ENABLED) - .severity(Severity::Info) -} - // 7000-7099 **Health** pub const RECORDING_STORAGE_LOW: u32 = 7010; // (Warning): remaining_bytes, threshold_bytes @@ -400,10 +352,11 @@ pub fn xmf_not_found(path: impl AsRef, error: impl std::fmt::Display) -> E .field("error_chain", format!("{error:#}")) } -/// Every declared event code, paired with its symbolic name. +/// Every declared Gateway event code, paired with its symbolic name. /// -/// The Windows message catalogs are checked against this inventory, so a new event code has to be -/// registered here in addition to `devolutions-gateway.mc` and `devolutions-agent.mc`. +/// `devolutions-gateway.mc` is checked against this inventory, and the check is an exact match in +/// both directions, so adding a code here means adding its messages to the catalog, and an +/// Agent-only code must never appear there. pub static DECLARED_CODES: &[(&str, u32)] = &[ ("SERVICE_STARTED", SERVICE_STARTED), ("SERVICE_STOPPING", SERVICE_STOPPING), @@ -432,11 +385,6 @@ pub static DECLARED_CODES: &[(&str, u32)] = &[ ("JWT_ANOMALY", JWT_ANOMALY), ("AUTHORIZATION_DENIED", AUTHORIZATION_DENIED), ("AUTH_SUMMARY", AUTH_SUMMARY), - ("USER_SESSION_PROCESS_STARTED", USER_SESSION_PROCESS_STARTED), - ("USER_SESSION_PROCESS_TERMINATED", USER_SESSION_PROCESS_TERMINATED), - ("UPDATER_TASK_ENABLED", UPDATER_TASK_ENABLED), - ("UPDATER_ERROR", UPDATER_ERROR), - ("PEDM_ENABLED", PEDM_ENABLED), ("RECORDING_STORAGE_LOW", RECORDING_STORAGE_LOW), ("DEBUG_OPTIONS_ENABLED", DEBUG_OPTIONS_ENABLED), ("XMF_NOT_FOUND", XMF_NOT_FOUND), diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index 179f4f336..a3e0a8a1c 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -1,4 +1,8 @@ ; Devolutions Agent Windows Event Log message definitions. +; Scope: the codes declared in the `agent-sysevent-codes` crate, and only those. +; Codes shared with Devolutions Gateway are duplicated here, so the Agent never refers to a +; message it does not carry, and Gateway-only translations are absent. +; Languages: English, French, German. MessageIdTypedef=DWORD @@ -81,278 +85,6 @@ 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 @@ -415,20 +147,6 @@ 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 @@ -526,29 +244,3 @@ 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/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 470c1de05..5c8f24038 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -366,70 +366,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 ; ====================================================================== diff --git a/testsuite/tests/sysevent/message_catalog.rs b/testsuite/tests/sysevent/message_catalog.rs index 7a8846a24..68573fca0 100644 --- a/testsuite/tests/sysevent/message_catalog.rs +++ b/testsuite/tests/sysevent/message_catalog.rs @@ -1,4 +1,4 @@ -//! Verifies that declared event codes and the Windows message catalogs stay aligned. +//! Verifies that each product's declared event codes and Windows message catalog stay aligned. use std::path::{Path, PathBuf}; @@ -7,127 +7,149 @@ use sysevent::Entry; const GATEWAY_CATALOG: &str = "devolutions-gateway/devolutions-gateway.mc"; const AGENT_CATALOG: &str = "devolutions-agent/devolutions-agent.mc"; -/// A declared code set, with the catalogs that must define each of its codes. +/// A declared code set and the single catalog that must hold exactly its codes. struct DeclaredCodeSet { codes_crate: &'static str, codes: &'static [(&'static str, u32)], - catalogs: &'static [&'static str], + catalog: &'static str, } +/// The Gateway and the Agent own separate code tables, so each catalog holds the codes of its own +/// crate and nothing else. A code shared by both products is declared in both crates and appears in +/// both catalogs. const DECLARED_CODE_SETS: &[DeclaredCodeSet] = &[ DeclaredCodeSet { codes_crate: "sysevent-codes", codes: sysevent_codes::DECLARED_CODES, - catalogs: &[GATEWAY_CATALOG, AGENT_CATALOG], + catalog: GATEWAY_CATALOG, }, DeclaredCodeSet { codes_crate: "agent-sysevent-codes", codes: agent_sysevent_codes::DECLARED_CODES, - catalogs: &[AGENT_CATALOG], + catalog: AGENT_CATALOG, }, ]; #[test] -fn every_event_code_is_defined_once_in_every_catalog() { +fn every_catalog_defines_exactly_its_declared_codes() { for declared in DECLARED_CODE_SETS { let DeclaredCodeSet { codes_crate, codes, - catalogs, + catalog, } = declared; assert!(!codes.is_empty(), "{codes_crate} declares no event code"); - for catalog in *catalogs { - let path = catalog_path(catalog); - let content = read(&path); - - for (name, code) in *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, - "{codes_crate}: {}: expected one {expected_id}, found {}", - path.display(), - positions.len() - ); + let path = catalog_path(catalog); + let content = read(&path); + let defined = catalog_codes(&content, &path); - 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, - "{codes_crate}: {}: {expected_id} must be followed by {expected_name}", - path.display() - ); - } - } + let declared_names: Vec<&str> = codes.iter().map(|(name, _)| *name).collect(); + let defined_names: Vec<&str> = defined.iter().map(|(name, _)| name.as_str()).collect(); + + let missing: Vec<&str> = declared_names + .iter() + .copied() + .filter(|name| !defined_names.contains(name)) + .collect(); + assert!( + missing.is_empty(), + "{codes_crate}: {} does not define {missing:?}", + path.display() + ); + + let unexpected: Vec<&str> = defined_names + .iter() + .copied() + .filter(|name| !declared_names.contains(name)) + .collect(); + assert!( + unexpected.is_empty(), + "{} defines {unexpected:?}, which {codes_crate} does not declare; a catalog holds \ + exactly the codes of its own product", + path.display() + ); + + assert_eq!( + defined_names.len(), + declared_names.len(), + "{} defines {} messages for {} declared {codes_crate} codes; each code needs exactly \ + one message", + path.display(), + defined_names.len(), + declared_names.len() + ); } } #[test] fn every_catalog_message_terminates_each_translation() { for declared in DECLARED_CODE_SETS { - let DeclaredCodeSet { - codes_crate, - codes, - catalogs, - } = declared; - - for catalog in *catalogs { - let path = catalog_path(catalog); - let content = read(&path); - assert!( - content.starts_with('\u{feff}'), - "{}: mc.exe requires a UTF-8 BOM to avoid decoding translations as ANSI", - path.display() - ); + let DeclaredCodeSet { catalog, .. } = declared; + let path = catalog_path(catalog); + let content = read(&path); + assert!( + content.starts_with('\u{feff}'), + "{}: mc.exe requires a UTF-8 BOM to avoid decoding translations as ANSI", + path.display() + ); - for (name, code) in *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() - ); + for (name, code) in catalog_codes(&content, &path) { + 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!( - terminated, + !text.starts_with("Language="), "{}: MessageId={code} {language} lacks a message terminator", path.display() ); } - languages.sort_unstable(); - assert_eq!( - languages, - ["English", "French", "German"], - "{codes_crate}: {}: MessageId={code} {name} must define each translation once", + assert!( + terminated, + "{}: MessageId={code} {language} lacks a message terminator", path.display() ); } + languages.sort_unstable(); + assert_eq!( + languages, + ["English", "French", "German"], + "{}: MessageId={code} {name} must define each translation once", + path.display() + ); } } } #[test] -fn agent_policy_messages_insert_the_message_then_every_field() { +fn agent_messages_insert_the_message_then_every_field() { let path = catalog_path(AGENT_CATALOG); let catalog = read(&path); - for (code, entry) in agent_policy_events() { + let events = agent_events(); + let mut covered: Vec = events.iter().map(|(code, _)| *code).collect(); + covered.sort_unstable(); + let mut declared: Vec = agent_sysevent_codes::DECLARED_CODES + .iter() + .map(|(_, code)| *code) + .collect(); + declared.sort_unstable(); + assert_eq!( + covered, declared, + "every declared Agent code needs a builder here, so its insertion strings stay checked" + ); + + for (code, entry) in events { assert_eq!( entry.event_code, Some(code), @@ -152,11 +174,48 @@ fn agent_policy_messages_insert_the_message_then_every_field() { } } -/// The Agent policy events, each paired with the entry its builder produces. -fn agent_policy_events() -> Vec<(u32, Entry)> { +/// Every declared Agent event, each paired with the entry its builder produces. +fn agent_events() -> Vec<(u32, Entry)> { let path = Path::new("C:\\ProgramData\\Devolutions\\Agent\\policy.json"); - vec![ + let mut events = vec![ + ( + agent_sysevent_codes::SERVICE_STARTED, + agent_sysevent_codes::service_started("2026.3.0"), + ), + ( + agent_sysevent_codes::SERVICE_STOPPING, + agent_sysevent_codes::service_stopping("received stop control code"), + ), + ( + agent_sysevent_codes::CONFIG_INVALID, + agent_sysevent_codes::config_invalid("invalid config", path), + ), + ( + agent_sysevent_codes::START_FAILED, + agent_sysevent_codes::start_failed("failed to bind", "service_start"), + ), + ( + agent_sysevent_codes::BOOT_STACKTRACE_WRITTEN, + agent_sysevent_codes::boot_stacktrace_written(path), + ), + ( + agent_sysevent_codes::USER_SESSION_PROCESS_STARTED, + agent_sysevent_codes::user_session_process_started(1, "console", "DevolutionsSession.exe"), + ), + ( + agent_sysevent_codes::USER_SESSION_PROCESS_TERMINATED, + agent_sysevent_codes::user_session_process_terminated(1, 0, "user"), + ), + ( + agent_sysevent_codes::UPDATER_TASK_ENABLED, + agent_sysevent_codes::updater_task_enabled(), + ), + ( + agent_sysevent_codes::UPDATER_ERROR, + agent_sysevent_codes::updater_error("download", "invalid signature"), + ), + (agent_sysevent_codes::PEDM_ENABLED, agent_sysevent_codes::pedm_enabled()), ( agent_sysevent_codes::POLICY_WRITE_ATTEMPTED, agent_sysevent_codes::policy_write_attempted("S-1-5-18", "devolutions-agent.exe", "policy_write", path), @@ -241,7 +300,10 @@ fn agent_policy_events() -> Vec<(u32, Entry)> { agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, agent_sysevent_codes::policy_external_change_rejected(path, "invalid"), ), - ] + ]; + + events.sort_unstable_by_key(|(code, _)| *code); + events } fn read(path: &Path) -> String { @@ -256,6 +318,36 @@ fn catalog_path(catalog: &str) -> PathBuf { .join(catalog) } +/// Every message of a catalog, as its symbolic name and message id, in file order. +fn catalog_codes(content: &str, path: &Path) -> Vec<(String, u32)> { + let mut codes = Vec::new(); + let mut lines = content.lines(); + + while let Some(line) = lines.next() { + let Some(id) = line.strip_prefix("MessageId=") else { + continue; + }; + let id = id.trim(); + let name = lines + .next() + .unwrap_or_else(|| panic!("{}: MessageId={id} without a symbolic name", path.display())); + let name = name.strip_prefix("SymbolicName=").unwrap_or_else(|| { + panic!( + "{}: MessageId={id} must be followed by its SymbolicName, found {name:?}", + path.display() + ) + }); + + codes.push(( + name.trim().to_owned(), + id.parse() + .unwrap_or_else(|error| panic!("{}: MessageId={id} is not an event code: {error}", path.display())), + )); + } + + codes +} + /// The message of each translation inside the `MessageId=` block. fn catalog_messages(catalog: &str, code: u32) -> Vec<&str> { let mut messages = Vec::new(); @@ -268,12 +360,16 @@ fn catalog_messages(catalog: &str, code: u32) -> Vec<&str> { messages } +/// The lines following `MessageId=`, up to the next message. The trailing newline keeps a +/// code from matching a longer code such as `800` against `8001`. 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 marker = format!("MessageId={code}\n"); + let start = content + .find(&marker) + .unwrap_or_else(|| panic!("missing MessageId={code}")); let after = &content[start + marker.len()..]; let end = after.find("\nMessageId=").unwrap_or(after.len()); - &content[start..start + marker.len() + end] + &after[..end] } /// Insertion indices (`%1`, `%2`, ...) referenced by a catalog message, in ascending order. From abaf2533b7237781aee90387bfad523251e05b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:46:25 +0900 Subject: [PATCH 17/41] docs(agent): drop the cross-product reference from the event table The Agent crate does not need to name the Gateway crate to record that its catalog holds exactly the codes it declares, and that a code shared with another product is declared again here on purpose. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index 82e28ddc8..56ab568a7 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -1,10 +1,8 @@ //! Devolutions Agent Windows Event Log event definitions. //! -//! This crate is the Agent's event code table. It is independent from the Gateway's -//! (`sysevent-codes`): the two crates share no items, each product's catalog holds exactly the -//! codes declared here, and a block may be reused by both products. Lifecycle and Agent -//! Integration codes below are duplicated from the Gateway table on purpose, so the Agent never -//! ships a message it cannot emit. +//! This crate is the Agent's own event code table. The Agent's catalog holds exactly the codes +//! declared here, so the Agent never ships a message it cannot emit. A numeric block may be +//! shared, and a code this crate has in common with another product is declared here on purpose. use std::path::Path; @@ -232,8 +230,8 @@ pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToSt /// Every declared Agent event code, paired with its symbolic name. /// /// `devolutions-agent.mc` is checked against this inventory, and the check is an exact match in -/// both directions, so adding a code here means adding its messages to the catalog, and a -/// Gateway-only code must never appear there. +/// both directions, so adding a code here means adding its messages to the catalog, and a code +/// this crate does not declare must never appear there. pub static DECLARED_CODES: &[(&str, u32)] = &[ ("SERVICE_STARTED", SERVICE_STARTED), ("SERVICE_STOPPING", SERVICE_STOPPING), From fed277ef77a44dc30dff3f6f3892b346acb381c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:50:17 +0900 Subject: [PATCH 18/41] docs(agent): name the families in the 6000 event code block "Agent Integration" did not say what the block holds, and it hid three unrelated families behind one label. The header now lists them, the way the Gateway's 4000 block names "Sessions, Tokens & Recording". The Agent catalog carries the same header. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 2 +- devolutions-agent/devolutions-agent.mc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index 56ab568a7..e08deaf78 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -59,7 +59,7 @@ pub fn boot_stacktrace_written(path: &Path) -> Entry { .field("path", path.display()) } -// 6000-6099 **Agent Integration** +// 6000-6099 **User Sessions, Updater & PEDM** /// `DevolutionsSession.exe` started in session; include session id & kind (console/remote). pub const USER_SESSION_PROCESS_STARTED: u32 = 6000; diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index a3e0a8a1c..e24ebe170 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -85,7 +85,7 @@ Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . -; 6000-6099 Agent Integration +; 6000-6099 User Sessions, Updater & PEDM MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED From 00ef8498b9a8c9a20a822ab2a77618d51dfa5c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:56:53 +0900 Subject: [PATCH 19/41] docs(agent): split the 6000 event block per module The 6000 block grouped user sessions, the updater, and PEDM under one header, so nothing marked where one module's range ended and the next began. Split it into three ten-wide sub-blocks, one per owner: 6000-6009 user sessions, 6010-6019 updater, 6020-6029 PEDM. Tens are how the other blocks are already subdivided (4010, 4030, 6020 in the Gateway table), and each module gets headroom to grow without colliding with its neighbour. Event codes and message ids do not move, so operator filters and alert rules are unaffected. The constants, their builders, and the catalog messages are regrouped under their own header. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 14 ++++++++++---- devolutions-agent/devolutions-agent.mc | 6 +++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index e08deaf78..bb6153f16 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -59,15 +59,12 @@ pub fn boot_stacktrace_written(path: &Path) -> Entry { .field("path", path.display()) } -// 6000-6099 **User Sessions, Updater & PEDM** +// 6000-6009 **User Sessions** /// `DevolutionsSession.exe` started in session; include session id & kind (console/remote). pub const USER_SESSION_PROCESS_STARTED: u32 = 6000; /// Exit code; who triggered. pub const USER_SESSION_PROCESS_TERMINATED: u32 = 6001; -pub const UPDATER_TASK_ENABLED: u32 = 6010; -pub const UPDATER_ERROR: u32 = 6011; -pub const PEDM_ENABLED: u32 = 6020; pub fn user_session_process_started(session_id: u32, kind: impl ToString, exe: impl ToString) -> Entry { Entry::new("User session process started") @@ -87,6 +84,11 @@ pub fn user_session_process_terminated(session_id: u32, exit_code: i32, by: impl .field("by", by) // "user","service","timeout" } +// 6010-6019 **Updater** + +pub const UPDATER_TASK_ENABLED: u32 = 6010; +pub const UPDATER_ERROR: u32 = 6011; + pub fn updater_task_enabled() -> Entry { Entry::new("Updater task enabled") .event_code(UPDATER_TASK_ENABLED) @@ -101,6 +103,10 @@ pub fn updater_error(step: impl ToString, error: impl std::fmt::Display) -> Entr .field("error_chain", format!("{error:#}")) } +// 6020-6029 **PEDM** + +pub const PEDM_ENABLED: u32 = 6020; + pub fn pedm_enabled() -> Entry { Entry::new("PEDM enabled") .event_code(PEDM_ENABLED) diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index e24ebe170..b3263e74c 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -85,7 +85,7 @@ Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . -; 6000-6099 User Sessions, Updater & PEDM +; 6000-6009 User Sessions MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED @@ -111,6 +111,8 @@ Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . +; 6010-6019 Updater + MessageId=6010 SymbolicName=UPDATER_TASK_ENABLED Language=English @@ -135,6 +137,8 @@ Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . +; 6020-6029 PEDM + MessageId=6020 SymbolicName=PEDM_ENABLED Language=English From 18ba0643b9fe47def9724212f454d42f9ef0e865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 29 Sep 2026 23:58:41 +0900 Subject: [PATCH 20/41] docs(agent): state the event table contract without the history note The crate doc explained why a code may appear in more than one product, which narrates how the crate came to exist rather than what it is. Keep the contract that matters to a reader -- the Agent catalog holds exactly the codes declared here -- and drop the rest. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index bb6153f16..9fdfa183d 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -1,8 +1,7 @@ //! Devolutions Agent Windows Event Log event definitions. //! -//! This crate is the Agent's own event code table. The Agent's catalog holds exactly the codes -//! declared here, so the Agent never ships a message it cannot emit. A numeric block may be -//! shared, and a code this crate has in common with another product is declared here on purpose. +//! The Agent message catalog holds exactly the codes declared here, so the Agent never ships a +//! message it cannot emit. use std::path::Path; From c35633dd12fa90f2129f35eb4a05d8b7d5098c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 00:11:27 +0900 Subject: [PATCH 21/41] refactor(agent): give each 6000-family module its own hundred User sessions, the updater, and PEDM shared the 6000 hundred, which confined each module to a ten inside it and left two families to grow into whatever the third did not claim. Give every module its own hundred: user sessions keep 6000-6099, the updater moves to 6100-6199, and PEDM moves to 6200-6299. The ranges are free in the Gateway catalog too, which no longer declares 6000-6099. Nothing has shipped, so no operator filter or alert rule depends on the old numbers. Renumbered UPDATER_TASK_ENABLED 6010 -> 6100, UPDATER_ERROR 6011 -> 6101, and PEDM_ENABLED 6020 -> 6200, with the Agent catalog message ids and section comments following. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 10 +++++----- devolutions-agent/devolutions-agent.mc | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index 9fdfa183d..979f76c62 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -83,10 +83,10 @@ pub fn user_session_process_terminated(session_id: u32, exit_code: i32, by: impl .field("by", by) // "user","service","timeout" } -// 6010-6019 **Updater** +// 6100-6199 **Updater** -pub const UPDATER_TASK_ENABLED: u32 = 6010; -pub const UPDATER_ERROR: u32 = 6011; +pub const UPDATER_TASK_ENABLED: u32 = 6100; +pub const UPDATER_ERROR: u32 = 6101; pub fn updater_task_enabled() -> Entry { Entry::new("Updater task enabled") @@ -102,9 +102,9 @@ pub fn updater_error(step: impl ToString, error: impl std::fmt::Display) -> Entr .field("error_chain", format!("{error:#}")) } -// 6020-6029 **PEDM** +// 6200-6299 **PEDM** -pub const PEDM_ENABLED: u32 = 6020; +pub const PEDM_ENABLED: u32 = 6200; pub fn pedm_enabled() -> Entry { Entry::new("PEDM enabled") diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index b3263e74c..9fca1c03d 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -111,9 +111,9 @@ Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . -; 6010-6019 Updater +; 6100-6199 Updater -MessageId=6010 +MessageId=6100 SymbolicName=UPDATER_TASK_ENABLED Language=English Updater task enabled. Context=%1 @@ -125,7 +125,7 @@ Language=German Update-Aufgabe aktiviert. Kontext=%1 . -MessageId=6011 +MessageId=6101 SymbolicName=UPDATER_ERROR Language=English Updater error. Context=%1 Step=%2 Error=%3 @@ -137,9 +137,9 @@ Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . -; 6020-6029 PEDM +; 6200-6299 PEDM -MessageId=6020 +MessageId=6200 SymbolicName=PEDM_ENABLED Language=English PEDM enabled. Context=%1 From ca50d000e5a337170735ae0db2f2c268d86395a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 00:57:09 +0900 Subject: [PATCH 22/41] test(sysevent): tie each catalog message to the code it declares The exact-match check compared symbolic names only, so a `MessageId` bound to the wrong number passed. Each declared code is now compared with the number the catalog gives its message, which fails on a swap. The message block lookup searched for a literal `MessageId=\n`, which a Windows checkout with CRLF endings never contains. It now walks whole lines and trims the line ending, and `*.mc` joins the other text extensions pinned to LF so both checkouts agree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 1 + testsuite/tests/sysevent/message_catalog.rs | 91 ++++++++++++--------- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/.gitattributes b/.gitattributes index 7c78e6099..b3ba74198 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,6 +16,7 @@ *.scss text eol=lf *.html text eol=lf *.slog text eol=lf +*.mc text eol=lf devolutions-gateway/openapi/doc/index.adoc linguist-generated merge=binary devolutions-gateway/openapi/dotnet-client/src/** linguist-generated merge=binary diff --git a/testsuite/tests/sysevent/message_catalog.rs b/testsuite/tests/sysevent/message_catalog.rs index 68573fca0..5ff53df78 100644 --- a/testsuite/tests/sysevent/message_catalog.rs +++ b/testsuite/tests/sysevent/message_catalog.rs @@ -44,40 +44,41 @@ fn every_catalog_defines_exactly_its_declared_codes() { let content = read(&path); let defined = catalog_codes(&content, &path); - let declared_names: Vec<&str> = codes.iter().map(|(name, _)| *name).collect(); - let defined_names: Vec<&str> = defined.iter().map(|(name, _)| name.as_str()).collect(); - - let missing: Vec<&str> = declared_names - .iter() - .copied() - .filter(|name| !defined_names.contains(name)) - .collect(); - assert!( - missing.is_empty(), - "{codes_crate}: {} does not define {missing:?}", - path.display() - ); - - let unexpected: Vec<&str> = defined_names - .iter() - .copied() - .filter(|name| !declared_names.contains(name)) - .collect(); + // The code is the value the runtime passes to ReportEventW, so a name bound to the wrong + // number must fail here even though every declared name is present. + let mut disagreements: Vec = Vec::new(); + for (name, code) in codes.iter().copied() { + match defined.iter().find(|(defined_name, _)| defined_name.as_str() == name) { + None => disagreements.push(format!("{name} is missing")), + Some((_, defined_code)) if *defined_code == code => {} + Some((_, defined_code)) => disagreements.push(format!( + "{name} is MessageId={defined_code}, but {codes_crate} declares {code}" + )), + } + } + for (name, code) in &defined { + if !codes.iter().any(|(declared_name, _)| *declared_name == name.as_str()) { + disagreements.push(format!( + "{name} is MessageId={code}, which {codes_crate} does not declare; a catalog \ + holds exactly the codes of its own product" + )); + } + } assert!( - unexpected.is_empty(), - "{} defines {unexpected:?}, which {codes_crate} does not declare; a catalog holds \ - exactly the codes of its own product", - path.display() + disagreements.is_empty(), + "{} and {codes_crate} disagree: {}", + path.display(), + disagreements.join("; ") ); assert_eq!( - defined_names.len(), - declared_names.len(), - "{} defines {} messages for {} declared {codes_crate} codes; each code needs exactly \ - one message", + defined.len(), + codes.len(), + "{} declares {} messages for {} {codes_crate} codes; each code needs exactly one \ + message", path.display(), - defined_names.len(), - declared_names.len() + defined.len(), + codes.len() ); } } @@ -360,16 +361,30 @@ fn catalog_messages(catalog: &str, code: u32) -> Vec<&str> { messages } -/// The lines following `MessageId=`, up to the next message. The trailing newline keeps a -/// code from matching a longer code such as `800` against `8001`. +/// The lines following `MessageId=`, up to the next message. Each line is compared whole, +/// which keeps a code from matching a longer code such as `800` against `8001`, and the line ending +/// is trimmed so a catalog checked out with CRLF endings reads the same as one with LF. fn message_block(content: &str, code: u32) -> &str { - let marker = format!("MessageId={code}\n"); - let start = content - .find(&marker) - .unwrap_or_else(|| panic!("missing MessageId={code}")); - let after = &content[start + marker.len()..]; - let end = after.find("\nMessageId=").unwrap_or(after.len()); - &after[..end] + let marker = format!("MessageId={code}"); + let mut start = None; + let mut end = content.len(); + let mut offset = 0; + + for line in content.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if trimmed == marker.as_str() { + start = Some(offset + line.len()); + } else if start.is_some() && trimmed.starts_with("MessageId=") { + end = offset; + break; + } + offset += line.len(); + } + + match start { + Some(start) => &content[start..end], + None => panic!("missing MessageId={code}"), + } } /// Insertion indices (`%1`, `%2`, ...) referenced by a catalog message, in ascending order. From 4b855bef1282860ad945ea40dde6afb7dc7c5e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 01:29:49 +0900 Subject: [PATCH 23/41] fix(agent): stop auditing post-publication failures as external changes A write that failed after its content had already been published emitted a policy external-change event, because the reobserved fingerprint differed from the pre-write snapshot. That attributed the request's own write to an external writer, next to the terminal write-failure event for the same request. Publish the reobserved snapshot without an external event, and pin the behaviour with a test asserting the activation-failure outcome, a single terminal event, and no external-change event. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/mod.rs | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 1785c9c67..cf497e90d 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -484,7 +484,9 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); let audit_path = current.canonical_path.clone(); - let management = self.publish_external_observation(current); + // This request already made its own content live, so the reobserved difference is this + // write and not an external change. The terminal event reports the failed activation. + let management = self.publish_observation(current); audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); return Err(error_with_management( ErrorCode::PolicyActivationFailed, @@ -716,6 +718,7 @@ struct TestStorage { fail_target_retention: std::sync::atomic::AtomicBool, race_before_persist: parking_lot::Mutex>, post_persist_capability: parking_lot::Mutex)>>, + fail_after_publication: std::sync::atomic::AtomicBool, persisted_configured_paths: parking_lot::Mutex>, } @@ -729,6 +732,7 @@ impl TestStorage { fail_target_retention: std::sync::atomic::AtomicBool::new(false), race_before_persist: parking_lot::Mutex::new(None), post_persist_capability: parking_lot::Mutex::new(None), + fail_after_publication: std::sync::atomic::AtomicBool::new(false), persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), } } @@ -741,6 +745,7 @@ impl TestStorage { fail_target_retention: std::sync::atomic::AtomicBool::new(false), race_before_persist: parking_lot::Mutex::new(None), post_persist_capability: parking_lot::Mutex::new(None), + fail_after_publication: std::sync::atomic::AtomicBool::new(false), persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), } } @@ -752,6 +757,12 @@ impl TestStorage { fn race_before_next_persist(&self, policy: PolicyDocument) { *self.race_before_persist.lock() = Some(policy); } + + /// Makes the next `persist` report activation failure after the content was published. + fn fail_after_next_publication(&self) { + self.fail_after_publication + .store(true, std::sync::atomic::Ordering::SeqCst); + } } #[cfg(test)] @@ -837,6 +848,14 @@ impl TestStorage { next.fingerprint = DiskFingerprint::test_active(bytes, 2, 1, 1, 2); } *self.observation.lock() = clone_observation(&next); + if self + .fail_after_publication + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(WriteFailure::PostPublication(anyhow::anyhow!( + "injected post-publication activation failure" + ))); + } Ok(PersistedPolicy { policy, fingerprint: next.fingerprint, @@ -1176,6 +1195,58 @@ mod storage_tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn post_publication_failure_is_not_audited_as_an_external_change() { + 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")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + let (audit, recorder) = recording_audit(); + storage.fail_after_next_publication(); + + let error = store + .replace(request, audit) + .await + .expect_err("authoritative reload fails"); + + assert_eq!(error.code, ErrorCode::PolicyActivationFailed); + // This request published its own content, so the reobserved difference is not an external change. + assert!(crate::audit::tests::take_events().is_empty()); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "activation_failed") + ); + assert_eq!( + store + .active_policy() + .expect("published policy is active") + .metadata + .revision, + 2 + ); + assert_eq!( + error.management.expect("management snapshot").state, + PolicyManagementState::Active + ); + } + #[tokio::test] async fn audited_replacement_records_one_success_after_activation() { let store = PolicyStore::load_with_storage( From 54953f98c61710659f43d801e23f3a8cd2b80ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 01:29:49 +0900 Subject: [PATCH 24/41] build(deps): relax the now-policy-api version requirement Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index fca797a41..e0538c88e 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -34,7 +34,7 @@ notify = { version = "7", default-features = false } http-body-util = "0.1" mime = "0.3" now-policy = "=0.5.0" -now-policy-api = "=0.7.0" +now-policy-api = "0.7" now-policy-server-template = "=0.7.0" parking_lot = "0.12" regex = "1" From 3b87f50b09f1645c051497c5a89a915d1f35ad99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 02:01:30 +0900 Subject: [PATCH 25/41] refactor(dgw,agent): compare the profile against `release` when gating the catalog Both build scripts gated the event catalog embedding on a profile-name allow-list. Cargo derives `PROFILE` from the profile's base: every profile inheriting the release profile (release, production, profiling) reports "release", so the `production` arm could never match. Compare against "release" alone and document why that selects exactly the builds where the runtime uses the Windows Event Log sink (`not(debug_assertions)`), which no profile in this repository overrides. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-agent/build.rs | 6 +++++- devolutions-gateway/build.rs | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index 96da29750..1a4ef4eec 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -108,8 +108,12 @@ END"#, use std::path::PathBuf; use std::process::Command; + // Cargo only ever reports "release" or "debug" here: every profile inheriting the release + // profile (release, production, profiling) reports "release". No profile overrides + // `debug-assertions`, so this selects exactly the builds where the runtime uses the Windows + // Event Log sink (`not(debug_assertions)`), and those builds need the embedded catalog. let profile = env::var("PROFILE").unwrap_or_default(); - if !matches!(profile.as_str(), "release" | "production") { + if profile != "release" { return; } diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index 93b484b13..4522aa8c4 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -94,9 +94,13 @@ END"#, use std::path::PathBuf; use std::process::Command; - // --- gate: only release and production profiles -------------------- + // --- gate: mirror the runtime sink, `cfg(not(debug_assertions))` ---- + // Cargo only ever reports "release" or "debug" here: every profile inheriting the release + // profile (release, production, profiling) reports "release". No profile overrides + // `debug-assertions`, so this selects exactly the builds where the runtime uses the Windows + // Event Log sink, and those builds need the embedded catalog. let profile = env::var("PROFILE").unwrap_or_default(); - if !matches!(profile.as_str(), "release" | "production") { + if profile != "release" { return; } From 13ae19b3b8613c90df2d534d8a083ebbcc33ca14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 02:46:30 +0900 Subject: [PATCH 26/41] fix(agent): drain the policy audit queue when the broker shuts down The audit recorder lives in a process-lifetime static, so nothing ever dropped it: the sender stayed alive and the worker thread was killed with the process, losing every entry still queued at service stop or MSI uninstall. Those drops were not counted either. Give the recorder a drain path that closes the queue and joins the worker, and call it once the writers of audit events have stopped -- the pipe server and the policy watcher, which the broker now waits for. The queue stays closed afterwards, so an entry recorded later is counted and reported as a drop instead of disappearing. Also drop three redundant `sysevent::` qualifications that clippy denies in the release profiles, where this code is compiled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 90 +++++++++++++++++++++----- crates/now-package-broker/src/task.rs | 12 ++-- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 598971c4e..787ada97a 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -22,7 +22,22 @@ 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); +static RECORDER: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// The process-wide recorder, started on the first policy audit event. +fn recorder() -> &'static Arc { + RECORDER.get_or_init(default_recorder) +} + +/// Stops accepting policy audit events and waits for the ones already accepted to reach the sink. +/// +/// The recorder lives in a process-lifetime static, so its worker is otherwise killed with whatever +/// it is still holding when the process exits. +pub(crate) fn drain() { + if let Some(recorder) = RECORDER.get() { + recorder.drain(); + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum DenialReason { @@ -76,6 +91,9 @@ impl FailureReason { trait AuditRecorder: Send + Sync { fn record(&self, entry: Entry); + + /// Stops accepting entries and waits for the accepted ones to be emitted. + fn drain(&self) {} } fn default_recorder() -> Arc { @@ -111,29 +129,64 @@ impl AuditRecorder for TracingRecorder { #[cfg(all(not(test), not(debug_assertions)))] struct SystemRecorder { - sender: std::sync::mpsc::SyncSender, + queue: parking_lot::Mutex, dropped: AtomicU64, } +/// The queue and the worker thread that moves accepted entries to the Windows Event Log. #[cfg(all(not(test), not(debug_assertions)))] -impl SystemRecorder { - fn new() -> std::io::Result { +struct EventLogQueue { + /// `None` once [`Self::drain`] closed the queue, so no later entry can be accepted. + sender: Option>, + worker: Option>, +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl EventLogQueue { + fn start() -> std::io::Result { let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); - std::thread::Builder::new() + let worker = std::thread::Builder::new() .name("policy-audit-event-log".to_owned()) - .spawn(move || event_log_worker(&receiver)) - .map(|_| Self { - sender, - dropped: AtomicU64::new(0), - }) + .spawn(move || event_log_worker(&receiver))?; + + Ok(Self { + sender: Some(sender), + worker: Some(worker), + }) + } + + fn drain(&mut self) { + // Dropping the sender ends the worker's iteration as soon as the queue is empty. + self.sender = None; + let Some(worker) = self.worker.take() else { + return; + }; + if worker.join().is_err() { + tracing::warn!("The Windows Event Log policy audit worker panicked"); + } + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl SystemRecorder { + fn new() -> std::io::Result { + Ok(Self { + queue: parking_lot::Mutex::new(EventLogQueue::start()?), + dropped: AtomicU64::new(0), + }) } } #[cfg(all(not(test), not(debug_assertions)))] impl AuditRecorder for SystemRecorder { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { trace_entry(&entry); - if let Err(error) = self.sender.try_send(entry) { + let error = match self.queue.lock().sender.as_ref() { + Some(sender) => sender.try_send(entry).err(), + // The queue is closed after the broker drained it, so nothing can be emitted anymore. + None => Some(std::sync::mpsc::TrySendError::Disconnected(entry)), + }; + if let Some(error) = error { let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; if dropped.is_power_of_two() { tracing::warn!( @@ -147,6 +200,11 @@ impl AuditRecorder for SystemRecorder { } } } + + fn drain(&self) { + // The lock keeps a concurrent `record` from queueing an entry the closed queue would drop. + self.queue.lock().drain(); + } } #[cfg(not(test))] @@ -164,7 +222,7 @@ fn trace_entry(entry: &Entry) { } #[cfg(all(not(test), not(debug_assertions)))] -fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { +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) => { @@ -206,7 +264,7 @@ 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)) + 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 { @@ -351,7 +409,7 @@ impl WriteAuditState { } pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { - RECORDER.record(policy_events::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, @@ -364,7 +422,7 @@ pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState PolicyManagementState::Missing => "missing", PolicyManagementState::Invalid => "invalid", }; - RECORDER.record(policy_events::policy_external_change_rejected( + recorder().record(policy_events::policy_external_change_rejected( bounded_path(path), reason, )); diff --git a/crates/now-package-broker/src/task.rs b/crates/now-package-broker/src/task.rs index fdeb01fc7..88fb3018c 100644 --- a/crates/now-package-broker/src/task.rs +++ b/crates/now-package-broker/src/task.rs @@ -84,7 +84,7 @@ impl Task for BrokerTask { Ok(Err(failure)) => fail_closed(&state.policy_store, failure).await, Err(_) => fail_closed(&state.policy_store, WatcherFailure::TaskTerminated).await, } - tokio::spawn(monitor_watcher_task( + let monitor_handle = tokio::spawn(monitor_watcher_task( Arc::clone(&state.policy_store), shutdown.clone(), watcher_handle, @@ -102,11 +102,15 @@ impl Task for BrokerTask { info!("package broker received shutdown signal"); shutdown.cancel(); - // Wait for the server task to finish. - match server_handle.await { + // Wait for the writers of policy audit events to stop, so the drain below is complete. + let result = match server_handle.await { Ok(Ok(())) => Ok(()), Ok(Err(error)) => Err(error).context("broker pipe server error"), Err(error) => Err(error).context("broker server task panicked"), - } + }; + let _ = monitor_handle.await; + crate::audit::drain(); + + result } } From 2770d923b50d6da7f36b6e6a00c2f9d78bccb3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 02:46:31 +0900 Subject: [PATCH 27/41] test(sysevent): check the Gateway builders against their catalog insertions The insertion-index check covered the Agent builders only, so a Gateway message that dropped or reordered an insertion string passed while its event reached the Event Log with a missing or shifted field. Check both catalogs, driven by the same per-product inventories: every declared code needs a builder that declares it, and each of its three translations must use `%1` as the message and one `%n` per field. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/sysevent/message_catalog.rs | 206 ++++++++++++++++---- 1 file changed, 172 insertions(+), 34 deletions(-) diff --git a/testsuite/tests/sysevent/message_catalog.rs b/testsuite/tests/sysevent/message_catalog.rs index 5ff53df78..ab3b89c47 100644 --- a/testsuite/tests/sysevent/message_catalog.rs +++ b/testsuite/tests/sysevent/message_catalog.rs @@ -7,11 +7,13 @@ use sysevent::Entry; const GATEWAY_CATALOG: &str = "devolutions-gateway/devolutions-gateway.mc"; const AGENT_CATALOG: &str = "devolutions-agent/devolutions-agent.mc"; -/// A declared code set and the single catalog that must hold exactly its codes. +/// A declared code set, the single catalog that must hold exactly its codes, and every builder of +/// those codes paired with the entry it produces. struct DeclaredCodeSet { codes_crate: &'static str, codes: &'static [(&'static str, u32)], catalog: &'static str, + events: fn() -> Vec<(u32, Entry)>, } /// The Gateway and the Agent own separate code tables, so each catalog holds the codes of its own @@ -22,11 +24,13 @@ const DECLARED_CODE_SETS: &[DeclaredCodeSet] = &[ codes_crate: "sysevent-codes", codes: sysevent_codes::DECLARED_CODES, catalog: GATEWAY_CATALOG, + events: gateway_events, }, DeclaredCodeSet { codes_crate: "agent-sysevent-codes", codes: agent_sysevent_codes::DECLARED_CODES, catalog: AGENT_CATALOG, + events: agent_events, }, ]; @@ -37,6 +41,7 @@ fn every_catalog_defines_exactly_its_declared_codes() { codes_crate, codes, catalog, + .. } = declared; assert!(!codes.is_empty(), "{codes_crate} declares no event code"); @@ -133,48 +138,181 @@ fn every_catalog_message_terminates_each_translation() { } #[test] -fn agent_messages_insert_the_message_then_every_field() { - let path = catalog_path(AGENT_CATALOG); - let catalog = read(&path); - - let events = agent_events(); - let mut covered: Vec = events.iter().map(|(code, _)| *code).collect(); - covered.sort_unstable(); - let mut declared: Vec = agent_sysevent_codes::DECLARED_CODES - .iter() - .map(|(_, code)| *code) - .collect(); - declared.sort_unstable(); - assert_eq!( - covered, declared, - "every declared Agent code needs a builder here, so its insertion strings stay checked" - ); - - for (code, entry) in events { +fn every_code_builder_inserts_the_message_then_every_field() { + for declared in DECLARED_CODE_SETS { + let DeclaredCodeSet { + codes_crate, + codes, + catalog, + events, + } = declared; + let path = catalog_path(catalog); + let catalog = read(&path); + + let events = events(); + let mut covered: Vec = events.iter().map(|(code, _)| *code).collect(); + covered.sort_unstable(); + let mut declared_codes: Vec = codes.iter().map(|(_, code)| *code).collect(); + declared_codes.sort_unstable(); assert_eq!( - entry.event_code, - Some(code), - "the builder for MessageId={code} must declare that event code" + covered, declared_codes, + "every {codes_crate} code needs a builder here, so its insertion strings stay checked" ); - // The Windows Event Log sink passes the message text as the first insertion string, then - // one string per field, so a message needs exactly one insertion per field plus one. - let count = u32::try_from(entry.fields.len() + 1).expect("the entry has few fields"); - let expected: Vec = (1..=count).collect(); - - let messages = catalog_messages(&catalog, code); - assert_eq!(messages.len(), 3, "{}: MessageId={code} translations", path.display()); - for message in messages { + for (code, entry) in events { assert_eq!( - insertions(message), - expected, - "{}: MessageId={code} must use %1 as the message and %2..%{count} as its fields: {message}", - path.display() + entry.event_code, + Some(code), + "the builder for MessageId={code} must declare that event code" ); + + // The Windows Event Log sink passes the message text as the first insertion string, then + // one string per field, so a message needs exactly one insertion per field plus one. + let count = u32::try_from(entry.fields.len() + 1).expect("the entry has few fields"); + let expected: Vec = (1..=count).collect(); + + let messages = catalog_messages(&catalog, code); + assert_eq!(messages.len(), 3, "{}: MessageId={code} translations", path.display()); + for message in messages { + assert_eq!( + insertions(message), + expected, + "{}: MessageId={code} must use %1 as the message and %2..%{count} as its fields: {message}", + path.display() + ); + } } } } +/// Every declared Gateway event, each paired with the entry its builder produces. +fn gateway_events() -> Vec<(u32, Entry)> { + let path = Path::new("C:\\ProgramData\\Devolutions\\Gateway\\gateway.json"); + + let mut events = vec![ + ( + sysevent_codes::SERVICE_STARTED, + sysevent_codes::service_started("2026.3.0"), + ), + ( + sysevent_codes::SERVICE_STOPPING, + sysevent_codes::service_stopping("received stop control code"), + ), + ( + sysevent_codes::CONFIG_INVALID, + sysevent_codes::config_invalid("invalid config", path), + ), + ( + sysevent_codes::START_FAILED, + sysevent_codes::start_failed("failed to bind", "service_start"), + ), + ( + sysevent_codes::BOOT_STACKTRACE_WRITTEN, + sysevent_codes::boot_stacktrace_written(path), + ), + ( + sysevent_codes::LISTENER_STARTED, + sysevent_codes::listener_started("127.0.0.1:7171", "tcp"), + ), + ( + sysevent_codes::LISTENER_BIND_FAILED, + sysevent_codes::listener_bind_failed("127.0.0.1:7171", "address in use"), + ), + ( + sysevent_codes::LISTENER_STOPPED, + sysevent_codes::listener_stopped("127.0.0.1:7171", "shutdown"), + ), + (sysevent_codes::TLS_CONFIGURED, sysevent_codes::tls_configured("file")), + ( + sysevent_codes::TLS_VERIFY_STRICT_DISABLED, + sysevent_codes::tls_verify_strict_disabled("compat"), + ), + ( + sysevent_codes::TLS_CERTIFICATE_REJECTED, + sysevent_codes::tls_certificate_rejected("CN=gateway", "missing_san"), + ), + ( + sysevent_codes::SYSTEM_CERT_SELECTED, + sysevent_codes::system_cert_selected("", "CN=gateway"), + ), + ( + sysevent_codes::TLS_KEY_LOAD_FAILED, + sysevent_codes::tls_key_load_failed(path, "permission denied"), + ), + ( + sysevent_codes::TLS_CERTIFICATE_NAME_MISMATCH, + sysevent_codes::tls_certificate_name_mismatch("gateway.example.com", "CN=gateway"), + ), + ( + sysevent_codes::TLS_NO_SUITABLE_CERTIFICATE, + sysevent_codes::tls_no_suitable_certificate("no usable certificate", "expired"), + ), + ( + sysevent_codes::SESSION_OPENED, + sysevent_codes::session_opened("RDP", "10.0.0.1", "srv01", "token_id"), + ), + ( + sysevent_codes::SESSION_CLOSED, + sysevent_codes::session_closed(1000, 1024, 2048, "ok"), + ), + ( + sysevent_codes::TOKEN_PROVISIONED, + sysevent_codes::token_provisioned("token_id"), + ), + ( + sysevent_codes::TOKEN_REUSED, + sysevent_codes::token_reused("token_id", 1), + ), + ( + sysevent_codes::TOKEN_REUSE_LIMIT_EXCEEDED, + sysevent_codes::token_reuse_limit_exceeded("token_id", 2), + ), + ( + sysevent_codes::RECORDING_STARTED, + sysevent_codes::recording_started("C:\\recordings"), + ), + ( + sysevent_codes::RECORDING_STOPPED, + sysevent_codes::recording_stopped(1024, 1), + ), + ( + sysevent_codes::RECORDING_ERROR, + sysevent_codes::recording_error(path, "no space left"), + ), + ( + sysevent_codes::JWT_REJECTED, + sysevent_codes::jwt_rejected("expired", "the token expired"), + ), + ( + sysevent_codes::JWT_ANOMALY, + sysevent_codes::jwt_anomaly("issuer", "audience", "kid", "clock_skew", "detail"), + ), + ( + sysevent_codes::AUTHORIZATION_DENIED, + sysevent_codes::authorization_denied("subject", "action", "resource", "rule"), + ), + ( + sysevent_codes::AUTH_SUMMARY, + sysevent_codes::auth_summary(60, 10, 2, 1, "{}"), + ), + ( + sysevent_codes::RECORDING_STORAGE_LOW, + sysevent_codes::recording_storage_low(1024, 4096), + ), + ( + sysevent_codes::DEBUG_OPTIONS_ENABLED, + sysevent_codes::debug_options_enabled("verbose"), + ), + ( + sysevent_codes::XMF_NOT_FOUND, + sysevent_codes::xmf_not_found(path, "not found"), + ), + ]; + + events.sort_unstable_by_key(|(code, _)| *code); + events +} + /// Every declared Agent event, each paired with the entry its builder produces. fn agent_events() -> Vec<(u32, Entry)> { let path = Path::new("C:\\ProgramData\\Devolutions\\Agent\\policy.json"); From 9aa4ebb76b44aa6dd7cbbccbe3e6360a51eb755c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 03:14:35 +0900 Subject: [PATCH 28/41] fix(agent): wait for the connections still serving a request before draining Accepted named pipe connections are now tracked, so the pipe server does not return while a connection can still record a policy audit event. Shutdown waits for them (bounded by a five-second grace) and aborts the ones that outlive it, because the broker drains the audit queue as soon as the pipe server returns. A connection aborted that way loses its terminal event, which is better than writing to a queue that is already closed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 108 +++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 10 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 6a4c51b00..f9324794f 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -42,25 +42,37 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// 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); +/// How long shutdown waits for the connections that are still serving a request. +/// +/// Serving a connection can record a policy audit event, and the caller drains the audit queue as +/// soon as this function returns, so nothing may still be running by then. A healthy exchange +/// completes in milliseconds, and each connection is already bounded by `CONNECTION_DEADLINE`, so +/// this only has to cover the tail of a request already in progress; connections still stuck at +/// the end of it are aborted rather than allowed to hold the shutdown. +const CONNECTION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); + /// Start the named pipe server and accept connections until shutdown. pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> { let pipe_name = state.pipe_name.clone(); info!(%pipe_name, "Starting named pipe server"); let connection_permits = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS)); + // Serving a connection can record policy audit events, so shutdown has to wait for these. + let mut connections = tokio::task::JoinSet::new(); let mut first_instance = true; loop { + // Reap the connections that already finished, so completed tasks do not accumulate here + // for the lifetime of the process. + while connections.try_join_next().is_some() {} + // Wait for a free connection slot before exposing a new pipe instance, // bounding the number of concurrently served connections. let permit = tokio::select! { permit = Arc::clone(&connection_permits).acquire_owned() => { permit.expect("the semaphore is never closed") } - _ = shutdown.cancelled() => { - info!("Pipe server shutting down"); - return Ok(()); - } + _ = shutdown.cancelled() => break, }; // Create a new pipe instance for each connection. @@ -72,7 +84,7 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke match result { Ok(()) => { let state = Arc::clone(&state); - tokio::spawn(async move { + connections.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. @@ -113,12 +125,29 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke } } } - _ = shutdown.cancelled() => { - info!("Pipe server shutting down"); - return Ok(()); - } + _ = shutdown.cancelled() => break, } } + + info!("Pipe server shutting down"); + + wait_for_connections(&mut connections, CONNECTION_SHUTDOWN_GRACE).await; + + Ok(()) +} + +/// Wait for the connection tasks to finish, then abort the ones that outlive the grace. +/// +/// Serving a connection can record policy audit events, so the caller drains the audit queue as +/// soon as this returns and no connection may outlive it. +async fn wait_for_connections(connections: &mut tokio::task::JoinSet<()>, grace: std::time::Duration) { + let drained = tokio::time::timeout(grace, async { while connections.join_next().await.is_some() {} }).await; + + if drained.is_err() { + warn!("Aborted named pipe connections still serving at shutdown"); + connections.abort_all(); + while connections.join_next().await.is_some() {} + } } fn spawn_bounded_capture(permit: OwnedSemaphorePermit, capture: F) -> JoinHandle<(OwnedSemaphorePermit, T)> @@ -195,8 +224,11 @@ fn build_pipe_security_attributes() -> anyhow::Result); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_waits_for_the_connections_that_are_still_serving() { + let release = CancellationToken::new(); + let mut connections = JoinSet::new(); + connections.spawn({ + let release = release.clone(); + async move { release.cancelled().await } + }); + + let waited = tokio::spawn(async move { + wait_for_connections(&mut connections, Duration::from_secs(30)).await; + }); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !waited.is_finished(), + "the wait must last as long as a connection is being served" + ); + + release.cancel(); + tokio::time::timeout(Duration::from_secs(5), waited) + .await + .expect("the wait completes once the connection is done") + .expect("the wait task does not panic"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_aborts_the_connections_that_outlive_the_grace() { + let dropped = Arc::new(AtomicBool::new(false)); + let mut connections = JoinSet::new(); + connections.spawn({ + let dropped = Arc::clone(&dropped); + async move { + let _flag = DropFlag(dropped); + std::future::pending::<()>().await; + } + }); + + let started = Instant::now(); + wait_for_connections(&mut connections, Duration::from_millis(200)).await; + + assert!( + started.elapsed() >= Duration::from_millis(200), + "the grace must elapse first" + ); + assert!(dropped.load(Ordering::SeqCst), "the connection task must be aborted"); + assert!(connections.is_empty(), "no connection task may outlive shutdown"); + } + #[tokio::test] async fn completed_capture_returns_its_permit_to_the_connection_task() { let permits = Arc::new(Semaphore::new(1)); From 248ee67f91adf2c7090493d59bf23087b457e2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 03:51:27 +0900 Subject: [PATCH 29/41] fix(agent-installer): keep the Event Log source key on uninstall Declare the Agent's Event Log source value with the regular MSI component lifecycle instead of `createAndRemoveOnUninstall`. Windows Installer already removes the values it creates when the owning component is uninstalled, so the source registration still goes away on uninstall. `createAndRemoveOnUninstall` additionally deletes the whole source key, including values written by an administrator or another installer, which the Agent does not own. The installer test now pins `create` and describes the lifecycle it actually asserts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EventLogSourceRegistryTests.cs | 7 ++++--- package/AgentWindowsManaged/Program.cs | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs index d04923b84..86ff3c76e 100644 --- a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs +++ b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs @@ -41,9 +41,10 @@ public void SourceUsesNativeMsiRegistryLifecycle(bool win64) Assert.Equal("[INSTALLDIR]DevolutionsAgent.exe", value.Value); // 64-bit readers only see the source if the value lands in the matching registry view. Assert.Equal(win64, value.Win64); - // Registered on install and removed on uninstall, without clobbering a source that an - // administrator or another product owns. - Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + // Registered on install and removed on uninstall through the component lifecycle, which + // leaves the source key itself alone. createAndRemoveOnUninstall would delete the whole key + // on uninstall, including anything an administrator or another installer put there. + Assert.Equal(RegistryKeyAction.create, value.RegistryKeyAction); Assert.False(value.ForceCreateOnInstall); Assert.False(value.ForceDeleteOnUninstall); // EventMessageFile has to be REG_SZ, since a REG_MULTI_SZ value is not read as a path. diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 09a32b242..f9ff4595a 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -432,7 +432,10 @@ internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => { AttributesDefinition = "Type=string", Win64 = win64, - RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + // Uninstall removes this value through the ordinary component lifecycle. Do not use + // createAndRemoveOnUninstall: it deletes the whole source key, including values + // written by an administrator or another installer. + RegistryKeyAction = RegistryKeyAction.create, }; private static void Project_UnhandledException(ExceptionEventArgs e) From ce7e2b56a658624014c56f1fa3b1f3f0bd0f6054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 04:27:37 +0900 Subject: [PATCH 30/41] fix(agent): reserve Event Log capacity for the policy audit outcomes A policy write is audited before the client is authenticated, so any local user can fill the bounded Event Log queue with attempts and the denials they provoke. With the single queue, a full one also swallowed the terminal outcome of a later, authenticated policy change, leaving that change without a recorded result. Attempts and denials now use a droppable queue, while terminal outcomes and the policy store's own external-change events use a second, reserved queue that the worker drains first. A flood can still drop its own entries with the existing drop telemetry, but it cannot consume the capacity reserved for the outcome of an authenticated write. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 255 ++++++++++++++++++++----- 1 file changed, 204 insertions(+), 51 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 787ada97a..b853fb1f4 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -19,8 +19,22 @@ 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; +/// Capacity of the queue holding attempts and denials. +/// +/// The write attempt is recorded before the pipe client is authenticated, so a client that never +/// authenticates can produce this class at will. It is the class that yields when the sink +/// saturates. +#[cfg(any(test, not(debug_assertions)))] +const EVENT_LOG_ADMISSION_QUEUE_CAPACITY: usize = 256; + +/// Capacity of the queue holding terminal outcomes and external changes, reserved on top of the +/// admission capacity. +/// +/// Only authenticated policy writes, which the policy store serializes, and the policy store's own +/// observation of external changes produce this class, so a request flood cannot reach it. Keeping +/// the capacity separate rather than sharing it is what makes the reservation hold. +#[cfg(any(test, not(debug_assertions)))] +const EVENT_LOG_OUTCOME_QUEUE_CAPACITY: usize = 64; static RECORDER: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -89,8 +103,31 @@ impl FailureReason { } } +/// Which queue of the Event Log worker an audit entry is routed to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EntryClass { + /// A write attempt or a denial. Recorded from unauthenticated requests, so it may be dropped + /// when the sink saturates. + Admission, + /// The terminal outcome of a policy write, or the observation of an external change. Never + /// dropped while the reserved capacity lasts. + Outcome, +} + +impl EntryClass { + #[cfg(all(not(test), not(debug_assertions)))] + const fn as_str(self) -> &'static str { + match self { + Self::Admission => "admission", + Self::Outcome => "outcome", + } + } +} + trait AuditRecorder: Send + Sync { - fn record(&self, entry: Entry); + /// Records one entry of `class`. Only [`EntryClass::Admission`] entries may be dropped, and only + /// when the queue for their class is full. + fn record(&self, entry: Entry, class: EntryClass); /// Stops accepting entries and waits for the accepted ones to be emitted. fn drain(&self) {} @@ -122,7 +159,7 @@ struct TracingRecorder; #[cfg(not(test))] impl AuditRecorder for TracingRecorder { - fn record(&self, entry: Entry) { + fn record(&self, entry: Entry, _: EntryClass) { trace_entry(&entry); } } @@ -133,31 +170,52 @@ struct SystemRecorder { dropped: AtomicU64, } -/// The queue and the worker thread that moves accepted entries to the Windows Event Log. -#[cfg(all(not(test), not(debug_assertions)))] +/// The queues and the worker thread that moves accepted entries to the Windows Event Log. +/// +/// Admission entries and terminal outcomes have separate bounded queues, so a flood of admission +/// entries cannot consume the capacity reserved for outcomes. +#[cfg(any(test, not(debug_assertions)))] struct EventLogQueue { /// `None` once [`Self::drain`] closed the queue, so no later entry can be accepted. - sender: Option>, + admission: Option>, + /// `None` once [`Self::drain`] closed the queue, so no later entry can be accepted. + outcome: Option>, worker: Option>, } -#[cfg(all(not(test), not(debug_assertions)))] +#[cfg(any(test, not(debug_assertions)))] impl EventLogQueue { - fn start() -> std::io::Result { - let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); + fn start(emit: impl FnMut(Entry) + Send + 'static) -> std::io::Result { + let (admission, admission_rx) = std::sync::mpsc::sync_channel(EVENT_LOG_ADMISSION_QUEUE_CAPACITY); + let (outcome, outcome_rx) = std::sync::mpsc::sync_channel(EVENT_LOG_OUTCOME_QUEUE_CAPACITY); let worker = std::thread::Builder::new() .name("policy-audit-event-log".to_owned()) - .spawn(move || event_log_worker(&receiver))?; + .spawn(move || event_log_worker(&admission_rx, &outcome_rx, emit))?; Ok(Self { - sender: Some(sender), + admission: Some(admission), + outcome: Some(outcome), worker: Some(worker), }) } + /// Queues one entry, unless the queue for its class is full or closed. + fn record(&self, entry: Entry, class: EntryClass) -> Result<(), std::sync::mpsc::TrySendError> { + let sender = match class { + EntryClass::Admission => self.admission.as_ref(), + EntryClass::Outcome => self.outcome.as_ref(), + }; + match sender { + Some(sender) => sender.try_send(entry), + // The queue is closed after the broker drained it, so nothing can be emitted anymore. + None => Err(std::sync::mpsc::TrySendError::Disconnected(entry)), + } + } + fn drain(&mut self) { - // Dropping the sender ends the worker's iteration as soon as the queue is empty. - self.sender = None; + // Dropping the senders ends the worker's iteration as soon as the queues are empty. + self.admission = None; + self.outcome = None; let Some(worker) = self.worker.take() else { return; }; @@ -171,7 +229,7 @@ impl EventLogQueue { impl SystemRecorder { fn new() -> std::io::Result { Ok(Self { - queue: parking_lot::Mutex::new(EventLogQueue::start()?), + queue: parking_lot::Mutex::new(EventLogQueue::start(event_log_emitter())?), dropped: AtomicU64::new(0), }) } @@ -179,18 +237,15 @@ impl SystemRecorder { #[cfg(all(not(test), not(debug_assertions)))] impl AuditRecorder for SystemRecorder { - fn record(&self, entry: Entry) { + fn record(&self, entry: Entry, class: EntryClass) { trace_entry(&entry); - let error = match self.queue.lock().sender.as_ref() { - Some(sender) => sender.try_send(entry).err(), - // The queue is closed after the broker drained it, so nothing can be emitted anymore. - None => Some(std::sync::mpsc::TrySendError::Disconnected(entry)), - }; + let error = self.queue.lock().record(entry, class).err(); if let Some(error) = error { let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; if dropped.is_power_of_two() { tracing::warn!( dropped, + class = class.as_str(), error = %match error { std::sync::mpsc::TrySendError::Full(_) => "queue_full", std::sync::mpsc::TrySendError::Disconnected(_) => "worker_disconnected", @@ -221,8 +276,9 @@ fn trace_entry(entry: &Entry) { } } +/// The Windows Event Log sink, wrapped in the closure the worker thread owns. #[cfg(all(not(test), not(debug_assertions)))] -fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { +fn event_log_emitter() -> impl FnMut(Entry) + Send + 'static { let sink: Arc = match sysevent_winevent::WinEvent::new("Devolutions Agent") { Ok(event_log) => Arc::new(event_log), Err(error) => { @@ -230,13 +286,54 @@ fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { Arc::new(sysevent::NoopSink) } }; - for entry in receiver { + move |entry| { if let Err(error) = sink.emit(entry) { tracing::warn!(%error, "Failed to emit policy audit event to the Windows Event Log"); } } } +/// Emits entries until both queues are closed, draining terminal outcomes before admission entries +/// so that a saturated admission queue cannot delay or drop an outcome. +#[cfg(any(test, not(debug_assertions)))] +fn event_log_worker( + admission: &std::sync::mpsc::Receiver, + outcome: &std::sync::mpsc::Receiver, + mut emit: impl FnMut(Entry), +) { + use std::sync::mpsc::{RecvTimeoutError, TryRecvError}; + + // Bounded wait, so a queued outcome is emitted even when no admission entry arrives. + let poll_interval = std::time::Duration::from_millis(50); + loop { + match outcome.try_recv() { + Ok(entry) => { + emit(entry); + continue; + } + Err(TryRecvError::Empty) => {} + // No outcome can arrive anymore, so the admission queue holds everything that is left. + Err(TryRecvError::Disconnected) => { + for entry in admission.iter() { + emit(entry); + } + return; + } + } + match admission.recv_timeout(poll_interval) { + Ok(entry) => emit(entry), + Err(RecvTimeoutError::Timeout) => {} + // Admission is closed, so the outcome queue holds everything that is left. + Err(RecvTimeoutError::Disconnected) => { + for entry in outcome.iter() { + emit(entry); + } + return; + } + } + } +} + struct WriteAuditState { actor_sid: String, actor_exe: String, @@ -248,13 +345,16 @@ struct WriteAuditState { impl Drop for WriteAuditState { fn drop(&mut self) { if !self.terminal_recorded.swap(true, Ordering::AcqRel) { - self.record(policy_events::policy_write_denied( - &self.actor_sid, - &self.actor_exe, - INTENT, - &self.path, - DenialReason::RequestRejected.as_str(), - )); + self.record( + policy_events::policy_write_denied( + &self.actor_sid, + &self.actor_exe, + INTENT, + &self.path, + DenialReason::RequestRejected.as_str(), + ), + EntryClass::Admission, + ); } } } @@ -275,12 +375,10 @@ impl WriteAudit { terminal_recorded: AtomicBool::new(false), recorder, }); - state.record(policy_events::policy_write_attempted( - &state.actor_sid, - &state.actor_exe, - INTENT, - &state.path, - )); + state.record( + policy_events::policy_write_attempted(&state.actor_sid, &state.actor_exe, INTENT, &state.path), + EntryClass::Admission, + ); Self(state) } @@ -390,6 +488,7 @@ impl WriteAudit { }); } + /// Records the terminal outcome, which the reserved queue shields from admission flooding. fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> Entry) { if self .0 @@ -397,23 +496,26 @@ impl WriteAudit { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_ok() { - self.0.record(entry(&self.0)); + self.0.record(entry(&self.0), EntryClass::Outcome); } } } impl WriteAuditState { - fn record(&self, entry: Entry) { - self.recorder.record(entry); + fn record(&self, entry: Entry, class: EntryClass) { + self.recorder.record(entry, class); } } pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { - recorder().record(policy_events::policy_external_change_applied( - bounded_path(path), - bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), - new_revision, - )); + recorder().record( + policy_events::policy_external_change_applied( + bounded_path(path), + bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), + new_revision, + ), + EntryClass::Outcome, + ); } pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { @@ -422,10 +524,10 @@ pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState PolicyManagementState::Missing => "missing", PolicyManagementState::Invalid => "invalid", }; - recorder().record(policy_events::policy_external_change_rejected( - bounded_path(path), - reason, - )); + recorder().record( + policy_events::policy_external_change_rejected(bounded_path(path), reason), + EntryClass::Outcome, + ); } fn bounded(mut value: String, max_bytes: usize) -> String { @@ -478,7 +580,7 @@ pub(crate) mod tests { pub(crate) struct TestRecorder; impl AuditRecorder for TestRecorder { - fn record(&self, entry: Entry) { + fn record(&self, entry: Entry, _: EntryClass) { EVENTS.with(|events| events.borrow_mut().push(entry)); } } @@ -497,7 +599,7 @@ pub(crate) mod tests { } impl AuditRecorder for Recorder { - fn record(&self, entry: Entry) { + fn record(&self, entry: Entry, _: EntryClass) { self.0.lock().push(entry); } } @@ -526,7 +628,7 @@ pub(crate) mod tests { struct NoopRecorder; impl AuditRecorder for NoopRecorder { - fn record(&self, _: Entry) {} + fn record(&self, _: Entry, _: EntryClass) {} } fn test_audit() -> (WriteAudit, Arc) { @@ -570,6 +672,57 @@ pub(crate) mod tests { ); } + #[test] + fn terminal_outcomes_are_reserved_against_an_admission_flood() { + // A worker that cannot make progress leaves the admission queue in its saturated state. + let open = Arc::new(AtomicBool::new(false)); + let release = Arc::clone(&open); + let (emitted, received) = std::sync::mpsc::channel(); + let mut queue = EventLogQueue::start(move |entry| { + while !release.load(Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let _ = emitted.send(entry); + }) + .expect("the audit worker starts"); + + let admission = + || Entry::new("Policy management write attempted").event_code(policy_events::POLICY_WRITE_ATTEMPTED); + let outcome = + Entry::new("Policy management change succeeded").event_code(policy_events::POLICY_CHANGE_SUCCEEDED); + + let mut admitted = 0; + let mut refused = 0; + for _ in 0..EVENT_LOG_ADMISSION_QUEUE_CAPACITY + 8 { + if queue.record(admission(), EntryClass::Admission).is_ok() { + admitted += 1; + } else { + refused += 1; + } + } + assert!(admitted >= EVENT_LOG_ADMISSION_QUEUE_CAPACITY); + assert!(refused > 0, "the flood must saturate the admission queue"); + + // The outcome is still accepted, because its own queue is reserved. + queue + .record(outcome, EntryClass::Outcome) + .expect("a terminal outcome is accepted while admission entries are refused"); + + open.store(true, Ordering::Release); + queue.drain(); + + // Every admitted entry reached the sink, plus the outcome that the flood could not displace. + let codes = received.iter().map(|entry| entry.event_code).collect::>(); + assert_eq!(codes.len(), admitted + 1); + assert_eq!( + codes + .iter() + .filter(|code| **code == Some(policy_events::POLICY_CHANGE_SUCCEEDED)) + .count(), + 1 + ); + } + #[test] fn audit_text_removes_control_characters_before_truncation() { let value = format!( From 695fddcc14d80c582eb5962f355a397fc46ddad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 04:37:12 +0900 Subject: [PATCH 31/41] build(deps): restore the master lockfile resolution for the windows crates The rebase left `generator` and `iana-time-zone` linked against the older `windows-link`, `windows-result`, and `windows-core` entries, which showed up as unrelated downgrades against master. Nothing in this branch intends to move those versions, so the lockfile is back to master's resolution; `now-policy-api` 0.7.0 and the new `agent-sysevent-codes` dependencies are the only remaining changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0610dfee..e84bb2930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2679,8 +2679,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3215,7 +3215,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] From 6fe4c89ae60def058a8050b9fb27541b19ed6958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 05:01:50 +0900 Subject: [PATCH 32/41] fix(agent): preserve the policy audit order and audit readiness reloads The Event Log worker drained terminal outcomes with priority over write attempts, so an accepted write could be emitted after its own outcome. Both classes now share a single bounded queue and reach the sink in the order they were recorded, while a budget of 256 admission slots keeps the 64 reserved slots for outcomes and external changes out of reach of a request flood. The policy store also reported watcher readiness through the in-process observation path, so a policy that changed between the provisional load and the watcher becoming ready produced no external change event. Readiness now publishes through the external change path, like the reload path already did. - `crates/now-package-broker/src/audit.rs`: one FIFO queue plus an admission budget; a refusal now reports whether the budget, the queue, or the worker refused the entry. - `crates/now-package-broker/src/policy_store/mod.rs`: `mark_monitoring_ready` publishes external observations. - `crates/now-package-broker/src/policy_store/receipt.rs`: the readiness reload test asserts the event each settled state emits, and that a second readiness report audits nothing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 232 ++++++++++++------ .../src/policy_store/mod.rs | 4 +- .../src/policy_store/receipt.rs | 39 ++- 3 files changed, 192 insertions(+), 83 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index b853fb1f4..02403652d 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(all(not(test), not(debug_assertions)))] use std::sync::atomic::AtomicU64; +#[cfg(any(test, not(debug_assertions)))] +use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicBool, Ordering}; use agent_sysevent_codes as policy_events; @@ -19,22 +21,24 @@ 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; -/// Capacity of the queue holding attempts and denials. +/// Slots of the Event Log queue kept for terminal outcomes and external changes. +/// +/// Only authenticated policy writes, which the policy store serializes, and the policy store's own +/// observation of external changes produce this class, so a request flood cannot reach the reserve. +#[cfg(any(test, not(debug_assertions)))] +const EVENT_LOG_OUTCOME_RESERVE: usize = 64; + +/// Slots of the Event Log queue that write attempts and denials may occupy. /// /// The write attempt is recorded before the pipe client is authenticated, so a client that never /// authenticates can produce this class at will. It is the class that yields when the sink /// saturates. #[cfg(any(test, not(debug_assertions)))] -const EVENT_LOG_ADMISSION_QUEUE_CAPACITY: usize = 256; +const EVENT_LOG_ADMISSION_BUDGET: usize = 256; -/// Capacity of the queue holding terminal outcomes and external changes, reserved on top of the -/// admission capacity. -/// -/// Only authenticated policy writes, which the policy store serializes, and the policy store's own -/// observation of external changes produce this class, so a request flood cannot reach it. Keeping -/// the capacity separate rather than sharing it is what makes the reservation hold. +/// Capacity of the queue holding every policy audit entry waiting for the Event Log worker. #[cfg(any(test, not(debug_assertions)))] -const EVENT_LOG_OUTCOME_QUEUE_CAPACITY: usize = 64; +const EVENT_LOG_QUEUE_CAPACITY: usize = EVENT_LOG_ADMISSION_BUDGET + EVENT_LOG_OUTCOME_RESERVE; static RECORDER: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -103,7 +107,10 @@ impl FailureReason { } } -/// Which queue of the Event Log worker an audit entry is routed to. +/// Which class an audit entry belongs to. +/// +/// Both classes share one queue, so entries reach the sink in the order they were recorded. The +/// class only decides whether an entry may be refused to keep capacity for the other class. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum EntryClass { /// A write attempt or a denial. Recorded from unauthenticated requests, so it may be dropped @@ -124,9 +131,29 @@ impl EntryClass { } } +/// Why the Event Log queue refused an entry. +#[cfg(any(test, not(debug_assertions)))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueueRefusal { + /// The queue, or the slots this class may occupy, is full. + Full, + /// The worker is gone, so nothing can reach the sink anymore. + Disconnected, +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl QueueRefusal { + const fn as_str(self) -> &'static str { + match self { + Self::Full => "queue_full", + Self::Disconnected => "worker_disconnected", + } + } +} + trait AuditRecorder: Send + Sync { /// Records one entry of `class`. Only [`EntryClass::Admission`] entries may be dropped, and only - /// when the queue for their class is full. + /// when their budget or the queue is full. fn record(&self, entry: Entry, class: EntryClass); /// Stops accepting entries and waits for the accepted ones to be emitted. @@ -170,52 +197,69 @@ struct SystemRecorder { dropped: AtomicU64, } -/// The queues and the worker thread that moves accepted entries to the Windows Event Log. +/// The queue and the worker thread that move accepted entries to the Windows Event Log. /// -/// Admission entries and terminal outcomes have separate bounded queues, so a flood of admission -/// entries cannot consume the capacity reserved for outcomes. +/// Every entry shares one bounded queue, so entries reach the sink in the order they were recorded +/// and an accepted write's attempt precedes its terminal outcome. Write attempts and denials are +/// refused once they occupy [`EVENT_LOG_ADMISSION_BUDGET`] slots, which keeps +/// [`EVENT_LOG_OUTCOME_RESERVE`] slots available for outcomes and external changes. #[cfg(any(test, not(debug_assertions)))] struct EventLogQueue { /// `None` once [`Self::drain`] closed the queue, so no later entry can be accepted. - admission: Option>, - /// `None` once [`Self::drain`] closed the queue, so no later entry can be accepted. - outcome: Option>, + sender: Option>, + /// Write attempts and denials still queued, shared with the worker that dequeues them. + admission_pending: Arc, worker: Option>, } #[cfg(any(test, not(debug_assertions)))] impl EventLogQueue { fn start(emit: impl FnMut(Entry) + Send + 'static) -> std::io::Result { - let (admission, admission_rx) = std::sync::mpsc::sync_channel(EVENT_LOG_ADMISSION_QUEUE_CAPACITY); - let (outcome, outcome_rx) = std::sync::mpsc::sync_channel(EVENT_LOG_OUTCOME_QUEUE_CAPACITY); + let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); + let admission_pending = Arc::new(AtomicUsize::new(0)); + let queued = Arc::clone(&admission_pending); let worker = std::thread::Builder::new() .name("policy-audit-event-log".to_owned()) - .spawn(move || event_log_worker(&admission_rx, &outcome_rx, emit))?; + .spawn(move || event_log_worker(&receiver, &queued, emit))?; Ok(Self { - admission: Some(admission), - outcome: Some(outcome), + sender: Some(sender), + admission_pending, worker: Some(worker), }) } - /// Queues one entry, unless the queue for its class is full or closed. - fn record(&self, entry: Entry, class: EntryClass) -> Result<(), std::sync::mpsc::TrySendError> { - let sender = match class { - EntryClass::Admission => self.admission.as_ref(), - EntryClass::Outcome => self.outcome.as_ref(), + /// Queues one entry, unless its class is over budget or the queue is full or closed. + fn record(&mut self, entry: Entry, class: EntryClass) -> Result<(), QueueRefusal> { + // The queue is closed after the broker drained it, so nothing can be emitted anymore. + let Some(sender) = self.sender.as_ref() else { + return Err(QueueRefusal::Disconnected); }; - match sender { - Some(sender) => sender.try_send(entry), - // The queue is closed after the broker drained it, so nothing can be emitted anymore. - None => Err(std::sync::mpsc::TrySendError::Disconnected(entry)), + if class == EntryClass::Admission { + // Claim the slot before sending, so concurrent attempts cannot overdraw the budget. + if self.admission_pending.fetch_add(1, Ordering::Relaxed) >= EVENT_LOG_ADMISSION_BUDGET { + self.admission_pending.fetch_sub(1, Ordering::Relaxed); + return Err(QueueRefusal::Full); + } + } + match sender.try_send((entry, class)) { + Ok(()) => Ok(()), + Err(error) => { + if class == EntryClass::Admission { + // The entry never entered the queue, so its claimed slot is free again. + self.admission_pending.fetch_sub(1, Ordering::Relaxed); + } + Err(match error { + std::sync::mpsc::TrySendError::Full(_) => QueueRefusal::Full, + std::sync::mpsc::TrySendError::Disconnected(_) => QueueRefusal::Disconnected, + }) + } } } fn drain(&mut self) { - // Dropping the senders ends the worker's iteration as soon as the queues are empty. - self.admission = None; - self.outcome = None; + // Dropping the sender ends the worker's iteration as soon as the queue is empty. + self.sender = None; let Some(worker) = self.worker.take() else { return; }; @@ -239,17 +283,13 @@ impl SystemRecorder { impl AuditRecorder for SystemRecorder { fn record(&self, entry: Entry, class: EntryClass) { trace_entry(&entry); - let error = self.queue.lock().record(entry, class).err(); - if let Some(error) = error { + if let Some(refusal) = self.queue.lock().record(entry, class).err() { let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; if dropped.is_power_of_two() { tracing::warn!( dropped, class = class.as_str(), - error = %match error { - std::sync::mpsc::TrySendError::Full(_) => "queue_full", - std::sync::mpsc::TrySendError::Disconnected(_) => "worker_disconnected", - }, + error = refusal.as_str(), "Dropped policy audit Windows Event Log entries" ); } @@ -293,44 +333,19 @@ fn event_log_emitter() -> impl FnMut(Entry) + Send + 'static { } } -/// Emits entries until both queues are closed, draining terminal outcomes before admission entries -/// so that a saturated admission queue cannot delay or drop an outcome. +/// Emits entries in the order they were recorded, until the queue is closed. #[cfg(any(test, not(debug_assertions)))] fn event_log_worker( - admission: &std::sync::mpsc::Receiver, - outcome: &std::sync::mpsc::Receiver, + receiver: &std::sync::mpsc::Receiver<(Entry, EntryClass)>, + admission_pending: &AtomicUsize, mut emit: impl FnMut(Entry), ) { - use std::sync::mpsc::{RecvTimeoutError, TryRecvError}; - - // Bounded wait, so a queued outcome is emitted even when no admission entry arrives. - let poll_interval = std::time::Duration::from_millis(50); - loop { - match outcome.try_recv() { - Ok(entry) => { - emit(entry); - continue; - } - Err(TryRecvError::Empty) => {} - // No outcome can arrive anymore, so the admission queue holds everything that is left. - Err(TryRecvError::Disconnected) => { - for entry in admission.iter() { - emit(entry); - } - return; - } - } - match admission.recv_timeout(poll_interval) { - Ok(entry) => emit(entry), - Err(RecvTimeoutError::Timeout) => {} - // Admission is closed, so the outcome queue holds everything that is left. - Err(RecvTimeoutError::Disconnected) => { - for entry in outcome.iter() { - emit(entry); - } - return; - } + while let Ok((entry, class)) = receiver.recv() { + if class == EntryClass::Admission { + // The entry left the queue, so its slot in the admission budget is free again. + admission_pending.fetch_sub(1, Ordering::Relaxed); } + emit(entry); } } @@ -674,7 +689,7 @@ pub(crate) mod tests { #[test] fn terminal_outcomes_are_reserved_against_an_admission_flood() { - // A worker that cannot make progress leaves the admission queue in its saturated state. + // A worker that cannot make progress leaves the queue in its saturated state. let open = Arc::new(AtomicBool::new(false)); let release = Arc::clone(&open); let (emitted, received) = std::sync::mpsc::channel(); @@ -693,23 +708,28 @@ pub(crate) mod tests { let mut admitted = 0; let mut refused = 0; - for _ in 0..EVENT_LOG_ADMISSION_QUEUE_CAPACITY + 8 { + for _ in 0..EVENT_LOG_ADMISSION_BUDGET + 8 { if queue.record(admission(), EntryClass::Admission).is_ok() { admitted += 1; } else { refused += 1; } } - assert!(admitted >= EVENT_LOG_ADMISSION_QUEUE_CAPACITY); - assert!(refused > 0, "the flood must saturate the admission queue"); + assert!(admitted >= EVENT_LOG_ADMISSION_BUDGET); + assert!(refused > 0, "the flood must saturate the admission budget"); - // The outcome is still accepted, because its own queue is reserved. + // The outcome is still accepted, because the outcome reserve is out of reach of the flood. queue .record(outcome, EntryClass::Outcome) .expect("a terminal outcome is accepted while admission entries are refused"); open.store(true, Ordering::Release); queue.drain(); + assert_eq!( + queue.admission_pending.load(Ordering::Relaxed), + 0, + "every admitted entry released its slot in the admission budget" + ); // Every admitted entry reached the sink, plus the outcome that the flood could not displace. let codes = received.iter().map(|entry| entry.event_code).collect::>(); @@ -723,6 +743,62 @@ pub(crate) mod tests { ); } + #[test] + fn an_accepted_attempt_precedes_its_terminal_outcome() { + // The worker blocks on the first entry it takes, so the attempt and its outcome are both + // queued while it cannot make progress, and the emitted order is the recorded order. + let busy = Arc::new(AtomicBool::new(false)); + let open = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&busy); + let release = Arc::clone(&open); + let (emitted, received) = std::sync::mpsc::channel(); + let mut queue = EventLogQueue::start(move |entry| { + started.store(true, Ordering::Release); + while !release.load(Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let _ = emitted.send(entry); + }) + .expect("the audit worker starts"); + + queue + .record( + // The terminal outcome of a previous write, which the worker takes and emits first. + Entry::new("Policy management change failed").event_code(policy_events::POLICY_CHANGE_FAILED), + EntryClass::Outcome, + ) + .expect("the blocking outcome is accepted"); + while !busy.load(Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + queue + .record( + Entry::new("Policy management write attempted").event_code(policy_events::POLICY_WRITE_ATTEMPTED), + EntryClass::Admission, + ) + .expect("the attempt is accepted"); + queue + .record( + Entry::new("Policy management change succeeded").event_code(policy_events::POLICY_CHANGE_SUCCEEDED), + EntryClass::Outcome, + ) + .expect("the terminal outcome is accepted"); + + open.store(true, Ordering::Release); + queue.drain(); + + let codes = received.iter().map(|entry| entry.event_code).collect::>(); + assert_eq!( + codes, + [ + Some(policy_events::POLICY_CHANGE_FAILED), + Some(policy_events::POLICY_WRITE_ATTEMPTED), + Some(policy_events::POLICY_CHANGE_SUCCEEDED) + ] + ); + } + #[test] fn audit_text_removes_control_characters_before_truncation() { let value = format!( diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index cf497e90d..9811ccede 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -265,8 +265,10 @@ impl PolicyStore { if *monitoring != Monitoring::Initializing { return self.management_snapshot(); } + // The watcher reports ready after the provisional load, so a policy that changed in between + // is an external change and must be audited as one. let (_, observation) = self.observe_storage(false); - let management = self.publish_observation(observation); + let management = self.publish_external_observation(observation); *monitoring = Monitoring::Available; management } diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index 300c416fb..bddd993f7 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -414,10 +414,25 @@ mod tests { } #[tokio::test] async fn readiness_reloads_each_disk_state_after_provisional_load() { - for (disk_policy, invalid, expected) in [ - (Some(policy("changed", 2)), false, PolicyManagementState::Active), - (None, false, PolicyManagementState::Missing), - (None, true, PolicyManagementState::Invalid), + for (disk_policy, invalid, expected, expected_event) in [ + ( + Some(policy("changed", 2)), + false, + PolicyManagementState::Active, + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED), + ), + ( + None, + false, + PolicyManagementState::Missing, + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED), + ), + ( + None, + true, + PolicyManagementState::Invalid, + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED), + ), ] { let storage = Arc::new(TestStorage::new(Some(policy("provisional", 1)))); let store = PolicyStore::load_with_storage( @@ -426,11 +441,27 @@ mod tests { Monitoring::Initializing, ); storage.set_disk_state(disk_policy, invalid, 3); + crate::audit::tests::take_events(); assert_eq!(store.mark_monitoring_ready().await.state, expected); assert_eq!( store.active_policy().is_some(), expected == PolicyManagementState::Active ); + // A policy that changed before the watcher reported ready is an external change. + let events = crate::audit::tests::take_events(); + assert_eq!( + events.iter().map(|entry| entry.event_code).collect::>(), + [expected_event] + ); + assert_eq!( + store.mark_monitoring_ready().await.state, + expected, + "a settled store keeps its state on a second readiness report" + ); + assert!( + crate::audit::tests::take_events().is_empty(), + "readiness audits the observation once" + ); } } #[tokio::test] From 2d890ee458154140a14c5f99b016c8530a5abb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 05:30:55 +0900 Subject: [PATCH 33/41] fix(agent): record a policy denial as an admission event A denial was recorded with the class reserved for terminal outcomes, so an unauthenticated flood of `PUT /v1/policy` requests could spend the 64 slots kept for the outcome of an accepted write and drop it. Denials are recorded before the pipe client authenticates, so they belong to the class that yields under saturation, as the abandoned-write path already did. `WriteAudit::finish` now takes the class of the terminal event it records: a denial uses `Admission`, the outcome of a write that reached the policy store keeps `Outcome`. The single-terminal-event guard is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 51 ++++++++++++++++++++------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 02403652d..49a7a4993 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -23,8 +23,9 @@ const MAX_PATH_BYTES: usize = 1024; const MAX_POLICY_ID_BYTES: usize = 256; /// Slots of the Event Log queue kept for terminal outcomes and external changes. /// -/// Only authenticated policy writes, which the policy store serializes, and the policy store's own -/// observation of external changes produce this class, so a request flood cannot reach the reserve. +/// Only the outcome of an authenticated policy write, which the policy store serializes, and the +/// policy store's own observation of an external change take these slots. Attempts and denials, +/// which an unauthenticated client can produce at will, are refused before reaching them. #[cfg(any(test, not(debug_assertions)))] const EVENT_LOG_OUTCOME_RESERVE: usize = 64; @@ -398,7 +399,9 @@ impl WriteAudit { } pub(crate) fn denied(&self, reason: DenialReason) { - self.finish(|state| { + // A denial is recorded before the pipe client authenticates, so an unauthenticated flood can + // produce it at will: it yields rather than consuming the capacity reserved for outcomes. + self.finish(EntryClass::Admission, |state| { policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str()) }); } @@ -415,7 +418,7 @@ impl WriteAudit { } else { "failed" }; - self.finish(|state| { + self.finish(EntryClass::Outcome, |state| { if operation == PolicyReplacementOperation::Create { policy_events::policy_write_failed( policy_events::POLICY_CREATE_FAILED, @@ -468,7 +471,7 @@ impl WriteAudit { } else { "applied" }; - self.finish(|state| { + self.finish(EntryClass::Outcome, |state| { if operation == PolicyReplacementOperation::Create { policy_events::policy_write_succeeded( policy_events::POLICY_CREATE_SUCCEEDED, @@ -503,15 +506,18 @@ impl WriteAudit { }); } - /// Records the terminal outcome, which the reserved queue shields from admission flooding. - fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> Entry) { + /// Records the terminal event of `class`, which decides whether a request flood may drop it. + /// + /// A denial never reaches the policy store, so it yields like an attempt; the reserved capacity + /// is kept for the outcome of an authenticated write. + fn finish(&self, class: EntryClass, entry: impl FnOnce(&WriteAuditState) -> Entry) { if self .0 .terminal_recorded .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_ok() { - self.0.record(entry(&self.0), EntryClass::Outcome); + self.0.record(entry(&self.0), class); } } } @@ -605,17 +611,21 @@ pub(crate) mod tests { } #[derive(Default)] - pub(crate) struct Recorder(parking_lot::Mutex>); + pub(crate) struct Recorder(parking_lot::Mutex>); impl Recorder { pub(crate) fn events(&self) -> Vec { - self.0.lock().clone() + self.0.lock().iter().map(|(entry, _)| entry.clone()).collect() + } + + fn classes(&self) -> Vec { + self.0.lock().iter().map(|(_, class)| *class).collect() } } impl AuditRecorder for Recorder { - fn record(&self, entry: Entry, _: EntryClass) { - self.0.lock().push(entry); + fn record(&self, entry: Entry, class: EntryClass) { + self.0.lock().push((entry, class)); } } @@ -669,6 +679,23 @@ pub(crate) mod tests { ); } + #[test] + fn a_denial_yields_like_an_attempt_while_an_outcome_keeps_the_reserve() { + // A denial is reachable before the pipe client authenticates, so an unauthenticated flood + // must be unable to spend the capacity reserved for the outcome of an accepted write. + let (denied, recorder) = test_audit(); + denied.denied(DenialReason::AuthenticationFailed); + assert_eq!(recorder.classes(), [EntryClass::Admission, EntryClass::Admission]); + + let (abandoned, recorder) = test_audit(); + drop(abandoned); + assert_eq!(recorder.classes(), [EntryClass::Admission, EntryClass::Admission]); + + let (failed, recorder) = test_audit(); + failed.failed(PolicyReplacementOperation::Update, FailureReason::InvalidPolicy); + assert_eq!(recorder.classes(), [EntryClass::Admission, EntryClass::Outcome]); + } + #[test] fn abandoned_clones_record_one_terminal_denial() { let (audit, recorder) = test_audit(); From 1bdb44df85dff2ad17b82db52e6d7e59f490f82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 05:49:24 +0900 Subject: [PATCH 34/41] fix(agent): audit an external policy replacement after publication A policy replaced or deleted on disk in the window between publication and the store's own confirmation was absorbed silently: the new content was served, but no external change event was recorded, so the replacement could not be seen in the Event Log. The post-publication failure path now verifies that the observation still holds the document it published. It suppresses the notification only when that is true; otherwise the observed content is a change made outside the store and is audited as one, before the write is recorded as failed. The audit recorder documentation no longer claims that only admission entries can be dropped: `/v1/policy` is unauthenticated at that point, so both classes are best-effort under saturation, and only a full queue or a gone worker refuses an outcome. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 13 +- .../src/policy_store/mod.rs | 142 +++++++++++++++++- 2 files changed, 146 insertions(+), 9 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 49a7a4993..66152f531 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -117,8 +117,8 @@ enum EntryClass { /// A write attempt or a denial. Recorded from unauthenticated requests, so it may be dropped /// when the sink saturates. Admission, - /// The terminal outcome of a policy write, or the observation of an external change. Never - /// dropped while the reserved capacity lasts. + /// The terminal outcome of a policy write, or the observation of an external change. It does + /// not consume the admission budget, so only a full queue or a gone worker can refuse it. Outcome, } @@ -153,8 +153,9 @@ impl QueueRefusal { } trait AuditRecorder: Send + Sync { - /// Records one entry of `class`. Only [`EntryClass::Admission`] entries may be dropped, and only - /// when their budget or the queue is full. + /// Records one entry of `class`. Admission is best-effort for both classes: an + /// [`EntryClass::Admission`] entry is refused once admissions fill their budget, and either + /// class is refused while the shared queue is full or its worker is gone. fn record(&self, entry: Entry, class: EntryClass); /// Stops accepting entries and waits for the accepted ones to be emitted. @@ -202,8 +203,8 @@ struct SystemRecorder { /// /// Every entry shares one bounded queue, so entries reach the sink in the order they were recorded /// and an accepted write's attempt precedes its terminal outcome. Write attempts and denials are -/// refused once they occupy [`EVENT_LOG_ADMISSION_BUDGET`] slots, which keeps -/// [`EVENT_LOG_OUTCOME_RESERVE`] slots available for outcomes and external changes. +/// refused once they occupy [`EVENT_LOG_ADMISSION_BUDGET`] slots, so a flood of unauthenticated +/// attempts cannot fill the queue and starve outcomes and external changes. #[cfg(any(test, not(debug_assertions)))] struct EventLogQueue { /// `None` once [`Self::drain`] closed the queue, so no later entry can be accepted. diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 9811ccede..74f947431 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -486,9 +486,16 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); let audit_path = current.canonical_path.clone(); - // This request already made its own content live, so the reobserved difference is this - // write and not an external change. The terminal event reports the failed activation. - let management = self.publish_observation(current); + // The publication is this request's own content, so a difference the reload reports + // afterwards is this write rather than an external change -- but only while the + // storage still holds the document this request committed. An administrator may + // replace the policy between publication and this re-observation, and that + // replacement must be audited as an external change instead of being absorbed. + let management = if holds_published_document(¤t, &policy) { + self.publish_observation(current) + } else { + self.publish_external_observation(current) + }; audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); return Err(error_with_management( ErrorCode::PolicyActivationFailed, @@ -707,6 +714,21 @@ fn error_with_management( response } +/// Whether a re-observed storage state still holds the document a request just published. +/// +/// Documents never compare equal by identity alone: an administrator can write a modified document +/// that keeps the publication's id and revision, so both sides are compared as serialized values, +/// exactly as the authoritative reload the storage performs after its own write. +fn holds_published_document(observation: &Observation, published: &PolicyDocument) -> bool { + let Some(current) = observation.policy.as_ref() else { + return false; + }; + match (serde_json::to_value(current), serde_json::to_value(published)) { + (Ok(current), Ok(published)) => current == published, + _ => false, + } +} + #[cfg(test)] fn observe_file(source: PolicyConfigurationSource, path: &Path) -> Observation { windows::observe(source, path, &windows::AtomicityProbeCache::new()) @@ -719,6 +741,7 @@ struct TestStorage { fail_concurrent_check: std::sync::atomic::AtomicBool, fail_target_retention: std::sync::atomic::AtomicBool, race_before_persist: parking_lot::Mutex>, + race_after_persist: parking_lot::Mutex>, post_persist_capability: parking_lot::Mutex)>>, fail_after_publication: std::sync::atomic::AtomicBool, persisted_configured_paths: parking_lot::Mutex>, @@ -733,6 +756,7 @@ impl TestStorage { fail_concurrent_check: std::sync::atomic::AtomicBool::new(false), fail_target_retention: std::sync::atomic::AtomicBool::new(false), race_before_persist: parking_lot::Mutex::new(None), + race_after_persist: parking_lot::Mutex::new(None), post_persist_capability: parking_lot::Mutex::new(None), fail_after_publication: std::sync::atomic::AtomicBool::new(false), persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), @@ -746,6 +770,7 @@ impl TestStorage { fail_concurrent_check: std::sync::atomic::AtomicBool::new(false), fail_target_retention: std::sync::atomic::AtomicBool::new(false), race_before_persist: parking_lot::Mutex::new(None), + race_after_persist: parking_lot::Mutex::new(None), post_persist_capability: parking_lot::Mutex::new(None), fail_after_publication: std::sync::atomic::AtomicBool::new(false), persisted_configured_paths: parking_lot::Mutex::new(Vec::new()), @@ -760,6 +785,13 @@ impl TestStorage { *self.race_before_persist.lock() = Some(policy); } + /// Makes the next `persist` publish the request's content, then replace it on disk with + /// `replacement` and report activation failure -- an administrator writing the file in the + /// window between publication and the authoritative reload. + fn race_after_next_publication(&self, replacement: Observation) { + *self.race_after_persist.lock() = Some(replacement); + } + /// Makes the next `persist` report activation failure after the content was published. fn fail_after_next_publication(&self) { self.fail_after_publication @@ -850,6 +882,12 @@ impl TestStorage { next.fingerprint = DiskFingerprint::test_active(bytes, 2, 1, 1, 2); } *self.observation.lock() = clone_observation(&next); + if let Some(replacement) = self.race_after_persist.lock().take() { + *self.observation.lock() = replacement; + return Err(WriteFailure::PostPublication(anyhow::anyhow!( + "injected external policy replacement after publication" + ))); + } if self .fail_after_publication .swap(false, std::sync::atomic::Ordering::SeqCst) @@ -1249,6 +1287,104 @@ mod storage_tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn post_publication_external_replacement_is_audited() { + 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")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + let (audit, recorder) = recording_audit(); + storage.race_after_next_publication(test_observation(Some(policy("external", 7)), false, 9)); + + let error = store + .replace(request, audit) + .await + .expect_err("the reload observed a replacement"); + + assert_eq!(error.code, ErrorCode::PolicyActivationFailed); + // The storage no longer holds the committed document, so the replacement is an external + // change: it must be audited and served, not absorbed into this request. + let active = store.active_policy().expect("external policy is active"); + assert_eq!(active.metadata.id.0, "external"); + assert_eq!(active.metadata.revision, 7); + assert_eq!( + crate::audit::tests::take_events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] + ); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "activation_failed") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn post_publication_external_removal_is_audited() { + 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")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + let request = update_request(&store); + let (audit, recorder) = recording_audit(); + storage.race_after_next_publication(test_observation(None, false, 9)); + + let error = store + .replace(request, audit) + .await + .expect_err("the reload observed a removal"); + + assert_eq!(error.code, ErrorCode::PolicyActivationFailed); + assert!( + store.active_policy().is_none(), + "a removed external policy is not published" + ); + let events = crate::audit::tests::take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + ); + assert!( + events[0] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "missing") + ); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + } + #[tokio::test] async fn audited_replacement_records_one_success_after_activation() { let store = PolicyStore::load_with_storage( From 5436ba4737cf522461a066a6d1de049360193e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 06:06:17 +0900 Subject: [PATCH 35/41] test(agent-installer): correct the Event Log source registration rationale The comments on `EventLogSourceRegistryTests` described the regression mode incorrectly. `WinEvent::new` only calls `RegisterEventSourceW`, which succeeds without a registry source key: the event still reaches the Application log, and what the key supplies is the message template. Dropping the key leaves the audit entry readable as a raw event id with its insertion strings, it does not turn the sink into a no-op that loses the events. The same paragraph claimed the matching registry view is what makes 64-bit readers see the source. `HKLM\SYSTEM` is shared between the WOW64 registry views, so the assertion pins the architecture flag passed to the MSI component rather than a separate source visibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EventLogSourceRegistryTests.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs index 86ff3c76e..9041cf6fc 100644 --- a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs +++ b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs @@ -13,11 +13,15 @@ public sealed class EventLogSourceRegistryTests // Log source, so this test pins that declaration against regression. // // The broker's audit sink writes under the source name "Devolutions Agent" (WinEvent::new in - // now-package-broker/src/audit.rs). Windows resolves that source to the messages compiled into - // DevolutionsAgent.exe only from a registry source key named exactly after the runtime source - // name, with EventMessageFile pointing at the executable carrying the message table. A - // regression here fails nothing at build time: the sink degrades to a no-op on initialization - // failure and only logs a tracing error, so the release build would silently lose those events. + // now-package-broker/src/audit.rs), which only calls RegisterEventSourceW. That call succeeds + // without a registry source key, so the event still reaches the Application log; what the key + // provides is the message template. Without a source key named exactly after the runtime source + // name, with EventMessageFile pointing at the executable carrying the message table, Windows + // cannot resolve the template and the entry shows the raw event id and its insertion strings + // instead of the description compiled into DevolutionsAgent.exe. + // + // Nothing fails at build time when the declaration regresses, so this test is what keeps it in + // step with the runtime source name. // // This asserts the declared value only. It does not build or install an MSI, so it does not // validate the WiX pipeline or the [INSTALLDIR] substitution. @@ -39,7 +43,9 @@ public void SourceUsesNativeMsiRegistryLifecycle(bool win64) Assert.Equal(@"SYSTEM\CurrentControlSet\Services\EventLog\Application\Devolutions Agent", value.Key); Assert.Equal("EventMessageFile", value.Name); Assert.Equal("[INSTALLDIR]DevolutionsAgent.exe", value.Value); - // 64-bit readers only see the source if the value lands in the matching registry view. + // Pins the architecture flag the installer passes to the MSI component. HKLM\SYSTEM is + // shared between the WOW64 registry views, so this is not what makes a 64-bit reader see + // the source. Assert.Equal(win64, value.Win64); // Registered on install and removed on uninstall through the component lifecycle, which // leaves the source key itself alone. createAndRemoveOnUninstall would delete the whole key From fd5e6ca0f8ceff9f6bad881051986ddd62feb901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 06:19:24 +0900 Subject: [PATCH 36/41] fix(agent): drain the accepted pipe connections before an accept loop failure The accept loop drained its connection set only when it exited normally. If creating the next pipe instance failed, the `?` returned straight out of `run_pipe_server` and dropped the set, so a connection still serving a request could record a policy audit event after the caller drained the audit queue, losing that terminal event. The loop now runs in its own function, so every exit path, including the failure, reaches a single drain before the result is reported. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 69 +++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index f9324794f..f075e59fd 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -53,12 +53,26 @@ const CONNECTION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from /// Start the named pipe server and accept connections until shutdown. pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> { + // Serving a connection can record policy audit events, and the caller drains the audit queue as + // soon as this function returns, so no connection may outlive it. The accept loop runs in its + // own function, so that every one of its exit paths, including a failure to create the next + // pipe instance, reaches the drain below instead of returning straight out. + let mut connections = tokio::task::JoinSet::new(); + let result = accept_connections(&state, &shutdown, &mut connections).await; + + drain_after_accept_loop(&mut connections, CONNECTION_SHUTDOWN_GRACE, result).await +} + +/// Accept connections until `shutdown` is cancelled or the next pipe instance cannot be created. +async fn accept_connections( + state: &Arc, + shutdown: &CancellationToken, + connections: &mut tokio::task::JoinSet<()>, +) -> anyhow::Result<()> { let pipe_name = state.pipe_name.clone(); info!(%pipe_name, "Starting named pipe server"); let connection_permits = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS)); - // Serving a connection can record policy audit events, so shutdown has to wait for these. - let mut connections = tokio::task::JoinSet::new(); let mut first_instance = true; loop { @@ -83,7 +97,7 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke result = server.connect() => { match result { Ok(()) => { - let state = Arc::clone(&state); + let state = Arc::clone(state); connections.spawn(async move { let serve = async move { // Keep blocking unauthenticated capture off the accept loop and @@ -131,11 +145,25 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke info!("Pipe server shutting down"); - wait_for_connections(&mut connections, CONNECTION_SHUTDOWN_GRACE).await; - Ok(()) } +/// Drain the connections the accept loop spawned, then report what the loop returned. +/// +/// Serving a connection can record policy audit events, and the caller drains the audit queue as +/// soon as `run_pipe_server` returns, so no accepted connection may outlive it. An accept loop that +/// fails after accepting one has to wait for that connection too, rather than let the `?` on the +/// failing call drop the set and run an audit recorder destructor after the queue was drained. +async fn drain_after_accept_loop( + connections: &mut tokio::task::JoinSet<()>, + grace: std::time::Duration, + loop_result: anyhow::Result<()>, +) -> anyhow::Result<()> { + wait_for_connections(connections, grace).await; + + loop_result +} + /// Wait for the connection tasks to finish, then abort the ones that outlive the grace. /// /// Serving a connection can record policy audit events, so the caller drains the audit queue as @@ -315,6 +343,37 @@ mod tests { assert!(connections.is_empty(), "no connection task may outlive shutdown"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_accept_loop_failure_waits_for_the_connections_it_accepted() { + // Mirrors `create_pipe_instance` failing after a connection was accepted: the error may + // reach the caller only once no connection can still record an audit event. + let dropped = Arc::new(AtomicBool::new(false)); + let mut connections = JoinSet::new(); + connections.spawn({ + let dropped = Arc::clone(&dropped); + async move { + let _flag = DropFlag(dropped); + std::future::pending::<()>().await; + } + }); + + let started = Instant::now(); + let result = drain_after_accept_loop( + &mut connections, + Duration::from_millis(200), + Err(anyhow::anyhow!("failed to create the next pipe instance")), + ) + .await; + + assert!(result.is_err(), "the accept loop failure is still reported"); + assert!( + started.elapsed() >= Duration::from_millis(200), + "the accepted connections have to settle first" + ); + assert!(dropped.load(Ordering::SeqCst), "the connection task must be aborted"); + assert!(connections.is_empty(), "no connection task may outlive the accept loop"); + } + #[tokio::test] async fn completed_capture_returns_its_permit_to_the_connection_task() { let permits = Arc::new(Semaphore::new(1)); From 690dad92382f41bb99e08d49be7e866bf4c88e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 06:36:03 +0900 Subject: [PATCH 37/41] fix(agent): bound the pipe shutdown so the audit queue is always drained Aborting a connection is cooperative. A connection inside synchronous work, such as authenticating a client or reading the policy storage, never reaches a cancellation point, so `abort_all()` alone does not stop it. The join after the abort was unbounded, so such a connection could hold the shutdown past the runtime's own budget. The agent then stops the runtime before the audit queue is drained, which loses every queued event rather than the events of that one connection. The settle after the abort is now bounded by the same grace as the drain, and reports the residual window instead of silently waiting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 48 ++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index f075e59fd..3fc86dcf4 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -42,13 +42,16 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// 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); -/// How long shutdown waits for the connections that are still serving a request. +/// How long shutdown waits for the connections that are still serving a request, then for the +/// aborted ones to actually stop. /// /// Serving a connection can record a policy audit event, and the caller drains the audit queue as /// soon as this function returns, so nothing may still be running by then. A healthy exchange /// completes in milliseconds, and each connection is already bounded by `CONNECTION_DEADLINE`, so /// this only has to cover the tail of a request already in progress; connections still stuck at -/// the end of it are aborted rather than allowed to hold the shutdown. +/// the end of it are aborted rather than allowed to hold the shutdown. The same budget then bounds +/// the wait for those aborts to take effect, so a connection stuck in synchronous work costs the +/// queue one grace period instead of keeping it from ever being drained. const CONNECTION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); /// Start the named pipe server and accept connections until shutdown. @@ -167,14 +170,23 @@ async fn drain_after_accept_loop( /// Wait for the connection tasks to finish, then abort the ones that outlive the grace. /// /// Serving a connection can record policy audit events, so the caller drains the audit queue as -/// soon as this returns and no connection may outlive it. +/// soon as this returns. Waiting is bounded on both sides: a task inside synchronous work, such as +/// authenticating a client or reading the policy storage, never reaches a cancellation point, so +/// the settle after the abort cannot be left unbounded either. Draining the queue after +/// interrupting one such connection is worth far more than losing every event of it, and the agent +/// gives the whole shutdown a fixed budget before it stops the runtime. async fn wait_for_connections(connections: &mut tokio::task::JoinSet<()>, grace: std::time::Duration) { let drained = tokio::time::timeout(grace, async { while connections.join_next().await.is_some() {} }).await; if drained.is_err() { warn!("Aborted named pipe connections still serving at shutdown"); connections.abort_all(); - while connections.join_next().await.is_some() {} + + let settled = tokio::time::timeout(grace, async { while connections.join_next().await.is_some() {} }).await; + + if settled.is_err() { + error!("Named pipe connections are still running blocking work; their policy audit events may be lost"); + } } } @@ -260,6 +272,9 @@ mod tests { use super::*; + /// Blocking work a connection can be stuck in that aborting it cannot interrupt. + const NON_ABORTABLE_CONNECTION_WORK: Duration = Duration::from_secs(1); + #[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)); @@ -343,6 +358,31 @@ mod tests { assert!(connections.is_empty(), "no connection task may outlive shutdown"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn shutdown_gives_up_on_a_connection_stuck_in_blocking_work() { + // A connection inside synchronous work, such as authenticating a client or reading the + // policy storage, never reaches a cancellation point, so aborting it does not stop it. + // The wait still has to return, because the caller drains the audit queue right after. + let mut connections = JoinSet::new(); + connections.spawn(async { + tokio::task::block_in_place(|| std::thread::sleep(NON_ABORTABLE_CONNECTION_WORK)); + }); + + let started = Instant::now(); + wait_for_connections(&mut connections, Duration::from_millis(200)).await; + + // Returning with the task still in the set is the regression: an unbounded settle only + // returns once every connection has finished. + assert!( + !connections.is_empty(), + "the wait must not be held by a connection that cannot be aborted" + ); + assert!( + started.elapsed() < NON_ABORTABLE_CONNECTION_WORK, + "the wait must return long before the blocking work is over" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_accept_loop_failure_waits_for_the_connections_it_accepted() { // Mirrors `create_pipe_instance` failing after a connection was accepted: the error may From 268554c59bd8c95f3973458c5fa3dbd48e06ff28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 06:57:18 +0900 Subject: [PATCH 38/41] fix(agent): keep the audit queue open for a connection that can still commit Serving a named pipe connection can commit a policy and record its terminal audit event, and that write is synchronous, so shutdown can give up on a connection that is still inside it. The recorder was then drained right away, closing the queue, so the event of the policy that connection went on to commit was rejected instead of being emitted. Each connection now holds an audit lease for as long as it can record. The recorder closes the queue only when no lease is alive; otherwise it flushes the accepted entries and leaves the queue accepting, so a late terminal event is emitted rather than rejected. Closing the queue is what loses the event, and waiting for the connection to finish is the unbounded join the shutdown must not perform. The flush is bounded, and giving up on a stalled Windows Event Log sink is logged with the number of entries still pending. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 218 ++++++++++++++++++++++++- crates/now-package-broker/src/pipe.rs | 49 +++--- crates/now-package-broker/src/task.rs | 4 +- 3 files changed, 245 insertions(+), 26 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 66152f531..21ffad3fe 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -4,9 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(all(not(test), not(debug_assertions)))] use std::sync::atomic::AtomicU64; -#[cfg(any(test, not(debug_assertions)))] -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use agent_sysevent_codes as policy_events; use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; @@ -41,20 +39,78 @@ const EVENT_LOG_ADMISSION_BUDGET: usize = 256; #[cfg(any(test, not(debug_assertions)))] const EVENT_LOG_QUEUE_CAPACITY: usize = EVENT_LOG_ADMISSION_BUDGET + EVENT_LOG_OUTCOME_RESERVE; +/// How long the shutdown waits for the accepted entries to reach the Event Log sink. +/// +/// The worker is a plain thread, so a sink that stopped making progress would otherwise hold the +/// shutdown until the agent stops the process. Entries still queued when this expires stay in the +/// queue, and the worker keeps emitting them for as long as the process lives. +#[cfg(any(test, not(debug_assertions)))] +const EVENT_LOG_FLUSH_GRACE: std::time::Duration = std::time::Duration::from_secs(5); + +/// How often flushing re-checks the queue, which the worker empties without signalling completion. +#[cfg(any(test, not(debug_assertions)))] +const EVENT_LOG_FLUSH_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); + static RECORDER: std::sync::OnceLock> = std::sync::OnceLock::new(); +/// Connections that may still record a policy audit event. +static AUDIT_PRODUCERS: AtomicUsize = AtomicUsize::new(0); + /// The process-wide recorder, started on the first policy audit event. fn recorder() -> &'static Arc { RECORDER.get_or_init(default_recorder) } +/// Held by a connection for as long as it may record a terminal policy audit event. +/// +/// Serving a connection can commit a policy and record its outcome, and that work is synchronous, +/// so the shutdown can give up on a connection that is still inside it. Holding a lease for the +/// lifetime of the connection keeps [`drain`] from closing the queue under it. +pub(crate) struct AuditLease(&'static AtomicUsize); + +impl AuditLease { + /// Marks the caller as able to record until the returned lease is dropped. + #[must_use] + pub(crate) fn acquire() -> Self { + Self::acquire_on(&AUDIT_PRODUCERS) + } + + fn acquire_on(counter: &'static AtomicUsize) -> Self { + counter.fetch_add(1, Ordering::AcqRel); + Self(counter) + } +} + +impl Drop for AuditLease { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + /// Stops accepting policy audit events and waits for the ones already accepted to reach the sink. /// /// The recorder lives in a process-lifetime static, so its worker is otherwise killed with whatever /// it is still holding when the process exits. +/// +/// A connection that can still record an event keeps the queue open: closing it would reject the +/// terminal event of a policy write that is still finishing, so the queue is flushed and left +/// accepting for as long as the process lives instead. pub(crate) fn drain() { if let Some(recorder) = RECORDER.get() { + shutdown(recorder.as_ref(), AUDIT_PRODUCERS.load(Ordering::Acquire)); + } +} + +/// Closes the queue, unless a connection that can still record into it is alive. +fn shutdown(recorder: &dyn AuditRecorder, producers: usize) { + if producers == 0 { recorder.drain(); + } else { + tracing::warn!( + producers, + "Named pipe connections are still running blocking work; flushing the policy audit queue without closing it" + ); + recorder.flush(); } } @@ -160,6 +216,12 @@ trait AuditRecorder: Send + Sync { /// Stops accepting entries and waits for the accepted ones to be emitted. fn drain(&self) {} + + /// Waits for the accepted entries to be emitted, without closing the queue. + /// + /// Used instead of [`Self::drain`] while a connection that can still record is alive, so its + /// terminal event is emitted rather than rejected. + fn flush(&self) {} } fn default_recorder() -> Arc { @@ -211,6 +273,8 @@ struct EventLogQueue { sender: Option>, /// Write attempts and denials still queued, shared with the worker that dequeues them. admission_pending: Arc, + /// Entries accepted but not yet emitted, shared with the worker that emits them. + pending: Arc, worker: Option>, } @@ -219,14 +283,17 @@ impl EventLogQueue { fn start(emit: impl FnMut(Entry) + Send + 'static) -> std::io::Result { let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); let admission_pending = Arc::new(AtomicUsize::new(0)); + let pending = Arc::new(AtomicUsize::new(0)); let queued = Arc::clone(&admission_pending); + let outstanding = Arc::clone(&pending); let worker = std::thread::Builder::new() .name("policy-audit-event-log".to_owned()) - .spawn(move || event_log_worker(&receiver, &queued, emit))?; + .spawn(move || event_log_worker(&receiver, &queued, &outstanding, emit))?; Ok(Self { sender: Some(sender), admission_pending, + pending, worker: Some(worker), }) } @@ -244,9 +311,12 @@ impl EventLogQueue { return Err(QueueRefusal::Full); } } + // Claim the entry before sending, so the worker cannot emit it before a flush counts it. + self.pending.fetch_add(1, Ordering::AcqRel); match sender.try_send((entry, class)) { Ok(()) => Ok(()), Err(error) => { + self.pending.fetch_sub(1, Ordering::AcqRel); if class == EntryClass::Admission { // The entry never entered the queue, so its claimed slot is free again. self.admission_pending.fetch_sub(1, Ordering::Relaxed); @@ -302,6 +372,13 @@ impl AuditRecorder for SystemRecorder { // The lock keeps a concurrent `record` from queueing an entry the closed queue would drop. self.queue.lock().drain(); } + + fn flush(&self) { + // The pending count is read outside the lock, so a connection that is finishing its policy + // write can still record its terminal event while the worker catches up. + let pending = Arc::clone(&self.queue.lock().pending); + wait_for_emission(&pending, EVENT_LOG_FLUSH_GRACE); + } } #[cfg(not(test))] @@ -340,6 +417,7 @@ fn event_log_emitter() -> impl FnMut(Entry) + Send + 'static { fn event_log_worker( receiver: &std::sync::mpsc::Receiver<(Entry, EntryClass)>, admission_pending: &AtomicUsize, + pending: &AtomicUsize, mut emit: impl FnMut(Entry), ) { while let Ok((entry, class)) = receiver.recv() { @@ -348,6 +426,33 @@ fn event_log_worker( admission_pending.fetch_sub(1, Ordering::Relaxed); } emit(entry); + // Counted as emitted only after the sink call, so flushing cannot miss an entry being + // emitted: it is still counted before and after the call. + pending.fetch_sub(1, Ordering::Release); + } +} + +/// Waits, at most for `grace`, until every accepted entry has been emitted. +/// +/// The worker empties the queue without signalling completion, so this polls the count it +/// decrements. A sink that stopped making progress would otherwise hold the shutdown +/// indefinitely, so giving up is logged rather than waited out. +#[cfg(any(test, not(debug_assertions)))] +fn wait_for_emission(pending: &AtomicUsize, grace: std::time::Duration) { + let deadline = std::time::Instant::now() + grace; + loop { + let queued = pending.load(Ordering::Acquire); + if queued == 0 { + return; + } + if std::time::Instant::now() >= deadline { + tracing::warn!( + queued, + "Windows Event Log policy audit entries are still pending; the agent is stopping without waiting for them" + ); + return; + } + std::thread::sleep(EVENT_LOG_FLUSH_POLL_INTERVAL); } } @@ -657,6 +762,22 @@ pub(crate) mod tests { fn record(&self, _: Entry, _: EntryClass) {} } + /// Records which shutdown the recorder was asked for. + #[derive(Default)] + struct ShutdownRecorder(parking_lot::Mutex>); + + impl AuditRecorder for ShutdownRecorder { + fn record(&self, _: Entry, _: EntryClass) {} + + fn drain(&self) { + self.0.lock().push("drain"); + } + + fn flush(&self) { + self.0.lock().push("flush"); + } + } + 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")) @@ -909,4 +1030,93 @@ pub(crate) mod tests { assert_eq!(succeeded_recorder.events()[1].event_code, Some(success_code)); } } + + #[test] + fn a_lease_holds_the_recorder_open_until_the_last_one_is_dropped() { + static PRODUCERS: AtomicUsize = AtomicUsize::new(0); + + let lease = AuditLease::acquire_on(&PRODUCERS); + assert_eq!(PRODUCERS.load(Ordering::Acquire), 1); + + let nested = AuditLease::acquire_on(&PRODUCERS); + drop(lease); + assert_eq!( + PRODUCERS.load(Ordering::Acquire), + 1, + "a connection can only record while it holds a lease" + ); + + drop(nested); + assert_eq!(PRODUCERS.load(Ordering::Acquire), 0); + } + + #[test] + fn a_live_producer_makes_the_shutdown_flush_instead_of_closing_the_queue() { + let recorder = ShutdownRecorder(parking_lot::Mutex::new(Vec::new())); + shutdown(&recorder, 1); + assert_eq!(*recorder.0.lock(), ["flush"]); + + let recorder = ShutdownRecorder(parking_lot::Mutex::new(Vec::new())); + shutdown(&recorder, 0); + assert_eq!(*recorder.0.lock(), ["drain"]); + } + + #[test] + fn a_flushed_queue_still_accepts_the_terminal_event_of_a_live_producer() { + let (emitted, received) = std::sync::mpsc::channel(); + let mut queue = EventLogQueue::start(move |entry| { + let _ = emitted.send(entry); + }) + .expect("the audit worker starts"); + + queue + .record( + Entry::new("Policy management write attempted").event_code(policy_events::POLICY_WRITE_ATTEMPTED), + EntryClass::Admission, + ) + .expect("the attempt is accepted"); + wait_for_emission(&queue.pending, EVENT_LOG_FLUSH_GRACE); + assert_eq!( + queue.pending.load(Ordering::Acquire), + 0, + "flushing waits for every accepted entry to reach the sink" + ); + + // The connection that is still finishing its policy write records its terminal event here. + queue + .record( + Entry::new("Policy management change succeeded").event_code(policy_events::POLICY_CHANGE_SUCCEEDED), + EntryClass::Outcome, + ) + .expect("a flush leaves the queue accepting"); + queue.drain(); + + let codes = received.iter().map(|entry| entry.event_code).collect::>(); + assert_eq!( + codes, + [ + Some(policy_events::POLICY_WRITE_ATTEMPTED), + Some(policy_events::POLICY_CHANGE_SUCCEEDED) + ] + ); + } + + #[test] + fn flushing_gives_up_on_a_sink_that_stopped_making_progress() { + let pending = AtomicUsize::new(1); + let started = std::time::Instant::now(); + + wait_for_emission(&pending, std::time::Duration::from_millis(50)); + assert!(started.elapsed() >= std::time::Duration::from_millis(50)); + assert_eq!( + pending.load(Ordering::Acquire), + 1, + "the entry stays queued for the worker" + ); + + pending.store(0, Ordering::Release); + let flushed = std::time::Instant::now(); + wait_for_emission(&pending, std::time::Duration::from_secs(5)); + assert!(flushed.elapsed() < std::time::Duration::from_secs(1)); + } } diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 3fc86dcf4..88721a675 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -45,21 +45,23 @@ const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs( /// How long shutdown waits for the connections that are still serving a request, then for the /// aborted ones to actually stop. /// -/// Serving a connection can record a policy audit event, and the caller drains the audit queue as -/// soon as this function returns, so nothing may still be running by then. A healthy exchange -/// completes in milliseconds, and each connection is already bounded by `CONNECTION_DEADLINE`, so -/// this only has to cover the tail of a request already in progress; connections still stuck at -/// the end of it are aborted rather than allowed to hold the shutdown. The same budget then bounds -/// the wait for those aborts to take effect, so a connection stuck in synchronous work costs the -/// queue one grace period instead of keeping it from ever being drained. +/// A healthy exchange completes in milliseconds, and each connection is already bounded by +/// `CONNECTION_DEADLINE`, so this only has to cover the tail of a request already in progress; +/// connections still stuck at the end of it are aborted rather than allowed to hold the shutdown. +/// The same budget then bounds the wait for those aborts to take effect, so a connection stuck in +/// synchronous work costs the shutdown one grace period. Each connection holds an audit lease for +/// its whole lifetime, so the queued events of a connection the shutdown gave up on are flushed +/// instead of being dropped (see [`crate::audit::AuditLease`]). const CONNECTION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); /// Start the named pipe server and accept connections until shutdown. pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> { - // Serving a connection can record policy audit events, and the caller drains the audit queue as - // soon as this function returns, so no connection may outlive it. The accept loop runs in its - // own function, so that every one of its exit paths, including a failure to create the next - // pipe instance, reaches the drain below instead of returning straight out. + // Serving a connection can record policy audit events, and the caller stops the audit recorder + // as soon as this function returns, so a connection that outlives it must be known to the + // recorder. Each connection holds an audit lease for as long as it can record, which keeps its + // terminal event from being rejected. The accept loop runs in its own function, so that every + // one of its exit paths, including a failure to create the next pipe instance, reaches the + // drain below instead of returning straight out. let mut connections = tokio::task::JoinSet::new(); let result = accept_connections(&state, &shutdown, &mut connections).await; @@ -102,6 +104,11 @@ async fn accept_connections( Ok(()) => { let state = Arc::clone(state); connections.spawn(async move { + // Serving this connection can commit a policy and record its terminal + // event, which is blocking work the shutdown cannot interrupt, so the + // lease keeps the recorder from closing the queue under that event. + let _audit_lease = crate::audit::AuditLease::acquire(); + let serve = async move { // Keep blocking unauthenticated capture off the accept loop and // retain the connection slot until the work actually completes. @@ -153,10 +160,8 @@ async fn accept_connections( /// Drain the connections the accept loop spawned, then report what the loop returned. /// -/// Serving a connection can record policy audit events, and the caller drains the audit queue as -/// soon as `run_pipe_server` returns, so no accepted connection may outlive it. An accept loop that -/// fails after accepting one has to wait for that connection too, rather than let the `?` on the -/// failing call drop the set and run an audit recorder destructor after the queue was drained. +/// An accept loop that fails after accepting a connection has to wait for that connection too, +/// rather than let the `?` on the failing call drop the set and abandon a served request mid-flight. async fn drain_after_accept_loop( connections: &mut tokio::task::JoinSet<()>, grace: std::time::Duration, @@ -169,11 +174,11 @@ async fn drain_after_accept_loop( /// Wait for the connection tasks to finish, then abort the ones that outlive the grace. /// -/// Serving a connection can record policy audit events, so the caller drains the audit queue as -/// soon as this returns. Waiting is bounded on both sides: a task inside synchronous work, such as -/// authenticating a client or reading the policy storage, never reaches a cancellation point, so -/// the settle after the abort cannot be left unbounded either. Draining the queue after -/// interrupting one such connection is worth far more than losing every event of it, and the agent +/// Waiting is bounded on both sides: a task inside synchronous work, such as authenticating a +/// client or writing the policy storage, never reaches a cancellation point, so the settle after +/// the abort cannot be left unbounded either. A connection given up on here holds an audit lease, +/// so the recorder flushes its queued events and keeps accepting, rather than closing the queue +/// under the terminal event of the policy write that connection is still finishing. The agent /// gives the whole shutdown a fixed budget before it stops the runtime. async fn wait_for_connections(connections: &mut tokio::task::JoinSet<()>, grace: std::time::Duration) { let drained = tokio::time::timeout(grace, async { while connections.join_next().await.is_some() {} }).await; @@ -185,7 +190,9 @@ async fn wait_for_connections(connections: &mut tokio::task::JoinSet<()>, grace: let settled = tokio::time::timeout(grace, async { while connections.join_next().await.is_some() {} }).await; if settled.is_err() { - error!("Named pipe connections are still running blocking work; their policy audit events may be lost"); + error!( + "Named pipe connections are still running blocking work; their policy audit events may not reach the Windows Event Log before the agent stops the process" + ); } } } diff --git a/crates/now-package-broker/src/task.rs b/crates/now-package-broker/src/task.rs index 88fb3018c..dec73e04a 100644 --- a/crates/now-package-broker/src/task.rs +++ b/crates/now-package-broker/src/task.rs @@ -102,7 +102,9 @@ impl Task for BrokerTask { info!("package broker received shutdown signal"); shutdown.cancel(); - // Wait for the writers of policy audit events to stop, so the drain below is complete. + // Wait for the writers of policy audit events to stop, so the drain below is complete. The + // pipe server reports a connection it gave up on by keeping an audit lease alive, so the + // queue is flushed rather than closed and its terminal event is not rejected. let result = match server_handle.await { Ok(Ok(())) => Ok(()), Ok(Err(error)) => Err(error).context("broker pipe server error"), From 3ec088604a183ed7bd761f4a35b91f83c1531c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 07:09:09 +0900 Subject: [PATCH 39/41] fix(agent): bound the audit worker shutdown Closing the Event Log queue ended the worker's iteration, but the normal shutdown then joined it without a bound, so a sink that stopped making progress held the shutdown until the agent stopped the process, despite the documented five-second limit. The worker is now joined only while it finishes within the same grace the flush uses, and is detached afterwards: it holds nothing the process needs to release, and a panic is still reported. Both waits share one polling helper, so the flush and the worker exit cannot drift apart. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 102 ++++++++++++++---- .../src/policy_store/receipt.rs | 2 +- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 21ffad3fe..9bf89dd48 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -39,17 +39,18 @@ const EVENT_LOG_ADMISSION_BUDGET: usize = 256; #[cfg(any(test, not(debug_assertions)))] const EVENT_LOG_QUEUE_CAPACITY: usize = EVENT_LOG_ADMISSION_BUDGET + EVENT_LOG_OUTCOME_RESERVE; -/// How long the shutdown waits for the accepted entries to reach the Event Log sink. +/// How long the shutdown waits for the accepted entries to reach the Event Log sink, and for the +/// worker to stop after the queue is closed. /// /// The worker is a plain thread, so a sink that stopped making progress would otherwise hold the /// shutdown until the agent stops the process. Entries still queued when this expires stay in the /// queue, and the worker keeps emitting them for as long as the process lives. #[cfg(any(test, not(debug_assertions)))] -const EVENT_LOG_FLUSH_GRACE: std::time::Duration = std::time::Duration::from_secs(5); +const EVENT_LOG_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); -/// How often flushing re-checks the queue, which the worker empties without signalling completion. +/// How often a bounded shutdown wait re-checks what it is waiting for. #[cfg(any(test, not(debug_assertions)))] -const EVENT_LOG_FLUSH_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); +const EVENT_LOG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); static RECORDER: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -330,11 +331,27 @@ impl EventLogQueue { } fn drain(&mut self) { + self.drain_with_grace(EVENT_LOG_SHUTDOWN_GRACE); + } + + /// Closes the queue and waits, at most for `grace`, for the worker to emit what is left. + /// + /// Dropping the sender ends the worker's iteration as soon as the queue is empty, so waiting for + /// it is normally bounded by the sink. A sink that stopped making progress would hold the + /// shutdown until the agent stops the process instead, so the worker is detached once the grace + /// expires: it owns nothing the process needs to release. + fn drain_with_grace(&mut self, grace: std::time::Duration) { // Dropping the sender ends the worker's iteration as soon as the queue is empty. self.sender = None; let Some(worker) = self.worker.take() else { return; }; + if !wait_until(|| worker.is_finished(), grace) { + tracing::warn!( + "The Windows Event Log policy audit worker is still emitting after the queue was closed; the agent is stopping without waiting for it" + ); + return; + } if worker.join().is_err() { tracing::warn!("The Windows Event Log policy audit worker panicked"); } @@ -377,7 +394,7 @@ impl AuditRecorder for SystemRecorder { // The pending count is read outside the lock, so a connection that is finishing its policy // write can still record its terminal event while the worker catches up. let pending = Arc::clone(&self.queue.lock().pending); - wait_for_emission(&pending, EVENT_LOG_FLUSH_GRACE); + wait_for_emission(&pending, EVENT_LOG_SHUTDOWN_GRACE); } } @@ -432,27 +449,34 @@ fn event_log_worker( } } +/// Waits, at most for `grace`, until `settled` reports completion, and returns whether it did. +/// +/// The queue worker is a plain thread without a completion signal, so its progress is polled. A +/// sink that stopped making progress would otherwise hold the shutdown indefinitely. +#[cfg(any(test, not(debug_assertions)))] +fn wait_until(settled: impl Fn() -> bool, grace: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + grace; + while !settled() { + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(EVENT_LOG_POLL_INTERVAL); + } + true +} + /// Waits, at most for `grace`, until every accepted entry has been emitted. /// -/// The worker empties the queue without signalling completion, so this polls the count it +/// The worker empties the queue without signalling completion, so this checks the count it /// decrements. A sink that stopped making progress would otherwise hold the shutdown /// indefinitely, so giving up is logged rather than waited out. #[cfg(any(test, not(debug_assertions)))] fn wait_for_emission(pending: &AtomicUsize, grace: std::time::Duration) { - let deadline = std::time::Instant::now() + grace; - loop { - let queued = pending.load(Ordering::Acquire); - if queued == 0 { - return; - } - if std::time::Instant::now() >= deadline { - tracing::warn!( - queued, - "Windows Event Log policy audit entries are still pending; the agent is stopping without waiting for them" - ); - return; - } - std::thread::sleep(EVENT_LOG_FLUSH_POLL_INTERVAL); + if !wait_until(|| pending.load(Ordering::Acquire) == 0, grace) { + tracing::warn!( + queued = pending.load(Ordering::Acquire), + "Windows Event Log policy audit entries are still pending; the agent is stopping without waiting for them" + ); } } @@ -1075,7 +1099,7 @@ pub(crate) mod tests { EntryClass::Admission, ) .expect("the attempt is accepted"); - wait_for_emission(&queue.pending, EVENT_LOG_FLUSH_GRACE); + wait_for_emission(&queue.pending, EVENT_LOG_SHUTDOWN_GRACE); assert_eq!( queue.pending.load(Ordering::Acquire), 0, @@ -1119,4 +1143,40 @@ pub(crate) mod tests { wait_for_emission(&pending, std::time::Duration::from_secs(5)); assert!(flushed.elapsed() < std::time::Duration::from_secs(1)); } + + #[test] + fn closing_the_queue_gives_up_on_a_sink_that_stopped_making_progress() { + let open = Arc::new(AtomicBool::new(false)); + let release = Arc::clone(&open); + let entered = Arc::new(AtomicBool::new(false)); + let sink_entered = Arc::clone(&entered); + let mut queue = EventLogQueue::start(move |_| { + sink_entered.store(true, Ordering::Release); + while !release.load(Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }) + .expect("the audit worker starts"); + + queue + .record( + Entry::new("Policy management write attempted").event_code(policy_events::POLICY_WRITE_ATTEMPTED), + EntryClass::Admission, + ) + .expect("the attempt is accepted"); + while !entered.load(Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + let closing = std::time::Instant::now(); + queue.drain_with_grace(std::time::Duration::from_millis(50)); + assert!( + closing.elapsed() < std::time::Duration::from_secs(2), + "closing the queue does not wait for a sink that stopped making progress" + ); + + // The detached worker holds nothing the process needs, so releasing the sink lets it emit the + // entry it took and then exit. + open.store(true, Ordering::Release); + } } diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index bddd993f7..7f402aa75 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -412,7 +412,7 @@ mod tests { assert_ne!(management.store_token, old_token); assert!(store.active_policy().is_none()); } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn readiness_reloads_each_disk_state_after_provisional_load() { for (disk_policy, invalid, expected, expected_event) in [ ( From affb04b3711d6b0c440400bd9bd84f3c2bde88cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 11:42:31 +0900 Subject: [PATCH 40/41] docs(agent): label the user session block by its full hundred The Updater and PEDM families were each given their own hundred, but the user session header still described a ten-wide range. Name it 6000-6099 in both the event table and the Agent message catalog, so the three families read the same way. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-sysevent-codes/src/lib.rs | 2 +- devolutions-agent/devolutions-agent.mc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs index 979f76c62..83fc18d65 100644 --- a/crates/agent-sysevent-codes/src/lib.rs +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -58,7 +58,7 @@ pub fn boot_stacktrace_written(path: &Path) -> Entry { .field("path", path.display()) } -// 6000-6009 **User Sessions** +// 6000-6099 **User Sessions** /// `DevolutionsSession.exe` started in session; include session id & kind (console/remote). pub const USER_SESSION_PROCESS_STARTED: u32 = 6000; diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index 9fca1c03d..0edd300bd 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -85,7 +85,7 @@ Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . -; 6000-6009 User Sessions +; 6000-6099 User Sessions MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED From dbc4ae2a909f8bbd33c32c5f96e453242626aaba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 30 Sep 2026 12:01:16 +0900 Subject: [PATCH 41/41] build(deps): relax the now-policy-server-template requirement Keep the two now-policy crates consistent: both require the 0.7 series rather than a single patch release. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index e0538c88e..337463431 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -35,7 +35,7 @@ http-body-util = "0.1" mime = "0.3" now-policy = "=0.5.0" now-policy-api = "0.7" -now-policy-server-template = "=0.7.0" +now-policy-server-template = "0.7" parking_lot = "0.12" regex = "1" semver = "1"