2a8e2981fe
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
143 lines
8.3 KiB
PowerShell
143 lines
8.3 KiB
PowerShell
<#
|
|
.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
|