0c19f26c81
v2-ci / unit-tests (push) Has been cancelled
Root cause: the protected-DACL coverage scan, not the ACL writes. Both -Reapply and -Verify walked every denied folder (all of E:\ApiInstall on vd03, nested roots again) with Get-ChildItem -Recurse + Get-Acl per item + a pipeline per item for -Skip, about 3.4 ms an item on PS 5.1, all while the service was stopped. An unchanged deny is read, not rewritten; the re-applied install-folder deny propagates over ~1,400 files in 0.5 s. - Find-OtOpcUaDenyCoverageGap: .NET enumeration, -Skip folders pruned, DACL-only GetAccessControl(Access) (about 0.15 ms an item); reparse points not followed. - Get-OtOpcUaCoverageWalkRoots: each denied folder walked once (outermost only). - Install-ScriptWorkerIsolation.ps1 -Scope All|InstallRoot|Outside (with -Reapply, -Verify, -PlanOnly). InstallRoot = the folder ACEs a swap drops; Outside = the rest, including the firewall rule (its query costs 3-5 s; its exe path does not change). The two partition the plan. Prints the scan time. - Invoke-OtOpcUaInstallSwap.ps1: -Reapply/-Verify -Scope Outside before the stop (a failure stops before anything moves), -Scope InstallRoot inside the window; preflight checks the candidate carries the worker exe; prints the stopped window and its parts. -NoStart, -NoCarry and the pre-#901 refusal unchanged. windev, vd03-like tree (17,851 items): stopped window 153.1 s -> 6.0 s (PS 5.1), 101.6 s -> 6.2 s (PS 7); the Outside part takes ~23 s with the old build serving. Dry-run harness 101 -> 123 passed; Windows test 72 passed on PS 5.1 and PS 7. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
700 lines
43 KiB
PowerShell
700 lines
43 KiB
PowerShell
<#
|
|
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)
|
|
# 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'
|
|
|
|
# lmxopcua#901: the effective install arguments, recorded beside DenyPaths so `-Reapply` (after an upgrade swapped the
|
|
# install folder) and `-Verify` (post-deploy gate) need no arguments. Paths, an account name and two flags; no secrets.
|
|
# The key's DACL is set protected, SYSTEM + Administrators only (the services hive is otherwise readable by Users, one of
|
|
# the worker token's restricting SIDs, and the record lists exactly which folders are denied).
|
|
$script:SettingsValueNames = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'ConfigureHostService')
|
|
$script:RecordKeySddl = 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)'
|
|
|
|
$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\ 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: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 {
|
|
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',
|
|
# 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 = @(),
|
|
# lmxopcua#901: the -HostServiceAccount as the operator gave it (recorded for -Reapply). Defaults to the SID.
|
|
[string]$HostServiceAccount,
|
|
[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"
|
|
$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 }))
|
|
$settings = [ordered]@{
|
|
InstallRoot = $root; HostServiceAccount = $(if ($HostServiceAccount) { $HostServiceAccount } else { $HostSid })
|
|
PeCacheDir = $cache; HostServiceName = $HostServiceName; ConfigureHostService = [int][bool]$ConfigureHostService
|
|
}
|
|
$steps.Add((New-OtOpcUaPlanStep 'SettingsRecord' $recordKey `
|
|
"record the effective settings (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) and lock the key to SYSTEM + Administrators, so -Reapply and -Verify need no arguments (lmxopcua#901)" `
|
|
@{ Key = $recordKey; Settings = $settings; Sddl = $script:RecordKeySddl }))
|
|
$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 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 }))
|
|
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 }))
|
|
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 `
|
|
"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 '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 }))
|
|
}
|
|
$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 Get-OtOpcUaExplicitAceState {
|
|
# The explicit ACEs for $Sid on $Path and whether they are exactly the wanted one (or none when $Remove).
|
|
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
|
|
$acl = Get-Acl -LiteralPath $Path # #800: DirectoryInfo.GetAccessControl does not exist on PowerShell 7 (.NET)
|
|
$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'
|
|
# #800: @() keeps $wanted an array; the if-expression unrolled it to a scalar ($null on -Remove), and .Count on that
|
|
# throws under Set-StrictMode -Version Latest on Windows PowerShell 5.1.
|
|
$wanted = @(if (-not $Remove) {
|
|
[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'))
|
|
$found = @($explicit | ForEach-Object { '{0} 0x{1:x} {2}' -f $_.AccessControlType, [int]$_.FileSystemRights, $_.InheritanceFlags }) -join '; '
|
|
[pscustomobject]@{ Acl = $acl; Identity = $identity; Wanted = $wanted; Same = $same; Found = $(if ($found) { $found } else { 'none' }) }
|
|
}
|
|
|
|
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"
|
|
}
|
|
$state = Get-OtOpcUaExplicitAceState -Path $Path -Sid $Sid -Mask $Mask -Type $Type -Remove:$Remove
|
|
if ($state.Same) { return 'unchanged' }
|
|
$acl = $state.Acl
|
|
$acl.PurgeAccessRules($state.Identity)
|
|
foreach ($rule in $state.Wanted) { $acl.AddAccessRule($rule) }
|
|
Set-Acl -LiteralPath $Path -AclObject $acl
|
|
'changed'
|
|
}
|
|
|
|
function Invoke-OtOpcUaSc {
|
|
# Runs sc.exe with an explicitly quoted command line (CommandLineToArgvW rules), identical on PowerShell 5.1 and 7.
|
|
# Returns the exit code. Used where an argument contains literal double quotes.
|
|
param([string[]]$Arguments)
|
|
$quoted = foreach ($a in $Arguments) { '"' + ($a -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' }
|
|
$psi = [System.Diagnostics.ProcessStartInfo]::new((Join-Path ([Environment]::SystemDirectory) 'sc.exe'), ($quoted -join ' '))
|
|
$psi.UseShellExecute = $false
|
|
$psi.CreateNoWindow = $true
|
|
$psi.RedirectStandardOutput = $true
|
|
$psi.RedirectStandardError = $true
|
|
$proc = [System.Diagnostics.Process]::Start($psi)
|
|
$null = $proc.StandardOutput.ReadToEnd()
|
|
$null = $proc.StandardError.ReadToEnd()
|
|
$proc.WaitForExit()
|
|
$proc.ExitCode
|
|
}
|
|
|
|
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'
|
|
}
|
|
# #883: the binPath value carries literal quotes. Windows PowerShell 5.1 passes native arguments with legacy quoting
|
|
# (embedded quotes are dropped or mangled, so the stored path was unquoted and every re-run reported 'changed'),
|
|
# PowerShell 7 escapes them. Build the command line explicitly so both hosts store the same value.
|
|
$scExit = Invoke-OtOpcUaSc @($verb, $d.Name, 'binPath=', "`"$($d.BinPath)`"", 'start=', 'disabled', 'obj=', $d.Identity,
|
|
'DisplayName=', 'OtOpcUa ScriptWorker identity (anchor, never started)')
|
|
if ($scExit -ne 0) { throw "sc.exe $verb $($d.Name) failed ($scExit)" }
|
|
& 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 }
|
|
$current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner')
|
|
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
|
|
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
|
|
if ($current -ne $desired.GetSecurityDescriptorSddlForm('Access, Owner')) {
|
|
Set-Acl -LiteralPath $d.Path -AclObject $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', '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'
|
|
}
|
|
'SettingsRecord' {
|
|
# Install only: -Uninstall deletes the whole key in its DenyPathRecord step.
|
|
$changed = $false
|
|
if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null; $changed = $true }
|
|
$current = Get-ItemProperty -LiteralPath $d.Key -ErrorAction SilentlyContinue
|
|
foreach ($name in @($d.Settings.Keys)) {
|
|
$want = $d.Settings[$name]
|
|
$have = if ($current -and $current.PSObject.Properties[$name]) { $current.$name } else { $null }
|
|
if ($null -ne $have -and "$have" -ceq "$want") { continue }
|
|
$kind = if ($want -is [int]) { 'DWord' } else { 'String' }
|
|
Set-ItemProperty -LiteralPath $d.Key -Name $name -Type $kind -Value $want
|
|
$changed = $true
|
|
}
|
|
if (-not (Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl)) {
|
|
$acl = Get-Acl -Path $d.Key
|
|
$acl.SetSecurityDescriptorSddlForm($d.Sddl, 'Access')
|
|
Set-Acl -Path $d.Key -AclObject $acl
|
|
$changed = $true
|
|
}
|
|
return $(if ($changed) { 'changed' } else { 'unchanged' })
|
|
}
|
|
'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)'" }
|
|
}
|
|
}
|
|
|
|
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 Test-OtOpcUaRecordKeyAcl {
|
|
# True when the record key's DACL is exactly $Sddl (protected; SYSTEM + Administrators), compared in canonical form.
|
|
param([string]$Key, [string]$Sddl = $script:RecordKeySddl)
|
|
$want = [System.Security.AccessControl.RegistrySecurity]::new()
|
|
$want.SetSecurityDescriptorSddlForm($Sddl, 'Access')
|
|
# -Path, not -LiteralPath: Windows PowerShell 5.1's Get-Acl -LiteralPath does not find registry keys (windev, #901).
|
|
(Get-Acl -Path $Key).GetSecurityDescriptorSddlForm('Access') -ceq $want.GetSecurityDescriptorSddlForm('Access')
|
|
}
|
|
|
|
function Get-OtOpcUaIsolationSettings {
|
|
<#
|
|
.SYNOPSIS
|
|
The settings an earlier install recorded (lmxopcua#901) as a hashtable: InstallRoot, HostServiceAccount, PeCacheDir,
|
|
HostServiceName, ConfigureHostService ([bool]) and DenyPaths. $null when the key or InstallRoot/HostServiceAccount
|
|
is missing (nothing installed, or installed by an installer older than #901, which recorded only DenyPaths).
|
|
#>
|
|
param([string]$WorkerServiceName = 'OtOpcUaScriptWorker')
|
|
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
|
|
if (-not (Test-Path -LiteralPath $key)) { return $null }
|
|
$v = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue
|
|
if (-not $v) { return $null }
|
|
$get = { param($n) if ($v.PSObject.Properties[$n]) { $v.$n } else { $null } }
|
|
$root = & $get 'InstallRoot'; $account = & $get 'HostServiceAccount'
|
|
if (-not $root -or -not $account) { return $null }
|
|
@{
|
|
InstallRoot = [string]$root
|
|
HostServiceAccount = [string]$account
|
|
PeCacheDir = [string](& $get 'PeCacheDir')
|
|
HostServiceName = $(if (& $get 'HostServiceName') { [string](& $get 'HostServiceName') } else { 'OtOpcUaHost' })
|
|
ConfigureHostService = [bool][int](& $get 'ConfigureHostService')
|
|
DenyPaths = @(& $get $script:DenyRecordValue | Where-Object { $_ })
|
|
}
|
|
}
|
|
|
|
function Test-ScriptWorkerIsolationStep {
|
|
<#
|
|
.SYNOPSIS
|
|
Checks one INSTALL plan step against the machine without changing anything (lmxopcua#901, -Verify). Returns
|
|
[pscustomobject]@{ Ok; Detail }. Windows only.
|
|
#>
|
|
[CmdletBinding()]
|
|
param([Parameter(Mandatory)] $Step)
|
|
$d = $Step.Data
|
|
$result = { param([bool]$Ok, [string]$Detail) [pscustomobject]@{ Ok = $Ok; Detail = $Detail } }
|
|
switch ($Step.Id) {
|
|
'AnchorService' {
|
|
$cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue
|
|
if (-not $cfg) { return (& $result $false "service '$($d.Name)' not found") }
|
|
$ok = $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`""
|
|
return (& $result $ok "StartMode=$($cfg.StartMode) StartName=$($cfg.StartName) PathName=$($cfg.PathName)")
|
|
}
|
|
'DenyPathRecord' {
|
|
$current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name)
|
|
$ok = (@($current) -join "`n") -ceq (@($d.Paths) -join "`n")
|
|
return (& $result $ok "recorded: $(if ($current.Count) { $current -join '; ' } else { 'none' })")
|
|
}
|
|
'SettingsRecord' {
|
|
if (-not (Test-Path -LiteralPath $d.Key)) { return (& $result $false 'record key missing') }
|
|
$v = Get-ItemProperty -LiteralPath $d.Key
|
|
$bad = @(foreach ($n in @($d.Settings.Keys)) {
|
|
if (-not $v.PSObject.Properties[$n] -or "$($v.$n)" -cne "$($d.Settings[$n])") { $n }
|
|
})
|
|
$aclOk = Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl
|
|
if (-not $aclOk) { $bad += 'key ACL (want SYSTEM + Administrators only, protected)' }
|
|
return (& $result ($bad.Count -eq 0) $(if ($bad.Count) { "differs: $($bad -join ', ')" } else { 'settings recorded, key locked' }))
|
|
}
|
|
'PeCacheDir' {
|
|
if (-not (Test-Path -LiteralPath $d.Path)) { return (& $result $false 'missing') }
|
|
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
|
|
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
|
|
$current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner')
|
|
return (& $result ($current -eq $desired.GetSecurityDescriptorSddlForm('Access, Owner')) "SDDL $current")
|
|
}
|
|
{ $_ -in 'PeEntriesDir', 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } {
|
|
if (-not (Test-Path -LiteralPath $d.Path)) {
|
|
if ($d.ContainsKey('Remove')) { return (& $result $true 'absent') }
|
|
return (& $result $false 'folder missing')
|
|
}
|
|
$state = if ($d.ContainsKey('Remove')) { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Remove }
|
|
else { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Mask $d.Mask -Type $d.Type }
|
|
$want = if ($d.ContainsKey('Remove')) { 'none' } else { '{0} 0x{1:x} ContainerInherit, ObjectInherit' -f $d.Type, $d.Mask }
|
|
return (& $result $state.Same "explicit worker ACE: $($state.Found) (want $want)")
|
|
}
|
|
'FirewallBlockOutbound' {
|
|
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
|
|
if (-not $rule) { return (& $result $false "rule '$($d.Name)' missing") }
|
|
$app = ($rule | Get-NetFirewallApplicationFilter).Program
|
|
$ok = "$($rule.Direction)" -eq 'Outbound' -and "$($rule.Action)" -eq 'Block' -and "$($rule.Enabled)" -eq 'True' -and
|
|
"$($rule.Profile)" -eq 'Any' -and $app -ieq $d.Program
|
|
if ($ok -and -not (Test-Path -LiteralPath $d.Program)) { return (& $result $false "rule ok but $($d.Program) is missing") }
|
|
return (& $result $ok "$($rule.Direction)/$($rule.Action)/$($rule.Profile)/enabled=$($rule.Enabled) program=$app")
|
|
}
|
|
'HostServiceEnv' {
|
|
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)"
|
|
if (-not (Test-Path $key)) { return (& $result $false "service '$($d.Service)' not installed") }
|
|
$current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ })
|
|
$missing = @(foreach ($k in $d.Set.Keys) { if ($current -notcontains "$k=$($d.Set[$k])") { $k } })
|
|
return (& $result ($missing.Count -eq 0) $(if ($missing.Count) { "missing or different: $($missing -join ', ')" } else { 'both entries present' }))
|
|
}
|
|
default { throw "unknown plan step '$($Step.Id)'" }
|
|
}
|
|
}
|
|
|
|
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 folders (script-worker\, the PE cache, ...) are not walked.
|
|
|
|
.NOTES
|
|
lmxopcua#905: this walk was the whole cost of a swap's stopped window (vd03 denies all of E:\ApiInstall). It used
|
|
Get-ChildItem -Recurse + Get-Acl per item + a pipeline per item for $Skip: about 3.4 ms an item on Windows
|
|
PowerShell 5.1 (59 s for 17.5k items on windev). It now enumerates with .NET, prunes $Skip folders instead of
|
|
filtering every descendant, and reads only the DACL (GetAccessControl(Access): no owner/group lookup, no PS
|
|
wrapping), about 0.15 ms an item. Reparse-point folders (junctions, symlinks) are not followed: the target keeps
|
|
its own DACL, and following them can loop.
|
|
#>
|
|
param([Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$WorkerSid, [string[]]$Skip = @())
|
|
$start = $Path.TrimEnd('\')
|
|
if (-not [System.IO.Directory]::Exists($start)) { return }
|
|
$skipList = @($Skip | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') })
|
|
foreach ($s in $skipList) { if (Test-OtOpcUaPathWithin -Path $start -Ancestor $s) { return } }
|
|
$identity = [System.Security.Principal.SecurityIdentifier]::new($WorkerSid)
|
|
$core = $PSVersionTable.PSEdition -eq 'Core' # #800: FileSystemInfo.GetAccessControl does not exist on .NET (PS 7)
|
|
$sections = [System.Security.AccessControl.AccessControlSections]::Access
|
|
$dirAttr = [System.IO.FileAttributes]::Directory
|
|
$reparse = [System.IO.FileAttributes]::ReparsePoint
|
|
$stack = [System.Collections.Generic.Stack[System.IO.DirectoryInfo]]::new()
|
|
$stack.Push([System.IO.DirectoryInfo]::new($start))
|
|
while ($stack.Count -gt 0) {
|
|
$dir = $stack.Pop()
|
|
$entries = $null
|
|
try { $entries = @($dir.EnumerateFileSystemInfos()) } catch { $entries = @() } # unreadable folder: skip, as before
|
|
foreach ($item in $entries) {
|
|
if (($item.Attributes -band $dirAttr) -ne 0) {
|
|
$skipped = $false
|
|
foreach ($s in $skipList) { if ($item.FullName -ieq $s) { $skipped = $true; break } }
|
|
if ($skipped) { continue }
|
|
if (($item.Attributes -band $reparse) -eq 0) { $stack.Push($item) }
|
|
}
|
|
$acl = $null
|
|
try {
|
|
$acl = if ($core) { [System.IO.FileSystemAclExtensions]::GetAccessControl($item, $sections) }
|
|
else { $item.GetAccessControl($sections) }
|
|
}
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-OtOpcUaStepInInstallRoot {
|
|
# lmxopcua#905: true when a plan step's state lives in the install folder, so a folder swap can lose it: a folder ACE
|
|
# on InstallRoot or below (WorkerFolderAcl, InstallRootDeny, a -DenyPaths folder inside it such as vd03's pki\).
|
|
# Everything else (the anchor service, the records, the PE cache, folders outside, the host env, the firewall rule) is
|
|
# untouched by a swap. The firewall rule names the worker exe by path, and that path does not change; its check is
|
|
# Outside because Get-NetFirewallRule + its application filter cost about 3-5 s a run (windev), which the stopped
|
|
# window should not pay. Invoke-OtOpcUaInstallSwap.ps1 checks that the candidate carries the worker exe before the stop.
|
|
# Pure.
|
|
param([Parameter(Mandatory)] $Step, [Parameter(Mandatory)] [string]$InstallRoot)
|
|
$d = $Step.Data
|
|
if ($d -is [System.Collections.IDictionary] -and $d.Contains('Path') -and $d['Path']) {
|
|
return [bool](Test-OtOpcUaPathWithin -Path ([string]$d['Path']) -Ancestor $InstallRoot)
|
|
}
|
|
$false
|
|
}
|
|
|
|
function Select-ScriptWorkerIsolationPlanScope {
|
|
<#
|
|
.SYNOPSIS
|
|
lmxopcua#905: the part of a plan a -Scope covers. 'InstallRoot' = the steps a folder swap can undo (see
|
|
Test-OtOpcUaStepInInstallRoot); 'Outside' = all the others; 'All' = the plan unchanged. InstallRoot and Outside
|
|
partition the plan, so an Outside run before the service stops plus an InstallRoot run after the swap cover every
|
|
step. Pure.
|
|
#>
|
|
param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Plan, [Parameter(Mandatory)] [string]$InstallRoot,
|
|
[ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All')
|
|
if ($Scope -eq 'All') { return $Plan }
|
|
$wantIn = $Scope -eq 'InstallRoot'
|
|
foreach ($step in $Plan) { if ((Test-OtOpcUaStepInInstallRoot -Step $step -InstallRoot $InstallRoot) -eq $wantIn) { $step } }
|
|
}
|
|
|
|
function Get-OtOpcUaCoverageWalkRoots {
|
|
<#
|
|
.SYNOPSIS
|
|
lmxopcua#905: which folders the protected-DACL coverage scan walks for a -Scope, and what it must skip besides
|
|
script-worker\ and the PE cache. Only the outermost folders are walked: a denied folder inside another one
|
|
(InstallRoot or pki\ inside E:\ApiInstall on vd03) was walked a second (and third) time before. 'InstallRoot'
|
|
walks InstallRoot alone; 'Outside' walks the denied folders not inside InstallRoot and skips InstallRoot; 'All'
|
|
walks both. Pure. Returns [pscustomobject]@{ Roots; Skip }.
|
|
#>
|
|
param([Parameter(Mandatory)] [string]$InstallRoot, [string[]]$DeniedPaths = @(),
|
|
[ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All')
|
|
$root = $InstallRoot.TrimEnd('\')
|
|
$denied = @($DeniedPaths | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') })
|
|
$candidates = switch ($Scope) {
|
|
'InstallRoot' { @($root) }
|
|
'Outside' { @($denied | Where-Object { -not (Test-OtOpcUaPathWithin -Path $_ -Ancestor $root) }) }
|
|
default { @(@($root) + $denied) }
|
|
}
|
|
$outer = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($c in $candidates) {
|
|
$nested = @($candidates | Where-Object { $_ -ine $c -and (Test-OtOpcUaPathWithin -Path $c -Ancestor $_) }).Count -gt 0
|
|
if (-not $nested -and -not ($outer | Where-Object { $_ -ieq $c })) { $outer.Add($c) }
|
|
}
|
|
[pscustomobject]@{ Roots = $outer.ToArray(); Skip = $(if ($Scope -eq 'Outside') { @($root) } else { @() }) }
|
|
}
|
|
|
|
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
|
|
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan,
|
|
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap, Get-OtOpcUaIsolationSettings, Test-OtOpcUaPathWithin,
|
|
Test-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl, Test-OtOpcUaStepInInstallRoot, Select-ScriptWorkerIsolationPlanScope,
|
|
Get-OtOpcUaCoverageWalkRoots
|