<# .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 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: 1. AnchorService a DISABLED service '' whose account is NT SERVICE\, 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 \pe, the worker identity gets Modify (workers compile and write HMAC'd entries). 4. WorkerFolderAcl \script-worker, the worker identity gets read + execute. 5. InstallRootDeny , 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\\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 \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. 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. .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 .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( [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$HostServiceAccount, [string]$PeCacheDir, [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$HostServiceName = 'OtOpcUaHost', [string[]]$DenyPaths = @(), [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 # 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 -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 ` $(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" } 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)' } $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 -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) { 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