mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Read audit masks through Windows API and preserve missing registry parents
This commit is contained in:
1 parent
36c4b4018f
commit
eb3232faf5
4 files changed
+117
-27
No files matched your search
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user