feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user