<# 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\. 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\: 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 ` 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