Files
lmxopcua/scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1
T
Joseph Doherty f75ee7d089
v2-ci / unit-tests (push) Waiting to run
fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
The installer denied NT SERVICE\OtOpcUaScriptWorker only on -InstallRoot. On the wonder layout the node's keys and
secrets sit beside it (E:\ApiInstall\OtOpcUa\pki with the application certificate's private key, data\ with
secrets.db and LocalDb), inheriting BUILTIN\Users:RX. Users is a restricting SID of the worker token, so a worker
could read them.

- Install-ScriptWorkerIsolation.ps1 -DenyPaths <dir>,...: each folder gets the same inherited deny as InstallRoot
  (new SensitiveDirDeny steps). Refuses drive/share roots, script-worker\ and the PE cache; folders must exist;
  a comma/semicolon string works for powershell -File.
- The deny is read AND write (0xD0157: ReadData, WriteData, AppendData, WriteEA, DeleteChild, WriteAttributes,
  Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay so the
  loader still reaches script-worker\. The write half stops a worker planting a cert in pki\trusted\certs where
  a data drive lets Users create files in subfolders.
- The list is recorded under the anchor service's key (Parameters\DenyPaths, step DenyPathRecord): a re-run
  removes the deny from folders dropped from the list, -Uninstall removes every recorded one without repeating
  -DenyPaths. -WhatIf/-PlanOnly print every step.
- After an apply, WARN for each protected-DACL item under a denied folder that the inherited deny cannot reach;
  without -DenyPaths, WARN for pki/data/logs/backup/config/secrets siblings of InstallRoot.

Tests:
- Dry-run harness 46 -> 86 assertions (QA layout plan, mask bits, refusals, stale removal, record-driven
  rollback, host account never denied). Mutations (no sibling deny, deny->allow, mask back to ReadData, no
  stale removal, rollback ignoring the record, script-worker not refused) each turn it red.
- Test-ScriptWorkerIsolation.Windows.ps1 (Windows PowerShell 5.1, windev): 34/34, incl. -DenyPaths apply,
  11/11 unchanged re-apply, shorter-list re-apply, uninstall from the record, second uninstall unchanged.
- New InstallerLayoutIsolationTests (OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated Windows): a scratch wonder
  layout under the real restricted worker token. Before install and after uninstall the token reads pki/data/
  logs/backup/appsettings and plants a trust-list file; installed it can do neither, still reads script-worker\,
  a real worker launched from the layout runs a script, and the host still reads everything. Mutating the
  -DenyPaths denies to a no-op mask turns it red on pki\own\private\otopcua.pfx (reproduces #890).

QA vq03/vq04 are NOT re-applied; the command is in docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 17:31:28 -05:00

154 lines
12 KiB
PowerShell

<#
.SYNOPSIS
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890): apply -> re-apply -> verify -> shorter
-DenyPaths -> uninstall (from the record, without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1.
Run it once under EACH host:
pwsh -NoProfile -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
.DESCRIPTION
Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM'.
It does NOT touch OtOpcUaHost. The firewall rule name is fixed by the installer, so the test refuses to run (exit 2) when
that rule already exists, i.e. on a node that has the real isolation installed. Everything it creates is removed in
`finally`, including on failure.
#>
[CmdletBinding()]
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost')
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Write-Host 'SKIP: Windows only'; exit 0 }
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host 'run elevated'; exit 2 }
$installer = Join-Path (Split-Path -Parent $PSScriptRoot) 'Install-ScriptWorkerIsolation.ps1'
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'ScriptWorkerIsolation.psm1') -Force
$ruleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-Host "refusing: firewall rule $ruleName already exists (real isolation installed on this node)"; exit 2 }
foreach ($n in $WorkerServiceName, $HostServiceName) {
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { Write-Host "refusing: service $n already exists"; exit 2 }
}
$script:failures = 0; $script:passes = 0
function Assert([bool]$Condition, [string]$Message) {
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
}
function Invoke-Installer([string[]]$Extra) {
# $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits.
# Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'.
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount 'NT AUTHORITY\SYSTEM' `
-PeCacheDir $cache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService @Extra 2>&1 | Out-String
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } })
[pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps }
}
function Get-Explicit([string]$Path, [string]$Sid) {
$id = [System.Security.Principal.SecurityIdentifier]::new($Sid)
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $id })
}
$scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$root = Join-Path $scratch 'install'
$cache = Join-Path $scratch 'pe-cache'
$deny = @($scratch, (Join-Path $scratch 'pki'), (Join-Path $scratch 'data')) # lmxopcua#890: the deployment folder + keys + secrets
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
$workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
$hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName"
try {
New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null
Set-Content -LiteralPath $workerExe -Value 'stub'
foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
& sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" }
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch"
Write-Host 'Dry run'
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and
-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing'
Write-Host 'Apply'
$a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
Assert ($a.Steps.Count -eq 11 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 11 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled'
Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid'
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)'
$pe = @(Get-Explicit (Join-Path $cache 'pe') $workerSid)
Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on <PeCacheDir>\pe'
Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root'
Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected'
$rootDeny = @(Get-Explicit $root $workerSid)
Assert ($rootDeny.Count -eq 1 -and $rootDeny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\'
foreach ($d in $deny) {
$dd = @(Get-Explicit $d $workerSid)
Assert ($dd.Count -eq 1 -and $dd[0].AccessControlType -eq 'Deny' -and $dd[0].InheritanceFlags -eq 'ContainerInherit, ObjectInherit') "worker has one inherited Deny ACE on $d"
}
Assert (((Get-ItemProperty -Path $recordKey -Name DenyPaths).DenyPaths -join '|') -eq ($deny -join '|')) 'the denied folders are recorded under the anchor key'
$hostDeny = @((Get-Acl -LiteralPath $scratch).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference.Value -ne $workerSid })
Assert ($hostDeny.Count -eq 0) 'no deny for anyone but the worker identity (the host account is unaffected)'
$fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe'
$env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
Assert (($env1 -contains 'Keep__Me=1') -and (@($env1 | Where-Object { $_ -like 'Scripting__Worker__Identity=*' }).Count -eq 1) -and
(@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others'
Write-Host 'Re-apply (idempotent)'
$b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
Assert ($b.Steps.Count -eq 11 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 11 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay'
$c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ','))
Assert ($c.Exit -eq 0) "shorter re-apply exits 0 ($($c.Output))"
$changedIds = @($c.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ','
Assert ($changedIds -eq 'SensitiveDirDeny,DenyPathRecord') "only the stale deny and the record change ($changedIds)"
Assert (@(Get-Explicit $deny[2] $workerSid).Count -eq 0 -and @(Get-Explicit $deny[1] $workerSid).Count -eq 1) 'data\ un-denied, pki\ still denied'
$c2 = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only'
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
$u1 = Invoke-Installer @('-Uninstall')
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
Assert ($u1.Steps.Count -eq 10 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 10 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed'
Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and
@(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0 -and
@($deny | Where-Object { @(Get-Explicit $_ $workerSid).Count -ne 0 }).Count -eq 0) 'no explicit worker ACEs remain, on the denied folders either'
Assert (-not (Test-Path $recordKey)) 'the record is gone'
Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache'
$env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))"
Start-Sleep -Milliseconds 500
$u2 = Invoke-Installer @('-Uninstall')
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
}
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
finally {
# Best-effort cleanup that does not depend on the code under test.
Remove-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
foreach ($n in $WorkerServiceName, $HostServiceName) {
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { & sc.exe delete $n | Out-Null }
}
if (Test-Path -LiteralPath $scratch) {
& icacls $scratch /reset /T /C /Q 2>&1 | Out-Null
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
}
}
Write-Host ''
Write-Host "$($script:passes) passed, $($script:failures) failed"
if ($script:failures -gt 0) { exit 1 }