fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
The installer denied NT SERVICE\OtOpcUaScriptWorker only on -InstallRoot. On the wonder layout the node's keys and secrets sit beside it (E:\ApiInstall\OtOpcUa\pki with the application certificate's private key, data\ with secrets.db and LocalDb), inheriting BUILTIN\Users:RX. Users is a restricting SID of the worker token, so a worker could read them. - Install-ScriptWorkerIsolation.ps1 -DenyPaths <dir>,...: each folder gets the same inherited deny as InstallRoot (new SensitiveDirDeny steps). Refuses drive/share roots, script-worker\ and the PE cache; folders must exist; a comma/semicolon string works for powershell -File. - The deny is read AND write (0xD0157: ReadData, WriteData, AppendData, WriteEA, DeleteChild, WriteAttributes, Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay so the loader still reaches script-worker\. The write half stops a worker planting a cert in pki\trusted\certs where a data drive lets Users create files in subfolders. - The list is recorded under the anchor service's key (Parameters\DenyPaths, step DenyPathRecord): a re-run removes the deny from folders dropped from the list, -Uninstall removes every recorded one without repeating -DenyPaths. -WhatIf/-PlanOnly print every step. - After an apply, WARN for each protected-DACL item under a denied folder that the inherited deny cannot reach; without -DenyPaths, WARN for pki/data/logs/backup/config/secrets siblings of InstallRoot. Tests: - Dry-run harness 46 -> 86 assertions (QA layout plan, mask bits, refusals, stale removal, record-driven rollback, host account never denied). Mutations (no sibling deny, deny->allow, mask back to ReadData, no stale removal, rollback ignoring the record, script-worker not refused) each turn it red. - Test-ScriptWorkerIsolation.Windows.ps1 (Windows PowerShell 5.1, windev): 34/34, incl. -DenyPaths apply, 11/11 unchanged re-apply, shorter-list re-apply, uninstall from the record, second uninstall unchanged. - New InstallerLayoutIsolationTests (OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated Windows): a scratch wonder layout under the real restricted worker token. Before install and after uninstall the token reads pki/data/ logs/backup/appsettings and plants a trust-list file; installed it can do neither, still reads script-worker\, a real worker launched from the layout runs a script, and the host still reads everything. Mutating the -DenyPaths denies to a no-op mask turns it red on pki\own\private\otopcua.pfx (reproduces #890). QA vq03/vq04 are NOT re-applied; the command is in docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
.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.
|
||||
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:
|
||||
@@ -17,8 +17,21 @@
|
||||
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.
|
||||
5. InstallRootDeny <InstallRoot>, 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\<WorkerServiceName>\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
|
||||
<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
|
||||
@@ -30,12 +43,22 @@
|
||||
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.
|
||||
@@ -63,6 +86,11 @@
|
||||
|
||||
.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(
|
||||
@@ -71,6 +99,7 @@ param(
|
||||
[string]$PeCacheDir,
|
||||
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
|
||||
[string]$HostServiceName = 'OtOpcUaHost',
|
||||
[string[]]$DenyPaths = @(),
|
||||
[switch]$ConfigureHostService,
|
||||
[switch]$Uninstall,
|
||||
[switch]$RemovePeCache,
|
||||
@@ -96,9 +125,13 @@ function Resolve-HostSid([string]$Account) {
|
||||
}
|
||||
|
||||
$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 `
|
||||
-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 `
|
||||
@@ -113,6 +146,21 @@ if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-
|
||||
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)'
|
||||
@@ -126,6 +174,17 @@ $results = foreach ($step in $plan) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user