Read audit masks through Windows API and preserve missing registry parents

This commit is contained in:
Shirofune-Security committed 2026-09-18 21:53:44 +09:00
1 parent 36c4b4018f
commit eb3232faf5
4 files changed
+117 -27

No files matched your search

+6 -5
View File
@@ -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.
+65 -12
View File
@@ -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.' }
@@ -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() }
+43 -10
View File
@@ -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) {