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:
@@ -1,6 +1,8 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883): apply -> re-apply -> verify -> uninstall -> uninstall.
|
||||
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890): apply -> re-apply -> verify -> shorter
|
||||
-DenyPaths -> uninstall (from the record, without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
|
||||
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
|
||||
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
|
||||
DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1.
|
||||
Run it once under EACH host:
|
||||
@@ -38,6 +40,7 @@ function Assert([bool]$Condition, [string]$Message) {
|
||||
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
|
||||
}
|
||||
function Invoke-Installer([string[]]$Extra) {
|
||||
# $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits.
|
||||
# Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'.
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount 'NT AUTHORITY\SYSTEM' `
|
||||
@@ -53,27 +56,30 @@ function Get-Explicit([string]$Path, [string]$Sid) {
|
||||
$scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$root = Join-Path $scratch 'install'
|
||||
$cache = Join-Path $scratch 'pe-cache'
|
||||
$deny = @($scratch, (Join-Path $scratch 'pki'), (Join-Path $scratch 'data')) # lmxopcua#890: the deployment folder + keys + secrets
|
||||
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
|
||||
$workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
|
||||
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
|
||||
$hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName"
|
||||
try {
|
||||
New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null
|
||||
Set-Content -LiteralPath $workerExe -Value 'stub'
|
||||
foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||
& sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" }
|
||||
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
|
||||
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch"
|
||||
|
||||
Write-Host 'Dry run'
|
||||
$dry = Invoke-Installer @('-WhatIf')
|
||||
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
|
||||
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
|
||||
Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and
|
||||
-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing'
|
||||
|
||||
Write-Host 'Apply'
|
||||
$a = Invoke-Installer @()
|
||||
$a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
||||
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
|
||||
Assert ($a.Steps.Count -eq 7 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 7 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ($a.Steps.Count -eq 11 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 11 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled'
|
||||
Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid'
|
||||
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)'
|
||||
@@ -81,9 +87,17 @@ try {
|
||||
Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on <PeCacheDir>\pe'
|
||||
Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root'
|
||||
Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected'
|
||||
$deny = @(Get-Explicit $root $workerSid)
|
||||
Assert ($deny.Count -eq 1 -and $deny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
|
||||
$rootDeny = @(Get-Explicit $root $workerSid)
|
||||
Assert ($rootDeny.Count -eq 1 -and $rootDeny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
|
||||
Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\'
|
||||
foreach ($d in $deny) {
|
||||
$dd = @(Get-Explicit $d $workerSid)
|
||||
Assert ($dd.Count -eq 1 -and $dd[0].AccessControlType -eq 'Deny' -and $dd[0].InheritanceFlags -eq 'ContainerInherit, ObjectInherit') "worker has one inherited Deny ACE on $d"
|
||||
}
|
||||
Assert (((Get-ItemProperty -Path $recordKey -Name DenyPaths).DenyPaths -join '|') -eq ($deny -join '|')) 'the denied folders are recorded under the anchor key'
|
||||
$hostDeny = @((Get-Acl -LiteralPath $scratch).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
|
||||
Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference.Value -ne $workerSid })
|
||||
Assert ($hostDeny.Count -eq 0) 'no deny for anyone but the worker identity (the host account is unaffected)'
|
||||
$fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
|
||||
Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe'
|
||||
$env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
|
||||
@@ -91,24 +105,35 @@ try {
|
||||
(@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others'
|
||||
|
||||
Write-Host 'Re-apply (idempotent)'
|
||||
$b = Invoke-Installer @()
|
||||
$b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
||||
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
|
||||
Assert ($b.Steps.Count -eq 7 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 7 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ($b.Steps.Count -eq 11 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 11 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
|
||||
Write-Host 'Uninstall, twice'
|
||||
Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay'
|
||||
$c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ','))
|
||||
Assert ($c.Exit -eq 0) "shorter re-apply exits 0 ($($c.Output))"
|
||||
$changedIds = @($c.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ','
|
||||
Assert ($changedIds -eq 'SensitiveDirDeny,DenyPathRecord') "only the stale deny and the record change ($changedIds)"
|
||||
Assert (@(Get-Explicit $deny[2] $workerSid).Count -eq 0 -and @(Get-Explicit $deny[1] $workerSid).Count -eq 1) 'data\ un-denied, pki\ still denied'
|
||||
$c2 = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
||||
Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only'
|
||||
|
||||
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
|
||||
$u1 = Invoke-Installer @('-Uninstall')
|
||||
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
|
||||
Assert ($u1.Steps.Count -eq 6 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 6 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ($u1.Steps.Count -eq 10 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 10 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed'
|
||||
Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and
|
||||
@(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0) 'no explicit worker ACEs remain'
|
||||
@(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0 -and
|
||||
@($deny | Where-Object { @(Get-Explicit $_ $workerSid).Count -ne 0 }).Count -eq 0) 'no explicit worker ACEs remain, on the denied folders either'
|
||||
Assert (-not (Test-Path $recordKey)) 'the record is gone'
|
||||
Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache'
|
||||
$env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
|
||||
Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))"
|
||||
Start-Sleep -Milliseconds 500
|
||||
$u2 = Invoke-Installer @('-Uninstall')
|
||||
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
|
||||
Assert ($u2.Steps.Count -eq 6 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 6 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
}
|
||||
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
|
||||
finally {
|
||||
|
||||
@@ -18,6 +18,7 @@ $script:passes = 0
|
||||
function Assert([bool]$Condition, [string]$Message) {
|
||||
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
|
||||
}
|
||||
function Join-Targets($Steps) { (@($Steps | ForEach-Object { $_.Target }) -join '|') } # StrictMode-safe on an empty list
|
||||
function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) {
|
||||
try { & $Block; Assert $false "$Message (did not throw)" }
|
||||
catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" }
|
||||
@@ -38,7 +39,7 @@ Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a spac
|
||||
Write-Host 'Install plan'
|
||||
$plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService)
|
||||
$ids = $plan.Id -join ','
|
||||
Assert ($ids -eq 'AnchorService,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
|
||||
Assert ($ids -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
|
||||
$byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s }
|
||||
|
||||
$anchor = $byId['AnchorService'].Data
|
||||
@@ -65,8 +66,18 @@ Assert ($wf.Ace -eq "(A;OICI;0x1200a9;;;$workerSid)") "worker gets read+execute
|
||||
Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))"
|
||||
|
||||
$deny = $byId['InstallRootDeny'].Data
|
||||
Assert ($deny.Ace -eq "(D;OICI;0x1;;;$workerSid)") "install root DENIES the worker ReadData ($($deny.Ace))"
|
||||
Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root) 'install-root ACE is a deny on the root'
|
||||
Assert ($deny.Ace -eq "(D;OICI;0xd0157;;;$workerSid)") "install root DENIES the worker read and write, inherited ($($deny.Ace))"
|
||||
Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root -and $deny.Mask -eq 0xD0157) 'install-root ACE is a deny on the root'
|
||||
# lmxopcua#890: the bits that matter, and the ones that must stay off so the worker can still reach script-worker\.
|
||||
foreach ($bit in @{ ReadData = 0x1; WriteData = 0x2; AppendData = 0x4; WriteAttributes = 0x100; Delete = 0x10000; DeleteChild = 0x40; WriteDac = 0x40000 }.GetEnumerator()) {
|
||||
Assert (($deny.Mask -band $bit.Value) -ne 0) "the deny includes $($bit.Key)"
|
||||
}
|
||||
foreach ($bit in @{ TraverseExecute = 0x20; ReadAttributes = 0x80; ReadPermissions = 0x20000; Synchronize = 0x100000 }.GetEnumerator()) {
|
||||
Assert (($deny.Mask -band $bit.Value) -eq 0) "the deny leaves $($bit.Key) alone (the loader walks through denied folders)"
|
||||
}
|
||||
$rec = $byId['DenyPathRecord'].Data
|
||||
Assert ($rec.Key -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters' -and $rec.Name -eq 'DenyPaths' -and @($rec.Paths).Count -eq 0) `
|
||||
"without -DenyPaths the record is empty, under the anchor service's key ($($rec.Key))"
|
||||
|
||||
$fw = $byId['FirewallBlockOutbound'].Data
|
||||
Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))"
|
||||
@@ -85,12 +96,60 @@ Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostS
|
||||
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused'
|
||||
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused'
|
||||
$sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache')
|
||||
Assert ($sibling.Count -eq 6) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
|
||||
Assert ($sibling.Count -eq 7) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
|
||||
|
||||
Write-Host 'Sensitive siblings: -DenyPaths (lmxopcua#890, the wonder layout)'
|
||||
$qaRoot = 'E:\ApiInstall\OtOpcUa'
|
||||
$qaApp = "$qaRoot\app"
|
||||
$qaDeny = @($qaRoot, "$qaRoot\pki", "$qaRoot\data")
|
||||
$qa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -ConfigureHostService)
|
||||
$qaIds = $qa.Id -join ','
|
||||
Assert ($qaIds -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') `
|
||||
"QA plan order ($qaIds)"
|
||||
$sens = @($qa | Where-Object Id -eq 'SensitiveDirDeny')
|
||||
Assert ((Join-Targets $sens) -eq ($qaDeny -join '|')) "one deny per folder, in the given order ($(Join-Targets $sens))"
|
||||
foreach ($s in $sens) {
|
||||
Assert ($s.Data.Ace -eq "(D;OICI;0xd0157;;;$workerSid)" -and $s.Data.Type -eq 'Deny' -and $s.Data.Sid -eq $workerSid -and $s.Data.Mask -eq 0xD0157) `
|
||||
"$($s.Target): the worker identity is denied read and write, inherited ($($s.Data.Ace))"
|
||||
}
|
||||
Assert ((@(($qa | Where-Object Id -eq 'DenyPathRecord').Data.Paths) -join '|') -eq ($qaDeny -join '|')) 'the record lists exactly the denied folders'
|
||||
# The host account is never denied anything: every deny ACE names the worker identity alone.
|
||||
$allDeny = @($qa | Where-Object { $_.Data.ContainsKey('Type') -and $_.Data.Type -eq 'Deny' })
|
||||
Assert ($allDeny.Count -eq 4 -and @($allDeny | Where-Object { $_.Data.Sid -ne $workerSid }).Count -eq 0) "every deny ACE is the worker identity's ($($allDeny.Count) denies)"
|
||||
$asUser = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid $hostSid -PeCacheDir $cache -DenyPaths $qaDeny)
|
||||
Assert (@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like "(D;*;;;$hostSid)" }).Count -eq 0 -and
|
||||
@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like '(D;*' }).Count -eq 4) 'a domain host account is never the subject of a deny'
|
||||
# script-worker\ keeps its allow, and the PE cache is untouched by the denies.
|
||||
Assert (($qa | Where-Object Id -eq 'WorkerFolderAcl').Data.Path -eq "$qaApp\script-worker") 'script-worker\ allow still planned under app\'
|
||||
Assert (@($sens | Where-Object { $_.Target -like "$cache*" }).Count -eq 0) 'no deny on the PE cache'
|
||||
|
||||
$norm = @(Resolve-OtOpcUaDenyPaths -Paths @("$qaRoot\pki\,$qaRoot\PKI;$qaRoot\data", '', $qaApp, "$qaRoot\") -InstallRoot $qaApp -PeCacheDir $cache)
|
||||
Assert (($norm -join '|') -eq "$qaRoot\pki|$qaRoot\data|$qaRoot") "comma/semicolon split, trailing '\' trimmed, case-insensitive de-dup, InstallRoot dropped ($($norm -join '|'))"
|
||||
$viaString = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ','))
|
||||
Assert (@($viaString | Where-Object Id -eq 'SensitiveDirDeny').Count -eq 3) 'a single comma-separated string (powershell -File) gives the same three denies'
|
||||
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'E:\' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a drive root is refused'
|
||||
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths '\\srv\share' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a share root is refused'
|
||||
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\script-worker" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'script-worker\ itself is refused'
|
||||
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\SCRIPT-WORKER\runtimes" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'a folder inside script-worker\ is refused'
|
||||
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$cache\pe" -InstallRoot $qaApp -PeCacheDir $cache } '*PeCacheDir*' 'the PE cache (pe\) is refused'
|
||||
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'pki' -InstallRoot $qaApp -PeCacheDir $cache } '*absolute*' 'a relative path is refused'
|
||||
$okAncestor = @(Resolve-OtOpcUaDenyPaths -Paths @('E:\ApiInstall', "$qaApp-old\script-worker") -InstallRoot $qaApp -PeCacheDir $cache)
|
||||
Assert ($okAncestor.Count -eq 2) 'an ancestor of script-worker\ and a prefix-sharing sibling are accepted (positive control)'
|
||||
|
||||
Write-Host 'Re-run with a shorter list: the dropped folder loses its deny (lmxopcua#890)'
|
||||
$rerun = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths @($qaRoot, "$qaRoot\pki") `
|
||||
-RecordedDenyPaths @($qaRoot, "$qaRoot\pki", "$qaRoot\data\"))
|
||||
$staleSteps = @($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and $_.Data.ContainsKey('Remove') })
|
||||
Assert ((Join-Targets $staleSteps) -eq "$qaRoot\data") "only the dropped folder is un-denied ($(Join-Targets $staleSteps))"
|
||||
Assert ([array]::IndexOf(@($rerun.Id), 'SensitiveDirDeny') -lt [array]::IndexOf(@($rerun.Id), 'DenyPathRecord')) 'the stale removal runs before the record is rewritten'
|
||||
Assert (@($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and -not $_.Data.ContainsKey('Remove') }).Count -eq 2) 'the two kept folders keep their deny'
|
||||
$same = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -RecordedDenyPaths $qaDeny)
|
||||
Assert (@($same | Where-Object { $_.Data.ContainsKey('Remove') }).Count -eq 0) 'an unchanged list plans no removal (idempotent re-run)'
|
||||
|
||||
Write-Host 'Uninstall plan'
|
||||
$un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall)
|
||||
$unIds = $un.Id -join ','
|
||||
Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,AnchorService') "rollback order ($unIds)"
|
||||
Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') "rollback order ($unIds)"
|
||||
Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes'
|
||||
Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) `
|
||||
'rollback reverses every install step but the cache (kept by default)'
|
||||
@@ -98,6 +157,16 @@ Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scri
|
||||
foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) {
|
||||
Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs"
|
||||
}
|
||||
# lmxopcua#890: -Uninstall removes the deny from every recorded folder, plus any passed again, once each.
|
||||
$unQa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -Uninstall `
|
||||
-DenyPaths @("$qaRoot\PKI") -RecordedDenyPaths $qaDeny)
|
||||
$unSens = @($unQa | Where-Object Id -eq 'SensitiveDirDeny')
|
||||
Assert ($unSens.Count -eq 3 -and @($unSens | Where-Object { -not $_.Data.ContainsKey('Remove') -or $_.Data.Sid -ne $workerSid }).Count -eq 0) `
|
||||
"rollback un-denies each recorded folder once, worker ACEs only ($(Join-Targets $unSens))"
|
||||
Assert ((@(@($unSens | ForEach-Object { $_.Target }) | Sort-Object) -join '|') -eq (@($qaDeny | Sort-Object) -join '|')) 'rollback covers exactly the recorded folders'
|
||||
Assert ((@($unQa.Id) -join ',') -eq 'FirewallBlockOutbound,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') `
|
||||
"QA rollback order ($(@($unQa.Id) -join ','))"
|
||||
Assert (($unQa | Where-Object Id -eq 'DenyPathRecord').Data.ContainsKey('Remove')) 'rollback deletes the record'
|
||||
$unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache)
|
||||
Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it'
|
||||
|
||||
@@ -109,13 +178,15 @@ Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount 'OTOPCUA\svc' -P
|
||||
'-PlanOnly refuses an account name (no lookup without the OS)'
|
||||
$defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null)
|
||||
Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa'
|
||||
$qaEntry = @(& $entry -InstallRoot $qaApp -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',') -ConfigureHostService -PlanOnly 6>$null)
|
||||
Assert ((@($qaEntry.Id) -join ',') -eq $qaIds) "-PlanOnly -DenyPaths 'a,b,c' (one string, as powershell -File passes it) plans the same steps"
|
||||
|
||||
if ($IsWindows) {
|
||||
Write-Host 'SDDL parses on Windows'
|
||||
$ds = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$ds.SetSecurityDescriptorSddlForm($sddl)
|
||||
Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected'
|
||||
foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace) {
|
||||
foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace, $sens[0].Data.Ace) {
|
||||
$one = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$one.SetSecurityDescriptorSddlForm("D:$ace")
|
||||
Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses"
|
||||
|
||||
Reference in New Issue
Block a user