diff --git a/tests/configs/isolation_session_streaming_smoke.json b/tests/configs/isolation_session_streaming_smoke.json index 6e84bb631..3b7615e98 100644 --- a/tests/configs/isolation_session_streaming_smoke.json +++ b/tests/configs/isolation_session_streaming_smoke.json @@ -4,7 +4,7 @@ "containerId": "isolation-session-streaming-smoke", "containment": "isolation_session", "process": { - "commandLine": "echo line_1 & ping -n 2 127.0.0.1 >nul & echo line_2 & ping -n 2 127.0.0.1 >nul & echo line_3", + "commandLine": "echo line_1 & ping -n 6 127.0.0.1 >nul & echo line_2 & ping -n 6 127.0.0.1 >nul & echo line_3", "timeout": 30000 }, "network": { diff --git a/tests/configs/seatbelt_fs_denied_metadata.json b/tests/configs/seatbelt_fs_denied_metadata.json new file mode 100644 index 000000000..998ca639e --- /dev/null +++ b/tests/configs/seatbelt_fs_denied_metadata.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containment": "seatbelt", + "process": { + "commandLine": "echo FS_PARENT_ENTRIES=$(ls {{TESTDIR}}/rw | tr '\\n' ','); (ls {{TESTDIR}}/rw/nested >/dev/null 2>&1 && echo FS_DENIED_LIST_SUCCEEDED); (/usr/bin/stat -f %z {{TESTDIR}}/rw/nested/secret.txt >/dev/null 2>&1 && echo FS_DENIED_STAT_SUCCEEDED); (rm {{TESTDIR}}/rw/nested/secret.txt 2>/dev/null && echo FS_DENIED_DELETE_SUCCEEDED); (mv {{TESTDIR}}/rw/nested/secret.txt {{TESTDIR}}/rw/moved.txt 2>/dev/null && echo FS_DENIED_RENAME_SUCCEEDED); echo FS_PROBE_DONE", + "timeout": 10000 + }, + "filesystem": { + "readwritePaths": ["{{TESTDIR}}/rw"], + "deniedPaths": ["{{TESTDIR}}/rw/nested"] + } +} diff --git a/tests/configs/seatbelt_fs_denied_unix_socket.json b/tests/configs/seatbelt_fs_denied_unix_socket.json new file mode 100644 index 000000000..4fd1a309d --- /dev/null +++ b/tests/configs/seatbelt_fs_denied_unix_socket.json @@ -0,0 +1,18 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containment": "seatbelt", + "process": { + "commandLine": "/usr/bin/python3 -c \"import socket\ndef probe(tag, path, op):\n s = socket.socket(socket.AF_UNIX)\n try:\n getattr(s, op)(path)\n print(tag + '_SUCCEEDED')\n except OSError as e:\n print(tag + '_REFUSED', e.errno)\n s.close()\nprobe('UNIX_GRANTED_CONNECT', '{{TESTDIR}}/rw/open/agent.sock', 'connect')\nprobe('UNIX_DENIED_CONNECT', '{{TESTDIR}}/rw/nested/agent.sock', 'connect')\nprobe('UNIX_DENIED_BIND', '{{TESTDIR}}/rw/nested/new.sock', 'bind')\nprint('UNIX_PROBE_DONE')\"", + "timeout": 20000 + }, + "filesystem": { + "readonlyPaths": ["{{DEVDIR}}"], + "readwritePaths": ["{{TESTDIR}}/rw"], + "deniedPaths": ["{{TESTDIR}}/rw/nested"] + }, + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + } +} diff --git a/tests/configs/seatbelt_fs_denied_write.json b/tests/configs/seatbelt_fs_denied_write.json new file mode 100644 index 000000000..0f50757ce --- /dev/null +++ b/tests/configs/seatbelt_fs_denied_write.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containment": "seatbelt", + "process": { + "commandLine": "(echo denied > {{TESTDIR}}/rw/nested/written.txt && echo FS_NESTED_DENIED_WRITE_SUCCEEDED); (echo denied > {{TESTDIR}}/rw/sibling.txt && echo FS_RW_WRITE_CREATED); echo FS_PROBE_DONE", + "timeout": 10000 + }, + "filesystem": { + "readwritePaths": ["{{TESTDIR}}/rw"], + "deniedPaths": ["{{TESTDIR}}/rw/nested"] + } +} diff --git a/tests/configs/seatbelt_path_alias_denied.json b/tests/configs/seatbelt_path_alias_denied.json new file mode 100644 index 000000000..c68ba0f23 --- /dev/null +++ b/tests/configs/seatbelt_path_alias_denied.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containment": "seatbelt", + "process": { + "commandLine": "(cat {{TESTDIR}}/alias/secret.txt && echo PATH_ALIAS_DENIED_READ_SUCCEEDED); (echo x > {{TESTDIR}}/alias/d.txt && echo PATH_ALIAS_DENIED_WRITE_SUCCEEDED); echo PATH_PROBE_DONE", + "timeout": 10000 + }, + "filesystem": { + "deniedPaths": ["{{TESTDIR_TMP}}/alias"], + "readonlyPaths": ["{{TESTDIR}}/alias"], + "readwritePaths": ["{{TESTDIR}}/./alias/"] + } +} diff --git a/tests/configs/wslc_network_bridged.json b/tests/configs/wslc_network_bridged.json new file mode 100644 index 000000000..da66096d3 --- /dev/null +++ b/tests/configs/wslc_network_bridged.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containerId": "wslc-network-bridged", + "containment": "wslc", + "process": { + "commandLine": "python3 -c \"import socket\ns = socket.socket(); s.settimeout(8)\ntry:\n s.connect(('1.1.1.1', 443)); print('NET_EGRESS_REACHED')\nexcept OSError as e:\n print('NET_EGRESS_BLOCKED', e.errno)\nprint('NET_PROBE_DONE')\"" + }, + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, + "wslc": { + "image": "python:3.12-alpine" + } +} diff --git a/tests/configs/wslc_network_egress_deny_rules_rejected.json b/tests/configs/wslc_network_egress_deny_rules_rejected.json new file mode 100644 index 000000000..9699d98a7 --- /dev/null +++ b/tests/configs/wslc_network_egress_deny_rules_rejected.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containerId": "wslc-network-egress-deny-rules-rejected", + "containment": "wslc", + "process": { + "commandLine": "echo WSLC_EGRESS_RULES_SHOULD_NOT_RUN" + }, + "network": { + "egress": { + "default": "allow", + "deny": [{ "to": [{ "cidr": "10.0.0.0/8" }] }] + }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, + "wslc": { + "image": "alpine:latest" + } +} diff --git a/tests/configs/wslc_network_egress_rules_rejected.json b/tests/configs/wslc_network_egress_rules_rejected.json new file mode 100644 index 000000000..0582de62f --- /dev/null +++ b/tests/configs/wslc_network_egress_rules_rejected.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containerId": "wslc-network-egress-rules-rejected", + "containment": "wslc", + "process": { + "commandLine": "echo WSLC_EGRESS_RULES_SHOULD_NOT_RUN" + }, + "network": { + "egress": { + "default": "deny", + "allow": [{ "to": [{ "cidr": "1.1.1.1/32" }], "ports": [{ "port": 443, "protocol": "tcp" }] }] + }, + "ingress": { "default": "deny", "hostLoopback": "deny" } + }, + "wslc": { + "image": "alpine:latest" + } +} diff --git a/tests/configs/wslc_network_isolated.json b/tests/configs/wslc_network_isolated.json index 0562e11ac..5e0d19147 100644 --- a/tests/configs/wslc_network_isolated.json +++ b/tests/configs/wslc_network_isolated.json @@ -4,12 +4,12 @@ "containerId": "wslc-network-isolated", "containment": "wslc", "process": { - "commandLine": "command -v wget >/dev/null 2>&1 || { echo 'wget not found in image' >&2; exit 1; }; wget -q -O /dev/null http://example.com && echo 'Network accessible' || echo 'Network blocked (expected)'" + "commandLine": "python3 -c \"import socket\ns = socket.socket(); s.settimeout(8)\ntry:\n s.connect(('1.1.1.1', 443)); print('NET_EGRESS_REACHED')\nexcept OSError as e:\n print('NET_EGRESS_BLOCKED', e.errno)\nprint('NET_PROBE_DONE')\"" }, "network": { "egress": { "default": "deny" } }, "wslc": { - "image": "alpine:latest" + "image": "python:3.12-alpine" } } diff --git a/tests/configs/wslc_network_proxy_credentials_rejected.json b/tests/configs/wslc_network_proxy_credentials_rejected.json new file mode 100644 index 000000000..c5a6453e9 --- /dev/null +++ b/tests/configs/wslc_network_proxy_credentials_rejected.json @@ -0,0 +1,20 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containerId": "wslc-network-proxy-credentials-rejected", + "containment": "wslc", + "process": { + "commandLine": "echo WSLC_PROXY_CREDENTIALS_SHOULD_NOT_RUN", + "env": ["MXC_PROBE=1"] + }, + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, + "runtimeConfig": { + "networkProxy": "http://proxyuser:s3cr3t@127.0.0.1:8888" + }, + "wslc": { + "image": "alpine:latest" + } +} diff --git a/tests/scripts/README.md b/tests/scripts/README.md index c0e2d04a0..2ccffdb3e 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -72,6 +72,11 @@ must be **dot-sourced, not imported as a module** — `Initialize-WpcContext` publishes the suite context into the calling script's scope, which only works because dot-sourcing merges scopes. +The IsolationSession suites dot-source `tests/scripts/lib/LoopbackAnchor.ps1` +for their positive network oracle: a host loopback listener an isolated session +can reach, so the assertion covers the session's network posture rather than +the runner's outbound internet access. + T2 (`appcontainer-bfs`) is out of scope: it is off by default behind the `tier2_bfs` Cargo feature and is not in use, so the suite records no assertions about it. The remaining `bfscfg` checks are guards, not coverage — invoking diff --git a/tests/scripts/lib/LoopbackAnchor.ps1 b/tests/scripts/lib/LoopbackAnchor.ps1 new file mode 100644 index 000000000..07e2dfec2 --- /dev/null +++ b/tests/scripts/lib/LoopbackAnchor.ps1 @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# LoopbackAnchor.ps1 — host-side loopback HTTP anchor for IsolationSession +# network tests. +# +# An isolated session is a separate OS logon session on the same machine, so a +# host loopback listener is reachable from inside it. Anchoring the positive +# network oracle here keeps it hermetic: it asserts the session's network +# posture rather than the runner's outbound internet access. + +# Binds an ephemeral loopback port and serves a fixed body from a background +# runspace, so the caller can stay blocked on a sandbox exec while the +# contained process connects back. Returns $null if the port cannot be bound. +function Start-LoopbackAnchor { + # Bind port 0 and read back what the OS assigned, rather than reserving a + # port up front and re-binding it: another process can take the port in + # between, and the anchor then answers on a socket nobody can reach. + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + } catch { + return $null + } + $port = $listener.LocalEndpoint.Port + $ps = [PowerShell]::Create() + [void]$ps.AddScript({ + param($l) + $body = 'MXC-ISO-LOOPBACK-ANCHOR' + $response = [Text.Encoding]::ASCII.GetBytes( + "HTTP/1.1 200 OK`r`nContent-Type: text/plain`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n$body") + while ($true) { + try { + $client = $l.AcceptTcpClient() + $stream = $client.GetStream() + # Read whatever request line the client sent before replying; + # curl will not report success if the peer resets first. + $stream.ReadTimeout = 2000 + $buf = New-Object byte[] 1024 + try { [void]$stream.Read($buf, 0, $buf.Length) } catch {} + $stream.Write($response, 0, $response.Length) + $stream.Flush() + $client.Close() + } catch { break } + } + }).AddArgument($listener) + $handle = $ps.BeginInvoke() + return [pscustomobject]@{ + Port = $port + Url = "http://127.0.0.1:$port/" + Body = 'MXC-ISO-LOOPBACK-ANCHOR' + Stop = { + try { $listener.Stop() } catch {} + try { [void]$ps.EndInvoke($handle) } catch {} + try { $ps.Dispose() } catch {} + }.GetNewClosure() + } +} + +function Stop-LoopbackAnchor { + param($Anchor) + if ($null -eq $Anchor) { return } + & $Anchor.Stop +} + +# Command line that fetches the anchor from inside a sandbox. +# +# Deliberately a bare curl rather than a `&& echo REACHED || echo BLOCKED` +# token pair: the one-shot runner invokes wxc-exec with --debug, which echoes +# the command line, so any verdict token spelled here would appear in the +# captured output whether or not the probe ran. The anchor's response body +# cannot be echoed that way, which makes it the only honest oracle -- and +# curl's non-zero exit on a failed connect covers the negative side. +function Get-LoopbackAnchorCommand { + param( + [Parameter(Mandatory)][string]$Url, + [int]$TimeoutSec = 15 + ) + $curl = Join-Path $env:SystemRoot 'System32\curl.exe' + "$curl --silent --show-error --fail --noproxy * --max-time $TimeoutSec $Url" +} diff --git a/tests/scripts/run_isolation_session_state_aware_tests.ps1 b/tests/scripts/run_isolation_session_state_aware_tests.ps1 index 75a61c8f3..6e9b41f71 100644 --- a/tests/scripts/run_isolation_session_state_aware_tests.ps1 +++ b/tests/scripts/run_isolation_session_state_aware_tests.ps1 @@ -46,6 +46,11 @@ param( $ErrorActionPreference = "Stop" $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +# Host-side loopback anchor used by the positive network oracle. It serves from +# a background runspace so the harness can sit blocked on an exec while the +# session connects back to it. +. (Join-Path $PSScriptRoot 'lib\LoopbackAnchor.ps1') + # ---------------- Locate wxc-exec.exe ---------------- $HostTarget = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { @@ -303,6 +308,101 @@ function Invoke-StateAware { } } +# Reads a redirect file that wxc-exec still holds open for writing. Get-Content +# takes an exclusive-ish share and fails against a live writer, so open the +# file explicitly with FileShare.ReadWrite instead. Returns '' when the file is +# not readable yet. +function Read-LiveFile { + param([string]$Path) + try { + $stream = [System.IO.File]::Open( + $Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite) + try { + $reader = New-Object System.IO.StreamReader($stream) + [string]$reader.ReadToEnd() + } finally { $stream.Dispose() } + } catch { '' } +} + +# Same invocation as Invoke-StateAware, but returns while wxc-exec is still +# running so the caller can observe output as it arrives or kill the process +# mid-phase. The caller must pass the returned handle to +# Complete-StateAwareAsync, which collects the final streams and deletes the +# redirect files. +function Start-StateAwareAsync { + param( + [hashtable]$Request, + [string]$ConfigFile, + [string]$SandboxId + ) + + $invocation = ConvertTo-StateAwareInvocation ` + -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId + + $argList = @('--operation', $invocation.Operation) + if ($invocation.Operation -ne 'provision') { + $argList += @('--container-id', $invocation.SandboxId) + } + $argList += @('--config-base64', $invocation.ConfigBase64) + + $stdoutFile = [System.IO.Path]::GetTempFileName() + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $WxcExec -ArgumentList $argList ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile ` + -NoNewWindow -PassThru + @{ + Process = $proc + StdoutFile = $stdoutFile + StderrFile = $stderrFile + } +} + +# Polls the live stdout of an async invocation until $Pattern appears, the +# process exits, or $TimeoutSeconds elapses. Returns $true only if the pattern +# was seen while the process was still running. +function Wait-AsyncStdoutPattern { + param( + [hashtable]$Async, + [string]$Pattern, + [int]$TimeoutSeconds = 30 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline -and -not $Async.Process.HasExited) { + if ((Read-LiveFile $Async.StdoutFile) -match $Pattern) { return $true } + Start-Sleep -Milliseconds 200 + } + $false +} + +# Waits for an async invocation to finish (killing it past the deadline so a +# hang cannot stall the suite) and returns the same shape as Invoke-StateAware. +function Complete-StateAwareAsync { + param( + [hashtable]$Async, + [int]$TimeoutSeconds = 120 + ) + try { + if (-not $Async.Process.WaitForExit($TimeoutSeconds * 1000)) { + try { $Async.Process.Kill() } catch { } + $null = $Async.Process.WaitForExit(5000) + } + $stdoutText = Read-LiveFile $Async.StdoutFile + $stderrText = Read-LiveFile $Async.StderrFile + $exitCode = try { $Async.Process.ExitCode } catch { $null } + @{ + ExitCode = $exitCode + Stdout = $stdoutText + Stderr = $stderrText + } + } finally { + Remove-Item $Async.StdoutFile -ErrorAction SilentlyContinue + Remove-Item $Async.StderrFile -ErrorAction SilentlyContinue + } +} + # Parses the wxc-exec stdout envelope and returns the parsed object, or # $null if the stdout is not valid JSON. function Parse-Envelope { @@ -401,6 +501,18 @@ function Assert-True { } } +# A behaviour we document but do not yet get from the platform. Never fails the +# suite. If the expectation starts holding it says so, so the waiver leaves +# alongside the fix. +function Assert-KnownGap { + param([bool]$Condition, [string]$Message, [string]$Issue) + if ($Condition) { + Write-Host " FIXED ($Issue): $Message -- remove this waiver" -ForegroundColor Yellow + } else { + Write-Host " KNOWN GAP ($Issue): $Message" -ForegroundColor Yellow + } +} + # Wraps a logical test (one phase, one phase-pair, or one multi-exec # scenario). Prints the test name as a section header, runs the body # (which uses Assert-True for per-dimension checks), and records one @@ -824,6 +936,28 @@ try { Assert-True ($msg -match 'unknown field `ui`') "error.message reports the closed ui field (got '$msg')" } | Out-Null + # UI policy is excluded from every post-provision root too, so a caller + # cannot smuggle it past provision by attaching it to a later phase. + foreach ($phase in @('start', 'exec', 'stop', 'deprovision')) { + Run-StateAwareTest "$phase (ui policy rejected structurally)" { + $req = @{ + phase = $phase + sandboxId = 'iso:unused' + ui = @{ enabled = $true } + } + if ($phase -eq 'exec') { + $req.process = @{ commandLine = 'cmd /c echo ui_should_not_run'; timeout = 30000 } + } + $r = Invoke-StateAware -Request $req -DryRun + Assert-True ($r.ExitCode -ne 0) "exit code is non-zero (contract rejected)" + $envObj = Parse-Envelope -Stdout $r.Stdout + $code = if ($envObj) { $envObj.error.code } else { '' } + Assert-True ($code -eq 'malformed_request') "error.code is 'malformed_request' (got '$code')" + $msg = if ($envObj) { [string]$envObj.error.message } else { '' } + Assert-True ($msg -match 'unknown field `ui`') "error.message reports the closed ui field (got '$msg')" + } | Out-Null + } + # Test 2: start succeeds against the provisioned sandbox. Exercises the # multi-invocation pattern -- provision was a separate wxc-exec process; # this is a fresh wxc-exec process consuming the same sandbox_id. @@ -1193,6 +1327,57 @@ try { } | Out-Null } + # Test 11b: stale_id breadth. Every non-provision phase resolves the agent + # user from the sandbox id, so a deprovisioned id must read as stale on all + # of them -- not just the `stop` asserted above. `operation` is checked for + # shape rather than an exact value here: which API call first reports + # ERROR_NOT_FOUND depends on the OS-side capability set, and test 11 already + # pins one exact constant. `deprovision` is covered separately below. + if ($deprovisionedOk) { + foreach ($phase in @('start', 'exec')) { + Run-StateAwareTest "stale_id ($phase on previously-deprovisioned sandbox)" { + $req = @{ + phase = $phase + sandboxId = $script:sandboxId + } + if ($phase -eq 'exec') { + $req.process = @{ commandLine = 'cmd /c echo stale_should_not_run'; timeout = 30000 } + } + $r = Invoke-StateAware -Request $req + Assert-True ($r.ExitCode -ne 0) "exit code is non-zero ($phase on a stale sandbox failed as expected)" + $envObj = Parse-Envelope -Stdout $r.Stdout + if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } + Assert-True ($null -ne $envObj) "the failure is a parseable envelope" + $code = if ($envObj) { $envObj.error.code } else { '' } + Assert-True ($code -eq 'stale_id') "error.code is 'stale_id' (got '$code')" + $operation = if ($envObj) { [string]$envObj.error.operation } else { '' } + Assert-True ($operation.StartsWith('IsoSessionOps.')) ` + "error.operation names the failing API call (got '$operation')" + $nativeCode = if ($envObj) { [string]$envObj.error.nativeCode } else { '' } + Assert-True ($nativeCode -eq '0x80070490') ` + "error.nativeCode is '0x80070490' (got '$nativeCode')" + Assert-True (-not ($r.Stdout -match 'stale_should_not_run')) ` + "no workload ran against the stale sandbox" + } | Out-Null + } + } + + # Test 11c: `deprovision` against a deprovisioned sandbox. Documented to + # report stale_id like the phases above, but RemoveUser reports success for + # an agent user that is already gone, so the runner never sees an + # ERROR_NOT_FOUND to promote. Recorded without failing the suite while + # #1429 is open. + if ($deprovisionedOk) { + Run-StateAwareTest "stale_id (deprovision on previously-deprovisioned sandbox)" { + $r = Invoke-StateAware -Request @{ phase = 'deprovision'; sandboxId = $script:sandboxId } + $envObj = Parse-Envelope -Stdout $r.Stdout + if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } + $code = if ($envObj) { [string]$envObj.error.code } else { '' } + Assert-KnownGap (($r.ExitCode -ne 0) -and ($code -eq 'stale_id')) ` + "a repeated deprovision reports stale_id (got exit=$($r.ExitCode), code='$code')" '#1429' + } | Out-Null + } + } finally { # Best-effort cleanup. If the suite reached the deprovision test and it # succeeded, the agent user is already torn down -- nothing to do. If @@ -1636,6 +1821,262 @@ try { } +# ---------------- Lifecycle G: Runtime behaviour of a live session ---------------- +# +# Lifecycles A-F cover the request/response contract. This group covers what a +# running session actually does: +# - the mandated all-allow network posture really carries traffic, +# - exec output reaches the caller while the command is still running, +# - `process.timeout` ends a long command and leaves the session usable, +# - a caller killed mid-exec does not take the sandbox with it, +# - repeating a lifecycle call never corrupts the sandbox, +# - the agent account named in the provision metadata is created and removed. + +# Provision a sandbox and capture the full provision metadata. +function Provision-LifecycleGSandbox { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_provision.json' + $envObj = Parse-Envelope -Stdout $r.Stdout + if ((Envelope-Arm $envObj) -ne 'result') { + Write-Host " G provision arm: $(Envelope-Arm $envObj)" -ForegroundColor Red + Write-Host " Stdout: $($r.Stdout)" -ForegroundColor Gray + Write-Host " Stderr: $($r.Stderr)" -ForegroundColor Gray + return $null + } + $meta = $envObj.result.metadata + $obj = @{ + SandboxId = [string]$envObj.result.sandboxId + UserName = if ($meta) { [string]$meta.agentUserName } else { '' } + Sid = if ($meta) { [string]$meta.agentUserSid } else { '' } + Workspace = if ($meta) { [string]$meta.ephemeralWorkspacePath } else { '' } + } + Write-Host " G provisioned: sandboxId=$($obj.SandboxId) user=$($obj.UserName)" -ForegroundColor DarkGray + return $obj +} + +$script:gSandbox = $null +$script:gAnchor = $null +$gStarted = $false +$gDeprov = $false +$gAccountsBefore = @((Get-LocalUser -ErrorAction SilentlyContinue).Name) + +try { + # G1: the metadata names the account that was really created. Deprovision + # auditing and orphan cleanup both key off agentUserName, so it has to be + # the real local account name rather than a display label. + Run-StateAwareTest "Lifecycle G: provision creates the agent account named in the metadata" { + $script:gSandbox = Provision-LifecycleGSandbox + Assert-True ($null -ne $script:gSandbox) "provision returned a result envelope" + if ($null -ne $script:gSandbox) { + Assert-True (-not [string]::IsNullOrWhiteSpace($script:gSandbox.UserName)) "agentUserName is present" + Assert-True (-not [string]::IsNullOrWhiteSpace($script:gSandbox.Sid)) "agentUserSid is present" + Assert-True ($script:gSandbox.Sid -match '^S-1-5-21-') "agentUserSid is a machine-local account SID" + Assert-True ($gAccountsBefore -notcontains $script:gSandbox.UserName) "no account of that name existed before provision" + $account = Get-LocalUser -Name $script:gSandbox.UserName -ErrorAction SilentlyContinue + Assert-True ($null -ne $account) "a local account now exists under agentUserName" + if ($null -ne $account) { + Assert-True ($account.SID.Value -eq $script:gSandbox.Sid) "the local account's SID matches agentUserSid" + } + } + } | Out-Null + + if ($null -ne $script:gSandbox) { + $gStarted = Run-StateAwareTest "Lifecycle G: start" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_start.json' -SandboxId $script:gSandbox.SandboxId + Assert-True ($r.ExitCode -eq 0) "start exit 0" + } + } + + if ($gStarted) { + # G2: positive network oracle. Provision mandates an all-allow posture + # (egress + ingress + hostLoopback); every other network test here is a + # rejection, so nothing yet proved the session can actually reach + # anything. The host-side anchor keeps this hermetic -- it does not + # depend on the runner having outbound internet access -- and the + # harness fetches it first with the same tool, so an anchor that never + # came up is distinguishable from a session that cannot reach it. + Run-StateAwareTest "Lifecycle G: the session reaches a host loopback listener" { + $script:gAnchor = Start-LoopbackAnchor + Assert-True ($null -ne $script:gAnchor) "the host anchor bound a loopback port" + if ($null -ne $script:gAnchor) { + # `Stop` turns curl's stderr into a terminating error once 2>&1 + # merges it into the output stream, so a failed fetch would read + # as a harness crash instead of a network verdict. + $prevPref = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $hostProbe = & curl.exe --silent --show-error --noproxy "*" --max-time 15 $script:gAnchor.Url 2>&1 | Out-String + $ErrorActionPreference = $prevPref + Assert-True ("$hostProbe" -match [regex]::Escape($script:gAnchor.Body)) ` + "the harness itself reaches the anchor (the oracle is live)" + + $command = Get-LoopbackAnchorCommand -Url $script:gAnchor.Url + $r = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine $command + Assert-True ($r.ExitCode -eq 0) "the session's fetch exits 0 (got $($r.ExitCode))" + Assert-True ($r.Stdout -match [regex]::Escape($script:gAnchor.Body)) ` + "the session received the anchor's body (got '$($r.Stdout)')" + } + } | Out-Null + + # G3: streaming. Reading the first line off the live redirect file while + # wxc-exec is still running is what separates streamed output from a + # single burst at exit; inspecting the finished output cannot tell them + # apart. + Run-StateAwareTest "Lifecycle G: exec output reaches the caller before the command exits" { + $req = @{ + phase = 'exec' + sandboxId = $script:gSandbox.SandboxId + process = @{ + commandLine = 'echo stream_line_1 & ping -n 8 127.0.0.1 >nul & echo stream_line_2' + timeout = 60000 + } + } + $async = Start-StateAwareAsync -Request $req + $sawEarly = Wait-AsyncStdoutPattern -Async $async -Pattern 'stream_line_1' -TimeoutSeconds 20 + $r = Complete-StateAwareAsync -Async $async + Assert-True ($r.ExitCode -eq 0) "exec exit 0" + Assert-True ($r.Stdout -match 'stream_line_1') "stream_line_1 reached the caller" + Assert-True ($r.Stdout -match 'stream_line_2') "stream_line_2 reached the caller" + Assert-True $sawEarly "stream_line_1 was readable while the command was still running" + } | Out-Null + + # G4: process.timeout. Only the one-shot path had a timeout test; the + # state-aware exec deadline was unexercised, as was the question of + # whether a timed-out exec consumes the sandbox. + Run-StateAwareTest "Lifecycle G: exec honours process.timeout and leaves the session usable" { + $req = @{ + phase = 'exec' + sandboxId = $script:gSandbox.SandboxId + process = @{ + commandLine = 'echo timeout_probe_started & ping -n 60 127.0.0.1 >nul & echo timeout_probe_finished' + timeout = 3000 + } + } + $startedAt = Get-Date + $r = Invoke-StateAware -Request $req + $elapsed = ((Get-Date) - $startedAt).TotalSeconds + Assert-True ($r.ExitCode -ne 0) "a timed-out exec reports failure" + # The positive marker keeps the negative one honest: without it, + # an exec that never started would also satisfy "did not finish". + Assert-True ($r.Stdout -match 'timeout_probe_started') "the command did start" + Assert-True (-not ($r.Stdout -match 'timeout_probe_finished')) ` + "the command was terminated rather than allowed to finish" + # The command would take ~59s; anything well under that proves the + # deadline, not the command, ended the run. + Assert-True ($elapsed -lt 45) "the deadline ended the run early (took $([int]$elapsed)s)" + + $after = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine 'cmd /c echo after_timeout_marker' + Assert-True ($after.ExitCode -eq 0) "a later exec against the same sandbox exits 0" + Assert-True ($after.Stdout -match 'after_timeout_marker') "the sandbox survives a timed-out exec" + } | Out-Null + + # G5: recovery. The sandbox outlives the process that created it, so a + # caller dying mid-exec must not strand or tear down the session. + Run-StateAwareTest "Lifecycle G: a caller killed mid-exec leaves the sandbox usable" { + $req = @{ + phase = 'exec' + sandboxId = $script:gSandbox.SandboxId + process = @{ + commandLine = 'echo crash_probe_started & ping -n 30 127.0.0.1 >nul & echo crash_probe_finished' + timeout = 120000 + } + } + $async = Start-StateAwareAsync -Request $req + # Kill only once the workload is demonstrably running, so the kill + # lands mid-exec rather than during dispatch. + $running = Wait-AsyncStdoutPattern -Async $async -Pattern 'crash_probe_started' -TimeoutSeconds 30 + Assert-True $running "the exec reached the session before the caller was killed" + try { $async.Process.Kill() } catch { } + $r = Complete-StateAwareAsync -Async $async -TimeoutSeconds 30 + Assert-True (-not ($r.Stdout -match 'crash_probe_finished')) "the killed exec did not run to completion" + + $after = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine 'cmd /c echo recovered_marker' + Assert-True ($after.ExitCode -eq 0) "a later exec against the same sandbox exits 0" + Assert-True ($after.Stdout -match 'recovered_marker') "the sandbox is still usable after its caller died" + } | Out-Null + + # G6: repeated start. MXC forwards start/stop straight to the OS service + # and does not normalise the repeat outcome, so the assertable contract + # is that repeating the call is *safe*: it answers in a well-formed way + # and the sandbox still works afterwards. A hang, a crash, unparseable + # output, or a bricked session all fail this. + Run-StateAwareTest "Lifecycle G: repeating start is safe" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_start.json' -SandboxId $script:gSandbox.SandboxId + if ($r.ExitCode -eq 0) { + Write-Host " repeat start succeeded" -ForegroundColor DarkGray + } else { + $envObj = Parse-Envelope -Stdout $r.Stdout + if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } + Assert-True ((Envelope-Arm $envObj) -eq 'error') "a refused repeat start is a well-formed error envelope" + Write-Host " repeat start refused with '$(if ($envObj) { $envObj.error.code } else { '' })'" -ForegroundColor DarkGray + } + $after = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine 'cmd /c echo after_repeat_start_marker' + Assert-True ($after.ExitCode -eq 0) "the sandbox still execs after a repeated start" + Assert-True ($after.Stdout -match 'after_repeat_start_marker') "the repeated start did not corrupt the sandbox" + } | Out-Null + } + + if ($gStarted) { + $gStoppedOk = Run-StateAwareTest "Lifecycle G: stop" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_stop.json' -SandboxId $script:gSandbox.SandboxId + Assert-True ($r.ExitCode -eq 0) "stop exit 0" + } + + # G7: repeated stop. Same contract as the repeated start: whatever the + # OS reports, the sandbox must remain deprovisionable (asserted by G8 + # running immediately after this). + if ($gStoppedOk) { + Run-StateAwareTest "Lifecycle G: repeating stop is safe" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_stop.json' -SandboxId $script:gSandbox.SandboxId + if ($r.ExitCode -eq 0) { + Write-Host " repeat stop succeeded" -ForegroundColor DarkGray + } else { + $envObj = Parse-Envelope -Stdout $r.Stdout + if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } + Assert-True ((Envelope-Arm $envObj) -eq 'error') "a refused repeat stop is a well-formed error envelope" + $code = if ($envObj) { [string]$envObj.error.code } else { '' } + Assert-True ($code -ne 'stale_id') "a stopped-but-provisioned sandbox is not reported as stale (got '$code')" + } + } | Out-Null + } + } + + # G8: deprovision removes the local account, and repeating it reads as + # stale rather than succeeding a second time. + if ($null -ne $script:gSandbox) { + $gDeprovPassed = Run-StateAwareTest "Lifecycle G: deprovision removes the agent account" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_deprovision.json' -SandboxId $script:gSandbox.SandboxId + Assert-True ($r.ExitCode -eq 0) "deprovision exit 0" + if ($r.ExitCode -eq 0) { + $account = Get-LocalUser -Name $script:gSandbox.UserName -ErrorAction SilentlyContinue + Assert-True ($null -eq $account) "the agent's local account is gone after deprovision" + $accountsAfter = @((Get-LocalUser -ErrorAction SilentlyContinue).Name) + Assert-True ($accountsAfter -notcontains $script:gSandbox.UserName) ` + "the agent account is absent from the machine's account list" + } + } + if ($gDeprovPassed) { + $gDeprov = $true + Run-StateAwareTest "Lifecycle G: repeating deprovision reports a stale sandbox" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_deprovision.json' -SandboxId $script:gSandbox.SandboxId + $envObj = Parse-Envelope -Stdout $r.Stdout + if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } + $code = if ($envObj) { [string]$envObj.error.code } else { '' } + Assert-KnownGap (($r.ExitCode -ne 0) -and ($code -eq 'stale_id')) ` + "a second deprovision reports stale_id (got exit=$($r.ExitCode), code='$code')" '#1429' + } | Out-Null + } + } +} finally { + Stop-LoopbackAnchor -Anchor $script:gAnchor + if ($null -ne $script:gSandbox -and -not $gDeprov) { + Write-Host "" + Write-Host "[cleanup] best-effort deprovision of Lifecycle G sandbox ($($script:gSandbox.SandboxId))" -ForegroundColor DarkGray + try { + $null = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_deprovision.json' -SandboxId $script:gSandbox.SandboxId + } catch { } + } +} + + # ---------------- Summary ---------------- $total = $script:TestResults.Count diff --git a/tests/scripts/run_isolation_session_tests.ps1 b/tests/scripts/run_isolation_session_tests.ps1 index ed5cac243..ea0f65904 100644 --- a/tests/scripts/run_isolation_session_tests.ps1 +++ b/tests/scripts/run_isolation_session_tests.ps1 @@ -30,12 +30,10 @@ - isolation_session_stderr.json --separate stderr in non-ConPTY mode - isolation_session_stdout_stderr_interleaved.json --interleaved streams - isolation_session_timeout.json --timeout enforcement + - isolation_session_streaming_smoke.json --output arrives incrementally + rather than as a burst at exit Manual smoke configs (NOT asserted --observe the output yourself): - - isolation_session_streaming_smoke.json --output appears with delays - rather than a burst at exit; verifies Commit 1 streaming. - Run from cmd.exe directly (not redirected) so wxc-exec sees a TTY: - wxc-exec.exe isolation_session_streaming_smoke.json - isolation_session_powershell_interactive.json --launches powershell.exe in the isolation session; type commands at the prompt (e.g. `Get-Date`, `whoami`, `exit 7`) and verify input forwarding + @@ -63,6 +61,9 @@ param( $ErrorActionPreference = "Stop" $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +# Host-side loopback anchor used by the positive network oracle. +. (Join-Path $PSScriptRoot 'lib\LoopbackAnchor.ps1') + if (-not $ConfigDir) { $ConfigDir = Join-Path $RepoRoot "tests\configs" } @@ -204,6 +205,22 @@ if ($probeResult.Status -ne 'available') { exit 1 } +# Peek at a file a child process still holds open for writing. FileShare.ReadWrite +# is required: the default share mode fails against a live writer. Returns '' +# when the file is absent or not yet readable. +function Read-LiveFile { + param([string]$Path) + if (-not (Test-Path $Path)) { return '' } + try { + $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + $sr = New-Object System.IO.StreamReader($fs) + [string]$sr.ReadToEnd() + } finally { $fs.Close() } + } catch { '' } +} + # Helper: run one IsolationSession test config. # # The wxc-exec invocation is wrapped in try-catch so an unexpected @@ -515,25 +532,17 @@ function Start-ConcurrentWxc { } # Block until "X-started" appears in $LogPath (the agent's own log file, -# not the wxc-exec stdout capture). Reading with FileShare.ReadWrite lets -# us peek while the agent's PowerShell holds the file open for Add-Content. +# not the wxc-exec stdout capture), peeking while the agent's PowerShell +# still holds the file open for Add-Content. function Wait-AgentLogStart { param([string]$Label, [string]$LogPath, [int]$TimeoutSeconds = 30) $pattern = [regex]::new("\b$Label-started\b") $start = Get-Date while (((Get-Date) - $start).TotalSeconds -lt $TimeoutSeconds) { - if (Test-Path $LogPath) { - try { - $fs = [System.IO.File]::Open($LogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) - $sr = New-Object System.IO.StreamReader($fs) - $text = $sr.ReadToEnd() - $sr.Close(); $fs.Close() - if ($pattern.IsMatch($text)) { - $elapsed = ((Get-Date) - $start).TotalMilliseconds - Write-Host " $Label-started in log after $([int]$elapsed) ms" -ForegroundColor DarkGray - return $true - } - } catch { } + if ($pattern.IsMatch((Read-LiveFile $LogPath))) { + $elapsed = ((Get-Date) - $start).TotalMilliseconds + Write-Host " $Label-started in log after $([int]$elapsed) ms" -ForegroundColor DarkGray + return $true } Start-Sleep -Milliseconds 100 } @@ -658,6 +667,138 @@ try { } +# ---------------- Positive network oracle ---------------- +# +# Every other network test here is a rejection, so nothing proved the mandated +# all-allow posture actually carries traffic. The anchor runs on host loopback, +# which an isolated session can reach because it is a separate logon session on +# the same machine -- so this asserts the session's posture rather than the +# runner's outbound internet access. The harness fetches the anchor first with +# the same tool, making "the anchor never came up" distinguishable from "the +# session could not reach it". + +Write-Host "" +Write-Host "--- Network ---" -ForegroundColor Cyan + +$anchor = Start-LoopbackAnchor +try { + $anchorLive = $false + if ($null -eq $anchor) { + $null = $results.Add(@{ Name = 'network: host loopback anchor'; Pass = $false; Skipped = $false + Reason = 'Could not bind a host loopback anchor port' }) + } else { + # `Stop` turns curl's stderr into a terminating error once 2>&1 merges + # it into the output stream, so flip it for the fetch. + $prevPref = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $hostProbe = & curl.exe --silent --show-error --noproxy "*" --max-time 15 $anchor.Url 2>&1 | Out-String + $ErrorActionPreference = $prevPref + $anchorLive = "$hostProbe" -match [regex]::Escape($anchor.Body) + if (-not $anchorLive) { + $null = $results.Add(@{ Name = 'network: host loopback anchor'; Pass = $false; Skipped = $false + Reason = "The anchor did not answer the harness itself: $hostProbe" }) + } + } + + if ($anchorLive) { + $anchorRequest = @{ + version = '1.0.0' + containment = 'isolation_session' + process = @{ + commandLine = (Get-LoopbackAnchorCommand -Url $anchor.Url) + timeout = 60000 + } + network = @{ + egress = @{ default = 'allow' } + ingress = @{ default = 'allow'; hostLoopback = 'allow' } + } + } + $null = $results.Add((Run-IsolationSessionTest "network reaches the host loopback anchor" ` + -Request $anchorRequest -OutputContains @($anchor.Body))) + } +} finally { + Stop-LoopbackAnchor -Anchor $anchor +} + + +# ---------------- Streaming ---------------- +# +# The streaming config was previously observed by hand. Reading the first line +# off the live capture file while wxc-exec is still running is what separates +# streamed output from a single burst at exit, so the assertion has to peek at +# a running process rather than inspect its final output. + +Write-Host "" +Write-Host "--- Streaming ---" -ForegroundColor Cyan + +$streamingRoot = Join-Path $env:TEMP 'mxc_oneshot_streaming' +Remove-Item -Recurse -Force $streamingRoot -ErrorAction SilentlyContinue +New-Item -Path $streamingRoot -ItemType Directory -Force | Out-Null +$streamingStdout = Join-Path $streamingRoot 'stdout.txt' +$streamingStderr = Join-Path $streamingRoot 'stderr.txt' +$streamingConfig = Join-Path $ConfigDir 'isolation_session_streaming_smoke.json' + +Write-Host " isolation_session_streaming_smoke.json ... " -NoNewline +if (-not (Test-Path $streamingConfig)) { + Write-Host "SKIP (file not found)" -ForegroundColor Yellow + $null = $results.Add(@{ Name = 'streaming'; Pass = $true; Skipped = $true; Reason = 'File not found' }) +} else { + # Routed through cmd.exe with cmd-managed redirects, matching the + # concurrent launcher above. Deliberately without --debug: the Logger + # echoes the command line, and that echo contains line_1/2/3 itself, which + # would satisfy both the early-read and the ordering checks without any + # streaming happening. + $streamingCmd = "/c $WxcExec $streamingConfig 1>$streamingStdout 2>$streamingStderr" + $streamingProc = Start-Process -FilePath cmd.exe -ArgumentList $streamingCmd ` + -WindowStyle Hidden -PassThru + + # The config prints line_1, waits, prints line_2, waits, prints line_3. + # line_1 must be readable long before the process exits. + $sawEarly = $false + $deadline = (Get-Date).AddSeconds(60) + while ((Get-Date) -lt $deadline -and -not $streamingProc.HasExited) { + if ((Read-LiveFile $streamingStdout) -match 'line_1') { $sawEarly = $true; break } + Start-Sleep -Milliseconds 50 + } + if (-not $streamingProc.WaitForExit(60000)) { + try { $streamingProc.Kill() } catch { } + $null = $streamingProc.WaitForExit(5000) + } + + $streamingOutput = Read-LiveFile $streamingStdout + $streamingPass = $true + $streamingReason = '' + if ($streamingProc.ExitCode -ne 0) { + $streamingPass = $false + $streamingReason = "Expected exit 0, got $($streamingProc.ExitCode)" + } elseif (-not $sawEarly) { + $streamingPass = $false + $streamingReason = "line_1 was not readable before the command exited (output buffered to exit)" + } else { + # Ordering as well as presence: a relay that reorders or drops a stream + # chunk is a streaming bug even when every line eventually arrives. + $i1 = $streamingOutput.IndexOf('line_1') + $i2 = $streamingOutput.IndexOf('line_2') + $i3 = $streamingOutput.IndexOf('line_3') + if ($i1 -lt 0 -or $i2 -lt 0 -or $i3 -lt 0) { + $streamingPass = $false + $streamingReason = "Output missing one of line_1/line_2/line_3" + } elseif (-not ($i1 -lt $i2 -and $i2 -lt $i3)) { + $streamingPass = $false + $streamingReason = "Output lines arrived out of order" + } + } + + if ($streamingPass) { + Write-Host "PASS" -ForegroundColor Green + } else { + Write-Host "FAIL" -ForegroundColor Red + Write-Host " Reason: $streamingReason" -ForegroundColor Red + } + $null = $results.Add(@{ Name = 'streaming (incremental delivery)'; Pass = $streamingPass; Skipped = $false; Reason = $streamingReason }) +} + + # Summary -- wrap each filtered pipeline in @(...) to force array context. # Without @(), a Where-Object that returns a single hashtable is unwrapped # to the bare hashtable; calling .Count on a single hashtable returns its @@ -686,8 +827,11 @@ if (Test-Path 'C:\mxc_workdir_test') { $scratchLeft += 'C:\mxc_workdir_test' } if ($failed -eq 0) { Remove-Item -Recurse -Force $concurrentTempRoot -ErrorAction SilentlyContinue if (Test-Path $concurrentTempRoot) { $scratchLeft += $concurrentTempRoot } + Remove-Item -Recurse -Force $streamingRoot -ErrorAction SilentlyContinue + if (Test-Path $streamingRoot) { $scratchLeft += $streamingRoot } } else { Write-Host " (concurrent stdout/stderr preserved at: $concurrentTempRoot)" -ForegroundColor DarkGray + Write-Host " (streaming stdout/stderr preserved at: $streamingRoot)" -ForegroundColor DarkGray } foreach ($leftover in $scratchLeft) { Write-Host "FAILED: could not remove scratch directory $leftover" -ForegroundColor Red diff --git a/tests/scripts/run_seatbelt_filesystem_test.sh b/tests/scripts/run_seatbelt_filesystem_test.sh index dc12a7693..2d44a6b87 100755 --- a/tests/scripts/run_seatbelt_filesystem_test.sh +++ b/tests/scripts/run_seatbelt_filesystem_test.sh @@ -47,6 +47,30 @@ expect_marker "the nested-denied probe ran" "FS_PROBE_DONE" expect_absent "a denied path nested in a readwrite grant stays denied" "FS_NESTED_DENIED_READ_SUCCEEDED" expect_absent "a nested denied path leaks no content" "FS_SECRET_CONTENT" +run_config "$(render seatbelt_fs_denied_write.json TESTDIR "$TESTDIR")" +expect_marker "the nested-denied-write probe ran" "FS_PROBE_DONE" +expect_marker "the same run writes the enclosing readwrite grant" "FS_RW_WRITE_CREATED" +expect_absent "a denied path nested in a readwrite grant is not writable" "FS_NESTED_DENIED_WRITE_SUCCEEDED" +[ ! -f "$TESTDIR/rw/nested/written.txt" ] || fail "a nested denied write created a file on the host" +pass "a nested denied write left no file behind" + +# Seatbelt has no masking primitive: a denied entry stays *visible* in a +# granted parent and every operation on it is refused instead. Pinning the +# visibility matters as much as the refusals -- a reader who assumes the entry +# disappears will mis-size what a denial actually conceals. +run_config "$(render seatbelt_fs_denied_metadata.json TESTDIR "$TESTDIR")" +expect_marker "the denied-metadata probe ran" "FS_PROBE_DONE" +grep -q 'FS_PARENT_ENTRIES=.*nested' <<<"$OUT" || + fail "a denied entry stays visible in its granted parent" "$OUT" +pass "a denied entry stays visible in its granted parent" +expect_absent "a denied directory cannot be enumerated" "FS_DENIED_LIST_SUCCEEDED" +expect_absent "a denied path's metadata cannot be read" "FS_DENIED_STAT_SUCCEEDED" +expect_absent "a denied path cannot be deleted" "FS_DENIED_DELETE_SUCCEEDED" +expect_absent "a denied path cannot be renamed out of its subtree" "FS_DENIED_RENAME_SUCCEEDED" +[ -f "$TESTDIR/rw/nested/secret.txt" ] || fail "a denied file was deleted from the host" +[ ! -f "$TESTDIR/rw/moved.txt" ] || fail "a denied file was renamed into a readwrite grant" +pass "the denied subtree is unchanged on the host" + run_config "$(render seatbelt_fs_ungranted_denied.json TESTDIR "$TESTDIR")" expect_marker "the ungranted-read probe ran" "FS_PROBE_DONE" expect_absent "an ungranted path is denied by default" "FS_UNGRANTED_READ_SUCCEEDED" diff --git a/tests/scripts/run_seatbelt_path_resolution_test.sh b/tests/scripts/run_seatbelt_path_resolution_test.sh index a2fd67876..3fc160f4e 100755 --- a/tests/scripts/run_seatbelt_path_resolution_test.sh +++ b/tests/scripts/run_seatbelt_path_resolution_test.sh @@ -40,6 +40,18 @@ run_config "$(render seatbelt_path_alias_conflict.json TESTDIR "$TESTDIR" TESTDI expect_marker "an aliased readonly/readwrite pair still grants read" "PATH_SECRET_CONTENT" expect_absent "an aliased readonly/readwrite pair resolves to read-only" "PATH_ALIAS_WRITE_SUCCEEDED" +# deniedPaths is emitted after the allow rules so it outranks them regardless +# of depth, but that only holds if the alias resolution runs first: here the +# deny is written as /tmp/... while the grants name /private/tmp/... and a +# redundant spelling of it. +run_config "$(render seatbelt_path_alias_denied.json TESTDIR "$TESTDIR" TESTDIR_TMP "$TESTDIR_TMP")" +expect_marker "the aliased-denied probe ran" "PATH_PROBE_DONE" +expect_absent "an aliased deny outranks a readonly grant" "PATH_ALIAS_DENIED_READ_SUCCEEDED" +expect_absent "an aliased deny leaks no content" "PATH_SECRET_CONTENT" +expect_absent "an aliased deny outranks a readwrite grant" "PATH_ALIAS_DENIED_WRITE_SUCCEEDED" +[ ! -f "$TESTDIR/alias/d.txt" ] || fail "an aliased deny was written through on the host" +pass "an aliased deny left no file behind" + run_config "$(render seatbelt_path_readonly_nested_in_readwrite.json TESTDIR "$TESTDIR")" expect_marker "a readonly path nested in a readwrite grant is readable" "PATH_SECRET_CONTENT" expect_absent "a readonly path nested in a readwrite grant stays read-only" "PATH_NESTED_RO_WRITE_SUCCEEDED" diff --git a/tests/scripts/run_seatbelt_unix_socket_test.sh b/tests/scripts/run_seatbelt_unix_socket_test.sh index e165067b7..5bb7208c3 100755 --- a/tests/scripts/run_seatbelt_unix_socket_test.sh +++ b/tests/scripts/run_seatbelt_unix_socket_test.sh @@ -16,7 +16,7 @@ require_python3_probe TESTDIR="$(mktemp -d /private/tmp/mxc-seatbelt-sock.XXXXXX)" trap 'rm -rf "$TESTDIR" "$SEATBELT_TMP"' EXIT -mkdir -p "$TESTDIR/ro" "$TESTDIR/rw" +mkdir -p "$TESTDIR/ro" "$TESTDIR/rw" "$TESTDIR/rw/open" "$TESTDIR/rw/nested" chmod -R a+rwX "$TESTDIR" run_config "$(render seatbelt_unix_socket_readwrite.json TESTDIR "$TESTDIR")" @@ -31,4 +31,49 @@ expect_absent "an AF_UNIX socket cannot bind under a readonly grant" "UNIX_BIND_ [ ! -S "$TESTDIR/ro/s.sock" ] || fail "a socket was created in a readonly grant" pass "the refused bind left no socket behind" +# A denied subtree inside a broader readwrite grant, under a network posture +# that emits an unfiltered `(allow network-outbound)`. Both allows cover the +# socket, so only the deniedPaths rule stands between the sandbox and whatever +# listens there -- a Docker, ssh-agent, or gpg-agent socket is a control plane, +# so reaching one would be an escape. +# +# The listeners run on the host because the sandbox is the client here: a bind +# from inside would test the wrong half. +LISTENER_SRC="$SEATBELT_TMP/listener.py" +cat >"$LISTENER_SRC" <<'PY' +import socket, sys, time +# Hold every socket: a dropped reference is closed at once, and because close() +# leaves the bound path in place the readiness check below would still pass. +listeners = [] +for path in sys.argv[1:]: + s = socket.socket(socket.AF_UNIX) + s.bind(path) + s.listen(8) + listeners.append(s) +time.sleep(600) +PY +/usr/bin/python3 "$LISTENER_SRC" "$TESTDIR/rw/open/agent.sock" "$TESTDIR/rw/nested/agent.sock" & +LISTENER_PID=$! +trap 'kill "$LISTENER_PID" 2>/dev/null; rm -rf "$TESTDIR" "$SEATBELT_TMP"' EXIT + +for _ in $(seq 1 50); do + [ -S "$TESTDIR/rw/open/agent.sock" ] && [ -S "$TESTDIR/rw/nested/agent.sock" ] && break + sleep 0.1 +done +[ -S "$TESTDIR/rw/open/agent.sock" ] && [ -S "$TESTDIR/rw/nested/agent.sock" ] || + fail "the host listeners never came up, so a refused connect would prove nothing" + +run_config "$(render seatbelt_fs_denied_unix_socket.json TESTDIR "$TESTDIR")" +expect_marker "the denied-socket probe ran" "UNIX_PROBE_DONE" +# The control: the same run reaches an identical socket that is only covered by +# the readwrite grant, so a refusal below is the deny rule and not a listener +# that was never reachable. +expect_marker "an AF_UNIX connect succeeds elsewhere in the readwrite grant" "UNIX_GRANTED_CONNECT_SUCCEEDED" +expect_marker "an AF_UNIX connect into a denied subtree is refused" "UNIX_DENIED_CONNECT_REFUSED" +expect_absent "a denied subtree's socket is unreachable" "UNIX_DENIED_CONNECT_SUCCEEDED" +expect_marker "an AF_UNIX bind into a denied subtree is refused" "UNIX_DENIED_BIND_REFUSED" +expect_absent "a denied subtree cannot host a new socket" "UNIX_DENIED_BIND_SUCCEEDED" +[ ! -S "$TESTDIR/rw/nested/new.sock" ] || fail "a socket was created in a denied subtree" +pass "the refused denied-subtree bind left no socket behind" + summary "Seatbelt UNIX sockets" diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 252956b59..8c18efd39 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -349,7 +349,26 @@ $null = $results.Add(@{ }) Write-Host "`n--- Network Tests ---" -ForegroundColor Cyan -$null = $results.Add((Run-WslcTest "wslc_network_isolated.json")) +# The pair is the oracle. Both fixtures run the same raw-IP TCP connect, so the +# only difference between a reached and a blocked verdict is the posture -- +# a probe that cannot resolve DNS or has no interpreter reports neither. +$null = $results.Add((Run-WslcTest "wslc_network_isolated.json" ` + -OutputContains "NET_EGRESS_BLOCKED" -OutputNotContains "NET_EGRESS_REACHED")) +$null = $results.Add((Run-WslcTest "wslc_network_bridged.json" ` + -OutputContains "NET_EGRESS_REACHED")) +# WSLc networking is all-or-nothing: it has no CAP_NET_ADMIN for in-container +# rules, so a per-destination egress rule must be refused rather than quietly +# widened to the posture's default. +foreach ($ruleConfig in @("wslc_network_egress_rules_rejected.json", "wslc_network_egress_deny_rules_rejected.json")) { + $null = $results.Add((Run-WslcTest $ruleConfig -ExpectedExit -1 ` + -OutputContains "network.egress allow/deny rules" ` + -OutputNotContains "WSLC_EGRESS_RULES_SHOULD_NOT_RUN")) +} +# `process.env` without `inheritDefaultEnv` is launched through `env -i NAME=VALUE`, +# which puts the injected proxy URL in argv and therefore in /proc//cmdline. +$null = $results.Add((Run-WslcTest "wslc_network_proxy_credentials_rejected.json" -ExpectedExit -1 ` + -OutputContains "must not carry credentials" ` + -OutputNotContains "WSLC_PROXY_CREDENTIALS_SHOULD_NOT_RUN")) # Delegate the cooperative proxy fixture to its owning script, which asserts # HTTP_PROXY injection/scrub, NO_PROXY neutralization, and attacker-value # removal -- assertions the marker-only Run-WslcTest path cannot make. Both