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
+136 -9
View File
@@ -18,6 +18,20 @@ $script:FullControl = 0x1F01FF
$script:Modify = 0x1301BF # Modify | Synchronize
$script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize
$script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders)
# lmxopcua#890: the deny the worker identity gets on the install folder and on every -DenyPaths folder. Read AND write:
# ReadData/ListDirectory 0x1, WriteData/AddFile 0x2, AppendData/AddSubdirectory 0x4, WriteExtendedAttributes 0x10,
# DeleteSubdirectoriesAndFiles 0x40, WriteAttributes 0x100, Delete 0x10000, ChangePermissions 0x40000,
# TakeOwnership 0x80000.
# Not denied, so the loader can still walk to script-worker\ and the worker can still run: Traverse/Execute 0x20,
# ReadAttributes 0x80, ReadPermissions 0x20000, Synchronize 0x100000 (and ReadExtendedAttributes 0x8, which leaks nothing).
# The write half matters because a data drive commonly grants BUILTIN\Users (one of the worker's restricting SIDs)
# CreateFiles/AppendData on its subfolders: without it a worker could drop a certificate into pki\trusted\certs.
$script:DenyMask = 0x0D0157
# lmxopcua#890: where the installer records the -DenyPaths it applied, so a later re-run removes the ones dropped from
# the list and -Uninstall removes exactly what was added without the operator repeating them. The value lives under the
# anchor service's key and goes with it.
$script:DenyRecordValue = 'DenyPaths'
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
@@ -77,12 +91,51 @@ function Get-OtOpcUaScriptWorkerFolderAce {
function Get-OtOpcUaInstallRootDenyAce {
<#
.SYNOPSIS
InstallRoot\ : DENY the worker identity ReadData/ListDirectory, inherited — so appsettings*.json, the logs and
the host binaries are unreadable to the worker even where BUILTIN\Users (one of its restricting SIDs) can read
them (Program Files grants Users read). script-worker\'s explicit allow is the nearer ACE and wins below it.
InstallRoot\ and every -DenyPaths folder: DENY the worker identity read and write ($script:DenyMask), inherited —
so appsettings*.json, the logs, the host binaries, pki\ (private keys, trust lists) and data\ (secrets.db,
LocalDb) are unreadable and unwritable to the worker even where BUILTIN\Users (one of its restricting SIDs) can
read or create files (lmxopcua#890). script-worker\'s explicit allow is the nearer ACE and wins below it.
#>
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:ReadData -Sid $WorkerSid
Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:DenyMask -Sid $WorkerSid
}
function Test-OtOpcUaPathWithin {
# True when $Path equals $Ancestor or is below it (Windows paths, case-insensitive, no OS call).
param([string]$Path, [string]$Ancestor)
$p = $Path.TrimEnd('\'); $a = $Ancestor.TrimEnd('\')
$p -ieq $a -or $p.StartsWith($a + '\', [StringComparison]::OrdinalIgnoreCase)
}
function Resolve-OtOpcUaDenyPaths {
<#
.SYNOPSIS
Normalises -DenyPaths (lmxopcua#890): splits 'a,b' / 'a;b' (powershell -File passes an array as one string),
trims trailing '\', drops blanks, de-duplicates case-insensitively, drops InstallRoot itself (InstallRootDeny
covers it) and refuses what would break the worker or deny far too much.
#>
param([string[]]$Paths, [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$PeCacheDir)
$root = $InstallRoot.TrimEnd('\')
$workerFolder = "$root\script-worker"
$cache = $PeCacheDir.TrimEnd('\')
$out = [System.Collections.Generic.List[string]]::new()
foreach ($raw in @($Paths | Where-Object { $_ } | ForEach-Object { $_ -split '[,;]' })) {
$p = $raw.Trim().TrimEnd('\')
if (-not $p) { continue }
if ($p -match '^[A-Za-z]:$' -or $p -match '^\\\\[^\\]+(\\[^\\]+)?$') {
throw "DenyPaths entry '$raw' is a drive or share root; deny the deployment folder instead"
}
if ($p -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "DenyPaths entry '$raw' must be an absolute Windows path" }
if (Test-OtOpcUaPathWithin -Path $p -Ancestor $workerFolder) {
throw "DenyPaths entry '$raw' is (inside) $workerFolder, the worker's own binaries"
}
if (Test-OtOpcUaPathWithin -Path $p -Ancestor $cache) {
throw "DenyPaths entry '$raw' is (inside) PeCacheDir '$PeCacheDir'; the worker writes its compiled entries there"
}
if ($p -ieq $root) { continue }
if (-not ($out | Where-Object { $_ -ieq $p })) { $out.Add($p) }
}
$out.ToArray()
}
function New-OtOpcUaPlanStep {
@@ -103,6 +156,12 @@ function Get-ScriptWorkerIsolationPlan {
[Parameter(Mandatory)] [string]$PeCacheDir,
[string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost',
# lmxopcua#890: more folders to deny the worker identity (the deployment folder around InstallRoot, pki\, data\,
# logs\, a Secrets or LocalDb folder elsewhere). Each gets the same inherited deny as InstallRoot.
[string[]]$DenyPaths = @(),
# The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under
# -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all.
[string[]]$RecordedDenyPaths = @(),
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache
@@ -121,12 +180,26 @@ function Get-ScriptWorkerIsolationPlan {
$workerFolder = "$root\script-worker"
$workerExe = "$workerFolder\$($script:WorkerExeName)"
$entries = "$cache\pe"
$deny = @(Resolve-OtOpcUaDenyPaths -Paths $DenyPaths -InstallRoot $root -PeCacheDir $cache)
# Recorded paths are not re-validated (they were when recorded); only normalised, so a removal always matches.
$recorded = @($RecordedDenyPaths | Where-Object { $_ } | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ -and $_ -ine $root } |
Sort-Object -Unique)
$denyAce = Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
$steps = [System.Collections.Generic.List[object]]::new()
if (-not $Uninstall) {
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
"register the disabled anchor service '$WorkerServiceName' so the virtual account '$identity' ($workerSid) resolves by name (never started)" `
@{ Name = $WorkerServiceName; Identity = $identity; BinPath = $workerExe }))
foreach ($stale in @($recorded | Where-Object { $p = $_; -not ($deny | Where-Object { $_ -ieq $p }) })) {
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $stale `
"remove every explicit ACE for the worker identity from '$stale' (recorded by an earlier run, no longer in -DenyPaths)" `
@{ Path = $stale; Sid = $workerSid; Remove = $true }))
}
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
"record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" `
@{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny }))
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
"create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" `
@{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) }))
@@ -137,8 +210,13 @@ function Get-ScriptWorkerIsolationPlan {
"grant the worker identity read+execute on its own binaries" `
@{ Path = $workerFolder; Ace = (Get-OtOpcUaScriptWorkerFolderAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadAndExecute; Type = 'Allow' }))
$steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
"deny the worker identity ReadData/ListDirectory on the install folder (appsettings, logs, host binaries); script-worker\ stays readable" `
@{ Path = $root; Ace = (Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadData; Type = 'Deny' }))
"deny the worker identity read and write on the install folder (appsettings, logs, host binaries), inherited; script-worker\ stays readable" `
@{ Path = $root; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' }))
foreach ($p in $deny) {
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p `
"deny the worker identity read and write on '$p' (certificates, secrets, stores, logs, backups), inherited" `
@{ Path = $p; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' }))
}
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"add a Windows Firewall rule blocking ALL outbound traffic from '$workerExe' (every profile)" `
@{ Name = $script:FirewallRuleName; Program = $workerExe }))
@@ -156,6 +234,10 @@ function Get-ScriptWorkerIsolationPlan {
}
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true }))
foreach ($p in @(@($deny) + @($recorded) | Sort-Object -Unique)) {
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p `
"remove every explicit ACE for the worker identity from '$p'" @{ Path = $p; Sid = $workerSid; Remove = $true }))
}
$steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
"remove every explicit ACE for the worker identity from the install folder" @{ Path = $root; Sid = $workerSid; Remove = $true }))
$steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
@@ -170,6 +252,8 @@ function Get-ScriptWorkerIsolationPlan {
"remove every explicit ACE for the worker identity from the PE entries directory (the cache itself is kept; -RemovePeCache deletes it)" `
@{ Path = $entries; Sid = $workerSid; Remove = $true }))
}
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
"delete the record of denied folders" @{ Key = $recordKey; Name = $script:DenyRecordValue; Remove = $true }))
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
"delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true }))
}
@@ -288,9 +372,22 @@ function Invoke-ScriptWorkerIsolationStep {
}
return (Set-OtOpcUaStepAce -Data $d)
}
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny' } {
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } {
return (Set-OtOpcUaStepAce -Data $d)
}
'DenyPathRecord' {
$current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name)
if ($d.ContainsKey('Remove')) {
if (-not (Test-Path -LiteralPath $d.Key)) { return 'unchanged' }
Remove-Item -LiteralPath $d.Key -Recurse -Force
return 'changed'
}
if ((@($current) -join "`n") -ceq (@($d.Paths) -join "`n")) { return 'unchanged' }
if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null }
if (@($d.Paths).Count -eq 0) { Remove-ItemProperty -LiteralPath $d.Key -Name $d.Name -ErrorAction SilentlyContinue }
else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) }
return 'changed'
}
'FirewallBlockOutbound' {
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) {
@@ -327,6 +424,36 @@ function Invoke-ScriptWorkerIsolationStep {
}
}
function Get-OtOpcUaRecordedDenyPaths {
# The -DenyPaths an earlier run recorded under the anchor service's key (lmxopcua#890); empty when there is none.
param([string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$Key, [string]$Name = $script:DenyRecordValue)
if (-not $Key) { $Key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" }
if (-not (Test-Path -LiteralPath $Key)) { return }
$v = (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue)
if ($v) { @($v.$Name | Where-Object { $_ }) }
}
function Find-OtOpcUaDenyCoverageGap {
<#
.SYNOPSIS
After the denies are applied (lmxopcua#890): the files and folders under $Path that the inherited deny does NOT
reach because their DACL is protected (inheritance switched off, e.g. a store that sets its own ACL on a private
key) and that carry no explicit deny of their own. Each one is reported as a WARN; the operator decides.
$Skip (script-worker\) is not walked.
#>
param([Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$WorkerSid, [string[]]$Skip = @())
if (-not (Test-Path -LiteralPath $Path)) { return }
$identity = [System.Security.Principal.SecurityIdentifier]::new($WorkerSid)
foreach ($item in @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue)) {
if (@($Skip | Where-Object { $_ -and (Test-OtOpcUaPathWithin -Path $item.FullName -Ancestor $_) }).Count) { continue }
try { $acl = Get-Acl -LiteralPath $item.FullName } catch { continue }
if (-not $acl.AreAccessRulesProtected) { continue }
$explicitDeny = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.IdentityReference -eq $identity -and $_.AccessControlType -eq 'Deny' })
if ($explicitDeny.Count -eq 0) { $item.FullName }
}
}
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Get-ScriptWorkerIsolationPlan,
Invoke-ScriptWorkerIsolationStep
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan,
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap