From 4c2193964e11d16fd142ab4a1175e8163e47221e Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:13:58 +0900 Subject: [PATCH] Keep deferred configuration callbacks in script scope on PowerShell 5.1 --- .github/workflows/configuration-results.yml | 12 +++ scripts/Configuration.ps1 | 96 +++++++++++-------- .../Test-ConfigurationScriptScopeWindows.ps1 | 53 ++++++++++ 3 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 tests/Test-ConfigurationScriptScopeWindows.ps1 diff --git a/.github/workflows/configuration-results.yml b/.github/workflows/configuration-results.yml index ef3aac70..76e1de6d 100644 --- a/.github/workflows/configuration-results.yml +++ b/.github/workflows/configuration-results.yml @@ -11,6 +11,12 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Actual script-scope helpers with disposable HKCU key in Windows PowerShell 5.1 + shell: powershell + run: ./tests/Test-ConfigurationScriptScopeWindows.ps1 + - name: Actual script-scope helpers with disposable HKCU key in PowerShell 7 + shell: pwsh + run: ./tests/Test-ConfigurationScriptScopeWindows.ps1 - name: Mocked regressions in Windows PowerShell 5.1 shell: powershell run: ./tests/Test-ConfigurationResults.ps1 @@ -35,3 +41,9 @@ jobs: - name: Profile integration in PowerShell 7 shell: pwsh run: ./tests/IntegrationProfileConfiguration.Tests.ps1 + - name: Dry-run and NTLM race safety in Windows PowerShell 5.1 + shell: powershell + run: ./tests/IntegrationSafety.Tests.ps1 + - name: Dry-run and NTLM race safety in PowerShell 7 + shell: pwsh + run: ./tests/IntegrationSafety.Tests.ps1 diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 5816c294..eb49793f 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -36,17 +36,17 @@ function New-WelaConfigurationContext { function Invoke-WelaConfigurationControl { param($Context, [string]$Id, [string]$Kind, $Target, $Desired, [scriptblock]$Read, [scriptblock]$Compliant, [scriptblock]$Apply, - [string]$Description = '', [scriptblock]$PreserveWhen) + [string]$Description = '', [scriptblock]$PreserveWhen, $CallbackState) $result = [pscustomobject][ordered]@{ Id = $Id; Kind = $Kind; Target = $Target; Desired = $Desired Before = $null; After = $null; Status = 'Failed'; Diagnostic = '' } try { - $result.Before = & $Read + $result.Before = & $Read $CallbackState $preserveReason = if ($PreserveWhen) { & $PreserveWhen $result.Before } else { $null } if ($preserveReason) { $result.Status = 'Skipped'; $result.After = $result.Before; $result.Diagnostic = [string]$preserveReason - } elseif (& $Compliant $result.Before) { + } elseif (& $Compliant $result.Before $CallbackState) { $result.Status = 'AlreadyCompliant' $result.After = $result.Before } elseif ($Context.DryRun) { @@ -70,19 +70,19 @@ function Invoke-WelaConfigurationControl { } $entry | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $Context.BackupPath 'before.jsonl') -Encoding UTF8 -ErrorAction Stop - $applied = @(& $Apply) + $applied = @(& $Apply $CallbackState) $result.Diagnostic = ($applied | ForEach-Object { if ($_.PSObject.Properties['Diagnostic']) { $_.Diagnostic } else { $_.ToString() } }) -join [Environment]::NewLine - $result.After = & $Read - if (-not (& $Compliant $result.After)) { + $result.After = & $Read $CallbackState + if (-not (& $Compliant $result.After $CallbackState)) { throw "Post-apply verification did not match the requested state. $($result.Diagnostic)" } $result.Status = 'Applied' } } if ($result.Status -in @('Applied', 'AlreadyCompliant')) { - $Context.Checks.Add([pscustomobject]@{ Result = $result; Read = $Read; Compliant = $Compliant }) + $Context.Checks.Add([pscustomobject]@{ Result = $result; Read = $Read; Compliant = $Compliant; CallbackState = $CallbackState }) } } catch { $result.Status = 'Failed'; $result.Diagnostic = $_.ToString() @@ -100,8 +100,8 @@ function Complete-WelaConfiguration { # this run. It does not establish whether GPO or another writer caused drift. foreach ($check in $Context.Checks) { try { - $check.Result.After = & $check.Read - if (-not (& $check.Compliant $check.Result.After)) { + $check.Result.After = & $check.Read $check.CallbackState + if (-not (& $check.Compliant $check.Result.After $check.CallbackState)) { $check.Result.Status = 'Overridden' $check.Result.Diagnostic = 'State was compliant earlier but changed before the final check; cause unknown.' } @@ -139,14 +139,22 @@ function Complete-WelaConfiguration { function Set-WelaEventLogControl { param($Context, [string]$Log, [string]$Property, $Desired) - $read = { (Get-WinEvent -ListLog $Log -ErrorAction Stop).$Property }.GetNewClosure() - $test = if ($Property -eq 'MaximumSizeInBytes') { - { param($value) $value -ge $Desired }.GetNewClosure() - } else { { param($value) $value -eq $Desired }.GetNewClosure() } - $argument = if ($Property -eq 'MaximumSizeInBytes') { "/ms:$Desired" } else { '/e:true' } - $apply = { Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments @('sl', $Log, $argument) }.GetNewClosure() + $state = @{ Log = $Log; Property = $Property; Desired = $Desired } + # Explicit callback state preserves values for the final recheck without + # GetNewClosure's dynamic-module scope, which hides script-local helpers in 5.1. + $read = { param($state) (Get-WinEvent -ListLog $state.Log -ErrorAction Stop).($state.Property) } + $test = { + param($value, $state) + if ($state.Property -eq 'MaximumSizeInBytes') { return $value -ge $state.Desired } + return $value -eq $state.Desired + } + $apply = { + param($state) + $argument = if ($state.Property -eq 'MaximumSizeInBytes') { "/ms:$($state.Desired)" } else { '/e:true' } + Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments @('sl', $state.Log, $argument) + } Invoke-WelaConfigurationControl -Context $Context -Id "EventLog/$Log/$Property" -Kind EventLog ` - -Target @{ Log = $Log; Property = $Property } -Desired $Desired -Read $read -Compliant $test -Apply $apply + -Target @{ Log = $Log; Property = $Property } -Desired $Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state } function Get-WelaRegistryState { @@ -179,21 +187,23 @@ function New-WelaRegistryKey { function Set-WelaRegistryControl { param($Context, [string]$Path, [string]$Name, $Value, [string]$Type = 'DWord', [scriptblock]$PreserveWhen) - $read = { Get-WelaRegistryState -Path $Path -Name $Name }.GetNewClosure() - $test = { param($state) $state.ValueExists -and $state.Value -eq $Value -and $state.Type -eq $Type }.GetNewClosure() + $state = @{ Path = $Path; Name = $Name; Value = $Value; Type = $Type; PreserveWhen = $PreserveWhen } + $read = { param($state) Get-WelaRegistryState -Path $state.Path -Name $state.Name } + $test = { param($value, $state) $value.ValueExists -and $value.Value -eq $state.Value -and $value.Type -eq $state.Type } $apply = { - New-WelaRegistryKey -Path $Path - if ($PreserveWhen) { + param($state) + New-WelaRegistryKey -Path $state.Path + if ($state.PreserveWhen) { # Recheck after the prompt and journal, immediately before the value write. - $fresh = Get-WelaRegistryState -Path $Path -Name $Name - $preserveReason = & $PreserveWhen $fresh + $fresh = Get-WelaRegistryState -Path $state.Path -Name $state.Name + $preserveReason = & $state.PreserveWhen $fresh if ($preserveReason) { throw "Refused registry write after state changed: $preserveReason" } } - Set-ItemProperty -LiteralPath $Path -Name $Name -Value $Value -Type $Type -ErrorAction Stop - }.GetNewClosure() + Set-ItemProperty -LiteralPath $state.Path -Name $state.Name -Value $state.Value -Type $state.Type -ErrorAction Stop + } Invoke-WelaConfigurationControl -Context $Context -Id "Registry/$Path/$Name" -Kind Registry ` -Target @{ Path = $Path; Name = $Name } -Desired @{ Value = $Value; Type = $Type } ` - -Read $read -Compliant $test -Apply $apply -PreserveWhen $PreserveWhen + -Read $read -Compliant $test -Apply $apply -PreserveWhen $PreserveWhen -CallbackState $state } function Initialize-WelaConfigurationAuditApi { @@ -255,27 +265,29 @@ function Set-WelaAuditPolicyControl { param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3, [ValidateSet('exact', 'minimum')][string]$Mode = 'exact') $guid = $Policy.GUID - $read = { Get-WelaAuditPolicyMask -Guid $guid }.GetNewClosure() + $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode } + $read = { param($state) Get-WelaAuditPolicyMask -Guid $state.Guid } $test = { - param($value) - if ($Mode -eq 'minimum') { return ($value -band $Mask) -eq $Mask } - return $value -eq $Mask - }.GetNewClosure() + param($value, $state) + if ($state.Mode -eq 'minimum') { return ($value -band $state.Mask) -eq $state.Mask } + return $value -eq $state.Mask + } $apply = { - $arguments = @('/set', "/subcategory:{$guid}") - if ($Mode -eq 'minimum') { + param($state) + $arguments = @('/set', "/subcategory:{$($state.Guid)}") + if ($state.Mode -eq 'minimum') { # Only enable required flags: never disable another writer's added flag. - if ($Mask -band 1) { $arguments += '/success:enable' } - if ($Mask -band 2) { $arguments += '/failure:enable' } + if ($state.Mask -band 1) { $arguments += '/success:enable' } + if ($state.Mask -band 2) { $arguments += '/failure:enable' } } else { - $success = if ($Mask -band 1) { 'enable' } else { 'disable' } - $failure = if ($Mask -band 2) { 'enable' } else { 'disable' } + $success = if ($state.Mask -band 1) { 'enable' } else { 'disable' } + $failure = if ($state.Mask -band 2) { 'enable' } else { 'disable' } $arguments += "/success:$success", "/failure:$failure" } Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments - }.GetNewClosure() + } Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy ` - -Target @{ Guid = $guid } -Desired @{ Mask = $Mask; Mode = $Mode } -Read $read -Compliant $test -Apply $apply + -Target @{ Guid = $guid } -Desired @{ Mask = $Mask; Mode = $Mode } -Read $read -Compliant $test -Apply $apply -CallbackState $state } function Set-WelaProfileAuditControls { @@ -310,12 +322,14 @@ function Set-WelaCertificateAuditControl { $caName = (Get-ItemProperty -LiteralPath $root -Name Active -ErrorAction Stop).Active if (-not $caName) { throw 'CA configuration has no active CA name.' } $path = Join-Path $root $caName + $state = @{ Path = $path } $read = { + param($state) [pscustomobject]@{ - Registry = Get-WelaRegistryState -Path $path -Name AuditFilter + Registry = Get-WelaRegistryState -Path $state.Path -Name AuditFilter ServiceStatus = (Get-Service -Name CertSvc -ErrorAction Stop).Status.ToString() } - }.GetNewClosure() + } $test = { param($value) $value.Registry.ValueExists -and $value.Registry.Value -eq 127 -and $value.Registry.Type -eq 'DWord' -and $value.ServiceStatus -eq 'Running' } $apply = { $state = Get-Service -Name CertSvc -ErrorAction Stop @@ -327,7 +341,7 @@ function Set-WelaCertificateAuditControl { } Invoke-WelaConfigurationControl -Context $Context -Id 'ADCS/AuditFilter' -Kind CertificateService ` -Target @{ Path = $path; Name = 'AuditFilter'; Service = 'CertSvc' } -Desired 127 ` - -Read $read -Compliant $test -Apply $apply -Description 'Set AuditFilter=127 and restart Certificate Services.' + -Read $read -Compliant $test -Apply $apply -Description 'Set AuditFilter=127 and restart Certificate Services.' -CallbackState $state } catch { $Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Failed'; Diagnostic = $_.ToString() }) } diff --git a/tests/Test-ConfigurationScriptScopeWindows.ps1 b/tests/Test-ConfigurationScriptScopeWindows.ps1 new file mode 100644 index 00000000..24e22e93 --- /dev/null +++ b/tests/Test-ConfigurationScriptScopeWindows.ps1 @@ -0,0 +1,53 @@ +# Actual script-scope helpers and Windows registry provider. Only a unique test +# key under HKCU and a temporary journal are written; no Windows logging changes. +$ErrorActionPreference = 'Stop' +if ($env:OS -ne 'Windows_NT') { throw 'Run this test on Windows.' } +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +. (Join-Path $repo 'scripts/Configuration.ps1') +# Deliberately keep all helper functions script-local, as in WELA.ps1 -File. +# Do not promote helpers or replace their calls with global mocks. +$token = [guid]::NewGuid().ToString('N') +$key = "HKCU:\Software\WELA-Configuration-Scope-$token" +$backup = Join-Path ([IO.Path]::GetTempPath()) "wela-script-scope-$token" +$ownsTestArtifacts = $false +function Assert-Scope($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" } +} +function Add-ScopeControls($Context, [string]$Root) { + Set-WelaRegistryControl -Context $Context -Path "$Root\ParentA\Child" -Name Counter -Value 42 + Set-WelaRegistryControl -Context $Context -Path "$Root\ParentB\Child" -Name Label -Value 'second control' -Type String +} +try { + if ((Test-Path -LiteralPath $key) -or (Test-Path -LiteralPath $backup)) { throw 'Unique test artifact unexpectedly exists; refusing to use it.' } + $ownsTestArtifacts = $true + $context = New-WelaConfigurationContext -Auto -BackupPath $backup + # These calls must resolve Get-WelaRegistryState and recursively resolve + # New-WelaRegistryKey from ordinary script scope, then use real provider APIs. + Add-ScopeControls -Context $context -Root $key + $report = Complete-WelaConfiguration -Context $context + Assert-Scope ($report.ExitCode -eq 0) 'Script-local registry helper chain resolves and verifies' + Assert-Scope (@($report.Results | Where-Object Status -eq Applied).Count -eq 2) 'Both distinct control states remain available after their calling function returns' + Assert-Scope ((Get-ItemProperty -LiteralPath "$key\ParentA\Child" -Name Counter).Counter -eq 42) 'Actual DWORD value was written and read back' + Assert-Scope ((Get-ItemProperty -LiteralPath "$key\ParentB\Child" -Name Label).Label -eq 'second control') 'Actual string value was written and read back' + $journal = @(Get-Content -LiteralPath (Join-Path $backup 'before.jsonl') | ConvertFrom-Json) + Assert-Scope ($journal.Count -eq 2 -and -not $journal[0].Before.KeyExists -and -not $journal[1].Before.KeyExists) 'Missing nested registry parents were journaled before creation' + Add-ScopeControls -Context $context -Root $key + $report = Complete-WelaConfiguration -Context $context + Assert-Scope ($report.ExitCode -eq 0 -and @($report.Results | Where-Object Status -eq AlreadyCompliant).Count -eq 2) 'Actual registry rerun is idempotent' + Assert-Scope (@(Get-Content -LiteralPath (Join-Path $backup 'before.jsonl')).Count -eq 2) 'Compliant rerun writes no additional mutation journal records' + + # Exercise deferred native-policy and event-log readers in the same script + # scope. DryRun prevents every native configuration or service mutation. + $dry = New-WelaConfigurationContext -Auto -DryRun + Set-WelaRegistryControl -Context $dry -Path "$key\ParentA\Child" -Name Counter -Value 99 + Set-WelaAuditPolicyControl -Context $dry -Policy @{ GUID = '0CCE922B-69AE-11D9-BED3-505054503030'; Name = 'Process Creation' } + Set-WelaEventLogControl -Context $dry -Log Security -Property MaximumSizeInBytes -Desired 1 + $dryReport = Complete-WelaConfiguration -Context $dry + Assert-Scope ($dryReport.ExitCode -eq 0) 'Deferred native API and event-log callbacks resolve script-local helpers' + Assert-Scope ((Get-ItemProperty -LiteralPath "$key\ParentA\Child" -Name Counter).Counter -eq 42) 'DryRun leaves the actual registry value unchanged' + Write-Host 'Script-scope Windows provider checks passed. Only a disposable HKCU test key and temp journal were changed.' +} finally { + if ($ownsTestArtifacts -and (Test-Path -LiteralPath $key)) { Remove-Item -LiteralPath $key -Recurse -Force -ErrorAction Stop } + if ($ownsTestArtifacts -and (Test-Path -LiteralPath $backup)) { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop } +}