<# .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. SettingsRecord (lmxopcua#901) the other effective arguments (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) under the same key, whose DACL is set protected, SYSTEM + Administrators only. No secrets. -Reapply and -Verify read them back. 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). A deploy that REPLACES the install folder (a rename/swap) drops the explicit ACEs on script-worker\, on the install folder and on any denied folder that is recreated or copied in (pki\ on vd03); files copied into an existing folder inherit them (lmxopcua#901). After every swap run, with no other arguments: .\Install-ScriptWorkerIsolation.ps1 -Reapply # the recorded settings; prints changed/unchanged per step .\Install-ScriptWorkerIsolation.ps1 -Verify # check only; exit 1 and a [MISSING] line per gap Invoke-OtOpcUaInstallSwap.ps1 does both around its swap and does not start the service when -Verify fails: -Scope Outside while the old build still serves, -Scope InstallRoot after the swap (lmxopcua#905). 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. The walk covers each denied folder once (a folder inside another denied folder is walked with it) and prints its time; about 0.15 ms an item (lmxopcua#905: before, Get-Acl per item made it about 3.4 ms, 59 s for 17.5k items). .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 Reapply lmxopcua#901. Re-apply with the settings the last install recorded (no other argument except -WorkerServiceName, -WhatIf). Fails when nothing is recorded, or when the box was installed by an installer older than #901 (that one recorded only DenyPaths): run the full-argument install once, which writes the record. .PARAMETER Verify lmxopcua#901. Check only: compares every install step with the machine, prints [ok] or [MISSING] per step and exits 1 when anything is missing (0 when all is in place). Uses the recorded settings, or -InstallRoot/-HostServiceAccount/... when given. Protected-DACL coverage gaps are WARNed, as after an apply, and do not fail the check. .PARAMETER Scope lmxopcua#905, with -Reapply, -Verify or -PlanOnly. Which install steps run: All every step (default). InstallRoot only what a folder swap can undo: the folder ACEs on InstallRoot and below (WorkerFolderAcl, InstallRootDeny, a -DenyPaths folder inside InstallRoot such as vd03's pki\). The coverage scan walks InstallRoot only. Outside every other step: the anchor service, the records, the PE cache, the denies outside InstallRoot, the firewall rule (its path to the worker exe does not change with a swap; the query costs seconds), the host service env. The coverage scan walks the denied folders and skips InstallRoot. InstallRoot and Outside together are exactly All. Invoke-OtOpcUaInstallSwap.ps1 runs Outside before it stops the service and InstallRoot inside the stopped window. .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 # After an upgrade swapped the install folder (lmxopcua#901): re-apply from the record, then gate on the check. .\Install-ScriptWorkerIsolation.ps1 -Reapply .\Install-ScriptWorkerIsolation.ps1 -Verify; if ($LASTEXITCODE) { throw 'worker isolation incomplete' } .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( # Required unless -Reapply / -Verify / -Uninstall take them from the record (lmxopcua#901). [string]$InstallRoot, [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]$Reapply, [switch]$Verify, [switch]$PlanOnly, # lmxopcua#905: with -Reapply / -Verify / -PlanOnly only. Which steps run (see .PARAMETER Scope). [ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force # lmxopcua#901: -Reapply / -Verify / -Uninstall without arguments run from what the last install recorded. if (@($Reapply, $Verify, $Uninstall, $PlanOnly | Where-Object { $_ }).Count -gt 1) { throw '-Reapply, -Verify, -Uninstall and -PlanOnly are exclusive' } if ($Scope -ne 'All' -and -not ($Reapply -or $Verify -or $PlanOnly)) { throw '-Scope works with -Reapply, -Verify or -PlanOnly only (an install or -Uninstall always covers every step)' } if ($Reapply) { $given = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'DenyPaths', 'ConfigureHostService', 'RemovePeCache' | Where-Object { $PSBoundParameters.ContainsKey($_) }) if ($given.Count) { throw "-Reapply takes its settings from the record; do not pass -$($given -join ', -') (run a normal install to change them)" } } $fromRecord = $false if (($Reapply -or $Verify -or $Uninstall) -and -not $InstallRoot -and -not $HostServiceAccount) { # The record key is SYSTEM + Administrators only, so a non-elevated read would look like "nothing recorded". $me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run elevated (the recorded settings are readable by SYSTEM and Administrators only)' } $rec = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName if (-not $rec) { throw ("no recorded ScriptWorker isolation settings under HKLM\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters. " + 'Either isolation is not installed, or it was installed by an installer older than lmxopcua#901 (which recorded only DenyPaths): ' + 'run the install once with its full arguments (-InstallRoot, -HostServiceAccount, -DenyPaths, [-ConfigureHostService]); that writes the record.') } $InstallRoot = $rec.InstallRoot $HostServiceAccount = $rec.HostServiceAccount if (-not $PSBoundParameters.ContainsKey('PeCacheDir') -and $rec.PeCacheDir) { $PeCacheDir = $rec.PeCacheDir } if (-not $PSBoundParameters.ContainsKey('HostServiceName')) { $HostServiceName = $rec.HostServiceName } if (-not $PSBoundParameters.ContainsKey('DenyPaths')) { $DenyPaths = @($rec.DenyPaths) } if (-not $PSBoundParameters.ContainsKey('ConfigureHostService')) { $ConfigureHostService = [switch]$rec.ConfigureHostService } $fromRecord = $true } if (-not $InstallRoot -or -not $HostServiceAccount) { throw '-InstallRoot and -HostServiceAccount are required (or -Reapply / -Verify / -Uninstall with a recorded install)' } 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 -HostServiceAccount $HostServiceAccount ` -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 { $_ }) # lmxopcua#905: a swap runs -Scope Outside while the old build still serves and -Scope InstallRoot after the swap, so only # the steps the swap can undo run while the service is stopped. The two scopes partition the plan. $plan = @(Select-ScriptWorkerIsolationPlanScope -Plan $plan -InstallRoot $InstallRoot -Scope $Scope) $deniedInScope = @($denied | Where-Object { $Scope -eq 'All' -or ((Test-OtOpcUaPathWithin -Path $_ -Ancestor $InstallRoot) -eq ($Scope -eq 'InstallRoot')) }) function Invoke-CoverageScan { # The protected-DACL coverage scan (lmxopcua#890), over the outermost folders of this scope only (lmxopcua#905). $walk = Get-OtOpcUaCoverageWalkRoots -InstallRoot $InstallRoot -DeniedPaths $denied -Scope $Scope $skip = @((Join-Path $InstallRoot.TrimEnd('\') 'script-worker'), $PeCacheDir.TrimEnd('\')) + @($walk.Skip) $sw = [System.Diagnostics.Stopwatch]::StartNew() $found = @(foreach ($p in $walk.Roots) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip $skip }) $sw.Stop() [pscustomobject]@{ Gaps = @($found | Sort-Object -Unique); Roots = @($walk.Roots); Seconds = $sw.Elapsed.TotalSeconds } } $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}{6}" -f ` $(if ($Uninstall) { 'ROLLBACK' } elseif ($Verify) { 'VERIFY' } elseif ($Reapply) { 'REAPPLY' } else { 'install' }), $WorkerServiceName, $workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' }), $(if ($Scope -ne 'All') { ", scope $Scope ($($plan.Count) step(s))" } else { '' })) if ($fromRecord) { Write-Host (" InstallRoot {0}; PeCacheDir {1}; DenyPaths {2}; ConfigureHostService {3}" -f $InstallRoot, $PeCacheDir, $(if (@($DenyPaths).Count) { $DenyPaths -join ', ' } else { 'none' }), [bool]$ConfigureHostService) } 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 ($Verify) { # lmxopcua#901: check only, nothing changes. Exit 1 when any step is not in place. $checks = foreach ($step in $plan) { $r = try { Test-ScriptWorkerIsolationStep -Step $step } catch { [pscustomobject]@{ Ok = $false; Detail = "check failed: $($_.Exception.Message)" } } Write-Host (" [{0,-7}] {1,-22} {2} - {3}" -f $(if ($r.Ok) { 'ok' } else { 'MISSING' }), $step.Id, $step.Target, $r.Detail) [pscustomobject]@{ Id = $step.Id; Target = $step.Target; Ok = [bool]$r.Ok; Detail = $r.Detail } } $scan = Invoke-CoverageScan foreach ($g in $scan.Gaps) { Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own); check it grants nothing to Everyone, BUILTIN\Users or RESTRICTED." } Write-Host (" coverage scan: {0} folder(s) walked ({1}), {2} protected item(s) the deny does not reach, {3:N1} s" -f ` $scan.Roots.Count, $(if ($scan.Roots.Count) { $scan.Roots -join '; ' } else { 'none' }), $scan.Gaps.Count, $scan.Seconds) $scopeNote = if ($Scope -ne 'All') { " (scope $Scope)" } else { '' } $missing = @($checks | Where-Object { -not $_.Ok }) Write-Host '' if ($missing.Count) { Write-Host ("VERIFY FAILED{3}: {0} of {1} step(s) not in place: {2}. Run -Reapply (as administrator) and verify again." -f ` $missing.Count, @($checks).Count, (($missing | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '; '), $scopeNote) exit 1 } Write-Host ("VERIFY OK{1}: {0}/{0} step(s) in place." -f @($checks).Count, $scopeNote) exit 0 } 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 = @($deniedInScope | 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) { $nChanged = @($results | Where-Object Outcome -eq 'changed').Count Write-Host (" summary: {0} step(s), {1} changed, {2} unchanged" -f $results.Count, $nChanged, ($results.Count - $nChanged)) } 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). $scan = Invoke-CoverageScan $gaps = $scan.Gaps 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} folder(s) walked; {2} protected item(s) the deny does not reach; {3:N1} s" -f ` (1 + $denied.Count), $scan.Roots.Count, $gaps.Count, $scan.Seconds) } 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