From eb3232faf5762784b6229973a5f8fbdb6311a9fb Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:53:44 +0900 Subject: [PATCH] Read audit masks through Windows API and preserve missing registry parents --- docs/configuration-results.md | 11 +-- scripts/Configuration.ps1 | 77 +++++++++++++++++---- tests/Test-ConfigurationReadOnlyWindows.ps1 | 3 + tests/Test-ConfigurationResults.ps1 | 53 +++++++++++--- 4 files changed, 117 insertions(+), 27 deletions(-) diff --git a/docs/configuration-results.md b/docs/configuration-results.md index 63f787c8..419cf112 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -39,8 +39,10 @@ controls do not claim universal success. Verification is an observation at that moment; it does not prove future GPO persistence, event production, collection or Sigma rule coverage. A zero exit code with skipped controls is not full compliance. -Audit policy reads use GUIDs and the numeric value in `auditpol /r` output, rather -than localized setting names. Registry writes use terminating errors and verify +Audit policy reads use GUIDs and numeric flags from the Windows +[AuditQuerySystemPolicy API](https://learn.microsoft.com/en-us/windows/win32/api/ntsecapi/nf-ntsecapi-auditquerysystempolicy). +`auditpol /get /r` contains localized labels and no numeric setting column; it is +not parsed as though it were `auditpol /backup` output. Registry writes use terminating errors and verify both the value and registry type. Log sizes retain larger existing buffers. A CA is detected from its configured registry state; certutil must succeed before a restart is attempted, and the restart must return to Running. A stopped CA is not @@ -93,9 +95,8 @@ force a Group Policy setting. These are design constraints, not implemented clai `tests/Test-ConfigurationResults.ps1` uses mock Windows APIs and disposable temp journals. It exercises nonzero native exits and stderr, false-success writes, -read-back, idempotence, final drift, dry runs, journal failure, localized audit CSV -labels, and CA write/restart failure. It does not change Windows settings. -`tests/Test-ConfigurationReadOnlyWindows.ps1` runs real read-only `auditpol /get` +read-back, idempotence, final drift, dry runs, journal failure, locale-independent native audit flags, and CA write/restart failure. It does not change Windows settings. +`tests/Test-ConfigurationReadOnlyWindows.ps1` runs real read-only Windows audit-policy API and `auditpol /get` queries and a child `cmd.exe` diagnostic/exit test. CI runs both scripts in Windows PowerShell 5.1 and PowerShell 7. Mutating behavior still requires isolated Windows/CA lab validation; mock and read-only tests do not establish end-to-end event production. diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index feec7e0d..0ec682a1 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -151,14 +151,24 @@ function Get-WelaRegistryState { } } +function New-WelaRegistryKey { + param([string]$Path) + if (Test-Path -LiteralPath $Path -ErrorAction Stop) { return } + $separator = $Path.TrimEnd('\').LastIndexOf('\') + if ($separator -lt 1) { throw "Registry root is unavailable: $Path" } + $parent = $Path.Substring(0, $separator) + # Registry New-Item without Force requires its immediate parent. Build only + # missing ancestors; never run New-Item -Force against an existing key. + New-WelaRegistryKey -Path $parent + $null = New-Item -Path $Path -ErrorAction Stop +} + function Set-WelaRegistryControl { param($Context, [string]$Path, [string]$Name, $Value, [string]$Type = 'DWord') $read = { Get-WelaRegistryState -Path $Path -Name $Name }.GetNewClosure() $test = { param($state) $state.ValueExists -and $state.Value -eq $Value -and $state.Type -eq $Type }.GetNewClosure() $apply = { - if (-not (Test-Path -LiteralPath $Path -ErrorAction Stop)) { - $null = New-Item -Path $Path -ErrorAction Stop - } + New-WelaRegistryKey -Path $Path Set-ItemProperty -LiteralPath $Path -Name $Name -Value $Value -Type $Type -ErrorAction Stop }.GetNewClosure() Invoke-WelaConfigurationControl -Context $Context -Id "Registry/$Path/$Name" -Kind Registry ` @@ -166,16 +176,59 @@ function Set-WelaRegistryControl { -Read $read -Compliant $test -Apply $apply } +function Initialize-WelaConfigurationAuditApi { + if ('Wela.ConfigurationAuditApi' -as [type]) { return } + # Querying the Windows API avoids localized auditpol /get CSV (six columns; + # unlike /backup output, it has no numeric Setting Value column). + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +namespace Wela { + public static class ConfigurationAuditApi { + [StructLayout(LayoutKind.Sequential)] + private struct AuditPolicyInformation { + public Guid Subcategory; + public UInt32 Information; + public Guid Category; + } + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.U1)] + private static extern bool AuditQuerySystemPolicy( + [In] Guid[] subcategories, UInt32 count, out IntPtr policy); + [DllImport("advapi32.dll")] + private static extern void AuditFree(IntPtr buffer); + public static UInt32 Query(Guid subcategory) { + IntPtr buffer = IntPtr.Zero; + if (!AuditQuerySystemPolicy(new Guid[] { subcategory }, 1, out buffer)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + try { + if (buffer == IntPtr.Zero) throw new InvalidOperationException("Audit policy query returned no buffer."); + AuditPolicyInformation policy = (AuditPolicyInformation)Marshal.PtrToStructure(buffer, typeof(AuditPolicyInformation)); + if (policy.Subcategory != subcategory) throw new InvalidOperationException("Audit policy query returned a different subcategory."); + return policy.Information; + } finally { + if (buffer != IntPtr.Zero) AuditFree(buffer); + } + } + } +} +'@ -ErrorAction Stop +} + +function Get-WelaNativeAuditPolicy { + param([string]$Guid) + Initialize-WelaConfigurationAuditApi + return [Wela.ConfigurationAuditApi]::Query([guid]$Guid) +} + function Get-WelaAuditPolicyMask { param([string]$Guid) - $native = Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments @('/get', "/subcategory:{$Guid}", '/r') - # Column order is stable; names and Inclusion Setting text are localized. - $rows = $native.Output | ConvertFrom-Csv -Header Machine, Target, Name, Guid, Inclusion, Exclusion, SettingValue - $row = @($rows | Where-Object { $_.Guid -and $_.Guid.Trim('{}') -eq $Guid }) - if ($row.Count -ne 1 -or $row[0].SettingValue -notmatch '^[0-3]$') { - throw "auditpol returned no unambiguous numeric setting for $Guid. $($native.Diagnostic)" - } - return [int]$row[0].SettingValue + $flags = Get-WelaNativeAuditPolicy -Guid $Guid + if ($flags -notin @(0, 1, 2, 3, 4)) { throw "Unexpected audit policy flags $flags for $Guid." } + # POLICY_AUDIT_EVENT_NONE is 4; the success/failure mask is zero. + return [int]($flags -band 3) } function Set-WelaAuditPolicyControl { @@ -204,7 +257,7 @@ function Set-WelaCertificateAuditControl { ServiceStatus = (Get-Service -Name CertSvc -ErrorAction Stop).Status.ToString() } }.GetNewClosure() - $test = { param($value) $value.Registry.ValueExists -and $value.Registry.Value -eq 127 -and $value.ServiceStatus -eq 'Running' } + $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 if ($state.Status -ne 'Running') { throw 'CertSvc is not running; refusing to start a previously stopped CA. Start it deliberately before retrying.' } diff --git a/tests/Test-ConfigurationReadOnlyWindows.ps1 b/tests/Test-ConfigurationReadOnlyWindows.ps1 index 4b3b38f8..87597fa9 100644 --- a/tests/Test-ConfigurationReadOnlyWindows.ps1 +++ b/tests/Test-ConfigurationReadOnlyWindows.ps1 @@ -4,6 +4,9 @@ if ($env:OS -ne 'Windows_NT') { throw 'Run this smoke test on Windows.' } . (Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts/Configuration.ps1') $mask = Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030' if ($mask -notin @(0, 1, 2, 3)) { throw "Unexpected process-creation audit mask: $mask" } +# Also exercise the real read-only auditpol command and its native exit status. +$csv = Invoke-WelaNative -FilePath auditpol.exe -Arguments @('/get', '/subcategory:{0CCE922B-69AE-11D9-BED3-505054503030}', '/r') +if ($csv.Diagnostic -notmatch '0CCE922B-69AE-11D9-BED3-505054503030') { throw 'auditpol query returned no requested subcategory.' } $caught = '' try { Invoke-WelaNative -FilePath $env:ComSpec -Arguments @('/d', '/c', 'echo WELA-smoke-diagnostic 1>&2 & exit /b 9') } catch { $caught = $_.ToString() } diff --git a/tests/Test-ConfigurationResults.ps1 b/tests/Test-ConfigurationResults.ps1 index 146583b9..f7b7b954 100644 --- a/tests/Test-ConfigurationResults.ps1 +++ b/tests/Test-ConfigurationResults.ps1 @@ -2,7 +2,10 @@ $ErrorActionPreference = 'Stop' $repo = Split-Path $PSScriptRoot -Parent $script:ScriptRoot = $repo -. (Join-Path $repo 'scripts/Configuration.ps1') +# Load trusted source functions into the same scope as the mocks. Windows +# PowerShell 5.1 otherwise resolves a script-local original ahead of global mocks. +$definitions = Get-Content -LiteralPath (Join-Path $repo 'scripts/Configuration.ps1') -Raw +Invoke-Expression ($definitions -replace '(?m)^function ', 'function global:') $script:passed = 0 function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" } @@ -93,6 +96,29 @@ try { Set-WelaRegistryControl $c 'HKLM:\mock' Value 1 Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:registryWrites -eq $beforeWrites) 'Registry reruns preserve compliant values' + # Missing nested registry parents must be created individually, retaining + # existing parent keys/values. Mock provider rejects children without parents. + $script:mockKeys = @{'HKLM:' = $true; 'HKLM:\SOFTWARE' = $true} + $script:createdKeys = New-Object 'System.Collections.Generic.List[string]' + function global:Test-Path { + param($LiteralPath, $Path, $ErrorAction) + if ($LiteralPath -like 'HKLM:*') { return $script:mockKeys.ContainsKey($LiteralPath) } + Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path }) + } + function global:New-Item { + param($Path, $ItemType, [switch]$Force, $ErrorAction) + if ($Path -notlike 'HKLM:*') { return Microsoft.PowerShell.Management\New-Item @PSBoundParameters } + if ($Force) { throw 'Test refuses Force on registry keys' } + if ($script:mockKeys.ContainsKey($Path)) { throw 'Existing parent would be recreated' } + $parent = $Path.Substring(0, $Path.LastIndexOf('\')) + if (-not $script:mockKeys.ContainsKey($parent)) { throw "Missing registry parent: $parent" } + $script:createdKeys.Add($Path); $script:mockKeys[$Path] = $true + } + New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + Assert ($script:createdKeys.Count -eq 5 -and $script:mockKeys.ContainsKey('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging')) 'Missing registry ancestors are created safely in order' + New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + Assert ($script:createdKeys.Count -eq 5) 'Existing registry parents are preserved on rerun' + # Function stubs stand in for the Windows APIs from this point onward. $script:logSize = 1048576; $script:nativeFails = $true; $script:nativeWrites = 0 function global:Get-WinEvent { param($ListLog, $ErrorAction) [pscustomobject]@{ MaximumSizeInBytes = $script:logSize; IsEnabled = $false } } @@ -114,15 +140,17 @@ try { Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728 Assert ($c.Results[1].Status -eq 'AlreadyCompliant') 'Event log helper avoids repeated writes' - function global:Invoke-WelaNative { - param($FilePath, $Arguments) - [pscustomobject]@{ ExitCode = 0; Output = @('Localized,header,labels,here,x,y,z', 'host,System,localized name,{0CCE922B-69AE-11D9-BED3-505054503030},localized text,,3'); Diagnostic = '' } - } - Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 3) 'Audit policy parser uses numeric mask and GUID, not localized labels' - function global:Invoke-WelaNative { param($FilePath, $Arguments) [pscustomobject]@{ Output = @('unparseable'); Diagnostic = 'bad data' } } + # Compile the interop declaration without invoking Windows APIs on this host. + Initialize-WelaConfigurationAuditApi + Assert ($null -ne ('Wela.ConfigurationAuditApi' -as [type])) 'Audit query interop compiles' + function global:Get-WelaNativeAuditPolicy { param($Guid) return 3 } + Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 3) 'Audit policy uses native numeric flags independent of locale' + function global:Get-WelaNativeAuditPolicy { param($Guid) return 4 } + Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 0) 'Native NONE flag normalizes to no success/failure audit' + function global:Get-WelaNativeAuditPolicy { param($Guid) return 16 } $caught = '' try { Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030' } catch { $caught = $_.ToString() } - Assert ($caught -ne '') 'Unparseable audit state cannot be marked compliant' + Assert ($caught -ne '') 'Unexpected native flags cannot be marked compliant' # Extract ConfigureAuditSettings without running the WELA command dispatcher. $tokens = $null; $errors = $null @@ -154,8 +182,8 @@ try { if ($Path -like 'HKLM:*') { return "$Path\$ChildPath" } Microsoft.PowerShell.Management\Join-Path -Path $Path -ChildPath $ChildPath } - $script:filter = 0; $script:restartCalls = 0 - function global:Get-WelaRegistryState { param($Path, $Name) [pscustomobject]@{ ValueExists = $true; Value = $script:filter; Type = 'DWord'; KeyExists = $true } } + $script:filter = 0; $script:restartCalls = 0; $script:filterType = 'DWord' + function global:Get-WelaRegistryState { param($Path, $Name) [pscustomobject]@{ ValueExists = $true; Value = $script:filter; Type = $script:filterType; KeyExists = $true } } function global:Get-Service { param($Name, $ErrorAction) [pscustomobject]@{ Status = 'Running' } } function global:Restart-Service { param($Name, [switch]$Force, $ErrorAction) $script:restartCalls++; throw 'Injected CertSvc restart failure' } function global:Invoke-WelaNative { param($FilePath, $Arguments) $script:filter = 127; [pscustomobject]@{ ExitCode = 0; Diagnostic = 'mock certutil' } } @@ -172,6 +200,11 @@ try { Set-WelaCertificateAuditControl $c Assert ($c.Results[0].Status -eq 'Skipped' -and $script:restartCalls -eq 0) 'CA dry run never writes or restarts' + $script:filter = '127'; $script:filterType = 'String' + $c = New-TestContext -DryRun + Set-WelaCertificateAuditControl $c + Assert ($c.Results[0].Status -eq 'Skipped') 'REG_SZ 127 is not accepted as a compliant CA DWORD AuditFilter' + Write-Host "$script:passed configuration-result regression assertions passed. No Windows settings changed." } finally { foreach ($path in $script:cleanup) {