diff --git a/.gitattributes b/.gitattributes index 3eb52bd81..6e1660656 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ +/.gitattributes text eol=lf + *.rs text eol=lf *.toml text eol=lf *.cs text eol=lf @@ -6,6 +8,7 @@ *.sln text eol=crlf *.md text eol=lf *.mustache text eol=lf +*.yml text eol=lf *.yaml text eol=lf devolutions-gateway/openapi/doc/index.adoc linguist-generated merge=binary @@ -14,3 +17,6 @@ devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binar devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary + +# Sample assets produce huge LoC counts; exclude them from language statistics. +devolutions-agent/src/broker/assets/** linguist-generated diff --git a/Cargo.lock b/Cargo.lock index 560224325..9a3a1e894 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1576,6 +1585,29 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2 1.0.106", + "quote 1.0.45", + "rustc_version", + "syn 2.0.117", + "unicode-xid 0.2.6", +] + [[package]] name = "des" version = "0.9.0-rc.1" @@ -1593,11 +1625,13 @@ dependencies = [ "anyhow", "async-trait", "aws-lc-rs", + "axum 0.8.9", "backoff", "base64 0.22.1", "bytes 1.11.1", "camino", "ceviche", + "chrono", "ctrlc", "devolutions-agent-shared", "devolutions-gateway-task", @@ -1609,9 +1643,15 @@ dependencies = [ "hex", "hostname 0.4.2", "http-client-proxy", + "hyper 1.9.0", + "hyper-util", "ipnetwork", "ironrdp", + "notify 7.0.0", "notify-debouncer-mini", + "now-policy", + "now-policy-api", + "now-policy-server-template", "parking_lot", "prost 0.13.5", "prost-types", @@ -1619,12 +1659,14 @@ dependencies = [ "quinn", "rand 0.8.6", "rcgen", + "regex", "reqwest", "rustls 0.23.41", "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", + "serde_yaml", "sha2 0.10.9", "tap", "tempfile", @@ -1636,6 +1678,7 @@ dependencies = [ "tokio-tungstenite 0.26.2", "tonic 0.12.3", "tonic-build", + "tower-service", "tracing", "url", "uuid", @@ -2303,6 +2346,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3277,6 +3330,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + [[package]] name = "inotify" version = "0.11.1" @@ -4631,6 +4695,24 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify" version = "8.2.0" @@ -4639,12 +4721,12 @@ checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.11.1", "fsevent-sys", - "inotify", + "inotify 0.11.1", "kqueue", "libc", "log", "mio", - "notify-types", + "notify-types 2.1.0", "walkdir", "windows-sys 0.60.2", ] @@ -4656,11 +4738,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8" dependencies = [ "log", - "notify", - "notify-types", + "notify 8.2.0", + "notify-types 2.1.0", "tempfile", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "notify-types" version = "2.1.0" @@ -4670,6 +4761,57 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "now-policy" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d87d7ba63d8fcbbae125f975c5e30a54ed4507027dd70d6daa0aa63003cac2e" +dependencies = [ + "chrono", + "schemars", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "now-policy-api" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3837290f7ed48038a8ca4d9dfb98a0c44a5debe141cdd7d740d59eca4df4fa5" +dependencies = [ + "base64 0.22.1", + "chrono", + "derive_more", + "now-policy", + "schemars", + "semver", + "serde", + "serde_json", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "now-policy-server-template" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92afa468bf18b66af5d209caac39fcff241b07495c538d032221736e8a7f95c6" +dependencies = [ + "aide", + "async-trait", + "axum 0.8.9", + "now-policy", + "now-policy-api", + "schemars", + "serde", + "serde_json", + "serde_yaml", +] + [[package]] name = "now-proto-pdu" version = "0.4.3" @@ -7098,6 +7240,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8272,6 +8435,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-xid" version = "0.1.0" diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index 3e19a8017..50f87dc0d 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -16,10 +16,12 @@ agent-tunnel-proto = { path = "../crates/agent-tunnel-proto" } anyhow = "1" backoff = "0.4" async-trait = "0.1" +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "original-uri", "matched-path"] } base64 = "0.22" bytes = "1" camino = { version = "1.1", features = ["serde1"] } ceviche = "0.7" +chrono = { version = "0.4", features = ["serde"] } ctrlc = "3.5" devolutions-agent-shared = { path = "../crates/devolutions-agent-shared" } devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } @@ -27,14 +29,21 @@ devolutions-log = { path = "../crates/devolutions-log" } futures = "0.3" hex = "0.4" hostname = "0.4" +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "service"] } http-client-proxy = { path = "../crates/http-client-proxy" } ipnetwork = "0.20" +notify = { version = "7", default-features = false, features = ["macos_kqueue"] } +now-policy = "0.1" +now-policy-api = { version = "0.1", features = ["policy-compat"] } +now-policy-server-template = { version = "0.1", features = ["policy-compat"] } parking_lot = "0.12" prost = "0.13" prost-types = "0.13" quinn = "0.11" rand = "0.8" # FIXME(@CBenoit): maybe we don't need this crate rcgen = { version = "0.13", features = ["pem"] } +regex = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "http2", "socks", "json"] } rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } rustls-pemfile = "2.2" @@ -42,12 +51,14 @@ rustls-pki-types = "1" sha2 = "0.10" serde_json = "1" serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" tap = "1.0" tempfile = "3" tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "ring"] } tokio-stream = "0.1" tonic = { version = "0.12", features = ["transport"] } +tower-service = "0.3" tracing = "0.1" url = { version = "2.5", features = ["serde"] } x509-parser = "0.16" @@ -74,6 +85,7 @@ features = [ "fs", "process", "sync", + "time", ] [target.'cfg(windows)'.dependencies] @@ -97,6 +109,7 @@ features = [ "Win32_Security_Cryptography", "Win32_Security_Authorization", "Win32_System_ApplicationInstallationAndServicing", + "Win32_System_Pipes", "Win32_System_RemoteDesktop", ] diff --git a/devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json b/devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json new file mode 100644 index 000000000..55827e552 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.standard-allowlist", + "Publisher": "Contoso IT", + "Revision": 4, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Fail-closed policy for standard workstation package installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Enabled": true, + "Priority": 10, + "Decision": "Deny", + "Reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.custom-parameters", + "Enabled": true, + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Enabled": true, + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "allow.winget.vscode", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "Visual Studio Code is approved for managed workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.powertoys", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is approved for developer workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json b/devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json new file mode 100644 index 000000000..76197cb01 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.deny-risky-options", + "Publisher": "Contoso IT", + "Revision": 2, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Default-allow policy that blocks risky broker request options." + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Priority": 10, + "Decision": "Deny", + "Reason": "Do not broker installs that skip WinGet hash checks or PowerShell publisher checks.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.manager-custom-parameters", + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters require a dedicated exception policy.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 40, + "Decision": "Deny", + "Reason": "Killing processes before a brokered package operation is not allowed by this policy.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "deny.unapproved-winget-source", + "Priority": 50, + "Decision": "Deny", + "Reason": "Only the default WinGet source is accepted by this deny-list sample.", + "Match": { + "Managers": [ + "Winget" + ], + "Sources": [ + "msstore", + "winget-fonts" + ] + } + } + ] +} \ No newline at end of file diff --git a/devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json b/devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json new file mode 100644 index 000000000..5396eaa11 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json @@ -0,0 +1,31 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-unknown-install", + "CreatedAt": "2026-05-05T12:05:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Example.UnapprovedTool", + "Architecture": "X64" + }, + "Options": { + "Scope": "User", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Standard", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json new file mode 100644 index 000000000..479ea486a --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json @@ -0,0 +1,32 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-vscode-install", + "CreatedAt": "2026-05-05T12:00:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Microsoft.VisualStudioCode", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + }, + "IncludeCommandPreview": true +} diff --git a/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json new file mode 100644 index 000000000..e180ad2cf --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json @@ -0,0 +1,31 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-vscode-skiphash", + "CreatedAt": "2026-05-05T12:10:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Microsoft.VisualStudioCode", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": true, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json b/devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json new file mode 100644 index 000000000..c8ba84719 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json @@ -0,0 +1,38 @@ +{ + "ScenarioSet": "baseline", + "Description": "Broad end-to-end scenarios for the gateway-owned package broker policy flow.", + "Scenarios": [ + { + "Id": "baseline.winget.vscode.allow", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-vscode-install.request.json", + "ExpectedDecision": "Allow", + "ExpectedRuleId": "allow.winget.vscode", + "Tags": ["baseline", "winget", "allowlist"] + }, + { + "Id": "baseline.winget.unknown.default-deny", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-unknown-install.request.json", + "ExpectedDecision": "Deny", + "ExpectedRuleId": "", + "Tags": ["baseline", "winget", "default-deny"] + }, + { + "Id": "baseline.winget.skiphash.deny", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-vscode-skiphash.request.json", + "ExpectedDecision": "Deny", + "ExpectedRuleId": "deny.integrity-bypass", + "Tags": ["baseline", "winget", "risky-options"] + }, + { + "Id": "baseline.default-allow.normal-install", + "Policy": "deny-risky-options.policy.json", + "Request": "requests/winget-vscode-install.request.json", + "ExpectedDecision": "Allow", + "ExpectedRuleId": "", + "Tags": ["baseline", "winget", "default-allow"] + } + ] +} \ No newline at end of file diff --git a/devolutions-agent/src/broker/auth.rs b/devolutions-agent/src/broker/auth.rs new file mode 100644 index 000000000..23edf95b1 --- /dev/null +++ b/devolutions-agent/src/broker/auth.rs @@ -0,0 +1,184 @@ +//! Package broker pipe client authentication. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, bail}; +use now_policy_api::PackageRequest; +use tokio::net::windows::named_pipe::NamedPipeServer; +use win_api_wrappers::process::Process; +use windows::Win32::Security::TOKEN_QUERY; +use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; + +use crate::code_signing::validate_devolutions_authenticode_signature; + +#[derive(Clone, Debug)] +pub(crate) struct PipeClient { + process_id: u32, + executable_path: PathBuf, + user: ClientUser, +} + +#[derive(Clone, Debug)] +struct ClientUser { + domain: String, + name: String, +} + +impl PipeClient { + pub(crate) fn from_connected_pipe(server: &NamedPipeServer) -> anyhow::Result { + let process_id = connected_pipe_client_process_id(server).context("failed to query pipe client process id")?; + let process = Process::get_by_pid(process_id, PROCESS_QUERY_LIMITED_INFORMATION) + .with_context(|| format!("failed to open pipe client process {process_id}"))?; + let executable_path = process + .exe_path() + .with_context(|| format!("failed to query pipe client process {process_id} executable path"))?; + let sid = process + .token(TOKEN_QUERY) + .with_context(|| format!("failed to open pipe client process {process_id} token"))? + .sid_and_attributes() + .with_context(|| format!("failed to query pipe client process {process_id} token user"))? + .sid; + let account = sid + .lookup_account(None) + .with_context(|| format!("failed to resolve pipe client process {process_id} user"))?; + let user = ClientUser { + domain: account.domain_name.to_string_lossy(), + name: account.name.to_string_lossy(), + }; + + Ok(Self { + process_id, + executable_path, + user, + }) + } + + pub(crate) fn validate_request( + &self, + request: &PackageRequest, + skip_signature_validation: bool, + ) -> anyhow::Result<()> { + self.validate_effective_user(&request.client.effective_user)?; + self.validate_executable_path(&request.client.client_executable_path)?; + + if skip_signature_validation { + tracing::warn!("DEBUG MODE: Skipping package broker client signature validation"); + return Ok(()); + } + + let thumbprint = validate_devolutions_authenticode_signature(&self.executable_path)?; + + tracing::debug!( + process_id = self.process_id, + executable = %self.executable_path.display(), + certificate_thumbprint = %thumbprint, + "Package broker pipe client authenticated" + ); + + Ok(()) + } + + fn validate_effective_user(&self, effective_user: &str) -> anyhow::Result<()> { + if same_user(effective_user, &self.user) { + return Ok(()); + } + + bail!( + "pipe client user '{}\\{}' does not match request effective_user '{}'", + self.user.domain, + self.user.name, + effective_user + ) + } + + fn validate_executable_path(&self, requested_executable_path: &str) -> anyhow::Result<()> { + let requested_path = Path::new(requested_executable_path); + if !requested_path.is_absolute() { + bail!("request client executable path is not absolute"); + } + + let actual_path = canonicalize_for_comparison(&self.executable_path).with_context(|| { + format!( + "failed to canonicalize pipe client executable path '{}'", + self.executable_path.display() + ) + })?; + let requested_path = canonicalize_for_comparison(requested_path).with_context(|| { + format!( + "failed to canonicalize request client executable path '{}'", + requested_executable_path + ) + })?; + + if same_windows_path(&actual_path, &requested_path) { + return Ok(()); + } + + bail!( + "pipe client executable '{}' does not match request client executable '{}'", + actual_path.display(), + requested_path.display() + ) + } +} + +fn connected_pipe_client_process_id(server: &NamedPipeServer) -> anyhow::Result { + use std::os::windows::io::AsRawHandle as _; + + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Pipes::GetNamedPipeClientProcessId; + + let mut process_id = 0u32; + let handle = HANDLE(server.as_raw_handle()); + + // SAFETY: `server` is a connected named-pipe server instance and the process id + // output pointer is valid for the duration of the call. + unsafe { GetNamedPipeClientProcessId(handle, &mut process_id) }?; + + Ok(process_id) +} + +fn same_user(expected: &str, actual: &ClientUser) -> bool { + let Some((expected_domain, expected_name)) = expected.rsplit_once('\\') else { + return expected.eq_ignore_ascii_case(&actual.name); + }; + + expected_domain.eq_ignore_ascii_case(&actual.domain) && expected_name.eq_ignore_ascii_case(&actual.name) +} + +fn canonicalize_for_comparison(path: &Path) -> anyhow::Result { + Ok(std::fs::canonicalize(path)?) +} + +fn same_windows_path(left: &Path, right: &Path) -> bool { + left.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client_user() -> ClientUser { + ClientUser { + domain: "CONTOSO".to_owned(), + name: "alice".to_owned(), + } + } + + #[test] + fn same_user_matches_domain_qualified_user() { + assert!(same_user("contoso\\ALICE", &client_user())); + } + + #[test] + fn same_user_matches_unqualified_user() { + assert!(same_user("ALICE", &client_user())); + } + + #[test] + fn same_user_rejects_wrong_domain() { + assert!(!same_user("FABRIKAM\\alice", &client_user())); + } +} diff --git a/devolutions-agent/src/broker/command_builder/mod.rs b/devolutions-agent/src/broker/command_builder/mod.rs new file mode 100644 index 000000000..f50bd0a00 --- /dev/null +++ b/devolutions-agent/src/broker/command_builder/mod.rs @@ -0,0 +1,38 @@ +//! Command-line builders for package manager operations. +//! +//! Constructs the commands the broker would execute from validated request fields. +//! The broker never executes client-supplied commands directly. + +pub mod powershell; +pub mod winget; + +use now_policy_api::{ManagerName, PackageRequest}; + +/// Build a command line from a validated request, dispatching to the appropriate +/// package manager builder. +/// +/// Returns the command as a list of arguments (first element is the executable). +pub fn build_command(request: &PackageRequest) -> Vec { + match request.manager { + ManagerName::Winget => winget::build_winget_command(request), + ManagerName::PowerShell => powershell::build_powershell5_command(request), + ManagerName::PowerShell7 => powershell::build_powershell7_command(request), + } +} + +/// Append `--flag value` to command if value is `Some` and non-empty. +pub(crate) fn set_if_specified(command: &mut Vec, flag: &str, value: Option<&str>) { + if let Some(v) = value + && !v.is_empty() + { + command.push(flag.to_owned()); + command.push(v.to_owned()); + } +} + +/// Append `--flag` to command if value is true. +pub(crate) fn set_if_true(command: &mut Vec, flag: &str, value: bool) { + if value { + command.push(flag.to_owned()); + } +} diff --git a/devolutions-agent/src/broker/command_builder/powershell.rs b/devolutions-agent/src/broker/command_builder/powershell.rs new file mode 100644 index 000000000..8b0d313c5 --- /dev/null +++ b/devolutions-agent/src/broker/command_builder/powershell.rs @@ -0,0 +1,285 @@ +//! PowerShell command-line builders for WinPS 5.x and PowerShell 7.x. +//! +//! These mirror package broker client PowerShell manager helpers so the broker runs the +//! same command the unelevated client would have run: +//! - PowerShell 5 uses PowerShellGet (`Install-Module`/`Update-Module`/`Uninstall-Module`) +//! invoked as `powershell.exe -NoProfile -Command