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
|
||||
Reference in New Issue
Block a user