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:
@@ -18,6 +18,20 @@ $script:FullControl = 0x1F01FF
|
||||
$script:Modify = 0x1301BF # Modify | Synchronize
|
||||
$script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize
|
||||
$script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders)
|
||||
# lmxopcua#890: the deny the worker identity gets on the install folder and on every -DenyPaths folder. Read AND write:
|
||||
# ReadData/ListDirectory 0x1, WriteData/AddFile 0x2, AppendData/AddSubdirectory 0x4, WriteExtendedAttributes 0x10,
|
||||
# DeleteSubdirectoriesAndFiles 0x40, WriteAttributes 0x100, Delete 0x10000, ChangePermissions 0x40000,
|
||||
# TakeOwnership 0x80000.
|
||||
# Not denied, so the loader can still walk to script-worker\ and the worker can still run: Traverse/Execute 0x20,
|
||||
# ReadAttributes 0x80, ReadPermissions 0x20000, Synchronize 0x100000 (and ReadExtendedAttributes 0x8, which leaks nothing).
|
||||
# The write half matters because a data drive commonly grants BUILTIN\Users (one of the worker's restricting SIDs)
|
||||
# CreateFiles/AppendData on its subfolders: without it a worker could drop a certificate into pki\trusted\certs.
|
||||
$script:DenyMask = 0x0D0157
|
||||
|
||||
# lmxopcua#890: where the installer records the -DenyPaths it applied, so a later re-run removes the ones dropped from
|
||||
# the list and -Uninstall removes exactly what was added without the operator repeating them. The value lives under the
|
||||
# anchor service's key and goes with it.
|
||||
$script:DenyRecordValue = 'DenyPaths'
|
||||
|
||||
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
|
||||
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
|
||||
@@ -77,12 +91,51 @@ function Get-OtOpcUaScriptWorkerFolderAce {
|
||||
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.
|
||||
InstallRoot\ and every -DenyPaths folder: DENY the worker identity read and write ($script:DenyMask), inherited —
|
||||
so appsettings*.json, the logs, the host binaries, pki\ (private keys, trust lists) and data\ (secrets.db,
|
||||
LocalDb) are unreadable and unwritable to the worker even where BUILTIN\Users (one of its restricting SIDs) can
|
||||
read or create files (lmxopcua#890). 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
|
||||
Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:DenyMask -Sid $WorkerSid
|
||||
}
|
||||
|
||||
function Test-OtOpcUaPathWithin {
|
||||
# True when $Path equals $Ancestor or is below it (Windows paths, case-insensitive, no OS call).
|
||||
param([string]$Path, [string]$Ancestor)
|
||||
$p = $Path.TrimEnd('\'); $a = $Ancestor.TrimEnd('\')
|
||||
$p -ieq $a -or $p.StartsWith($a + '\', [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Resolve-OtOpcUaDenyPaths {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Normalises -DenyPaths (lmxopcua#890): splits 'a,b' / 'a;b' (powershell -File passes an array as one string),
|
||||
trims trailing '\', drops blanks, de-duplicates case-insensitively, drops InstallRoot itself (InstallRootDeny
|
||||
covers it) and refuses what would break the worker or deny far too much.
|
||||
#>
|
||||
param([string[]]$Paths, [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$PeCacheDir)
|
||||
$root = $InstallRoot.TrimEnd('\')
|
||||
$workerFolder = "$root\script-worker"
|
||||
$cache = $PeCacheDir.TrimEnd('\')
|
||||
$out = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($raw in @($Paths | Where-Object { $_ } | ForEach-Object { $_ -split '[,;]' })) {
|
||||
$p = $raw.Trim().TrimEnd('\')
|
||||
if (-not $p) { continue }
|
||||
if ($p -match '^[A-Za-z]:$' -or $p -match '^\\\\[^\\]+(\\[^\\]+)?$') {
|
||||
throw "DenyPaths entry '$raw' is a drive or share root; deny the deployment folder instead"
|
||||
}
|
||||
if ($p -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "DenyPaths entry '$raw' must be an absolute Windows path" }
|
||||
if (Test-OtOpcUaPathWithin -Path $p -Ancestor $workerFolder) {
|
||||
throw "DenyPaths entry '$raw' is (inside) $workerFolder, the worker's own binaries"
|
||||
}
|
||||
if (Test-OtOpcUaPathWithin -Path $p -Ancestor $cache) {
|
||||
throw "DenyPaths entry '$raw' is (inside) PeCacheDir '$PeCacheDir'; the worker writes its compiled entries there"
|
||||
}
|
||||
if ($p -ieq $root) { continue }
|
||||
if (-not ($out | Where-Object { $_ -ieq $p })) { $out.Add($p) }
|
||||
}
|
||||
$out.ToArray()
|
||||
}
|
||||
|
||||
function New-OtOpcUaPlanStep {
|
||||
@@ -103,6 +156,12 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
[Parameter(Mandatory)] [string]$PeCacheDir,
|
||||
[string]$WorkerServiceName = 'OtOpcUaScriptWorker',
|
||||
[string]$HostServiceName = 'OtOpcUaHost',
|
||||
# lmxopcua#890: more folders to deny the worker identity (the deployment folder around InstallRoot, pki\, data\,
|
||||
# logs\, a Secrets or LocalDb folder elsewhere). Each gets the same inherited deny as InstallRoot.
|
||||
[string[]]$DenyPaths = @(),
|
||||
# The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under
|
||||
# -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all.
|
||||
[string[]]$RecordedDenyPaths = @(),
|
||||
[switch]$ConfigureHostService,
|
||||
[switch]$Uninstall,
|
||||
[switch]$RemovePeCache
|
||||
@@ -121,12 +180,26 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
$workerFolder = "$root\script-worker"
|
||||
$workerExe = "$workerFolder\$($script:WorkerExeName)"
|
||||
$entries = "$cache\pe"
|
||||
$deny = @(Resolve-OtOpcUaDenyPaths -Paths $DenyPaths -InstallRoot $root -PeCacheDir $cache)
|
||||
# Recorded paths are not re-validated (they were when recorded); only normalised, so a removal always matches.
|
||||
$recorded = @($RecordedDenyPaths | Where-Object { $_ } | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ -and $_ -ine $root } |
|
||||
Sort-Object -Unique)
|
||||
$denyAce = Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid
|
||||
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
|
||||
$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 }))
|
||||
foreach ($stale in @($recorded | Where-Object { $p = $_; -not ($deny | Where-Object { $_ -ieq $p }) })) {
|
||||
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $stale `
|
||||
"remove every explicit ACE for the worker identity from '$stale' (recorded by an earlier run, no longer in -DenyPaths)" `
|
||||
@{ Path = $stale; Sid = $workerSid; Remove = $true }))
|
||||
}
|
||||
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
|
||||
"record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" `
|
||||
@{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny }))
|
||||
$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) }))
|
||||
@@ -137,8 +210,13 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
"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' }))
|
||||
"deny the worker identity read and write on the install folder (appsettings, logs, host binaries), inherited; script-worker\ stays readable" `
|
||||
@{ Path = $root; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' }))
|
||||
foreach ($p in $deny) {
|
||||
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p `
|
||||
"deny the worker identity read and write on '$p' (certificates, secrets, stores, logs, backups), inherited" `
|
||||
@{ Path = $p; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; 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 }))
|
||||
@@ -156,6 +234,10 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
}
|
||||
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
|
||||
"remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true }))
|
||||
foreach ($p in @(@($deny) + @($recorded) | Sort-Object -Unique)) {
|
||||
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p `
|
||||
"remove every explicit ACE for the worker identity from '$p'" @{ Path = $p; Sid = $workerSid; 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 `
|
||||
@@ -170,6 +252,8 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
"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 'DenyPathRecord' $recordKey `
|
||||
"delete the record of denied folders" @{ Key = $recordKey; Name = $script:DenyRecordValue; Remove = $true }))
|
||||
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
|
||||
"delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true }))
|
||||
}
|
||||
@@ -288,9 +372,22 @@ function Invoke-ScriptWorkerIsolationStep {
|
||||
}
|
||||
return (Set-OtOpcUaStepAce -Data $d)
|
||||
}
|
||||
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny' } {
|
||||
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } {
|
||||
return (Set-OtOpcUaStepAce -Data $d)
|
||||
}
|
||||
'DenyPathRecord' {
|
||||
$current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name)
|
||||
if ($d.ContainsKey('Remove')) {
|
||||
if (-not (Test-Path -LiteralPath $d.Key)) { return 'unchanged' }
|
||||
Remove-Item -LiteralPath $d.Key -Recurse -Force
|
||||
return 'changed'
|
||||
}
|
||||
if ((@($current) -join "`n") -ceq (@($d.Paths) -join "`n")) { return 'unchanged' }
|
||||
if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null }
|
||||
if (@($d.Paths).Count -eq 0) { Remove-ItemProperty -LiteralPath $d.Key -Name $d.Name -ErrorAction SilentlyContinue }
|
||||
else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) }
|
||||
return 'changed'
|
||||
}
|
||||
'FirewallBlockOutbound' {
|
||||
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
|
||||
if ($d.ContainsKey('Remove')) {
|
||||
@@ -327,6 +424,36 @@ function Invoke-ScriptWorkerIsolationStep {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OtOpcUaRecordedDenyPaths {
|
||||
# The -DenyPaths an earlier run recorded under the anchor service's key (lmxopcua#890); empty when there is none.
|
||||
param([string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$Key, [string]$Name = $script:DenyRecordValue)
|
||||
if (-not $Key) { $Key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" }
|
||||
if (-not (Test-Path -LiteralPath $Key)) { return }
|
||||
$v = (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue)
|
||||
if ($v) { @($v.$Name | Where-Object { $_ }) }
|
||||
}
|
||||
|
||||
function Find-OtOpcUaDenyCoverageGap {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
After the denies are applied (lmxopcua#890): the files and folders under $Path that the inherited deny does NOT
|
||||
reach because their DACL is protected (inheritance switched off, e.g. a store that sets its own ACL on a private
|
||||
key) and that carry no explicit deny of their own. Each one is reported as a WARN; the operator decides.
|
||||
$Skip (script-worker\) is not walked.
|
||||
#>
|
||||
param([Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$WorkerSid, [string[]]$Skip = @())
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||
$identity = [System.Security.Principal.SecurityIdentifier]::new($WorkerSid)
|
||||
foreach ($item in @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue)) {
|
||||
if (@($Skip | Where-Object { $_ -and (Test-OtOpcUaPathWithin -Path $item.FullName -Ancestor $_) }).Count) { continue }
|
||||
try { $acl = Get-Acl -LiteralPath $item.FullName } catch { continue }
|
||||
if (-not $acl.AreAccessRulesProtected) { continue }
|
||||
$explicitDeny = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
|
||||
Where-Object { $_.IdentityReference -eq $identity -and $_.AccessControlType -eq 'Deny' })
|
||||
if ($explicitDeny.Count -eq 0) { $item.FullName }
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
|
||||
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Get-ScriptWorkerIsolationPlan,
|
||||
Invoke-ScriptWorkerIsolationStep
|
||||
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan,
|
||||
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap
|
||||
|
||||
Reference in New Issue
Block a user