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
@@ -2,7 +2,7 @@
.SYNOPSIS
Isolates the OtOpcUa ScriptWorker processes on a Windows node (lmxopcua#780, design §6, owner decisions §14 #5/#6):
the worker's virtual-account identity, the PeCacheDir ACL, an outbound firewall block on the worker executable and
a deny on the install folder. Idempotent; -WhatIf prints every change; -Uninstall rolls it back.
a deny on the install folder and on every other sensitive folder (-DenyPaths: pki\, data\, logs\, ...). Idempotent; -WhatIf prints every change; -Uninstall rolls it back.
.DESCRIPTION
Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order:
@@ -17,8 +17,21 @@
account full control. No Users, no worker access (the HMAC key and launch markers live here).
3. PeEntriesDir <PeCacheDir>\pe, the worker identity gets Modify (workers compile and write HMAC'd entries).
4. WorkerFolderAcl <InstallRoot>\script-worker, the worker identity gets read + execute.
5. InstallRootDeny <InstallRoot>, the worker identity is DENIED ReadData/ListDirectory (inherited), so the
worker cannot read appsettings*.json, the logs or the host binaries even where Users can.
5. InstallRootDeny <InstallRoot>, the worker identity is DENIED read and write (inherited): ReadData/List,
WriteData/AddFile, AppendData/AddSubdirectory, WriteAttributes/EA, Delete, DeleteChild,
ChangePermissions, TakeOwnership. Traverse/Execute, ReadAttributes and ReadPermissions stay,
so the loader still reaches script-worker\. The worker cannot read appsettings*.json, the logs
or the host binaries even where Users can.
SensitiveDirDeny (one per -DenyPaths folder, lmxopcua#890) the same inherited deny. The worker token's
restricting SIDs include Everyone and BUILTIN\Users, so ANY folder Users can read is readable
to the worker unless it is denied here. Name the deployment folder around InstallRoot (which
covers pki\, data\, logs\, backups and old app.bak.* swaps in one ACE) AND each folder that
holds keys or secrets explicitly (pki\, data\, the Secrets/LocalDb folder when it lives
elsewhere), so the deny also holds on a folder whose DACL does not inherit.
DenyPathRecord the -DenyPaths list, recorded under the anchor service's key
(HKLM\...\Services\<WorkerServiceName>\Parameters, value DenyPaths). A re-run removes the
deny from folders dropped from the list; -Uninstall removes it from every recorded folder
without the operator repeating -DenyPaths.
6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from
<InstallRoot>\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The
self-contained Windows publish starts the worker through that apphost; a framework-dependent
@@ -30,12 +43,22 @@
Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into
an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect.
After an apply it walks every denied folder and WARNs about any file or folder whose DACL is protected (does not
inherit) and has no deny of its own; the inherited deny does not reach those. Without -DenyPaths it WARNs when the
folder around InstallRoot has a pki, data, logs, backup, config or secrets sibling.
.PARAMETER InstallRoot
The host install folder (holds OtOpcUa.Host.exe and script-worker\).
.PARAMETER HostServiceAccount
The OtOpcUaHost service account: DOMAIN\name, NT SERVICE\name, LocalSystem, or a SID. Gets full control of PeCacheDir.
.PARAMETER DenyPaths
More folders the worker identity is denied (read and write, inherited), lmxopcua#890. Absolute; must exist; not a
drive or share root; not script-worker\ or the PE cache. A comma- or semicolon-separated string also works (for
powershell -File). Example for the wonder layout E:\ApiInstall\OtOpcUa\{app,pki,data,logs,backup}:
-DenyPaths 'E:\ApiInstall\OtOpcUa','E:\ApiInstall\OtOpcUa\pki','E:\ApiInstall\OtOpcUa\data'
.PARAMETER PeCacheDir
The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise).
Default: $env:ProgramData\OtOpcUa\script-pe-cache.
@@ -63,6 +86,11 @@
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall
.EXAMPLE
# The wonder layout: binaries in app\, keys in pki\, secrets.db + LocalDb in data\ (lmxopcua#890).
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService `
-DenyPaths E:\ApiInstall\OtOpcUa, E:\ApiInstall\OtOpcUa\pki, E:\ApiInstall\OtOpcUa\data
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
@@ -71,6 +99,7 @@ param(
[string]$PeCacheDir,
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost',
[string[]]$DenyPaths = @(),
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache,
@@ -96,9 +125,13 @@ function Resolve-HostSid([string]$Account) {
}
$hostSid = Resolve-HostSid $HostServiceAccount
# lmxopcua#890: what an earlier run denied, so a re-run drops stale denies and -Uninstall removes all of them. -PlanOnly
# reads nothing from the machine, so there it is empty.
$recorded = if ($PlanOnly) { @() } else { @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName) }
$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir `
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName `
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -DenyPaths $DenyPaths -RecordedDenyPaths $recorded `
-ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
$denied = @($plan | Where-Object { $_.Id -eq 'DenyPathRecord' -and $_.Data.ContainsKey('Paths') } | ForEach-Object { $_.Data.Paths } | Where-Object { $_ })
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f `
@@ -113,6 +146,21 @@ if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-
if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it"
}
if (-not $Uninstall) {
# Checked up front so a typo stops the run before any step changes the machine.
$missing = @($denied | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Container) })
if ($missing.Count) { throw "DenyPaths folder(s) not found: $($missing -join '; ')" }
if ($denied.Count -eq 0) {
$parent = Split-Path -Parent $InstallRoot.TrimEnd('\')
if ($parent -and $parent -notmatch '^[A-Za-z]:\\?$') {
$siblings = @(Get-ChildItem -LiteralPath $parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^(pki|data|logs?|backups?|config|secrets?)$' } | ForEach-Object FullName)
foreach ($s in $siblings) {
Write-Warning "$s sits beside the install folder and is NOT denied to the worker (BUILTIN\Users is one of its restricting SIDs). Pass -DenyPaths '$parent' and the folders holding keys or secrets (lmxopcua#890)."
}
}
}
}
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'run elevated (services, firewall rules and folder ACLs need an administrator)'
@@ -126,6 +174,17 @@ $results = foreach ($step in $plan) {
}
}
if (-not $WhatIfPreference -and -not $Uninstall) {
# The inherited deny stops at a protected DACL; list every such item that has no deny of its own (lmxopcua#890).
$workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker'
$gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) })
$gaps = @($gaps | Sort-Object -Unique)
foreach ($g in $gaps) {
Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own). Check its ACL grants nothing to Everyone, BUILTIN\Users or RESTRICTED, or add it to -DenyPaths."
}
Write-Host (" coverage: {0} denied folder(s); {1} protected item(s) the deny does not reach" -f (1 + $denied.Count), $gaps.Count)
}
if (-not $WhatIfPreference) {
Write-Host ''
if ($Uninstall) {