Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 96 additions & 9 deletions src/backends/learning_mode/windows/src/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ pub fn build_denial_from_access_check(
.map(|v| v.trim_matches('"').to_string())
.filter(|name| !name.is_empty())?;

if resource_type == ResourceType::File {
let app_path = find_prop(&parts.props, "AppPath")
.or_else(|| find_prop(&parts.props, "ApplicationPath"))
.map(|value| value.trim_matches('"'));
if app_path.is_some_and(|app_path| is_self_access(&object_name, app_path)) {
return None;
}
}

let access_type = if resource_type == ResourceType::Capability {
// Capability checks report a mask (often 0x1) that is not a
// read/write/execute verb, so don't run the file/registry
Expand All @@ -202,6 +211,48 @@ pub fn build_denial_from_access_check(
})
}

fn is_self_access(object_name: &str, app_path: &str) -> bool {
let object_name = strip_dos_namespace_prefix(object_name);
let app_path = strip_dos_namespace_prefix(app_path);
match (
volume_relative_path(object_name),
volume_relative_path(app_path),
) {
(Some(object_relative), Some(app_relative)) => {
!object_relative.is_empty() && object_relative.eq_ignore_ascii_case(app_relative)
}
_ => false,
}
}

fn strip_dos_namespace_prefix(path: &str) -> &str {
for prefix in [r"\??\", r"\\?\", r"\\.\"] {
if let Some(path) = path.strip_prefix(prefix) {
return path;
}
}
path
}

fn volume_relative_path(path: &str) -> Option<&str> {
let bytes = path.as_bytes();
if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
return path.get(2..);
}

const VOLUME_PREFIX: &str = r"\Device\HarddiskVolume";
if path
.get(..VOLUME_PREFIX.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(VOLUME_PREFIX))
{
return path
.get(VOLUME_PREFIX.len()..)?
.find('\\')
.and_then(|separator| path.get(VOLUME_PREFIX.len() + separator..));
}
None
}

/// Builds a [`RawDenial`] from a `LearningModeViolation` (event 27) payload.
///
/// These represent UI-surface denials. `Category` identifies the class and
Expand Down Expand Up @@ -323,7 +374,6 @@ fn parse_u32(raw: &str) -> Option<u32> {
fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType {
// Standard rights (object-type independent).
const DELETE: u32 = 0x0001_0000;
const READ_CONTROL: u32 = 0x0002_0000;
const WRITE_DAC: u32 = 0x0004_0000;
const WRITE_OWNER: u32 = 0x0008_0000;
// Generic rights (object-type independent).
Expand All @@ -343,12 +393,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType {
const KEY_NOTIFY: u32 = 0x0010;
const KEY_CREATE_LINK: u32 = 0x0020;
(
KEY_QUERY_VALUE
| KEY_ENUMERATE_SUB_KEYS
| KEY_NOTIFY
| READ_CONTROL
| GENERIC_READ
| GENERIC_EXECUTE,
KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY | GENERIC_READ | GENERIC_EXECUTE,
KEY_SET_VALUE
| KEY_CREATE_SUB_KEY
| KEY_CREATE_LINK
Expand All @@ -370,7 +415,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType {
const FILE_READ_ATTRIBUTES: u32 = 0x0080;
const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
(
FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES | READ_CONTROL | GENERIC_READ,
FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES | GENERIC_READ,
FILE_WRITE_DATA
| FILE_APPEND_DATA
| FILE_WRITE_EA
Expand Down Expand Up @@ -484,6 +529,39 @@ mod tests {
assert_eq!(ev.access_type, AccessType::Write);
}

#[test]
fn access_check_drops_workload_self_access() {
for app_path in [
r#""\Device\HarddiskVolume3\Tools\app.exe""#,
r#""C:\Tools\app.exe""#,
] {
let p = parts(
14,
&[
("ObjectType", "\"File\""),
("ObjectName", r#""\??\C:\Tools\App.EXE""#),
("AppPath", app_path),
("AccessMask", "0x1"),
],
);
assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none());
}
}

#[test]
fn access_check_keeps_same_name_at_different_path() {
let p = parts(
14,
&[
("ObjectType", "\"File\""),
("ObjectName", r#""C:\app.exe""#),
("AppPath", r#""\Device\HarddiskVolume3\Tools\app.exe""#),
("AccessMask", "0x1"),
],
);
assert!(extract_denial(&p, 1, FIXED_FILETIME).is_some());
}

#[test]
fn access_check_key_denial_uses_registry_vocabulary() {
let p = parts(
Expand Down Expand Up @@ -790,7 +868,12 @@ mod tests {

#[test]
fn file_mask_no_recognised_right_is_unknown() {
// SYNCHRONIZE (0x100000) alone and MAXIMUM_ALLOWED (0x02000000) alone.
// READ_CONTROL, SYNCHRONIZE, and MAXIMUM_ALLOWED alone grant no
// file-content access and must not become readonly recommendations.
assert_eq!(
access_type_from_mask(0x0002_0000, false),
AccessType::Unknown
);
assert_eq!(
access_type_from_mask(0x0010_0000, false),
AccessType::Unknown
Expand All @@ -815,5 +898,9 @@ mod tests {
assert_eq!(access_type_from_mask(0x0020, true), AccessType::Write); // KEY_CREATE_LINK (execute for files!)
// Registry has no execute concept: 0x20 is a write here, not execute.
assert_ne!(access_type_from_mask(0x0020, true), AccessType::Execute);
assert_eq!(
access_type_from_mask(0x0002_0000, true),
AccessType::Unknown
);
}
}
38 changes: 33 additions & 5 deletions src/core/wxc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,21 @@ fn config_file_path(cli: &Cli) -> Option<std::path::PathBuf> {
.map(std::path::PathBuf::from)
}

#[cfg(target_os = "windows")]
fn audit_stop_args(
config_path: Option<&std::path::Path>,
exit_code: i32,
) -> Vec<std::ffi::OsString> {
let mut args = vec![std::ffi::OsString::from("stop")];
if let Some(config_path) = config_path {
args.push(std::ffi::OsString::from("--config-path"));
args.push(config_path.as_os_str().to_owned());
}
args.push(std::ffi::OsString::from("--exit-code"));
args.push(std::ffi::OsString::from(exit_code.to_string()));
args
}

#[cfg(target_os = "windows")]
use audit::{
cancel_active_audit_trace, mark_audit_active, release_audit_singleton, run_plm_command,
Expand Down Expand Up @@ -1313,11 +1328,7 @@ fn main() {
// `Drop` runs `wpr -cancel` for us.
#[cfg(target_os = "windows")]
if cli.audit {
let mut stop_args: Vec<std::ffi::OsString> = vec![std::ffi::OsString::from("stop")];
if let Some(cfg) = audit_config_file.as_ref() {
stop_args.push(std::ffi::OsString::from("--config-path"));
stop_args.push(cfg.clone().into_os_string());
}
let stop_args = audit_stop_args(audit_config_file.as_deref(), response.exit_code);
let borrowed: Vec<&std::ffi::OsStr> = stop_args
.iter()
.map(std::ffi::OsString::as_os_str)
Expand Down Expand Up @@ -1490,6 +1501,23 @@ mod tests {
}
}

#[cfg(target_os = "windows")]
#[test]
fn audit_stop_args_include_workload_exit_code() {
let args = audit_stop_args(Some(std::path::Path::new(r"C:\config.json")), 23);
assert_eq!(
args,
[
"stop",
"--config-path",
r"C:\config.json",
"--exit-code",
"23"
]
.map(std::ffi::OsString::from)
);
}

#[test]
fn state_aware_dispatch_errors_use_only_auxiliary_diagnostic_sinks() {
let directory = tempfile::tempdir().unwrap();
Expand Down
45 changes: 40 additions & 5 deletions src/core/wxc_common/src/filesystem_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

use crate::logger::Logger;
use crate::models::ContainerPolicy;
use std::path::Path;

/// Intent class for a policy path, ordered least β†’ most restrictive so that
/// `max()` yields the strictest intent in a group of aliases.
Expand Down Expand Up @@ -94,6 +95,17 @@ enum PathResolution {
Unknown,
}

/// Result of comparing two paths by filesystem-object identity.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExistingObjectComparison {
/// Both paths resolved to the same object.
Same,
/// At least one path is absent, or both resolved to different objects.
Different,
/// At least one existing or potentially existing path could not be examined.
Unknown,
}

/// Resolve a path to its filesystem-object identity, following symlinks so two
/// names for the same target collide.
///
Expand All @@ -102,7 +114,7 @@ enum PathResolution {
/// ([`PathResolution::Unknown`]), so the caller can fail closed on the latter
/// without rejecting the common "path created at mount time" case.
#[cfg(unix)]
fn resolve_object(path: &str) -> PathResolution {
fn resolve_object(path: &Path) -> PathResolution {
use std::os::unix::fs::MetadataExt;
// `metadata` follows symlinks, giving the target object's identity.
match std::fs::metadata(path) {
Expand All @@ -121,7 +133,8 @@ fn resolve_object(path: &str) -> PathResolution {
}

#[cfg(windows)]
fn resolve_object(path: &str) -> PathResolution {
fn resolve_object(path: &Path) -> PathResolution {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{
CloseHandle, GetLastError, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND,
Expand All @@ -132,7 +145,11 @@ fn resolve_object(path: &str) -> PathResolution {
OPEN_EXISTING,
};

let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;

// FILE_READ_ATTRIBUTES is the minimum access GetFileInformationByHandleEx
Expand Down Expand Up @@ -192,12 +209,30 @@ fn resolve_object(path: &str) -> PathResolution {
}

#[cfg(not(any(unix, windows)))]
fn resolve_object(_path: &str) -> PathResolution {
fn resolve_object(_path: &Path) -> PathResolution {
// No way to determine object identity on unsupported platforms; treat as
// unexaminable so the fail-closed path applies when deniedPaths are present.
PathResolution::Unknown
}

/// Compare two paths by filesystem-object identity.
pub fn compare_existing_filesystem_objects(a: &Path, b: &Path) -> ExistingObjectComparison {
match (resolve_object(a), resolve_object(b)) {
(PathResolution::Object(a), PathResolution::Object(b)) if a == b => {
ExistingObjectComparison::Same
}
(PathResolution::Absent, _) | (_, PathResolution::Absent) => {
ExistingObjectComparison::Different
}
(PathResolution::Unknown, _) | (_, PathResolution::Unknown) => {
ExistingObjectComparison::Unknown
}
(PathResolution::Object(_), PathResolution::Object(_)) => {
ExistingObjectComparison::Different
}
}
}

/// Detect cross-path object conflicts and return a tightened copy of `policy`.
///
/// For each set of policy paths that resolve to the same filesystem object but
Expand Down Expand Up @@ -254,7 +289,7 @@ pub fn normalize_object_conflicts(
let has_denied = !policy.denied_paths.is_empty();
let mut groups: HashMap<ObjectId, Vec<usize>> = HashMap::new();
for (i, (path, intent)) in entries.iter().enumerate() {
match resolve_object(path) {
match resolve_object(Path::new(path)) {
PathResolution::Object(id) => {
groups.entry(id).or_default().push(i);
}
Expand Down
11 changes: 7 additions & 4 deletions src/host/plm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ test = false
[dependencies]
clap.workspace = true
anyhow.workspace = true
# Portable deps (config / access_event / event_parser) must compile on
# every target so their unit tests run in cross-platform CI. The
# `windows` crate stays target-gated below.
# Portable config-generation dependencies must compile on every target so
# their unit tests run in cross-platform CI. Windows capture and analysis
# dependencies stay target-gated below.
serde_json.workspace = true
serde.workspace = true
chrono.workspace = true
quick-xml.workspace = true
tempfile.workspace = true

[target.'cfg(target_os = "windows")'.dependencies]
Expand All @@ -37,6 +37,8 @@ windows = { workspace = true, features = [
"Win32_System_Threading",
] }
wxc_common = { workspace = true }
learning_mode_core = { workspace = true }
learning_mode_windows = { workspace = true }

[build-dependencies]
mxc_build_common.workspace = true
Expand All @@ -46,3 +48,4 @@ embed-manifest = "1.4"

[dev-dependencies]
tempfile.workspace = true
quick-xml.workspace = true
Loading
Loading