Files
lmxopcua/scripts/install/tests/Test-ScriptWorkerIsolation.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

199 lines
16 KiB
PowerShell

<#
.SYNOPSIS
Dry-run harness for Install-ScriptWorkerIsolation.ps1 (lmxopcua#780). Asserts the planned changes — ids, order,
targets, SDDL and ACEs — WITHOUT touching the machine, on any OS with PowerShell 7 (`pwsh`). The repo has no Pester;
this is a plain script that exits non-zero on the first failed group.
.EXAMPLE
pwsh -NoProfile -File scripts/install/tests/Test-ScriptWorkerIsolation.ps1
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$installDir = Split-Path -Parent $here
Import-Module (Join-Path $installDir 'ScriptWorkerIsolation.psm1') -Force
$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 Join-Targets($Steps) { (@($Steps | ForEach-Object { $_.Target }) -join '|') } # StrictMode-safe on an empty list
function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) {
try { & $Block; Assert $false "$Message (did not throw)" }
catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" }
}
$hostSid = 'S-1-5-21-1111111111-2222222222-3333333333-1001'
$workerSid = 'S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135' # NT SERVICE\OtOpcUaScriptWorker
$root = 'C:\Program Files\OtOpcUa'
$cache = 'C:\ProgramData\OtOpcUa\script-pe-cache'
Write-Host 'Service SID algorithm'
Assert ((Get-OtOpcUaServiceSid -Name 'TrustedInstaller') -eq 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') `
'NT SERVICE\TrustedInstaller matches the well-known SID'
Assert ((Get-OtOpcUaServiceSid -Name 'OtOpcUaScriptWorker') -eq $workerSid) 'OtOpcUaScriptWorker SID (pinned; ScriptWorkerIdentityTests pins the same value)'
Assert ((Get-OtOpcUaServiceSid -Name 'otopcuascriptworker') -eq $workerSid) 'the name is case-insensitive'
Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a space is refused'
Write-Host 'Install plan'
$plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService)
$ids = $plan.Id -join ','
Assert ($ids -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
$byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s }
$anchor = $byId['AnchorService'].Data
Assert ($anchor.Identity -eq 'NT SERVICE\OtOpcUaScriptWorker') 'anchor service runs as its own virtual account'
Assert ($anchor.BinPath -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "anchor binPath ($($anchor.BinPath))"
Assert ($byId['AnchorService'].Change -like '*disabled*never started*') 'anchor is described as disabled and never started'
$sddl = $byId['PeCacheDir'].Data.Sddl
Assert ($sddl -eq "O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)(A;OICI;0x1f01ff;;;$hostSid)") "PeCacheDir SDDL ($sddl)"
Assert ($sddl.StartsWith('O:BAG:SYD:PAI')) 'PeCacheDir DACL is protected (no inherited Users/Everyone)'
foreach ($forbidden in 'BU', 'WD', 'AU', 'S-1-5-32-545', 'S-1-1-0', 'S-1-5-11', $workerSid) {
Assert (-not $sddl.Contains(";;;$forbidden)")) "PeCacheDir root grants nothing to $forbidden"
}
$sysSddl = Get-OtOpcUaPeCacheSddl -HostSid 'S-1-5-18'
Assert ($sysSddl -eq 'O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)') "a LocalSystem host gets no duplicate ACE ($sysSddl)"
$pe = $byId['PeEntriesDir'].Data
Assert ($pe.Path -eq "$cache\pe" -or $pe.Path -eq "$cache/pe") "entries dir is <PeCacheDir>\pe ($($pe.Path))"
Assert ($pe.Ace -eq "(A;OICI;0x1301bf;;;$workerSid)") "worker gets Modify on pe\ ($($pe.Ace))"
Assert ($pe.Type -eq 'Allow' -and $pe.Mask -eq 0x1301BF -and $pe.Sid -eq $workerSid) 'pe\ ACE data matches its SDDL'
$wf = $byId['WorkerFolderAcl'].Data
Assert ($wf.Ace -eq "(A;OICI;0x1200a9;;;$workerSid)") "worker gets read+execute on script-worker\ ($($wf.Ace))"
Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))"
$deny = $byId['InstallRootDeny'].Data
Assert ($deny.Ace -eq "(D;OICI;0xd0157;;;$workerSid)") "install root DENIES the worker read and write, inherited ($($deny.Ace))"
Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root -and $deny.Mask -eq 0xD0157) 'install-root ACE is a deny on the root'
# lmxopcua#890: the bits that matter, and the ones that must stay off so the worker can still reach script-worker\.
foreach ($bit in @{ ReadData = 0x1; WriteData = 0x2; AppendData = 0x4; WriteAttributes = 0x100; Delete = 0x10000; DeleteChild = 0x40; WriteDac = 0x40000 }.GetEnumerator()) {
Assert (($deny.Mask -band $bit.Value) -ne 0) "the deny includes $($bit.Key)"
}
foreach ($bit in @{ TraverseExecute = 0x20; ReadAttributes = 0x80; ReadPermissions = 0x20000; Synchronize = 0x100000 }.GetEnumerator()) {
Assert (($deny.Mask -band $bit.Value) -eq 0) "the deny leaves $($bit.Key) alone (the loader walks through denied folders)"
}
$rec = $byId['DenyPathRecord'].Data
Assert ($rec.Key -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters' -and $rec.Name -eq 'DenyPaths' -and @($rec.Paths).Count -eq 0) `
"without -DenyPaths the record is empty, under the anchor service's key ($($rec.Key))"
$fw = $byId['FirewallBlockOutbound'].Data
Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))"
Assert ($byId['FirewallBlockOutbound'].Change -like '*blocking ALL outbound*') 'firewall rule blocks outbound'
$envSet = $byId['HostServiceEnv'].Data.Set
Assert ($envSet['Scripting__Worker__Identity'] -eq 'NT SERVICE\OtOpcUaScriptWorker') 'env: Scripting__Worker__Identity'
Assert ($envSet['Scripting__Worker__PeCacheDir'] -eq $cache) 'env: Scripting__Worker__PeCacheDir'
Assert (-not ($envSet.Keys -contains 'Scripting__Runner')) 'the runner flip is NOT part of the installer'
$noEnv = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache)
Assert (-not ($noEnv.Id -contains 'HostServiceEnv')) 'without -ConfigureHostService the host service is untouched'
Write-Host 'Path rules (positive and negative controls)'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir "$root\cache" } '*inside InstallRoot*' 'a cache inside the install root is refused'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused'
$sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache')
Assert ($sibling.Count -eq 7) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
Write-Host 'Sensitive siblings: -DenyPaths (lmxopcua#890, the wonder layout)'
$qaRoot = 'E:\ApiInstall\OtOpcUa'
$qaApp = "$qaRoot\app"
$qaDeny = @($qaRoot, "$qaRoot\pki", "$qaRoot\data")
$qa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -ConfigureHostService)
$qaIds = $qa.Id -join ','
Assert ($qaIds -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') `
"QA plan order ($qaIds)"
$sens = @($qa | Where-Object Id -eq 'SensitiveDirDeny')
Assert ((Join-Targets $sens) -eq ($qaDeny -join '|')) "one deny per folder, in the given order ($(Join-Targets $sens))"
foreach ($s in $sens) {
Assert ($s.Data.Ace -eq "(D;OICI;0xd0157;;;$workerSid)" -and $s.Data.Type -eq 'Deny' -and $s.Data.Sid -eq $workerSid -and $s.Data.Mask -eq 0xD0157) `
"$($s.Target): the worker identity is denied read and write, inherited ($($s.Data.Ace))"
}
Assert ((@(($qa | Where-Object Id -eq 'DenyPathRecord').Data.Paths) -join '|') -eq ($qaDeny -join '|')) 'the record lists exactly the denied folders'
# The host account is never denied anything: every deny ACE names the worker identity alone.
$allDeny = @($qa | Where-Object { $_.Data.ContainsKey('Type') -and $_.Data.Type -eq 'Deny' })
Assert ($allDeny.Count -eq 4 -and @($allDeny | Where-Object { $_.Data.Sid -ne $workerSid }).Count -eq 0) "every deny ACE is the worker identity's ($($allDeny.Count) denies)"
$asUser = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid $hostSid -PeCacheDir $cache -DenyPaths $qaDeny)
Assert (@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like "(D;*;;;$hostSid)" }).Count -eq 0 -and
@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like '(D;*' }).Count -eq 4) 'a domain host account is never the subject of a deny'
# script-worker\ keeps its allow, and the PE cache is untouched by the denies.
Assert (($qa | Where-Object Id -eq 'WorkerFolderAcl').Data.Path -eq "$qaApp\script-worker") 'script-worker\ allow still planned under app\'
Assert (@($sens | Where-Object { $_.Target -like "$cache*" }).Count -eq 0) 'no deny on the PE cache'
$norm = @(Resolve-OtOpcUaDenyPaths -Paths @("$qaRoot\pki\,$qaRoot\PKI;$qaRoot\data", '', $qaApp, "$qaRoot\") -InstallRoot $qaApp -PeCacheDir $cache)
Assert (($norm -join '|') -eq "$qaRoot\pki|$qaRoot\data|$qaRoot") "comma/semicolon split, trailing '\' trimmed, case-insensitive de-dup, InstallRoot dropped ($($norm -join '|'))"
$viaString = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ','))
Assert (@($viaString | Where-Object Id -eq 'SensitiveDirDeny').Count -eq 3) 'a single comma-separated string (powershell -File) gives the same three denies'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'E:\' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a drive root is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths '\\srv\share' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a share root is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\script-worker" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'script-worker\ itself is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\SCRIPT-WORKER\runtimes" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'a folder inside script-worker\ is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$cache\pe" -InstallRoot $qaApp -PeCacheDir $cache } '*PeCacheDir*' 'the PE cache (pe\) is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'pki' -InstallRoot $qaApp -PeCacheDir $cache } '*absolute*' 'a relative path is refused'
$okAncestor = @(Resolve-OtOpcUaDenyPaths -Paths @('E:\ApiInstall', "$qaApp-old\script-worker") -InstallRoot $qaApp -PeCacheDir $cache)
Assert ($okAncestor.Count -eq 2) 'an ancestor of script-worker\ and a prefix-sharing sibling are accepted (positive control)'
Write-Host 'Re-run with a shorter list: the dropped folder loses its deny (lmxopcua#890)'
$rerun = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths @($qaRoot, "$qaRoot\pki") `
-RecordedDenyPaths @($qaRoot, "$qaRoot\pki", "$qaRoot\data\"))
$staleSteps = @($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and $_.Data.ContainsKey('Remove') })
Assert ((Join-Targets $staleSteps) -eq "$qaRoot\data") "only the dropped folder is un-denied ($(Join-Targets $staleSteps))"
Assert ([array]::IndexOf(@($rerun.Id), 'SensitiveDirDeny') -lt [array]::IndexOf(@($rerun.Id), 'DenyPathRecord')) 'the stale removal runs before the record is rewritten'
Assert (@($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and -not $_.Data.ContainsKey('Remove') }).Count -eq 2) 'the two kept folders keep their deny'
$same = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -RecordedDenyPaths $qaDeny)
Assert (@($same | Where-Object { $_.Data.ContainsKey('Remove') }).Count -eq 0) 'an unchanged list plans no removal (idempotent re-run)'
Write-Host 'Uninstall plan'
$un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall)
$unIds = $un.Id -join ','
Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') "rollback order ($unIds)"
Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes'
Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) `
'rollback reverses every install step but the cache (kept by default)'
Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scripting__Worker__Identity') 'rollback removes the identity env entry'
foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) {
Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs"
}
# lmxopcua#890: -Uninstall removes the deny from every recorded folder, plus any passed again, once each.
$unQa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -Uninstall `
-DenyPaths @("$qaRoot\PKI") -RecordedDenyPaths $qaDeny)
$unSens = @($unQa | Where-Object Id -eq 'SensitiveDirDeny')
Assert ($unSens.Count -eq 3 -and @($unSens | Where-Object { -not $_.Data.ContainsKey('Remove') -or $_.Data.Sid -ne $workerSid }).Count -eq 0) `
"rollback un-denies each recorded folder once, worker ACEs only ($(Join-Targets $unSens))"
Assert ((@(@($unSens | ForEach-Object { $_.Target }) | Sort-Object) -join '|') -eq (@($qaDeny | Sort-Object) -join '|')) 'rollback covers exactly the recorded folders'
Assert ((@($unQa.Id) -join ',') -eq 'FirewallBlockOutbound,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') `
"QA rollback order ($(@($unQa.Id) -join ','))"
Assert (($unQa | Where-Object Id -eq 'DenyPathRecord').Data.ContainsKey('Remove')) 'rollback deletes the record'
$unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache)
Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it'
Write-Host 'Entry script -PlanOnly (end to end, no machine access)'
$entry = Join-Path $installDir 'Install-ScriptWorkerIsolation.ps1'
$viaScript = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PeCacheDir $cache -ConfigureHostService -PlanOnly 6>$null)
Assert (($viaScript.Id -join ',') -eq $ids) "-PlanOnly returns the module's plan ($($viaScript.Id -join ','))"
Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount 'OTOPCUA\svc' -PeCacheDir $cache -PlanOnly 6>$null } '*needs -HostServiceAccount as a SID*' `
'-PlanOnly refuses an account name (no lookup without the OS)'
$defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null)
Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa'
$qaEntry = @(& $entry -InstallRoot $qaApp -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',') -ConfigureHostService -PlanOnly 6>$null)
Assert ((@($qaEntry.Id) -join ',') -eq $qaIds) "-PlanOnly -DenyPaths 'a,b,c' (one string, as powershell -File passes it) plans the same steps"
if ($IsWindows) {
Write-Host 'SDDL parses on Windows'
$ds = [System.Security.AccessControl.DirectorySecurity]::new()
$ds.SetSecurityDescriptorSddlForm($sddl)
Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected'
foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace, $sens[0].Data.Ace) {
$one = [System.Security.AccessControl.DirectorySecurity]::new()
$one.SetSecurityDescriptorSddlForm("D:$ace")
Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses"
}
}
Write-Host ''
Write-Host "$($script:passes) passed, $($script:failures) failed"
if ($script:failures -gt 0) { exit 1 }