From 1a12220b511249d0042f3e48e1ba33002accfb00 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:21:55 +0900 Subject: [PATCH 1/3] Verify advanced audit precedence before applying subcategories --- .github/workflows/audit-precedence.yml | 25 +++++ CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 16 +-- docs/audit-profiles.md | 14 ++- docs/configuration-results.md | 2 +- scripts/Configuration.ps1 | 78 ++++++++++++++- tests/AuditPrecedence.Tests.ps1 | 98 +++++++++++++++++++ tests/AuditPrecedence.Windows.Tests.ps1 | 10 ++ .../IntegrationProfileConfiguration.Tests.ps1 | 15 ++- website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 12 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/audit-precedence.yml create mode 100644 tests/AuditPrecedence.Tests.ps1 create mode 100644 tests/AuditPrecedence.Windows.Tests.ps1 diff --git a/.github/workflows/audit-precedence.yml b/.github/workflows/audit-precedence.yml new file mode 100644 index 00000000..d3afe4d9 --- /dev/null +++ b/.github/workflows/audit-precedence.yml @@ -0,0 +1,25 @@ +name: Audit precedence regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + precedence: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Mocked precedence and drift (Windows PowerShell 5.1) + shell: powershell + run: ./tests/AuditPrecedence.Tests.ps1 + - name: Mocked precedence and drift (PowerShell 7) + shell: pwsh + run: ./tests/AuditPrecedence.Tests.ps1 + - name: Read-only precedence and audit API (Windows PowerShell 5.1) + shell: powershell + run: ./tests/AuditPrecedence.Windows.Tests.ps1 + - name: Read-only precedence and audit API (PowerShell 7) + shell: pwsh + run: ./tests/AuditPrecedence.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 47edb38e..abaa9848 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -14,6 +14,7 @@ **バグ修正:** +- 通常の`configure`と`configure -Profile`で、詳細監査サブカテゴリを適用する前に`SCENoApplyLegacyAuditPolicy=1` (DWORD)の変更前の状態を記録し、設定後の値を検証するようにした。前提設定の変更に失敗した場合や変更を拒否した場合は、依存する書き込みを行わない。書き込み直前と最終確認で設定の変化を検出し、プロファイルの計画には現在の状態と取得可能な最終適用RSoP情報を含める。ポリシー更新後の永続性は保証しない。 (issue #374) (@Shirofune-Security) - `configure`が既定で送信NTLM認証をブロックしていた問題を修正した。未設定またはAllow allの場合はAudit all (`RestrictSendingNTLMTraffic=1`)を設定し、既存のDeny all (`2`)や不明な値・型は維持する。拒否設定を明示的に監査へ変更するには`-OutgoingNtlmMode Audit`、ブロックを有効にするには`Deny`を指定する。書き込み前にポリシーを再確認し、変更後の値の検証、失敗の報告、確認できたポリシー状態と取得可能な最終適用RSoP情報の表示に対応した。 (#388) (@Shirofune-Security) - `audit-settings`でホストの役割に適用されない監査ポリシーを`Not applicable`と表示し、カテゴリの有効・無効の集計から除外するようにした。NTLMポリシーの値は、DWORD型で保存されている場合にのみ有効な設定値として解釈・検証する。 (#392) (@Shirofune-Security) - 設定時に外部コマンドの終了コードと変更後の設定値を確認し、処理の終了前にも再確認するようにした。書き込み失敗、設定の未反映、CAサービスの再起動失敗、最終確認時の設定の不一致を明示的に報告し、一律に成功とせず、0以外の終了コードを返すようにした。 (#392) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index a61df55f..cdeacb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ **Bug Fixes:** +- Both `configure` paths now journal and verify `SCENoApplyLegacyAuditPolicy=1` (DWORD) before applying advanced audit subcategories. Failed or declined precedence changes block dependent writes; pre-write and final checks detect drift. Profile plans report precedence state and available last-applied RSoP evidence without claiming persistence through policy refresh. (issue #374) (@Shirofune-Security) - Fixed `configure` enabling outgoing NTLM blocking by default. It now sets Audit all (`RestrictSendingNTLMTraffic=1`) for unset or Allow policies while preserving existing Deny all (`2`) and unknown values/types. Use `-OutgoingNtlmMode Audit` to explicitly replace a deny policy, or `Deny` to enable blocking. Configuration rechecks policy before writing, verifies changes, reports failures, and displays the observed policy and available last-applied RSoP information. (#388) (@Shirofune-Security) - `audit-settings` now reports role-inapplicable audit policies as `Not applicable` and excludes them from category enablement totals. NTLM policy values are interpreted and verified only when stored as DWORDs. (#392) (@Shirofune-Security) - Configuration now checks native command exit codes, verifies settings after applying changes, and checks them again before finishing. Failed writes, ineffective changes, CA restart failures and settings that no longer match at the final check produce explicit results and a nonzero exit code instead of unconditional success. (#392) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 2b78f3df..f5a1ebe9 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -307,7 +307,7 @@ function Show-WelaAuditProfilePrerequisites { function Invoke-WelaProfileCommand { param([string]$Command) - if ($script:Baseline) { throw "Use -Profile or -Baseline, not both. Versioned profiles cover advanced audit policy only." } + if ($script:Baseline) { throw "Use -Profile or -Baseline, not both. Versioned profiles cover advanced audit policy and its precedence prerequisite." } if (-not $script:Profile) { throw "Specify -Profile. Use './WELA.ps1 profiles' to list versioned profiles." } $context = Get-WelaSelectedContext $current = @{} @@ -319,8 +319,12 @@ function Invoke-WelaProfileCommand { } elseif ($Command -ne 'plan') { throw "Audit and configure require Windows. Offline planning requires explicit -Role and -Build." } $plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional + $precedence = Get-WelaAuditPrecedenceState -Offline:($current.Count -eq 0) + $plan | Add-Member NoteProperty AuditPrecedence $precedence Write-Host "Profile: $($plan.profile); role: $($plan.role); build: $($plan.build)" - Write-Host "Scope: advanced audit policy only. Channels, command-line capture, PowerShell, NTLM, SACLs, CA AuditFilter and forwarding are separate." + Write-Host "Scope: advanced audit policy and its subcategory-precedence prerequisite. Channels, command-line capture, PowerShell, NTLM, SACLs, CA AuditFilter and forwarding are separate." + Write-Host "Audit precedence: $($precedence.State); required SCENoApplyLegacyAuditPolicy=1 (DWORD). $($precedence.Diagnostic)" + if ($precedence.PolicySource) { Write-Host $precedence.PolicySource.Description } Show-WelaAuditProfilePrerequisites -Plan $plan $result = $plan if ($Command -eq 'configure') { @@ -328,7 +332,7 @@ function Invoke-WelaProfileCommand { Assert-WelaAuditProfileTarget -Plan $plan -Context $actual -Current $current $configurationContext = New-WelaConfigurationContext -Auto:$script:Auto -DryRun:$script:DryRun -BackupPath $script:BackupPath Set-WelaProfileAuditControls -Context $configurationContext -Plan $plan - $result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan -Scope advanced-audit-policy-only + $result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan -Scope advanced-audit-policy-and-precedence $result.Results | Format-Table Id, Before, Desired, After, Status -AutoSize } else { $plan.policies | Format-Table id, mode, currentMask, requiredMask, action -AutoSize @@ -1755,7 +1759,7 @@ Usage: ./WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json ./WELA.ps1 audit-settings -Profile microsoft-sct-win11-24h2 -PlanPath audit.json ./WELA.ps1 configure -Profile asd-native-2021-10 -PlanPath result.json -Auto - # -Profile changes advanced audit policy ONLY. Optional controls need -IncludeOptional. + # -Profile changes advanced audit policy plus its precedence prerequisite. Optional controls need -IncludeOptional. ./WELA.ps1 audit-settings -Baseline YamatoSecurity # Audit current setting and show in stdout, save to csv ./WELA.ps1 audit-settings -Baseline ASD -OutType gui # Audit current setting and show in gui, save to csv ./WELA.ps1 audit-filesize -Baseline YamatoSecurity # Audit current file size and show in stdout, save to csv @@ -1834,14 +1838,14 @@ switch ($Cmd.ToLower()) { Write-Host "Usage: ./WELA.ps1 configure [-Profile ] [-Auto] [-DryRun] [-BackupPath ] [-ResultsPath ] [-OutgoingNtlmMode ]" Write-Host "" Write-Host "Options:" - Write-Host " -Profile Configure advanced audit policy only from a versioned profile; list IDs with profiles" + Write-Host " -Profile Configure advanced audit policy and precedence from a versioned profile; list IDs with profiles" Write-Host " -Auto Automatically configure without prompts" Write-Host " -OutgoingNtlmMode PreserveOrAudit (default): audit, preserving existing deny; Audit: explicitly replace deny; Deny: opt into enforcement" Write-Host " -DryRun Read live state and report proposed changes without writing Windows settings" Write-Host " -BackupPath New directory for the pre-change recovery journal (unique default beside WELA)" Write-Host " -ResultsPath Save structured per-control outcomes as JSON" Write-Host "" - Write-Host "Without -Profile, configure applies the YamatoSecurity native logging settings. -Profile applies advanced audit policy only. -DryRun and recovery/results options work with both." + Write-Host "Without -Profile, configure applies the YamatoSecurity native logging settings. -Profile applies advanced audit policy and its precedence prerequisite. -DryRun and recovery/results options work with both." Write-Host "" return } diff --git a/docs/audit-profiles.md b/docs/audit-profiles.md index 3020b4a5..ac847cd6 100644 --- a/docs/audit-profiles.md +++ b/docs/audit-profiles.md @@ -2,7 +2,7 @@ `audit-settings`, `plan`, and `configure` share `config/audit_profiles.json` for advanced Security audit policy. The ordinary `audit-settings -Baseline YamatoSecurity` and ordinary `configure` also use `wela-2.2.0`, eliminating a separate hard-coded configuration list. All 59 subcategories use canonical GUIDs, including categories missing from the older display catalog. -**Profile scope is advanced audit policy only.** Selecting Microsoft, CIS or ASD does not configure their PowerShell settings, command-line capture, channel buffers, NTLM policy, firewall logs, SACLs, CA AuditFilter, forwarding or retention. This is not a claim of full baseline compliance or detection coverage. Sysmon and external sensors are outside this feature. Ordinary `configure` without `-Profile` continues the existing broader WELA setup, with its advanced audit portion supplied by the shared profile. +**Profile definitions cover advanced audit policy.** Configuration also verifies and enables its `SCENoApplyLegacyAuditPolicy=1` DWORD prerequisite before applying subcategories. Selecting Microsoft, CIS or ASD does not configure their PowerShell settings, command-line capture, channel buffers, NTLM policy, firewall logs, SACLs, CA AuditFilter, forwarding or retention. This is not a claim of full baseline compliance or detection coverage. Sysmon and external sensors are outside this feature. Ordinary `configure` without `-Profile` continues the existing broader WELA setup, with its advanced audit portion supplied by the shared profile. ## Commands @@ -16,7 +16,7 @@ # On Windows, omit Role/Build to detect this host and read effective auditpol values. .\WELA.ps1 audit-settings -Profile microsoft-sct-win11-24h2 -PlanPath audit.json -# Apply ONLY advanced audit policy. Interactive unless -Auto is supplied. +# Apply advanced audit policy and its precedence prerequisite. Interactive unless -Auto is supplied. .\WELA.ps1 configure -Profile asd-native-2021-10 -Auto -PlanPath result.json # Select optional File System/Registry policy flags, without creating SACLs. @@ -72,3 +72,13 @@ powershell -NoProfile -File tests/audit-profiles.Tests.ps1 ``` The tests cover source/schema validation, role/build gating, exact/minimum/optional/NC behavior, locale-independent native policy reads, unknown-state refusal, fresh-state merging, idempotence, failed commands and verification, and the ordinary Yamato audit display. CI runs these on Windows PowerShell 5.1 and PowerShell 7. Source review and mocked tests are not substitutes for checking effective policy and benign event XML on isolated Windows clients, member servers, DCs and CAs. + +## Advanced audit precedence + +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. + +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. diff --git a/docs/configuration-results.md b/docs/configuration-results.md index 6f4fc9dc..74607ebd 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -135,7 +135,7 @@ source identifiers and prerequisites such as SACLs; recording an enabled audit subcategory does not claim its prerequisite was installed. The result `Scope` is `native-windows-configuration` for default configure and -`advanced-audit-policy-only` for `configure -Profile`. `ProfileScope` describes the +`advanced-audit-policy-and-precedence` for `configure -Profile`. `ProfileScope` describes the advanced-policy subset within either result. `-PlanPath` remains available for profile JSON output; `-ResultsPath` saves the verified configuration report. diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index d8baddde..b9aa8b55 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only")] + [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence")] [string]$Scope = "native-windows-configuration") # A second read detects a value that was compliant earlier but changed during # this run. It does not establish whether GPO or another writer caused drift. @@ -263,9 +263,9 @@ function Get-WelaAuditPolicyMask { function Set-WelaAuditPolicyControl { param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3, - [ValidateSet('exact', 'minimum')][string]$Mode = 'exact') + [ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence) $guid = $Policy.GUID - $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode } + $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence } $read = { param($state) Get-WelaAuditPolicyMask -Guid $state.Guid } $test = { param($value, $state) @@ -274,6 +274,12 @@ function Set-WelaAuditPolicyControl { } $apply = { param($state) + if ($state.RequirePrecedence) { + $precedence = Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy + if (-not $precedence.ValueExists -or $precedence.Type -ne 'DWord' -or $precedence.Value -ne 1) { + throw 'Audit precedence changed before the write; subcategory policy was not changed.' + } + } $arguments = @('/set', "/subcategory:{$($state.Guid)}") if ($state.Mode -eq 'minimum') { # Only enable required flags: never disable another writer's added flag. @@ -293,10 +299,18 @@ function Set-WelaAuditPolicyControl { function Set-WelaProfileAuditControls { param($Context, $Plan) # The caller must complete Assert-WelaAuditProfileTarget before any mutations. + $selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) }) + if ($selected.Count -eq 0) { return } + Set-WelaAuditPrecedenceControl -Context $Context + $precedence = $Context.Results[$Context.Results.Count - 1] foreach ($policy in $Plan.policies) { if ($policy.mode -notin @('exact', 'minimum') -and -not ($policy.mode -eq 'optional' -and $Plan.includeOptional)) { continue } + if ($precedence.Status -eq 'Failed' -or ($precedence.Status -eq 'Skipped' -and -not $Context.DryRun)) { + $Context.Results.Add([pscustomobject]@{ Id = "AuditPolicy/$($policy.id)"; Kind = 'AuditPolicy'; Target = @{ Guid = $policy.guid }; Desired = $policy.requiredMask; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'Audit precedence was not verified; dependent policy was not changed.' }) + continue + } $mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' } - Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode + Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence $row = $Context.Results[$Context.Results.Count - 1] $row | Add-Member NoteProperty Profile $Plan.profile $row | Add-Member NoteProperty Version $Plan.version @@ -311,6 +325,62 @@ function Set-WelaProfileAuditControls { } } +function Get-WelaAuditPrecedenceSource { + # RSoP records last-applied GPO data, not the current registry writer. + $matches = @() + foreach ($class in @('RSOP_RegistryPolicySetting', 'RSOP_SecuritySettingNumeric')) { + 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' + }) + } 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')) + [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)' } + } +} + +function Get-WelaAuditPrecedenceState { + param([switch]$Offline) + $registry = $null; $status = 'Unknown'; $diagnostic = 'Offline plan; live precedence was not read.' + $source = $null + if (-not $Offline) { + $source = Get-WelaAuditPrecedenceSource + try { + $registry = Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy + $status = if (-not $registry.ValueExists) { 'Not configured' } + elseif ($registry.Type -ne 'DWord' -or $registry.Value -notin @(0, 1)) { 'Unknown' } + elseif ($registry.Value -eq 1) { 'Enabled' } else { 'Disabled' } + $diagnostic = 'Observed registry state only; effective audit masks are read separately. GPO/MDM can change this value after verification.' + } catch { $diagnostic = $_.ToString() } + } + [pscustomobject]@{ Name = 'SCENoApplyLegacyAuditPolicy'; RequiredValue = 1; RequiredType = 'DWord'; State = $status; Registry = $registry; PolicySource = $source; Diagnostic = $diagnostic } +} + +function Set-WelaAuditPrecedenceControl { + param($Context) + Set-WelaRegistryControl -Context $Context -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 + $row = $Context.Results[$Context.Results.Count - 1] + $source = Get-WelaAuditPrecedenceSource + $row | Add-Member NoteProperty PolicySource $source + $row | Add-Member NoteProperty VerificationScope 'Current registry value and per-subcategory effective masks; no Group Policy refresh was performed.' + if ($source.ConflictsWithRequiredValue) { + $row.Diagnostic += ' Last-applied RSoP reports a different value; reconcile that GPO and verify again after policy refresh.' + Write-Host $row.Diagnostic -ForegroundColor DarkYellow + } + Write-Host "Audit precedence policy source: $($source.Description)" +} + function Set-WelaCertificateAuditControl { param($Context) $root = 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' diff --git a/tests/AuditPrecedence.Tests.ps1 b/tests/AuditPrecedence.Tests.ps1 new file mode 100644 index 00000000..b5912b03 --- /dev/null +++ b/tests/AuditPrecedence.Tests.ps1 @@ -0,0 +1,98 @@ +# Mocked registry/audit policy; no Windows policy changes. +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '../scripts/Configuration.ps1') +$script:assertions = 0 +$script:paths = @() +function Assert($Condition, $Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ } +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 +} +function Get-WelaRegistryState { + param($Path, $Name) + if ($Name -ne 'SCENoApplyLegacyAuditPolicy') { throw 'Unexpected registry read' } + if ($script:readFails) { throw 'Injected access denied' } + [pscustomobject]@{ KeyExists = $true; ValueExists = ($null -ne $script:value); Value = $script:value; Type = $script:type } +} +function New-WelaRegistryKey { param($Path) } +function Set-ItemProperty { + param($LiteralPath, $Name, $Value, $Type, $ErrorAction) + if ($Name -ne 'SCENoApplyLegacyAuditPolicy' -or $Type -ne 'DWord') { throw 'Unexpected registry write' } + if ($script:writeFails) { throw 'Injected write denied' } + $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-WelaNativeAuditPolicy { param($Guid) return $script:mask } +function Invoke-WelaNative { + param($FilePath, $Arguments) + if ($script:value -ne 1 -or $script:type -ne 'DWord') { throw 'Audit write without verified prerequisite' } + $script:auditWrites++; $script:mask = 1 + [pscustomobject]@{ Diagnostic = 'mock auditpol success' } +} +function Read-Host { param($Prompt) if ($script:flipOnPrompt) { $script:value = 0 }; return $script:response } +function New-TestContext([switch]$DryRun, [switch]$Interactive) { + $path = Join-Path ([IO.Path]::GetTempPath()) ('wela-precedence-' + [guid]::NewGuid().ToString('N')) + $script:paths += $path + New-WelaConfigurationContext -Auto:(-not $Interactive) -DryRun:$DryRun -BackupPath $path +} +$plan = [pscustomobject]@{ + profile = 'fixture'; version = '1'; schemaSha256 = 'fixture'; role = 'Client'; build = 26100; includeOptional = $false + policies = @([pscustomobject]@{ id = 'Process Creation'; guid = '0CCE922B-69AE-11D9-BED3-505054503030'; mode = 'minimum'; requiredMask = 1; prerequisites = ''; evidence = 'fixture'; sourceIds = @('fixture'); note = '' }) +} +try { + foreach ($initial in @($null, 0, 42, '1')) { + Reset-Fixture $initial $(if ($initial -is [string]) { 'String' } else { 'DWord' }) + $ctx = New-TestContext + Set-WelaProfileAuditControls -Context $ctx -Plan $plan + $report = Complete-WelaConfiguration $ctx + Assert ($report.ExitCode -eq 0 -and $script:writes -eq 1 -and $script:auditWrites -eq 1) 'Precedence repair precedes subcategory writes' + $journal = @(Get-Content (Join-Path $ctx.BackupPath 'before.jsonl') | ConvertFrom-Json) + Assert ($journal[0].Target.Name -eq 'SCENoApplyLegacyAuditPolicy' -and $journal[0].Before.Value -eq $initial) 'Journal retains the original value before policy writes' + Assert ($script:type -eq 'DWord') 'Numeric strings are repaired as DWORD' + Set-WelaProfileAuditControls -Context $ctx -Plan $plan + Assert ($script:writes -eq 1 -and $script:auditWrites -eq 1) 'Verified rerun is idempotent' + } + Reset-Fixture + $ctx = New-TestContext -DryRun + Set-WelaProfileAuditControls -Context $ctx -Plan $plan + Assert ($script:writes -eq 0 -and $script:auditWrites -eq 0 -and -not (Test-Path $ctx.BackupPath)) 'Dry-run changes nothing and creates no journal' + Assert ($ctx.Results.Count -eq 2) 'Dry-run previews both prerequisite and policy' + foreach ($failure in @('readFails','writeFails','ignoreWrite')) { + Reset-Fixture + Set-Variable -Name $failure -Value $true -Scope Script + $ctx = New-TestContext + Set-WelaProfileAuditControls -Context $ctx -Plan $plan + $report = Complete-WelaConfiguration $ctx + Assert ($report.ExitCode -eq 1 -and $script:auditWrites -eq 0) 'Failed prerequisite blocks dependent policy writes' + Assert ($ctx.Results[1].Status -eq 'Skipped') 'Blocked dependent control remains explicit' + } + Reset-Fixture + $script:response = 'n'; $ctx = New-TestContext -Interactive + Set-WelaProfileAuditControls -Context $ctx -Plan $plan + Assert ($script:writes -eq 0 -and $script:auditWrites -eq 0 -and $ctx.Results[1].Status -eq 'Skipped') 'Declined prerequisite does not allow downstream writes' + Reset-Fixture 1 + $script:flipOnPrompt = $true; $ctx = New-TestContext -Interactive + Set-WelaProfileAuditControls -Context $ctx -Plan $plan + $report = Complete-WelaConfiguration $ctx + 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 }) + $state = Get-WelaAuditPrecedenceState + Assert ($state.State -eq 'Enabled' -and $state.PolicySource.ConflictsWithRequiredValue) 'Observed value and conflicting last-applied GPO 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: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' + Reset-Fixture + $ctx = New-TestContext + Set-WelaProfileAuditControls -Context $ctx -Plan ([pscustomobject]@{ policies = @(); includeOptional = $false }) + Assert ($ctx.Results.Count -eq 0 -and $script:writes -eq 0) 'Empty profile does not change precedence' + Write-Host "PASS: $script:assertions audit precedence assertions (mocked; no host changes)." +} finally { + foreach ($path in $script:paths) { if (Test-Path $path) { Remove-Item $path -Recurse -Force } } +} diff --git a/tests/AuditPrecedence.Windows.Tests.ps1 b/tests/AuditPrecedence.Windows.Tests.ps1 new file mode 100644 index 00000000..cebf3ada --- /dev/null +++ b/tests/AuditPrecedence.Windows.Tests.ps1 @@ -0,0 +1,10 @@ +# Read-only host observations; never change precedence, GPO or audit masks. +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '../scripts/Configuration.ps1') +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force +$state = Get-WelaAuditPrecedenceState +if ($state.State -notin @('Enabled', 'Disabled', 'Not configured', 'Unknown')) { throw 'Invalid precedence observation' } +$current = Get-WelaEffectiveAuditPolicy +if ($current.Count -ne 59) { throw 'Audit policy API omitted catalog entries' } +if ($state.State -eq 'Unknown' -and -not $state.Diagnostic) { throw 'Unknown state must preserve diagnostics' } +Write-Host "Read-only precedence state: $($state.State); 59 effective masks read. No policy refresh or writes performed." diff --git a/tests/IntegrationProfileConfiguration.Tests.ps1 b/tests/IntegrationProfileConfiguration.Tests.ps1 index 9fb75430..28aa7045 100644 --- a/tests/IntegrationProfileConfiguration.Tests.ps1 +++ b/tests/IntegrationProfileConfiguration.Tests.ps1 @@ -33,6 +33,7 @@ function Assert-Throws([scriptblock]$Action, [string]$Pattern) { function Reset-Run([string]$Profile = 'cis-win11-v4-l1', [switch]$DryRun) { $script:state = $zero.Clone() $script:writes = @() + $script:precedenceValue = 0 $script:failGuid = '' $script:concurrentGuid = '' $script:Profile = $Profile @@ -46,6 +47,18 @@ function Reset-Run([string]$Profile = 'cis-win11-v4-l1', [switch]$DryRun) { $script:cleanup.Add($script:BackupPath) $script:cleanup.Add($script:ResultsPath) } +function Get-WelaRegistryState { + param($Path, $Name) + if ($Name -ne 'SCENoApplyLegacyAuditPolicy') { throw 'Unexpected registry read' } + [pscustomobject]@{ KeyExists = $true; ValueExists = $true; Value = $script:precedenceValue; Type = 'DWord' } +} +function New-WelaRegistryKey { param($Path) } +function Set-ItemProperty { + param($LiteralPath, $Name, $Value, $Type, $ErrorAction) + if ($Name -ne 'SCENoApplyLegacyAuditPolicy') { throw 'Unexpected registry write' } + $script:precedenceValue = $Value +} +function Get-CimInstance { param($Namespace, $ClassName, $ErrorAction) throw 'No RSoP fixture' } function TestWindows { return $true } function TestAdministrator { return $true } function Get-WelaHostContext { [pscustomobject]@{ Role = 'Client'; Build = $script:hostBuild } } @@ -84,7 +97,7 @@ try { Invoke-WelaProfileCommand configure | Out-Null $report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json Assert ($script:writes.Count -eq 0 -and $report.DryRun) 'configure -Profile -DryRun makes no audit writes' - Assert ($report.Scope -eq 'advanced-audit-policy-only' -and $report.ProfileScope -eq 'advanced-audit-policy-only') 'Profile-only results declare their narrower scope' + Assert ($report.Scope -eq 'advanced-audit-policy-and-precedence' -and $report.ProfileScope -eq 'advanced-audit-policy-only') 'Profile-only results declare their narrower scope' Assert (-not (Test-Path -LiteralPath $script:BackupPath)) 'Profile dry run creates no journal directory' Assert ($report.Results.Count -gt 0 -and @($report.Results | Where-Object Status -ne Skipped).Count -eq 0) 'Profile dry-run proposals remain explicit skipped results' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4f073d1f..02dbed6f 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -17,6 +17,7 @@ **バグ修正:** +- 通常の`configure`と`configure -Profile`で、詳細監査サブカテゴリを適用する前に`SCENoApplyLegacyAuditPolicy=1` (DWORD)の変更前の状態を記録し、設定後の値を検証するようにした。前提設定の変更に失敗した場合や変更を拒否した場合は、依存する書き込みを行わない。書き込み直前と最終確認で設定の変化を検出し、プロファイルの計画には現在の状態と取得可能な最終適用RSoP情報を含める。ポリシー更新後の永続性は保証しない。 (issue #374) (@Shirofune-Security) - `configure`が既定で送信NTLM認証をブロックしていた問題を修正した。未設定またはAllow allの場合はAudit all (`RestrictSendingNTLMTraffic=1`)を設定し、既存のDeny all (`2`)や不明な値・型は維持する。拒否設定を明示的に監査へ変更するには`-OutgoingNtlmMode Audit`、ブロックを有効にするには`Deny`を指定する。書き込み前にポリシーを再確認し、変更後の値の検証、失敗の報告、確認できたポリシー状態と取得可能な最終適用RSoP情報の表示に対応した。 (#388) (@Shirofune-Security) - `audit-settings`でホストの役割に適用されない監査ポリシーを`Not applicable`と表示し、カテゴリの有効・無効の集計から除外するようにした。NTLMポリシーの値は、DWORD型で保存されている場合にのみ有効な設定値として解釈・検証する。 (#392) (@Shirofune-Security) - 設定時に外部コマンドの終了コードと変更後の設定値を確認し、処理の終了前にも再確認するようにした。書き込み失敗、設定の未反映、CAサービスの再起動失敗、最終確認時の設定の不一致を明示的に報告し、一律に成功とせず、0以外の終了コードを返すようにした。 (#392) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 9e7520f5..7a75424d 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -19,6 +19,7 @@ **Bug Fixes:** +- Both `configure` paths now journal and verify `SCENoApplyLegacyAuditPolicy=1` (DWORD) before applying advanced audit subcategories. Failed or declined precedence changes block dependent writes; pre-write and final checks detect drift. Profile plans report precedence state and available last-applied RSoP evidence without claiming persistence through policy refresh. (issue #374) (@Shirofune-Security) - Fixed `configure` enabling outgoing NTLM blocking by default. It now sets Audit all (`RestrictSendingNTLMTraffic=1`) for unset or Allow policies while preserving existing Deny all (`2`) and unknown values/types. Use `-OutgoingNtlmMode Audit` to explicitly replace a deny policy, or `Deny` to enable blocking. Configuration rechecks policy before writing, verifies changes, reports failures, and displays the observed policy and available last-applied RSoP information. (#388) (@Shirofune-Security) - `audit-settings` now reports role-inapplicable audit policies as `Not applicable` and excludes them from category enablement totals. NTLM policy values are interpreted and verified only when stored as DWORDs. (#392) (@Shirofune-Security) - Configuration now checks native command exit codes, verifies settings after applying changes, and checks them again before finishing. Failed writes, ineffective changes, CA restart failures and settings that no longer match at the final check produce explicit results and a nonzero exit code instead of unconditional success. (#392) (@Shirofune-Security) From 007f7653b9ffc8f478f9325f7180398ced1ca914 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:23:10 +0900 Subject: [PATCH 2/3] Link audit precedence release notes to PR 393 --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index abaa9848..61b868f1 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -14,7 +14,7 @@ **バグ修正:** -- 通常の`configure`と`configure -Profile`で、詳細監査サブカテゴリを適用する前に`SCENoApplyLegacyAuditPolicy=1` (DWORD)の変更前の状態を記録し、設定後の値を検証するようにした。前提設定の変更に失敗した場合や変更を拒否した場合は、依存する書き込みを行わない。書き込み直前と最終確認で設定の変化を検出し、プロファイルの計画には現在の状態と取得可能な最終適用RSoP情報を含める。ポリシー更新後の永続性は保証しない。 (issue #374) (@Shirofune-Security) +- 通常の`configure`と`configure -Profile`で、詳細監査サブカテゴリを適用する前に`SCENoApplyLegacyAuditPolicy=1` (DWORD)の変更前の状態を記録し、設定後の値を検証するようにした。前提設定の変更に失敗した場合や変更を拒否した場合は、依存する書き込みを行わない。書き込み直前と最終確認で設定の変化を検出し、プロファイルの計画には現在の状態と取得可能な最終適用RSoP情報を含める。ポリシー更新後の永続性は保証しない。 (#393) (@Shirofune-Security) - `configure`が既定で送信NTLM認証をブロックしていた問題を修正した。未設定またはAllow allの場合はAudit all (`RestrictSendingNTLMTraffic=1`)を設定し、既存のDeny all (`2`)や不明な値・型は維持する。拒否設定を明示的に監査へ変更するには`-OutgoingNtlmMode Audit`、ブロックを有効にするには`Deny`を指定する。書き込み前にポリシーを再確認し、変更後の値の検証、失敗の報告、確認できたポリシー状態と取得可能な最終適用RSoP情報の表示に対応した。 (#388) (@Shirofune-Security) - `audit-settings`でホストの役割に適用されない監査ポリシーを`Not applicable`と表示し、カテゴリの有効・無効の集計から除外するようにした。NTLMポリシーの値は、DWORD型で保存されている場合にのみ有効な設定値として解釈・検証する。 (#392) (@Shirofune-Security) - 設定時に外部コマンドの終了コードと変更後の設定値を確認し、処理の終了前にも再確認するようにした。書き込み失敗、設定の未反映、CAサービスの再起動失敗、最終確認時の設定の不一致を明示的に報告し、一律に成功とせず、0以外の終了コードを返すようにした。 (#392) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdeacb5b..4e15e621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ **Bug Fixes:** -- Both `configure` paths now journal and verify `SCENoApplyLegacyAuditPolicy=1` (DWORD) before applying advanced audit subcategories. Failed or declined precedence changes block dependent writes; pre-write and final checks detect drift. Profile plans report precedence state and available last-applied RSoP evidence without claiming persistence through policy refresh. (issue #374) (@Shirofune-Security) +- Both `configure` paths now journal and verify `SCENoApplyLegacyAuditPolicy=1` (DWORD) before applying advanced audit subcategories. Failed or declined precedence changes block dependent writes; pre-write and final checks detect drift. Profile plans report precedence state and available last-applied RSoP evidence without claiming persistence through policy refresh. (#393) (@Shirofune-Security) - Fixed `configure` enabling outgoing NTLM blocking by default. It now sets Audit all (`RestrictSendingNTLMTraffic=1`) for unset or Allow policies while preserving existing Deny all (`2`) and unknown values/types. Use `-OutgoingNtlmMode Audit` to explicitly replace a deny policy, or `Deny` to enable blocking. Configuration rechecks policy before writing, verifies changes, reports failures, and displays the observed policy and available last-applied RSoP information. (#388) (@Shirofune-Security) - `audit-settings` now reports role-inapplicable audit policies as `Not applicable` and excludes them from category enablement totals. NTLM policy values are interpreted and verified only when stored as DWORDs. (#392) (@Shirofune-Security) - Configuration now checks native command exit codes, verifies settings after applying changes, and checks them again before finishing. Failed writes, ineffective changes, CA restart failures and settings that no longer match at the final check produce explicit results and a nonzero exit code instead of unconditional success. (#392) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 02dbed6f..5fd23506 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -17,7 +17,7 @@ **バグ修正:** -- 通常の`configure`と`configure -Profile`で、詳細監査サブカテゴリを適用する前に`SCENoApplyLegacyAuditPolicy=1` (DWORD)の変更前の状態を記録し、設定後の値を検証するようにした。前提設定の変更に失敗した場合や変更を拒否した場合は、依存する書き込みを行わない。書き込み直前と最終確認で設定の変化を検出し、プロファイルの計画には現在の状態と取得可能な最終適用RSoP情報を含める。ポリシー更新後の永続性は保証しない。 (issue #374) (@Shirofune-Security) +- 通常の`configure`と`configure -Profile`で、詳細監査サブカテゴリを適用する前に`SCENoApplyLegacyAuditPolicy=1` (DWORD)の変更前の状態を記録し、設定後の値を検証するようにした。前提設定の変更に失敗した場合や変更を拒否した場合は、依存する書き込みを行わない。書き込み直前と最終確認で設定の変化を検出し、プロファイルの計画には現在の状態と取得可能な最終適用RSoP情報を含める。ポリシー更新後の永続性は保証しない。 (#393) (@Shirofune-Security) - `configure`が既定で送信NTLM認証をブロックしていた問題を修正した。未設定またはAllow allの場合はAudit all (`RestrictSendingNTLMTraffic=1`)を設定し、既存のDeny all (`2`)や不明な値・型は維持する。拒否設定を明示的に監査へ変更するには`-OutgoingNtlmMode Audit`、ブロックを有効にするには`Deny`を指定する。書き込み前にポリシーを再確認し、変更後の値の検証、失敗の報告、確認できたポリシー状態と取得可能な最終適用RSoP情報の表示に対応した。 (#388) (@Shirofune-Security) - `audit-settings`でホストの役割に適用されない監査ポリシーを`Not applicable`と表示し、カテゴリの有効・無効の集計から除外するようにした。NTLMポリシーの値は、DWORD型で保存されている場合にのみ有効な設定値として解釈・検証する。 (#392) (@Shirofune-Security) - 設定時に外部コマンドの終了コードと変更後の設定値を確認し、処理の終了前にも再確認するようにした。書き込み失敗、設定の未反映、CAサービスの再起動失敗、最終確認時の設定の不一致を明示的に報告し、一律に成功とせず、0以外の終了コードを返すようにした。 (#392) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 7a75424d..aa8c6763 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -19,7 +19,7 @@ **Bug Fixes:** -- Both `configure` paths now journal and verify `SCENoApplyLegacyAuditPolicy=1` (DWORD) before applying advanced audit subcategories. Failed or declined precedence changes block dependent writes; pre-write and final checks detect drift. Profile plans report precedence state and available last-applied RSoP evidence without claiming persistence through policy refresh. (issue #374) (@Shirofune-Security) +- Both `configure` paths now journal and verify `SCENoApplyLegacyAuditPolicy=1` (DWORD) before applying advanced audit subcategories. Failed or declined precedence changes block dependent writes; pre-write and final checks detect drift. Profile plans report precedence state and available last-applied RSoP evidence without claiming persistence through policy refresh. (#393) (@Shirofune-Security) - Fixed `configure` enabling outgoing NTLM blocking by default. It now sets Audit all (`RestrictSendingNTLMTraffic=1`) for unset or Allow policies while preserving existing Deny all (`2`) and unknown values/types. Use `-OutgoingNtlmMode Audit` to explicitly replace a deny policy, or `Deny` to enable blocking. Configuration rechecks policy before writing, verifies changes, reports failures, and displays the observed policy and available last-applied RSoP information. (#388) (@Shirofune-Security) - `audit-settings` now reports role-inapplicable audit policies as `Not applicable` and excludes them from category enablement totals. NTLM policy values are interpreted and verified only when stored as DWORDs. (#392) (@Shirofune-Security) - Configuration now checks native command exit codes, verifies settings after applying changes, and checks them again before finishing. Failed writes, ineffective changes, CA restart failures and settings that no longer match at the final check produce explicit results and a nonzero exit code instead of unconditional success. (#392) (@Shirofune-Security) 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 3/3] 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'