fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
v2-ci / unit-tests (push) Waiting to run

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
This commit is contained in:
Joseph Doherty
2026-11-12 17:31:28 -05:00
parent c80d187c18
commit f75ee7d089
8 changed files with 597 additions and 34 deletions
@@ -1,6 +1,8 @@
<#
.SYNOPSIS
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883): apply -> re-apply -> verify -> uninstall -> uninstall.
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:
@@ -38,6 +40,7 @@ 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' `
@@ -53,27 +56,30 @@ function Get-Explicit([string]$Path, [string]$Sid) {
$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')
$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 @()
$a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
Assert ($a.Steps.Count -eq 7 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 7 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
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)'
@@ -81,9 +87,17 @@ try {
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'
$deny = @(Get-Explicit $root $workerSid)
Assert ($deny.Count -eq 1 -and $deny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
$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)
@@ -91,24 +105,35 @@ try {
(@($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 @()
$b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
Assert ($b.Steps.Count -eq 7 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 7 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
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 'Uninstall, twice'
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 6 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 6 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
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) 'no explicit worker ACEs remain'
@(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 6 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 6 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
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 {