源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
This commit is contained in:
commit
7be535059e
3358 files changed
+1306357
No files matched your search
@@ -0,0 +1,89 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Windows sandbox native evidence
|
||||
|
||||
This directory contains the native Rust broker/launcher and Windows evidence
|
||||
tooling selected by the Windows sandbox RFC. The release build is packaged by
|
||||
the Windows x64 pipeline and registered through `SandboxManager` only when the
|
||||
native resource is present.
|
||||
|
||||
The first slice freezes the launcher request/result shapes in the shipped Rust
|
||||
types and exercises them through native smoke tests. A launcher that merely
|
||||
starts a process is not sufficient evidence.
|
||||
|
||||
Run the contract checks on Windows:
|
||||
|
||||
```powershell
|
||||
cargo test --manifest-path experiments/windows-sandbox/launcher/Cargo.toml --locked
|
||||
pwsh experiments/windows-sandbox/appcontainer-smoke.ps1
|
||||
```
|
||||
|
||||
The smoke probes exit non-zero when an observation differs from its expectation.
|
||||
Their JSON reports are evidence inputs, not claims that the parent process is
|
||||
sandboxed.
|
||||
|
||||
`launcher/` also retains the rejected process-containment prototype. It probes a
|
||||
restricted primary token, suspended process creation, post-create Job
|
||||
assignment, kill-on-close descendants, and no inherited handles. The post-create
|
||||
assignment is explicitly not the atomic Job guarantee and is not the production
|
||||
path. It remains only as bounded negative evidence.
|
||||
|
||||
The current Windows 2025 evidence records an incompatibility in the
|
||||
unprivileged candidate: `CreateProcessWithTokenW` creates the restricted child,
|
||||
but both the native launcher self-probe and `cmd.exe /d /c exit 0` fail to finish
|
||||
initialization before the 30-second safety deadline. The launcher terminates the
|
||||
child and returns failure. The CI lane treats this exact bounded, fail-closed
|
||||
result as evidence; any other launch error still fails the job. This candidate
|
||||
does not satisfy W0 and must not be connected to the product.
|
||||
|
||||
`atomic-launch-capability.ps1` records whether the current Windows identity has
|
||||
the privileges needed to test the separate privileged-broker prototype. A
|
||||
missing privilege is an expected fail-closed capability result; it must not be
|
||||
worked around by silently using the non-atomic launcher path.
|
||||
|
||||
`maka-windows-sandbox --broker-local <manifest>` consumes one manifest,
|
||||
authorizes it in-process, launches it, restores ACLs, and exits. The lower-level
|
||||
`--broker-serve-once <pipe> <account-sid> <profile-digest>` mode exposes one
|
||||
bounded experimental request over a pipe. The pipe rejects remote
|
||||
clients, grants access only to SYSTEM and the supplied user SID, obtains the
|
||||
client PID from the kernel, enforces nonce replay and profile-digest checks,
|
||||
and calls only the AppContainer atomic launch path. Authorization validates the
|
||||
digest of the complete launch policy. The broker never falls back to post-create
|
||||
Job assignment.
|
||||
|
||||
`launcher --atomic <request.json>` is the privileged-broker launch candidate.
|
||||
It passes the Job handle through `PROC_THREAD_ATTRIBUTE_JOB_LIST` to
|
||||
`CreateProcessAsUserW`, so successful process creation and Job membership are
|
||||
one operation. The launcher verifies the restricted token and Job membership
|
||||
before reporting `atomicJob:true`. The atomic candidate never falls back to the
|
||||
post-create assignment path. `atomic-launch-smoke.ps1` requires complete proof
|
||||
when the runner has both broker privileges and otherwise requires an explicit
|
||||
non-zero, fail-closed outcome.
|
||||
|
||||
`launcher --appcontainer <request.json>` is the isolated-identity candidate. It
|
||||
creates a fresh request-derived AppContainer identity, combines its token with
|
||||
the same atomic Job attribute, and supplies no network capabilities. Before
|
||||
launch, the broker persists an ACL recovery ledger, rejects reparse points, and
|
||||
grants that per-launch SID only the requested roots. A short-lived global mutex
|
||||
serializes ACL mutation, while a request-specific kernel lease distinguishes
|
||||
live ledgers from abandoned ones without serializing child execution. The smoke
|
||||
proves allowed read/write access, denial of a user-readable sibling file and
|
||||
live loopback endpoint, stale-ledger recovery, concurrent launches, junction
|
||||
rejection, and removal of the temporary AppContainer ACE after exit.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
param(
|
||||
[string]$LauncherPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = if ($LauncherPath) {
|
||||
$LauncherPath
|
||||
} else {
|
||||
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
|
||||
$workRoot = Join-Path $tempRoot "maka-acl-recovery-$PID"
|
||||
$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers'
|
||||
# findstr exits 0 on match and nonzero on denial, and takes plain argv — no
|
||||
# cmd.exe quote-parsing pitfalls inside the AppContainer.
|
||||
$findstrExe = Join-Path $env:SystemRoot 'System32\findstr.exe'
|
||||
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
|
||||
|
||||
function Get-AppContainerSid {
|
||||
param([string]$RequestId)
|
||||
$sid = (& $launcher --appcontainer-sid $RequestId 2>&1) -join ''
|
||||
if ($LASTEXITCODE -ne 0 -or $sid -notmatch '^S-1-15-2-') {
|
||||
throw "Unable to resolve AppContainer SID for ${RequestId}: $sid"
|
||||
}
|
||||
return $sid
|
||||
}
|
||||
# A fake AppContainer-shaped SID nothing else uses: pre-existing ACEs that
|
||||
# recovery and cleanup must leave untouched.
|
||||
$markerSid = 'S-1-15-2-1999999999-1888888888-1777777777-1666666666-1555555555-1444444444-1333333333'
|
||||
|
||||
function New-LaunchRequest {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string[]]$Arguments,
|
||||
[string[]]$ReadRoots,
|
||||
[string[]]$ExactReadRoots,
|
||||
[Nullable[int]]$TimeoutMs,
|
||||
[string]$Executable = $findstrExe
|
||||
)
|
||||
$request = [ordered]@{
|
||||
version = 1
|
||||
requestId = $Name
|
||||
executable = $Executable
|
||||
arguments = $Arguments
|
||||
cwd = $env:SystemRoot
|
||||
readRoots = $ReadRoots
|
||||
writeRoots = @()
|
||||
network = 'restricted'
|
||||
environment = @{}
|
||||
}
|
||||
if ($ExactReadRoots) { $request.exactReadRoots = $ExactReadRoots }
|
||||
if ($null -ne $TimeoutMs) { $request.timeoutMs = $TimeoutMs }
|
||||
$path = Join-Path $workRoot "$Name.json"
|
||||
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path -Encoding utf8
|
||||
return $path
|
||||
}
|
||||
|
||||
function Get-Acl-Text { param([string]$Path) (& icacls.exe $Path 2>&1) -join "`n" }
|
||||
|
||||
try {
|
||||
# --- Existing-ACE preservation + subtree vs exact grants -------------------
|
||||
$dataRoot = Join-Path $workRoot 'data'
|
||||
New-Item -ItemType Directory -Path $dataRoot | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $dataRoot 'ok.txt') -Value 'subtree-read' -Encoding ascii
|
||||
& icacls.exe $dataRoot /grant "*$markerSid`:(OI)(CI)RX" /T /Q | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to seed marker ACE' }
|
||||
|
||||
$subtree = New-LaunchRequest -Name 'subtree-read' `
|
||||
-Arguments @('/c:subtree-read', "$dataRoot\ok.txt") -ReadRoots @($dataRoot)
|
||||
$subtreeSid = Get-AppContainerSid 'subtree-read'
|
||||
& $launcher --appcontainer $subtree *> $null
|
||||
if ($LASTEXITCODE -ne 0) { throw "subtree read launch failed: exit=$LASTEXITCODE" }
|
||||
|
||||
$exact = New-LaunchRequest -Name 'exact-read' `
|
||||
-Arguments @('/c:subtree-read', "$dataRoot\ok.txt") `
|
||||
-ReadRoots @($dataRoot) -ExactReadRoots @($dataRoot)
|
||||
$exactSid = Get-AppContainerSid 'exact-read'
|
||||
& $launcher --appcontainer $exact *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
throw 'exact-directory grant unexpectedly allowed reading a child file'
|
||||
}
|
||||
|
||||
$dataAcl = Get-Acl-Text $dataRoot
|
||||
$childAcl = Get-Acl-Text (Join-Path $dataRoot 'ok.txt')
|
||||
if ($dataAcl -match [regex]::Escape($subtreeSid) -or
|
||||
$childAcl -match [regex]::Escape($subtreeSid) -or
|
||||
$dataAcl -match [regex]::Escape($exactSid) -or
|
||||
$childAcl -match [regex]::Escape($exactSid)) {
|
||||
throw "AppContainer grants were not removed after launch: $dataAcl"
|
||||
}
|
||||
if ($dataAcl -notmatch [regex]::Escape($markerSid) -or
|
||||
$childAcl -notmatch [regex]::Escape($markerSid)) {
|
||||
throw "Pre-existing marker ACE was destroyed by cleanup: $dataAcl"
|
||||
}
|
||||
|
||||
# --- Concurrency: parallel launches on distinct roots ----------------------
|
||||
$jobs = @()
|
||||
$concurrentRoots = @()
|
||||
for ($index = 0; $index -lt 10; $index++) {
|
||||
$root = Join-Path $workRoot "concurrent-$index"
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $root 'ok.txt') -Value "concurrent-$index" -Encoding ascii
|
||||
$concurrentRoots += $root
|
||||
$requestPath = New-LaunchRequest -Name "concurrent-$index" `
|
||||
-Arguments @('/c:concurrent', "$root\ok.txt") -ReadRoots @($root) -TimeoutMs 120000
|
||||
$jobs += Start-Job -ScriptBlock {
|
||||
param($Launcher, $Request)
|
||||
$output = & $Launcher --appcontainer $Request 2>&1
|
||||
[pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($output -join ' | ') }
|
||||
} -ArgumentList $launcher, $requestPath
|
||||
}
|
||||
$results = $jobs | Wait-Job | Receive-Job
|
||||
$jobs | Remove-Job
|
||||
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
|
||||
if ($failed.Count -gt 0) {
|
||||
$detail = ($failed | ForEach-Object { "exit=$($_.ExitCode) output=$($_.Output)" }) -join "`n"
|
||||
throw "Concurrent launches failed:`n$detail"
|
||||
}
|
||||
foreach ($root in $concurrentRoots) {
|
||||
if ((Get-Acl-Text $root) -match 'S-1-15-2-') {
|
||||
throw "Concurrent launch left AppContainer grants on ${root}"
|
||||
}
|
||||
}
|
||||
$leftover = @(Get-ChildItem -LiteralPath $ledgerRoot -Filter '*.json' -ErrorAction SilentlyContinue)
|
||||
if ($leftover.Count -gt 0) {
|
||||
throw "Concurrent launches left ACL ledgers behind: $($leftover.Name -join ', ')"
|
||||
}
|
||||
|
||||
# --- Killed-broker recovery ------------------------------------------------
|
||||
$staleRoot = Join-Path $workRoot 'kill-recovery'
|
||||
New-Item -ItemType Directory -Path $staleRoot | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $staleRoot 'ok.txt') -Value 'kill-recovery' -Encoding ascii
|
||||
# The launcher's own --stdio-probe --sleep keeps the sandboxed child alive
|
||||
# without needing network or console features inside the AppContainer.
|
||||
$sleepRequest = New-LaunchRequest -Name 'kill-recovery' -Executable $launcher `
|
||||
-Arguments @('--stdio-probe', '--sleep', '50') -ReadRoots @($staleRoot) -TimeoutMs 120000
|
||||
$killRecoverySid = Get-AppContainerSid 'kill-recovery'
|
||||
$emptyStdin = Join-Path $workRoot 'empty-stdin.bin'
|
||||
[IO.File]::WriteAllBytes($emptyStdin, @())
|
||||
$broker = Start-Process -FilePath $launcher -ArgumentList @('--appcontainer', $sleepRequest) `
|
||||
-RedirectStandardInput $emptyStdin `
|
||||
-RedirectStandardOutput (Join-Path $workRoot 'kill-stdout.txt') `
|
||||
-RedirectStandardError (Join-Path $workRoot 'kill-stderr.txt') `
|
||||
-NoNewWindow -PassThru
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(30)
|
||||
while ([DateTime]::UtcNow -lt $deadline -and
|
||||
(Get-Acl-Text $staleRoot) -notmatch [regex]::Escape($killRecoverySid)) {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ((Get-Acl-Text $staleRoot) -notmatch [regex]::Escape($killRecoverySid)) {
|
||||
Stop-Process -Id $broker.Id -Force -ErrorAction SilentlyContinue
|
||||
throw 'Killed-broker scenario never observed the AppContainer grant'
|
||||
}
|
||||
Stop-Process -Id $broker.Id -Force
|
||||
Wait-Process -Id $broker.Id -ErrorAction SilentlyContinue
|
||||
$staleLedgers = @(Get-ChildItem -LiteralPath $ledgerRoot -Filter '*.json' -ErrorAction SilentlyContinue)
|
||||
if ($staleLedgers.Count -eq 0) {
|
||||
throw 'Killing the broker mid-launch left no ledger to recover'
|
||||
}
|
||||
|
||||
# The next launch must replay the stale ledger and remove the dead broker's
|
||||
# grants before establishing its own.
|
||||
$recovery = New-LaunchRequest -Name 'recovery' `
|
||||
-Arguments @('/c:subtree-read', "$dataRoot\ok.txt") -ReadRoots @($dataRoot)
|
||||
& $launcher --appcontainer $recovery *> $null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Recovery launch failed: exit=$LASTEXITCODE" }
|
||||
if ((Get-Acl-Text $staleRoot) -match [regex]::Escape($killRecoverySid)) {
|
||||
throw 'Recovery did not remove the dead broker''s AppContainer grants'
|
||||
}
|
||||
$remaining = @(Get-ChildItem -LiteralPath $ledgerRoot -Filter '*.json' -ErrorAction SilentlyContinue)
|
||||
if ($remaining.Count -gt 0) {
|
||||
throw "Recovery left ACL ledgers behind: $($remaining.Name -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host 'ACL recovery verified: ACE preservation, exact roots, 10-way concurrency, killed-broker replay'
|
||||
} finally {
|
||||
& icacls.exe $workRoot /remove "*$markerSid" /T /L /Q 2>$null | Out-Null
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LauncherPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = (Resolve-Path -LiteralPath $LauncherPath).Path
|
||||
$launcherDirectory = Split-Path -Parent $launcher
|
||||
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
|
||||
$workRoot = Join-Path $tempRoot "maka-phase4-adversarial-$PID"
|
||||
$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers'
|
||||
$registrySubkey = "Software\Maka\SandboxPhase4\$PID"
|
||||
$registryPath = "HKCU:\$registrySubkey"
|
||||
$registryValueName = 'HostSecret'
|
||||
$pipeShortName = "maka-phase4-host-$PID"
|
||||
$pipeName = "\\.\pipe\$pipeShortName"
|
||||
$hostSecretName = 'MAKA_PHASE4_HOST_SECRET'
|
||||
$listener = $null
|
||||
$pipe = $null
|
||||
$quarantinedSid = $null
|
||||
$quarantineRoot = $null
|
||||
$quarantinePath = $null
|
||||
|
||||
function Write-LaunchRequest {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string[]]$Arguments,
|
||||
[string[]]$ReadRoots,
|
||||
[string[]]$WriteRoots,
|
||||
[string[]]$ExactReadRoots,
|
||||
[string[]]$ExactWriteRoots
|
||||
)
|
||||
$request = [ordered]@{
|
||||
version = 1
|
||||
requestId = $Name
|
||||
executable = $launcher
|
||||
arguments = $Arguments
|
||||
cwd = Split-Path -Parent $launcher
|
||||
readRoots = $ReadRoots
|
||||
writeRoots = $WriteRoots
|
||||
exactReadRoots = $ExactReadRoots
|
||||
exactWriteRoots = $ExactWriteRoots
|
||||
network = 'restricted'
|
||||
environment = @{ MAKA_PHASE4_ALLOWED = 'allowed' }
|
||||
timeoutMs = 120000
|
||||
}
|
||||
$path = Join-Path $workRoot "$Name.json"
|
||||
$requestJson = $request | ConvertTo-Json -Depth 5
|
||||
[IO.File]::WriteAllText($path, $requestJson, [Text.UTF8Encoding]::new($false))
|
||||
return $path
|
||||
}
|
||||
|
||||
function Invoke-ExpectedAdmissionFailure {
|
||||
param(
|
||||
[string]$RequestPath,
|
||||
[string]$Pattern,
|
||||
[string]$Description
|
||||
)
|
||||
$result = Invoke-Launcher @('--appcontainer', $RequestPath)
|
||||
$output = $result.Output
|
||||
$exitCode = $result.ExitCode
|
||||
$rendered = $output -join "`n"
|
||||
if ($exitCode -eq 0 -or $rendered -notmatch $Pattern) {
|
||||
throw "$Description did not fail closed: exit=$exitCode output=$rendered"
|
||||
}
|
||||
$global:LASTEXITCODE = 0
|
||||
}
|
||||
|
||||
function Invoke-Launcher {
|
||||
param([string[]]$Arguments)
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$output = & $launcher @Arguments 2>&1
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $LASTEXITCODE
|
||||
Output = @($output)
|
||||
}
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AppContainerSid {
|
||||
param([string]$RequestId)
|
||||
$sid = (& $launcher --appcontainer-sid $RequestId 2>&1) -join ''
|
||||
if ($LASTEXITCODE -ne 0 -or $sid -notmatch '^S-1-15-2-') {
|
||||
throw "Unable to resolve AppContainer SID for ${RequestId}: $sid"
|
||||
}
|
||||
return $sid
|
||||
}
|
||||
|
||||
function Get-Sha256Hex {
|
||||
param([string]$Value)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value)) | ForEach-Object {
|
||||
$_.ToString('x2')
|
||||
})
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AclText {
|
||||
param([string]$Path)
|
||||
return (& icacls.exe $Path 2>&1) -join "`n"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $ledgerRoot -Force | Out-Null
|
||||
|
||||
try {
|
||||
$outsideRoot = Join-Path $workRoot 'outside'
|
||||
$allowedRoot = Join-Path $workRoot 'allowed'
|
||||
New-Item -ItemType Directory -Path $outsideRoot, $allowedRoot | Out-Null
|
||||
$deniedPath = Join-Path $outsideRoot 'host-secret.txt'
|
||||
$allowedReadPath = Join-Path $allowedRoot 'read.txt'
|
||||
$allowedWritePath = Join-Path $allowedRoot 'write.txt'
|
||||
[IO.File]::WriteAllText($deniedPath, 'must-not-be-readable')
|
||||
[IO.File]::WriteAllText($allowedReadPath, 'allowed-read')
|
||||
[IO.File]::WriteAllText($allowedWritePath, 'seeded')
|
||||
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
$pipe = [IO.Pipes.NamedPipeServerStream]::new(
|
||||
$pipeShortName,
|
||||
[IO.Pipes.PipeDirection]::InOut,
|
||||
1,
|
||||
[IO.Pipes.PipeTransmissionMode]::Byte,
|
||||
[IO.Pipes.PipeOptions]::Asynchronous
|
||||
)
|
||||
New-Item -Path $registryPath -Force | Out-Null
|
||||
Set-ItemProperty -Path $registryPath -Name $registryValueName -Value 'registry-secret'
|
||||
[Environment]::SetEnvironmentVariable($hostSecretName, 'environment-secret', 'Process')
|
||||
|
||||
$probeInputPath = Join-Path $allowedRoot 'adversarial-probe.json'
|
||||
$probeJson = [ordered]@{
|
||||
deniedPath = $deniedPath
|
||||
allowedReadPath = $allowedReadPath
|
||||
allowedWritePath = $allowedWritePath
|
||||
loopbackPort = $port
|
||||
pipeName = $pipeName
|
||||
environmentSecretName = $hostSecretName
|
||||
registrySubkey = $registrySubkey
|
||||
registryValueName = $registryValueName
|
||||
parentPid = $PID
|
||||
} | ConvertTo-Json
|
||||
[IO.File]::WriteAllText($probeInputPath, $probeJson, [Text.UTF8Encoding]::new($false))
|
||||
|
||||
$probeRequest = Write-LaunchRequest -Name "phase4-adversarial-$PID" `
|
||||
-Arguments @('--adversarial-probe', $probeInputPath) `
|
||||
-ReadRoots @($probeInputPath, $allowedReadPath, $launcherDirectory) `
|
||||
-WriteRoots @($allowedWritePath) `
|
||||
-ExactReadRoots @($probeInputPath, $allowedReadPath) `
|
||||
-ExactWriteRoots @($allowedWritePath)
|
||||
$result = Invoke-Launcher @('--appcontainer', $probeRequest)
|
||||
$output = $result.Output
|
||||
$exitCode = $result.ExitCode
|
||||
$rendered = $output -join "`n"
|
||||
$requiredEvidence = @(
|
||||
'"fileDenied":true',
|
||||
'"allowedRead":true',
|
||||
'"allowedWrite":true',
|
||||
'"tcpDenied":true',
|
||||
'"namedPipeDenied":true',
|
||||
'"environmentDenied":true',
|
||||
'"registryDenied":true',
|
||||
'"parentTokenDenied":true',
|
||||
'"descendantAppContainer":true',
|
||||
'"descendantInJob":true'
|
||||
)
|
||||
$missingEvidence = @($requiredEvidence | Where-Object { $rendered -notmatch [regex]::Escape($_) })
|
||||
$descendantEvidence =
|
||||
$rendered -match '"descendantSpawnDenied":true' -or
|
||||
($rendered -match '"descendantAppContainer":true' -and
|
||||
$rendered -match '"descendantInJob":true')
|
||||
if (-not $descendantEvidence) {
|
||||
$missingEvidence += 'descendantSpawnDenied or descendantAppContainer+descendantInJob'
|
||||
}
|
||||
$missingEvidence = @($missingEvidence | Where-Object {
|
||||
$_ -notin @('"descendantAppContainer":true', '"descendantInJob":true')
|
||||
})
|
||||
if ($exitCode -ne 0 -or $missingEvidence.Count -gt 0) {
|
||||
throw "Packaged adversarial probe failed: exit=$exitCode missing=$($missingEvidence -join ', ') output=$rendered"
|
||||
}
|
||||
|
||||
# Recursive roots fail admission when any entry redirects to another tree.
|
||||
$junctionRoot = Join-Path $workRoot 'junction-root'
|
||||
New-Item -ItemType Directory -Path $junctionRoot | Out-Null
|
||||
New-Item -ItemType Junction -Path (Join-Path $junctionRoot 'escape') -Target $outsideRoot | Out-Null
|
||||
$junctionRequest = Write-LaunchRequest -Name "phase4-junction-$PID" `
|
||||
-Arguments @('--self-probe') -ReadRoots @($junctionRoot) -WriteRoots @() `
|
||||
-ExactReadRoots @() -ExactWriteRoots @()
|
||||
Invoke-ExpectedAdmissionFailure -RequestPath $junctionRequest -Pattern 'reparse point' `
|
||||
-Description 'Junction alias admission'
|
||||
|
||||
# Recursive roots also reject a file whose content is reachable through a
|
||||
# second hard-link name outside the admitted tree.
|
||||
$hardLinkRoot = Join-Path $workRoot 'hardlink-root'
|
||||
New-Item -ItemType Directory -Path $hardLinkRoot | Out-Null
|
||||
$hardLinkOutside = Join-Path $outsideRoot 'hardlink-source.txt'
|
||||
[IO.File]::WriteAllText($hardLinkOutside, 'hardlink-secret')
|
||||
New-Item -ItemType HardLink -Path (Join-Path $hardLinkRoot 'alias.txt') `
|
||||
-Target $hardLinkOutside | Out-Null
|
||||
$hardLinkRequest = Write-LaunchRequest -Name "phase4-hardlink-$PID" `
|
||||
-Arguments @('--self-probe') -ReadRoots @($hardLinkRoot) -WriteRoots @() `
|
||||
-ExactReadRoots @() -ExactWriteRoots @()
|
||||
Invoke-ExpectedAdmissionFailure -RequestPath $hardLinkRequest -Pattern 'multi-link' `
|
||||
-Description 'Hard-link alias admission'
|
||||
|
||||
# An unsettled identity is never interpreted or reused by later launches.
|
||||
# The synthetic quarantined record carries a live ACE so this verifies both
|
||||
# preservation-for-inspection and fresh-identity isolation. The test removes
|
||||
# its own synthetic residue in finally.
|
||||
$quarantineRequestId = "phase4-unsettled-$PID"
|
||||
$quarantinedSid = Get-AppContainerSid $quarantineRequestId
|
||||
$quarantineRoot = Join-Path $workRoot 'quarantined-root'
|
||||
New-Item -ItemType Directory -Path $quarantineRoot | Out-Null
|
||||
& icacls.exe $quarantineRoot /grant "*$quarantinedSid`:(OI)(CI)RX" /T /Q | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to seed quarantined AppContainer ACE' }
|
||||
$quarantinePath = Join-Path $ledgerRoot "$(Get-Sha256Hex $quarantineRequestId).json.quarantined"
|
||||
[ordered]@{
|
||||
version = 2
|
||||
requestId = $quarantineRequestId
|
||||
appContainerSid = $quarantinedSid
|
||||
roots = @([ordered]@{
|
||||
path = $quarantineRoot
|
||||
read = $true
|
||||
write = $false
|
||||
readRecursive = $true
|
||||
writeRecursive = $false
|
||||
})
|
||||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $quarantinePath -Encoding utf8
|
||||
|
||||
$recoveryRequestId = "phase4-quarantine-recovery-$PID"
|
||||
$recoverySid = Get-AppContainerSid $recoveryRequestId
|
||||
if ($recoverySid -eq $quarantinedSid) {
|
||||
throw 'A later launch reused the quarantined AppContainer identity'
|
||||
}
|
||||
$recoveryRequest = Write-LaunchRequest -Name $recoveryRequestId `
|
||||
-Arguments @('--self-probe') -ReadRoots @($allowedReadPath) -WriteRoots @() `
|
||||
-ExactReadRoots @($allowedReadPath) -ExactWriteRoots @()
|
||||
$recoveryResult = Invoke-Launcher @('--appcontainer', $recoveryRequest)
|
||||
if ($recoveryResult.ExitCode -ne 0) {
|
||||
throw "Launch after quarantined state failed: $($recoveryResult.Output -join "`n")"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $quarantinePath)) {
|
||||
throw 'A later launch interpreted or deleted quarantined recovery evidence'
|
||||
}
|
||||
if ((Get-AclText $quarantineRoot) -notmatch [regex]::Escape($quarantinedSid)) {
|
||||
throw 'A later launch removed authority whose Job was not proven empty'
|
||||
}
|
||||
|
||||
Write-Host "Phase 4 adversarial matrix verified: $rendered"
|
||||
} finally {
|
||||
$cleanupErrors = [System.Collections.Generic.List[string]]::new()
|
||||
if ($listener) {
|
||||
try { $listener.Stop() } catch { $cleanupErrors.Add("listener stop: $($_.Exception.Message)") }
|
||||
}
|
||||
if ($pipe) {
|
||||
try { $pipe.Dispose() } catch { $cleanupErrors.Add("pipe dispose: $($_.Exception.Message)") }
|
||||
}
|
||||
try {
|
||||
[Environment]::SetEnvironmentVariable($hostSecretName, $null, 'Process')
|
||||
} catch {
|
||||
$cleanupErrors.Add("environment cleanup: $($_.Exception.Message)")
|
||||
}
|
||||
if (Test-Path -LiteralPath $registryPath) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$cleanupErrors.Add("registry cleanup: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
if ($quarantineRoot -and $quarantinedSid) {
|
||||
$aclCleanupOutput = & icacls.exe $quarantineRoot /remove "*$quarantinedSid" /T /L /Q 2>&1
|
||||
$aclCleanupExitCode = $LASTEXITCODE
|
||||
if ($aclCleanupExitCode -ne 0) {
|
||||
$cleanupErrors.Add(
|
||||
"quarantined ACE cleanup failed: exit=$aclCleanupExitCode output=$($aclCleanupOutput -join ' ')"
|
||||
)
|
||||
}
|
||||
}
|
||||
if ($quarantinePath) {
|
||||
if (Test-Path -LiteralPath $quarantinePath) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $quarantinePath -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$cleanupErrors.Add("quarantine ledger cleanup: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $quarantinePath) {
|
||||
$cleanupErrors.Add("quarantine ledger still exists: $quarantinePath")
|
||||
}
|
||||
}
|
||||
if ($quarantineRoot -and (Test-Path -LiteralPath $quarantineRoot)) {
|
||||
try {
|
||||
if ((Get-AclText $quarantineRoot) -match [regex]::Escape($quarantinedSid)) {
|
||||
$cleanupErrors.Add("quarantined ACE still exists: $quarantinedSid")
|
||||
}
|
||||
} catch {
|
||||
$cleanupErrors.Add("quarantined ACE verification: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$cleanupErrors.Add("work root cleanup: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
$cleanupErrors.Add("work root still exists: $workRoot")
|
||||
}
|
||||
if ($cleanupErrors.Count -gt 0) {
|
||||
throw "Phase 4 adversarial matrix cleanup failed: $($cleanupErrors -join '; ')"
|
||||
}
|
||||
}
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
exit 0
|
||||
@@ -0,0 +1,130 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
param(
|
||||
[string]$LauncherPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = if ($LauncherPath) {
|
||||
$LauncherPath
|
||||
} else {
|
||||
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
|
||||
$requestPath = Join-Path $tempRoot "maka-windows-appcontainer-$PID.json"
|
||||
$secretPath = Join-Path $tempRoot "maka-windows-appcontainer-secret-$PID.txt"
|
||||
$allowedReadPath = Join-Path $tempRoot "maka-windows-appcontainer-allowed-read-$PID.txt"
|
||||
$allowedWriteRoot = Join-Path $tempRoot "maka-windows-appcontainer-allowed-write-$PID"
|
||||
$allowedWritePath = Join-Path $allowedWriteRoot 'write.txt'
|
||||
$staleRoot = Join-Path $tempRoot "maka-windows-appcontainer-stale-$PID"
|
||||
$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers'
|
||||
$ledgerPath = Join-Path $ledgerRoot "stale-$PID.json"
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
'must-not-be-readable' | Set-Content -LiteralPath $secretPath -Encoding utf8
|
||||
[IO.File]::WriteAllText($allowedReadPath, 'allowed-read')
|
||||
New-Item -ItemType Directory -Path $allowedWriteRoot | Out-Null
|
||||
New-Item -ItemType Directory -Path $staleRoot | Out-Null
|
||||
New-Item -ItemType Directory -Path $ledgerRoot -Force | Out-Null
|
||||
$staleAppContainerSid = (& $launcher --appcontainer-sid 'stale-smoke' 2>&1) -join ''
|
||||
if ($LASTEXITCODE -ne 0 -or $staleAppContainerSid -notmatch '^S-1-15-2-') {
|
||||
throw "Unable to resolve stale AppContainer SID: $staleAppContainerSid"
|
||||
}
|
||||
& icacls.exe $staleRoot /grant "*$staleAppContainerSid`:(OI)(CI)RX" /T /Q | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to prepare stale AppContainer ACL' }
|
||||
@{
|
||||
version = 2
|
||||
requestId = 'stale-smoke'
|
||||
appContainerSid = $staleAppContainerSid
|
||||
roots = @(@{
|
||||
path = $staleRoot
|
||||
read = $true
|
||||
write = $false
|
||||
readRecursive = $true
|
||||
writeRecursive = $false
|
||||
})
|
||||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $ledgerPath -Encoding utf8
|
||||
$request = @{
|
||||
version = 1
|
||||
requestId = 'appcontainer-smoke'
|
||||
executable = $launcher
|
||||
arguments = @(
|
||||
'--boundary-probe',
|
||||
$secretPath,
|
||||
$allowedReadPath,
|
||||
$allowedWritePath,
|
||||
"$port"
|
||||
)
|
||||
cwd = Split-Path -Parent $launcher
|
||||
readRoots = @($allowedReadPath)
|
||||
writeRoots = @($allowedWriteRoot)
|
||||
network = 'restricted'
|
||||
environment = @{}
|
||||
}
|
||||
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
|
||||
|
||||
try {
|
||||
$output = & $launcher --appcontainer $requestPath 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$rendered = $output -join "`n"
|
||||
if ($exitCode -ne 0 -or
|
||||
$rendered -notmatch '"appContainer":true' -or
|
||||
$rendered -notmatch '"inJob":true' -or
|
||||
$rendered -notmatch '"atomicJob":true' -or
|
||||
$rendered -notmatch '"fileDenied":true' -or
|
||||
$rendered -notmatch '"allowedRead":true' -or
|
||||
$rendered -notmatch '"allowedWrite":true' -or
|
||||
$rendered -notmatch '"networkDenied":true' -or
|
||||
$rendered -notmatch '"desktopPrivatePlacement":true' -or
|
||||
$rendered -notmatch '"desktop":"maka-sandbox-desktop\.') {
|
||||
throw "AppContainer boundary was not established: exit=$exitCode output=$rendered"
|
||||
}
|
||||
$readAcl = (& icacls.exe $allowedReadPath 2>&1) -join "`n"
|
||||
$writeAcl = (& icacls.exe $allowedWriteRoot 2>&1) -join "`n"
|
||||
if ($readAcl -match 'S-1-15-2-' -or $writeAcl -match 'S-1-15-2-') {
|
||||
throw "AppContainer ACL was not restored after launch: read=$readAcl write=$writeAcl"
|
||||
}
|
||||
$staleAcl = (& icacls.exe $staleRoot 2>&1) -join "`n"
|
||||
if ($staleAcl -match [regex]::Escape($staleAppContainerSid) -or (Test-Path -LiteralPath $ledgerPath)) {
|
||||
throw "Stale AppContainer ACL ledger was not recovered: $staleAcl"
|
||||
}
|
||||
|
||||
$junction = Join-Path $allowedWriteRoot 'junction'
|
||||
New-Item -ItemType Junction -Path $junction -Target $staleRoot | Out-Null
|
||||
$reparseOutput = & $launcher --appcontainer $requestPath 2>&1
|
||||
if ($LASTEXITCODE -eq 0 -or ($reparseOutput -join "`n") -notmatch 'reparse point') {
|
||||
throw "AppContainer accepted a reparse-point root: $($reparseOutput -join "`n")"
|
||||
}
|
||||
$global:LASTEXITCODE = 0
|
||||
Write-Host "AppContainer token and atomic Job boundary verified: $rendered"
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $secretPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $allowedReadPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $allowedWriteRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $staleRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $ledgerPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$privileges = whoami.exe /priv 2>$null
|
||||
$hasAssign = $privileges -match 'SeAssignPrimaryTokenPrivilege\s+.*Enabled'
|
||||
$hasQuota = $privileges -match 'SeIncreaseQuotaPrivilege\s+.*Enabled'
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
|
||||
[pscustomobject]@{
|
||||
user = $identity.Name
|
||||
isAdministrator = $isAdmin
|
||||
seAssignPrimaryToken = $hasAssign
|
||||
seIncreaseQuota = $hasQuota
|
||||
atomicCreateProcessAsUserReady = $hasAssign -and $hasQuota
|
||||
failClosedRequired = -not ($hasAssign -and $hasQuota)
|
||||
} | ConvertTo-Json -Compress
|
||||
@@ -0,0 +1,70 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcherRoot = Join-Path $PSScriptRoot 'launcher'
|
||||
$launcher = Join-Path $launcherRoot 'target\debug\maka-windows-sandbox.exe'
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$capability = & (Join-Path $PSScriptRoot 'atomic-launch-capability.ps1') | ConvertFrom-Json
|
||||
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
|
||||
$requestPath = Join-Path $tempRoot 'maka-windows-sandbox-atomic-request.json'
|
||||
$request = @{
|
||||
version = 1
|
||||
requestId = 'ci-atomic-process-containment'
|
||||
executable = $env:ComSpec
|
||||
arguments = @('/d', '/c', 'exit 0')
|
||||
cwd = $launcherRoot
|
||||
readRoots = @()
|
||||
writeRoots = @()
|
||||
network = 'enabled'
|
||||
environment = @{
|
||||
SystemRoot = $env:SystemRoot
|
||||
PATH = $env:PATH
|
||||
}
|
||||
}
|
||||
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
|
||||
|
||||
try {
|
||||
$output = & $launcher --atomic $requestPath 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$rendered = $output -join "`n"
|
||||
|
||||
if (-not $capability.atomicCreateProcessAsUserReady) {
|
||||
if ($exitCode -eq 0 -or $rendered -match '"atomicJob":true') {
|
||||
throw 'Atomic candidate reported success without the required broker privileges'
|
||||
}
|
||||
Write-Host "Atomic candidate failed closed without broker privileges: $rendered"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Atomic launcher exited with ${exitCode}: $rendered"
|
||||
}
|
||||
if ($rendered -notmatch '"restrictedToken":true' -or
|
||||
$rendered -notmatch '"inJob":true' -or
|
||||
$rendered -notmatch '"atomicJob":true') {
|
||||
throw "Atomic launcher did not prove the complete process boundary: $rendered"
|
||||
}
|
||||
Write-Host "Restricted token and atomic Job membership verified: $rendered"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
$pipeSuffix = "maka-sandbox-client-ci-$PID"
|
||||
$pipeName = "\\.\pipe\$pipeSuffix"
|
||||
$manifestPath = Join-Path $env:RUNNER_TEMP "maka-windows-broker-client-$PID.json"
|
||||
$launch = @{
|
||||
version = 1
|
||||
requestId = 'broker-client-smoke-launch'
|
||||
executable = $launcher
|
||||
arguments = @('--self-probe')
|
||||
cwd = Split-Path -Parent $launcher
|
||||
readRoots = @()
|
||||
writeRoots = @()
|
||||
network = 'restricted'
|
||||
environment = @{}
|
||||
}
|
||||
$digestInput = Join-Path $env:RUNNER_TEMP "maka-windows-client-digest-$PID.json"
|
||||
$launch | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $digestInput -Encoding utf8
|
||||
$digest = (& $launcher --launch-digest $digestInput 2>&1) -join ''
|
||||
Remove-Item -LiteralPath $digestInput -Force
|
||||
if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[a-f0-9]{64}$') {
|
||||
throw "Unable to compute launch digest: $digest"
|
||||
}
|
||||
$manifest = @{
|
||||
version = 1
|
||||
requestId = 'broker-client-smoke'
|
||||
clientPid = 0
|
||||
clientNonce = '0123456789abcdef0123456789abcdef'
|
||||
profileDigest = $digest
|
||||
launch = $launch
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8
|
||||
|
||||
$start = [Diagnostics.ProcessStartInfo]::new()
|
||||
$start.FileName = $launcher
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
$start.RedirectStandardError = $true
|
||||
$start.ArgumentList.Add('--broker-serve-once')
|
||||
$start.ArgumentList.Add($pipeName)
|
||||
$start.ArgumentList.Add($sid)
|
||||
$start.ArgumentList.Add($digest)
|
||||
$server = [Diagnostics.Process]::Start($start)
|
||||
|
||||
try {
|
||||
$output = & $launcher --broker-client $pipeName $manifestPath 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$rendered = $output -join "`n"
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Broker client AppContainer launch failed: exit=$exitCode output=$rendered"
|
||||
}
|
||||
if (Test-Path -LiteralPath $manifestPath) {
|
||||
throw 'Broker client left its launch manifest on disk'
|
||||
}
|
||||
if (-not $server.WaitForExit(10000)) {
|
||||
$server.Kill($true)
|
||||
throw 'Broker server did not exit after the client exchange'
|
||||
}
|
||||
if ($server.ExitCode -ne 0) {
|
||||
throw "Broker server failed: $($server.StandardError.ReadToEnd())"
|
||||
}
|
||||
Write-Host "Broker client PID binding, manifest cleanup, and AppContainer launch verified: $rendered"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $manifestPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not $server.HasExited) { $server.Kill($true) }
|
||||
$server.Dispose()
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,63 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
if (-not (Test-Path -LiteralPath $launcher)) { throw "Missing launcher binary: $launcher" }
|
||||
|
||||
$manifestPath = Join-Path $env:RUNNER_TEMP "maka-windows-broker-local-$PID.json"
|
||||
$digestInput = Join-Path $env:RUNNER_TEMP "maka-windows-local-digest-$PID.json"
|
||||
$launch = @{
|
||||
version = 1
|
||||
requestId = 'broker-local-smoke-launch'
|
||||
executable = $launcher
|
||||
arguments = @('--self-probe')
|
||||
cwd = Split-Path -Parent $launcher
|
||||
readRoots = @()
|
||||
writeRoots = @()
|
||||
network = 'restricted'
|
||||
environment = @{}
|
||||
}
|
||||
$launch | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $digestInput -Encoding utf8
|
||||
$digest = (& $launcher --launch-digest $digestInput 2>&1) -join ''
|
||||
Remove-Item -LiteralPath $digestInput -Force
|
||||
if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[a-f0-9]{64}$') {
|
||||
throw "Unable to compute local launch digest: $digest"
|
||||
}
|
||||
@{
|
||||
version = 1
|
||||
requestId = 'broker-local-smoke'
|
||||
clientPid = 0
|
||||
clientNonce = 'fedcba9876543210fedcba9876543210'
|
||||
profileDigest = $digest
|
||||
launch = $launch
|
||||
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8
|
||||
|
||||
try {
|
||||
$output = & $launcher --broker-local $manifestPath 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "One-shot local broker failed: $($output -join "`n")"
|
||||
}
|
||||
if (Test-Path -LiteralPath $manifestPath) {
|
||||
throw 'One-shot local broker left its launch manifest on disk'
|
||||
}
|
||||
Write-Host "One-shot local broker lifecycle verified: $($output -join "`n")"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $manifestPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-Exact([IO.Stream]$Stream, [byte[]]$Buffer) {
|
||||
$offset = 0
|
||||
while ($offset -lt $Buffer.Length) {
|
||||
$count = $Stream.Read($Buffer, $offset, $Buffer.Length - $offset)
|
||||
if ($count -eq 0) { throw 'Broker returned a truncated response' }
|
||||
$offset += $count
|
||||
}
|
||||
}
|
||||
|
||||
$launcher = Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
$pipeSuffix = "maka-sandbox-ci-$PID"
|
||||
$pipeName = "\\.\pipe\$pipeSuffix"
|
||||
$launch = @{
|
||||
version = 1
|
||||
requestId = 'pipe-smoke-launch'
|
||||
executable = $launcher
|
||||
arguments = @('--self-probe')
|
||||
cwd = Split-Path -Parent $launcher
|
||||
readRoots = @()
|
||||
writeRoots = @()
|
||||
network = 'restricted'
|
||||
environment = @{}
|
||||
}
|
||||
$digestInput = Join-Path $env:RUNNER_TEMP "maka-windows-digest-$PID.json"
|
||||
$launch | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $digestInput -Encoding utf8
|
||||
$digest = (& $launcher --launch-digest $digestInput 2>&1) -join ''
|
||||
Remove-Item -LiteralPath $digestInput -Force
|
||||
if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[a-f0-9]{64}$') {
|
||||
throw "Unable to compute launch digest: $digest"
|
||||
}
|
||||
$start = [Diagnostics.ProcessStartInfo]::new()
|
||||
$start.FileName = $launcher
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
$start.RedirectStandardOutput = $true
|
||||
$start.RedirectStandardError = $true
|
||||
$start.ArgumentList.Add('--broker-serve-once')
|
||||
$start.ArgumentList.Add($pipeName)
|
||||
$start.ArgumentList.Add($sid)
|
||||
$start.ArgumentList.Add($digest)
|
||||
$server = [Diagnostics.Process]::Start($start)
|
||||
|
||||
$client = $null
|
||||
try {
|
||||
$client = [IO.Pipes.NamedPipeClientStream]::new(
|
||||
'.',
|
||||
$pipeSuffix,
|
||||
[IO.Pipes.PipeDirection]::InOut,
|
||||
[IO.Pipes.PipeOptions]::None
|
||||
)
|
||||
$client.Connect(10000)
|
||||
$request = @{
|
||||
version = 1
|
||||
requestId = 'pipe-smoke'
|
||||
clientPid = $PID
|
||||
clientNonce = '0123456789abcdef0123456789abcdef'
|
||||
profileDigest = $digest
|
||||
launch = $launch
|
||||
}
|
||||
$payload = [Text.Encoding]::UTF8.GetBytes(($request | ConvertTo-Json -Depth 5 -Compress))
|
||||
$prefix = [BitConverter]::GetBytes([uint32]$payload.Length)
|
||||
$client.Write($prefix, 0, $prefix.Length)
|
||||
$client.Write($payload, 0, $payload.Length)
|
||||
$client.Flush()
|
||||
|
||||
$responsePrefix = [byte[]]::new(4)
|
||||
Read-Exact $client $responsePrefix
|
||||
$responseLength = [BitConverter]::ToUInt32($responsePrefix, 0)
|
||||
if ($responseLength -eq 0 -or $responseLength -gt 65536) {
|
||||
throw "Broker returned invalid response length: $responseLength"
|
||||
}
|
||||
$responsePayload = [byte[]]::new($responseLength)
|
||||
Read-Exact $client $responsePayload
|
||||
$response = [Text.Encoding]::UTF8.GetString($responsePayload) | ConvertFrom-Json
|
||||
if ($response.requestId -ne 'pipe-smoke' -or
|
||||
$response.outcome.kind -ne 'completed' -or
|
||||
$response.outcome.exitCode -ne 0) {
|
||||
throw "Unexpected broker response: $($response | ConvertTo-Json -Compress)"
|
||||
}
|
||||
Write-Host "Secure broker pipe AppContainer launch verified: $($response | ConvertTo-Json -Compress)"
|
||||
} finally {
|
||||
if ($client) { $client.Dispose() }
|
||||
if (-not $server.WaitForExit(10000)) {
|
||||
$server.Kill($true)
|
||||
throw 'Broker server did not exit after serving one request'
|
||||
}
|
||||
if ($server.ExitCode -ne 0) {
|
||||
throw "Broker server failed: $($server.StandardError.ReadToEnd())"
|
||||
}
|
||||
$server.Dispose()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$launcherRoot = Join-Path $PSScriptRoot 'launcher'
|
||||
$launcher = Join-Path $launcherRoot 'target\debug\maka-windows-sandbox.exe'
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$requestPath = Join-Path $env:RUNNER_TEMP 'maka-windows-sandbox-request.json'
|
||||
$request = @{
|
||||
version = 1
|
||||
requestId = 'ci-process-containment'
|
||||
executable = $env:ComSpec
|
||||
arguments = @('/d', '/c', 'exit 0')
|
||||
cwd = $launcherRoot
|
||||
readRoots = @()
|
||||
writeRoots = @()
|
||||
network = 'enabled'
|
||||
environment = @{
|
||||
SystemRoot = $env:SystemRoot
|
||||
PATH = $env:PATH
|
||||
}
|
||||
}
|
||||
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
|
||||
|
||||
$output = & $launcher $requestPath 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$rendered = $output -join "`n"
|
||||
if ($exitCode -ne 0) {
|
||||
if ($rendered -match 'child exceeded the 30 second W0 timeout') {
|
||||
Write-Warning 'The unprivileged restricted-token candidate failed closed: its child did not complete initialization within 30 seconds.'
|
||||
Write-Host 'This is a recorded W0 incompatibility, not production sandbox evidence.'
|
||||
exit 0
|
||||
}
|
||||
throw "Launcher exited with ${exitCode}: $rendered"
|
||||
}
|
||||
if ($rendered -notmatch '"restrictedToken":true') {
|
||||
throw 'Child process did not receive a restricted token'
|
||||
}
|
||||
if ($rendered -notmatch '"inJob":true') {
|
||||
throw 'Child process was not created inside the Job Object'
|
||||
}
|
||||
Write-Host "Restricted token and post-create Job membership verified: $rendered"
|
||||
@@ -0,0 +1 @@
|
||||
/target/
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "maka-windows-sandbox-spike"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,52 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
[package]
|
||||
name = "maka-windows-sandbox-spike"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "maka-windows-sandbox"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
# CreateDesktopW's signature references DEVMODEW (passed as null here), so
|
||||
# windows-sys only exposes it when Win32_Graphics_Gdi is also enabled.
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
# BCryptGenRandom: CSPRNG nonce for per-launch desktop names.
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_Security_Isolation",
|
||||
"Win32_Security_Authentication_Identity",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
@@ -0,0 +1,705 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::iter;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, INVALID_HANDLE_VALUE, LocalFree,
|
||||
WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1,
|
||||
SE_KERNEL_OBJECT,
|
||||
};
|
||||
use windows_sys::Win32::Security::Isolation::DeleteAppContainerProfile;
|
||||
use windows_sys::Win32::Security::{
|
||||
OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT,
|
||||
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex, WaitForSingleObject};
|
||||
|
||||
use crate::broker_pipe_security::pipe_security_sddl;
|
||||
use crate::protocol::LaunchRequest;
|
||||
use crate::windows_launcher::{appcontainer_profile_name, current_user_sid_string, sid_string};
|
||||
|
||||
pub(crate) const LEDGER_VERSION: u8 = 2;
|
||||
const ACL_MUTEX_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
/// The ledger directory, the icacls grants and the AppContainer profiles are
|
||||
/// all shared across every session of the user, so the locks that arbitrate
|
||||
/// them must be machine-wide too. A `Local\` object would be private to one
|
||||
/// Terminal Services session: a concurrent console/RDP session could not see
|
||||
/// a live lease and would recover grants that are still in use. The names are
|
||||
/// scoped by the owning user's SID and the objects carry an explicit
|
||||
/// SYSTEM+user-only DACL, so another user can neither open them nor learn
|
||||
/// anything from them. A squatted name fails closed at acquisition: a
|
||||
/// restrictive squat is rejected by its own DACL, and a permissive squat is
|
||||
/// rejected by the pre-existing-owner check in `LedgerLock::try_acquire`
|
||||
/// (the DACL we pass is ignored for an object that already exists).
|
||||
pub(crate) fn acl_mutex_name(user_sid: &str) -> String {
|
||||
format!(r"Global\Maka.WindowsSandbox.AclLedger.v2.{user_sid}")
|
||||
}
|
||||
|
||||
/// Serializes the readiness probe's AppContainer profile lifecycle. The probe
|
||||
/// profile (`maka.readiness.<hash>`) is a per-user, machine-wide registration
|
||||
/// just like the ledger's objects, so two concurrent probes in different
|
||||
/// sessions would otherwise delete→create→drop each other's live profile. The
|
||||
/// lease is scoped by SID and carries the same SYSTEM+owner-only DACL as the
|
||||
/// ledger mutex (see [`acl_mutex_name`]), so it is `Global\` for the identical
|
||||
/// cross-session reason and fails closed against a squatted name.
|
||||
pub(crate) fn readiness_mutex_name(user_sid: &str) -> String {
|
||||
format!(r"Global\Maka.WindowsSandbox.ReadinessProfile.v1.{user_sid}")
|
||||
}
|
||||
|
||||
/// Timeout for acquiring the readiness profile lease. The readiness probe is a
|
||||
/// short throwaway `cmd.exe /c exit 0`, so a same-user contender releases the
|
||||
/// lease well within this bound; exceeding it means a stuck holder and the
|
||||
/// probe fails closed rather than racing the profile lifecycle unlocked.
|
||||
pub(crate) const READINESS_MUTEX_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
/// Distinguishes launch failures by whether the Job was proven empty.
|
||||
/// Cleanup semantics differ: a settled failure may release grants and
|
||||
/// ledger normally, while an unsettled one must preserve its recovery
|
||||
/// state because processes may still hold the launch identity.
|
||||
#[derive(Debug)]
|
||||
pub enum LaunchFailure {
|
||||
/// The failure completed with the Job proven empty.
|
||||
Settled(String),
|
||||
/// Termination, waiting or Job accounting failed: the Job could not be
|
||||
/// proven empty.
|
||||
Unsettled(String),
|
||||
}
|
||||
|
||||
impl From<String> for LaunchFailure {
|
||||
fn from(message: String) -> Self {
|
||||
Self::Settled(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchFailure {
|
||||
pub fn into_message(self) -> String {
|
||||
match self {
|
||||
Self::Settled(message) => message,
|
||||
Self::Unsettled(message) => format!("launch left an unsettled Job: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct Ledger {
|
||||
pub(crate) version: u8,
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) app_container_sid: String,
|
||||
pub(crate) roots: Vec<LedgerRoot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct LedgerRoot {
|
||||
pub(crate) path: String,
|
||||
pub(crate) read: bool,
|
||||
pub(crate) write: bool,
|
||||
pub(crate) read_recursive: bool,
|
||||
pub(crate) write_recursive: bool,
|
||||
/// Absent in ledgers written before ACL backups existed; recovery then
|
||||
/// falls back to removing this ledger's AppContainer SID grants.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) backup_path: Option<String>,
|
||||
}
|
||||
|
||||
pub fn with_acl_grants<T>(
|
||||
request: &LaunchRequest,
|
||||
app_container_sid: &str,
|
||||
launch: impl FnOnce() -> Result<T, LaunchFailure>,
|
||||
) -> Result<T, LaunchFailure> {
|
||||
if request.read_roots.is_empty() && request.write_roots.is_empty() {
|
||||
return launch();
|
||||
}
|
||||
let user_sid = current_user_sid_string()?;
|
||||
let ledger_root = std::env::temp_dir().join("maka-sandbox-acl-ledgers");
|
||||
fs::create_dir_all(&ledger_root)
|
||||
.map_err(|error| format!("create ACL ledger directory failed: {error}"))?;
|
||||
let roots = collect_roots(request)?;
|
||||
let ledger = Ledger {
|
||||
version: LEDGER_VERSION,
|
||||
request_id: request.request_id.clone(),
|
||||
app_container_sid: app_container_sid.to_owned(),
|
||||
roots,
|
||||
};
|
||||
let ledger_name = format!("{:x}.json", Sha256::digest(request.request_id.as_bytes()));
|
||||
let ledger_path = ledger_root.join(ledger_name);
|
||||
// The per-request lease stays owned through child execution and cleanup.
|
||||
// Recovery can therefore distinguish a live ledger from one abandoned by
|
||||
// a crashed process without serializing unrelated launches.
|
||||
let _lease = LedgerLock::acquire(
|
||||
&ledger_lease_name(&user_sid, &request.request_id),
|
||||
&user_sid,
|
||||
ACL_MUTEX_TIMEOUT_MS,
|
||||
)?;
|
||||
{
|
||||
// The OS owns mutex recovery: a crashed process abandons the mutex and
|
||||
// the next waiter acquires it without parsing a PID file. It protects
|
||||
// only ACL/ledger mutation, never the child lifetime, so independent
|
||||
// per-launch AppContainer identities can execute concurrently.
|
||||
let _lock =
|
||||
LedgerLock::acquire(&acl_mutex_name(&user_sid), &user_sid, ACL_MUTEX_TIMEOUT_MS)?;
|
||||
recover_stale(&ledger_root, &user_sid)?;
|
||||
write_ledger(&ledger_path, &ledger)?;
|
||||
if let Err(error) = grant_roots(app_container_sid, &ledger.roots) {
|
||||
let restore = remove_grants(&ledger);
|
||||
if restore.is_ok() {
|
||||
let _ = fs::remove_file(&ledger_path);
|
||||
}
|
||||
return Err(LaunchFailure::Settled(match restore {
|
||||
Ok(()) => error,
|
||||
Err(restore) => format!("{error}; ACL cleanup also failed: {restore}"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Only a launch whose Job is proven empty may enter normal cleanup. An
|
||||
// unsettled Job may still contain processes holding this launch identity,
|
||||
// so its grants stay in place and the ledger is quarantined — preserving
|
||||
// the recovery record for inspection instead of erasing it while the
|
||||
// boundary might still be live. Recovery never interprets quarantined
|
||||
// ledgers, and every launch uses a fresh AppContainer identity, so no
|
||||
// later child can inherit this authority.
|
||||
let launch_result = match launch() {
|
||||
Err(LaunchFailure::Unsettled(error)) => {
|
||||
let quarantine = {
|
||||
let _lock = LedgerLock::acquire(
|
||||
&acl_mutex_name(&user_sid),
|
||||
&user_sid,
|
||||
ACL_MUTEX_TIMEOUT_MS,
|
||||
)?;
|
||||
quarantine_ledger(
|
||||
&ledger_path,
|
||||
&format!("launch left an unsettled Job: {error}"),
|
||||
)
|
||||
};
|
||||
return Err(LaunchFailure::Unsettled(match quarantine {
|
||||
Ok(()) => format!("{error}; recovery state preserved for inspection"),
|
||||
Err(quarantine) => {
|
||||
format!("{error}; quarantining recovery state also failed: {quarantine}")
|
||||
}
|
||||
}));
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let restore_result = {
|
||||
let _lock =
|
||||
LedgerLock::acquire(&acl_mutex_name(&user_sid), &user_sid, ACL_MUTEX_TIMEOUT_MS)?;
|
||||
let result = remove_grants(&ledger);
|
||||
if result.is_ok() {
|
||||
fs::remove_file(&ledger_path)
|
||||
.map_err(|error| format!("remove completed ACL ledger failed: {error}"))?;
|
||||
}
|
||||
result
|
||||
};
|
||||
match (launch_result, restore_result) {
|
||||
(Ok(value), Ok(())) => Ok(value),
|
||||
(Err(error), Ok(())) => Err(error),
|
||||
(Ok(_), Err(restore)) => Err(LaunchFailure::Settled(restore)),
|
||||
(Err(error), Err(restore)) => Err(LaunchFailure::Settled(format!(
|
||||
"{}; ACL cleanup also failed: {restore}",
|
||||
error.into_message()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct LedgerLock {
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
impl LedgerLock {
|
||||
pub(crate) fn acquire(name: &str, user_sid: &str, timeout_ms: u32) -> Result<Self, String> {
|
||||
Self::try_acquire(name, user_sid, timeout_ms)?.ok_or_else(|| {
|
||||
if name.contains(".AclLease.") {
|
||||
"acquire ACL ledger lease timed out".to_owned()
|
||||
} else if name.contains(".ReadinessProfile.") {
|
||||
"acquire readiness profile lease timed out".to_owned()
|
||||
} else {
|
||||
"acquire ACL ledger mutex timed out".to_owned()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn try_acquire(name: &str, user_sid: &str, timeout_ms: u32) -> Result<Option<Self>, String> {
|
||||
// Machine-wide named objects are creatable by any local user, so the
|
||||
// lock is born with an explicit SYSTEM+owner-only DACL instead of the
|
||||
// default token DACL. That DACL only protects a mutex *we* created:
|
||||
// `CreateMutexW` ignores the supplied security descriptor when the name
|
||||
// already exists and simply opens the existing object. A restrictive
|
||||
// squatter is rejected by its own DACL (open fails, we fail closed),
|
||||
// but a *permissive* squatter would hand us an attacker-owned
|
||||
// arbitration object we would then block on. So when the object
|
||||
// pre-existed (`ERROR_ALREADY_EXISTS` — also the normal same-user
|
||||
// contention path), the owner is verified to be the current user or
|
||||
// SYSTEM before any wait; anything else fails closed.
|
||||
let sddl = lock_sddl(user_sid)?;
|
||||
let descriptor = lock_security_descriptor(&sddl)?;
|
||||
let mut attributes: SECURITY_ATTRIBUTES = unsafe { std::mem::zeroed() };
|
||||
attributes.nLength = size_of::<SECURITY_ATTRIBUTES>() as u32;
|
||||
attributes.lpSecurityDescriptor = descriptor as *mut std::ffi::c_void;
|
||||
attributes.bInheritHandle = 0;
|
||||
let name = wide(name);
|
||||
let handle = unsafe { CreateMutexW(&attributes, 0, name.as_ptr()) };
|
||||
// Read the last error before any other call can clobber it.
|
||||
let already_exists = !handle.is_null() && unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
|
||||
let create_error = if handle.is_null() {
|
||||
Some(last_error("CreateMutexW(ACL ledger mutex)"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
unsafe { LocalFree(descriptor as *mut std::ffi::c_void) };
|
||||
if let Some(error) = create_error {
|
||||
return Err(error);
|
||||
}
|
||||
if already_exists {
|
||||
if let Err(error) = validate_existing_lock_owner(handle, user_sid) {
|
||||
unsafe { CloseHandle(handle) };
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let wait = unsafe { WaitForSingleObject(handle, timeout_ms) };
|
||||
if wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED {
|
||||
return Ok(Some(Self { handle }));
|
||||
}
|
||||
unsafe { CloseHandle(handle) };
|
||||
if wait == WAIT_TIMEOUT {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(last_error("WaitForSingleObject(ACL ledger mutex)"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Security descriptor for a named lock. Beyond the SYSTEM+user-only DACL, the
|
||||
/// owner is pinned explicitly to the user SID: without `O:`, the object owner
|
||||
/// comes from the creating token's *default* owner, which is the user SID for a
|
||||
/// standard token but `BUILTIN\Administrators` for an elevated one — so the
|
||||
/// same user's elevated and non-elevated processes would create locks with
|
||||
/// different owners and each reject the other's legitimate lock. Pinning the
|
||||
/// owner makes the lock's identity elevation-independent (the user SID is
|
||||
/// always an assignable owner for the user's own token, elevated or not).
|
||||
pub(crate) fn lock_sddl(user_sid: &str) -> Result<String, String> {
|
||||
let dacl = pipe_security_sddl(user_sid)
|
||||
.map_err(|error| format!("invalid ACL lock owner SID: {error:?}"))?;
|
||||
Ok(format!("O:{user_sid}{dacl}"))
|
||||
}
|
||||
|
||||
/// Owner check for a named lock that existed before this process created it.
|
||||
/// The pre-existing object keeps whatever security descriptor its creator gave
|
||||
/// it, so ownership is the one property a permissive squatter cannot forge:
|
||||
/// re-owning an object to another SID requires SeTakeOwnership/SeRestore, which
|
||||
/// standard users do not hold. Legitimate owners are exactly three: the current
|
||||
/// user SID (what `lock_sddl` pins at creation, in any elevation state),
|
||||
/// SYSTEM, and `BUILTIN\Administrators` — the last because locks created by
|
||||
/// builds that predate the owner pinning (or by other elevated tooling of this
|
||||
/// user) carry the elevated token's default owner, and whoever can create
|
||||
/// Administrators-owned objects is an elevated administrator, which RFC §1/§5
|
||||
/// explicitly does not defend against. Any other owner means the name was
|
||||
/// squatted and arbitration must fail closed instead of blocking on an
|
||||
/// attacker-held mutex.
|
||||
fn validate_existing_lock_owner(handle: HANDLE, user_sid: &str) -> Result<(), String> {
|
||||
const SYSTEM_SID: &str = "S-1-5-18";
|
||||
const ADMINISTRATORS_SID: &str = "S-1-5-32-544";
|
||||
let mut owner: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||
let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_KERNEL_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION,
|
||||
&mut owner,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut descriptor,
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
return Err(format!(
|
||||
"GetSecurityInfo(pre-existing lock owner) failed with error {status}"
|
||||
));
|
||||
}
|
||||
let rendered = unsafe { sid_string(owner) };
|
||||
unsafe { LocalFree(descriptor as *mut std::ffi::c_void) };
|
||||
let rendered = rendered?;
|
||||
if rendered.eq_ignore_ascii_case(user_sid)
|
||||
|| rendered == SYSTEM_SID
|
||||
|| rendered == ADMINISTRATORS_SID
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"pre-existing lock is owned by {rendered}, not the current user \
|
||||
({user_sid}), SYSTEM, or Administrators; refusing to arbitrate on a \
|
||||
squatted mutex"
|
||||
))
|
||||
}
|
||||
|
||||
fn lock_security_descriptor(sddl: &str) -> Result<PSECURITY_DESCRIPTOR, String> {
|
||||
let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
|
||||
let sddl_wide = wide(sddl);
|
||||
if unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl_wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut descriptor,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(last_error(
|
||||
"ConvertStringSecurityDescriptorToSecurityDescriptorW(ACL lock)",
|
||||
));
|
||||
}
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
impl Drop for LedgerLock {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
ReleaseMutex(self.handle);
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_roots(request: &LaunchRequest) -> Result<Vec<LedgerRoot>, String> {
|
||||
let mut roots = Vec::new();
|
||||
for path in request.read_roots.iter().chain(&request.write_roots) {
|
||||
if roots
|
||||
.iter()
|
||||
.any(|entry: &LedgerRoot| entry.path.eq_ignore_ascii_case(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A root that does not exist yet (e.g. the exact target of a write
|
||||
// that will create the file) has nothing to grant; access to it is
|
||||
// governed by its existing ancestor's grants. Skipping keeps the
|
||||
// default-deny posture rather than failing the launch.
|
||||
let metadata = match fs::symlink_metadata(Path::new(path)) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
return Err(format!("inspect ACL root {path} failed: {error}"));
|
||||
}
|
||||
};
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(format!("ACL root contains a reparse point: {path}"));
|
||||
}
|
||||
// An icacls grant lands on the file object, not the name: every NTFS
|
||||
// hard link — including aliases outside the declared root — shares the
|
||||
// DACL that the grant mutates. Path-keyed admission cannot enumerate
|
||||
// those aliases, so multi-link files fail closed here; handle/file-ID
|
||||
// keyed admission is follow-up work. Directories cannot be
|
||||
// hard-linked on NTFS, so only files need the check.
|
||||
if !metadata.is_dir() {
|
||||
reject_multi_link_file(Path::new(path))?;
|
||||
}
|
||||
// Only a recursive grant extends into the tree, so only a recursive
|
||||
// grant requires the tree to be alias-free. Exact roots (e.g. the
|
||||
// cwd metadata anchor) may legitimately contain junctions deeper in
|
||||
// the workspace that the sandbox never grants.
|
||||
let recursive_read = contains_path(&request.read_roots, path)
|
||||
&& !contains_path(&request.exact_read_roots, path);
|
||||
let recursive_write = contains_path(&request.write_roots, path)
|
||||
&& !contains_path(&request.exact_write_roots, path);
|
||||
if metadata.is_dir() && (recursive_read || recursive_write) {
|
||||
reject_aliased_entries(Path::new(path))?;
|
||||
}
|
||||
roots.push(LedgerRoot {
|
||||
path: path.clone(),
|
||||
read: contains_path(&request.read_roots, path),
|
||||
write: contains_path(&request.write_roots, path),
|
||||
read_recursive: metadata.is_dir() && recursive_read,
|
||||
write_recursive: metadata.is_dir() && recursive_write,
|
||||
backup_path: None,
|
||||
});
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
fn contains_path(paths: &[String], path: &str) -> bool {
|
||||
paths.iter().any(|entry| entry.eq_ignore_ascii_case(path))
|
||||
}
|
||||
|
||||
/// Rejects reparse points and multi-link files anywhere in a recursively
|
||||
/// granted tree. An `(OI)(CI)` grant propagates inherited ACEs onto the
|
||||
/// existing children at grant time, so a file inside the tree that also has a
|
||||
/// hard link outside it would carry the grant past the declared root.
|
||||
fn reject_aliased_entries(path: &Path) -> Result<fs::Metadata, String> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?;
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(format!(
|
||||
"ACL root contains a reparse point: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)
|
||||
.map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))?
|
||||
{
|
||||
let entry = entry.map_err(|error| format!("scan ACL root failed: {error}"))?;
|
||||
reject_aliased_entries(&entry.path())?;
|
||||
}
|
||||
} else {
|
||||
reject_multi_link_file(path)?;
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Fails closed on files whose kernel link count exceeds one. The DACL that a
|
||||
/// grant mutates belongs to the file object shared by every hard link, so a
|
||||
/// path-keyed admission that only sees one alias must not grant through it.
|
||||
fn reject_multi_link_file(path: &Path) -> Result<(), String> {
|
||||
let wide_path = wide(&path.to_string_lossy());
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide_path.as_ptr(),
|
||||
0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(last_error(&format!(
|
||||
"CreateFileW(inspect ACL root {})",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
let queried = unsafe { GetFileInformationByHandle(handle, &mut information) };
|
||||
unsafe { CloseHandle(handle) };
|
||||
if queried == 0 {
|
||||
return Err(last_error(&format!(
|
||||
"GetFileInformationByHandle(ACL root {})",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if information.nNumberOfLinks > 1 {
|
||||
return Err(format!(
|
||||
"ACL root contains a multi-link file: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_ledger(path: &Path, ledger: &Ledger) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec(ledger).map_err(|error| error.to_string())?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("create ACL ledger failed: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| format!("persist ACL ledger failed: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn recover_stale(root: &Path, user_sid: &str) -> Result<(), String> {
|
||||
for entry in fs::read_dir(root).map_err(|error| format!("read ACL ledgers failed: {error}"))? {
|
||||
let path = entry
|
||||
.map_err(|error| format!("read ACL ledger entry failed: {error}"))?
|
||||
.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let source = fs::read(&path)
|
||||
.map_err(|error| format!("read stale ACL ledger {} failed: {error}", path.display()))?;
|
||||
let ledger: Ledger = match serde_json::from_slice(&source) {
|
||||
Ok(ledger) => ledger,
|
||||
Err(error) => {
|
||||
quarantine_ledger(&path, &format!("invalid stale ACL ledger: {error}"))?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if ledger.version != LEDGER_VERSION {
|
||||
quarantine_ledger(
|
||||
&path,
|
||||
&format!("unsupported ACL ledger version {}", ledger.version),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let lease_name = ledger_lease_name(user_sid, &ledger.request_id);
|
||||
let Some(_stale_lease) = LedgerLock::try_acquire(&lease_name, user_sid, 0)? else {
|
||||
// The owner keeps this request-specific mutex for the entire child
|
||||
// and cleanup lifetime. A busy lease means this is an active
|
||||
// ledger, not stale recovery work.
|
||||
continue;
|
||||
};
|
||||
// A root the user could grant but cannot un-grant (e.g. an ACL-locked
|
||||
// file deep in the tree) must not brick every future launch. This is
|
||||
// safe only because every request has a fresh AppContainer identity:
|
||||
// no later child can receive the quarantined ledger's SID.
|
||||
if let Err(error) = remove_grants(&ledger) {
|
||||
quarantine_ledger(&path, &format!("stale ACL ledger recovery failed: {error}"))?;
|
||||
continue;
|
||||
}
|
||||
let profile_name = appcontainer_profile_name(&ledger.request_id);
|
||||
unsafe { DeleteAppContainerProfile(profile_name.as_ptr()) };
|
||||
fs::remove_file(&path).map_err(|error| {
|
||||
format!("remove stale ACL ledger {} failed: {error}", path.display())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grant_roots(sid: &str, ledger_roots: &[LedgerRoot]) -> Result<(), String> {
|
||||
for root in ledger_roots {
|
||||
if root.read {
|
||||
grant(&root.path, sid, "RX", root.read_recursive)?;
|
||||
}
|
||||
if root.write {
|
||||
grant(&root.path, sid, "M", root.write_recursive)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn ledger_lease_name(user_sid: &str, request_id: &str) -> String {
|
||||
format!(
|
||||
r"Global\Maka.WindowsSandbox.AclLease.{user_sid}.{:x}",
|
||||
Sha256::digest(request_id.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn grant(path: &str, sid: &str, access: &str, recursive: bool) -> Result<(), String> {
|
||||
let permission = if recursive {
|
||||
format!("*{sid}:(OI)(CI){access}")
|
||||
} else {
|
||||
format!("*{sid}:{access}")
|
||||
};
|
||||
let mut args = vec![path, "/grant", &permission, "/L", "/Q"];
|
||||
if recursive {
|
||||
args.push("/T");
|
||||
}
|
||||
run_icacls(&args, "grant")
|
||||
}
|
||||
|
||||
/// Undo the grants recorded by a ledger by removing exactly this
|
||||
/// AppContainer SID's ACEs. The broker's only ACL mutation is adding grants
|
||||
/// for its own per-app SID, so targeted removal restores the prior state while
|
||||
/// preserving every pre-existing ACE. `icacls /save`+`/restore` is not usable
|
||||
/// here: `/restore` requires SeRestorePrivilege (fails in the non-elevated
|
||||
/// desktop process) and saved entries are relative to the saved path's parent.
|
||||
fn remove_grants(ledger: &Ledger) -> Result<(), String> {
|
||||
for root in &ledger.roots {
|
||||
// Backups written by older broker builds are obsolete; drop them.
|
||||
if let Some(backup) = root.backup_path.as_deref() {
|
||||
let _ = fs::remove_file(backup);
|
||||
}
|
||||
if !Path::new(&root.path).exists() {
|
||||
continue;
|
||||
}
|
||||
remove_sid_grants(
|
||||
&root.path,
|
||||
&ledger.app_container_sid,
|
||||
root.read_recursive || root.write_recursive,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_sid_grants(path: &str, sid: &str, recursive: bool) -> Result<(), String> {
|
||||
let principal = format!("*{sid}");
|
||||
let mut args = vec![path, "/remove", &principal, "/L", "/Q"];
|
||||
if recursive {
|
||||
args.push("/T");
|
||||
}
|
||||
run_icacls(&args, "remove")
|
||||
}
|
||||
|
||||
/// Preserve an unreadable ledger for inspection instead of letting one corrupt
|
||||
/// file permanently block every future sandbox launch. The rename keeps the
|
||||
/// evidence (`*.json.quarantined` no longer matches the recovery scan) and the
|
||||
/// warning keeps the failure loud on stderr.
|
||||
fn quarantine_ledger(path: &Path, reason: &str) -> Result<(), String> {
|
||||
let mut quarantined = path.as_os_str().to_owned();
|
||||
quarantined.push(".quarantined");
|
||||
let quarantined = PathBuf::from(quarantined);
|
||||
let _ = fs::remove_file(&quarantined);
|
||||
fs::rename(path, &quarantined)
|
||||
.map_err(|error| format!("quarantine ACL ledger {} failed: {error}", path.display()))?;
|
||||
eprintln!(
|
||||
"maka-windows-sandbox: {reason}; quarantined {} to {}",
|
||||
path.display(),
|
||||
quarantined.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_icacls(args: &[&str], operation: &str) -> Result<(), String> {
|
||||
let executable = system_icacls()?;
|
||||
let output = Command::new(&executable)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("start {} failed: {error}", executable.display()))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"icacls {operation} failed with {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
fn system_icacls() -> Result<PathBuf, String> {
|
||||
let system_root = std::env::var_os("SystemRoot")
|
||||
.ok_or_else(|| "SystemRoot is unavailable to the broker".to_owned())?;
|
||||
let path = PathBuf::from(system_root)
|
||||
.join("System32")
|
||||
.join("icacls.exe");
|
||||
if !path.is_file() {
|
||||
return Err(format!("system icacls is unavailable: {}", path.display()));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn wide(value: &str) -> Vec<u16> {
|
||||
OsStr::new(value)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn last_error(operation: &str) -> String {
|
||||
format!("{operation} failed: {}", std::io::Error::last_os_error())
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::acl_ledger::{
|
||||
LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale,
|
||||
with_acl_grants, write_ledger,
|
||||
};
|
||||
use crate::protocol::{LaunchRequest, NetworkMode};
|
||||
|
||||
// Synthetic AppContainer-shaped SIDs so the tests never touch the real
|
||||
// per-app profile. icacls accepts arbitrary `*SID` principals and renders
|
||||
// unresolvable SIDs verbatim in its output, which keeps assertions
|
||||
// locale-independent.
|
||||
const APP_SID: &str =
|
||||
"S-1-15-2-1111111111-1222222222-1333333333-1444444444-1555555555-1666666666-1777777777";
|
||||
const MARKER_SID: &str =
|
||||
"S-1-15-2-1777777777-1666666666-1555555555-1444444444-1333333333-1222222222-1111111111";
|
||||
|
||||
struct Fixture {
|
||||
ledger_dir: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"maka-acl-ledger-tests-{}-{name}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let ledger_dir = base.join("ledgers");
|
||||
let target = base.join("target");
|
||||
fs::create_dir_all(&ledger_dir).expect("create ledger dir");
|
||||
fs::create_dir_all(target.join("child")).expect("create target tree");
|
||||
fs::write(target.join("child").join("file.txt"), "payload").expect("seed file");
|
||||
Self { ledger_dir, target }
|
||||
}
|
||||
|
||||
fn target_str(&self) -> String {
|
||||
self.target.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn ledger_path(&self, file_name: &str) -> PathBuf {
|
||||
self.ledger_dir.join(file_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
if let Some(base) = self.ledger_dir.parent() {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn icacls(args: &[&str]) -> String {
|
||||
let output = Command::new("icacls.exe")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run icacls");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"icacls {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn grant_recursive(path: &Path, sid: &str) {
|
||||
let grant = format!("*{sid}:(OI)(CI)RX");
|
||||
icacls(&[
|
||||
path.to_str().expect("path"),
|
||||
"/grant",
|
||||
&grant,
|
||||
"/L",
|
||||
"/Q",
|
||||
"/T",
|
||||
]);
|
||||
}
|
||||
|
||||
fn sid_listed(path: &Path, sid: &str) -> bool {
|
||||
icacls(&[path.to_str().expect("path")]).contains(sid)
|
||||
}
|
||||
|
||||
fn user_sid() -> String {
|
||||
crate::windows_launcher::current_user_sid_string().expect("current user SID")
|
||||
}
|
||||
|
||||
fn ledger(request_id: &str, roots: Vec<LedgerRoot>) -> Ledger {
|
||||
Ledger {
|
||||
version: LEDGER_VERSION,
|
||||
request_id: request_id.to_owned(),
|
||||
app_container_sid: APP_SID.to_owned(),
|
||||
roots,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarantines_legacy_stable_identity_ledgers_without_interpreting_them() {
|
||||
let fixture = Fixture::new("old-format");
|
||||
grant_recursive(&fixture.target, APP_SID);
|
||||
assert!(sid_listed(&fixture.target, APP_SID));
|
||||
let ledger_path = fixture.ledger_path("old.json");
|
||||
fs::write(
|
||||
&ledger_path,
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"requestId": "stale-old",
|
||||
"appContainerSid": APP_SID,
|
||||
"roots": [{ "path": fixture.target_str(), "recursive": true }],
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write old-format ledger");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid()).expect("quarantine old-format ledger");
|
||||
|
||||
assert!(sid_listed(&fixture.target, APP_SID));
|
||||
assert!(sid_listed(&fixture.target.join("child"), APP_SID));
|
||||
assert!(!ledger_path.exists());
|
||||
assert!(fixture.ledger_path("old.json.quarantined").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_preserves_unrelated_aces_and_drops_obsolete_backups() {
|
||||
let fixture = Fixture::new("preserve");
|
||||
grant_recursive(&fixture.target, MARKER_SID);
|
||||
grant_recursive(&fixture.target, APP_SID);
|
||||
let backup = fixture.ledger_path("obsolete.acl");
|
||||
fs::write(&backup, "obsolete backup contents").expect("seed backup");
|
||||
let ledger_path = fixture.ledger_path("with-backup.json");
|
||||
write_ledger(
|
||||
&ledger_path,
|
||||
&ledger(
|
||||
"preserve",
|
||||
vec![LedgerRoot {
|
||||
path: fixture.target_str(),
|
||||
read: true,
|
||||
write: false,
|
||||
read_recursive: true,
|
||||
write_recursive: false,
|
||||
backup_path: Some(backup.to_string_lossy().into_owned()),
|
||||
}],
|
||||
),
|
||||
)
|
||||
.expect("write ledger");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid()).expect("recover ledger with backup");
|
||||
|
||||
assert!(!sid_listed(&fixture.target, APP_SID));
|
||||
assert!(sid_listed(&fixture.target, MARKER_SID));
|
||||
assert!(sid_listed(&fixture.target.join("child"), MARKER_SID));
|
||||
assert!(!backup.exists());
|
||||
assert!(!ledger_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_skips_missing_roots() {
|
||||
let fixture = Fixture::new("missing-root");
|
||||
let missing = fixture.target.join("does-not-exist");
|
||||
let ledger_path = fixture.ledger_path("missing-root.json");
|
||||
write_ledger(
|
||||
&ledger_path,
|
||||
&ledger(
|
||||
"missing-root",
|
||||
vec![LedgerRoot {
|
||||
path: missing.to_string_lossy().into_owned(),
|
||||
read: true,
|
||||
write: false,
|
||||
read_recursive: true,
|
||||
write_recursive: false,
|
||||
backup_path: None,
|
||||
}],
|
||||
),
|
||||
)
|
||||
.expect("write ledger");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid()).expect("recover ledger with missing root");
|
||||
|
||||
assert!(!ledger_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_ledger_is_quarantined_and_siblings_still_recover() {
|
||||
let fixture = Fixture::new("corrupt");
|
||||
grant_recursive(&fixture.target, APP_SID);
|
||||
let corrupt = fixture.ledger_path("corrupt.json");
|
||||
fs::write(&corrupt, "{ not json").expect("write corrupt ledger");
|
||||
let valid = fixture.ledger_path("valid.json");
|
||||
write_ledger(
|
||||
&valid,
|
||||
&ledger(
|
||||
"corrupt-valid",
|
||||
vec![LedgerRoot {
|
||||
path: fixture.target_str(),
|
||||
read: true,
|
||||
write: false,
|
||||
read_recursive: true,
|
||||
write_recursive: false,
|
||||
backup_path: None,
|
||||
}],
|
||||
),
|
||||
)
|
||||
.expect("write valid ledger");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid())
|
||||
.expect("recovery continues past corrupt ledger");
|
||||
|
||||
assert!(!corrupt.exists());
|
||||
assert!(fixture.ledger_path("corrupt.json.quarantined").exists());
|
||||
assert!(!sid_listed(&fixture.target, APP_SID));
|
||||
assert!(!valid.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_version_is_quarantined_without_acl_changes() {
|
||||
let fixture = Fixture::new("version");
|
||||
grant_recursive(&fixture.target, APP_SID);
|
||||
let ledger_path = fixture.ledger_path("future.json");
|
||||
fs::write(
|
||||
&ledger_path,
|
||||
serde_json::json!({
|
||||
"version": 99,
|
||||
"requestId": "future",
|
||||
"appContainerSid": APP_SID,
|
||||
"roots": [{ "path": fixture.target_str(), "recursive": true }],
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write future-version ledger");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid())
|
||||
.expect("recovery quarantines future version");
|
||||
|
||||
assert!(!ledger_path.exists());
|
||||
assert!(fixture.ledger_path("future.json.quarantined").exists());
|
||||
// An unknown version must not be interpreted: grants stay until a
|
||||
// build that understands the format recovers them.
|
||||
assert!(sid_listed(&fixture.target, APP_SID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_rejected_and_quarantined() {
|
||||
let fixture = Fixture::new("unknown-field");
|
||||
let ledger_path = fixture.ledger_path("extra.json");
|
||||
fs::write(
|
||||
&ledger_path,
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"requestId": "extra",
|
||||
"appContainerSid": APP_SID,
|
||||
"surprise": true,
|
||||
"roots": [],
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write ledger with unknown field");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid())
|
||||
.expect("recovery quarantines unknown fields");
|
||||
|
||||
assert!(!ledger_path.exists());
|
||||
assert!(fixture.ledger_path("extra.json.quarantined").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecoverable_ledger_is_quarantined_instead_of_blocking_recovery() {
|
||||
let fixture = Fixture::new("unrecoverable");
|
||||
let ledger_path = fixture.ledger_path("stuck.json");
|
||||
// An invalid SID makes icacls /remove fail exactly like a root whose
|
||||
// grants cannot be removed; recovery must quarantine and move on.
|
||||
write_ledger(
|
||||
&ledger_path,
|
||||
&Ledger {
|
||||
version: LEDGER_VERSION,
|
||||
request_id: "stuck".to_owned(),
|
||||
app_container_sid: "not-a-sid".to_owned(),
|
||||
roots: vec![LedgerRoot {
|
||||
path: fixture.target_str(),
|
||||
read: true,
|
||||
write: false,
|
||||
read_recursive: true,
|
||||
write_recursive: false,
|
||||
backup_path: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.expect("write stuck ledger");
|
||||
|
||||
recover_stale(&fixture.ledger_dir, &user_sid())
|
||||
.expect("recovery continues past stuck ledger");
|
||||
|
||||
assert!(!ledger_path.exists());
|
||||
assert!(fixture.ledger_path("stuck.json.quarantined").exists());
|
||||
}
|
||||
|
||||
fn launch_request(
|
||||
read_roots: Vec<String>,
|
||||
exact_read_roots: Vec<String>,
|
||||
write_roots: Vec<String>,
|
||||
exact_write_roots: Vec<String>,
|
||||
) -> LaunchRequest {
|
||||
LaunchRequest {
|
||||
version: 1,
|
||||
request_id: "hard-link-admission".to_owned(),
|
||||
executable: "C:\\Windows\\System32\\cmd.exe".to_owned(),
|
||||
arguments: Vec::new(),
|
||||
cwd: "C:\\".to_owned(),
|
||||
read_roots,
|
||||
write_roots,
|
||||
exact_read_roots,
|
||||
exact_write_roots,
|
||||
network: NetworkMode::Restricted,
|
||||
environment: BTreeMap::new(),
|
||||
timeout_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_link_file_root_fails_closed() {
|
||||
let fixture = Fixture::new("hardlink-root");
|
||||
let original = fixture.target.join("child").join("file.txt");
|
||||
let alias = fixture.target.join("alias.txt");
|
||||
fs::hard_link(&original, &alias).expect("create hard link");
|
||||
let root = original.to_string_lossy().into_owned();
|
||||
let request = launch_request(vec![root.clone()], vec![root], Vec::new(), Vec::new());
|
||||
|
||||
let error = collect_roots(&request).expect_err("multi-link file root must fail closed");
|
||||
|
||||
assert!(error.contains("multi-link"), "unexpected error: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_link_file_inside_recursive_root_fails_closed() {
|
||||
let fixture = Fixture::new("hardlink-tree");
|
||||
// The alias inside the granted tree shares its file object with a
|
||||
// name outside it; an (OI)(CI) grant would follow that object out.
|
||||
let outside = fixture
|
||||
.target
|
||||
.parent()
|
||||
.expect("fixture base")
|
||||
.join("outside.txt");
|
||||
fs::write(&outside, "outside payload").expect("seed outside file");
|
||||
fs::hard_link(&outside, fixture.target.join("child").join("linked.txt"))
|
||||
.expect("create hard link into tree");
|
||||
let request = launch_request(
|
||||
vec![fixture.target_str()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let error = collect_roots(&request).expect_err("multi-link file in tree must fail closed");
|
||||
|
||||
assert!(error.contains("multi-link"), "unexpected error: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_link_tree_admits_and_multi_link_below_exact_root_is_out_of_scope() {
|
||||
let fixture = Fixture::new("hardlink-exact");
|
||||
let roots = collect_roots(&launch_request(
|
||||
vec![fixture.target_str()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
))
|
||||
.expect("single-link tree admits");
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert!(roots[0].read_recursive);
|
||||
|
||||
// An exact grant mutates only the directory object itself; entries
|
||||
// below it receive no ACE, so aliases deeper in the tree stay out of
|
||||
// admission scope exactly like reparse points already do.
|
||||
let outside = fixture
|
||||
.target
|
||||
.parent()
|
||||
.expect("fixture base")
|
||||
.join("exact-outside.txt");
|
||||
fs::write(&outside, "outside payload").expect("seed outside file");
|
||||
fs::hard_link(&outside, fixture.target.join("child").join("linked.txt"))
|
||||
.expect("create hard link into tree");
|
||||
let roots = collect_roots(&launch_request(
|
||||
vec![fixture.target_str()],
|
||||
vec![fixture.target_str()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
))
|
||||
.expect("exact root skips tree scan");
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert!(!roots[0].read_recursive);
|
||||
}
|
||||
|
||||
fn shared_ledger_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("maka-sandbox-acl-ledgers")
|
||||
}
|
||||
|
||||
fn remove_recursive_grant(path: &Path, sid: &str) {
|
||||
let principal = format!("*{sid}");
|
||||
icacls(&[
|
||||
path.to_str().expect("path"),
|
||||
"/remove",
|
||||
&principal,
|
||||
"/L",
|
||||
"/Q",
|
||||
"/T",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsettled_launch_preserves_grants_and_quarantines_the_ledger() {
|
||||
let fixture = Fixture::new("unsettled-launch");
|
||||
let root = fixture.target_str();
|
||||
let mut request = launch_request(vec![root], Vec::new(), Vec::new(), Vec::new());
|
||||
request.request_id = format!("unsettled-injection-{}", std::process::id());
|
||||
let ledger_name = format!("{:x}.json", Sha256::digest(request.request_id.as_bytes()));
|
||||
let ledger_path = shared_ledger_dir().join(&ledger_name);
|
||||
let quarantined = shared_ledger_dir().join(format!("{ledger_name}.quarantined"));
|
||||
|
||||
let result = with_acl_grants(&request, APP_SID, || -> Result<(), LaunchFailure> {
|
||||
assert!(
|
||||
sid_listed(&fixture.target, APP_SID),
|
||||
"grant must be live while the child runs"
|
||||
);
|
||||
Err(LaunchFailure::Unsettled(
|
||||
"injected settlement failure".to_owned(),
|
||||
))
|
||||
});
|
||||
|
||||
let message = match result {
|
||||
Err(LaunchFailure::Unsettled(message)) => message,
|
||||
other => panic!("expected an unsettled failure, got {other:?}"),
|
||||
};
|
||||
assert!(message.contains("injected settlement failure"), "{message}");
|
||||
assert!(message.contains("preserved"), "{message}");
|
||||
// Processes may still hold the launch identity: the grant must stay
|
||||
// and the ledger must survive as a quarantined recovery record.
|
||||
assert!(sid_listed(&fixture.target, APP_SID));
|
||||
assert!(!ledger_path.exists());
|
||||
assert!(quarantined.exists());
|
||||
|
||||
let _ = fs::remove_file(&quarantined);
|
||||
remove_recursive_grant(&fixture.target, APP_SID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_launch_failure_still_releases_grants_and_ledger() {
|
||||
let fixture = Fixture::new("settled-launch");
|
||||
let root = fixture.target_str();
|
||||
let mut request = launch_request(vec![root], Vec::new(), Vec::new(), Vec::new());
|
||||
request.request_id = format!("settled-injection-{}", std::process::id());
|
||||
let ledger_name = format!("{:x}.json", Sha256::digest(request.request_id.as_bytes()));
|
||||
|
||||
let result = with_acl_grants(&request, APP_SID, || -> Result<(), LaunchFailure> {
|
||||
Err(LaunchFailure::Settled(
|
||||
"injected settled failure".to_owned(),
|
||||
))
|
||||
});
|
||||
|
||||
match result {
|
||||
Err(LaunchFailure::Settled(message)) => {
|
||||
assert!(message.contains("injected settled failure"), "{message}");
|
||||
}
|
||||
other => panic!("expected a settled failure, got {other:?}"),
|
||||
}
|
||||
// A proven-empty Job enters normal cleanup: no grant, no ledger.
|
||||
assert!(!sid_listed(&fixture.target, APP_SID));
|
||||
assert!(!shared_ledger_dir().join(&ledger_name).exists());
|
||||
assert!(
|
||||
!shared_ledger_dir()
|
||||
.join(format!("{ledger_name}.quarantined"))
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_names_are_machine_wide_and_scoped_to_the_owning_user() {
|
||||
// The ledger directory, icacls grants and AppContainer profiles are
|
||||
// shared across sessions; a `Local\` lock would let a concurrent
|
||||
// console/RDP session mistake a live lease for a stale one.
|
||||
let sid = user_sid();
|
||||
let mutex_name = crate::acl_ledger::acl_mutex_name(&sid);
|
||||
let lease_name = crate::acl_ledger::ledger_lease_name(&sid, "request");
|
||||
for name in [&mutex_name, &lease_name] {
|
||||
assert!(name.starts_with(r"Global\Maka.WindowsSandbox."), "{name}");
|
||||
assert!(name.contains(&sid), "{name}");
|
||||
}
|
||||
assert!(lease_name.contains(".AclLease."), "{lease_name}");
|
||||
// Distinct request ids must never share a lease object.
|
||||
assert_ne!(
|
||||
lease_name,
|
||||
crate::acl_ledger::ledger_lease_name(&sid, "request-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ledgers_serialize_without_backup_path() {
|
||||
let value = serde_json::to_value(ledger(
|
||||
"serialization",
|
||||
vec![LedgerRoot {
|
||||
path: "C:\\workspace".to_owned(),
|
||||
read: true,
|
||||
write: true,
|
||||
read_recursive: false,
|
||||
write_recursive: true,
|
||||
backup_path: None,
|
||||
}],
|
||||
))
|
||||
.expect("serialize ledger");
|
||||
let root = &value["roots"][0];
|
||||
assert!(root.get("backupPath").is_none());
|
||||
assert_eq!(root["path"], "C:\\workspace");
|
||||
assert_eq!(root["read"], true);
|
||||
assert_eq!(root["write"], true);
|
||||
assert_eq!(root["readRecursive"], false);
|
||||
assert_eq!(root["writeRecursive"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_sddl_pins_an_elevation_independent_owner() {
|
||||
// Without an explicit `O:` clause the object owner comes from the
|
||||
// creating token's *default* owner — the user SID for a standard token
|
||||
// but BUILTIN\Administrators for an elevated one — so the same user's
|
||||
// elevated and non-elevated processes would create locks with different
|
||||
// owners and each reject the other's legitimate lock. Pinning the owner
|
||||
// keeps the lock identity stable across elevation states.
|
||||
let sid = "S-1-5-21-11111111-22222222-33333333-1001";
|
||||
let sddl = crate::acl_ledger::lock_sddl(sid).expect("lock SDDL");
|
||||
assert!(
|
||||
sddl.starts_with(&format!("O:{sid}D:P")),
|
||||
"owner must be pinned before the protected DACL: {sddl}"
|
||||
);
|
||||
assert!(
|
||||
sddl.contains("(A;;GA;;;SY)"),
|
||||
"SYSTEM retains access: {sddl}"
|
||||
);
|
||||
assert!(
|
||||
sddl.contains(&format!("(A;;GA;;;{sid})")),
|
||||
"the owning user retains access: {sddl}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::protocol::{BrokerLaunchRequest, launch_digest};
|
||||
|
||||
const MAX_REMEMBERED_NONCES: usize = 4096;
|
||||
|
||||
pub struct BrokerAuthorizer {
|
||||
allowed_profile_digests: HashSet<String>,
|
||||
used_nonces: HashSet<String>,
|
||||
}
|
||||
|
||||
impl BrokerAuthorizer {
|
||||
pub fn new(allowed_profile_digests: impl IntoIterator<Item = String>) -> Self {
|
||||
Self {
|
||||
allowed_profile_digests: allowed_profile_digests
|
||||
.into_iter()
|
||||
.map(|digest| digest.to_ascii_lowercase())
|
||||
.collect(),
|
||||
used_nonces: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authorize(
|
||||
&mut self,
|
||||
request: &BrokerLaunchRequest,
|
||||
connected_client_pid: u32,
|
||||
) -> Result<(), BrokerAuthorizationError> {
|
||||
request
|
||||
.validate()
|
||||
.map_err(BrokerAuthorizationError::InvalidRequest)?;
|
||||
if request.client_pid != connected_client_pid {
|
||||
return Err(BrokerAuthorizationError::ClientPidMismatch);
|
||||
}
|
||||
let computed_digest =
|
||||
launch_digest(&request.launch).map_err(BrokerAuthorizationError::InvalidRequest)?;
|
||||
if !request
|
||||
.profile_digest
|
||||
.eq_ignore_ascii_case(&computed_digest)
|
||||
{
|
||||
return Err(BrokerAuthorizationError::ProfileDigestMismatch);
|
||||
}
|
||||
if !self
|
||||
.allowed_profile_digests
|
||||
.contains(&request.profile_digest.to_ascii_lowercase())
|
||||
{
|
||||
return Err(BrokerAuthorizationError::ProfileNotApproved);
|
||||
}
|
||||
let nonce = request.client_nonce.to_ascii_lowercase();
|
||||
if self.used_nonces.contains(&nonce) {
|
||||
return Err(BrokerAuthorizationError::NonceReplayed);
|
||||
}
|
||||
if self.used_nonces.len() >= MAX_REMEMBERED_NONCES {
|
||||
return Err(BrokerAuthorizationError::NonceCapacityExceeded);
|
||||
}
|
||||
self.used_nonces.insert(nonce);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum BrokerAuthorizationError {
|
||||
InvalidRequest(String),
|
||||
ClientPidMismatch,
|
||||
ProfileNotApproved,
|
||||
ProfileDigestMismatch,
|
||||
NonceReplayed,
|
||||
NonceCapacityExceeded,
|
||||
}
|
||||
|
||||
impl BrokerAuthorizationError {
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRequest(_) => "invalid_request",
|
||||
Self::ClientPidMismatch => "client_pid_mismatch",
|
||||
Self::ProfileNotApproved => "profile_not_approved",
|
||||
Self::ProfileDigestMismatch => "profile_digest_mismatch",
|
||||
Self::NonceReplayed => "nonce_replayed",
|
||||
Self::NonceCapacityExceeded => "nonce_capacity_exceeded",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Self::InvalidRequest(message) => message.clone(),
|
||||
Self::ClientPidMismatch => {
|
||||
"request clientPid does not match the connected pipe client".to_owned()
|
||||
}
|
||||
Self::ProfileNotApproved => "profileDigest is not approved by the broker".to_owned(),
|
||||
Self::ProfileDigestMismatch => {
|
||||
"profileDigest does not match the canonical launch policy".to_owned()
|
||||
}
|
||||
Self::NonceReplayed => "clientNonce has already been used".to_owned(),
|
||||
Self::NonceCapacityExceeded => {
|
||||
"broker nonce capacity is exhausted; restart is required".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::broker_authorization::{BrokerAuthorizationError, BrokerAuthorizer};
|
||||
use crate::protocol::{BrokerLaunchRequest, launch_digest};
|
||||
|
||||
fn request(nonce: &str) -> BrokerLaunchRequest {
|
||||
let mut request: BrokerLaunchRequest = serde_json::from_value(serde_json::json!({
|
||||
"version": 1,
|
||||
"requestId": "broker-1",
|
||||
"clientPid": 42,
|
||||
"clientNonce": nonce,
|
||||
"profileDigest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"launch": {
|
||||
"version": 1,
|
||||
"requestId": "launch-1",
|
||||
"executable": "C:\\Windows\\System32\\cmd.exe",
|
||||
"arguments": ["/d", "/c", "exit 0"],
|
||||
"cwd": "C:\\Windows",
|
||||
"readRoots": [],
|
||||
"writeRoots": [],
|
||||
"network": "enabled",
|
||||
"environment": {}
|
||||
}
|
||||
}))
|
||||
.expect("valid request");
|
||||
request.profile_digest = launch_digest(&request.launch).expect("launch digest");
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_authorization_to_connected_client_pid() {
|
||||
let value = request("0123456789abcdef0123456789abcdef");
|
||||
let mut authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
|
||||
assert_eq!(
|
||||
authorizer.authorize(&value, 43),
|
||||
Err(BrokerAuthorizationError::ClientPidMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unapproved_profile_without_consuming_nonce() {
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
let value = request(nonce);
|
||||
let mut authorizer = BrokerAuthorizer::new([]);
|
||||
assert_eq!(
|
||||
authorizer.authorize(&value, 42),
|
||||
Err(BrokerAuthorizationError::ProfileNotApproved)
|
||||
);
|
||||
authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
|
||||
assert_eq!(authorizer.authorize(&value, 42), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_replayed_nonce_after_successful_authorization() {
|
||||
let nonce = "0123456789abcdef0123456789abcdef";
|
||||
let value = request(nonce);
|
||||
let mut authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
|
||||
assert_eq!(authorizer.authorize(&value, 42), Ok(()));
|
||||
assert_eq!(
|
||||
authorizer.authorize(&value, 42),
|
||||
Err(BrokerAuthorizationError::NonceReplayed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_replayed_nonce_with_different_hex_case() {
|
||||
let value = request("0123456789abcdef0123456789abcdef");
|
||||
let mut variant = value.clone();
|
||||
variant.client_nonce = "0123456789ABCDEF0123456789ABCDEF".to_owned();
|
||||
let mut authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
|
||||
assert_eq!(authorizer.authorize(&value, 42), Ok(()));
|
||||
assert_eq!(
|
||||
authorizer.authorize(&variant, 42),
|
||||
Err(BrokerAuthorizationError::NonceReplayed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_digest_that_is_not_bound_to_the_launch_policy() {
|
||||
let mut value = request("0123456789abcdef0123456789abcdef");
|
||||
let approved = value.profile_digest.clone();
|
||||
value.launch.arguments.push("tampered".to_owned());
|
||||
let mut authorizer = BrokerAuthorizer::new([approved]);
|
||||
assert_eq!(
|
||||
authorizer.authorize(&value, 42),
|
||||
Err(BrokerAuthorizationError::ProfileDigestMismatch)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::iter;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr::{null, null_mut};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, FILETIME, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
CreateFileW, OPEN_EXISTING, ReadFile, SYNCHRONIZE, WriteFile,
|
||||
};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetCurrentProcess, GetCurrentProcessId, GetProcessId, GetProcessTimes, OpenProcess,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
use crate::broker_authorization::BrokerAuthorizer;
|
||||
use crate::broker_framing::{MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame};
|
||||
use crate::broker_pipe_security::validate_pipe_name;
|
||||
use crate::protocol::{BrokerLaunchOutcome, BrokerLaunchRequest, BrokerLaunchResponse};
|
||||
use crate::windows_launcher;
|
||||
|
||||
pub fn run_local(manifest_path: &str) -> Result<u8, String> {
|
||||
let source = fs::read_to_string(manifest_path)
|
||||
.map_err(|error| format!("read local broker manifest failed: {error}"))?;
|
||||
fs::remove_file(manifest_path)
|
||||
.map_err(|error| format!("remove local broker manifest failed: {error}"))?;
|
||||
let mut request: BrokerLaunchRequest = serde_json::from_str(&source)
|
||||
.map_err(|error| format!("invalid local broker manifest: {error}"))?;
|
||||
// The packaged broker is a direct child of Runtime Host. Bind the
|
||||
// manifest to that kernel-observed parent and keep a wait handle open for
|
||||
// the whole launch. The process creation-time check rejects a PID that was
|
||||
// reused after this broker was created. If Runtime Host dies, the broker
|
||||
// terminates and drains the AppContainer Job instead of leaving the worker
|
||||
// alive until timeout.
|
||||
let owner_pid = parent_process_id()?;
|
||||
request.client_pid = owner_pid;
|
||||
request.validate()?;
|
||||
let owner = unsafe {
|
||||
OpenProcess(
|
||||
SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
0,
|
||||
owner_pid,
|
||||
)
|
||||
};
|
||||
if owner.is_null() {
|
||||
return Err(last_error("OpenProcess(local broker owner)"));
|
||||
}
|
||||
if let Err(error) = validate_owner_process(owner, owner_pid) {
|
||||
unsafe { CloseHandle(owner) };
|
||||
return Err(error);
|
||||
}
|
||||
// The packaged path is a same-process one-shot launch. Authorize and hand
|
||||
// off directly instead of routing through a synchronous named-pipe thread;
|
||||
// the standalone pipe modes remain only as transport experiments.
|
||||
let result = (|| {
|
||||
let mut authorizer = BrokerAuthorizer::new([request.profile_digest.clone()]);
|
||||
authorizer
|
||||
.authorize(&request, request.client_pid)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"broker rejected request ({}): {}",
|
||||
error.code(),
|
||||
error.message()
|
||||
)
|
||||
})?;
|
||||
windows_launcher::launch_appcontainer_owned(&request.launch, owner)
|
||||
})();
|
||||
unsafe { CloseHandle(owner) };
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_owner_process(owner: HANDLE, owner_pid: u32) -> Result<(), String> {
|
||||
let opened_pid = unsafe { GetProcessId(owner) };
|
||||
if opened_pid != owner_pid {
|
||||
return Err(format!(
|
||||
"local broker owner identity changed while opening process: expected pid {owner_pid}, got {opened_pid}"
|
||||
));
|
||||
}
|
||||
let owner_started = process_creation_time(owner, "local broker owner")?;
|
||||
let broker_started = process_creation_time(unsafe { GetCurrentProcess() }, "local broker")?;
|
||||
if owner_started >= broker_started {
|
||||
return Err(
|
||||
"local broker parent process identity was reused after broker creation".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_creation_time(process: HANDLE, label: &str) -> Result<u64, String> {
|
||||
let mut creation = FILETIME {
|
||||
dwLowDateTime: 0,
|
||||
dwHighDateTime: 0,
|
||||
};
|
||||
let mut exit = FILETIME {
|
||||
dwLowDateTime: 0,
|
||||
dwHighDateTime: 0,
|
||||
};
|
||||
let mut kernel = FILETIME {
|
||||
dwLowDateTime: 0,
|
||||
dwHighDateTime: 0,
|
||||
};
|
||||
let mut user = FILETIME {
|
||||
dwLowDateTime: 0,
|
||||
dwHighDateTime: 0,
|
||||
};
|
||||
if unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) } == 0 {
|
||||
return Err(last_error(&format!("GetProcessTimes({label})")));
|
||||
}
|
||||
Ok((u64::from(creation.dwHighDateTime) << 32) | u64::from(creation.dwLowDateTime))
|
||||
}
|
||||
|
||||
fn parent_process_id() -> Result<u32, String> {
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(last_error("CreateToolhelp32Snapshot(local broker owner)"));
|
||||
}
|
||||
let current = unsafe { GetCurrentProcessId() };
|
||||
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||
let mut found = None;
|
||||
let mut next = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
|
||||
while next {
|
||||
if entry.th32ProcessID == current {
|
||||
found = Some(entry.th32ParentProcessID);
|
||||
break;
|
||||
}
|
||||
next = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
|
||||
}
|
||||
unsafe { CloseHandle(snapshot) };
|
||||
found
|
||||
.filter(|pid| *pid > 0)
|
||||
.ok_or_else(|| "local broker parent process was not found".to_owned())
|
||||
}
|
||||
|
||||
pub fn run(pipe_name: &str, manifest_path: &str) -> Result<u8, String> {
|
||||
validate_pipe_name(pipe_name).map_err(|error| format!("invalid broker pipe: {error:?}"))?;
|
||||
let source = fs::read_to_string(manifest_path)
|
||||
.map_err(|error| format!("read broker manifest failed: {error}"))?;
|
||||
fs::remove_file(manifest_path)
|
||||
.map_err(|error| format!("remove broker manifest failed: {error}"))?;
|
||||
let mut request: BrokerLaunchRequest = serde_json::from_str(&source)
|
||||
.map_err(|error| format!("invalid broker manifest: {error}"))?;
|
||||
request.client_pid = unsafe { GetCurrentProcessId() };
|
||||
request.validate()?;
|
||||
|
||||
let payload = serde_json::to_vec(&request).map_err(|error| error.to_string())?;
|
||||
let frame =
|
||||
encode_frame(&payload).map_err(|error| format!("invalid request frame: {error:?}"))?;
|
||||
let pipe = connect(pipe_name)?;
|
||||
let result = unsafe { exchange(pipe, &frame) };
|
||||
unsafe { CloseHandle(pipe) };
|
||||
let response = result?;
|
||||
if response.request_id != request.request_id || response.version != 1 {
|
||||
return Err("broker response identity does not match the request".to_owned());
|
||||
}
|
||||
match response.outcome {
|
||||
BrokerLaunchOutcome::Completed { exit_code } => Ok(exit_code),
|
||||
BrokerLaunchOutcome::Rejected { code, message } => {
|
||||
Err(format!("broker rejected request ({code}): {message}"))
|
||||
}
|
||||
BrokerLaunchOutcome::Started { .. } => {
|
||||
Err("broker returned unsupported asynchronous outcome".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parent_process_id, validate_owner_process};
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetCurrentProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn resolves_a_real_parent_process_without_self_binding() {
|
||||
let parent = parent_process_id().expect("test harness parent process");
|
||||
let current = unsafe { GetCurrentProcessId() };
|
||||
assert_ne!(parent, 0);
|
||||
assert_ne!(parent, current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_a_real_parent_process_before_waiting_on_it() {
|
||||
let parent = parent_process_id().expect("test harness parent process");
|
||||
let handle =
|
||||
unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, parent) };
|
||||
assert!(!handle.is_null(), "OpenProcess(test harness parent)");
|
||||
let result = validate_owner_process(handle, parent);
|
||||
unsafe { CloseHandle(handle) };
|
||||
result.expect("parent process identity must be pinned before waiting");
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(pipe_name: &str) -> Result<HANDLE, String> {
|
||||
let name = wide(pipe_name);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let pipe = unsafe {
|
||||
CreateFileW(
|
||||
name.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
0,
|
||||
null(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
null_mut(),
|
||||
)
|
||||
};
|
||||
if pipe != INVALID_HANDLE_VALUE {
|
||||
return Ok(pipe);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(last_error("connect broker pipe"));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn exchange(pipe: HANDLE, frame: &[u8]) -> Result<BrokerLaunchResponse, String> {
|
||||
unsafe { write_all(pipe, frame)? };
|
||||
let mut prefix = [0u8; 4];
|
||||
unsafe { read_exact(pipe, &mut prefix)? };
|
||||
let payload_length = u32::from_le_bytes(prefix) as usize;
|
||||
if payload_length == 0 || payload_length > MAX_BROKER_MESSAGE_BYTES {
|
||||
return Err("invalid broker response length".to_owned());
|
||||
}
|
||||
let mut response_frame = Vec::with_capacity(4 + payload_length);
|
||||
response_frame.extend_from_slice(&prefix);
|
||||
response_frame.resize(4 + payload_length, 0);
|
||||
unsafe { read_exact(pipe, &mut response_frame[4..])? };
|
||||
let payload = decode_frame(&response_frame)
|
||||
.map_err(|error| format!("invalid response frame: {error:?}"))?;
|
||||
serde_json::from_slice(payload).map_err(|error| format!("invalid broker response: {error}"))
|
||||
}
|
||||
|
||||
unsafe fn read_exact(pipe: HANDLE, mut buffer: &mut [u8]) -> Result<(), String> {
|
||||
while !buffer.is_empty() {
|
||||
let mut read = 0;
|
||||
if unsafe {
|
||||
ReadFile(
|
||||
pipe,
|
||||
buffer.as_mut_ptr(),
|
||||
buffer.len() as u32,
|
||||
&mut read,
|
||||
null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(last_error("read broker response"));
|
||||
}
|
||||
if read == 0 {
|
||||
return Err("broker closed before completing the response".to_owned());
|
||||
}
|
||||
buffer = &mut buffer[read as usize..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn write_all(pipe: HANDLE, mut buffer: &[u8]) -> Result<(), String> {
|
||||
while !buffer.is_empty() {
|
||||
let mut written = 0;
|
||||
if unsafe {
|
||||
WriteFile(
|
||||
pipe,
|
||||
buffer.as_ptr(),
|
||||
buffer.len() as u32,
|
||||
&mut written,
|
||||
null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(last_error("write broker request"));
|
||||
}
|
||||
if written == 0 {
|
||||
return Err("broker request write made no progress".to_owned());
|
||||
}
|
||||
buffer = &buffer[written as usize..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wide(value: &str) -> Vec<u16> {
|
||||
OsStr::new(value)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn last_error(operation: &str) -> String {
|
||||
format!("{operation} failed: {}", std::io::Error::last_os_error())
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
pub const MAX_BROKER_MESSAGE_BYTES: usize = 64 * 1024;
|
||||
const LENGTH_PREFIX_BYTES: usize = size_of::<u32>();
|
||||
|
||||
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>, BrokerFrameError> {
|
||||
if payload.is_empty() {
|
||||
return Err(BrokerFrameError::EmptyPayload);
|
||||
}
|
||||
if payload.len() > MAX_BROKER_MESSAGE_BYTES {
|
||||
return Err(BrokerFrameError::PayloadTooLarge);
|
||||
}
|
||||
let length = u32::try_from(payload.len()).map_err(|_| BrokerFrameError::PayloadTooLarge)?;
|
||||
let mut frame = Vec::with_capacity(LENGTH_PREFIX_BYTES + payload.len());
|
||||
frame.extend_from_slice(&length.to_le_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
pub fn decode_frame(frame: &[u8]) -> Result<&[u8], BrokerFrameError> {
|
||||
if frame.len() < LENGTH_PREFIX_BYTES {
|
||||
return Err(BrokerFrameError::TruncatedPrefix);
|
||||
}
|
||||
let length = u32::from_le_bytes(
|
||||
frame[..LENGTH_PREFIX_BYTES]
|
||||
.try_into()
|
||||
.expect("length prefix has fixed width"),
|
||||
) as usize;
|
||||
if length == 0 {
|
||||
return Err(BrokerFrameError::EmptyPayload);
|
||||
}
|
||||
if length > MAX_BROKER_MESSAGE_BYTES {
|
||||
return Err(BrokerFrameError::PayloadTooLarge);
|
||||
}
|
||||
let expected = LENGTH_PREFIX_BYTES + length;
|
||||
if frame.len() < expected {
|
||||
return Err(BrokerFrameError::TruncatedPayload);
|
||||
}
|
||||
if frame.len() > expected {
|
||||
return Err(BrokerFrameError::TrailingBytes);
|
||||
}
|
||||
Ok(&frame[LENGTH_PREFIX_BYTES..expected])
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum BrokerFrameError {
|
||||
EmptyPayload,
|
||||
PayloadTooLarge,
|
||||
TruncatedPrefix,
|
||||
TruncatedPayload,
|
||||
TrailingBytes,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::broker_framing::{
|
||||
BrokerFrameError, MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn round_trips_bounded_payload() {
|
||||
let frame = encode_frame(br#"{"version":1}"#).expect("encode frame");
|
||||
assert_eq!(decode_frame(&frame), Ok(br#"{"version":1}"#.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_payload_before_allocation() {
|
||||
let payload = vec![0; MAX_BROKER_MESSAGE_BYTES + 1];
|
||||
assert_eq!(
|
||||
encode_frame(&payload),
|
||||
Err(BrokerFrameError::PayloadTooLarge)
|
||||
);
|
||||
|
||||
let declared = (MAX_BROKER_MESSAGE_BYTES as u32 + 1).to_le_bytes();
|
||||
assert_eq!(
|
||||
decode_frame(&declared),
|
||||
Err(BrokerFrameError::PayloadTooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_ambiguous_frames() {
|
||||
assert_eq!(
|
||||
decode_frame(&[1, 0]),
|
||||
Err(BrokerFrameError::TruncatedPrefix)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_frame(&[4, 0, 0, 0, 1, 2]),
|
||||
Err(BrokerFrameError::TruncatedPayload)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_frame(&[1, 0, 0, 0, 1, 2]),
|
||||
Err(BrokerFrameError::TrailingBytes)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
use std::ffi::{OsStr, c_void};
|
||||
use std::iter;
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr::null_mut;
|
||||
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, ERROR_PIPE_CONNECTED, GetLastError, INVALID_HANDLE_VALUE, LocalFree,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_FLAG_FIRST_PIPE_INSTANCE, FlushFileBuffers, PIPE_ACCESS_DUPLEX, ReadFile, WriteFile,
|
||||
};
|
||||
use windows_sys::Win32::System::Pipes::{
|
||||
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, GetNamedPipeClientProcessId,
|
||||
PIPE_REJECT_REMOTE_CLIENTS, PIPE_WAIT,
|
||||
};
|
||||
|
||||
use crate::broker_authorization::BrokerAuthorizer;
|
||||
use crate::broker_framing::{MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame};
|
||||
use crate::broker_pipe_security::{pipe_security_sddl, validate_pipe_name};
|
||||
use crate::protocol::{BrokerLaunchOutcome, BrokerLaunchRequest, BrokerLaunchResponse};
|
||||
use crate::windows_launcher;
|
||||
|
||||
pub fn serve_once(pipe_name: &str, account_sid: &str, profile_digest: &str) -> Result<(), String> {
|
||||
validate_pipe_name(pipe_name).map_err(|error| format!("invalid broker pipe: {error:?}"))?;
|
||||
let sddl = pipe_security_sddl(account_sid)
|
||||
.map_err(|error| format!("invalid broker account SID: {error:?}"))?;
|
||||
unsafe { serve_once_with_security(pipe_name, &sddl, profile_digest) }
|
||||
}
|
||||
|
||||
unsafe fn serve_once_with_security(
|
||||
pipe_name: &str,
|
||||
sddl: &str,
|
||||
profile_digest: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut descriptor: PSECURITY_DESCRIPTOR = null_mut();
|
||||
let sddl_wide = wide(sddl);
|
||||
if unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl_wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut descriptor,
|
||||
null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(last_error(
|
||||
"ConvertStringSecurityDescriptorToSecurityDescriptorW",
|
||||
));
|
||||
}
|
||||
let mut attributes: SECURITY_ATTRIBUTES = unsafe { zeroed() };
|
||||
attributes.nLength = size_of::<SECURITY_ATTRIBUTES>() as u32;
|
||||
attributes.lpSecurityDescriptor = descriptor as *mut c_void;
|
||||
attributes.bInheritHandle = 0;
|
||||
let pipe_name_wide = wide(pipe_name);
|
||||
let pipe = unsafe {
|
||||
CreateNamedPipeW(
|
||||
pipe_name_wide.as_ptr(),
|
||||
// FIRST_PIPE_INSTANCE makes creation fail closed when the name is
|
||||
// already taken: without it a same-user process could pre-create
|
||||
// the pipe and impersonate the broker toward the client.
|
||||
PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE,
|
||||
PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
|
||||
1,
|
||||
(MAX_BROKER_MESSAGE_BYTES + 4) as u32,
|
||||
(MAX_BROKER_MESSAGE_BYTES + 4) as u32,
|
||||
5_000,
|
||||
&attributes,
|
||||
)
|
||||
};
|
||||
unsafe { LocalFree(descriptor as *mut c_void) };
|
||||
if pipe == INVALID_HANDLE_VALUE {
|
||||
return Err(last_error("CreateNamedPipeW"));
|
||||
}
|
||||
|
||||
let result = (|| -> Result<(), String> {
|
||||
if unsafe { ConnectNamedPipe(pipe, null_mut()) } == 0
|
||||
&& unsafe { GetLastError() } != ERROR_PIPE_CONNECTED
|
||||
{
|
||||
return Err(last_error("ConnectNamedPipe"));
|
||||
}
|
||||
let mut client_pid = 0;
|
||||
if unsafe { GetNamedPipeClientProcessId(pipe, &mut client_pid) } == 0 {
|
||||
return Err(last_error("GetNamedPipeClientProcessId"));
|
||||
}
|
||||
let mut prefix = [0u8; 4];
|
||||
unsafe { read_exact(pipe, &mut prefix)? };
|
||||
let payload_length = u32::from_le_bytes(prefix) as usize;
|
||||
if payload_length == 0 || payload_length > MAX_BROKER_MESSAGE_BYTES {
|
||||
return Err("invalid broker payload length".to_owned());
|
||||
}
|
||||
let mut buffer = Vec::with_capacity(4 + payload_length);
|
||||
buffer.extend_from_slice(&prefix);
|
||||
buffer.resize(4 + payload_length, 0);
|
||||
unsafe { read_exact(pipe, &mut buffer[4..])? };
|
||||
let payload = decode_frame(&buffer).map_err(|error| format!("invalid frame: {error:?}"))?;
|
||||
let request: BrokerLaunchRequest = serde_json::from_slice(payload)
|
||||
.map_err(|error| format!("invalid broker request: {error}"))?;
|
||||
let request_id = request.request_id.clone();
|
||||
let mut authorizer = BrokerAuthorizer::new([profile_digest.to_owned()]);
|
||||
let outcome = match authorizer.authorize(&request, client_pid) {
|
||||
Ok(()) => match windows_launcher::launch_appcontainer(&request.launch) {
|
||||
Ok(exit_code) => BrokerLaunchOutcome::Completed { exit_code },
|
||||
Err(message) => BrokerLaunchOutcome::Rejected {
|
||||
code: "appcontainer_launch_failed".to_owned(),
|
||||
message,
|
||||
},
|
||||
},
|
||||
Err(error) => BrokerLaunchOutcome::Rejected {
|
||||
code: error.code().to_owned(),
|
||||
message: error.message(),
|
||||
},
|
||||
};
|
||||
let response = BrokerLaunchResponse {
|
||||
version: 1,
|
||||
request_id,
|
||||
outcome,
|
||||
};
|
||||
let response_payload = serde_json::to_vec(&response).map_err(|error| error.to_string())?;
|
||||
let response_frame = encode_frame(&response_payload)
|
||||
.map_err(|error| format!("response frame rejected: {error:?}"))?;
|
||||
unsafe { write_all(pipe, &response_frame)? };
|
||||
if unsafe { FlushFileBuffers(pipe) } == 0 {
|
||||
return Err(last_error("FlushFileBuffers(broker response)"));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
unsafe {
|
||||
DisconnectNamedPipe(pipe);
|
||||
CloseHandle(pipe);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
unsafe fn read_exact(pipe: *mut c_void, mut buffer: &mut [u8]) -> Result<(), String> {
|
||||
while !buffer.is_empty() {
|
||||
let mut bytes_read = 0;
|
||||
if unsafe {
|
||||
ReadFile(
|
||||
pipe,
|
||||
buffer.as_mut_ptr(),
|
||||
buffer.len() as u32,
|
||||
&mut bytes_read,
|
||||
null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(last_error("ReadFile(broker request)"));
|
||||
}
|
||||
if bytes_read == 0 {
|
||||
return Err("broker client closed before completing the frame".to_owned());
|
||||
}
|
||||
buffer = &mut buffer[bytes_read as usize..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn write_all(pipe: *mut c_void, mut buffer: &[u8]) -> Result<(), String> {
|
||||
while !buffer.is_empty() {
|
||||
let mut bytes_written = 0;
|
||||
if unsafe {
|
||||
WriteFile(
|
||||
pipe,
|
||||
buffer.as_ptr(),
|
||||
buffer.len() as u32,
|
||||
&mut bytes_written,
|
||||
null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(last_error("WriteFile(broker response)"));
|
||||
}
|
||||
if bytes_written == 0 {
|
||||
return Err("broker response write made no progress".to_owned());
|
||||
}
|
||||
buffer = &buffer[bytes_written as usize..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wide(value: &str) -> Vec<u16> {
|
||||
OsStr::new(value)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn last_error(operation: &str) -> String {
|
||||
format!("{operation} failed: {}", std::io::Error::last_os_error())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
const PIPE_PREFIX: &str = r"\\.\pipe\maka-sandbox-";
|
||||
|
||||
pub fn validate_pipe_name(name: &str) -> Result<(), PipeSecurityError> {
|
||||
let suffix = name
|
||||
.strip_prefix(PIPE_PREFIX)
|
||||
.ok_or(PipeSecurityError::InvalidPipeName)?;
|
||||
if suffix.is_empty()
|
||||
|| suffix.len() > 64
|
||||
|| !suffix
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err(PipeSecurityError::InvalidPipeName);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_account_sid(sid: &str) -> Result<(), PipeSecurityError> {
|
||||
let components = sid
|
||||
.strip_prefix("S-1-")
|
||||
.ok_or(PipeSecurityError::InvalidAccountSid)?
|
||||
.split('-')
|
||||
.collect::<Vec<_>>();
|
||||
let user_sid_shape = matches!(components.as_slice(), ["5", "21", _, _, _, _])
|
||||
|| matches!(components.as_slice(), ["12", "1", _, _, _, _]);
|
||||
if !user_sid_shape
|
||||
|| components.iter().any(|component| {
|
||||
component.is_empty()
|
||||
|| component.len() > 10
|
||||
|| !component.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
|| components
|
||||
.last()
|
||||
.and_then(|component| component.parse::<u32>().ok())
|
||||
== Some(0)
|
||||
{
|
||||
return Err(PipeSecurityError::InvalidAccountSid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pipe_security_sddl(account_sid: &str) -> Result<String, PipeSecurityError> {
|
||||
validate_account_sid(account_sid)?;
|
||||
Ok(format!("D:P(A;;GA;;;SY)(A;;GA;;;{account_sid})"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum PipeSecurityError {
|
||||
InvalidPipeName,
|
||||
InvalidAccountSid,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::broker_pipe_security::{
|
||||
PipeSecurityError, pipe_security_sddl, validate_account_sid, validate_pipe_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn accepts_only_private_maka_pipe_namespace() {
|
||||
assert_eq!(
|
||||
validate_pipe_name(r"\\.\pipe\maka-sandbox-session_42"),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_pipe_name(r"\\.\pipe\other-session"),
|
||||
Err(PipeSecurityError::InvalidPipeName)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_pipe_name(r"\\server\pipe\maka-sandbox-session"),
|
||||
Err(PipeSecurityError::InvalidPipeName)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_pipe_name(r"\\.\pipe\maka-sandbox-..\escape"),
|
||||
Err(PipeSecurityError::InvalidPipeName)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_canonical_numeric_account_sid() {
|
||||
assert_eq!(validate_account_sid("S-1-5-21-1-2-3-1001"), Ok(()));
|
||||
assert_eq!(
|
||||
validate_account_sid("BA"),
|
||||
Err(PipeSecurityError::InvalidAccountSid)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_account_sid("S-1-5-21-user"),
|
||||
Err(PipeSecurityError::InvalidAccountSid)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_account_sid("S-1-1-0"),
|
||||
Err(PipeSecurityError::InvalidAccountSid)
|
||||
);
|
||||
assert_eq!(validate_account_sid("S-1-12-1-1-2-3-4"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_protected_system_and_user_only_dacl() {
|
||||
assert_eq!(
|
||||
pipe_security_sddl("S-1-5-21-1-2-3-1001"),
|
||||
Ok("D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1001)".to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsStr;
|
||||
use std::iter;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr::null;
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
|
||||
use windows_sys::Win32::System::Pipes::{CreateNamedPipeW, PIPE_WAIT};
|
||||
|
||||
use crate::broker_pipe::serve_once;
|
||||
use crate::windows_launcher::current_user_sid_string;
|
||||
|
||||
#[test]
|
||||
fn refuses_to_serve_on_a_squatted_pipe_name() {
|
||||
let name = format!(r"\\.\pipe\maka-sandbox-squat-{}", std::process::id());
|
||||
let name_wide: Vec<u16> = OsStr::new(&name)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect();
|
||||
// A same-user squatter that allows additional instances: without
|
||||
// FILE_FLAG_FIRST_PIPE_INSTANCE the broker's own create would succeed
|
||||
// as a second instance and the squatter could race the client.
|
||||
let squatter = unsafe {
|
||||
CreateNamedPipeW(
|
||||
name_wide.as_ptr(),
|
||||
PIPE_ACCESS_DUPLEX,
|
||||
PIPE_WAIT,
|
||||
2,
|
||||
1024,
|
||||
1024,
|
||||
5_000,
|
||||
null(),
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
squatter != INVALID_HANDLE_VALUE,
|
||||
"squatter pipe create failed"
|
||||
);
|
||||
|
||||
let sid = current_user_sid_string().expect("current user SID");
|
||||
let digest = "0".repeat(64);
|
||||
let result = serve_once(&name, &sid, &digest);
|
||||
unsafe { CloseHandle(squatter) };
|
||||
|
||||
let error = result.expect_err("a squatted pipe name must not be served");
|
||||
assert!(
|
||||
error.contains("CreateNamedPipeW"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(not(windows))]
|
||||
compile_error!("maka-windows-sandbox is Windows-only");
|
||||
|
||||
mod acl_ledger;
|
||||
#[cfg(test)]
|
||||
mod acl_ledger_tests;
|
||||
mod broker_authorization;
|
||||
#[cfg(test)]
|
||||
mod broker_authorization_tests;
|
||||
mod broker_client;
|
||||
mod broker_framing;
|
||||
#[cfg(test)]
|
||||
mod broker_framing_tests;
|
||||
mod broker_pipe;
|
||||
mod broker_pipe_security;
|
||||
#[cfg(test)]
|
||||
mod broker_pipe_security_tests;
|
||||
#[cfg(test)]
|
||||
mod broker_pipe_tests;
|
||||
mod protocol;
|
||||
#[cfg(test)]
|
||||
mod protocol_tests;
|
||||
mod windows_launcher;
|
||||
#[cfg(test)]
|
||||
mod windows_launcher_desktop_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_launcher_tests;
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::process::{Command, ExitCode};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use broker_authorization::BrokerAuthorizer;
|
||||
use broker_framing::{decode_frame, encode_frame};
|
||||
use broker_pipe::serve_once;
|
||||
use protocol::{
|
||||
BrokerLaunchOutcome, BrokerLaunchRequest, BrokerLaunchResponse, LaunchRequest, launch_digest,
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
eprintln!("maka-windows-sandbox: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<u8, String> {
|
||||
let mut args = env::args_os();
|
||||
let _program = args.next();
|
||||
let first = args.next().ok_or_else(|| {
|
||||
"usage: maka-windows-sandbox [--atomic|--broker-validate] <request.json>".to_owned()
|
||||
})?;
|
||||
if first == "--self-probe" {
|
||||
if args.next().is_some() {
|
||||
return Err("--self-probe does not accept arguments".to_owned());
|
||||
}
|
||||
return windows_launcher::self_probe();
|
||||
}
|
||||
if first == "--readiness-probe" {
|
||||
if args.next().is_some() {
|
||||
return Err("--readiness-probe does not accept arguments".to_owned());
|
||||
}
|
||||
return windows_launcher::readiness_probe();
|
||||
}
|
||||
if first == "--stdio-probe" {
|
||||
let sleep_seconds = match args.next() {
|
||||
None => 0,
|
||||
Some(flag) if flag == "--sleep" => {
|
||||
let seconds = args
|
||||
.next()
|
||||
.ok_or_else(|| "--sleep requires a seconds value".to_owned())?
|
||||
.to_string_lossy()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("invalid --sleep seconds: {error}"))?;
|
||||
if args.next().is_some() {
|
||||
return Err("--stdio-probe accepts only --sleep <seconds>".to_owned());
|
||||
}
|
||||
seconds
|
||||
}
|
||||
Some(_) => return Err("--stdio-probe accepts only --sleep <seconds>".to_owned()),
|
||||
};
|
||||
return stdio_probe(sleep_seconds);
|
||||
}
|
||||
if first == "--appcontainer-sid" {
|
||||
let request_id = args
|
||||
.next()
|
||||
.ok_or_else(|| "--appcontainer-sid requires a request id".to_owned())?;
|
||||
if args.next().is_some() {
|
||||
return Err("--appcontainer-sid accepts exactly one request id".to_owned());
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
windows_launcher::appcontainer_sid_string(&request_id.to_string_lossy())?
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
if first == "--boundary-probe" {
|
||||
let denied_path = args.next().ok_or_else(|| {
|
||||
"--boundary-probe requires resource paths and loopback port".to_owned()
|
||||
})?;
|
||||
let allowed_read_path = args.next().ok_or_else(|| {
|
||||
"--boundary-probe requires resource paths and loopback port".to_owned()
|
||||
})?;
|
||||
let allowed_write_path = args.next().ok_or_else(|| {
|
||||
"--boundary-probe requires resource paths and loopback port".to_owned()
|
||||
})?;
|
||||
let port = args
|
||||
.next()
|
||||
.ok_or_else(|| "--boundary-probe requires resource paths and loopback port".to_owned())?
|
||||
.to_string_lossy()
|
||||
.parse::<u16>()
|
||||
.map_err(|error| format!("invalid boundary-probe port: {error}"))?;
|
||||
if args.next().is_some() {
|
||||
return Err("--boundary-probe accepts exactly four arguments".to_owned());
|
||||
}
|
||||
return boundary_probe(
|
||||
&denied_path.to_string_lossy(),
|
||||
&allowed_read_path.to_string_lossy(),
|
||||
&allowed_write_path.to_string_lossy(),
|
||||
port,
|
||||
);
|
||||
}
|
||||
if first == "--adversarial-probe" {
|
||||
let input_path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--adversarial-probe requires an input path".to_owned())?;
|
||||
if args.next().is_some() {
|
||||
return Err("--adversarial-probe accepts exactly one input path".to_owned());
|
||||
}
|
||||
let source = fs::read_to_string(&input_path)
|
||||
.map_err(|error| format!("read adversarial probe input failed: {error}"))?;
|
||||
// Windows PowerShell 5.1 may emit a UTF-8 BOM for `Set-Content
|
||||
// -Encoding utf8`; accepting it here keeps the probe input parser
|
||||
// deterministic across the supported PowerShell implementations.
|
||||
let source = source.strip_prefix('\u{feff}').unwrap_or(&source);
|
||||
let input: AdversarialProbeInput = serde_json::from_str(source)
|
||||
.map_err(|error| format!("decode adversarial probe input failed: {error}"))?;
|
||||
return adversarial_probe(&input);
|
||||
}
|
||||
if first == "--launch-digest" {
|
||||
let request_path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--launch-digest requires a request path".to_owned())?;
|
||||
if args.next().is_some() {
|
||||
return Err("--launch-digest accepts exactly one request path".to_owned());
|
||||
}
|
||||
let source = fs::read_to_string(request_path).map_err(|error| error.to_string())?;
|
||||
let request: LaunchRequest =
|
||||
serde_json::from_str(&source).map_err(|error| error.to_string())?;
|
||||
request.validate()?;
|
||||
println!("{}", launch_digest(&request)?);
|
||||
return Ok(0);
|
||||
}
|
||||
if first == "--broker-serve-once" {
|
||||
let pipe_name = args.next().ok_or_else(|| {
|
||||
"--broker-serve-once requires pipe name, account SID, and profile digest".to_owned()
|
||||
})?;
|
||||
let account_sid = args.next().ok_or_else(|| {
|
||||
"--broker-serve-once requires pipe name, account SID, and profile digest".to_owned()
|
||||
})?;
|
||||
let profile_digest = args.next().ok_or_else(|| {
|
||||
"--broker-serve-once requires pipe name, account SID, and profile digest".to_owned()
|
||||
})?;
|
||||
if args.next().is_some() {
|
||||
return Err("--broker-serve-once accepts exactly three arguments".to_owned());
|
||||
}
|
||||
serve_once(
|
||||
&pipe_name.to_string_lossy(),
|
||||
&account_sid.to_string_lossy(),
|
||||
&profile_digest.to_string_lossy(),
|
||||
)?;
|
||||
return Ok(0);
|
||||
}
|
||||
if first == "--broker-client" {
|
||||
let pipe_name = args
|
||||
.next()
|
||||
.ok_or_else(|| "--broker-client requires pipe name and manifest path".to_owned())?;
|
||||
let manifest_path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--broker-client requires pipe name and manifest path".to_owned())?;
|
||||
if args.next().is_some() {
|
||||
return Err("--broker-client accepts exactly two arguments".to_owned());
|
||||
}
|
||||
return broker_client::run(
|
||||
&pipe_name.to_string_lossy(),
|
||||
&manifest_path.to_string_lossy(),
|
||||
);
|
||||
}
|
||||
if first == "--broker-local" {
|
||||
let manifest_path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--broker-local requires a manifest path".to_owned())?;
|
||||
if args.next().is_some() {
|
||||
return Err("--broker-local accepts exactly one argument".to_owned());
|
||||
}
|
||||
return broker_client::run_local(&manifest_path.to_string_lossy());
|
||||
}
|
||||
if first == "--appcontainer" {
|
||||
let request_path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--appcontainer requires a request path".to_owned())?;
|
||||
if args.next().is_some() {
|
||||
return Err("--appcontainer accepts exactly one request path".to_owned());
|
||||
}
|
||||
let source = fs::read_to_string(request_path).map_err(|error| error.to_string())?;
|
||||
let request: LaunchRequest =
|
||||
serde_json::from_str(&source).map_err(|error| error.to_string())?;
|
||||
request.validate()?;
|
||||
return windows_launcher::launch_appcontainer(&request);
|
||||
}
|
||||
let (mode, request_path) = if first == "--atomic" {
|
||||
let path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--atomic requires exactly one request path".to_owned())?;
|
||||
("atomic", path)
|
||||
} else if first == "--broker-validate" {
|
||||
let path = args
|
||||
.next()
|
||||
.ok_or_else(|| "--broker-validate requires exactly one request path".to_owned())?;
|
||||
("broker-validate", path)
|
||||
} else {
|
||||
("legacy", first)
|
||||
};
|
||||
if args.next().is_some() {
|
||||
return Err("expected exactly one request path".to_owned());
|
||||
}
|
||||
|
||||
let source = fs::read_to_string(request_path).map_err(|error| error.to_string())?;
|
||||
match mode {
|
||||
"broker-validate" => validate_broker_request(&source),
|
||||
"atomic" | "legacy" => {
|
||||
let request: LaunchRequest =
|
||||
serde_json::from_str(&source).map_err(|error| error.to_string())?;
|
||||
request.validate()?;
|
||||
if mode == "atomic" {
|
||||
windows_launcher::launch_atomic(&request)
|
||||
} else {
|
||||
windows_launcher::launch(&request)
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test child for the stdio relay: consumes all of stdin, reports what it saw
|
||||
/// on stdout as a single JSON document, and keeps diagnostics on stderr —
|
||||
/// mirroring the filesystem-worker contract without needing Node inside the
|
||||
/// AppContainer.
|
||||
fn stdio_probe(sleep_seconds: u64) -> Result<u8, String> {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut payload = Vec::new();
|
||||
std::io::stdin()
|
||||
.read_to_end(&mut payload)
|
||||
.map_err(|error| format!("stdio-probe read failed: {error}"))?;
|
||||
if sleep_seconds > 0 {
|
||||
std::thread::sleep(Duration::from_secs(sleep_seconds));
|
||||
}
|
||||
eprintln!("stdio-probe: diagnostics stay on stderr");
|
||||
println!(
|
||||
"{{\"echoBytes\":{},\"sha256\":\"{:x}\"}}",
|
||||
payload.len(),
|
||||
Sha256::digest(&payload)
|
||||
);
|
||||
std::io::stdout()
|
||||
.flush()
|
||||
.map_err(|error| format!("stdio-probe flush failed: {error}"))?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn boundary_probe(
|
||||
denied_path: &str,
|
||||
allowed_read_path: &str,
|
||||
allowed_write_path: &str,
|
||||
port: u16,
|
||||
) -> Result<u8, String> {
|
||||
let file_denied = fs::read(denied_path).is_err();
|
||||
let allowed_read =
|
||||
matches!(fs::read_to_string(allowed_read_path), Ok(value) if value == "allowed-read");
|
||||
let allowed_write = fs::write(allowed_write_path, b"allowed-write").is_ok();
|
||||
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
|
||||
let network_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err();
|
||||
// Self-attest the desktop the confined child was *initially placed* on
|
||||
// (RFC §6.3): the per-launch private desktop, never the interactive
|
||||
// `Default`. Querying its own desktop name is allowed because the desktop
|
||||
// DACL grants this AppContainer SID DESKTOP_READOBJECTS. This proves initial
|
||||
// `STARTUPINFOW.lpDesktop` placement only — it is NOT proof of escape-proof
|
||||
// confinement: absent a no-Win32k mitigation or a window-station/token
|
||||
// boundary, in-process code could still `OpenDesktopW("Default")` +
|
||||
// `SetThreadDesktop`. The escape-proof boundary is a deferred gate (§6.5).
|
||||
let desktop = current_desktop_name()?;
|
||||
let desktop_private_placement = desktop_is_private_placement(&desktop);
|
||||
println!(
|
||||
"{{\"fileDenied\":{file_denied},\"allowedRead\":{allowed_read},\"allowedWrite\":{allowed_write},\"networkDenied\":{network_denied},\"desktop\":{desktop_json},\"desktopPrivatePlacement\":{desktop_private_placement}}}",
|
||||
desktop_json = json_string(&desktop)
|
||||
);
|
||||
if !file_denied || !allowed_read || !allowed_write || !network_denied {
|
||||
return Err("AppContainer boundary did not enforce the requested resources".to_owned());
|
||||
}
|
||||
if !desktop_private_placement {
|
||||
return Err(format!(
|
||||
"confined child was placed on the interactive desktop {desktop:?} instead of a private one"
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AdversarialProbeInput {
|
||||
denied_path: String,
|
||||
allowed_read_path: String,
|
||||
allowed_write_path: String,
|
||||
loopback_port: u16,
|
||||
pipe_name: String,
|
||||
environment_secret_name: String,
|
||||
registry_subkey: String,
|
||||
registry_value_name: String,
|
||||
parent_pid: u32,
|
||||
}
|
||||
|
||||
/// Malicious-child probe for the packaged Phase 4 matrix. Every check runs
|
||||
/// from inside the production AppContainer identity. A false field is a hard
|
||||
/// failure: the release verifier must never turn an unavailable probe into a
|
||||
/// vacuous pass.
|
||||
fn adversarial_probe(input: &AdversarialProbeInput) -> Result<u8, String> {
|
||||
let file_denied = fs::read(&input.denied_path).is_err();
|
||||
let allowed_read = matches!(fs::read_to_string(&input.allowed_read_path), Ok(value) if value == "allowed-read");
|
||||
let allowed_write = fs::write(&input.allowed_write_path, b"allowed-write").is_ok();
|
||||
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), input.loopback_port);
|
||||
let tcp_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err();
|
||||
let named_pipe_denied = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&input.pipe_name)
|
||||
.is_err();
|
||||
let environment_denied = env::var_os(&input.environment_secret_name).is_none();
|
||||
let registry_denied = registry_value_denied(&input.registry_subkey, &input.registry_value_name);
|
||||
let parent_token_denied = parent_token_denied(input.parent_pid);
|
||||
let (descendant_app_container, descendant_in_job, descendant_spawn_denied) =
|
||||
descendant_boundary();
|
||||
|
||||
let evidence = serde_json::json!({
|
||||
"fileDenied": file_denied,
|
||||
"allowedRead": allowed_read,
|
||||
"allowedWrite": allowed_write,
|
||||
"tcpDenied": tcp_denied,
|
||||
"namedPipeDenied": named_pipe_denied,
|
||||
"environmentDenied": environment_denied,
|
||||
"registryDenied": registry_denied,
|
||||
"parentTokenDenied": parent_token_denied,
|
||||
"descendantAppContainer": descendant_app_container,
|
||||
"descendantInJob": descendant_in_job,
|
||||
"descendantSpawnDenied": descendant_spawn_denied,
|
||||
});
|
||||
println!("{evidence}");
|
||||
|
||||
let passed = file_denied
|
||||
&& allowed_read
|
||||
&& allowed_write
|
||||
&& tcp_denied
|
||||
&& named_pipe_denied
|
||||
&& environment_denied
|
||||
&& registry_denied
|
||||
&& parent_token_denied
|
||||
&& (descendant_spawn_denied || (descendant_app_container && descendant_in_job));
|
||||
if !passed {
|
||||
return Err(format!(
|
||||
"AppContainer adversarial matrix did not hold: {evidence}"
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn registry_value_denied(subkey: &str, value_name: &str) -> bool {
|
||||
use std::ptr::{null, null_mut};
|
||||
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW, RegQueryValueExW,
|
||||
};
|
||||
|
||||
let subkey = wide(subkey);
|
||||
let value_name = wide(value_name);
|
||||
let mut key = null_mut();
|
||||
let open = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut key) };
|
||||
if open != ERROR_SUCCESS {
|
||||
return true;
|
||||
}
|
||||
let mut value_type: u32 = 0;
|
||||
let mut value_bytes: u32 = 0;
|
||||
let query = unsafe {
|
||||
RegQueryValueExW(
|
||||
key,
|
||||
value_name.as_ptr(),
|
||||
null(),
|
||||
&mut value_type,
|
||||
null_mut(),
|
||||
&mut value_bytes,
|
||||
)
|
||||
};
|
||||
unsafe { RegCloseKey(key) };
|
||||
query != ERROR_SUCCESS
|
||||
}
|
||||
|
||||
fn parent_token_denied(parent_pid: u32) -> bool {
|
||||
use std::ptr::null_mut;
|
||||
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::Security::TOKEN_QUERY;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, parent_pid) };
|
||||
if process.is_null() {
|
||||
return true;
|
||||
}
|
||||
let mut token = null_mut();
|
||||
let denied = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0;
|
||||
if !token.is_null() {
|
||||
unsafe { CloseHandle(token) };
|
||||
}
|
||||
unsafe { CloseHandle(process) };
|
||||
denied
|
||||
}
|
||||
|
||||
fn descendant_boundary() -> (bool, bool, bool) {
|
||||
let executable = match env::current_exe() {
|
||||
Ok(executable) => executable,
|
||||
Err(_) => return (false, false, true),
|
||||
};
|
||||
let output = match Command::new(executable).arg("--self-probe").output() {
|
||||
Ok(output) if output.status.success() => output,
|
||||
Err(_) => return (false, false, true),
|
||||
Ok(_) => return (false, false, false),
|
||||
};
|
||||
let evidence: serde_json::Value = match serde_json::from_slice(&output.stdout) {
|
||||
Ok(evidence) => evidence,
|
||||
Err(_) => return (false, false, false),
|
||||
};
|
||||
(
|
||||
evidence
|
||||
.get("appContainer")
|
||||
.and_then(|value| value.as_bool())
|
||||
== Some(true),
|
||||
evidence.get("inJob").and_then(|value| value.as_bool()) == Some(true),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn wide(value: &str) -> Vec<u16> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
std::ffi::OsStr::new(value)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prefix every launcher-created private desktop name carries
|
||||
/// (`maka-sandbox-desktop.<pid>.<nonce>`). Attesting against the prefix rather
|
||||
/// than merely "not Default" means a child that somehow started on *any other*
|
||||
/// pre-existing desktop (`Winlogon`, a screensaver desktop, another product's
|
||||
/// alternate desktop) still fails the placement check.
|
||||
const PRIVATE_DESKTOP_PREFIX: &str = "maka-sandbox-desktop.";
|
||||
|
||||
/// A confined worker must be *initially placed* on the per-launch private
|
||||
/// desktop this launcher created — never the shared interactive `Default`
|
||||
/// desktop, never an empty/unnamed one, and never some other desktop that
|
||||
/// merely isn't `Default`. This is a placement check, not an escape-proof
|
||||
/// confinement check: it only attests the initial `lpDesktop` name
|
||||
/// (RFC §6.3, §6.5).
|
||||
fn desktop_is_private_placement(name: &str) -> bool {
|
||||
name.len() > PRIVATE_DESKTOP_PREFIX.len()
|
||||
&& name
|
||||
.get(..PRIVATE_DESKTOP_PREFIX.len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(PRIVATE_DESKTOP_PREFIX))
|
||||
}
|
||||
|
||||
/// JSON string encoding for names embedded in probe output. Delegates to
|
||||
/// `serde_json` (already a dependency) instead of hand-rolled escaping; string
|
||||
/// serialization is infallible.
|
||||
fn json_string(value: &str) -> String {
|
||||
serde_json::to_string(value).expect("JSON string encoding cannot fail")
|
||||
}
|
||||
|
||||
/// Name of the desktop the current thread is attached to. Fails closed: a
|
||||
/// worker that cannot even confirm its desktop is treated as unconfined.
|
||||
fn current_desktop_name() -> Result<String, String> {
|
||||
use std::ffi::c_void;
|
||||
|
||||
use windows_sys::Win32::System::StationsAndDesktops::{
|
||||
GetThreadDesktop, GetUserObjectInformationW, UOI_NAME,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
|
||||
|
||||
unsafe {
|
||||
let desktop = GetThreadDesktop(GetCurrentThreadId());
|
||||
if desktop.is_null() {
|
||||
return Err("GetThreadDesktop returned null".to_owned());
|
||||
}
|
||||
let mut needed = 0u32;
|
||||
// First call sizes the name buffer (bytes needed, including the
|
||||
// terminating wide NUL).
|
||||
GetUserObjectInformationW(desktop, UOI_NAME, std::ptr::null_mut(), 0, &mut needed);
|
||||
if needed == 0 {
|
||||
return Err("GetUserObjectInformationW(UOI_NAME size) returned zero".to_owned());
|
||||
}
|
||||
let words = (needed as usize).div_ceil(std::mem::size_of::<u16>());
|
||||
let mut buffer = vec![0u16; words];
|
||||
if GetUserObjectInformationW(
|
||||
desktop,
|
||||
UOI_NAME,
|
||||
buffer.as_mut_ptr() as *mut c_void,
|
||||
needed,
|
||||
&mut needed,
|
||||
) == 0
|
||||
{
|
||||
return Err("GetUserObjectInformationW(UOI_NAME) failed".to_owned());
|
||||
}
|
||||
let length = buffer.iter().position(|&code| code == 0).unwrap_or(words);
|
||||
Ok(String::from_utf16_lossy(&buffer[..length]))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_broker_request(source: &str) -> Result<u8, String> {
|
||||
let request: BrokerLaunchRequest = serde_json::from_str(source)
|
||||
.map_err(|error| format!("broker request rejected: {error}"))?;
|
||||
// Contract validation has no pipe peer yet. The service path will replace
|
||||
// these claimed values with the connected process PID and approved policy.
|
||||
let mut authorizer = BrokerAuthorizer::new([request.profile_digest.clone()]);
|
||||
let connected_client_pid = request.client_pid;
|
||||
let response = match authorizer.authorize(&request, connected_client_pid) {
|
||||
Ok(()) => BrokerLaunchResponse {
|
||||
version: 1,
|
||||
request_id: request.request_id,
|
||||
outcome: BrokerLaunchOutcome::Rejected {
|
||||
code: "broker_not_connected".to_owned(),
|
||||
message: "request is valid but no broker service is connected".to_owned(),
|
||||
},
|
||||
},
|
||||
Err(error) => BrokerLaunchResponse {
|
||||
version: 1,
|
||||
request_id: request.request_id,
|
||||
outcome: BrokerLaunchOutcome::Rejected {
|
||||
code: error.code().to_owned(),
|
||||
message: error.message(),
|
||||
},
|
||||
},
|
||||
};
|
||||
let payload = serde_json::to_vec(&response).map_err(|error| error.to_string())?;
|
||||
let frame =
|
||||
encode_frame(&payload).map_err(|error| format!("broker frame rejected: {error:?}"))?;
|
||||
let decoded =
|
||||
decode_frame(&frame).map_err(|error| format!("broker frame rejected: {error:?}"))?;
|
||||
println!("{}", String::from_utf8_lossy(decoded));
|
||||
Ok(0)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LaunchRequest {
|
||||
pub version: u32,
|
||||
pub request_id: String,
|
||||
pub executable: String,
|
||||
pub arguments: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exact_read_roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exact_write_roots: Vec<String>,
|
||||
pub network: NetworkMode,
|
||||
pub environment: BTreeMap<String, String>,
|
||||
/// Optional child-wait deadline. Appended last and skipped when absent so
|
||||
/// manifests written before this field keep an identical launch digest.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub const MIN_LAUNCH_TIMEOUT_MS: u64 = 1_000;
|
||||
pub const MAX_LAUNCH_TIMEOUT_MS: u64 = 600_000;
|
||||
pub const DEFAULT_LAUNCH_TIMEOUT_MS: u64 = 30_000;
|
||||
|
||||
/// `request_id` reserved for the internal Windows readiness probe. Its own
|
||||
/// AppContainer profile/SID is derived from this exact identity, and
|
||||
/// `create_readiness` best-effort *deletes* that profile before recreating it.
|
||||
/// A production launch is therefore forbidden from carrying it: otherwise an
|
||||
/// ordinary launch would resolve to the same profile the probe reclaims,
|
||||
/// letting the two identities collide. `validate` rejects it so the readiness
|
||||
/// namespace is one production requests can never enter — enforced, not merely
|
||||
/// documented.
|
||||
pub const RESERVED_READINESS_REQUEST_ID: &str = "readiness-probe";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
pub struct BrokerLaunchRequest {
|
||||
pub version: u32,
|
||||
pub request_id: String,
|
||||
pub client_pid: u32,
|
||||
pub client_nonce: String,
|
||||
pub profile_digest: String,
|
||||
pub launch: LaunchRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
pub struct BrokerLaunchResponse {
|
||||
pub version: u32,
|
||||
pub request_id: String,
|
||||
pub outcome: BrokerLaunchOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
#[allow(dead_code)]
|
||||
pub enum BrokerLaunchOutcome {
|
||||
Started {
|
||||
process_id: u32,
|
||||
},
|
||||
Completed {
|
||||
#[serde(rename = "exitCode")]
|
||||
exit_code: u8,
|
||||
},
|
||||
Rejected {
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkMode {
|
||||
Restricted,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
impl LaunchRequest {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 1 {
|
||||
return Err("unsupported protocol version".to_owned());
|
||||
}
|
||||
if self.request_id.is_empty()
|
||||
|| self.request_id.len() > 128
|
||||
|| !self
|
||||
.request_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err("requestId must use 1-128 safe ASCII characters".to_owned());
|
||||
}
|
||||
if self.request_id == RESERVED_READINESS_REQUEST_ID {
|
||||
return Err(format!(
|
||||
"requestId '{RESERVED_READINESS_REQUEST_ID}' is reserved for the internal readiness probe"
|
||||
));
|
||||
}
|
||||
validate_path(&self.executable, "executable")?;
|
||||
validate_path(&self.cwd, "cwd")?;
|
||||
validate_roots(&self.read_roots, "readRoots")?;
|
||||
validate_roots(&self.write_roots, "writeRoots")?;
|
||||
validate_roots(&self.exact_read_roots, "exactReadRoots")?;
|
||||
validate_roots(&self.exact_write_roots, "exactWriteRoots")?;
|
||||
if let Some(timeout_ms) = self.timeout_ms {
|
||||
if !(MIN_LAUNCH_TIMEOUT_MS..=MAX_LAUNCH_TIMEOUT_MS).contains(&timeout_ms) {
|
||||
return Err(format!(
|
||||
"timeoutMs must be between {MIN_LAUNCH_TIMEOUT_MS} and {MAX_LAUNCH_TIMEOUT_MS}"
|
||||
));
|
||||
}
|
||||
}
|
||||
for (name, value) in &self.environment {
|
||||
// The CreateProcess environment block is `name=value\0...\0\0`, so
|
||||
// a name may not be empty, may not contain '=' or a control
|
||||
// character (NUL is < 0x20 and would truncate the block), and may
|
||||
// not begin with '=' — that leading form is Windows' hidden
|
||||
// per-drive cwd variable (`=C:`). Everything else, including the
|
||||
// parentheses in real names like `CommonProgramFiles(x86)`, is a
|
||||
// legitimate Windows environment name.
|
||||
if name.is_empty()
|
||||
|| name.starts_with('=')
|
||||
|| name.chars().any(|c| c == '=' || (c as u32) < 0x20)
|
||||
{
|
||||
return Err(format!("invalid environment name: {name}"));
|
||||
}
|
||||
if value.contains('\0') {
|
||||
return Err(format!("invalid environment value for {name}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BrokerLaunchRequest {
|
||||
#[allow(dead_code)]
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 1 {
|
||||
return Err("unsupported broker protocol version".to_owned());
|
||||
}
|
||||
if self.request_id.is_empty() {
|
||||
return Err("requestId must be non-empty".to_owned());
|
||||
}
|
||||
if self.client_pid == 0 {
|
||||
return Err("clientPid must be non-zero".to_owned());
|
||||
}
|
||||
if !is_fixed_hex(&self.client_nonce, 32) {
|
||||
return Err("clientNonce must be 32 hexadecimal characters".to_owned());
|
||||
}
|
||||
if !is_fixed_hex(&self.profile_digest, 64) {
|
||||
return Err("profileDigest must be 64 hexadecimal characters".to_owned());
|
||||
}
|
||||
self.launch.validate()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_digest(request: &LaunchRequest) -> Result<String, String> {
|
||||
let canonical = serde_json::to_vec(request)
|
||||
.map_err(|error| format!("serialize canonical launch policy failed: {error}"))?;
|
||||
Ok(format!("{:x}", Sha256::digest(canonical)))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn is_fixed_hex(value: &str, length: usize) -> bool {
|
||||
value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn validate_roots(roots: &[String], field: &str) -> Result<(), String> {
|
||||
let mut unique = HashSet::new();
|
||||
for (index, root) in roots.iter().enumerate() {
|
||||
validate_path(root, &format!("{field}[{index}]"))?;
|
||||
if !unique.insert(root.to_lowercase()) {
|
||||
return Err(format!("{field} must not contain duplicate paths"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_path(value: &str, field: &str) -> Result<(), String> {
|
||||
let path = Path::new(value);
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("{field} must be absolute"));
|
||||
}
|
||||
if value.contains('/') || (value.ends_with('\\') && !is_windows_root(value)) {
|
||||
return Err(format!("{field} must use canonical Windows separators"));
|
||||
}
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir | Component::CurDir))
|
||||
{
|
||||
return Err(format!("{field} must be lexically canonical"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_windows_root(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() == 3 && bytes[1] == b':' && bytes[2] == b'\\' && bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::protocol::{BrokerLaunchRequest, launch_digest};
|
||||
|
||||
fn request() -> BrokerLaunchRequest {
|
||||
serde_json::from_str(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"requestId": "broker-1",
|
||||
"clientPid": 42,
|
||||
"clientNonce": "0123456789abcdef0123456789abcdef",
|
||||
"profileDigest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"launch": {
|
||||
"version": 1,
|
||||
"requestId": "launch-1",
|
||||
"executable": "C:\\Windows\\System32\\cmd.exe",
|
||||
"arguments": ["/d", "/c", "exit 0"],
|
||||
"cwd": "C:\\Windows",
|
||||
"readRoots": [],
|
||||
"writeRoots": [],
|
||||
"network": "enabled",
|
||||
"environment": {}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("valid broker request")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_authorized_request_shape() {
|
||||
assert!(request().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_nonce_before_launch() {
|
||||
let mut value = request();
|
||||
value.client_nonce = "short".to_owned();
|
||||
assert_eq!(
|
||||
value.validate().unwrap_err(),
|
||||
"clientNonce must be 32 hexadecimal characters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_profile_digest_before_launch() {
|
||||
let mut value = request();
|
||||
value.profile_digest = "not-a-digest".to_owned();
|
||||
assert_eq!(
|
||||
value.validate().unwrap_err(),
|
||||
"profileDigest must be 64 hexadecimal characters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_request_ids_that_cannot_safely_name_lifecycle_artifacts() {
|
||||
for request_id in ["contains:stream", "contains/slash", "contains space"] {
|
||||
let mut value = request();
|
||||
value.launch.request_id = request_id.to_owned();
|
||||
assert_eq!(
|
||||
value.validate().unwrap_err(),
|
||||
"requestId must use 1-128 safe ASCII characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_digest_is_unchanged_for_manifests_without_timeout() {
|
||||
// timeoutMs is optional and skipped when absent, so a pre-timeout
|
||||
// manifest must produce the exact digest it produced before the field
|
||||
// existed — otherwise old manifests hit profile_digest_mismatch.
|
||||
let value = request();
|
||||
assert!(value.launch.timeout_ms.is_none());
|
||||
let serialized = serde_json::to_string(&value.launch).expect("serialize launch");
|
||||
assert!(!serialized.contains("timeoutMs"));
|
||||
let reparsed = serde_json::from_str::<super::super::protocol::LaunchRequest>(&serialized)
|
||||
.expect("reparse launch");
|
||||
assert_eq!(
|
||||
launch_digest(&value.launch).expect("digest"),
|
||||
launch_digest(&reparsed).expect("digest"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_real_windows_env_names_and_rejects_block_breaking_ones() {
|
||||
// `CommonProgramFiles(x86)` is a standard Windows variable; the
|
||||
// launcher must not reject legitimate names that contain parentheses.
|
||||
let mut value = request();
|
||||
value.launch.environment.insert(
|
||||
"CommonProgramFiles(x86)".to_owned(),
|
||||
"C:\\Program Files (x86)\\Common Files".to_owned(),
|
||||
);
|
||||
assert!(value.validate().is_ok());
|
||||
|
||||
for bad_name in ["A=B", "=C:", "BAD\u{0}NAME", ""] {
|
||||
let mut invalid = request();
|
||||
invalid
|
||||
.launch
|
||||
.environment
|
||||
.insert(bad_name.to_owned(), "value".to_owned());
|
||||
assert!(
|
||||
invalid.validate().is_err(),
|
||||
"expected env name {bad_name:?} to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
let mut nul_value = request();
|
||||
nul_value
|
||||
.launch
|
||||
.environment
|
||||
.insert("GOOD_NAME".to_owned(), "has\u{0}nul".to_owned());
|
||||
assert_eq!(
|
||||
nul_value.validate().unwrap_err(),
|
||||
"invalid environment value for GOOD_NAME"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_timeout_within_bounds_and_rejects_outside() {
|
||||
let mut value = request();
|
||||
value.launch.timeout_ms = Some(130_000);
|
||||
assert!(value.validate().is_ok());
|
||||
value.launch.timeout_ms = Some(999);
|
||||
assert_eq!(
|
||||
value.validate().unwrap_err(),
|
||||
"timeoutMs must be between 1000 and 600000"
|
||||
);
|
||||
value.launch.timeout_ms = Some(600_001);
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
//! Coverage for the private-desktop *placement* (RFC §6.3): the security
|
||||
//! descriptor handed to `CreateDesktopExW`, the child-side attestation that
|
||||
//! rejects everything but the launcher-owned desktop, and a real-OS check that
|
||||
//! the supported launch concurrency fits the bounded desktop-heap budget.
|
||||
//! These attest initial `lpDesktop` placement plus the desktop security
|
||||
//! descriptor, not escape-proof confinement (§6.5).
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::ptr::null_mut;
|
||||
|
||||
use windows_sys::Win32::Security::FreeSid;
|
||||
use windows_sys::Win32::Security::Isolation::DeriveAppContainerSidFromAppContainerName;
|
||||
|
||||
use crate::desktop_is_private_placement;
|
||||
use crate::windows_launcher::{create_confined_desktop, desktop_sddl};
|
||||
|
||||
const APP_SID: &str =
|
||||
"S-1-15-2-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890";
|
||||
const OWNER_SID: &str = "S-1-5-21-11111111-22222222-33333333-1001";
|
||||
|
||||
#[test]
|
||||
fn desktop_dacl_grants_owner_and_system_full_control() {
|
||||
let sddl = desktop_sddl(OWNER_SID, APP_SID);
|
||||
// Protected DACL: no inherited ACEs bleed onto the private desktop.
|
||||
assert!(sddl.starts_with("D:P"), "DACL must be protected: {sddl}");
|
||||
assert!(
|
||||
sddl.contains(&format!("(A;;GA;;;{OWNER_SID})")),
|
||||
"owner keeps full control for cleanup: {sddl}"
|
||||
);
|
||||
assert!(
|
||||
sddl.contains("(A;;GA;;;SY)"),
|
||||
"Local System keeps full control: {sddl}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_dacl_denies_interactive_control_to_the_effective_user_sid() {
|
||||
let sddl = desktop_sddl(OWNER_SID, APP_SID);
|
||||
// The AppContainer child's token carries the launching-user SID as an
|
||||
// effective SID, so the owner GA allow would otherwise name the child as a
|
||||
// grantee of SWITCHDESKTOP(0x100) | HOOKCONTROL(0x8) | JOURNALRECORD(0x10)
|
||||
// | JOURNALPLAYBACK(0x20) = 0x138. A deny ACE strips those bits in the
|
||||
// DACL itself instead of relying on lowbox access-check intersection.
|
||||
let deny = sddl
|
||||
.find(&format!("(D;;0x138;;;{OWNER_SID})"))
|
||||
.expect("deny ACE for interactive-control rights must be present");
|
||||
// Deny ACEs must precede allow ACEs to be effective in canonical order.
|
||||
let first_allow = sddl.find("(A;;").expect("allow ACEs present");
|
||||
assert!(
|
||||
deny < first_allow,
|
||||
"deny ACE must precede every allow ACE: {sddl}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_dacl_grants_app_container_only_minimal_rights() {
|
||||
let sddl = desktop_sddl(OWNER_SID, APP_SID);
|
||||
// READOBJECTS(0x1) | CREATEWINDOW(0x2) | CREATEMENU(0x4) | ENUMERATE(0x40)
|
||||
// | WRITEOBJECTS(0x80) = 0xc7. Never SWITCHDESKTOP(0x100), HOOKCONTROL(0x8),
|
||||
// or JOURNAL*(0x10/0x20).
|
||||
assert!(
|
||||
sddl.contains(&format!("(A;;0xc7;;;{APP_SID})")),
|
||||
"AppContainer SID gets exactly the minimal non-interactive mask: {sddl}"
|
||||
);
|
||||
assert!(
|
||||
!sddl.contains(&format!("GA;;;{APP_SID}")),
|
||||
"AppContainer SID must never get generic-all on the desktop: {sddl}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_placement_requires_the_launcher_owned_desktop_prefix() {
|
||||
assert!(desktop_is_private_placement(
|
||||
"maka-sandbox-desktop.4321.1699999999999999999"
|
||||
));
|
||||
// Desktop names are case-insensitive on Windows.
|
||||
assert!(desktop_is_private_placement("MAKA-SANDBOX-DESKTOP.1.2"));
|
||||
assert!(!desktop_is_private_placement("Default"));
|
||||
assert!(!desktop_is_private_placement("default"));
|
||||
assert!(!desktop_is_private_placement(""));
|
||||
// Not-Default is not enough: a child that lands on any other pre-existing
|
||||
// desktop was not placed on the launcher's per-launch private desktop.
|
||||
assert!(!desktop_is_private_placement("Winlogon"));
|
||||
assert!(!desktop_is_private_placement("Screen-saver"));
|
||||
// The bare prefix with no pid/nonce suffix is not a launcher-created name.
|
||||
assert!(!desktop_is_private_placement("maka-sandbox-desktop."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_sddl_pins_a_low_integrity_no_write_up_label() {
|
||||
let sddl = desktop_sddl(OWNER_SID, APP_SID);
|
||||
// Without an explicit label the desktop inherits the creator's Medium
|
||||
// integrity, and MIC — evaluated before the DACL — would make the Low-IL
|
||||
// AppContainer child's granted create-window/write rights unusable.
|
||||
assert!(
|
||||
sddl.ends_with("S:(ML;;NW;;;LW)"),
|
||||
"desktop must carry a Low no-write-up mandatory label: {sddl}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_confined_desktops_can_be_held_live() {
|
||||
// Real-OS budget check for `CONFINED_DESKTOP_HEAP_KB` (RFC §6.3): the
|
||||
// repository supports ten-way launch concurrency, so ten private desktops
|
||||
// must be able to exist *simultaneously* without exhausting the system
|
||||
// desktop heap. The default 3,072 KiB per-desktop allocation would put ten
|
||||
// live desktops at ~30 MiB of the documented 48 MiB limit; the bounded
|
||||
// 512 KiB budget keeps them at ~5 MiB. Children are not launched — the
|
||||
// desktops themselves own the heap allocation this bounds.
|
||||
let mut sid: *mut c_void = null_mut();
|
||||
let name: Vec<u16> = "maka-desktop-heap-test"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let derived =
|
||||
unsafe { DeriveAppContainerSidFromAppContainerName(name.as_ptr(), &mut sid) } == 0;
|
||||
assert!(derived, "derive test AppContainer SID");
|
||||
let desktops: Vec<_> = (0..10)
|
||||
.map(|index| {
|
||||
unsafe { create_confined_desktop(sid) }
|
||||
.unwrap_or_else(|error| panic!("desktop {index} must fit the heap budget: {error}"))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(desktops.len(), 10);
|
||||
// Ten *distinct* objects, not ten handles to a reopened one:
|
||||
// `CreateDesktopExW` opens an existing desktop on a name collision (and
|
||||
// then silently ignores the supplied DACL and heap size), so the CSPRNG
|
||||
// nonce must have produced ten different names while all handles are held
|
||||
// live simultaneously.
|
||||
let names: std::collections::HashSet<String> = desktops
|
||||
.iter()
|
||||
.map(|desktop| desktop.name_string())
|
||||
.collect();
|
||||
assert_eq!(names.len(), 10, "desktop names must be unique: {names:?}");
|
||||
for name in &names {
|
||||
assert!(
|
||||
desktop_is_private_placement(name),
|
||||
"every created desktop must pass the placement attestation: {name}"
|
||||
);
|
||||
}
|
||||
// Drop order releases all ten handles; the kernel reclaims the desktops.
|
||||
drop(desktops);
|
||||
unsafe { FreeSid(sid) };
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::acl_ledger::readiness_mutex_name;
|
||||
use crate::protocol::{LaunchRequest, NetworkMode, RESERVED_READINESS_REQUEST_ID};
|
||||
use crate::windows_launcher::{
|
||||
appcontainer_profile_name, appcontainer_readiness_profile_name, environment_block,
|
||||
readiness_probe_command_line,
|
||||
};
|
||||
|
||||
fn valid_launch_request(request_id: &str) -> LaunchRequest {
|
||||
LaunchRequest {
|
||||
version: 1,
|
||||
request_id: request_id.to_owned(),
|
||||
executable: "C:\\Windows\\System32\\cmd.exe".to_owned(),
|
||||
arguments: Vec::new(),
|
||||
cwd: "C:\\Windows\\System32".to_owned(),
|
||||
read_roots: Vec::new(),
|
||||
write_roots: Vec::new(),
|
||||
exact_read_roots: Vec::new(),
|
||||
exact_write_roots: Vec::new(),
|
||||
network: NetworkMode::Restricted,
|
||||
environment: BTreeMap::new(),
|
||||
timeout_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().collect()
|
||||
}
|
||||
|
||||
fn block_strings(block: &[u16]) -> Vec<String> {
|
||||
block
|
||||
.split(|value| *value == 0)
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| String::from_utf16(chunk).expect("utf16 environment entry"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_sorted_double_terminated_environment_block() {
|
||||
// Providing every substrate name keeps the block fully deterministic:
|
||||
// nothing is filled in from the test host's environment.
|
||||
let mut environment = BTreeMap::new();
|
||||
environment.insert("B".to_owned(), "2".to_owned());
|
||||
environment.insert("A".to_owned(), "1".to_owned());
|
||||
environment.insert("SystemRoot".to_owned(), "C:\\Windows".to_owned());
|
||||
environment.insert("SystemDrive".to_owned(), "C:".to_owned());
|
||||
environment.insert(
|
||||
"LOCALAPPDATA".to_owned(),
|
||||
"C:\\Users\\u\\AppData\\Local".to_owned(),
|
||||
);
|
||||
let mut expected = Vec::new();
|
||||
for entry in [
|
||||
"A=1",
|
||||
"B=2",
|
||||
"LOCALAPPDATA=C:\\Users\\u\\AppData\\Local",
|
||||
"SystemDrive=C:",
|
||||
"SystemRoot=C:\\Windows",
|
||||
] {
|
||||
expected.extend(wide(entry));
|
||||
expected.push(0);
|
||||
}
|
||||
expected.push(0);
|
||||
assert_eq!(environment_block(&environment), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_allowlist_fills_substrate_variables_but_stays_explicit() {
|
||||
// A null environment pointer would make CreateProcess inherit the
|
||||
// broker's ambient environment, bypassing the allowlist boundary.
|
||||
// AppContainer creation itself needs LOCALAPPDATA (else CreateProcessW
|
||||
// fails with os error 203), so those substrate paths are filled from
|
||||
// the broker; nothing else may leak in.
|
||||
let block = environment_block(&BTreeMap::new());
|
||||
let entries = block_strings(&block);
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|entry| entry.to_ascii_uppercase().starts_with("LOCALAPPDATA=")),
|
||||
"expected LOCALAPPDATA in {entries:?}"
|
||||
);
|
||||
for entry in &entries {
|
||||
let name = entry.split('=').next().unwrap_or_default();
|
||||
assert!(
|
||||
["SystemRoot", "SystemDrive", "LOCALAPPDATA"]
|
||||
.iter()
|
||||
.any(|substrate| substrate.eq_ignore_ascii_case(name)),
|
||||
"unexpected non-substrate entry {entry:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(&block[block.len() - 2..], &[0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_provided_substrate_values_win_case_insensitively() {
|
||||
let mut environment = BTreeMap::new();
|
||||
environment.insert("localappdata".to_owned(), "D:\\CustomLocal".to_owned());
|
||||
let entries = block_strings(&environment_block(&environment));
|
||||
assert!(entries.contains(&"localappdata=D:\\CustomLocal".to_owned()));
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| entry.to_ascii_uppercase().starts_with("LOCALAPPDATA="))
|
||||
.count(),
|
||||
1,
|
||||
"manifest value must not be duplicated by broker injection: {entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appcontainer_profile_identity_is_unique_and_bounded_per_request() {
|
||||
let first = appcontainer_profile_name("request-one");
|
||||
let second = appcontainer_profile_name("request-two");
|
||||
assert_ne!(first, second);
|
||||
let rendered = String::from_utf16(&first[..first.len() - 1]).expect("profile name");
|
||||
assert!(rendered.starts_with("maka.sandbox."));
|
||||
assert_eq!(rendered.len(), "maka.sandbox.".len() + 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_profile_name_is_fixed_and_self_reconciling() {
|
||||
// The readiness profile uses one stable name so a profile leaked by an
|
||||
// externally-killed probe is reclaimed by the next probe rather than
|
||||
// accumulating under a unique per-invocation name.
|
||||
let first = appcontainer_readiness_profile_name();
|
||||
let second = appcontainer_readiness_profile_name();
|
||||
assert_eq!(first, second);
|
||||
let rendered = String::from_utf16(&first[..first.len() - 1]).expect("readiness name");
|
||||
assert!(rendered.starts_with("maka.readiness."));
|
||||
assert_eq!(rendered.len(), "maka.readiness.".len() + 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_namespace_is_disjoint_from_production() {
|
||||
// Structural separation, not just a different hash input: the readiness
|
||||
// profile lives under `maka.readiness.` while every production profile
|
||||
// lives under `maka.sandbox.`, so the two lifecycles can never resolve to
|
||||
// the same registration even if `validate` were bypassed. Feeding the
|
||||
// reserved id through the production deriver must still land in the
|
||||
// production namespace, disjoint from the readiness one.
|
||||
let readiness = appcontainer_readiness_profile_name();
|
||||
let production_reserved = appcontainer_profile_name(RESERVED_READINESS_REQUEST_ID);
|
||||
assert_ne!(readiness, production_reserved);
|
||||
let readiness_str = String::from_utf16(&readiness[..readiness.len() - 1]).expect("name");
|
||||
let production_str =
|
||||
String::from_utf16(&production_reserved[..production_reserved.len() - 1])
|
||||
.expect("name");
|
||||
assert!(readiness_str.starts_with("maka.readiness."));
|
||||
assert!(production_str.starts_with("maka.sandbox."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_reserved_readiness_request_id() {
|
||||
// A production launch may never carry the readiness identity: otherwise
|
||||
// it would resolve to a profile the readiness probe deletes and recreates.
|
||||
let reserved = valid_launch_request(RESERVED_READINESS_REQUEST_ID);
|
||||
let error = reserved
|
||||
.validate()
|
||||
.expect_err("reserved id must be rejected");
|
||||
assert!(
|
||||
error.contains(RESERVED_READINESS_REQUEST_ID) && error.contains("reserved"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
// The same request with an ordinary id is otherwise valid, proving the id
|
||||
// is the sole reason for rejection.
|
||||
assert!(valid_launch_request("request-one").validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_lease_name_is_scoped_and_distinct() {
|
||||
// The readiness lease is a machine-wide (`Global\`) named mutex scoped by
|
||||
// the owning user's SID, and carries a distinct object name from the ACL
|
||||
// ledger mutex so the two never contend.
|
||||
let sid = "S-1-5-21-1111111111-2222222222-3333333333-1001";
|
||||
let name = readiness_mutex_name(sid);
|
||||
assert!(name.starts_with(r"Global\Maka.WindowsSandbox.ReadinessProfile."));
|
||||
assert!(name.ends_with(sid));
|
||||
assert!(!name.contains("AclLedger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_command_line_disables_autorun_before_exit() {
|
||||
// `/d` must precede `/c` so cmd.exe skips the machine-constant
|
||||
// `Command Processor\AutoRun` value. Without it, a host whose AutoRun
|
||||
// exits non-zero (or blocks) would make the readiness probe fail closed
|
||||
// on every startup, disabling the sandbox regardless of the boundary.
|
||||
let line = readiness_probe_command_line("C:\\Windows\\System32\\cmd.exe");
|
||||
assert_eq!(line, "\"C:\\Windows\\System32\\cmd.exe\" /d /c exit 0");
|
||||
let d = line.find("/d").expect("/d present");
|
||||
let c = line.find("/c").expect("/c present");
|
||||
assert!(d < c, "/d must come before /c: {line}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
param(
|
||||
[string]$LauncherPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = if ($LauncherPath) {
|
||||
$LauncherPath
|
||||
} else {
|
||||
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
# The production-identity readiness probe (RFC §6.4) stands up the real
|
||||
# AppContainer identity, token, and kill-on-close Job and launches a throwaway
|
||||
# confined child. Exit 0 alone is not evidence: the probe emits a machine-
|
||||
# readable attestation of what it verified, and this gate asserts those exact
|
||||
# fields so removing the exact-SID match, the specific-Job membership check,
|
||||
# the settlement drain, or the private-desktop placement turns this red rather
|
||||
# than leaving a hollow exit-0 gate green.
|
||||
function Assert-ReadinessAttestation {
|
||||
param([string]$Rendered, [string]$Phase)
|
||||
if ($Rendered -notmatch '"appContainerSidVerified":true' -or
|
||||
$Rendered -notmatch '"jobVerified":true' -or
|
||||
$Rendered -notmatch '"settled":true' -or
|
||||
$Rendered -notmatch '"appContainerSid":"S-1-15-2-' -or
|
||||
$Rendered -notmatch '"desktop":"maka-sandbox-desktop\.' -or
|
||||
$Rendered -notmatch '"desktopPrivatePlacement":true') {
|
||||
throw "Readiness probe ($Phase) did not attest the verified boundary: $Rendered"
|
||||
}
|
||||
}
|
||||
|
||||
$firstOutput = & $launcher --readiness-probe 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Readiness probe did not succeed: exit=$LASTEXITCODE output=$($firstOutput -join "`n")"
|
||||
}
|
||||
Assert-ReadinessAttestation -Rendered ($firstOutput -join "`n") -Phase 'first run'
|
||||
|
||||
# Availability is memoized per host, but the probe itself must be repeatable:
|
||||
# a second run proves the confined child and Job teardown leave no residue that
|
||||
# blocks the next probe.
|
||||
$secondOutput = & $launcher --readiness-probe 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Readiness probe was not repeatable: exit=$LASTEXITCODE output=$($secondOutput -join "`n")"
|
||||
}
|
||||
Assert-ReadinessAttestation -Rendered ($secondOutput -join "`n") -Phase 'second run'
|
||||
|
||||
# The probe takes no arguments; passing one must fail closed rather than silently
|
||||
# ignore unexpected input.
|
||||
$rejected = & $launcher --readiness-probe unexpected 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
throw "Readiness probe accepted an unexpected argument: $($rejected -join "`n")"
|
||||
}
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
Write-Host "Production-identity readiness probe verified: attested SID/Job/settlement/desktop, repeatable, argument-rejecting: $($secondOutput -join ' ')"
|
||||
@@ -0,0 +1,132 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
param(
|
||||
[string]$LauncherPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$launcher = if ($LauncherPath) {
|
||||
$LauncherPath
|
||||
} else {
|
||||
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
throw "Missing launcher binary: $launcher"
|
||||
}
|
||||
|
||||
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
|
||||
$workRoot = Join-Path $tempRoot "maka-stdio-relay-$PID"
|
||||
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
|
||||
|
||||
function Invoke-RelayLaunch {
|
||||
param(
|
||||
[string[]]$ChildArguments,
|
||||
[byte[]]$StdinBytes,
|
||||
[Nullable[int]]$TimeoutMs
|
||||
)
|
||||
$requestPath = Join-Path $workRoot "request-$([guid]::NewGuid().ToString('N')).json"
|
||||
$stdinPath = Join-Path $workRoot 'stdin.bin'
|
||||
$stdoutPath = Join-Path $workRoot 'stdout.bin'
|
||||
$stderrPath = Join-Path $workRoot 'stderr.txt'
|
||||
[IO.File]::WriteAllBytes($stdinPath, $StdinBytes)
|
||||
$request = [ordered]@{
|
||||
version = 1
|
||||
requestId = "stdio-relay-$PID"
|
||||
executable = $launcher
|
||||
arguments = $ChildArguments
|
||||
cwd = Split-Path -Parent $launcher
|
||||
readRoots = @()
|
||||
writeRoots = @()
|
||||
network = 'restricted'
|
||||
environment = @{}
|
||||
}
|
||||
if ($null -ne $TimeoutMs) { $request.timeoutMs = $TimeoutMs }
|
||||
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
|
||||
$process = Start-Process -FilePath $launcher `
|
||||
-ArgumentList @('--appcontainer', $requestPath) `
|
||||
-RedirectStandardInput $stdinPath `
|
||||
-RedirectStandardOutput $stdoutPath `
|
||||
-RedirectStandardError $stderrPath `
|
||||
-NoNewWindow -PassThru -Wait
|
||||
Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue
|
||||
[pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
Stdout = [IO.File]::ReadAllText($stdoutPath)
|
||||
Stderr = [IO.File]::ReadAllText($stderrPath)
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Probe {
|
||||
param($Result, [byte[]]$Payload, [string]$Case)
|
||||
if ($Result.ExitCode -ne 0) {
|
||||
throw "${Case}: relay launch failed: exit=$($Result.ExitCode) stderr=$($Result.Stderr)"
|
||||
}
|
||||
# stdout must be exactly the child's single JSON response: the relay owns
|
||||
# the response channel and nothing else may leak into it.
|
||||
$document = $null
|
||||
try {
|
||||
$document = $Result.Stdout.Trim() | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "${Case}: stdout is not a single JSON document: $($Result.Stdout)"
|
||||
}
|
||||
$sha = [BitConverter]::ToString(
|
||||
[Security.Cryptography.SHA256]::Create().ComputeHash($Payload)
|
||||
).Replace('-', '').ToLowerInvariant()
|
||||
if ($document.echoBytes -ne $Payload.Length -or $document.sha256 -ne $sha) {
|
||||
throw "${Case}: child did not receive the exact stdin payload: $($Result.Stdout)"
|
||||
}
|
||||
if ($Result.Stderr -notmatch '"appContainer":true' -or
|
||||
$Result.Stderr -notmatch 'stdio-probe: diagnostics stay on stderr') {
|
||||
throw "${Case}: diagnostics did not stay on stderr: $($Result.Stderr)"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# Small payload roundtrip.
|
||||
$small = [Text.Encoding]::UTF8.GetBytes('stdio-relay-small-payload')
|
||||
Assert-Probe -Result (Invoke-RelayLaunch -ChildArguments @('--stdio-probe') -StdinBytes $small) `
|
||||
-Payload $small -Case 'small'
|
||||
|
||||
# Empty stdin must still reach the child as immediate EOF.
|
||||
Assert-Probe -Result (Invoke-RelayLaunch -ChildArguments @('--stdio-probe') -StdinBytes @()) `
|
||||
-Payload @() -Case 'eof'
|
||||
|
||||
# Large payload (5 MiB, below the runtime's 8 MiB response cap).
|
||||
$large = [byte[]]::new(5MB)
|
||||
[Random]::new(20260815).NextBytes($large)
|
||||
Assert-Probe -Result (Invoke-RelayLaunch -ChildArguments @('--stdio-probe') -StdinBytes $large) `
|
||||
-Payload $large -Case 'large'
|
||||
|
||||
# Timeout: a child sleeping past timeoutMs must be killed and reported.
|
||||
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$timedOut = Invoke-RelayLaunch -ChildArguments @('--stdio-probe', '--sleep', '60') `
|
||||
-StdinBytes @() -TimeoutMs 2000
|
||||
$stopwatch.Stop()
|
||||
if ($timedOut.ExitCode -eq 0 -or $timedOut.Stderr -notmatch 'timeout') {
|
||||
throw "timeout: broker did not enforce timeoutMs: exit=$($timedOut.ExitCode) stderr=$($timedOut.Stderr)"
|
||||
}
|
||||
if ($stopwatch.Elapsed.TotalSeconds -gt 30) {
|
||||
throw "timeout: broker took $($stopwatch.Elapsed.TotalSeconds)s to enforce a 2s timeout"
|
||||
}
|
||||
|
||||
Write-Host 'Filesystem-worker stdio relay verified: roundtrip, EOF, large payload, stdout purity, timeout kill'
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Reference in new issue
Block a user