mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-25 14:38:17 +01:00
Keep deferred configuration callbacks in script scope on PowerShell 5.1
This commit is contained in:
1 parent
595f123327
commit
4c2193964e
3 files changed
+120
-41
No files matched your search
@@ -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
|
||||
+55
-41
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user