feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
v2-ci / unit-tests (push) Has been cancelled

Owner decisions §14 #5/#6 as built (design §12.5):

- Scripting:Worker:Identity (default unset, Windows only, fail-closed validation): each worker
  starts under a restricted token derived from the host's. Every privilege but SeChangeNotify is
  removed; the restricting SIDs are NT SERVICE\OtOpcUaScriptWorker's SID + Everyone + Users +
  RESTRICTED; the token and default DACLs are host + SYSTEM + identity + RESTRICTED. The worker
  is created suspended, put in its Job Object before it runs, inherits only its stdio pipes, gets
  an ACL'd work dir as TEMP, and runs with DOTNET_EnableDiagnostics=0. The identity is granted on
  the host's window station and desktop, because USER32 init made coreclr.dll fail with
  ERROR_DLL_INIT_FAILED on windev. The logon SID is not used because the host's process DACL
  gives it PROCESS_VM_READ.
- The worker pipe on Windows always has an explicit protected DACL: host account + identity
  only. There is no owner ACE, so an elevated host's Administrators gets no access.
- The worker treats access denied on its parent-pid poll as alive; the Job Object covers that case.
- scripts/install/Install-ScriptWorkerIsolation.ps1 + ScriptWorkerIsolation.psm1 are idempotent
  and support -WhatIf, -Uninstall and -PlanOnly. They create the disabled anchor service, the
  PeCacheDir ACL (protected, no Users, key and markers host-only, pe\ Modify for the identity),
  read+execute on script-worker\, a ReadData deny on the install root, the outbound firewall
  block on the worker exe, and optionally the host-service env.
- Tests: the dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1 (46
  assertions, mutation-checked); ScriptWorkerIdentityTests; WorkerIsolationTests, which cover
  the pipe ACL, token access and a real restricted worker running a script, all green on windev.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 08:46:23 -05:00
parent 0e96dd08a2
commit 2a8e2981fe
20 changed files with 1898 additions and 37 deletions
@@ -0,0 +1,142 @@
<#
.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.
.DESCRIPTION
Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order:
1. AnchorService a DISABLED service '<WorkerServiceName>' whose account is NT SERVICE\<WorkerServiceName>,
so that virtual account exists and resolves by name. It is never started: the host launches
every worker itself, under a restricted token whose restricting SIDs are that account +
Everyone + BUILTIN\Users + RESTRICTED, with every privilege removed
(Scripting:Worker:Identity; RestrictedWorkerLauncher.cs). Each access check the worker
makes must pass for the host account AND that set.
2. PeCacheDir created; ACL replaced: protected, owner Administrators, SYSTEM + Administrators + the host
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.
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
worker (dotnet.exe) is NOT covered — the analyzer's System.Net ban is then the only control.
7. HostServiceEnv (-ConfigureHostService only) Scripting__Worker__Identity and Scripting__Worker__PeCacheDir
in the OtOpcUaHost service environment. Scripting:Runner itself is NOT set here: the
runner flip is a separate, per-tier decision (design §12, P2/P3).
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.
.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 PeCacheDir
The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise).
Default: $env:ProgramData\OtOpcUa\script-pe-cache.
.PARAMETER WorkerServiceName
The worker identity's virtual account name. Default OtOpcUaScriptWorker (Scripting:Worker:Identity =
'NT SERVICE\OtOpcUaScriptWorker').
.PARAMETER ConfigureHostService
Also write the two Scripting__Worker__* entries into the host service's environment (or remove them with -Uninstall).
.PARAMETER Uninstall
Roll everything back: the firewall rule, every explicit ACE for the worker identity, the anchor service, and (with
-ConfigureHostService) the env entries. The PE cache is kept unless -RemovePeCache.
.PARAMETER PlanOnly
Print the plan and return it as objects without touching (or even reading) the machine. Runs on any OS; used by the
dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1. -HostServiceAccount must then be a SID.
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -WhatIf
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory)] [string]$InstallRoot,
[Parameter(Mandatory)] [string]$HostServiceAccount,
[string]$PeCacheDir,
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost',
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache,
[switch]$PlanOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force
if (-not $PeCacheDir) {
$programData = if ($env:ProgramData) { $env:ProgramData } else { 'C:\ProgramData' }
$PeCacheDir = "$($programData.TrimEnd('\'))\OtOpcUa\script-pe-cache"
}
function Resolve-HostSid([string]$Account) {
if ($Account -match '^[Ss]-1-\d+(-\d+)+$') { return $Account.ToUpperInvariant() }
if ($PlanOnly) { throw "-PlanOnly needs -HostServiceAccount as a SID (no account lookup without the OS)" }
if ($Account -in 'LocalSystem', '.\LocalSystem', 'NT AUTHORITY\SYSTEM') { return 'S-1-5-18' }
if ($Account -match '^NT SERVICE\\(.+)$') { return (Get-OtOpcUaServiceSid -Name $Matches[1]) }
$name = $Account -replace '^\.\\', "$env:COMPUTERNAME\"
([System.Security.Principal.NTAccount]::new($name)).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
$hostSid = Resolve-HostSid $HostServiceAccount
$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir `
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName `
-ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f `
$(if ($Uninstall) { 'ROLLBACK' } else { 'install' }), $WorkerServiceName, $workerSid, $HostServiceAccount, $hostSid)
if ($PlanOnly) {
foreach ($step in $plan) { Write-Host (" [plan] {0,-22} {1}" -f $step.Id, $step.Change) }
return $plan
}
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-ScriptWorkerIsolation.ps1 applies changes on Windows only (use -PlanOnly elsewhere)' }
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"
}
$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)'
}
$results = foreach ($step in $plan) {
if ($PSCmdlet.ShouldProcess($step.Target, "$($step.Id): $($step.Change)")) {
$outcome = Invoke-ScriptWorkerIsolationStep -Step $step
Write-Host (" [{0,-9}] {1,-22} {2}" -f $outcome, $step.Id, $step.Target)
[pscustomobject]@{ Id = $step.Id; Target = $step.Target; Outcome = $outcome }
}
}
if (-not $WhatIfPreference) {
Write-Host ''
if ($Uninstall) {
Write-Host 'Rolled back. If the host still sets Scripting:Worker:Identity (appsettings or env), unset it and restart the host.'
}
else {
Write-Host 'Done. Host configuration for this node:'
Write-Host " Scripting:Worker:Identity = NT SERVICE\$WorkerServiceName"
Write-Host " Scripting:Worker:PeCacheDir = $PeCacheDir"
if (-not $ConfigureHostService) { Write-Host ' (not written: re-run with -ConfigureHostService, or set them in appsettings)' }
Write-Host ' Restart OtOpcUaHost for a configuration change to take effect.'
}
}
$results
+312
View File
@@ -0,0 +1,312 @@
<#
ScriptWorker isolation (lmxopcua#780, design §6 + owner decisions §14 #5/#6, §7 PeCacheDir ACL).
Pure planning functions (no OS calls — the dry-run harness runs them on any OS) plus the Windows apply step.
Install-ScriptWorkerIsolation.ps1 is the entry point; see its help.
The worker identity is the virtual account NT SERVICE\<WorkerServiceName>. Its SID is computed from the name
(Get-OtOpcUaServiceSid, the algorithm behind `sc.exe showsid`), and the host starts each worker with a restricted
token whose restricting SIDs are that identity + Everyone + BUILTIN\Users + RESTRICTED (ScriptWorkerIdentity.cs,
RestrictedWorkerLauncher.cs). Every access check the worker makes must therefore pass for the host account AND for
that set — which is why the ACLs below grant or deny the worker identity specifically.
#>
Set-StrictMode -Version Latest
# Access masks (FileSystemRights) used in the planned ACEs.
$script:FullControl = 0x1F01FF
$script:Modify = 0x1301BF # Modify | Synchronize
$script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize
$script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders)
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
$script:EnvIdentity = 'Scripting__Worker__Identity'
$script:EnvPeCacheDir = 'Scripting__Worker__PeCacheDir'
function Get-OtOpcUaServiceSid {
<#
.SYNOPSIS
The SID of the virtual account NT SERVICE\<Name>: S-1-5-80- + SHA-1 of the upper-cased name (UTF-16LE) read as
five little-endian UInt32 values. Identical to `sc.exe showsid <Name>` and to ScriptWorkerIdentity.ServiceSid.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$Name)
$bytes = [System.Text.Encoding]::Unicode.GetBytes($Name.ToUpperInvariant())
$sha1 = [System.Security.Cryptography.SHA1]::Create()
try { $hash = $sha1.ComputeHash($bytes) } finally { $sha1.Dispose() }
$parts = for ($i = 0; $i -lt 5; $i++) { [BitConverter]::ToUInt32($hash, $i * 4) }
'S-1-5-80-' + ($parts -join '-')
}
function Format-OtOpcUaAce {
# One SDDL ACE string: (A|D;flags;0xMASK;;;SID).
param([ValidateSet('A', 'D')] [string]$Type, [string]$Flags, [int]$Mask, [string]$Sid)
'({0};{1};0x{2:x};;;{3})' -f $Type, $Flags, $Mask, $Sid
}
function Get-OtOpcUaPeCacheSddl {
<#
.SYNOPSIS
The PeCacheDir root's security descriptor: owner Administrators, PROTECTED (no inherited Users/Everyone/
Authenticated Users), SYSTEM + Administrators + the host account full control (container + object inherit).
The worker identity gets NOTHING here — the HMAC key (pe-cache.key) and the launch markers (workers\) stay
host-only; the worker's one writable place is the pe\ subfolder (Get-OtOpcUaPeEntriesAce).
#>
param([Parameter(Mandatory)] [string]$HostSid)
$aces = @(
(Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'SY')
(Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'BA')
)
if ($HostSid -ne 'S-1-5-18') { $aces += Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid $HostSid }
'O:BAG:SYD:PAI' + ($aces -join '')
}
function Get-OtOpcUaPeEntriesAce {
# pe\ : the worker compiles in-process (owner decision §14 #7) and writes HMAC'd entries here — Modify, inherited.
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:Modify -Sid $WorkerSid
}
function Get-OtOpcUaScriptWorkerFolderAce {
# script-worker\ : the worker's own binaries — read and execute, inherited.
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:ReadAndExecute -Sid $WorkerSid
}
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.
#>
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:ReadData -Sid $WorkerSid
}
function New-OtOpcUaPlanStep {
param([string]$Id, [string]$Target, [string]$Change, [hashtable]$Data = @{})
[pscustomobject]@{ Id = $Id; Target = $Target; Change = $Change; Data = $Data }
}
function Get-ScriptWorkerIsolationPlan {
<#
.SYNOPSIS
Every change Install-ScriptWorkerIsolation.ps1 makes, in order, as data. Pure: no OS call, so the dry-run harness
(scripts/install/tests/Test-ScriptWorkerIsolation.ps1) asserts it on any OS and -WhatIf prints it verbatim.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$InstallRoot,
[Parameter(Mandatory)] [string]$HostSid,
[Parameter(Mandatory)] [string]$PeCacheDir,
[string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost',
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache
)
# Windows paths, planned on any OS (the harness runs on macOS/Linux too): drive-rooted or UNC only.
foreach ($p in @{ InstallRoot = $InstallRoot; PeCacheDir = $PeCacheDir }.GetEnumerator()) {
if ($p.Value -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "$($p.Key) '$($p.Value)' must be an absolute Windows path" }
}
$root = $InstallRoot.TrimEnd('\')
$cache = $PeCacheDir.TrimEnd('\')
if ($cache -ieq $root -or $cache.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw "PeCacheDir '$PeCacheDir' is inside InstallRoot '$InstallRoot'; the host refuses a cache inside its content root"
}
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
$identity = "NT SERVICE\$WorkerServiceName"
$workerFolder = "$root\script-worker"
$workerExe = "$workerFolder\$($script:WorkerExeName)"
$entries = "$cache\pe"
$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 }))
$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) }))
$steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries `
"create the PE entries directory and grant the worker identity Modify (it compiles and writes HMAC'd entries)" `
@{ Path = $entries; Ace = (Get-OtOpcUaPeEntriesAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:Modify; Type = 'Allow' }))
$steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
"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' }))
$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 }))
if ($ConfigureHostService) {
$steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName `
"set $($script:EnvIdentity)=$identity and $($script:EnvPeCacheDir)=$cache in the '$HostServiceName' service environment (other entries kept; takes effect on the next service start)" `
@{ Service = $HostServiceName; Set = [ordered]@{ $script:EnvIdentity = $identity; $script:EnvPeCacheDir = $cache } }))
}
}
else {
if ($ConfigureHostService) {
$steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName `
"remove $($script:EnvIdentity) and $($script:EnvPeCacheDir) from the '$HostServiceName' service environment (workers revert to the host's token on the next start)" `
@{ Service = $HostServiceName; Remove = @($script:EnvIdentity, $script:EnvPeCacheDir) }))
}
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; 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 `
"remove every explicit ACE for the worker identity from script-worker\" @{ Path = $workerFolder; Sid = $workerSid; Remove = $true }))
if ($RemovePeCache) {
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
"delete the PE cache directory (HMAC key, entries, launch markers; the host recreates a fresh cache if still configured)" `
@{ Path = $cache; Remove = $true }))
}
else {
$steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries `
"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 'AnchorService' $WorkerServiceName `
"delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true }))
}
$steps
}
# ---------------------------------------------------------------------------------------------------------------------
# Apply (Windows only). Each function is idempotent: it reads the current state, changes only what differs, and
# returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them.
# ---------------------------------------------------------------------------------------------------------------------
function Set-OtOpcUaExplicitAce {
# Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
if (-not (Test-Path -LiteralPath $Path)) {
if ($Remove) { return 'unchanged' }
throw "$Path does not exist"
}
$item = Get-Item -LiteralPath $Path
$acl = $item.GetAccessControl('Access')
$identity = [System.Security.Principal.SecurityIdentifier]::new($Sid)
$explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.IdentityReference -eq $identity })
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$wanted = if ($Remove) { @() } else {
, [System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]$Mask,
$inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]$Type)
}
$same = ($explicit.Count -eq $wanted.Count) -and (
$wanted.Count -eq 0 -or (
[int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and
$explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None'))
if ($same) { return 'unchanged' }
$acl.PurgeAccessRules($identity)
foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
$item.SetAccessControl($acl)
'changed'
}
function Set-OtOpcUaStepAce {
param([hashtable]$Data)
if ($Data.ContainsKey('Remove')) { return (Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Remove) }
Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Mask $Data.Mask -Type $Data.Type
}
function Invoke-ScriptWorkerIsolationStep {
<#
.SYNOPSIS
Applies one plan step on Windows. Idempotent; returns 'changed' or 'unchanged'.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] $Step)
$d = $Step.Data
switch ($Step.Id) {
'AnchorService' {
$svc = Get-Service -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) {
if (-not $svc) { return 'unchanged' }
& sc.exe delete $d.Name | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe delete $($d.Name) failed ($LASTEXITCODE)" }
return 'changed'
}
$verb = if ($svc) { 'config' } else { 'create' }
$cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue
if ($cfg -and $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"") {
return 'unchanged'
}
& sc.exe $verb $d.Name binPath= "`"$($d.BinPath)`"" start= disabled obj= $d.Identity `
DisplayName= 'OtOpcUa ScriptWorker identity (anchor, never started)' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe $verb $($d.Name) failed ($LASTEXITCODE)" }
& sc.exe sidtype $d.Name unrestricted | Out-Null
& sc.exe description $d.Name 'Anchors the NT SERVICE virtual account OtOpcUa ScriptWorker processes are restricted to (lmxopcua#780). Never started: the OtOpcUaHost launches workers itself under a restricted token.' | Out-Null
return 'changed'
}
'PeCacheDir' {
if ($d.ContainsKey('Remove')) {
if (-not (Test-Path -LiteralPath $d.Path)) { return 'unchanged' }
Remove-Item -LiteralPath $d.Path -Recurse -Force
return 'changed'
}
$changed = $false
if (-not (Test-Path -LiteralPath $d.Path)) { New-Item -ItemType Directory -Path $d.Path -Force | Out-Null; $changed = $true }
$item = Get-Item -LiteralPath $d.Path
$current = $item.GetAccessControl('Access, Owner').GetSecurityDescriptorSddlForm('Access, Owner')
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
if ($current -ne $desired.GetSecurityDescriptorSddlForm('Access, Owner')) {
$item.SetAccessControl($desired) # propagates to the key file, pe\ and workers\ (inherited ACEs)
$changed = $true
}
return $(if ($changed) { 'changed' } else { 'unchanged' })
}
'PeEntriesDir' {
if (-not $d.ContainsKey('Remove') -and -not (Test-Path -LiteralPath $d.Path)) {
New-Item -ItemType Directory -Path $d.Path -Force | Out-Null
}
return (Set-OtOpcUaStepAce -Data $d)
}
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny' } {
return (Set-OtOpcUaStepAce -Data $d)
}
'FirewallBlockOutbound' {
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) {
if (-not $rule) { return 'unchanged' }
Remove-NetFirewallRule -Name $d.Name
return 'changed'
}
if ($rule) {
$app = ($rule | Get-NetFirewallApplicationFilter).Program
if ($rule.Direction -eq 'Outbound' -and $rule.Action -eq 'Block' -and $rule.Enabled -eq 'True' -and
$rule.Profile -eq 'Any' -and $app -ieq $d.Program) { return 'unchanged' }
Remove-NetFirewallRule -Name $d.Name
}
New-NetFirewallRule -Name $d.Name -DisplayName 'OtOpcUa ScriptWorker - block outbound' `
-Description 'lmxopcua#780 design §6: OtOpcUa script workers never need the network; scripts are also denied System.Net at compile time.' `
-Direction Outbound -Action Block -Program $d.Program -Profile Any -Enabled True | Out-Null
return 'changed'
}
'HostServiceEnv' {
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)"
if (-not (Test-Path $key)) {
if ($d.ContainsKey('Remove')) { return 'unchanged' }
throw "service '$($d.Service)' is not installed (run Install-Services.ps1 first)"
}
$current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ })
$names = if ($d.ContainsKey('Remove')) { $d.Remove } else { @($d.Set.Keys) }
$next = @($current | Where-Object { $n = ($_ -split '=', 2)[0]; $names -notcontains $n })
if (-not $d.ContainsKey('Remove')) { foreach ($k in $d.Set.Keys) { $next += "$k=$($d.Set[$k])" } }
if ((@($current) -join "`n") -eq (@($next) -join "`n")) { return 'unchanged' }
Set-ItemProperty -Path $key -Name Environment -Type MultiString -Value ([string[]]$next)
return 'changed'
}
default { throw "unknown plan step '$($Step.Id)'" }
}
}
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Get-ScriptWorkerIsolationPlan,
Invoke-ScriptWorkerIsolationStep
@@ -0,0 +1,127 @@
<#
.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 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,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;0x1;;;$workerSid)") "install root DENIES the worker ReadData ($($deny.Ace))"
Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root) 'install-root ACE is a deny on the root'
$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 6) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
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,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"
}
$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'
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) {
$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 }