From 24a77ee9cec38c1d749acd9223d29ba05700f10a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:35:06 +0900 Subject: [PATCH] Read audit precedence provenance from documented RSoP schemas --- docs/audit-profiles.md | 4 +- scripts/Configuration.ps1 | 72 ++++++++++++++++++++++++++------- tests/AuditPrecedence.Tests.ps1 | 68 ++++++++++++++++++++++++++++--- 3 files changed, 122 insertions(+), 22 deletions(-) diff --git a/docs/audit-profiles.md b/docs/audit-profiles.md index ac847cd6..f60dcbdc 100644 --- a/docs/audit-profiles.md +++ b/docs/audit-profiles.md @@ -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 -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. diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index b9aa8b55..2ec5c173 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -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)' } } } diff --git a/tests/AuditPrecedence.Tests.ps1 b/tests/AuditPrecedence.Tests.ps1 index b5912b03..5b1d7190 100644 --- a/tests/AuditPrecedence.Tests.ps1 +++ b/tests/AuditPrecedence.Tests.ps1 @@ -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'