diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d42b3ecc..50c653eef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -345,8 +345,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 @@ -687,9 +685,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 @@ -698,9 +693,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 @@ -966,6 +983,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") { @@ -976,6 +1024,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/Cargo.lock b/Cargo.lock index acc234f64..7878061d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4824,6 +4824,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 3c31102f8..ef4145420 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -39,6 +39,9 @@ regex = "1" semver = "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..ab767a512 --- /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, 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/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 14253c351..6623e85aa 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; @@ -323,7 +323,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 } @@ -363,8 +363,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", @@ -378,7 +398,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 management = self.publish_external_observation(observation); + if let Some(audit) = &audit { + let path = Path::new(&management.configured_path); + audit.failed_at(operation, path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the configured policy changed after the supplied store token was observed", @@ -387,6 +411,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, @@ -397,6 +428,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", @@ -413,6 +451,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", @@ -420,6 +465,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", @@ -427,21 +479,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 @@ -456,13 +543,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 management = self.publish_external_observation(current); + if let Some(audit) = &audit { + let path = Path::new(&management.configured_path); + audit.failed_at(operation, 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", @@ -475,12 +573,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 management = self.publish_external_observation(current); + if let Some(audit) = &audit { + let path = Path::new(&management.configured_path); + audit.failed_at(operation, path, crate::audit::FailureReason::StaleStoreToken); + } return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed during publication; retry with the current store token", @@ -493,7 +602,11 @@ impl PolicyStore { "Published policy failed authoritative reload" ); let (_, current) = self.observe_storage(false); - let management = self.publish_observation(current); + let management = self.publish_external_observation(current); + if let Some(audit) = &audit { + let path = Path::new(&management.configured_path); + audit.failed_at(operation, path, crate::audit::FailureReason::ActivationFailed); + } return Err(error_with_management( ErrorCode::PolicyActivationFailed, "the policy was published but failed authoritative reload", @@ -502,6 +615,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, @@ -515,6 +631,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, @@ -537,6 +665,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)); @@ -877,6 +1025,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; struct DefaultTransitionStorage { managed: PathBuf, @@ -981,8 +1130,15 @@ mod storage_tests { } } - #[tokio::test] + 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(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")), @@ -990,9 +1146,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!( @@ -1007,6 +1167,61 @@ 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)] + ); + let events = recorder.events(); + assert_eq!( + events.iter().map(|entry| entry.event_code).collect::>(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + 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 request = update_request(&store); + let (audit, recorder) = recording_audit(); + + let success = store + .replace_audited(request, audit) + .await + .expect("replacement succeeds"); + + assert_eq!(success.policy.metadata.revision, 2); + assert_eq!( + store.active_policy().expect("replacement is active").metadata.revision, + 2 + ); + assert!(matches!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>() + .as_slice(), + [ + Some(sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(sysevent_codes::POLICY_CHANGE_SUCCEEDED) + ] + )); } #[tokio::test] @@ -1119,7 +1334,7 @@ mod storage_tests { assert_eq!(store.watched_paths(), [replacement_canonical]); } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn default_store_switches_from_legacy_when_managed_policy_appears() { let dir = tempfile::tempdir().expect("create temp directory"); let managed = dir.path().join("PackageBroker").join(windows::POLICY_FILE_NAME); @@ -1141,6 +1356,7 @@ mod storage_tests { std::fs::create_dir_all(managed.parent().expect("managed path has a parent")) .expect("create managed directory"); std::fs::write(&managed, b"managed").expect("write managed marker"); + crate::audit::take_test_events(); store.reload_from_disk(ReloadCause::ExternalChange).await; assert_eq!( @@ -1151,6 +1367,10 @@ mod storage_tests { store.management_snapshot().configured_path, managed.display().to_string() ); + assert_eq!( + crate::audit::take_test_events()[0].event_code, + Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) + ); std::fs::remove_file(&managed).expect("remove managed marker"); *storage.managed_policy.write() = None; @@ -1160,6 +1380,10 @@ mod storage_tests { store.management_snapshot().configured_path, managed.display().to_string() ); + assert_eq!( + crate::audit::take_test_events()[0].event_code, + Some(sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + ); } #[tokio::test] diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index e7724f06f..0f2a5fd6c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1,6 +1,7 @@ //! Runtime implementation of the shared NOW package broker server facade. use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -43,6 +44,7 @@ use responses::{ 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. @@ -121,6 +123,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") @@ -130,6 +136,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, @@ -140,7 +149,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 } @@ -209,7 +223,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", @@ -217,7 +235,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 + ); + } + } +} 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] +} diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index b8d9ad669..96da29750 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,80 @@ 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); + } + } + } + + 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..93b484b13 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,42 @@ 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); - } + 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 ; ======================================================================