Read audit precedence provenance from documented RSoP schemas

This commit is contained in:
Shirofune-Security committed 2026-09-19 02:35:06 +09:00
1 parent 007f7653b9
commit 24a77ee9ce
3 files changed
+122 -22

No files matched your search

+3 -1
View File
@@ -77,8 +77,10 @@ The tests cover source/schema validation, role/build gating, exact/minimum/optio
Both configure paths journal and verify `HKLM\SYSTEM\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy=1` (DWORD). Declining or failing this prerequisite skips dependent audit-policy writes. Every actual subcategory write rechecks precedence, and final verification detects later registry or effective-mask drift. Profiles selecting no audit controls do not change the prerequisite. `plan` and `audit-settings -Profile` include `AuditPrecedence` evidence; offline plans leave its live state Unknown. The result scope is `advanced-audit-policy-and-precedence`; profile-definition scope remains `advanced-audit-policy-only`.
Matching last-applied RSoP GPO IDs are reported where readable. RSoP may be stale and does not identify the current writer. A reported 0 is flagged as conflicting with the desired value; unrecognized RSoP encodings remain unknown. Local success is a point-in-time observation, not proof of persistence through Group Policy or MDM refresh. WELA does not run `gpupdate` implicitly.
Matching last-applied RSoP GPO IDs are reported where readable. The reader handles the documented `RSOP_RegistryPolicySetting` (`registryKey`, `valueName`, DWORD byte data), `RSOP_SecuritySettingNumeric` (`KeyName`, `Setting`) and `RSOP_RegistryValue` (`Path`, `Type`, `Data`) schemas separately. Only canonical 0/1 values in a recognized DWORD/numeric representation are decoded; other encodings retain their source and raw evidence with unknown conflict status. Deleted registry-policy entries are ignored. All matched observations remain in `Matches`. RSoP may be stale and does not identify the current writer. A reported 0 is flagged as conflicting with the desired value; unrecognized RSoP encodings remain unknown. Local success is a point-in-time observation, not proof of persistence through Group Policy or MDM refresh. WELA does not run `gpupdate` implicitly.
See [Microsoft's precedence policy documentation](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/audit-force-audit-policy-subcategory-settings-to-override). This prevents legacy category policy from replacing subcategory settings; it does not supersede other advanced-audit policies.
For recovery, review the journal and restore the exact prior registry value/type (or remove only the value if it was previously absent), then restore reviewed subcategory settings. Never delete the Lsa key. In an isolated joined Windows VM, create a conflicting legacy category GPO, record `gpresult /scope computer /h before.html`, and capture `auditpol /get /category:* /r`. Apply WELA, explicitly refresh with `gpupdate /target:computer /force`, then rerun `audit-settings -Profile <selected-profile> -PlanPath after.json` and the auditpol capture. Verify registry precedence, each effective mask and GPO provenance; retain the snapshots and benign event XML. This domain-refresh/event test remains pending; CI exercises injected failures/drift and read-only Windows observations.
RSoP schema references: [registry policy](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/policy/rsop-registrypolicysetting), [numeric security setting](https://learn.microsoft.com/en-us/previous-versions/aa375064(v=vs.85)), and [security registry value](https://learn.microsoft.com/en-us/previous-versions/aa375052(v=vs.85)). Tests use these actual property shapes; they do not substitute a shared synthetic schema.
+57 -15
View File
@@ -326,27 +326,69 @@ function Set-WelaProfileAuditControls {
}
function Get-WelaAuditPrecedenceSource {
# RSoP records last-applied GPO data, not the current registry writer.
# Normalize each documented RSoP schema separately. Cached evidence does not
# prove the current registry writer, even when the represented value is known.
$targetKey = 'SYSTEM\CurrentControlSet\Control\Lsa'
$targetName = 'SCENoApplyLegacyAuditPolicy'
$matches = @()
foreach ($class in @('RSOP_RegistryPolicySetting', 'RSOP_SecuritySettingNumeric')) {
foreach ($class in @('RSOP_RegistryPolicySetting', 'RSOP_SecuritySettingNumeric', 'RSOP_RegistryValue')) {
try {
$matches += @(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName $class -ErrorAction Stop | Where-Object {
$keyProperty = $_.PSObject.Properties['keyName']
$valueProperty = $_.PSObject.Properties['valueName']
$key = if ($keyProperty) { [string]$keyProperty.Value -replace '^(MACHINE|HKEY_LOCAL_MACHINE|HKLM)\\', '' } else { '' }
($key -eq 'SYSTEM\CurrentControlSet\Control\Lsa' -and $valueProperty -and $valueProperty.Value -eq 'SCENoApplyLegacyAuditPolicy') -or
$key -eq 'SYSTEM\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy'
})
$records = @(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName $class -ErrorAction Stop)
foreach ($record in $records) {
$key = ''; $name = ''; $raw = $null; $reported = $null; $known = $false
if ($class -eq 'RSOP_RegistryPolicySetting') {
if ($record.PSObject.Properties['deleted'] -and $record.deleted -eq $true) { continue }
if ($record.PSObject.Properties['registryKey']) { $key = [string]$record.registryKey }
if ($record.PSObject.Properties['valueName']) { $name = [string]$record.valueName }
if ($record.PSObject.Properties['value']) { $raw = $record.value }
# REG_DWORD is exactly four little-endian bytes. Other types,
# arrays and lengths remain unknown rather than being coerced.
if ($record.PSObject.Properties['valueType'] -and ($record.valueType -is [int] -or $record.valueType -is [uint32] -or $record.valueType -is [long]) -and $record.valueType -eq 4 -and $raw -is [byte[]] -and $raw.Length -eq 4) {
if ($raw[1] -eq 0 -and $raw[2] -eq 0 -and $raw[3] -eq 0 -and $raw[0] -in @(0, 1)) {
$reported = [uint32]$raw[0]; $known = $true
}
}
} elseif ($class -eq 'RSOP_SecuritySettingNumeric') {
if ($record.PSObject.Properties['KeyName']) { $key = [string]$record.KeyName }
if ($record.PSObject.Properties['Setting']) { $raw = $record.Setting }
if (($raw -is [int] -or $raw -is [uint32] -or $raw -is [long]) -and $raw -in @(0, 1)) {
$reported = [uint32]$raw; $known = $true
}
# This security schema identifies settings by name; accept the
# exact policy name as well as a matching full registry path.
if ($key -eq $targetName) { $key = "$targetKey\$targetName" }
} else {
if ($record.PSObject.Properties['Path']) { $key = [string]$record.Path }
if ($record.PSObject.Properties['Data']) { $raw = $record.Data }
# Security-option registry values expose Type/Data, not Value.
# Only canonical decimal strings 0/1 of REG_DWORD are decoded.
if ($record.PSObject.Properties['Type'] -and ($record.Type -is [int] -or $record.Type -is [uint32] -or $record.Type -is [long]) -and $record.Type -eq 4 -and $raw -is [string] -and $raw -cin @('0', '1')) {
$reported = [uint32]$raw; $known = $true
}
}
$key = $key -replace '^(MACHINE|HKEY_LOCAL_MACHINE|HKLM)\\', ''
$matchingTarget = if ($class -eq 'RSOP_RegistryPolicySetting') { $key -eq $targetKey -and $name -eq $targetName } else { $key -eq "$targetKey\$targetName" }
if (-not $matchingTarget) { continue }
$matches += [pscustomobject]@{
SourceClass = $class
GpoId = $(if ($record.PSObject.Properties['GPOID']) { $record.GPOID } else { $null })
Precedence = $(if ($record.PSObject.Properties['precedence']) { $record.precedence } else { [uint32]::MaxValue })
ReportedValue = $(if ($known) { $reported } else { $raw })
ValueRecognized = $known
}
}
} catch { }
}
$policy = $matches | Sort-Object { if ($_.PSObject.Properties['precedence']) { $_.precedence } else { [int]::MaxValue } } | Select-Object -First 1
$gpo = if ($policy -and $policy.PSObject.Properties['GPOID']) { $policy.GPOID } else { $null }
$value = if ($policy -and $policy.PSObject.Properties['value']) { $policy.value } else { $null }
$knownValue = ($value -is [int] -or $value -is [uint32] -or $value -is [long] -or $value -is [string]) -and ([string]$value -in @('0', '1'))
$ordered = @($matches | Sort-Object Precedence)
$policy = $ordered | Select-Object -First 1
$gpo = if ($policy) { $policy.GpoId } else { $null }
$value = if ($policy) { $policy.ReportedValue } else { $null }
[pscustomobject]@{
GpoId = $gpo; ReportedValue = $value
ConflictsWithRequiredValue = if ($knownValue) { [string]$value -ne '1' } else { $null }
Description = if ($gpo) { "Last-applied RSoP GPO: $gpo (may be stale; current registry writer unknown)" } else { 'Unknown (no matching RSoP source; local, GPO or MDM ownership is not established)' }
SourceClass = $(if ($policy) { $policy.SourceClass } else { $null })
ConflictsWithRequiredValue = if ($policy -and $policy.ValueRecognized) { $value -ne 1 } else { $null }
Matches = $ordered
Description = if ($gpo) { "Last-applied RSoP GPO evidence: $gpo (may be stale; current registry writer unknown; see Matches for all observations)" } else { 'Unknown (no matching RSoP source; local, GPO or MDM ownership is not established)' }
}
}
+62 -6
View File
@@ -7,7 +7,7 @@ function Assert($Condition, $Message) { if (-not $Condition) { throw "FAIL: $Mes
function Reset-Fixture($Value = 0, $Type = 'DWord') {
$script:value = $Value; $script:type = $Type; $script:writes = 0; $script:auditWrites = 0
$script:readFails = $false; $script:writeFails = $false; $script:ignoreWrite = $false
$script:rsop = @(); $script:mask = 0; $script:response = 'Y'; $script:flipOnPrompt = $false
$script:rsop = @{}; $script:mask = 0; $script:response = 'Y'; $script:flipOnPrompt = $false
}
function Get-WelaRegistryState {
param($Path, $Name)
@@ -23,7 +23,7 @@ function Set-ItemProperty {
$script:writes++
if (-not $script:ignoreWrite) { $script:value = $Value; $script:type = $Type }
}
function Get-CimInstance { param($Namespace, $ClassName, $ErrorAction) if ($ClassName -eq 'RSOP_SecuritySettingNumeric') { return $script:rsop } }
function Get-CimInstance { param($Namespace, $ClassName, $ErrorAction) if ($script:rsop.ContainsKey($ClassName)) { return $script:rsop[$ClassName] } }
function Get-WelaNativeAuditPolicy { param($Guid) return $script:mask }
function Invoke-WelaNative {
param($FilePath, $Arguments)
@@ -79,12 +79,68 @@ try {
Assert ($script:auditWrites -eq 0 -and $report.ExitCode -eq 1) 'Pre-write guard rejects precedence changed during confirmation'
Assert ($ctx.Results[0].Status -eq 'Overridden') 'Final read-back detects precedence drift'
Reset-Fixture 1
$script:rsop = @([pscustomobject]@{ keyName = 'MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy'; GPOID = 'Test GPO'; value = 0; precedence = 1 })
$script:rsop['RSOP_SecuritySettingNumeric'] = @([pscustomobject]@{ KeyName = 'SCENoApplyLegacyAuditPolicy'; GPOID = 'Numeric GPO'; Setting = [uint32]0; precedence = [uint32]1 })
$state = Get-WelaAuditPrecedenceState
Assert ($state.State -eq 'Enabled' -and $state.PolicySource.ConflictsWithRequiredValue) 'Observed value and conflicting last-applied GPO are distinct'
Assert ($state.State -eq 'Enabled' -and $state.PolicySource.ConflictsWithRequiredValue -and $state.PolicySource.ReportedValue -eq 0) 'Observed registry and documented Numeric Setting conflict are distinct'
Assert ($state.PolicySource.Description -match 'may be stale') 'RSoP does not claim current ownership'
$script:rsop[0].value = [byte[]]@(1, 0, 0, 0)
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'Unrecognized RSoP encoding is not fabricated as conflict'
$script:rsop['RSOP_SecuritySettingNumeric'][0].Setting = '1'
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'Malformed numeric Setting strings are not coerced'
# Microsoft documents registryKey/valueName/value/valueType for ADM registry
# policies, and Path/Type/Data for security-option registry values. Keep these
# fixtures schema-faithful so a shared fictional keyName/value cannot pass.
foreach ($value in @(0, 1)) {
$script:rsop = @{}
$script:rsop['RSOP_RegistryPolicySetting'] = @([pscustomobject]@{
registryKey = 'HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa'; valueName = 'SCENoApplyLegacyAuditPolicy'
valueType = [uint32]4; value = [byte[]]@($value, 0, 0, 0); deleted = $false; GPOID = 'Registry GPO'; precedence = [uint32]1
})
$source = Get-WelaAuditPrecedenceSource
Assert ($source.GpoId -eq 'Registry GPO' -and $source.SourceClass -eq 'RSOP_RegistryPolicySetting') 'Documented registryKey resolves the matching ADM policy GPO'
Assert ($source.ReportedValue -eq $value -and $source.ConflictsWithRequiredValue -eq ($value -eq 0)) 'Four-byte little-endian DWORD RSoP data is decoded cautiously'
$script:rsop = @{}
$script:rsop['RSOP_RegistryValue'] = @([pscustomobject]@{
Path = 'MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy'
Type = [uint32]4; Data = [string]$value; GPOID = 'Security option GPO'; precedence = [uint32]1
})
$source = Get-WelaAuditPrecedenceSource
Assert ($source.GpoId -eq 'Security option GPO' -and $source.SourceClass -eq 'RSOP_RegistryValue') 'Security-option Path resolves the matching RSoP GPO'
Assert ($source.ReportedValue -eq $value -and $source.ConflictsWithRequiredValue -eq ($value -eq 0)) 'Canonical DWORD Data string is decoded without inventing other encodings'
}
foreach ($data in @('01', '0x00000001', ' 1', '4,1')) {
$script:rsop['RSOP_RegistryValue'][0].Data = $data
$source = Get-WelaAuditPrecedenceSource
Assert ($null -eq $source.ConflictsWithRequiredValue -and $source.ReportedValue -ceq $data -and $source.GpoId -eq 'Security option GPO') 'Unknown Data encoding retains source/raw evidence without a fabricated conflict'
}
$script:rsop['RSOP_RegistryValue'][0].Data = '0'; $script:rsop['RSOP_RegistryValue'][0].Type = [uint32]1
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'REG_SZ zero is not treated as a DWORD precedence setting'
$script:rsop['RSOP_RegistryValue'][0].Type = '4'
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'Malformed Type strings remain unknown'
$script:rsop = @{}
$registry = [pscustomobject]@{
registryKey = 'HKLM\SYSTEM\CurrentControlSet\Control\Lsa'; valueName = 'SCENoApplyLegacyAuditPolicy'
valueType = [uint32]4; value = [byte[]]@(1); deleted = $false; GPOID = 'Registry GPO'; precedence = [uint32]1
}
$script:rsop['RSOP_RegistryPolicySetting'] = @($registry)
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'A short DWORD byte array is unknown'
$registry.value = [byte[]]@(1, 0, 0, 0, 0)
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'An oversized DWORD byte array is unknown'
$registry.value = [byte[]]@(1, 0, 0, 0); $registry.valueType = [uint32]3
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'Binary data is not decoded as DWORD even if four bytes long'
$registry.valueType = '4'
Assert ($null -eq (Get-WelaAuditPrecedenceSource).ConflictsWithRequiredValue) 'Malformed valueType strings remain unknown'
$registry.valueType = [uint32]4; $registry.deleted = $true
Assert ($null -eq (Get-WelaAuditPrecedenceSource).GpoId) 'Deleted registry policy entries do not masquerade as active settings'
$registry.deleted = $false; $registry.registryKey = 'HKCU\SYSTEM\CurrentControlSet\Control\Lsa'
Assert ($null -eq (Get-WelaAuditPrecedenceSource).GpoId) 'A same-name user-hive key is not mistaken for the machine security option'
$registry.registryKey = 'MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy'; $registry.valueName = 'OtherValue'
Assert ($null -eq (Get-WelaAuditPrecedenceSource).GpoId) 'A registry key sharing the policy name does not substitute for the exact value path'
$registry.valueName = 'SCENoApplyLegacyAuditPolicy'
$registry.registryKey = 'MACHINE\SYSTEM\CurrentControlSet\Control\Lsa'; $registry.precedence = [uint32]2
$winner = $registry.PSObject.Copy(); $winner.precedence = [uint32]1; $winner.GPOID = 'Higher precedence GPO'; $winner.value = [byte[]]@(0, 0, 0, 0)
$script:rsop['RSOP_RegistryPolicySetting'] = @($registry, $winner)
$source = Get-WelaAuditPrecedenceSource
Assert ($source.GpoId -eq 'Higher precedence GPO' -and $source.ConflictsWithRequiredValue -and $source.Matches.Count -eq 2) 'Lower precedence number is selected while all matching evidence remains available'
$script:readFails = $true
Assert ((Get-WelaAuditPrecedenceState).State -eq 'Unknown') 'Read errors remain unknown'
Assert ((Get-WelaAuditPrecedenceState -Offline).Registry -eq $null) 'Offline plans never read live registry state'