From f2c5c630ffc80b4eb282fa932b614005e056e5a2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 18:04:08 -0700 Subject: [PATCH] fix(mxc): repair audit configuration and qualification runner Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 8 + crates/openshell-driver-mxc/README.md | 14 ++ .../examples/README-ocsf-audit.txt | 27 ++- .../examples/mxc-ocsf-audit.toml | 5 + .../examples/run-ocsf-audit.ps1 | 203 ++++++++++++++---- crates/openshell-driver-mxc/src/driver.rs | 35 ++- .../openshell-driver-mxc/src/etw_consumer.rs | 27 +++ .../tests/windows_demo_examples.rs | 71 ++++++ crates/openshell-server/src/config_file.rs | 12 ++ tasks/scripts/windows-msvc.ps1 | 31 ++- tasks/windows.toml | 5 + 11 files changed, 380 insertions(+), 58 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 33c850e81..367037f0d 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -544,3 +544,11 @@ The MSVC wrapper selects native test architecture from the machine environment, so an emulated SSH PowerShell does not incorrectly reject ARM64 qualification. Keep Windows kernel calls in the boundary library and retain shared supervisor unsafe-code bans, per-generation proxy authentication, and executable digests. + +Use `windows:test:mxc-audit` to validate the shipped audit gateway config with +its real parser, test JSONL coverage classification, and run the live seven-event +ETW qualification. Build native release binaries first and set +`OPENSHELL_WXC_EXEC_PATH`. The runner requires elevation or Performance Log Users +membership and leaves a results bundle under the target directory. Ordinary MXC +E2E success does not establish ETW audit coverage. Coverage must use structured +JSONL and fail when an expected event is absent or has the wrong OCSF class. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index a6c733933..e6b23b909 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -321,3 +321,17 @@ workaround has been established. Provider environment publications reject case-insensitive duplicate names, invalid environment entries, and names reserved for the six injected TLS trust variables before installing them. Rejected updates retain the prior revision. + +For live ETW audit coverage, set `OPENSHELL_WXC_EXEC_PATH` and run +`mise run --skip-tools windows:test:mxc-audit` after building native release +binaries. This requires elevation or Performance Log Users membership. It checks +the shipped config and coverage classifier before requiring all seven event types +in the durable JSONL output; the ordinary E2E scenarios do not check this path. + +On the ARM64 test host (Windows build 28120, MXC 1.0.0), Sandboxing ETW records +come from the shared Appinfo broker rather than the driver-owned executor. +The consumer cannot correlate these records using the executor's PID and process +generation, so live audit qualification currently fails even though the workload +completes. The executor diagnostic log redacts its container identity. A trusted +broker-to-launch correlation is needed; treating the shared broker PID as a +sandbox identity would misattribute concurrent sandboxes and is not supported. diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 51bedd26a..e9acdd1f9 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -12,7 +12,7 @@ WHAT THIS PROVES / PRODUCES The deliverable is the OCSF log: openshell-ocsf..log, one OCSF event object per line - the same schema and medium the Linux OpenShell pipeline - produces (Windows is at functional parity). + produces. OCSF classes you will see: [6002] Application Lifecycle - sandbox created @@ -22,7 +22,8 @@ WHAT THIS PROVES / PRODUCES PREREQUISITES (on this test box) - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) - - process_container backend live (it was for our earlier runs) + - process_container backend live + - OpenSSL on PATH to generate disposable supervisor signing keys - Run ELEVATED (Run as administrator) OR from an account in the 'Performance Log Users' group. Opening the real-time ETW session needs this; without it the run fails fast with a clear message. @@ -46,6 +47,9 @@ WHAT YOU GET BACK FILES IN THIS PACKAGE openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) openshell.exe the CLI + openshell-supervisor.exe host supervisor + openshell-windows-sandbox.exe sandbox runtime + libz3.dll matching native Z3 runtime mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true, egress proxy) ocsf-audit.yaml sandbox policy (read-write grant to the share dir) run-ocsf-audit.ps1 the orchestrator you run @@ -54,9 +58,6 @@ FILES IN THIS PACKAGE USEFUL OPTIONS -SandboxCount Create n sandboxes (default 2). More sandboxes = more events. - -NoProxy Skip the per-sandbox egress proxy. This omits ONLY the - SandboxProxyConfigured config event; everything else is - still produced. (Default is proxy ON for the full set.) -ShareDir Host folder granted read-write to the workload. The script derives a disposable policy and per-sandbox config for it. -WxcExecPath Path to wxc-exec.exe on this box. @@ -65,10 +66,10 @@ USEFUL OPTIONS -KeepRunning Leave the gateway running afterward for inspection. NOTES + - Disposable supervisor signing keys are protected outside the results bundle + and removed after the gateway stops (retained with -KeepRunning). - The control plane between CLI and gateway runs with --disable-tls on loopback; that is unrelated to the OCSF audit path this example exercises. - - A "supervisor session not connected" / ssh 255 message during sandbox create - is EXPECTED on MXC and harmless - the agent already ran in-driver. - The proxy path requires the host-side CONNECT proxy and an absolute agent binary (the packaged config uses C:\Windows\System32\cmd.exe); the run script handles this for you. @@ -78,3 +79,15 @@ NOTES - MXC process audit events record the executable basename only. Command-line arguments are omitted from OCSF JSON and shorthand because they can contain credentials, signed URLs, or PII. + +COVERAGE + All seven required ETW event types must be present in structured JSONL. + Console shorthand formatting does not affect coverage. The supervisor proxy + is always provisioned; an OS proxy-configuration event is not required. + +CURRENT QUALIFICATION LIMITATION + On the ARM64 Windows build 28120 / MXC 1.0.0 test host, the Sandboxing provider + emits from the shared Appinfo broker. The consumer's executor PID/generation + anchor cannot correlate those records, so this qualification remains FAIL. + Captured records are not sufficient: all seven attributed JSONL event types + are still required. Do not use the shared broker PID as a sandbox identity. diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index ac42b57fd..375d3f5a2 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -13,7 +13,12 @@ # disposable copy of this file. Workload command and # cwd are sandbox-scoped and passed separately through --driver-config-json. +[openshell] +version = 2 + [openshell.drivers.mxc] +# This disposable example supplies its workload through --driver-config-json. +allow_driver_config = true # Path to wxc-exec.exe on the box (patched by the run script; default is the # location observed on the MXC test boxes). wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 17e90fd7c..0e2f40d5f 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -53,6 +53,88 @@ $PSNativeCommandUseErrorActionPreference = $false try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} $OutputEncoding = [System.Text.Encoding]::UTF8 +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList, [int]$timeoutSeconds = 0) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { throw "failed to start $filePath" } + } catch { + $message = $_.Exception.Message + if ($message -match '(?i)Application Control policy has blocked this file') { + $sha256 = try { (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash } catch { "unavailable" } + $signature = try { (Get-AuthenticodeSignature -LiteralPath $filePath).Status } catch { "unavailable" } + throw "Application Control blocked CLI launch '$filePath' (SHA256=$sha256; Authenticode=$signature). Review CodeIntegrity/Operational event 3077 to identify the blocking policy, then deploy or allow an approved binary. Original error: $message" + } + throw + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if ($timeoutSeconds -gt 0 -and -not $process.WaitForExit($timeoutSeconds * 1000)) { + $process.Kill() + } + $process.WaitForExit() + + return @{ + ExitCode = $process.ExitCode + Output = @($stdout.Result, $stderr.Result) | Where-Object { $_ } + } +} + +# Coverage comes from structured ETW-origin event messages, independent of the +# console formatter. Class checks prevent unrelated log text from satisfying it. +function Get-MxcAuditCoverage([object[]] $Events) { + $rules = [ordered]@{ + "sandbox lifecycle (start)" = @(6002, "MXC sandbox '*' created with policy enforcement") + "OS policy enforced" = @(5019, "MXC sandbox OS policy enforced") + "OS policy configured" = @(5019, "MXC sandbox OS policy configured") + "win32k lockdown applied" = @(5019, "MXC sandbox win32k lockdown applied") + "UI restrictions applied" = @(5019, "MXC sandbox UI restrictions applied") + "console reference plumbed" = @(5019, "MXC sandbox console reference plumbed") + "process launch (executable identity)" = @(1007, "MXC sandbox launched process: *") + } + $coverage = [ordered]@{} + foreach ($name in $rules.Keys) { + $rule = $rules[$name] + $coverage[$name] = @($Events | Where-Object { + $_.class_uid -eq $rule[0] -and $_.message -like $rule[1] + }).Count -gt 0 + } + return $coverage +} + $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } # Results bundle (everything we hand back) ------------------------------------ @@ -192,8 +274,10 @@ $tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" $toml = Join-Path $resultDir "mxc-ocsf-audit.used.toml" # disposable patched copy (bundled) $helloPath = Join-Path $ShareDir "hello.txt" +$secretDir = Join-Path $here ('audit-keys-' + [guid]::NewGuid().ToString('N')) $gw = $null $gatewayEtwSessions = @() +$createdSandboxes = @() $passed = $true try { @@ -221,6 +305,21 @@ try { } Info "wxc-exec: $WxcExecPath" + # Supervisor sessions require gateway-minted JWTs. Keep disposable private + # keys outside the results bundle and restrict them to this test account. + $openssl = (Get-Command openssl -ErrorAction Stop).Source + New-Item -ItemType Directory $secretDir | Out-Null + $ownerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + & icacls.exe $secretDir /inheritance:r /grant:r "*$($ownerSid):(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'cannot protect disposable audit signing keys' } + foreach ($keyArgs in @( + @('genpkey', '-algorithm', 'ED25519', '-out', (Join-Path $secretDir 'signing.pem')), + @('pkey', '-in', (Join-Path $secretDir 'signing.pem'), '-pubout', '-out', (Join-Path $secretDir 'public.pem')) + )) { + if ((Invoke-NativeCaptured $openssl $keyArgs).ExitCode -ne 0) { throw 'audit signing-key generation failed' } + } + Set-Content (Join-Path $secretDir 'kid') -Value 'mxc-ocsf-audit' -Encoding ASCII + # 2. Patch the disposable gateway config and derive a sandbox policy whose # filesystem grant matches -ShareDir. Step "Prepare gateway config and sandbox policy (disposable copies)" @@ -233,6 +332,17 @@ try { } else { $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") } + $tomlText += @" + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = '$(Join-Path $secretDir 'signing.pem')' +public_key_path = '$(Join-Path $secretDir 'public.pem')' +kid_path = '$(Join-Path $secretDir 'kid')' +gateway_id = 'mxc-ocsf-audit' +"@ Set-Content $toml -Value $tomlText -Encoding UTF8 $shareDirPolicy = $ShareDir.Replace('\', '/') @@ -248,20 +358,15 @@ try { $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" if (-not (Test-Path $cmdExe -PathType Leaf)) { throw "cmd.exe not found at '$cmdExe'" } $helloPathCommand = $helloPath.Replace('\', '/') + New-Item -ItemType Directory -Force $ShareDir | Out-Null + $batch = Join-Path $ShareDir 'audit-workload.cmd' + Set-Content -LiteralPath $batch -Encoding ASCII -Value ('@echo hello from openshell ocsf audit > "{0}"' -f $helloPathCommand) $driverConfig = @{ mxc = @{ - command = @($cmdExe, "/c", "echo hello from openshell ocsf audit 1>`"$helloPathCommand`"") + command = @($cmdExe, '/d', '/c', $batch) cwd = $shareDirPolicy } } | ConvertTo-Json -Compress -Depth 4 - # Windows PowerShell 5.1 strips embedded quotes when constructing a native - # command line. Escape them so openshell.exe receives valid JSON. - $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { - $driverConfig.Replace('"', '\"') - } else { - $driverConfig - } - Info "backend=process_container etw_audit=true runtime=supervisor+sandbox" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" @@ -342,23 +447,38 @@ try { # 9. Register CLI -> gateway. Step "Register CLI -> gateway" - & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } - if ($LASTEXITCODE -ne 0) { throw "gateway add failed (exit $LASTEXITCODE)" } - & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } - if ($LASTEXITCODE -ne 0) { throw "gateway select failed (exit $LASTEXITCODE)" } + foreach ($cliArgs in @( + @('gateway', 'add', "http://127.0.0.1:$Port", '--local', '--name', $GatewayName), + @('gateway', 'select', $GatewayName) + )) { + $cliResult = Invoke-NativeCaptured $cli $cliArgs 30 + $cliResult.Output | ForEach-Object { Info $_ } + if ($cliResult.ExitCode -ne 0) { throw "gateway registration failed (exit $($cliResult.ExitCode))" } + } Ok "selected gateway '$GatewayName'" # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF - # event burst. The post-create interactive attach failure is EXPECTED on - # MXC (no in-sandbox supervisor) and harmless - the agent already ran. + # event burst through the authenticated supervisor and sandbox runtime. Step "Create $SandboxCount sandbox(es) (drives the Sandboxing provider)" for ($i = 1; $i -le $SandboxCount; $i++) { $name = "ocsf$i" Info "-- creating $name --" - try { & $cli sandbox create --name $name --policy $policy --driver-config-json $driverConfigArg --no-tty 2>&1 | ForEach-Object { Info $_ } } - catch { Info "sandbox create attach: $($_.Exception.Message) (expected on MXC - agent ran in-driver; continuing)" } + Remove-Item -LiteralPath $helloPath -Force -ErrorAction SilentlyContinue + $created = Invoke-NativeCaptured $cli @('sandbox', 'create', '--name', $name, + '--policy', $policy, '--driver-config-json', $driverConfig, '--detach') 90 + $created.Output | ForEach-Object { Info $_ } + if ($created.ExitCode -ne 0) { throw "sandbox create failed (exit $($created.ExitCode))" } + $createdSandboxes += $name + $workloadDeadline = (Get-Date).AddSeconds(30) + while (-not (Test-Path -LiteralPath $helloPath -PathType Leaf) -and (Get-Date) -lt $workloadDeadline) { + Start-Sleep -Milliseconds 200 + } + if (-not (Test-Path -LiteralPath $helloPath -PathType Leaf)) { throw 'audit workload did not produce its artifact' } + # Give real-time ETW delivery time to complete before retiring attribution. Start-Sleep -Seconds 3 - try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + $deleted = Invoke-NativeCaptured $cli @('sandbox', 'delete', $name) 30 + if ($deleted.ExitCode -ne 0) { throw "sandbox delete failed (exit $($deleted.ExitCode)): $($deleted.Output)" } + $createdSandboxes = @($createdSandboxes | Where-Object { $_ -ne $name }) } } catch { @@ -366,6 +486,14 @@ catch { $passed = $false } finally { + # A failed artifact/coverage check must not orphan a created runtime or leave + # inherited log handles open when the gateway is stopped and results zipped. + if (-not $KeepRunning -and $gw -and -not $gw.HasExited) { + foreach ($name in $createdSandboxes) { + try { $null = Invoke-NativeCaptured $cli @('sandbox', 'delete', $name) 30 } + catch { Info "cleanup of $name failed: $($_.Exception.Message)" } + } + } if (-not $KeepRunning -and $gw -and $gatewayEtwSessions.Count -eq 0) { $gatewayEtwSessions = @(Get-MxcEtwSessions | Where-Object { $_.Pid -eq $gw.Id }) } @@ -386,6 +514,13 @@ finally { } } + if (-not $KeepRunning -and (Test-Path -LiteralPath $secretDir)) { + foreach ($keyFile in @('signing.pem', 'public.pem', 'kid')) { + Remove-Item -LiteralPath (Join-Path $secretDir $keyFile) -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $secretDir -ErrorAction SilentlyContinue + } + # ---- summarise the OCSF audit trail -------------------------------------- Exit-IsolatedCliEnvironment $logText = @() @@ -405,13 +540,14 @@ finally { $jsonlPath = if ($jsonlFiles.Count) { $jsonlFiles[0].FullName } else { $null } $classNames = @{ 6002 = "Application Lifecycle"; 5019 = "Device Config State Change"; 1007 = "Process Activity"; 2004 = "Detection Finding" } $classCounts = @{ 6002 = 0; 5019 = 0; 1007 = 0; 2004 = 0 } - $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @() + $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @(); $auditEvents = @() if ($jsonlPath) { $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) $jsonlCount = $raw.Count foreach ($line in $raw) { try { $o = $line | ConvertFrom-Json + $auditEvents += $o if ($o.class_uid -ne $null -and $classCounts.ContainsKey([int]$o.class_uid)) { $classCounts[[int]$o.class_uid]++ } if ($o.metadata -and $o.metadata.uid) { $sids += [string]$o.metadata.uid } if ($o.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } @@ -421,28 +557,19 @@ finally { $hosts = @($hosts | Select-Object -Unique) } - # Event-type coverage (detected from the human-readable shorthand lines). - function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } - - # Expected happy-path ETW->OCSF event types for THIS run. The egress-proxy - # event only fires when the proxy is enabled, so it only counts toward the - # expected total when -NoProxy was NOT passed. - $coreEvents = [ordered]@{ - "sandbox lifecycle (start)" = Seen "(?i)ocsf:.*LIFECYCLE:" - "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" - "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" - "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" - "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" - "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" - "process launch (executable identity)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + # Require all seven ETW event types in durable structured output. + $coreEvents = Get-MxcAuditCoverage $auditEvents + function SeenFinding([string] $Kind) { + @($auditEvents | Where-Object { + $_.class_uid -eq 2004 -and $_.message -like "*$Kind*" + }).Count -gt 0 } - if ($proxyOn) { $coreEvents["egress proxy configured"] = Seen "(?i)ocsf:.*proxy configured" } # Findings are anomaly / fallback signals - reported separately, NOT part of # the expected-coverage denominator (a clean run may emit none). $findingEvents = [ordered]@{ - "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" - "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + "ActivityError finding" = SeenFinding "ActivityError" + "FallbackError finding" = SeenFinding "FallbackError" } $coreExpected = $coreEvents.Count @@ -467,7 +594,7 @@ user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) queue overload : $(if ($consumerOverloaded) { 'YES - ETW records dropped; audit coverage gap' } else { 'no dropped ETW records observed' }) -proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) +proxy : authenticated supervisor proxy (ETW proxy event is not required) workload output : $(if ($workloadCompleted) { $helloPath } else { '(missing)' }) wxc_exec : $WxcExecPath backend : process_container @@ -499,7 +626,7 @@ started, decoded the Sandboxing provider, attributed each event to a sandbox_id, reported no callback-queue overload, mapped events to OCSF, and wrote a durable JSONL audit log covering all $coreExpected expected event types across $classesSeen OCSF class(es) - the full Windows OCSF path -end-to-end, at parity with the Linux pipeline. +end-to-end for the Sandboxing provider event types listed above. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 3ddddc1c9..5364d40bb 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -767,17 +767,30 @@ async fn run_lifecycle_inner( .map_err(|error| format!("start MXC ProcessContainer: {error}"))?; attach_child_logs(&context.sandbox.name, "sandbox", &mut boundary); attach_child_logs(&context.sandbox.name, "supervisor", &mut supervisor); - if context.config.etw_audit - && let Some(pid) = boundary.id() - && let Ok(process_start_key) = crate::etw_consumer::child_process_start_key(&boundary) - && let Ok(mut attribution) = context.attribution.lock() - { - attribution.register_launch( - &context.sandbox.id, - &context.sandbox.name, - pid, - process_start_key, - ); + if context.config.etw_audit { + match boundary + .id() + .ok_or_else(|| "MXC child PID is unavailable".to_string()) + .and_then(|pid| { + crate::etw_consumer::child_process_start_key(&boundary).map(|key| (pid, key)) + }) { + Ok((pid, process_start_key)) => { + let mut attribution = context + .attribution + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + attribution.register_launch( + &context.sandbox.id, + &context.sandbox.name, + pid, + process_start_key, + ); + tracing::debug!(target: "mxc_etw", pid, process_start_key, + sandbox_id = %context.sandbox.id, "registered driver-owned MXC audit launch"); + } + Err(error) => tracing::warn!(sandbox_id = %context.sandbox.id, %error, + "MXC ETW audit launch registration failed"), + } } let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (terminated_tx, terminated_rx) = watch::channel(false); diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 208dda4e5..d69d1e463 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1567,6 +1567,9 @@ impl EtwEventProcessor { // instant `wxc-exec` starts, which can beat the driver's // `register_launch`. Hold the event for replay instead of dropping // it (see `drain_and_emit`). + tracing::debug!(target: "mxc_etw", pid = ev.process_id, + process_start_key = ?ev.process_start_key, + "buffering uncorrelated MXC audit event"); idx.buffer_unresolved(ev); None } @@ -2069,6 +2072,30 @@ mod tests { } } + #[tokio::test] + async fn live_child_process_generation_is_stable_and_distinct() { + let mut keys = Vec::new(); + for _ in 0..2 { + let mut child = tokio::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn owned telemetry probe"); + let first = child_process_start_key(&child); + let second = child_process_start_key(&child); + child.kill().await.expect("stop owned telemetry probe"); + let first = first.expect("live driver-owned handle has a generation key"); + assert_eq!(first, second.unwrap()); + keys.push(first); + } + assert_ne!( + keys[0], keys[1], + "different children require different generation keys" + ); + } + #[test] fn extracts_process_start_key_from_etw_extended_data() { let unrelated = EVENT_HEADER_EXTENDED_DATA_ITEM { diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs index 317c59709..252d85450 100644 --- a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -266,3 +266,74 @@ if (-not $rejected) { throw 'conflicting runtimes accepted' } String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn audit_coverage_uses_structured_events_and_rejects_missing_or_wrong_classes() { + let script = r#" +$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_MXC_RUNNER, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw "parse errors: $errors" } +$function = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Get-MxcAuditCoverage' +}, $true) +if (!$function) { throw 'missing coverage function' } +Invoke-Expression $function.Extent.Text +foreach ($name in @('Quote-NativeArgument', 'Invoke-NativeCaptured')) { + $helper = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if (!$helper) { throw "missing $name" } + Invoke-Expression $helper.Extent.Text +} +foreach ($code in @(0, 7)) { + $result = Invoke-NativeCaptured 'powershell.exe' @('-NoProfile', '-Command', + "[Console]::Error.WriteLine('status on stderr'); exit $code") 10 + if ($result.ExitCode -ne $code -or ($result.Output -join '') -notmatch 'status on stderr') { + throw 'native stderr must be captured without hiding the exit code' + } +} +$events = @( + @{ class_uid=6002; message="MXC sandbox 'test' created with policy enforcement" }, + @{ class_uid=5019; message='MXC sandbox OS policy enforced' }, + @{ class_uid=5019; message='MXC sandbox OS policy configured' }, + @{ class_uid=5019; message='MXC sandbox win32k lockdown applied' }, + @{ class_uid=5019; message='MXC sandbox UI restrictions applied' }, + @{ class_uid=5019; message='MXC sandbox console reference plumbed' }, + @{ class_uid=1007; message='MXC sandbox launched process: cmd.exe' } +) | ForEach-Object { $_ | ConvertTo-Json -Compress | ConvertFrom-Json } +$coverage = Get-MxcAuditCoverage $events +if ($coverage.Count -ne 7 -or @($coverage.Values | Where-Object { $_ }).Count -ne 7) { + throw 'complete structured audit must satisfy seven event types' +} +foreach ($index in 0..6) { + $subset = @(for ($i=0; $i -lt 7; $i++) { if ($i -ne $index) { $events[$i] } }) + if (@((Get-MxcAuditCoverage $subset).Values | Where-Object { $_ }).Count -ne 6) { + throw "missing event $index must fail coverage" + } +} +$events[6].class_uid = 5019 +if ((Get-MxcAuditCoverage $events)['process launch (executable identity)']) { + throw 'matching text with wrong class must not pass' +} +if (@((Get-MxcAuditCoverage @()).Values | Where-Object { $_ }).Count -ne 0) { + throw 'empty audit must not pass' +} +"#; + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env( + "OPENSHELL_MXC_RUNNER", + examples().join("run-ocsf-audit.ps1"), + ) + .output() + .expect("launch PowerShell coverage regression"); + assert!( + output.status.success(), + "{} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 243d8e912..77e248286 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -1472,6 +1472,18 @@ ssh_gateway_port = 8080 )); } + #[test] + fn mxc_audit_example_loads_with_workload_opt_in() { + let path = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../openshell-driver-mxc/examples/mxc-ocsf-audit.toml" + )); + let file = load(path).expect("shipped MXC audit config must load"); + let driver = &file.openshell.drivers["mxc"]; + assert_eq!(driver["allow_driver_config"].as_bool(), Some(true)); + assert_eq!(driver["etw_audit"].as_bool(), Some(true)); + } + #[test] fn rejects_missing_version_in_nonempty_file() { let tmp = write_raw_tmp("[openshell]\n\n[openshell.gateway]\nname = \"test\"\n"); diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 81f2ee289..500f8701e 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "test-mxc-audit", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -657,7 +657,7 @@ if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real") } } -if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "ci")) { +if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "test-mxc-audit", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -733,6 +733,33 @@ switch ($Action) { } } } + "test-mxc-audit" { + foreach ($rustTarget in $targets) { + Assert-NativeTestTarget $rustTarget + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-driver-mxc --lib --target $rustTarget etw_consumer::tests -- --test-threads=1 --nocapture" ` + -LogName "test-$rustTarget-mxc-audit-attribution.log" + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-server --lib --target $rustTarget $Z3ServerFeatures mxc_audit_example -- --nocapture" ` + -LogName "test-$rustTarget-mxc-audit-config.log" + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-driver-mxc --test windows_demo_examples --target $rustTarget -- --test-threads=1 --nocapture" ` + -LogName "test-$rustTarget-mxc-audit-examples.log" + $package = Join-Path $TargetDir "mxc-ocsf-qualification-$rustTarget" + New-Item -ItemType Directory -Force $package | Out-Null + foreach ($binary in @('openshell-gateway.exe', 'openshell.exe', 'openshell-supervisor.exe', 'openshell-windows-sandbox.exe', 'libz3.dll')) { + Copy-Item -LiteralPath (Join-Path $TargetDir "$rustTarget/release/$binary") -Destination $package -Force + } + foreach ($artifact in @('run-ocsf-audit.ps1', 'mxc-ocsf-audit.toml', 'ocsf-audit.yaml')) { + Copy-Item -LiteralPath (Join-Path $RepoRoot "crates/openshell-driver-mxc/examples/$artifact") -Destination $package -Force + } + $wxc = $env:OPENSHELL_WXC_EXEC_PATH + if (-not $wxc) { throw 'Set OPENSHELL_WXC_EXEC_PATH to the real MXC executor.' } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $package 'run-ocsf-audit.ps1') ` + -WxcExecPath $wxc -ShareDir (Join-Path $package 'work') + if ($LASTEXITCODE -ne 0) { throw "Live MXC audit qualification failed; results are in $package" } + } + } "test-mxc-real" { foreach ($rustTarget in $targets) { Invoke-MxcRealTests $rustTarget diff --git a/tasks/windows.toml b/tasks/windows.toml index a712a4f18..d991b1d5e 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -120,3 +120,8 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Qualify real HTTPS and child socket-owner policy against native MXC (requires Internet)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-network native" + +["windows:test:mxc-audit"] +description = "Validate MXC audit fixtures and real seven-event JSONL coverage (requires ETW privileges and native release binaries)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-audit native"