From 31e20e59997a7d501aacdb0be6bd4728c5af2438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:52:25 -0400 Subject: [PATCH 01/13] feat(agent): add policy audit events Record bounded write attempts and operation-specific outcomes without exposing policy content or blocking request admission on Event Log I/O. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 + crates/now-package-broker/Cargo.toml | 3 + crates/now-package-broker/src/audit.rs | 545 ++++++++++++++++++++ crates/now-package-broker/src/auth.rs | 4 + crates/now-package-broker/src/lib.rs | 2 + crates/now-package-broker/src/server/mod.rs | 22 +- crates/sysevent-codes/src/lib.rs | 340 ++++++++++++ 7 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 crates/now-package-broker/src/audit.rs diff --git a/Cargo.lock b/Cargo.lock index 8e668cbb0..af374ee74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4826,6 +4826,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sysevent", + "sysevent-codes", + "sysevent-winevent", "tempfile", "tokio 1.52.3", "tokio-util", diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 662cbc510..344366322 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -42,6 +42,9 @@ semver = "1" serde = "1" serde_json = "1" sha2 = "0.10" +sysevent = { path = "../sysevent" } +sysevent-codes = { path = "../sysevent-codes" } +sysevent-winevent = { path = "../sysevent-winevent" } tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] } tokio-util = "0.7" tower-service = "0.3" diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs new file mode 100644 index 000000000..309c58e2f --- /dev/null +++ b/crates/now-package-broker/src/audit.rs @@ -0,0 +1,545 @@ +//! Structured audit events for policy management writes and external policy changes. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +#[cfg(all(not(test), not(debug_assertions)))] +use std::sync::atomic::AtomicU64; +use std::sync::atomic::{AtomicBool, Ordering}; + +use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +#[cfg(not(test))] +use sysevent::Severity; +#[cfg(all(not(test), not(debug_assertions)))] +use sysevent::SystemEventSink; +use win_api_wrappers::identity::sid::Sid; + +const INTENT: &str = "PUT /v1/policy"; +const MAX_SID_BYTES: usize = 256; +const MAX_PATH_BYTES: usize = 1024; +const MAX_POLICY_ID_BYTES: usize = 256; +#[cfg(all(not(test), not(debug_assertions)))] +const EVENT_LOG_QUEUE_CAPACITY: usize = 256; + +static RECORDER: std::sync::LazyLock> = std::sync::LazyLock::new(default_recorder); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DenialReason { + AuthenticationFailed, + AdministratorRequired, + RequestRejected, +} + +impl DenialReason { + const fn as_str(self) -> &'static str { + match self { + Self::AuthenticationFailed => "authentication_failed", + Self::AdministratorRequired => "administrator_required", + Self::RequestRejected => "request_rejected", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailureReason { + MonitoringUnavailable, + StaleStoreToken, + PathNotWritable, + InvalidPolicy, + InvalidReceipt, + WarningsNotAcknowledged, + RevisionConflict, + DraftCommitFailed, + SerializationFailed, + PersistenceFailed, + ConditionalPublicationFailed, + ActivationFailed, +} + +impl FailureReason { + const fn as_str(self) -> &'static str { + match self { + Self::MonitoringUnavailable => "monitoring_unavailable", + Self::StaleStoreToken => "stale_store_token", + Self::PathNotWritable => "path_not_writable", + Self::InvalidPolicy => "invalid_policy", + Self::InvalidReceipt => "invalid_receipt", + Self::WarningsNotAcknowledged => "warnings_not_acknowledged", + Self::RevisionConflict => "revision_conflict", + Self::DraftCommitFailed => "draft_commit_failed", + Self::SerializationFailed => "serialization_failed", + Self::PersistenceFailed => "persistence_failed", + Self::ConditionalPublicationFailed => "conditional_publication_failed", + Self::ActivationFailed => "activation_failed", + } + } +} + +trait AuditRecorder: Send + Sync { + fn record(&self, entry: sysevent::Entry); +} + +fn default_recorder() -> Arc { + #[cfg(test)] + { + Arc::new(TestRecorder) + } + #[cfg(all(not(test), debug_assertions))] + { + Arc::new(TracingRecorder) + } + #[cfg(all(not(test), not(debug_assertions)))] + { + match SystemRecorder::new() { + Ok(recorder) => Arc::new(recorder), + Err(error) => { + tracing::error!(%error, "Failed to start the Windows Event Log policy audit worker"); + Arc::new(TracingRecorder) + } + } + } +} + +#[cfg(test)] +std::thread_local! { + static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +struct TestRecorder; + +#[cfg(test)] +impl AuditRecorder for TestRecorder { + fn record(&self, entry: sysevent::Entry) { + TEST_EVENTS.with(|events| events.borrow_mut().push(entry)); + } +} + +#[cfg(test)] +pub(crate) fn take_test_events() -> Vec { + TEST_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) +} + +#[cfg(not(test))] +struct TracingRecorder; + +#[cfg(not(test))] +impl AuditRecorder for TracingRecorder { + fn record(&self, entry: sysevent::Entry) { + trace_entry(&entry); + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +struct SystemRecorder { + sender: std::sync::mpsc::SyncSender, + dropped: AtomicU64, +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl SystemRecorder { + fn new() -> std::io::Result { + let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); + std::thread::Builder::new() + .name("policy-audit-event-log".to_owned()) + .spawn(move || event_log_worker(&receiver)) + .map(|_| Self { + sender, + dropped: AtomicU64::new(0), + }) + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl AuditRecorder for SystemRecorder { + fn record(&self, entry: sysevent::Entry) { + trace_entry(&entry); + if let Err(error) = self.sender.try_send(entry) { + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + if dropped.is_power_of_two() { + tracing::warn!( + dropped, + error = %match error { + std::sync::mpsc::TrySendError::Full(_) => "queue_full", + std::sync::mpsc::TrySendError::Disconnected(_) => "worker_disconnected", + }, + "Dropped policy audit Windows Event Log entries" + ); + } + } + } +} + +#[cfg(not(test))] +fn trace_entry(entry: &sysevent::Entry) { + let code = entry.event_code; + let message = &entry.message; + let fields = &entry.fields; + match entry.severity { + Severity::Critical | Severity::Error => tracing::error!(?code, %message, ?fields, "Policy audit event"), + Severity::Warning => tracing::warn!(?code, %message, ?fields, "Policy audit event"), + Severity::Notice | Severity::Info | Severity::Debug => { + tracing::info!(?code, %message, ?fields, "Policy audit event"); + } + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { + let sink: Arc = match sysevent_winevent::WinEvent::new("Devolutions Agent") { + Ok(event_log) => Arc::new(event_log), + Err(error) => { + tracing::error!(%error, "Failed to initialize the Windows Event Log policy audit sink"); + Arc::new(sysevent::NoopSink) + } + }; + for entry in receiver { + if let Err(error) = sink.emit(entry) { + tracing::warn!(%error, "Failed to emit policy audit event to the Windows Event Log"); + } + } +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct RecordingAudit(parking_lot::Mutex>); + +#[cfg(test)] +impl RecordingAudit { + pub(crate) fn events(&self) -> Vec { + self.0.lock().clone() + } +} + +#[cfg(test)] +impl AuditRecorder for RecordingAudit { + fn record(&self, entry: sysevent::Entry) { + self.0.lock().push(entry); + } +} + +struct WriteAuditState { + actor_sid: String, + actor_exe: String, + path: PathBuf, + terminal_recorded: AtomicBool, + recorder: Arc, +} + +impl Drop for WriteAuditState { + fn drop(&mut self) { + if !self.terminal_recorded.swap(true, Ordering::AcqRel) { + self.record(sysevent_codes::policy_write_denied( + &self.actor_sid, + &self.actor_exe, + INTENT, + &self.path, + DenialReason::RequestRejected.as_str(), + )); + } + } +} + +#[derive(Clone)] +pub(crate) struct WriteAudit(Arc); + +impl WriteAudit { + pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> Self { + Self::begin_with_recorder(actor_sid, actor_exe, path, Arc::clone(&RECORDER)) + } + + fn begin_with_recorder(actor_sid: &Sid, actor_exe: &Path, path: &Path, recorder: Arc) -> Self { + let state = Arc::new(WriteAuditState { + actor_sid: bounded(actor_sid.to_string(), MAX_SID_BYTES), + actor_exe: bounded(actor_exe.display().to_string(), MAX_PATH_BYTES), + path: bounded_path(path), + terminal_recorded: AtomicBool::new(false), + recorder, + }); + state.record(sysevent_codes::policy_write_attempted( + &state.actor_sid, + &state.actor_exe, + INTENT, + &state.path, + )); + Self(state) + } + + #[cfg(test)] + pub(crate) fn begin_recording(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (Self, Arc) { + let recorder = Arc::new(RecordingAudit::default()); + let recorder_sink = Arc::::clone(&recorder); + let audit = Self::begin_with_recorder(actor_sid, actor_exe, path, recorder_sink); + (audit, recorder) + } + + pub(crate) fn denied(&self, reason: DenialReason) { + self.finish(|state| { + sysevent_codes::policy_write_denied( + &state.actor_sid, + &state.actor_exe, + INTENT, + &state.path, + reason.as_str(), + ) + }); + } + + pub(crate) fn failed(&self, operation: PolicyReplacementOperation, reason: FailureReason) { + self.failed_at(operation, &self.0.path.clone(), reason); + } + + pub(crate) fn failed_at(&self, operation: PolicyReplacementOperation, path: &Path, reason: FailureReason) { + let path = bounded_path(path); + let operation_name = operation_name(operation); + let outcome = if reason == FailureReason::StaleStoreToken { + "stale_conflict" + } else { + "failed" + }; + self.finish(|state| { + if operation == PolicyReplacementOperation::Create { + sysevent_codes::policy_create_failed( + &state.actor_sid, + &state.actor_exe, + INTENT, + path, + operation_name, + outcome, + reason.as_str(), + ) + } else { + sysevent_codes::policy_change_failed( + &state.actor_sid, + &state.actor_exe, + INTENT, + path, + operation_name, + outcome, + reason.as_str(), + ) + } + }); + } + + #[expect( + clippy::too_many_arguments, + reason = "the terminal event records operation and both policy identities" + )] + pub(crate) fn succeeded_at( + &self, + path: &Path, + old_id: Option<&str>, + old_revision: Option, + new_id: &str, + new_revision: u32, + operation: PolicyReplacementOperation, + confirmed_overwrite: bool, + ) { + let path = bounded_path(path); + let old_id = bounded(old_id.unwrap_or("").to_owned(), MAX_POLICY_ID_BYTES); + let old_revision = old_revision.map_or_else(|| "none".to_owned(), |revision| revision.to_string()); + let new_id = bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES); + let operation_name = operation_name(operation); + let outcome = if confirmed_overwrite { + "confirmed_overwrite" + } else { + "applied" + }; + self.finish(|state| { + if operation == PolicyReplacementOperation::Create { + sysevent_codes::policy_create_succeeded( + &state.actor_sid, + &state.actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + INTENT, + operation_name, + outcome, + ) + } else { + sysevent_codes::policy_change_succeeded( + &state.actor_sid, + &state.actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + INTENT, + operation_name, + outcome, + ) + } + }); + } + + fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> sysevent::Entry) { + if self + .0 + .terminal_recorded + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.0.record(entry(&self.0)); + } + } +} + +impl WriteAuditState { + fn record(&self, entry: sysevent::Entry) { + self.recorder.record(entry); + } +} + +pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { + RECORDER.record(sysevent_codes::policy_external_change_applied( + bounded_path(path), + bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), + new_revision, + )); +} + +pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { + let reason = match state { + PolicyManagementState::Active => "active", + PolicyManagementState::Missing => "missing", + PolicyManagementState::Invalid => "invalid", + }; + RECORDER.record(sysevent_codes::policy_external_change_rejected( + bounded_path(path), + reason, + )); +} + +fn bounded(mut value: String, max_bytes: usize) -> String { + value = value + .chars() + .map(|character| if character.is_control() { ' ' } else { character }) + .collect(); + if value.len() <= max_bytes { + return value; + } + const SUFFIX: &str = "..."; + let mut end = max_bytes - SUFFIX.len(); + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); + value.push_str(SUFFIX); + value +} + +fn bounded_path(path: &Path) -> PathBuf { + PathBuf::from(bounded(path.display().to_string(), MAX_PATH_BYTES)) +} + +const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { + match operation { + PolicyReplacementOperation::Create => "create", + PolicyReplacementOperation::Update => "update", + PolicyReplacementOperation::Repair => "repair", + PolicyReplacementOperation::ReplaceIdentity => "replace_identity", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_audit() -> (WriteAudit, Arc) { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + } + + #[test] + fn attempt_precedes_denial_and_only_one_terminal_event_is_recorded() { + let (audit, recorder) = test_audit(); + audit.denied(DenialReason::AuthenticationFailed); + audit.failed(PolicyReplacementOperation::Update, FailureReason::InvalidPolicy); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_WRITE_DENIED) + ] + ); + } + + #[test] + fn audit_values_are_bounded_and_fields_are_allowlisted() { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + let long = "é".repeat(MAX_PATH_BYTES); + let (audit, recorder) = WriteAudit::begin_recording(&sid, Path::new(&long), Path::new(&long)); + audit.succeeded_at( + Path::new(&long), + Some(&long), + Some(1), + &long, + 2, + PolicyReplacementOperation::Update, + false, + ); + + let events = recorder.events(); + let entry = &events[1]; + assert!(entry.fields.iter().all(|(name, value)| { + matches!( + name.as_str(), + "actor_sid" + | "actor_exe" + | "intent" + | "path" + | "old_id" + | "old_revision" + | "new_id" + | "new_revision" + | "operation" + | "outcome" + ) && value.len() <= MAX_PATH_BYTES + })); + for forbidden in ["body", "draft", "policy", "receipt", "store_token"] { + assert!(!entry.fields.iter().any(|(name, _)| name == forbidden)); + } + } + + #[test] + fn terminal_event_codes_follow_the_replacement_operation() { + for (operation, failure_code, success_code) in [ + ( + PolicyReplacementOperation::Create, + sysevent_codes::POLICY_CREATE_FAILED, + sysevent_codes::POLICY_CREATE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::Update, + sysevent_codes::POLICY_CHANGE_FAILED, + sysevent_codes::POLICY_CHANGE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::Repair, + sysevent_codes::POLICY_CHANGE_FAILED, + sysevent_codes::POLICY_CHANGE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::ReplaceIdentity, + sysevent_codes::POLICY_CHANGE_FAILED, + sysevent_codes::POLICY_CHANGE_SUCCEEDED, + ), + ] { + let (failed, failed_recorder) = test_audit(); + failed.failed(operation, FailureReason::StaleStoreToken); + assert_eq!(failed_recorder.events()[1].event_code, Some(failure_code)); + + let (succeeded, succeeded_recorder) = test_audit(); + succeeded.succeeded_at(Path::new(r"C:\policy.json"), None, None, "new", 1, operation, true); + assert_eq!(succeeded_recorder.events()[1].event_code, Some(success_code)); + } + } +} diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index d891f6351..d647e750c 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -222,6 +222,10 @@ impl PipeClient { &self.user_sid } + pub(crate) fn executable_path(&self) -> &Path { + &self.executable_path + } + pub(crate) fn is_elevated_administrator(&self) -> bool { self.is_elevated && self.is_administrator } diff --git a/crates/now-package-broker/src/lib.rs b/crates/now-package-broker/src/lib.rs index e1542dda4..f8acf7e70 100644 --- a/crates/now-package-broker/src/lib.rs +++ b/crates/now-package-broker/src/lib.rs @@ -5,6 +5,8 @@ //! //! The broker is only functional on Windows; on other platforms this crate is empty. +#[cfg(windows)] +mod audit; #[cfg(windows)] mod auth; #[cfg(windows)] diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 61664d16c..77755dfd5 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -48,6 +49,7 @@ use responses::{ // The unit value marks the scope in which an authenticated policy management request is dispatched. tokio::task_local! { static POLICY_MANAGEMENT_AUTHENTICATED: (); + static POLICY_WRITE_AUDIT: crate::audit::WriteAudit; } /// How long a per-user manager availability probe stays fresh before it is re-run. @@ -293,6 +295,10 @@ async fn authenticate_policy_management( request: Request, next: Next, ) -> Response { + let write_audit = matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")).then(|| { + let configured_path = PathBuf::from(state.policy_store.management_snapshot().configured_path); + crate::audit::WriteAudit::begin(client.user_sid(), client.executable_path(), &configured_path) + }); let protected = matches!( (request.method(), request.uri().path()), (&Method::GET, "/v1/policy/management") @@ -302,6 +308,9 @@ async fn authenticate_policy_management( ); if protected { if let Err(error) = client.validate_connection(state.skip_signature_validation) { + if let Some(audit) = write_audit { + audit.denied(crate::audit::DenialReason::AuthenticationFailed); + } warn!(error = format!("{error:#}"), "Rejected policy management request"); return ( StatusCode::UNAUTHORIZED, @@ -312,7 +321,12 @@ async fn authenticate_policy_management( ) .into_response(); } - return POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)).await; + let authenticated = POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)); + return if let Some(audit) = write_audit { + POLICY_WRITE_AUDIT.scope(audit, authenticated).await + } else { + authenticated.await + }; } next.run(request).await } @@ -381,7 +395,11 @@ impl PackageBrokerServer for BrokerConnection { request: PolicyReplacementRequest, ) -> Result { require_policy_management_authentication()?; + let audit = POLICY_WRITE_AUDIT + .try_with(Clone::clone) + .map_err(|_| error_response(ErrorCode::InternalError, "policy write audit context is unavailable"))?; if !self.client.is_elevated_administrator() { + audit.denied(crate::audit::DenialReason::AdministratorRequired); return Err(error_response( ErrorCode::AdministratorRequired, "policy replacement requires an elevated Administrator", @@ -389,7 +407,7 @@ impl PackageBrokerServer for BrokerConnection { } self.state .policy_store - .replace(request) + .replace_audited(request, audit) .await .map(|success| PolicyReplacementResponse { response_kind: now_policy_api::PolicyReplacementResponseKind, diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index e2eaad987..1b93a4c80 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -380,6 +380,242 @@ pub fn recording_storage_low(remaining_bytes: u64, threshold_bytes: u64) -> Entr .field("threshold_bytes", threshold_bytes) } +// 8000-8099 **Package Broker / Policy Management** + +/// A policy write was received before any authorization check. +pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; +/// A policy write was denied by caller authorization. +pub const POLICY_WRITE_DENIED: u32 = 8001; +/// A Create operation failed. +pub const POLICY_CREATE_FAILED: u32 = 8002; +/// A Create operation succeeded. +pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; +/// An Update, Repair, or ReplaceIdentity operation failed. +pub const POLICY_CHANGE_FAILED: u32 = 8004; +/// An Update, Repair, or ReplaceIdentity operation succeeded. +pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; +/// An external policy change became active. +pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; +/// An external policy change left the policy unavailable. +pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; + +pub fn policy_write_attempted( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, +) -> Entry { + Entry::new("Policy management write attempted") + .event_code(POLICY_WRITE_ATTEMPTED) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) +} + +pub fn policy_write_denied( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + reason: impl ToString, +) -> Entry { + Entry::new("Policy management write denied") + .event_code(POLICY_WRITE_DENIED) + .severity(Severity::Warning) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("reason", reason) +} + +pub fn policy_create_failed( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + policy_write_failed( + POLICY_CREATE_FAILED, + "Policy creation failed", + actor_sid, + actor_exe, + intent, + path, + operation, + outcome, + reason, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_create_succeeded( + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + policy_write_succeeded( + POLICY_CREATE_SUCCEEDED, + "Policy creation succeeded", + actor_sid, + actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + intent, + operation, + outcome, + ) +} + +pub fn policy_change_failed( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + policy_write_failed( + POLICY_CHANGE_FAILED, + "Policy change failed", + actor_sid, + actor_exe, + intent, + path, + operation, + outcome, + reason, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_change_succeeded( + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + policy_write_succeeded( + POLICY_CHANGE_SUCCEEDED, + "Policy change succeeded", + actor_sid, + actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + intent, + operation, + outcome, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the four outcome events field-compatible" +)] +fn policy_write_failed( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Error) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("operation", operation) + .field("outcome", outcome) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the four outcome events field-compatible" +)] +fn policy_write_succeeded( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("path", path.as_ref().display()) + .field("old_id", old_id) + .field("old_revision", old_revision) + .field("new_id", new_id) + .field("new_revision", new_revision) + .field("intent", intent) + .field("operation", operation) + .field("outcome", outcome) +} + +pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { + Entry::new("External policy change applied") + .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) + .severity(Severity::Notice) + .field("path", path.as_ref().display()) + .field("new_id", new_id) + .field("new_revision", new_revision) +} + +pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { + Entry::new("External policy change rejected") + .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) + .severity(Severity::Warning) + .field("path", path.as_ref().display()) + .field("reason", reason) +} + // 9000-9099 **Diagnostics** pub const DEBUG_OPTIONS_ENABLED: u32 = 9001; @@ -399,3 +635,107 @@ pub fn xmf_not_found(path: impl AsRef, error: impl std::fmt::Display) -> E .field("path", path.as_ref().display()) .field("error_chain", format!("{error:#}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_audit_entries_preserve_catalog_field_order() { + const WRITE: &[&str] = &["actor_sid", "actor_exe", "intent", "path"]; + const DENIED: &[&str] = &["actor_sid", "actor_exe", "intent", "path", "reason"]; + const FAILED: &[&str] = &[ + "actor_sid", + "actor_exe", + "intent", + "path", + "operation", + "outcome", + "reason", + ]; + const SUCCEEDED: &[&str] = &[ + "actor_sid", + "actor_exe", + "path", + "old_id", + "old_revision", + "new_id", + "new_revision", + "intent", + "operation", + "outcome", + ]; + let entries = [ + ( + policy_write_attempted("sid", "exe", "intent", "path"), + POLICY_WRITE_ATTEMPTED, + Severity::Info, + WRITE, + ), + ( + policy_write_denied("sid", "exe", "intent", "path", "reason"), + POLICY_WRITE_DENIED, + Severity::Warning, + DENIED, + ), + ( + policy_create_failed("sid", "exe", "intent", "path", "create", "failed", "reason"), + POLICY_CREATE_FAILED, + Severity::Error, + FAILED, + ), + ( + policy_create_succeeded( + "sid", "exe", "path", "old", "1", "new", 2, "intent", "create", "applied", + ), + POLICY_CREATE_SUCCEEDED, + Severity::Info, + SUCCEEDED, + ), + ( + policy_change_failed("sid", "exe", "intent", "path", "update", "stale_conflict", "reason"), + POLICY_CHANGE_FAILED, + Severity::Error, + FAILED, + ), + ( + policy_change_succeeded( + "sid", + "exe", + "path", + "old", + "1", + "new", + 2, + "intent", + "update", + "confirmed_overwrite", + ), + POLICY_CHANGE_SUCCEEDED, + Severity::Info, + SUCCEEDED, + ), + ( + policy_external_change_applied("path", "new", 2), + POLICY_EXTERNAL_CHANGE_APPLIED, + Severity::Notice, + &["path", "new_id", "new_revision"], + ), + ( + policy_external_change_rejected("path", "invalid"), + POLICY_EXTERNAL_CHANGE_REJECTED, + Severity::Warning, + &["path", "reason"], + ), + ]; + + for (entry, code, severity, expected_fields) in entries { + assert_eq!(entry.event_code, Some(code)); + assert_eq!(entry.severity, severity); + assert_eq!( + entry.fields.iter().map(|(name, _)| name.as_str()).collect::>(), + expected_fields + ); + } + } +} From 27fe9ff179251d163470fa249413a10f33bef446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:52:36 -0400 Subject: [PATCH 02/13] build(dgw,agent): embed policy event catalogs Compile localized message resources for release and production builds using trusted installed Windows SDK tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 63 ++- devolutions-agent/build.rs | 87 ++++ devolutions-agent/devolutions-agent.mc | 468 +++++++++++++++++++++ devolutions-gateway/build.rs | 68 ++- devolutions-gateway/devolutions-gateway.mc | 85 ++++ 5 files changed, 744 insertions(+), 27 deletions(-) create mode 100644 devolutions-agent/devolutions-agent.mc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67dd3762..b832cc5d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,8 +354,6 @@ jobs: $VSINSTALLDIR = $(vswhere.exe -latest -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath) Write-Output "LIBCLANG_PATH=$VSINSTALLDIR\VC\Tools\Llvm\x64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell - Install-Module VsDevShell -Force shell: pwsh - name: Configure Windows (arm) runner @@ -696,9 +694,6 @@ jobs: # NASM is required by aws-lc-rs (used as rustls crypto backend) choco install nasm - # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell - Install-Module VsDevShell -Force - # We need to add the NASM binary folder to the PATH manually. Write-Output "$Env:ProgramFiles\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append shell: pwsh @@ -707,9 +702,31 @@ jobs: id: find_mc if: ${{ matrix.os == 'windows' }} run: | - Enter-VsDevShell - $path = (Get-Command -Type Application mc).Source | Split-Path -Parent + $sdkRoots = @( + $Env:WindowsSdkDir + (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue) + "${Env:ProgramFiles(x86)}\Windows Kits\10" + ) | Where-Object { $_ } | Select-Object -Unique + $candidates = @() + if ($Env:WindowsSdkVerBinPath) { + $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe" + $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe" + } + foreach ($root in $sdkRoots) { + $bin = Join-Path $root "bin" + $candidates += Join-Path $bin "x64\mc.exe" + $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue | + Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\mc.exe" } + } + $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-Not $mc) { + throw "mc.exe was not found in the installed Windows SDK" + } + $path = Split-Path -Parent $mc Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 shell: pwsh - name: Build @@ -975,6 +992,37 @@ jobs: if: ${{ matrix.os == 'windows' }} uses: microsoft/setup-msbuild@v3 + - name: Find mc.exe + id: find_mc + if: ${{ matrix.os == 'windows' }} + run: | + $sdkRoots = @( + $Env:WindowsSdkDir + (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue) + "${Env:ProgramFiles(x86)}\Windows Kits\10" + ) | Where-Object { $_ } | Select-Object -Unique + $candidates = @() + if ($Env:WindowsSdkVerBinPath) { + $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe" + $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe" + } + foreach ($root in $sdkRoots) { + $bin = Join-Path $root "bin" + $candidates += Join-Path $bin "x64\mc.exe" + $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue | + Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\mc.exe" } + } + $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-Not $mc) { + throw "mc.exe was not found in the installed Windows SDK" + } + $path = Split-Path -Parent $mc + Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + shell: pwsh + - name: Build run: | if ($Env:RUNNER_OS -eq "Windows") { @@ -985,6 +1033,7 @@ jobs: $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" + $Env:WindowsSdkVerBinPath = '${{ steps.find_mc.outputs.windows_sdk_ver_bin_path }}' } if ($Env:RUNNER_OS -eq "Linux") { diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index b8d9ad669..5cf58aaa4 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -3,6 +3,9 @@ fn main() { #[cfg(target_os = "windows")] win::embed_version_rc(); + + #[cfg(target_os = "windows")] + win::embed_devolutions_agent_mc(); } fn generate_psu_agent_proto() { @@ -100,4 +103,88 @@ END"#, version_rc } + + pub(super) fn embed_devolutions_agent_mc() { + use std::path::PathBuf; + use std::process::Command; + + let profile = env::var("PROFILE").unwrap_or_default(); + if !matches!(profile.as_str(), "release" | "production") { + return; + } + + let mc_exe = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe is required to embed the Devolutions Agent Event Log catalog; \ + use a Visual Studio developer shell or set WindowsSdkVerBinPath or WindowsSdkDir" + ) + }); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let catalog = manifest_dir.join("devolutions-agent.mc"); + println!("cargo:rerun-if-changed={}", catalog.display()); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let status = Command::new(mc_exe) + .current_dir(&out_dir) + .args(["-um", "-h", ".", "-r", "."]) + .arg(catalog.canonicalize().expect("canonicalize Agent message catalog")) + .status() + .expect("run mc.exe"); + assert!(status.success(), "mc.exe failed with status {status}"); + + let resource = out_dir.join("devolutions-agent.rc"); + assert!(resource.is_file(), "mc.exe did not generate {}", resource.display()); + embed_resource::compile(resource, embed_resource::NONE) + .manifest_required() + .expect("BUG: failed to embed devolutions-agent.rc"); + } + + fn find_mc() -> Option { + if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { + let sdk_bin = std::path::Path::new(&sdk_bin); + for candidate in [sdk_bin.join("mc.exe"), sdk_bin.join("x64").join("mc.exe")] { + if candidate.is_file() { + return Some(candidate); + } + } + } + + if let Some(candidate) = env::var_os("PATH").and_then(|path| { + env::split_paths(&path) + .map(|directory| directory.join("mc.exe")) + .find(|path| path.is_file()) + }) { + return Some(candidate); + } + + let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); + let direct = bin_dir.join("x64").join("mc.exe"); + if direct.is_file() { + return Some(direct); + } + + let mut versions: Vec<_> = fs::read_dir(bin_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + versions.sort_by_key(|path| { + std::cmp::Reverse( + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| { + name.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) + .unwrap_or_default(), + ) + }); + versions + .into_iter() + .map(|directory| directory.join("x64").join("mc.exe")) + .find(|path| path.is_file()) + } } diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc new file mode 100644 index 000000000..37d25f533 --- /dev/null +++ b/devolutions-agent/devolutions-agent.mc @@ -0,0 +1,468 @@ +; Devolutions Agent Windows Event Log message definitions. + +MessageIdTypedef=DWORD + +SeverityNames=( + Success=0x0:STATUS_SEVERITY_SUCCESS + Informational=0x1:STATUS_SEVERITY_INFORMATIONAL + Warning=0x2:STATUS_SEVERITY_WARNING + Error=0x3:STATUS_SEVERITY_ERROR +) + +FacilityNames=( + Application=0x0:FACILITY_APPLICATION +) + +LanguageNames=( + English=0x409:MSG00409 + French=0x40c:MSG0040c + German=0x407:MSG00407 +) + +; 1000-1099 Service / Lifecycle + +MessageId=1000 +SymbolicName=SERVICE_STARTED +Language=English +Service started. Context=%1 Version=%2 +Language=French +Service démarré. Contexte=%1 Version=%2 +Language=German +Dienst gestartet. Kontext=%1 Version=%2 +. + +MessageId=1001 +SymbolicName=SERVICE_STOPPING +Language=English +Service stopping. Context=%1 Reason=%2 +Language=French +Arrêt du service. Contexte=%1 Raison=%2 +Language=German +Dienst wird gestoppt. Kontext=%1 Grund=%2 +. + +MessageId=1010 +SymbolicName=CONFIG_INVALID +Language=English +Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=1020 +SymbolicName=START_FAILED +Language=English +Start failed. Context=%1 Cause=%2 Error=%3 +Language=French +Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +Language=German +Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 +. + +MessageId=1030 +SymbolicName=BOOT_STACKTRACE_WRITTEN +Language=English +Boot stacktrace written. Context=%1 Path=%2 +Language=French +Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +Language=German +Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 +. + +; 2000-2099 Listeners and Networking + +MessageId=2000 +SymbolicName=LISTENER_STARTED +Language=English +Listener started. Context=%1 Address=%2 Proto=%3 +Language=French +Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +Language=German +Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 +. + +MessageId=2001 +SymbolicName=LISTENER_BIND_FAILED +Language=English +Listener bind failed. Context=%1 Address=%2 Error=%3 +Language=French +Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +Language=German +Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 +. + +MessageId=2002 +SymbolicName=LISTENER_STOPPED +Language=English +Listener stopped. Context=%1 Address=%2 Reason=%3 +Language=French +Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +Language=German +Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 +. + +; 3000-3099 TLS / Certificates + +MessageId=3000 +SymbolicName=TLS_CONFIGURED +Language=English +TLS configured. Context=%1 Source=%2 +Language=French +TLS configuré. Contexte=%1 Source=%2 +Language=German +TLS konfiguriert. Kontext=%1 Quelle=%2 +. + +MessageId=3001 +SymbolicName=TLS_VERIFY_STRICT_DISABLED +Language=English +TLS strict verification disabled. Context=%1 Mode=%2 +Language=French +Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +Language=German +Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 +. + +MessageId=3002 +SymbolicName=TLS_CERTIFICATE_REJECTED +Language=English +Certificate rejected. Context=%1 Subject=%2 Reason=%3 +Language=French +Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +Language=German +Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 +. + +MessageId=3003 +SymbolicName=SYSTEM_CERT_SELECTED +Language=English +System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +Language=French +Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +Language=German +Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 +. + +MessageId=3004 +SymbolicName=TLS_KEY_LOAD_FAILED +Language=English +TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=3005 +SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH +Language=English +TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +Language=French +Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +Language=German +TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 +. + +MessageId=3006 +SymbolicName=TLS_NO_SUITABLE_CERTIFICATE +Language=English +No suitable certificate found. Context=%1 Error=%2 Issues=%3 +Language=French +Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +Language=German +Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 +. + +; 4000-4099 Sessions, Tokens and Recording + +MessageId=4000 +SymbolicName=SESSION_OPENED +Language=English +Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +Language=French +Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +Language=German +Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 +. + +MessageId=4001 +SymbolicName=SESSION_CLOSED +Language=English +Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +Language=French +Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +Language=German +Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 +. + +MessageId=4010 +SymbolicName=TOKEN_PROVISIONED +Language=English +Token provisioned. Context=%1 TokenId=%2 +Language=French +Jeton provisionné. Contexte=%1 Jeton=%2 +Language=German +Token bereitgestellt. Kontext=%1 Token=%2 +. + +MessageId=4011 +SymbolicName=TOKEN_REUSED +Language=English +Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +Language=French +Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +Language=German +Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 +. + +MessageId=4012 +SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED +Language=English +Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +Language=French +Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +Language=German +Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 +. + +MessageId=4030 +SymbolicName=RECORDING_STARTED +Language=English +Recording started. Context=%1 Destination=%2 +Language=French +Enregistrement démarré. Contexte=%1 Destination=%2 +Language=German +Aufnahme gestartet. Kontext=%1 Ziel=%2 +. + +MessageId=4031 +SymbolicName=RECORDING_STOPPED +Language=English +Recording stopped. Context=%1 Bytes=%2 Files=%3 +Language=French +Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +Language=German +Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 +. + +MessageId=4032 +SymbolicName=RECORDING_ERROR +Language=English +Recording error. Context=%1 Path=%2 Error=%3 +Language=French +Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 +. + +; 5000-5099 Authentication / Authorization + +MessageId=5001 +SymbolicName=JWT_REJECTED +Language=English +JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +Language=French +JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +Language=German +JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 +. + +MessageId=5002 +SymbolicName=JWT_ANOMALY +Language=English +JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +Language=French +Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +Language=German +JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 +. + +MessageId=5010 +SymbolicName=AUTHORIZATION_DENIED +Language=English +Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +Language=French +Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +Language=German +Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 +. + +MessageId=5090 +SymbolicName=AUTH_SUMMARY +Language=English +Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +Language=French +Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +Language=German +Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 +. + +; 6000-6099 Agent Integration + +MessageId=6000 +SymbolicName=USER_SESSION_PROCESS_STARTED +Language=English +User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +Language=French +Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +Language=German +Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 +. + +MessageId=6001 +SymbolicName=USER_SESSION_PROCESS_TERMINATED +Language=English +User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +Language=French +Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +Language=German +Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 +. + +MessageId=6010 +SymbolicName=UPDATER_TASK_ENABLED +Language=English +Updater task enabled. Context=%1 +Language=French +Tâche de mise à jour activée. Contexte=%1 +Language=German +Update-Aufgabe aktiviert. Kontext=%1 +. + +MessageId=6011 +SymbolicName=UPDATER_ERROR +Language=English +Updater error. Context=%1 Step=%2 Error=%3 +Language=French +Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +Language=German +Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 +. + +MessageId=6020 +SymbolicName=PEDM_ENABLED +Language=English +PEDM enabled. Context=%1 +Language=French +PEDM activé. Contexte=%1 +Language=German +PEDM aktiviert. Kontext=%1 +. + +; 7000-7099 Health + +MessageId=7010 +SymbolicName=RECORDING_STORAGE_LOW +Language=English +Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +Language=French +Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +Language=German +Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 +. + +; 8000-8099 Package Broker / Policy Management + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_CREATE_FAILED +Language=English +Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8003 +SymbolicName=POLICY_CREATE_SUCCEEDED +Language=English +Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8004 +SymbolicName=POLICY_CHANGE_FAILED +Language=English +Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8005 +SymbolicName=POLICY_CHANGE_SUCCEEDED +Language=English +Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + +; 9000-9099 Diagnostics + +MessageId=9001 +SymbolicName=DEBUG_OPTIONS_ENABLED +Language=English +Debug options enabled. Context=%1 Options=%2 +Language=French +Options de débogage activées. Contexte=%1 Options=%2 +Language=German +Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 +. + +MessageId=9002 +SymbolicName=XMF_NOT_FOUND +Language=English +XMF not found. Context=%1 Path=%2 Error=%3 +Language=French +XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 +. diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index d242d5610..478c8fc73 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -94,20 +94,18 @@ END"#, use std::path::PathBuf; use std::process::Command; - // --- gate: only release builds ------------------------------------- + // --- gate: only release and production profiles -------------------- let profile = env::var("PROFILE").unwrap_or_default(); - if profile != "release" { + if !matches!(profile.as_str(), "release" | "production") { return; } - // --- gate: ignore with a warning when mc is not found -------------- - let mc_exe_path = match find_mc() { - Some(path) => path, - None => { - println!("cargo:warning=Did not find mc.exe"); - return; - } - }; + let mc_exe_path = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe is required to embed the Devolutions Gateway Event Log catalog; \ + use a Visual Studio developer shell or set WindowsSdkVerBinPath or WindowsSdkDir" + ) + }); // --- inputs/paths --------------------------------------------------- let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); @@ -163,20 +161,50 @@ END"#, fn find_mc() -> Option { if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { - let p = std::path::Path::new(&sdk_bin).join("mc.exe"); - if p.exists() { - return Some(p); + let sdk_bin = std::path::Path::new(&sdk_bin); + for candidate in [sdk_bin.join("mc.exe"), sdk_bin.join("x64").join("mc.exe")] { + if candidate.is_file() { + return Some(candidate); + } } } - if let Ok(sdk_dir) = env::var("WindowsSdkDir") { - // e.g. C:\Program Files (x86)\Windows Kits\10\ - let candidate = std::path::Path::new(&sdk_dir).join("bin").join("x64").join("mc.exe"); - if candidate.exists() { - return Some(candidate); - } + if let Some(candidate) = env::var_os("PATH").and_then(|path| { + env::split_paths(&path) + .map(|directory| directory.join("mc.exe")) + .find(|path| path.is_file()) + }) { + return Some(candidate); + } + + let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); + let direct = bin_dir.join("x64").join("mc.exe"); + if direct.is_file() { + return Some(direct); } - None + let mut versions: Vec<_> = fs::read_dir(bin_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + versions.sort_by_key(|path| { + std::cmp::Reverse( + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| { + name.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) + .unwrap_or_default(), + ) + }); + versions + .into_iter() + .map(|directory| directory.join("x64").join("mc.exe")) + .find(|path| path.is_file()) } } diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 4a9b99f8a..da060d36f 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -380,6 +380,91 @@ Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . +; ====================================================================== +; 8000-8099 Package Broker / Policy Management +; Emitted by Devolutions Agent only; both catalogs must define every code. +; ====================================================================== + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_CREATE_FAILED +Language=English +Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8003 +SymbolicName=POLICY_CREATE_SUCCEEDED +Language=English +Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8004 +SymbolicName=POLICY_CHANGE_FAILED +Language=English +Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +Language=French +Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +Language=German +Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8005 +SymbolicName=POLICY_CHANGE_SUCCEEDED +Language=English +Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +Language=French +Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +Language=German +Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + ; ====================================================================== ; 9000-9099 Diagnostics ; ====================================================================== From 07081aa4579973554c0737be2c70ccc44cbe44a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 11:52:42 -0400 Subject: [PATCH 03/13] test(dgw,agent): enforce event catalog parity Verify every shared event code and policy insertion string across both localized Windows message catalogs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/message_catalog_parity.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 crates/sysevent-codes/tests/message_catalog_parity.rs diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..3c3746894 --- /dev/null +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,117 @@ +//! Verifies that shared event codes and Windows message catalogs stay aligned. + +use std::path::Path; + +const MESSAGE_CATALOGS: &[&str] = &[ + "../../devolutions-gateway/devolutions-gateway.mc", + "../../devolutions-agent/devolutions-agent.mc", +]; + +const POLICY_INSERTION_COUNTS: &[(u32, usize)] = &[ + (sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), + (sysevent_codes::POLICY_WRITE_DENIED, 6), + (sysevent_codes::POLICY_CREATE_FAILED, 8), + (sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), + (sysevent_codes::POLICY_CHANGE_FAILED, 8), + (sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), + (sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), + (sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), +]; + +#[test] +fn every_event_code_is_defined_once_in_every_catalog() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let event_codes = declared_event_codes(); + + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + for (name, code) in &event_codes { + let expected_id = format!("MessageId={code}"); + let expected_name = format!("SymbolicName={name}"); + let positions: Vec<_> = content.match_indices(&expected_id).collect(); + assert_eq!( + positions.len(), + 1, + "{}: expected one {expected_id}, found {}", + path.display(), + positions.len() + ); + + let after_id = &content[positions[0].0..]; + let name_line = after_id.lines().nth(1).unwrap_or_default(); + assert_eq!( + name_line.trim(), + expected_name, + "{}: {expected_id} must be followed by {expected_name}", + path.display() + ); + } + } +} + +#[test] +fn policy_catalog_insertions_match_structured_field_order() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + for &(code, insertion_count) in POLICY_INSERTION_COUNTS { + let block = message_block(&content, code); + let messages: Vec<_> = block + .lines() + .enumerate() + .filter(|(_, line)| line.starts_with("Language=")) + .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) + .collect(); + assert_eq!(messages.len(), 3, "{}: MessageId={code}", path.display()); + + for message in messages { + for insertion in 1..=insertion_count { + assert!( + message.contains(&format!("%{insertion}")), + "{}: MessageId={code} omits %{insertion}", + path.display() + ); + } + assert!( + !message.contains(&format!("%{}", insertion_count + 1)), + "{}: MessageId={code} has an unexpected insertion", + path.display() + ); + } + } + } +} + +fn declared_event_codes() -> Vec<(&'static str, u32)> { + include_str!("../src/lib.rs") + .lines() + .filter_map(|line| line.trim().strip_prefix("pub const ")) + .map(|declaration| { + let (name, value) = declaration + .split_once(": u32 = ") + .unwrap_or_else(|| panic!("event code must use `pub const NAME: u32 = VALUE;`: {declaration}")); + let value = value + .split_once(';') + .unwrap_or_else(|| panic!("event code must contain a semicolon: {declaration}")) + .0 + .parse() + .unwrap_or_else(|error| panic!("event code must be a decimal u32 in `{declaration}`: {error}")); + (name, value) + }) + .collect() +} + +fn message_block(content: &str, code: u32) -> &str { + let marker = format!("MessageId={code}"); + let start = content.find(&marker).unwrap_or_else(|| panic!("missing {marker}")); + let after = &content[start + marker.len()..]; + let end = after.find("\nMessageId=").unwrap_or(after.len()); + &content[start..start + marker.len() + end] +} From 8d964b10d0e4675f770da078f6bdeecfdd2ecfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 20:29:12 -0400 Subject: [PATCH 04/13] fix(dgw,agent): harden policy audit validation Restrict message compiler discovery to trusted SDK paths, keep thread-local audit assertions on one runtime thread, and avoid an unnecessary path allocation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 2 +- devolutions-agent/build.rs | 8 -------- devolutions-gateway/build.rs | 8 -------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 309c58e2f..ab767a512 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -285,7 +285,7 @@ impl WriteAudit { } pub(crate) fn failed(&self, operation: PolicyReplacementOperation, reason: FailureReason) { - self.failed_at(operation, &self.0.path.clone(), reason); + self.failed_at(operation, &self.0.path, reason); } pub(crate) fn failed_at(&self, operation: PolicyReplacementOperation, path: &Path, reason: FailureReason) { diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index 5cf58aaa4..96da29750 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -149,14 +149,6 @@ END"#, } } - if let Some(candidate) = env::var_os("PATH").and_then(|path| { - env::split_paths(&path) - .map(|directory| directory.join("mc.exe")) - .find(|path| path.is_file()) - }) { - return Some(candidate); - } - let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); let direct = bin_dir.join("x64").join("mc.exe"); if direct.is_file() { diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index 478c8fc73..93b484b13 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -169,14 +169,6 @@ END"#, } } - if let Some(candidate) = env::var_os("PATH").and_then(|path| { - env::split_paths(&path) - .map(|directory| directory.join("mc.exe")) - .find(|path| path.is_file()) - }) { - return Some(candidate); - } - let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); let direct = bin_dir.join("x64").join("mc.exe"); if direct.is_file() { From 3619f062769b4b5ffe4f04512bc8d3e434fdfbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 01:47:38 +0900 Subject: [PATCH 05/13] fix(agent): audit legacy policy rejection Distinguish legacy-contract disk rejection without exposing document values. Cover validator9 receipt rejection, conversion observation, no-op reloads, and abandoned audit scopes without duplicating terminal write events. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index ab767a512..2aa37a6ed 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; -use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +use now_policy_api::{InvalidPolicyDiagnostics, PolicyFindingCode, PolicyManagementState, PolicyReplacementOperation}; #[cfg(not(test))] use sysevent::Severity; #[cfg(all(not(test), not(debug_assertions)))] @@ -402,10 +402,24 @@ pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u )); } -pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { +pub(crate) fn external_change_rejected( + path: &Path, + state: PolicyManagementState, + diagnostics: Option<&InvalidPolicyDiagnostics>, +) { let reason = match state { PolicyManagementState::Active => "active", PolicyManagementState::Missing => "missing", + PolicyManagementState::Invalid + if diagnostics.is_some_and(|diagnostics| { + diagnostics + .findings + .iter() + .any(|finding| finding.code == PolicyFindingCode::UnsupportedPolicyFormatVersion) + }) => + { + "legacy_policy_contract" + } PolicyManagementState::Invalid => "invalid", }; RECORDER.record(sysevent_codes::policy_external_change_rejected( @@ -472,6 +486,33 @@ mod tests { ); } + #[test] + fn abandoned_clones_record_one_terminal_denial() { + let (audit, recorder) = test_audit(); + let retained = audit.clone(); + drop(audit); + assert_eq!(recorder.events().len(), 1); + drop(retained); + let events = recorder.events(); + assert_eq!(events.len(), 2); + assert_eq!(events[1].event_code, Some(sysevent_codes::POLICY_WRITE_DENIED)); + assert!( + events[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "request_rejected") + ); + } + + #[test] + fn audit_text_removes_control_characters_before_truncation() { + let value = format!("injected\r\n\t\0{}", "é".repeat(MAX_POLICY_ID_BYTES)); + let bounded = bounded(value, MAX_POLICY_ID_BYTES); + assert!(bounded.len() <= MAX_POLICY_ID_BYTES); + assert!(bounded.ends_with("...")); + assert!(!bounded.chars().any(char::is_control)); + } + #[test] fn audit_values_are_bounded_and_fields_are_allowlisted() { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); From fddc0804981eb8899585a439d1675e264f788eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 01:47:38 +0900 Subject: [PATCH 06/13] fix(dgw,agent): compile localized event messages Terminate every language block and declare UTF-8 input so the message compiler produces separate, correctly encoded EN/FR/DE resources. Require these properties in event catalog parity tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/message_catalog_parity.rs | 48 ++++++++++ devolutions-agent/devolutions-agent.mc | 88 ++++++++++++++++++- devolutions-gateway/devolutions-gateway.mc | 88 ++++++++++++++++++- 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs index 3c3746894..ea9129fd4 100644 --- a/crates/sysevent-codes/tests/message_catalog_parity.rs +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -52,6 +52,54 @@ fn every_event_code_is_defined_once_in_every_catalog() { } } +#[test] +fn every_catalog_message_terminates_each_translation() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = std::fs::read_to_string(&path).expect("read message catalog"); + assert!( + content.starts_with('\u{feff}'), + "{}: mc.exe requires a UTF-8 BOM to avoid decoding translations as ANSI", + path.display() + ); + for (_, code) in declared_event_codes() { + let mut lines = message_block(&content, code).lines(); + let mut languages = Vec::new(); + while let Some(line) = lines.next() { + let Some(language) = line.strip_prefix("Language=") else { + continue; + }; + languages.push(language); + let mut terminated = false; + for text in lines.by_ref() { + if text == "." { + terminated = true; + break; + } + assert!( + !text.starts_with("Language="), + "{}: MessageId={code} {language} lacks a message terminator", + path.display() + ); + } + assert!( + terminated, + "{}: MessageId={code} {language} lacks a message terminator", + path.display() + ); + } + languages.sort_unstable(); + assert_eq!( + languages, + ["English", "French", "German"], + "{}: MessageId={code} must define each translation once", + path.display() + ); + } + } +} + #[test] fn policy_catalog_insertions_match_structured_field_order() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc index 37d25f533..179f4f336 100644 --- a/devolutions-agent/devolutions-agent.mc +++ b/devolutions-agent/devolutions-agent.mc @@ -1,4 +1,4 @@ -; Devolutions Agent Windows Event Log message definitions. +; Devolutions Agent Windows Event Log message definitions. MessageIdTypedef=DWORD @@ -25,8 +25,10 @@ MessageId=1000 SymbolicName=SERVICE_STARTED Language=English Service started. Context=%1 Version=%2 +. Language=French Service démarré. Contexte=%1 Version=%2 +. Language=German Dienst gestartet. Kontext=%1 Version=%2 . @@ -35,8 +37,10 @@ MessageId=1001 SymbolicName=SERVICE_STOPPING Language=English Service stopping. Context=%1 Reason=%2 +. Language=French Arrêt du service. Contexte=%1 Raison=%2 +. Language=German Dienst wird gestoppt. Kontext=%1 Grund=%2 . @@ -45,8 +49,10 @@ MessageId=1010 SymbolicName=CONFIG_INVALID Language=English Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -55,8 +61,10 @@ MessageId=1020 SymbolicName=START_FAILED Language=English Start failed. Context=%1 Cause=%2 Error=%3 +. Language=French Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +. Language=German Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 . @@ -65,8 +73,10 @@ MessageId=1030 SymbolicName=BOOT_STACKTRACE_WRITTEN Language=English Boot stacktrace written. Context=%1 Path=%2 +. Language=French Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +. Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . @@ -77,8 +87,10 @@ MessageId=2000 SymbolicName=LISTENER_STARTED Language=English Listener started. Context=%1 Address=%2 Proto=%3 +. Language=French Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +. Language=German Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 . @@ -87,8 +99,10 @@ MessageId=2001 SymbolicName=LISTENER_BIND_FAILED Language=English Listener bind failed. Context=%1 Address=%2 Error=%3 +. Language=French Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +. Language=German Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 . @@ -97,8 +111,10 @@ MessageId=2002 SymbolicName=LISTENER_STOPPED Language=English Listener stopped. Context=%1 Address=%2 Reason=%3 +. Language=French Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +. Language=German Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 . @@ -109,8 +125,10 @@ MessageId=3000 SymbolicName=TLS_CONFIGURED Language=English TLS configured. Context=%1 Source=%2 +. Language=French TLS configuré. Contexte=%1 Source=%2 +. Language=German TLS konfiguriert. Kontext=%1 Quelle=%2 . @@ -119,8 +137,10 @@ MessageId=3001 SymbolicName=TLS_VERIFY_STRICT_DISABLED Language=English TLS strict verification disabled. Context=%1 Mode=%2 +. Language=French Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +. Language=German Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 . @@ -129,8 +149,10 @@ MessageId=3002 SymbolicName=TLS_CERTIFICATE_REJECTED Language=English Certificate rejected. Context=%1 Subject=%2 Reason=%3 +. Language=French Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +. Language=German Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 . @@ -139,8 +161,10 @@ MessageId=3003 SymbolicName=SYSTEM_CERT_SELECTED Language=English System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +. Language=French Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +. Language=German Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 . @@ -149,8 +173,10 @@ MessageId=3004 SymbolicName=TLS_KEY_LOAD_FAILED Language=English TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -159,8 +185,10 @@ MessageId=3005 SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH Language=English TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +. Language=French Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +. Language=German TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 . @@ -169,8 +197,10 @@ MessageId=3006 SymbolicName=TLS_NO_SUITABLE_CERTIFICATE Language=English No suitable certificate found. Context=%1 Error=%2 Issues=%3 +. Language=French Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +. Language=German Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 . @@ -181,8 +211,10 @@ MessageId=4000 SymbolicName=SESSION_OPENED Language=English Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +. Language=French Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +. Language=German Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 . @@ -191,8 +223,10 @@ MessageId=4001 SymbolicName=SESSION_CLOSED Language=English Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +. Language=French Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +. Language=German Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 . @@ -201,8 +235,10 @@ MessageId=4010 SymbolicName=TOKEN_PROVISIONED Language=English Token provisioned. Context=%1 TokenId=%2 +. Language=French Jeton provisionné. Contexte=%1 Jeton=%2 +. Language=German Token bereitgestellt. Kontext=%1 Token=%2 . @@ -211,8 +247,10 @@ MessageId=4011 SymbolicName=TOKEN_REUSED Language=English Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +. Language=French Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +. Language=German Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 . @@ -221,8 +259,10 @@ MessageId=4012 SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED Language=English Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +. Language=French Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +. Language=German Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 . @@ -231,8 +271,10 @@ MessageId=4030 SymbolicName=RECORDING_STARTED Language=English Recording started. Context=%1 Destination=%2 +. Language=French Enregistrement démarré. Contexte=%1 Destination=%2 +. Language=German Aufnahme gestartet. Kontext=%1 Ziel=%2 . @@ -241,8 +283,10 @@ MessageId=4031 SymbolicName=RECORDING_STOPPED Language=English Recording stopped. Context=%1 Bytes=%2 Files=%3 +. Language=French Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +. Language=German Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 . @@ -251,8 +295,10 @@ MessageId=4032 SymbolicName=RECORDING_ERROR Language=English Recording error. Context=%1 Path=%2 Error=%3 +. Language=French Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 . @@ -263,8 +309,10 @@ MessageId=5001 SymbolicName=JWT_REJECTED Language=English JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +. Language=French JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +. Language=German JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 . @@ -273,8 +321,10 @@ MessageId=5002 SymbolicName=JWT_ANOMALY Language=English JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +. Language=French Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +. Language=German JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 . @@ -283,8 +333,10 @@ MessageId=5010 SymbolicName=AUTHORIZATION_DENIED Language=English Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +. Language=French Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +. Language=German Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 . @@ -293,8 +345,10 @@ MessageId=5090 SymbolicName=AUTH_SUMMARY Language=English Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +. Language=French Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +. Language=German Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 . @@ -305,8 +359,10 @@ MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED Language=English User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +. Language=French Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +. Language=German Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 . @@ -315,8 +371,10 @@ MessageId=6001 SymbolicName=USER_SESSION_PROCESS_TERMINATED Language=English User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +. Language=French Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +. Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . @@ -325,8 +383,10 @@ MessageId=6010 SymbolicName=UPDATER_TASK_ENABLED Language=English Updater task enabled. Context=%1 +. Language=French Tâche de mise à jour activée. Contexte=%1 +. Language=German Update-Aufgabe aktiviert. Kontext=%1 . @@ -335,8 +395,10 @@ MessageId=6011 SymbolicName=UPDATER_ERROR Language=English Updater error. Context=%1 Step=%2 Error=%3 +. Language=French Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +. Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . @@ -345,8 +407,10 @@ MessageId=6020 SymbolicName=PEDM_ENABLED Language=English PEDM enabled. Context=%1 +. Language=French PEDM activé. Contexte=%1 +. Language=German PEDM aktiviert. Kontext=%1 . @@ -357,8 +421,10 @@ MessageId=7010 SymbolicName=RECORDING_STORAGE_LOW Language=English Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +. Language=French Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +. Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . @@ -369,8 +435,10 @@ MessageId=8000 SymbolicName=POLICY_WRITE_ATTEMPTED Language=English Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +. Language=French Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +. Language=German Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 . @@ -379,8 +447,10 @@ MessageId=8001 SymbolicName=POLICY_WRITE_DENIED Language=English Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +. Language=French Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +. Language=German Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 . @@ -389,8 +459,10 @@ MessageId=8002 SymbolicName=POLICY_CREATE_FAILED Language=English Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -399,8 +471,10 @@ MessageId=8003 SymbolicName=POLICY_CREATE_SUCCEEDED Language=English Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -409,8 +483,10 @@ MessageId=8004 SymbolicName=POLICY_CHANGE_FAILED Language=English Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -419,8 +495,10 @@ MessageId=8005 SymbolicName=POLICY_CHANGE_SUCCEEDED Language=English Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -429,8 +507,10 @@ MessageId=8010 SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED Language=English External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +. Language=French Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +. Language=German Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 . @@ -439,8 +519,10 @@ MessageId=8011 SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED Language=English External policy change rejected. Context=%1 Path=%2 Reason=%3 +. Language=French Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +. Language=German Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 . @@ -451,8 +533,10 @@ MessageId=9001 SymbolicName=DEBUG_OPTIONS_ENABLED Language=English Debug options enabled. Context=%1 Options=%2 +. Language=French Options de débogage activées. Contexte=%1 Options=%2 +. Language=German Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 . @@ -461,8 +545,10 @@ MessageId=9002 SymbolicName=XMF_NOT_FOUND Language=English XMF not found. Context=%1 Path=%2 Error=%3 +. Language=French XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 . diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index da060d36f..54c592ce4 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -1,4 +1,4 @@ -; ---------------------------------------------------------------------- +; ---------------------------------------------------------------------- ; Devolutions Gateway - Windows Event Log message definitions (.mc) ; English (0x409), French (0x40c), German (0x407) ; ---------------------------------------------------------------------- @@ -30,8 +30,10 @@ MessageId=1000 SymbolicName=SERVICE_STARTED Language=English Service started. Context=%1 Version=%2 +. Language=French Service démarré. Contexte=%1 Version=%2 +. Language=German Dienst gestartet. Kontext=%1 Version=%2 . @@ -40,8 +42,10 @@ MessageId=1001 SymbolicName=SERVICE_STOPPING Language=English Service stopping. Context=%1 Reason=%2 +. Language=French Arrêt du service. Contexte=%1 Raison=%2 +. Language=German Dienst wird gestoppt. Kontext=%1 Grund=%2 . @@ -50,8 +54,10 @@ MessageId=1010 SymbolicName=CONFIG_INVALID Language=English Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -60,8 +66,10 @@ MessageId=1020 SymbolicName=START_FAILED Language=English Start failed. Context=%1 Cause=%2 Error=%3 +. Language=French Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +. Language=German Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 . @@ -70,8 +78,10 @@ MessageId=1030 SymbolicName=BOOT_STACKTRACE_WRITTEN Language=English Boot stacktrace written. Context=%1 Path=%2 +. Language=French Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +. Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . @@ -84,8 +94,10 @@ MessageId=2000 SymbolicName=LISTENER_STARTED Language=English Listener started. Context=%1 Address=%2 Proto=%3 +. Language=French Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +. Language=German Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 . @@ -94,8 +106,10 @@ MessageId=2001 SymbolicName=LISTENER_BIND_FAILED Language=English Listener bind failed. Context=%1 Address=%2 Error=%3 +. Language=French Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +. Language=German Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 . @@ -104,8 +118,10 @@ MessageId=2002 SymbolicName=LISTENER_STOPPED Language=English Listener stopped. Context=%1 Address=%2 Reason=%3 +. Language=French Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +. Language=German Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 . @@ -118,8 +134,10 @@ MessageId=3000 SymbolicName=TLS_CONFIGURED Language=English TLS configured. Context=%1 Source=%2 +. Language=French TLS configuré. Contexte=%1 Source=%2 +. Language=German TLS konfiguriert. Kontext=%1 Quelle=%2 . @@ -128,8 +146,10 @@ MessageId=3001 SymbolicName=TLS_VERIFY_STRICT_DISABLED Language=English TLS strict verification disabled. Context=%1 Mode=%2 +. Language=French Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +. Language=German Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 . @@ -138,8 +158,10 @@ MessageId=3002 SymbolicName=TLS_CERTIFICATE_REJECTED Language=English Certificate rejected. Context=%1 Subject=%2 Reason=%3 +. Language=French Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +. Language=German Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 . @@ -148,8 +170,10 @@ MessageId=3003 SymbolicName=SYSTEM_CERT_SELECTED Language=English System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +. Language=French Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +. Language=German Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 . @@ -158,8 +182,10 @@ MessageId=3004 SymbolicName=TLS_KEY_LOAD_FAILED Language=English TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -168,8 +194,10 @@ MessageId=3005 SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH Language=English TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +. Language=French Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +. Language=German TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 . @@ -178,8 +206,10 @@ MessageId=3006 SymbolicName=TLS_NO_SUITABLE_CERTIFICATE Language=English No suitable certificate found. Context=%1 Error=%2 Issues=%3 +. Language=French Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +. Language=German Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 . @@ -192,8 +222,10 @@ MessageId=4000 SymbolicName=SESSION_OPENED Language=English Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +. Language=French Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +. Language=German Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 . @@ -202,8 +234,10 @@ MessageId=4001 SymbolicName=SESSION_CLOSED Language=English Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +. Language=French Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +. Language=German Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 . @@ -212,8 +246,10 @@ MessageId=4010 SymbolicName=TOKEN_PROVISIONED Language=English Token provisioned. Context=%1 TokenId=%2 +. Language=French Jeton provisionné. Contexte=%1 Jeton=%2 +. Language=German Token bereitgestellt. Kontext=%1 Token=%2 . @@ -222,8 +258,10 @@ MessageId=4011 SymbolicName=TOKEN_REUSED Language=English Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +. Language=French Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +. Language=German Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 . @@ -232,8 +270,10 @@ MessageId=4012 SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED Language=English Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +. Language=French Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +. Language=German Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 . @@ -242,8 +282,10 @@ MessageId=4030 SymbolicName=RECORDING_STARTED Language=English Recording started. Context=%1 Destination=%2 +. Language=French Enregistrement démarré. Contexte=%1 Destination=%2 +. Language=German Aufnahme gestartet. Kontext=%1 Ziel=%2 . @@ -252,8 +294,10 @@ MessageId=4031 SymbolicName=RECORDING_STOPPED Language=English Recording stopped. Context=%1 Bytes=%2 Files=%3 +. Language=French Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +. Language=German Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 . @@ -262,8 +306,10 @@ MessageId=4032 SymbolicName=RECORDING_ERROR Language=English Recording error. Context=%1 Path=%2 Error=%3 +. Language=French Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 . @@ -276,8 +322,10 @@ MessageId=5001 SymbolicName=JWT_REJECTED Language=English JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +. Language=French JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +. Language=German JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 . @@ -286,8 +334,10 @@ MessageId=5002 SymbolicName=JWT_ANOMALY Language=English JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +. Language=French Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +. Language=German JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 . @@ -296,8 +346,10 @@ MessageId=5010 SymbolicName=AUTHORIZATION_DENIED Language=English Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +. Language=French Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +. Language=German Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 . @@ -306,8 +358,10 @@ MessageId=5090 SymbolicName=AUTH_SUMMARY Language=English Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +. Language=French Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +. Language=German Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 . @@ -320,8 +374,10 @@ MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED Language=English User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +. Language=French Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +. Language=German Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 . @@ -330,8 +386,10 @@ MessageId=6001 SymbolicName=USER_SESSION_PROCESS_TERMINATED Language=English User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +. Language=French Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +. Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . @@ -340,8 +398,10 @@ MessageId=6010 SymbolicName=UPDATER_TASK_ENABLED Language=English Updater task enabled. Context=%1 +. Language=French Tâche de mise à jour activée. Contexte=%1 +. Language=German Update-Aufgabe aktiviert. Kontext=%1 . @@ -350,8 +410,10 @@ MessageId=6011 SymbolicName=UPDATER_ERROR Language=English Updater error. Context=%1 Step=%2 Error=%3 +. Language=French Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +. Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . @@ -360,8 +422,10 @@ MessageId=6020 SymbolicName=PEDM_ENABLED Language=English PEDM enabled. Context=%1 +. Language=French PEDM activé. Contexte=%1 +. Language=German PEDM aktiviert. Kontext=%1 . @@ -374,8 +438,10 @@ MessageId=7010 SymbolicName=RECORDING_STORAGE_LOW Language=English Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +. Language=French Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +. Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . @@ -389,8 +455,10 @@ MessageId=8000 SymbolicName=POLICY_WRITE_ATTEMPTED Language=English Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +. Language=French Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +. Language=German Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 . @@ -399,8 +467,10 @@ MessageId=8001 SymbolicName=POLICY_WRITE_DENIED Language=English Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +. Language=French Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +. Language=German Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 . @@ -409,8 +479,10 @@ MessageId=8002 SymbolicName=POLICY_CREATE_FAILED Language=English Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -419,8 +491,10 @@ MessageId=8003 SymbolicName=POLICY_CREATE_SUCCEEDED Language=English Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -429,8 +503,10 @@ MessageId=8004 SymbolicName=POLICY_CHANGE_FAILED Language=English Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. Language=French Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. Language=German Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 . @@ -439,8 +515,10 @@ MessageId=8005 SymbolicName=POLICY_CHANGE_SUCCEEDED Language=English Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. Language=French Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. Language=German Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 . @@ -449,8 +527,10 @@ MessageId=8010 SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED Language=English External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +. Language=French Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +. Language=German Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 . @@ -459,8 +539,10 @@ MessageId=8011 SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED Language=English External policy change rejected. Context=%1 Path=%2 Reason=%3 +. Language=French Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +. Language=German Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 . @@ -473,8 +555,10 @@ MessageId=9001 SymbolicName=DEBUG_OPTIONS_ENABLED Language=English Debug options enabled. Context=%1 Options=%2 +. Language=French Options de débogage activées. Contexte=%1 Options=%2 +. Language=German Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 . @@ -483,8 +567,10 @@ MessageId=9002 SymbolicName=XMF_NOT_FOUND Language=English XMF not found. Context=%1 Path=%2 Error=%3 +. Language=French XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 . From 1b9c2f2a09d87ff1aefaf27fbeac13439c416684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 09:17:03 +0900 Subject: [PATCH 07/13] fix(dgw,agent,agent-installer): retain canonical audits Apply policy audit outcomes to the canonical storage contract and record the Agent Event Log source through MSI lifecycle registration. Keep a focused reflection test without retaining policy migration infrastructure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 16 +- crates/now-package-broker/src/audit.rs | 18 +- .../src/policy_store/mod.rs | 363 +++++++++++++++++- .../DevolutionsAgent.Installer.Tests.csproj | 21 + .../EventLogSourceRegistryTests.cs | 33 ++ package/AgentWindowsManaged/Program.cs | 15 +- 6 files changed, 427 insertions(+), 39 deletions(-) create mode 100644 package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj create mode 100644 package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b832cc5d2..82f7f49f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1171,6 +1171,20 @@ jobs: run: dotnet test utils/dotnet/GatewayUtils.sln shell: pwsh + agent-installer-event-log-tests: + name: Agent installer Event Log lifecycle tests + runs-on: windows-2022 + needs: [preflight] + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Tests + run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj + shell: pwsh winapi-sanitizer-tests: name: Windows API sanitizer tests @@ -1415,7 +1429,7 @@ jobs: success: name: Success if: ${{ always() }} - needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] + needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-installer-event-log-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] runs-on: ubuntu-latest steps: diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 2aa37a6ed..210e0964b 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; -use now_policy_api::{InvalidPolicyDiagnostics, PolicyFindingCode, PolicyManagementState, PolicyReplacementOperation}; +use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; #[cfg(not(test))] use sysevent::Severity; #[cfg(all(not(test), not(debug_assertions)))] @@ -402,24 +402,10 @@ pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u )); } -pub(crate) fn external_change_rejected( - path: &Path, - state: PolicyManagementState, - diagnostics: Option<&InvalidPolicyDiagnostics>, -) { +pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { let reason = match state { PolicyManagementState::Active => "active", PolicyManagementState::Missing => "missing", - PolicyManagementState::Invalid - if diagnostics.is_some_and(|diagnostics| { - diagnostics - .findings - .iter() - .any(|finding| finding.code == PolicyFindingCode::UnsupportedPolicyFormatVersion) - }) => - { - "legacy_policy_contract" - } PolicyManagementState::Invalid => "invalid", }; RECORDER.record(sysevent_codes::policy_external_change_rejected( diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 631025078..658a9ac10 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -9,9 +9,9 @@ use chrono::Utc; use now_policy::PolicyDocument; use now_policy_api::{ API_VERSION_STR, ErrorCode, ErrorResponse, ErrorResponseKind, InvalidPolicyDiagnostics, PolicyConfigurationSource, - PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, PolicyReplacementOperation, - PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, ServerContext, - Transport, + PolicyConflictHandling, PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, + PolicyReplacementOperation, PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, + PolicyWriteCapability, ServerContext, Transport, }; mod receipt; @@ -255,7 +255,7 @@ impl PolicyStore { return self.management_snapshot(); } let (_, observation) = self.observe_storage(false); - let management = self.publish_observation(observation); + let management = self.publish_external_observation(observation); tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy"); management } @@ -295,8 +295,28 @@ impl PolicyStore { } pub async fn replace(&self, request: PolicyReplacementRequest) -> Result { + self.replace_inner(request, None).await + } + + pub(crate) async fn replace_audited( + &self, + request: PolicyReplacementRequest, + audit: crate::audit::WriteAudit, + ) -> Result { + self.replace_inner(request, Some(audit)).await + } + + async fn replace_inner( + &self, + request: PolicyReplacementRequest, + audit: Option, + ) -> Result { + let operation = request.operation; let monitoring = self.writer.lock().await; if *monitoring != Monitoring::Available { + if let Some(audit) = &audit { + audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); + } return Err(error_with_management( ErrorCode::BrokerPaused, "policy change monitoring is unavailable", @@ -310,7 +330,11 @@ impl PolicyStore { // Both conflict modes require this exact token. // ConfirmOverwrite records retry intent without retaining token history. if fresh_token != request.expected_store_token { - let management = self.publish_observation(observation); + let audit_path = observation.canonical_path.clone(); + let management = self.publish_external_observation(observation); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the configured policy changed after the supplied store token was observed", @@ -319,6 +343,13 @@ impl PolicyStore { } if observation.write_capability != PolicyWriteCapability::Writable { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PathNotWritable, + ); + } let code = match observation.read_only_reason { Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem, Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat, @@ -329,6 +360,13 @@ impl PolicyStore { let validation = self.validate_draft(&request.draft); if !validation.is_valid { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidPolicy, + ); + } return Err(error_with_validation( ErrorCode::InvalidPolicy, "the submitted draft failed authoritative validation", @@ -345,6 +383,13 @@ impl PolicyStore { &validation.findings, &request.validation_receipt, ) { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidReceipt, + ); + } return Err(error_with_validation( ErrorCode::ValidationFailed, "the validation receipt does not match this draft", @@ -352,6 +397,13 @@ impl PolicyStore { )); } if !validation.findings.is_empty() && !request.warnings_acknowledged { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::WarningsNotAcknowledged, + ); + } return Err(error_with_validation( ErrorCode::WarningConfirmationRequired, "validation warnings must be explicitly acknowledged", @@ -359,21 +411,56 @@ impl PolicyStore { )); } - let revision = plan_revision( + let revision = match plan_revision( request.operation, observation.state, observation.policy.as_ref(), &draft.metadata.id.0, - ) - .map_err(|message| error_response(ErrorCode::Conflict, message))?; - let policy = draft.into_policy_document(revision, Utc::now()).map_err(|_| { - error_response( - ErrorCode::ValidationFailed, - "failed to commit the validated policy draft", - ) - })?; - let bytes = serde_json::to_vec_pretty(&policy) - .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?; + ) { + Ok(revision) => revision, + Err(message) => { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::RevisionConflict, + ); + } + return Err(error_response(ErrorCode::Conflict, message)); + } + }; + let policy = match draft.into_policy_document(revision, Utc::now()) { + Ok(policy) => policy, + Err(_) => { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::DraftCommitFailed, + ); + } + return Err(error_response( + ErrorCode::ValidationFailed, + "failed to commit the validated policy draft", + )); + } + }; + let bytes = match serde_json::to_vec_pretty(&policy) { + Ok(bytes) => bytes, + Err(_) => { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::SerializationFailed, + ); + } + return Err(error_response( + ErrorCode::InternalError, + "failed to serialize the committed policy", + )); + } + }; let persisted = if request.operation == PolicyReplacementOperation::Create { self.storage @@ -388,13 +475,24 @@ impl PolicyStore { tracing::warn!(error = format!("{error:#}"), "Policy persistence failed"); let (_, current) = self.observe_storage(false); if current.fingerprint != observation.fingerprint { - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed before publication; retry with the current store token", management, )); } + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PersistenceFailed, + ); + } return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to persist the policy", @@ -407,12 +505,23 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); if current.fingerprint == observation.fingerprint { + if let Some(audit) = &audit { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::ConditionalPublicationFailed, + ); + } return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to conditionally persist the policy", )); } - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed during publication; retry with the current store token", @@ -425,7 +534,11 @@ impl PolicyStore { "Published policy failed authoritative reload" ); let (_, current) = self.observe_storage(false); - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); + } return Err(error_with_management( ErrorCode::PolicyActivationFailed, "the policy was published but failed authoritative reload", @@ -434,6 +547,9 @@ impl PolicyStore { } }; + let old_id = observation.policy.as_ref().map(|policy| policy.metadata.id.0.clone()); + let old_revision = observation.policy.as_ref().map(|policy| policy.metadata.revision); + let canonical_path = observation.canonical_path.clone(); let token = token_for(&previous, &persisted.fingerprint); let snapshot = Arc::new(Snapshot { state: PolicyManagementState::Active, @@ -447,6 +563,18 @@ impl PolicyStore { }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; + if let Some(audit) = &audit { + audit.succeeded_at( + &canonical_path, + old_id.as_deref(), + old_revision, + &persisted.policy.metadata.id.0, + persisted.policy.metadata.revision, + operation, + request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, + ); + } + Ok(ReplaceSuccess { policy: persisted.policy, validation, @@ -469,6 +597,26 @@ impl PolicyStore { management } + fn publish_external_observation(&self, observation: Observation) -> PolicyManagementSnapshot { + let policy_changed = self.snapshot().fingerprint != observation.fingerprint; + let management = self.publish_observation(observation); + if policy_changed { + let path = Path::new(&management.configured_path); + match (management.state, management.policy.as_ref()) { + (PolicyManagementState::Active, Some(policy)) => { + crate::audit::external_change_applied(path, &policy.metadata.id.0, policy.metadata.revision); + } + (PolicyManagementState::Missing | PolicyManagementState::Invalid, _) => { + crate::audit::external_change_rejected(path, management.state); + } + (PolicyManagementState::Active, None) => { + crate::audit::external_change_rejected(path, PolicyManagementState::Invalid); + } + } + } + management + } + #[cfg(test)] pub(crate) fn for_tests(policy: Option) -> Arc { let storage = Arc::new(TestStorage::new(policy)); @@ -809,6 +957,7 @@ fn clone_observation(observation: &Observation) -> Observation { mod storage_tests { use now_policy::PolicyDraftDocument; use now_policy_api::{PolicyConflictHandling, PolicyReplacementRequestKind}; + use win_api_wrappers::identity::sid::Sid; use super::*; @@ -853,6 +1002,121 @@ mod storage_tests { } } + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + let sid = + Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); + crate::audit::WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + } + + #[tokio::test] + async fn audited_old_validator_receipt_fails_once_without_publication() { + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::new(TestStorage::new(Some(policy("current", 1)))), + Monitoring::Available, + ); + let mut request = update_request(&store); + let validation = store.validate_draft(&request.draft); + let canonical = validation.canonical_draft.as_ref().expect("canonical draft"); + request.validation_receipt = + store + .receipt_key + .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); + let (audit, recorder) = recording_audit(); + + let error = store + .replace_audited(request, audit) + .await + .expect_err("old validator receipt is rejected"); + + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert_eq!(store.active_policy().expect("unchanged policy").metadata.revision, 1); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "invalid_receipt") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn canonical_external_observations_are_audited_once_per_change() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + crate::audit::take_test_events(); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::take_test_events().is_empty(), + "unchanged policy is not an event" + ); + + storage.set_disk_state(None, true, 2); + let rejected = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(rejected.state, PolicyManagementState::Invalid); + assert!( + store.active_policy().is_none(), + "invalid external policy is not published" + ); + let events = crate::audit::take_test_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + ); + assert!( + events[0] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "invalid") + ); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::take_test_events().is_empty(), + "unchanged invalid policy is not an event" + ); + + storage.set_disk_state(Some(policy("external", 7)), false, 3); + let applied = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(applied.state, PolicyManagementState::Active); + assert_eq!( + store + .active_policy() + .expect("external policy is active") + .metadata + .revision, + 7 + ); + let events = crate::audit::take_test_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) + ); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::take_test_events().is_empty(), + "unchanged external policy is not an event" + ); + } + #[tokio::test] async fn compatible_format_version_is_bound_to_receipts_and_persisted_tokens() { let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); @@ -900,8 +1164,9 @@ mod storage_tests { ); } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { + crate::audit::take_test_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -909,9 +1174,13 @@ mod storage_tests { Monitoring::Available, ); let request = update_request(&store); + let (audit, recorder) = recording_audit(); storage.race_before_next_persist(policy("external", 7)); - let error = store.replace(request).await.expect_err("external replacement wins"); + let error = store + .replace_audited(request, audit) + .await + .expect_err("external replacement wins"); assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); assert_eq!( @@ -926,6 +1195,58 @@ mod storage_tests { .revision, 7 ); + assert_eq!( + crate::audit::take_test_events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] + ); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "outcome" && value == "stale_conflict") + ); + } + + #[tokio::test] + async fn audited_replacement_records_one_success_after_activation() { + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::new(TestStorage::new(Some(policy("current", 1)))), + Monitoring::Available, + ); + let (audit, recorder) = recording_audit(); + + let success = store + .replace_audited(update_request(&store), audit) + .await + .expect("replacement succeeds"); + + assert_eq!(success.policy.metadata.revision, 2); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_SUCCEEDED) + ] + ); } #[tokio::test] diff --git a/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj b/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj new file mode 100644 index 000000000..e54f1240d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj @@ -0,0 +1,21 @@ + + + net48 + latest + false + DevolutionsAgent.Installer.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs new file mode 100644 index 000000000..c6a05b87d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; + +using WixSharp; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class EventLogSourceRegistryTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SourceUsesNativeMsiRegistryLifecycle(bool win64) + { + Type program = System.Reflection.Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreateEventLogSourceRegistryValue", + BindingFlags.Static | BindingFlags.NonPublic); + RegValue value = Assert.IsType(method.Invoke(null, [win64])); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"SYSTEM\CurrentControlSet\Services\EventLog\Application\Devolutions Agent", value.Key); + Assert.Equal("EventMessageFile", value.Name); + Assert.Equal("[INSTALLDIR]DevolutionsAgent.exe", value.Value); + Assert.Equal(win64, value.Win64); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.False(value.ForceCreateOnInstall); + Assert.False(value.ForceDeleteOnUninstall); + Assert.Contains("Type=string", value.AttributesDefinition); + } +} diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index d2a246305..09a32b242 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -348,7 +348,8 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, Feature = Features.PSU_FEATURE, - } + }, + CreateEventLogSourceRegistryValue(project.Platform == Platform.x64), }; List projectProperties = AgentProperties.Properties.Select(x => x.ToWixSharpProperty()).ToList(); @@ -422,6 +423,18 @@ static void Main() } } + internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => + new( + RegistryHive.LocalMachine, + $"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\{Includes.PRODUCT_NAME}", + "EventMessageFile", + $"[{AgentProperties.InstallDir}]{Includes.EXECUTABLE_NAME}") + { + AttributesDefinition = "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + }; + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = From 5d94bfce009b70942583199c0bd0ca554f53a738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 15:51:06 +0900 Subject: [PATCH 08/13] fix(agent): sanitize audit text controls Replace Unicode line, paragraph, and bidirectional controls before audit values reach Windows Event Log insertion strings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 210e0964b..774302ba1 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -417,7 +417,7 @@ pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState fn bounded(mut value: String, max_bytes: usize) -> String { value = value .chars() - .map(|character| if character.is_control() { ' ' } else { character }) + .map(|character| if is_audit_control(character) { ' ' } else { character }) .collect(); if value.len() <= max_bytes { return value; @@ -432,6 +432,14 @@ fn bounded(mut value: String, max_bytes: usize) -> String { value } +fn is_audit_control(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' + ) +} + fn bounded_path(path: &Path) -> PathBuf { PathBuf::from(bounded(path.display().to_string(), MAX_PATH_BYTES)) } @@ -492,11 +500,14 @@ mod tests { #[test] fn audit_text_removes_control_characters_before_truncation() { - let value = format!("injected\r\n\t\0{}", "é".repeat(MAX_POLICY_ID_BYTES)); + let value = format!( + "injected\r\n\t\0\u{061c}\u{200e}\u{200f}\u{2028}\u{2029}\u{202a}\u{202b}\u{202c}\u{202d}\u{202e}\u{2066}\u{2067}\u{2068}\u{2069}{}", + "é".repeat(MAX_POLICY_ID_BYTES) + ); let bounded = bounded(value, MAX_POLICY_ID_BYTES); assert!(bounded.len() <= MAX_POLICY_ID_BYTES); assert!(bounded.ends_with("...")); - assert!(!bounded.chars().any(char::is_control)); + assert!(!bounded.chars().any(is_audit_control)); } #[test] From b1ed7fef064230d7017b5685a2a2842a7ffbcd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 16:58:55 +0900 Subject: [PATCH 09/13] refactor(agent): isolate policy audit event codes Move policy audit event definitions and Agent catalog parity checks out of the shared Gateway event-code crate. Gateway no longer embeds Agent-only policy event messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 9 +- crates/agent-sysevent-codes/Cargo.toml | 13 + crates/agent-sysevent-codes/src/lib.rs | 123 +++++++ .../tests/message_catalog_parity.rs | 48 +++ crates/now-package-broker/Cargo.toml | 2 +- crates/now-package-broker/src/audit.rs | 78 ++-- .../src/policy_store/mod.rs | 18 +- crates/sysevent-codes/src/lib.rs | 340 ------------------ .../tests/message_catalog_parity.rs | 48 --- devolutions-gateway/devolutions-gateway.mc | 101 ------ 10 files changed, 243 insertions(+), 537 deletions(-) create mode 100644 crates/agent-sysevent-codes/Cargo.toml create mode 100644 crates/agent-sysevent-codes/src/lib.rs create mode 100644 crates/agent-sysevent-codes/tests/message_catalog_parity.rs diff --git a/Cargo.lock b/Cargo.lock index af374ee74..a70a49781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,13 @@ dependencies = [ "tokio 1.52.3", ] +[[package]] +name = "agent-sysevent-codes" +version = "0.0.0" +dependencies = [ + "sysevent", +] + [[package]] name = "agent-tunnel" version = "0.0.0" @@ -4804,6 +4811,7 @@ dependencies = [ name = "now-package-broker" version = "0.0.0" dependencies = [ + "agent-sysevent-codes", "anyhow", "async-trait", "axum 0.8.9", @@ -4827,7 +4835,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sysevent", - "sysevent-codes", "sysevent-winevent", "tempfile", "tokio 1.52.3", diff --git a/crates/agent-sysevent-codes/Cargo.toml b/crates/agent-sysevent-codes/Cargo.toml new file mode 100644 index 000000000..beef0008e --- /dev/null +++ b/crates/agent-sysevent-codes/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-sysevent-codes" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +license = "MIT OR Apache-2.0" +publish = false + +[lints] +workspace = true + +[dependencies] +sysevent.path = "../sysevent" diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs new file mode 100644 index 000000000..05620c3b0 --- /dev/null +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -0,0 +1,123 @@ +//! Devolutions Agent-specific Windows Event Log event definitions. + +use std::path::Path; + +use sysevent::{Entry, Severity}; + +pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; +pub const POLICY_WRITE_DENIED: u32 = 8001; +pub const POLICY_CREATE_FAILED: u32 = 8002; +pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; +pub const POLICY_CHANGE_FAILED: u32 = 8004; +pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; +pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; +pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; + +pub fn policy_write_attempted( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: &Path, +) -> Entry { + Entry::new("Policy management write attempted") + .event_code(POLICY_WRITE_ATTEMPTED) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.display()) +} + +pub fn policy_write_denied( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: &Path, + reason: impl ToString, +) -> Entry { + Entry::new("Policy management write denied") + .event_code(POLICY_WRITE_DENIED) + .severity(Severity::Warning) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.display()) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the Create and change failure events field-compatible" +)] +pub fn policy_write_failed( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Error) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("operation", operation) + .field("outcome", outcome) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_write_succeeded( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("path", path.as_ref().display()) + .field("old_id", old_id) + .field("old_revision", old_revision) + .field("new_id", new_id) + .field("new_revision", new_revision) + .field("intent", intent) + .field("operation", operation) + .field("outcome", outcome) +} + +pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { + Entry::new("External policy change applied") + .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) + .severity(Severity::Notice) + .field("path", path.as_ref().display()) + .field("new_id", new_id) + .field("new_revision", new_revision) +} + +pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { + Entry::new("External policy change rejected") + .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) + .severity(Severity::Warning) + .field("path", path.as_ref().display()) + .field("reason", reason) +} diff --git a/crates/agent-sysevent-codes/tests/message_catalog_parity.rs b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..f838bfda0 --- /dev/null +++ b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,48 @@ +use std::path::Path; + +const EVENTS: &[(u32, usize)] = &[ + (agent_sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), + (agent_sysevent_codes::POLICY_WRITE_DENIED, 6), + (agent_sysevent_codes::POLICY_CREATE_FAILED, 8), + (agent_sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), + (agent_sysevent_codes::POLICY_CHANGE_FAILED, 8), + (agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), + (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), + (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), +]; + +#[test] +fn policy_events_match_the_agent_catalog() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../devolutions-agent/devolutions-agent.mc"); + let catalog = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + + for &(code, insertion_count) in EVENTS { + let marker = format!("MessageId={code}"); + let start = catalog + .find(&marker) + .unwrap_or_else(|| panic!("Agent catalog omits {marker}")); + let block = &catalog[start + ..catalog[start..] + .find("\nMessageId=") + .map_or(catalog.len(), |end| start + end)]; + let messages: Vec<_> = block + .lines() + .enumerate() + .filter(|(_, line)| line.starts_with("Language=")) + .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) + .collect(); + assert_eq!(messages.len(), 3, "Agent catalog {marker}"); + for message in messages { + for insertion in 1..=insertion_count { + assert!( + message.contains(&format!("%{insertion}")), + "Agent catalog {marker} omits %{insertion}" + ); + } + assert!( + !message.contains(&format!("%{}", insertion_count + 1)), + "Agent catalog {marker} has an unexpected insertion" + ); + } + } +} diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 344366322..3758faabc 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -43,7 +43,7 @@ serde = "1" serde_json = "1" sha2 = "0.10" sysevent = { path = "../sysevent" } -sysevent-codes = { path = "../sysevent-codes" } +agent-sysevent-codes = { path = "../agent-sysevent-codes" } sysevent-winevent = { path = "../sysevent-winevent" } tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] } tokio-util = "0.7" diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 774302ba1..ea3b169b9 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; +use agent_sysevent_codes as policy_events; use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +use sysevent::Entry; #[cfg(not(test))] use sysevent::Severity; #[cfg(all(not(test), not(debug_assertions)))] @@ -75,7 +77,7 @@ impl FailureReason { } trait AuditRecorder: Send + Sync { - fn record(&self, entry: sysevent::Entry); + fn record(&self, entry: Entry); } fn default_recorder() -> Arc { @@ -101,7 +103,7 @@ fn default_recorder() -> Arc { #[cfg(test)] std::thread_local! { - static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -109,13 +111,13 @@ struct TestRecorder; #[cfg(test)] impl AuditRecorder for TestRecorder { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { TEST_EVENTS.with(|events| events.borrow_mut().push(entry)); } } #[cfg(test)] -pub(crate) fn take_test_events() -> Vec { +pub(crate) fn take_test_events() -> Vec { TEST_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) } @@ -124,7 +126,7 @@ struct TracingRecorder; #[cfg(not(test))] impl AuditRecorder for TracingRecorder { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { trace_entry(&entry); } } @@ -170,7 +172,7 @@ impl AuditRecorder for SystemRecorder { } #[cfg(not(test))] -fn trace_entry(entry: &sysevent::Entry) { +fn trace_entry(entry: &Entry) { let code = entry.event_code; let message = &entry.message; let fields = &entry.fields; @@ -201,18 +203,18 @@ fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { #[cfg(test)] #[derive(Default)] -pub(crate) struct RecordingAudit(parking_lot::Mutex>); +pub(crate) struct RecordingAudit(parking_lot::Mutex>); #[cfg(test)] impl RecordingAudit { - pub(crate) fn events(&self) -> Vec { + pub(crate) fn events(&self) -> Vec { self.0.lock().clone() } } #[cfg(test)] impl AuditRecorder for RecordingAudit { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { self.0.lock().push(entry); } } @@ -228,7 +230,7 @@ struct WriteAuditState { impl Drop for WriteAuditState { fn drop(&mut self) { if !self.terminal_recorded.swap(true, Ordering::AcqRel) { - self.record(sysevent_codes::policy_write_denied( + self.record(policy_events::policy_write_denied( &self.actor_sid, &self.actor_exe, INTENT, @@ -255,7 +257,7 @@ impl WriteAudit { terminal_recorded: AtomicBool::new(false), recorder, }); - state.record(sysevent_codes::policy_write_attempted( + state.record(policy_events::policy_write_attempted( &state.actor_sid, &state.actor_exe, INTENT, @@ -274,13 +276,7 @@ impl WriteAudit { pub(crate) fn denied(&self, reason: DenialReason) { self.finish(|state| { - sysevent_codes::policy_write_denied( - &state.actor_sid, - &state.actor_exe, - INTENT, - &state.path, - reason.as_str(), - ) + policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str()) }); } @@ -298,7 +294,9 @@ impl WriteAudit { }; self.finish(|state| { if operation == PolicyReplacementOperation::Create { - sysevent_codes::policy_create_failed( + policy_events::policy_write_failed( + policy_events::POLICY_CREATE_FAILED, + "Policy creation failed", &state.actor_sid, &state.actor_exe, INTENT, @@ -308,7 +306,9 @@ impl WriteAudit { reason.as_str(), ) } else { - sysevent_codes::policy_change_failed( + policy_events::policy_write_failed( + policy_events::POLICY_CHANGE_FAILED, + "Policy change failed", &state.actor_sid, &state.actor_exe, INTENT, @@ -347,7 +347,9 @@ impl WriteAudit { }; self.finish(|state| { if operation == PolicyReplacementOperation::Create { - sysevent_codes::policy_create_succeeded( + policy_events::policy_write_succeeded( + policy_events::POLICY_CREATE_SUCCEEDED, + "Policy creation succeeded", &state.actor_sid, &state.actor_exe, path, @@ -360,7 +362,9 @@ impl WriteAudit { outcome, ) } else { - sysevent_codes::policy_change_succeeded( + policy_events::policy_write_succeeded( + policy_events::POLICY_CHANGE_SUCCEEDED, + "Policy change succeeded", &state.actor_sid, &state.actor_exe, path, @@ -376,7 +380,7 @@ impl WriteAudit { }); } - fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> sysevent::Entry) { + fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> Entry) { if self .0 .terminal_recorded @@ -389,13 +393,13 @@ impl WriteAudit { } impl WriteAuditState { - fn record(&self, entry: sysevent::Entry) { + fn record(&self, entry: Entry) { self.recorder.record(entry); } } pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { - RECORDER.record(sysevent_codes::policy_external_change_applied( + RECORDER.record(policy_events::policy_external_change_applied( bounded_path(path), bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), new_revision, @@ -408,7 +412,7 @@ pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState PolicyManagementState::Missing => "missing", PolicyManagementState::Invalid => "invalid", }; - RECORDER.record(sysevent_codes::policy_external_change_rejected( + RECORDER.record(policy_events::policy_external_change_rejected( bounded_path(path), reason, )); @@ -474,8 +478,8 @@ mod tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_WRITE_DENIED) + Some(policy_events::POLICY_WRITE_ATTEMPTED), + Some(policy_events::POLICY_WRITE_DENIED) ] ); } @@ -489,7 +493,7 @@ mod tests { drop(retained); let events = recorder.events(); assert_eq!(events.len(), 2); - assert_eq!(events[1].event_code, Some(sysevent_codes::POLICY_WRITE_DENIED)); + assert_eq!(events[1].event_code, Some(policy_events::POLICY_WRITE_DENIED)); assert!( events[1] .fields @@ -552,23 +556,23 @@ mod tests { for (operation, failure_code, success_code) in [ ( PolicyReplacementOperation::Create, - sysevent_codes::POLICY_CREATE_FAILED, - sysevent_codes::POLICY_CREATE_SUCCEEDED, + policy_events::POLICY_CREATE_FAILED, + policy_events::POLICY_CREATE_SUCCEEDED, ), ( PolicyReplacementOperation::Update, - sysevent_codes::POLICY_CHANGE_FAILED, - sysevent_codes::POLICY_CHANGE_SUCCEEDED, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, ), ( PolicyReplacementOperation::Repair, - sysevent_codes::POLICY_CHANGE_FAILED, - sysevent_codes::POLICY_CHANGE_SUCCEEDED, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, ), ( PolicyReplacementOperation::ReplaceIdentity, - sysevent_codes::POLICY_CHANGE_FAILED, - sysevent_codes::POLICY_CHANGE_SUCCEEDED, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, ), ] { let (failed, failed_recorder) = test_audit(); diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 658a9ac10..0630fd1fb 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1038,8 +1038,8 @@ mod storage_tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_CHANGE_FAILED) + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) ] ); assert!( @@ -1077,7 +1077,7 @@ mod storage_tests { assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, - Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) ); assert!( events[0] @@ -1107,7 +1107,7 @@ mod storage_tests { assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, - Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) ); store.reload_from_disk(ReloadCause::ExternalChange).await; @@ -1200,7 +1200,7 @@ mod storage_tests { .iter() .map(|entry| entry.event_code) .collect::>(), - [Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] + [Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] ); assert_eq!( recorder @@ -1209,8 +1209,8 @@ mod storage_tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_CHANGE_FAILED) + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) ] ); assert!( @@ -1243,8 +1243,8 @@ mod storage_tests { .map(|entry| entry.event_code) .collect::>(), [ - Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), - Some(sysevent_codes::POLICY_CHANGE_SUCCEEDED) + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED) ] ); } diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index 1b93a4c80..e2eaad987 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -380,242 +380,6 @@ pub fn recording_storage_low(remaining_bytes: u64, threshold_bytes: u64) -> Entr .field("threshold_bytes", threshold_bytes) } -// 8000-8099 **Package Broker / Policy Management** - -/// A policy write was received before any authorization check. -pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; -/// A policy write was denied by caller authorization. -pub const POLICY_WRITE_DENIED: u32 = 8001; -/// A Create operation failed. -pub const POLICY_CREATE_FAILED: u32 = 8002; -/// A Create operation succeeded. -pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; -/// An Update, Repair, or ReplaceIdentity operation failed. -pub const POLICY_CHANGE_FAILED: u32 = 8004; -/// An Update, Repair, or ReplaceIdentity operation succeeded. -pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; -/// An external policy change became active. -pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; -/// An external policy change left the policy unavailable. -pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; - -pub fn policy_write_attempted( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, -) -> Entry { - Entry::new("Policy management write attempted") - .event_code(POLICY_WRITE_ATTEMPTED) - .severity(Severity::Info) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("intent", intent) - .field("path", path.as_ref().display()) -} - -pub fn policy_write_denied( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - reason: impl ToString, -) -> Entry { - Entry::new("Policy management write denied") - .event_code(POLICY_WRITE_DENIED) - .severity(Severity::Warning) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("intent", intent) - .field("path", path.as_ref().display()) - .field("reason", reason) -} - -pub fn policy_create_failed( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - operation: impl ToString, - outcome: impl ToString, - reason: impl ToString, -) -> Entry { - policy_write_failed( - POLICY_CREATE_FAILED, - "Policy creation failed", - actor_sid, - actor_exe, - intent, - path, - operation, - outcome, - reason, - ) -} - -#[expect( - clippy::too_many_arguments, - reason = "the audit event records both policy identities and the operation outcome" -)] -pub fn policy_create_succeeded( - actor_sid: impl ToString, - actor_exe: impl ToString, - path: impl AsRef, - old_id: impl ToString, - old_revision: impl ToString, - new_id: impl ToString, - new_revision: u32, - intent: impl ToString, - operation: impl ToString, - outcome: impl ToString, -) -> Entry { - policy_write_succeeded( - POLICY_CREATE_SUCCEEDED, - "Policy creation succeeded", - actor_sid, - actor_exe, - path, - old_id, - old_revision, - new_id, - new_revision, - intent, - operation, - outcome, - ) -} - -pub fn policy_change_failed( - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - operation: impl ToString, - outcome: impl ToString, - reason: impl ToString, -) -> Entry { - policy_write_failed( - POLICY_CHANGE_FAILED, - "Policy change failed", - actor_sid, - actor_exe, - intent, - path, - operation, - outcome, - reason, - ) -} - -#[expect( - clippy::too_many_arguments, - reason = "the audit event records both policy identities and the operation outcome" -)] -pub fn policy_change_succeeded( - actor_sid: impl ToString, - actor_exe: impl ToString, - path: impl AsRef, - old_id: impl ToString, - old_revision: impl ToString, - new_id: impl ToString, - new_revision: u32, - intent: impl ToString, - operation: impl ToString, - outcome: impl ToString, -) -> Entry { - policy_write_succeeded( - POLICY_CHANGE_SUCCEEDED, - "Policy change succeeded", - actor_sid, - actor_exe, - path, - old_id, - old_revision, - new_id, - new_revision, - intent, - operation, - outcome, - ) -} - -#[expect( - clippy::too_many_arguments, - reason = "the shared builder keeps the four outcome events field-compatible" -)] -fn policy_write_failed( - event_code: u32, - message: &'static str, - actor_sid: impl ToString, - actor_exe: impl ToString, - intent: impl ToString, - path: impl AsRef, - operation: impl ToString, - outcome: impl ToString, - reason: impl ToString, -) -> Entry { - Entry::new(message) - .event_code(event_code) - .severity(Severity::Error) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("intent", intent) - .field("path", path.as_ref().display()) - .field("operation", operation) - .field("outcome", outcome) - .field("reason", reason) -} - -#[expect( - clippy::too_many_arguments, - reason = "the shared builder keeps the four outcome events field-compatible" -)] -fn policy_write_succeeded( - event_code: u32, - message: &'static str, - actor_sid: impl ToString, - actor_exe: impl ToString, - path: impl AsRef, - old_id: impl ToString, - old_revision: impl ToString, - new_id: impl ToString, - new_revision: u32, - intent: impl ToString, - operation: impl ToString, - outcome: impl ToString, -) -> Entry { - Entry::new(message) - .event_code(event_code) - .severity(Severity::Info) - .field("actor_sid", actor_sid) - .field("actor_exe", actor_exe) - .field("path", path.as_ref().display()) - .field("old_id", old_id) - .field("old_revision", old_revision) - .field("new_id", new_id) - .field("new_revision", new_revision) - .field("intent", intent) - .field("operation", operation) - .field("outcome", outcome) -} - -pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { - Entry::new("External policy change applied") - .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) - .severity(Severity::Notice) - .field("path", path.as_ref().display()) - .field("new_id", new_id) - .field("new_revision", new_revision) -} - -pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { - Entry::new("External policy change rejected") - .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) - .severity(Severity::Warning) - .field("path", path.as_ref().display()) - .field("reason", reason) -} - // 9000-9099 **Diagnostics** pub const DEBUG_OPTIONS_ENABLED: u32 = 9001; @@ -635,107 +399,3 @@ pub fn xmf_not_found(path: impl AsRef, error: impl std::fmt::Display) -> E .field("path", path.as_ref().display()) .field("error_chain", format!("{error:#}")) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn policy_audit_entries_preserve_catalog_field_order() { - const WRITE: &[&str] = &["actor_sid", "actor_exe", "intent", "path"]; - const DENIED: &[&str] = &["actor_sid", "actor_exe", "intent", "path", "reason"]; - const FAILED: &[&str] = &[ - "actor_sid", - "actor_exe", - "intent", - "path", - "operation", - "outcome", - "reason", - ]; - const SUCCEEDED: &[&str] = &[ - "actor_sid", - "actor_exe", - "path", - "old_id", - "old_revision", - "new_id", - "new_revision", - "intent", - "operation", - "outcome", - ]; - let entries = [ - ( - policy_write_attempted("sid", "exe", "intent", "path"), - POLICY_WRITE_ATTEMPTED, - Severity::Info, - WRITE, - ), - ( - policy_write_denied("sid", "exe", "intent", "path", "reason"), - POLICY_WRITE_DENIED, - Severity::Warning, - DENIED, - ), - ( - policy_create_failed("sid", "exe", "intent", "path", "create", "failed", "reason"), - POLICY_CREATE_FAILED, - Severity::Error, - FAILED, - ), - ( - policy_create_succeeded( - "sid", "exe", "path", "old", "1", "new", 2, "intent", "create", "applied", - ), - POLICY_CREATE_SUCCEEDED, - Severity::Info, - SUCCEEDED, - ), - ( - policy_change_failed("sid", "exe", "intent", "path", "update", "stale_conflict", "reason"), - POLICY_CHANGE_FAILED, - Severity::Error, - FAILED, - ), - ( - policy_change_succeeded( - "sid", - "exe", - "path", - "old", - "1", - "new", - 2, - "intent", - "update", - "confirmed_overwrite", - ), - POLICY_CHANGE_SUCCEEDED, - Severity::Info, - SUCCEEDED, - ), - ( - policy_external_change_applied("path", "new", 2), - POLICY_EXTERNAL_CHANGE_APPLIED, - Severity::Notice, - &["path", "new_id", "new_revision"], - ), - ( - policy_external_change_rejected("path", "invalid"), - POLICY_EXTERNAL_CHANGE_REJECTED, - Severity::Warning, - &["path", "reason"], - ), - ]; - - for (entry, code, severity, expected_fields) in entries { - assert_eq!(entry.event_code, Some(code)); - assert_eq!(entry.severity, severity); - assert_eq!( - entry.fields.iter().map(|(name, _)| name.as_str()).collect::>(), - expected_fields - ); - } - } -} diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs index ea9129fd4..4b65b680f 100644 --- a/crates/sysevent-codes/tests/message_catalog_parity.rs +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -7,17 +7,6 @@ const MESSAGE_CATALOGS: &[&str] = &[ "../../devolutions-agent/devolutions-agent.mc", ]; -const POLICY_INSERTION_COUNTS: &[(u32, usize)] = &[ - (sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), - (sysevent_codes::POLICY_WRITE_DENIED, 6), - (sysevent_codes::POLICY_CREATE_FAILED, 8), - (sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), - (sysevent_codes::POLICY_CHANGE_FAILED, 8), - (sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), - (sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), - (sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), -]; - #[test] fn every_event_code_is_defined_once_in_every_catalog() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -100,43 +89,6 @@ fn every_catalog_message_terminates_each_translation() { } } -#[test] -fn policy_catalog_insertions_match_structured_field_order() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - for catalog in MESSAGE_CATALOGS { - let path = manifest_dir.join(catalog); - let content = - std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - - for &(code, insertion_count) in POLICY_INSERTION_COUNTS { - let block = message_block(&content, code); - let messages: Vec<_> = block - .lines() - .enumerate() - .filter(|(_, line)| line.starts_with("Language=")) - .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) - .collect(); - assert_eq!(messages.len(), 3, "{}: MessageId={code}", path.display()); - - for message in messages { - for insertion in 1..=insertion_count { - assert!( - message.contains(&format!("%{insertion}")), - "{}: MessageId={code} omits %{insertion}", - path.display() - ); - } - assert!( - !message.contains(&format!("%{}", insertion_count + 1)), - "{}: MessageId={code} has an unexpected insertion", - path.display() - ); - } - } - } -} - fn declared_event_codes() -> Vec<(&'static str, u32)> { include_str!("../src/lib.rs") .lines() diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 54c592ce4..470c1de05 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -446,107 +446,6 @@ Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . -; ====================================================================== -; 8000-8099 Package Broker / Policy Management -; Emitted by Devolutions Agent only; both catalogs must define every code. -; ====================================================================== - -MessageId=8000 -SymbolicName=POLICY_WRITE_ATTEMPTED -Language=English -Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 -. -Language=French -Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 -. -Language=German -Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 -. - -MessageId=8001 -SymbolicName=POLICY_WRITE_DENIED -Language=English -Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 -. -Language=French -Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 -. -Language=German -Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 -. - -MessageId=8002 -SymbolicName=POLICY_CREATE_FAILED -Language=English -Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 -. -Language=French -Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 -. -Language=German -Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 -. - -MessageId=8003 -SymbolicName=POLICY_CREATE_SUCCEEDED -Language=English -Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 -. -Language=French -Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 -. -Language=German -Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 -. - -MessageId=8004 -SymbolicName=POLICY_CHANGE_FAILED -Language=English -Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 -. -Language=French -Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 -. -Language=German -Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 -. - -MessageId=8005 -SymbolicName=POLICY_CHANGE_SUCCEEDED -Language=English -Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 -. -Language=French -Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 -. -Language=German -Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 -. - -MessageId=8010 -SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED -Language=English -External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 -. -Language=French -Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 -. -Language=German -Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 -. - -MessageId=8011 -SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED -Language=English -External policy change rejected. Context=%1 Path=%2 Reason=%3 -. -Language=French -Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 -. -Language=German -Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 -. - ; ====================================================================== ; 9000-9099 Diagnostics ; ====================================================================== From 21a0fb41c846c2562b1cf81d160199bd9df5a2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 17:14:40 +0900 Subject: [PATCH 10/13] test(agent): isolate audit recorders Move test-only audit recorders and thread-local capture into an explicit mock module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 101 +++++++++--------- .../src/policy_store/mod.rs | 20 ++-- 2 files changed, 61 insertions(+), 60 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index ea3b169b9..87cef05e9 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -83,7 +83,7 @@ trait AuditRecorder: Send + Sync { fn default_recorder() -> Arc { #[cfg(test)] { - Arc::new(TestRecorder) + Arc::new(mock::TestRecorder) } #[cfg(all(not(test), debug_assertions))] { @@ -101,26 +101,6 @@ fn default_recorder() -> Arc { } } -#[cfg(test)] -std::thread_local! { - static TEST_EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; -} - -#[cfg(test)] -struct TestRecorder; - -#[cfg(test)] -impl AuditRecorder for TestRecorder { - fn record(&self, entry: Entry) { - TEST_EVENTS.with(|events| events.borrow_mut().push(entry)); - } -} - -#[cfg(test)] -pub(crate) fn take_test_events() -> Vec { - TEST_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) -} - #[cfg(not(test))] struct TracingRecorder; @@ -201,24 +181,6 @@ fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { } } -#[cfg(test)] -#[derive(Default)] -pub(crate) struct RecordingAudit(parking_lot::Mutex>); - -#[cfg(test)] -impl RecordingAudit { - pub(crate) fn events(&self) -> Vec { - self.0.lock().clone() - } -} - -#[cfg(test)] -impl AuditRecorder for RecordingAudit { - fn record(&self, entry: Entry) { - self.0.lock().push(entry); - } -} - struct WriteAuditState { actor_sid: String, actor_exe: String, @@ -266,14 +228,6 @@ impl WriteAudit { Self(state) } - #[cfg(test)] - pub(crate) fn begin_recording(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (Self, Arc) { - let recorder = Arc::new(RecordingAudit::default()); - let recorder_sink = Arc::::clone(&recorder); - let audit = Self::begin_with_recorder(actor_sid, actor_exe, path, recorder_sink); - (audit, recorder) - } - pub(crate) fn denied(&self, reason: DenialReason) { self.finish(|state| { policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str()) @@ -457,13 +411,60 @@ const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { } } +#[cfg(test)] +pub(crate) mod mock { + use super::*; + + std::thread_local! { + static EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + } + + pub(crate) struct TestRecorder; + + impl AuditRecorder for TestRecorder { + fn record(&self, entry: Entry) { + EVENTS.with(|events| events.borrow_mut().push(entry)); + } + } + + pub(crate) fn take_events() -> Vec { + EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) + } + + #[derive(Default)] + pub(crate) struct Recorder(parking_lot::Mutex>); + + impl Recorder { + pub(crate) fn events(&self) -> Vec { + self.0.lock().clone() + } + } + + impl AuditRecorder for Recorder { + fn record(&self, entry: Entry) { + self.0.lock().push(entry); + } + } + + pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (WriteAudit, Arc) { + let recorder = Arc::new(Recorder::default()); + let audit = WriteAudit::begin_with_recorder( + actor_sid, + actor_exe, + path, + Arc::clone(&recorder) as Arc, + ); + (audit, recorder) + } +} + #[cfg(test)] mod tests { use super::*; - fn test_audit() -> (WriteAudit, Arc) { + fn test_audit() -> (WriteAudit, Arc) { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); - WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[test] @@ -518,7 +519,7 @@ mod tests { fn audit_values_are_bounded_and_fields_are_allowlisted() { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); let long = "é".repeat(MAX_PATH_BYTES); - let (audit, recorder) = WriteAudit::begin_recording(&sid, Path::new(&long), Path::new(&long)); + let (audit, recorder) = mock::begin(&sid, Path::new(&long), Path::new(&long)); audit.succeeded_at( Path::new(&long), Some(&long), diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 0630fd1fb..06063d936 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1002,10 +1002,10 @@ mod storage_tests { } } - fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { let sid = Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); - crate::audit::WriteAudit::begin_recording(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + crate::audit::mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[tokio::test] @@ -1058,11 +1058,11 @@ mod storage_tests { Arc::clone(&storage) as Arc, Monitoring::Available, ); - crate::audit::take_test_events(); + crate::audit::mock::take_events(); store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::take_test_events().is_empty(), + crate::audit::mock::take_events().is_empty(), "unchanged policy is not an event" ); @@ -1073,7 +1073,7 @@ mod storage_tests { store.active_policy().is_none(), "invalid external policy is not published" ); - let events = crate::audit::take_test_events(); + let events = crate::audit::mock::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1088,7 +1088,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::take_test_events().is_empty(), + crate::audit::mock::take_events().is_empty(), "unchanged invalid policy is not an event" ); @@ -1103,7 +1103,7 @@ mod storage_tests { .revision, 7 ); - let events = crate::audit::take_test_events(); + let events = crate::audit::mock::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1112,7 +1112,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::take_test_events().is_empty(), + crate::audit::mock::take_events().is_empty(), "unchanged external policy is not an event" ); } @@ -1166,7 +1166,7 @@ mod storage_tests { #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { - crate::audit::take_test_events(); + crate::audit::mock::take_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -1196,7 +1196,7 @@ mod storage_tests { 7 ); assert_eq!( - crate::audit::take_test_events() + crate::audit::mock::take_events() .iter() .map(|entry| entry.event_code) .collect::>(), From cd08d9688ce4396dcf605694de1b6ce52493327b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 23:30:40 +0900 Subject: [PATCH 11/13] test(agent): scope audit fixtures locally Keep test recorders and capture within the audit tests module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 15 +++++--------- .../src/policy_store/mod.rs | 20 +++++++++---------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 87cef05e9..8b3fe1db6 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -83,7 +83,7 @@ trait AuditRecorder: Send + Sync { fn default_recorder() -> Arc { #[cfg(test)] { - Arc::new(mock::TestRecorder) + Arc::new(tests::TestRecorder) } #[cfg(all(not(test), debug_assertions))] { @@ -412,7 +412,7 @@ const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { } #[cfg(test)] -pub(crate) mod mock { +pub(crate) mod tests { use super::*; std::thread_local! { @@ -456,15 +456,10 @@ pub(crate) mod mock { ); (audit, recorder) } -} - -#[cfg(test)] -mod tests { - use super::*; - fn test_audit() -> (WriteAudit, Arc) { + fn test_audit() -> (WriteAudit, Arc) { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); - mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[test] @@ -519,7 +514,7 @@ mod tests { fn audit_values_are_bounded_and_fields_are_allowlisted() { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); let long = "é".repeat(MAX_PATH_BYTES); - let (audit, recorder) = mock::begin(&sid, Path::new(&long), Path::new(&long)); + let (audit, recorder) = begin(&sid, Path::new(&long), Path::new(&long)); audit.succeeded_at( Path::new(&long), Some(&long), diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 06063d936..8819f68df 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -1002,10 +1002,10 @@ mod storage_tests { } } - fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { let sid = Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); - crate::audit::mock::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + crate::audit::tests::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) } #[tokio::test] @@ -1058,11 +1058,11 @@ mod storage_tests { Arc::clone(&storage) as Arc, Monitoring::Available, ); - crate::audit::mock::take_events(); + crate::audit::tests::take_events(); store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::mock::take_events().is_empty(), + crate::audit::tests::take_events().is_empty(), "unchanged policy is not an event" ); @@ -1073,7 +1073,7 @@ mod storage_tests { store.active_policy().is_none(), "invalid external policy is not published" ); - let events = crate::audit::mock::take_events(); + let events = crate::audit::tests::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1088,7 +1088,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::mock::take_events().is_empty(), + crate::audit::tests::take_events().is_empty(), "unchanged invalid policy is not an event" ); @@ -1103,7 +1103,7 @@ mod storage_tests { .revision, 7 ); - let events = crate::audit::mock::take_events(); + let events = crate::audit::tests::take_events(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_code, @@ -1112,7 +1112,7 @@ mod storage_tests { store.reload_from_disk(ReloadCause::ExternalChange).await; assert!( - crate::audit::mock::take_events().is_empty(), + crate::audit::tests::take_events().is_empty(), "unchanged external policy is not an event" ); } @@ -1166,7 +1166,7 @@ mod storage_tests { #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { - crate::audit::mock::take_events(); + crate::audit::tests::take_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -1196,7 +1196,7 @@ mod storage_tests { 7 ); assert_eq!( - crate::audit::mock::take_events() + crate::audit::tests::take_events() .iter() .map(|entry| entry.event_code) .collect::>(), From 2dc816a13228bfd91409aa1ab92f54b4b3a34188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 23:38:44 +0900 Subject: [PATCH 12/13] refactor(agent): require policy write audits Make policy replacement require its audit lifecycle and keep uninstrumented test calls behind a test-only helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/audit.rs | 16 ++ .../src/policy_store/mod.rs | 205 ++++++++---------- .../src/policy_store/receipt.rs | 37 ++-- crates/now-package-broker/src/server/mod.rs | 2 +- 4 files changed, 133 insertions(+), 127 deletions(-) diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index 8b3fe1db6..b84b59b8c 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -457,6 +457,22 @@ pub(crate) mod tests { (audit, recorder) } + pub(crate) fn noop() -> WriteAudit { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + WriteAudit::begin_with_recorder( + &sid, + Path::new(r"C:\test-client.exe"), + Path::new(r"C:\policy.json"), + Arc::new(NoopRecorder), + ) + } + + struct NoopRecorder; + + impl AuditRecorder for NoopRecorder { + fn record(&self, _: Entry) {} + } + fn test_audit() -> (WriteAudit, Arc) { let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 8819f68df..1ad1200e3 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -294,29 +294,15 @@ impl PolicyStore { self.publish_observation(observation); } - pub async fn replace(&self, request: PolicyReplacementRequest) -> Result { - self.replace_inner(request, None).await - } - - pub(crate) async fn replace_audited( + pub(crate) async fn replace( &self, request: PolicyReplacementRequest, audit: crate::audit::WriteAudit, - ) -> Result { - self.replace_inner(request, Some(audit)).await - } - - async fn replace_inner( - &self, - request: PolicyReplacementRequest, - audit: Option, ) -> Result { let operation = request.operation; let monitoring = self.writer.lock().await; if *monitoring != Monitoring::Available { - if let Some(audit) = &audit { - audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); - } + audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); return Err(error_with_management( ErrorCode::BrokerPaused, "policy change monitoring is unavailable", @@ -332,9 +318,7 @@ impl PolicyStore { if fresh_token != request.expected_store_token { let audit_path = observation.canonical_path.clone(); let management = self.publish_external_observation(observation); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the configured policy changed after the supplied store token was observed", @@ -343,13 +327,11 @@ impl PolicyStore { } if observation.write_capability != PolicyWriteCapability::Writable { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::PathNotWritable, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PathNotWritable, + ); let code = match observation.read_only_reason { Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem, Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat, @@ -360,13 +342,11 @@ impl PolicyStore { let validation = self.validate_draft(&request.draft); if !validation.is_valid { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::InvalidPolicy, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidPolicy, + ); return Err(error_with_validation( ErrorCode::InvalidPolicy, "the submitted draft failed authoritative validation", @@ -383,13 +363,11 @@ impl PolicyStore { &validation.findings, &request.validation_receipt, ) { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::InvalidReceipt, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidReceipt, + ); return Err(error_with_validation( ErrorCode::ValidationFailed, "the validation receipt does not match this draft", @@ -397,13 +375,11 @@ impl PolicyStore { )); } if !validation.findings.is_empty() && !request.warnings_acknowledged { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::WarningsNotAcknowledged, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::WarningsNotAcknowledged, + ); return Err(error_with_validation( ErrorCode::WarningConfirmationRequired, "validation warnings must be explicitly acknowledged", @@ -419,26 +395,22 @@ impl PolicyStore { ) { Ok(revision) => revision, Err(message) => { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::RevisionConflict, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::RevisionConflict, + ); return Err(error_response(ErrorCode::Conflict, message)); } }; let policy = match draft.into_policy_document(revision, Utc::now()) { Ok(policy) => policy, Err(_) => { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::DraftCommitFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::DraftCommitFailed, + ); return Err(error_response( ErrorCode::ValidationFailed, "failed to commit the validated policy draft", @@ -448,13 +420,11 @@ impl PolicyStore { let bytes = match serde_json::to_vec_pretty(&policy) { Ok(bytes) => bytes, Err(_) => { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::SerializationFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::SerializationFailed, + ); return Err(error_response( ErrorCode::InternalError, "failed to serialize the committed policy", @@ -477,22 +447,18 @@ impl PolicyStore { if current.fingerprint != observation.fingerprint { let audit_path = current.canonical_path.clone(); let management = self.publish_external_observation(current); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed before publication; retry with the current store token", management, )); } - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::PersistenceFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PersistenceFailed, + ); return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to persist the policy", @@ -505,13 +471,11 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); if current.fingerprint == observation.fingerprint { - if let Some(audit) = &audit { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::ConditionalPublicationFailed, - ); - } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::ConditionalPublicationFailed, + ); return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to conditionally persist the policy", @@ -519,9 +483,7 @@ impl PolicyStore { } let audit_path = current.canonical_path.clone(); let management = self.publish_external_observation(current); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed during publication; retry with the current store token", @@ -536,9 +498,7 @@ impl PolicyStore { let (_, current) = self.observe_storage(false); let audit_path = current.canonical_path.clone(); let management = self.publish_external_observation(current); - if let Some(audit) = &audit { - audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); - } + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); return Err(error_with_management( ErrorCode::PolicyActivationFailed, "the policy was published but failed authoritative reload", @@ -563,17 +523,15 @@ impl PolicyStore { }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; - if let Some(audit) = &audit { - audit.succeeded_at( - &canonical_path, - old_id.as_deref(), - old_revision, - &persisted.policy.metadata.id.0, - persisted.policy.metadata.revision, - operation, - request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, - ); - } + audit.succeeded_at( + &canonical_path, + old_id.as_deref(), + old_revision, + &persisted.policy.metadata.id.0, + persisted.policy.metadata.revision, + operation, + request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, + ); Ok(ReplaceSuccess { policy: persisted.policy, @@ -617,6 +575,14 @@ impl PolicyStore { management } + #[cfg(test)] + pub(crate) async fn replace_for_tests( + &self, + request: PolicyReplacementRequest, + ) -> Result { + self.replace(request, crate::audit::tests::noop()).await + } + #[cfg(test)] pub(crate) fn for_tests(policy: Option) -> Arc { let storage = Arc::new(TestStorage::new(policy)); @@ -1025,7 +991,7 @@ mod storage_tests { let (audit, recorder) = recording_audit(); let error = store - .replace_audited(request, audit) + .replace(request, audit) .await .expect_err("old validator receipt is rejected"); @@ -1128,7 +1094,7 @@ mod storage_tests { let mut request = update_request(&store); request.draft["PolicyFormatVersion"] = serde_json::json!("1.7.3"); let error = store - .replace(request.clone()) + .replace_for_tests(request.clone()) .await .expect_err("format version is receipt-bound"); assert_eq!(error.code, ErrorCode::ValidationFailed); @@ -1142,14 +1108,17 @@ mod storage_tests { .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); request.validation_receipt = old_receipt; let error = store - .replace(request.clone()) + .replace_for_tests(request.clone()) .await .expect_err("old validator receipt is rejected"); assert_eq!(error.code, ErrorCode::ValidationFailed); request.validation_receipt = validation.validation_receipt.expect("current receipt"); let before = store.management_snapshot().store_token; - let result = store.replace(request).await.expect("compatible format is writable"); + let result = store + .replace_for_tests(request) + .await + .expect("compatible format is writable"); assert_eq!( serde_json::to_value(&result.policy).expect("serialize committed policy")["PolicyFormatVersion"], "1.7.3" @@ -1178,7 +1147,7 @@ mod storage_tests { storage.race_before_next_persist(policy("external", 7)); let error = store - .replace_audited(request, audit) + .replace(request, audit) .await .expect_err("external replacement wins"); @@ -1231,7 +1200,7 @@ mod storage_tests { let (audit, recorder) = recording_audit(); let success = store - .replace_audited(update_request(&store), audit) + .replace(update_request(&store), audit) .await .expect("replacement succeeds"); @@ -1263,7 +1232,10 @@ mod storage_tests { .fail_concurrent_check .store(true, std::sync::atomic::Ordering::SeqCst); - let error = store.replace(request).await.expect_err("identity check fails"); + let error = store + .replace_for_tests(request) + .await + .expect_err("identity check fails"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); assert_eq!(store.management_snapshot().store_token, previous_token); @@ -1291,7 +1263,10 @@ mod storage_tests { .fail_target_retention .store(true, std::sync::atomic::Ordering::SeqCst); - let error = store.replace(request).await.expect_err("target retention fails"); + let error = store + .replace_for_tests(request) + .await + .expect_err("target retention fails"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); assert_eq!(store.management_snapshot().store_token, previous_token); @@ -1317,7 +1292,10 @@ mod storage_tests { *storage.post_persist_capability.lock() = Some((PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath))); - let success = store.replace(request).await.expect("policy replacement succeeds"); + let success = store + .replace_for_tests(request) + .await + .expect("policy replacement succeeds"); assert_eq!(success.management.write_capability, PolicyWriteCapability::ReadOnly); assert_eq!( @@ -1343,7 +1321,10 @@ mod storage_tests { ); assert_eq!(store.watched_path(), canonical); - let success = store.replace(update_request(&store)).await.expect("replace policy"); + let success = store + .replace_for_tests(update_request(&store)) + .await + .expect("replace policy"); assert_eq!(&*storage.persisted_configured_paths.lock(), &[configured]); assert_eq!(store.watched_path(), canonical); diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index 4aec0b184..3858f66cf 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -199,7 +199,10 @@ mod tests { let mut stale_request = request(&store, PolicyReplacementOperation::Update, raw.clone()); storage.set_disk_state(Some(policy("retargeted", 9)), false, 9); stale_request.conflict_handling = PolicyConflictHandling::ConfirmOverwrite; - let stale_error = store.replace(stale_request).await.expect_err("stale token rejected"); + let stale_error = store + .replace_for_tests(stale_request) + .await + .expect_err("stale token rejected"); assert_eq!(stale_error.code, ErrorCode::StalePolicyStoreToken); assert!(stale_error.management.is_some()); assert_eq!( @@ -208,7 +211,10 @@ mod tests { ); let mut tampered = request(&store, PolicyReplacementOperation::Update, raw); tampered.draft["Metadata"]["Publisher"] = "Tampered".into(); - let receipt_error = store.replace(tampered).await.expect_err("tampered draft rejected"); + let receipt_error = store + .replace_for_tests(tampered) + .await + .expect_err("tampered draft rejected"); assert_eq!(receipt_error.code, ErrorCode::ValidationFailed); } #[tokio::test] @@ -253,12 +259,15 @@ mod tests { ); let mut replacement = request(&store, PolicyReplacementOperation::Create, risky); let error = store - .replace(replacement.clone()) + .replace_for_tests(replacement.clone()) .await .expect_err("warning must be acknowledged"); assert_eq!(error.code, ErrorCode::WarningConfirmationRequired); replacement.warnings_acknowledged = true; - store.replace(replacement).await.expect("acknowledged warning succeeds"); + store + .replace_for_tests(replacement) + .await + .expect("acknowledged warning succeeds"); } #[tokio::test] async fn canonical_sensitive_warnings_accept_the_original_receipt() { @@ -317,13 +326,13 @@ mod tests { let mut changed = replacement.clone(); changed.draft["Rules"][0]["Constraints"]["AllowSkipHashCheck"] = serde_json::json!(false); let error = store - .replace(changed) + .replace_for_tests(changed) .await .expect_err("meaningful option change invalidates receipt"); assert_eq!(error.code, ErrorCode::ValidationFailed); } store - .replace(replacement) + .replace_for_tests(replacement) .await .unwrap_or_else(|error| panic!("{option} via {explicit} failed: {error:?}")); } @@ -334,21 +343,21 @@ mod tests { let create = PolicyStore::for_tests(None); let raw = serde_json::to_value(draft("created")).expect("serialize draft"); let created = create - .replace(request(&create, PolicyReplacementOperation::Create, raw)) + .replace_for_tests(request(&create, PolicyReplacementOperation::Create, raw)) .await .expect("create succeeds"); assert_eq!(created.policy.metadata.revision, 1); let update = PolicyStore::for_tests(Some(policy("current", 7))); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let updated = update - .replace(request(&update, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&update, PolicyReplacementOperation::Update, raw)) .await .expect("update succeeds"); assert_eq!(updated.policy.metadata.revision, 8); let replace = PolicyStore::for_tests(Some(policy("current", 7))); let raw = serde_json::to_value(draft("replacement")).expect("serialize draft"); let replaced = replace - .replace(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw)) + .replace_for_tests(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw)) .await .expect("identity replacement succeeds"); assert_eq!(replaced.policy.metadata.revision, 1); @@ -360,14 +369,14 @@ mod tests { ); let raw = serde_json::to_value(draft("repaired")).expect("serialize draft"); let repaired = repair - .replace(request(&repair, PolicyReplacementOperation::Repair, raw)) + .replace_for_tests(request(&repair, PolicyReplacementOperation::Repair, raw)) .await .expect("repair succeeds"); assert_eq!(repaired.policy.metadata.revision, 1); let wrong_identity = PolicyStore::for_tests(Some(policy("current", 1))); let raw = serde_json::to_value(draft("different")).expect("serialize draft"); let error = wrong_identity - .replace(request(&wrong_identity, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&wrong_identity, PolicyReplacementOperation::Update, raw)) .await .expect_err("update must preserve identity"); assert_eq!(error.code, ErrorCode::Conflict); @@ -377,7 +386,7 @@ mod tests { let store = PolicyStore::for_tests(Some(policy("current", 1))); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let first = request(&store, PolicyReplacementOperation::Update, raw); - let (first, second) = tokio::join!(store.replace(first.clone()), store.replace(first)); + let (first, second) = tokio::join!(store.replace_for_tests(first.clone()), store.replace_for_tests(first)); let outcomes = [first, second]; assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!( @@ -399,7 +408,7 @@ mod tests { storage.fail_persist.store(true, std::sync::atomic::Ordering::SeqCst); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let error = store - .replace(request(&store, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&store, PolicyReplacementOperation::Update, raw)) .await .expect_err("persistence failure"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); @@ -470,7 +479,7 @@ mod tests { ); replacement.expected_store_token = token; let error = store - .replace(replacement) + .replace_for_tests(replacement) .await .expect_err("monitoring failure blocks PUT"); assert_eq!(error.code, ErrorCode::BrokerPaused); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 77755dfd5..b80361696 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -407,7 +407,7 @@ impl PackageBrokerServer for BrokerConnection { } self.state .policy_store - .replace_audited(request, audit) + .replace(request, audit) .await .map(|success| PolicyReplacementResponse { response_kind: now_policy_api::PolicyReplacementResponseKind, From 58e8ca02667a5a4854945fae9f4407cb9cfa417f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 19 Sep 2026 23:55:20 +0900 Subject: [PATCH 13/13] refactor(agent): adopt shared warning contract Adopt the released policy API contract and preserve advisory findings without a broker-specific acknowledgement gate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 14 +++++++------- crates/agent-policy-tester/src/windows.rs | 2 -- crates/now-package-broker/Cargo.toml | 4 ++-- crates/now-package-broker/src/audit.rs | 2 -- crates/now-package-broker/src/policy_store/mod.rs | 14 -------------- .../now-package-broker/src/policy_store/receipt.rs | 14 +++----------- crates/now-package-broker/src/server/mod.rs | 4 +--- 7 files changed, 13 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a70a49781..bdf99a73c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2679,8 +2679,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3215,7 +3215,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4864,9 +4864,9 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b61d66fd334d2dac6150d1ab83f3831ec4b0ee20272fb3386fbe5b5e31c6663" +checksum = "fcd733577077eb870204207836f596ec3fc8fe4876d3652be7f0dee4a52e0dc8" dependencies = [ "chrono", "derive_more", @@ -4881,9 +4881,9 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee165964d3b2dddfa2c6283b820d5cad337277d51365cf77e6b1376668f529d" +checksum = "567491bfc7bf5615d1854cc951172987fe638084b86c6c153ad6d860f0096ae8" dependencies = [ "aide 0.15.1", "async-trait", diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 26ac51f30..db8d93d22 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -307,7 +307,6 @@ async fn assert_redirected_policy_rejected( "ExpectedStoreToken": management["Management"]["StoreToken"], "Operation": "Repair", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": full_policy(), "ValidationReceipt": "invalid" }); @@ -393,7 +392,6 @@ async fn replace_policy( "ExpectedStoreToken": expected_store_token, "Operation": operation, "ConflictHandling": "Reject", - "WarningsAcknowledged": true, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 3758faabc..fca797a41 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -34,8 +34,8 @@ notify = { version = "7", default-features = false } http-body-util = "0.1" mime = "0.3" now-policy = "=0.5.0" -now-policy-api = "=0.6.0" -now-policy-server-template = "=0.6.0" +now-policy-api = "=0.7.0" +now-policy-server-template = "=0.7.0" parking_lot = "0.12" regex = "1" semver = "1" diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs index b84b59b8c..598971c4e 100644 --- a/crates/now-package-broker/src/audit.rs +++ b/crates/now-package-broker/src/audit.rs @@ -48,7 +48,6 @@ pub(crate) enum FailureReason { PathNotWritable, InvalidPolicy, InvalidReceipt, - WarningsNotAcknowledged, RevisionConflict, DraftCommitFailed, SerializationFailed, @@ -65,7 +64,6 @@ impl FailureReason { Self::PathNotWritable => "path_not_writable", Self::InvalidPolicy => "invalid_policy", Self::InvalidReceipt => "invalid_receipt", - Self::WarningsNotAcknowledged => "warnings_not_acknowledged", Self::RevisionConflict => "revision_conflict", Self::DraftCommitFailed => "draft_commit_failed", Self::SerializationFailed => "serialization_failed", diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 1ad1200e3..1785c9c67 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -374,19 +374,6 @@ impl PolicyStore { validation, )); } - if !validation.findings.is_empty() && !request.warnings_acknowledged { - audit.failed_at( - operation, - &observation.canonical_path, - crate::audit::FailureReason::WarningsNotAcknowledged, - ); - return Err(error_with_validation( - ErrorCode::WarningConfirmationRequired, - "validation warnings must be explicitly acknowledged", - validation, - )); - } - let revision = match plan_revision( request.operation, observation.state, @@ -962,7 +949,6 @@ mod storage_tests { expected_store_token: store.management_snapshot().store_token, operation: PolicyReplacementOperation::Update, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: false, draft: raw, validation_receipt: validation.validation_receipt.expect("valid receipt"), } diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index 3858f66cf..300c416fb 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -115,7 +115,6 @@ mod tests { expected_store_token: store.management_snapshot().store_token, operation, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: false, draft: raw, validation_receipt: validation.validation_receipt.expect("valid receipt"), } @@ -218,7 +217,7 @@ mod tests { assert_eq!(receipt_error.code, ErrorCode::ValidationFailed); } #[tokio::test] - async fn store_requires_warning_acknowledgement() { + async fn store_saves_valid_drafts_with_advisory_findings() { let store = PolicyStore::for_tests(None); let mut risky = serde_json::to_value(draft("risky")).expect("serialize draft"); risky["Rules"] = serde_json::Value::Array( @@ -257,17 +256,11 @@ mod tests { serde_json::to_value(round_trip).expect("serialize round-tripped validation result"), serialized ); - let mut replacement = request(&store, PolicyReplacementOperation::Create, risky); - let error = store - .replace_for_tests(replacement.clone()) - .await - .expect_err("warning must be acknowledged"); - assert_eq!(error.code, ErrorCode::WarningConfirmationRequired); - replacement.warnings_acknowledged = true; + let replacement = request(&store, PolicyReplacementOperation::Create, risky); store .replace_for_tests(replacement) .await - .expect("acknowledged warning succeeds"); + .expect("advisory findings do not block a valid draft"); } #[tokio::test] async fn canonical_sensitive_warnings_accept_the_original_receipt() { @@ -318,7 +311,6 @@ mod tests { expected_store_token: store.management_snapshot().store_token, operation: PolicyReplacementOperation::Create, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: true, draft: canonical.clone(), validation_receipt: receipt.clone(), }; diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index b80361696..6ace82de8 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1027,7 +1027,6 @@ mod tests { "ExpectedStoreToken": replacement_state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": replacement_draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), }); @@ -1054,7 +1053,7 @@ mod tests { Method::PUT, "/v1/policy", "Application/JSON; charset=utf-8", - r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","WarningsAcknowledged":false,"ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, + r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, ), ] { let response = route_raw( @@ -1229,7 +1228,6 @@ mod tests { "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt") });