Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.

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
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
22 changes: 12 additions & 10 deletions src/host/plm/readme.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# PLM β€” Permissive Learning Mode

`plm.exe` is the Windows-only trace driver for permissive learning mode. Long-form, it captures the access-denied events emitted by Windows' permissive sandbox layer, decodes them into structured findings, and merges those findings back into an MXC container config so the next enforcing run succeeds.
`plm.exe` is the Windows-only legacy WPR trace helper for Learning Mode. It captures both `learningModeLogging` block events and `permissiveLearningMode` allow events, then delegates ETL decoding to the same canonical `learning_mode_windows::EtlDenialAnalyzer` used by `captureDenials`.

This PR introduces **capability extraction**: `EventID=14` DACL ACE blobs are decoded into AppContainer capability names via `extract_caps`, and those names are merged into `processContainer.capabilities`. UI relaxation arrives in a subsequent PR.
The canonical analyzer decodes filesystem, capability, registry, and UI findings from both provider shapes. The standalone `extract-caps` command remains available only as a low-level ACE diagnostic.

PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mode-permissive-learning-mode); the standalone CLI documented here is for capturing traces, interactive iteration, and debugging the parser itself.

## How it works

1. **Capture** β€” `plm start` calls `wpr -start <plm.wprp>!AccessFailureProfile -filemode`, enabling the `Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode` and `Microsoft-Windows-Kernel-General` ETW providers in a secure realtime collector.
2. **Run** β€” the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied.
3. **Stop** β€” `plm stop` calls `wpr -stop <trace.etl>` and walks the `.etl` with `EvtQuery` / `EvtRender`.
4. **Parse** β€” for each `EventID=14`, the parser pulls the file path / access mask and decodes the DACL ACE blob into AppContainer capability names. `EventID=27` UI relaxation lands in a later PR.
5. **Merge** β€” file paths are added to `filesystem.readwritePaths` / `filesystem.readonlyPaths`; capability names are added to `processContainer.capabilities` (deduplicated case-insensitively against any capabilities already authored there, then sorted); results are written as `Adjusted_<name>.json` next to the captured trace.
3. **Stop** β€” `plm stop` calls `wpr -stop <trace.etl>` and analyzes the sealed ETL through `EtlDenialAnalyzer`.
4. **Emit** β€” canonical findings are written to `denials.json` in the log directory, and a one-line JSON result reports the trace, denials, and optional adjusted-config paths.
5. **Merge (temporary compatibility)** β€” file and capability denials are adapted into the existing adjusted-config generator until the shared regeneration engine replaces it.

> **Capability merge caveats.** Capabilities are only merged into a `processContainer` block β€” backends that cannot express AppContainer capabilities (LXC, Windows Sandbox, …) are left untouched and the discovered set is reported on stderr instead. The reserved names `learningModeLogging` and `permissiveLearningMode` are never written back, because `processContainer.capabilities` rejects them.

Expand All @@ -24,8 +24,7 @@ PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mo
| `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` |
| `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`) + parse + FS/capability merge |
| `src/log.rs` | Interactive mode: Enter to start, Enter to stop, then diff vs a blank config |
| `src/event_parser.rs` | `EvtQuery` / `EvtRender` walk; shared `ParseAccumulator` + per-event dispatcher |
| `src/access_failure.rs` | `EventID=14` decoder: file-path normalization, post-XPath filters, ACE blob -> capabilities |
| `src/analysis.rs` | Canonical ETL analysis, denials JSON emission, and temporary config-generator adapter |
| `src/access_event.rs` | `LearningModeAccessEvent` plain struct |
| `src/extract_caps.rs` | DACL ACE blob decoder; resolves capability SIDs via `DeriveCapabilitySidsFromName` |
| `src/config.rs` | JSON load/mutate; FS + capability merge into containment-backend section |
Expand Down Expand Up @@ -53,10 +52,13 @@ Stops the active trace (or accepts a previously captured one).

```powershell
plm.exe stop [--config-path <path>] [--log-dir <path>] [--bin-path <path>]
[--trace-file <path>] [--verbose-logging]
[--trace-file <path> | --trace-output <path>]
[--exit-code <code>] [--verbose-logging]
```

`--config-path` drives an in-memory merge of discovered file paths and capabilities against the input config and persists the result as `Adjusted_<name>.json` in the log directory. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it to an arbitrary path, because `plm.exe` runs elevated and an operator-named output path would be an admin-privileged arbitrary-write primitive. The write is atomic (temp file in the same directory, then rename over the destination) so a downstream enforcing run never observes a truncated policy.
`--trace-output` selects the exact ETL destination passed to `wpr -stop`; it cannot be combined with `--trace-file`, which re-processes an existing ETL. `--exit-code` is copied into the canonical `denials.json` summary.

`--config-path` temporarily preserves the existing adjusted-config behavior. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it independently. The write is atomic so a downstream enforcing run never observes a truncated policy.

### `plm extract-caps`

Expand Down Expand Up @@ -93,7 +95,7 @@ The WPR profile is embedded into `plm.exe` itself (see `src/profile_gen.rs`); on

- **Windows-only.** Uses `wpr.exe` and Job-Object UI-limit semantics that have no portable equivalent.
- **Deny matching is enforced on literal, lexically-normalized paths only.** `config::normalize_path` strips verbatim/device prefixes, lowercases, collapses separators, and rejects ADS / `.` / `..`, but it is filesystem-free and does **not** resolve directory junctions, symlinks/reparse points, or 8.3 short names. 8.3 short-name aliases of a denied directory are detected lexically and refused promotion (fail-closed), but a junction/symlink alias (e.g. `C:\work\link` β†’ `C:\Secrets`) is a lexically distinct path that will **not** match a deny entry and can therefore be promoted into the persisted `Adjusted_*.json`. Operators must deny the canonical target path; aliasing the target through a reparse point is a known gap. See the deny-matching code in `src/config.rs`.
- **No UI extraction yet.** `plm stop` writes `Adjusted_<name>.json` with the discovered file paths and AppContainer capabilities. UI-policy extraction (`EventID=27`) arrives in a subsequent PR.
- The compatibility adjusted-config generator consumes file and capability denials only. UI regeneration moves to the shared opt-in regeneration engine; UI denials are already present in `denials.json`.

## See also

Expand Down
Loading
Loading