diff --git a/docs/firewall-logging.md b/docs/firewall-logging.md index 9d8d04da..d7290677 100644 --- a/docs/firewall-logging.md +++ b/docs/firewall-logging.md @@ -30,7 +30,7 @@ The default `Preserve` path mode never writes `LogFileName`. `CisV4` explicitly Reports retain `ActiveStore` (resultant effective policy) and `PersistentStore` (local policy), plus differing logging fields. Configure writes only PersistentStore, verifies ActiveStore after each change, and checks it again at completion. A local write overridden by GPO/MDM fails verification. A previously compliant setting that changes before the final check becomes `Overridden`; the run exits 1. These observations do not establish the current policy writer or guarantee persistence after a later policy refresh. An already compliant effective policy is accepted without manufacturing a local override. -Recovery snapshots include both stores. WELA rechecks them after confirmation and journaling, and refuses the write if logging settings changed meanwhile. There is no atomic transaction with Group Policy; subsequent races remain detectable only through the following reads. A failed control does not stop the remaining profiles. +Recovery snapshots include both stores. Preserve mode rejects an effective path that changed after planning, even before the initial recovery snapshot, and verifies access to the actual effective destination. Rerun the plan to assess a changed path. WELA rechecks both stores after confirmation and journaling, and refuses the write if logging settings changed meanwhile. Explicit `CisV4` migration instead checks the selected new destination. There is no atomic transaction with Group Policy; subsequent races remain detectable only through the following reads. A failed control does not stop the remaining profiles. ## Service permissions and operational limits diff --git a/scripts/FirewallLogging.ps1 b/scripts/FirewallLogging.ps1 index f986d283..8cbe69e4 100644 --- a/scripts/FirewallLogging.ps1 +++ b/scripts/FirewallLogging.ps1 @@ -141,7 +141,13 @@ function Set-WelaFirewallLoggingControls { $read = { param($state) $snapshot = Get-WelaFirewallLoggingState -Name $state.Name - $targetAccess = if ($snapshot.Effective.LogFileName -eq $state.Desired.LogFileName) { $snapshot.Access } else { Get-WelaFirewallLogAccess -Path $state.Desired.LogFileName } + if ($state.Desired.PathMode -eq 'Preserve' -and + [Environment]::ExpandEnvironmentVariables($snapshot.Effective.LogFileName) -ne [Environment]::ExpandEnvironmentVariables($state.Desired.LogFileName)) { + throw 'Effective firewall log path changed after planning; rerun the plan to assess and preserve the current destination.' + } + $targetAccess = if ($state.Desired.PathMode -eq 'Preserve' -or $snapshot.Effective.LogFileName -eq $state.Desired.LogFileName) { + $snapshot.Access + } else { Get-WelaFirewallLogAccess -Path $state.Desired.LogFileName } if ($targetAccess.State -ne 'VerifiedExplicitGrant') { throw "Log path access is $($targetAccess.State): $($targetAccess.Diagnostic)" } $state.Observed = $snapshot return $snapshot @@ -156,7 +162,11 @@ function Set-WelaFirewallLoggingControls { if ($fresh.$store.$property -ne $state.Observed.$store.$property) { throw "Firewall $store $property changed after the recovery snapshot; retry after reviewing policy." } } } - $access = Get-WelaFirewallLogAccess -Path $state.Desired.LogFileName + if ($state.Desired.PathMode -eq 'Preserve' -and $fresh.Access.State -ne 'VerifiedExplicitGrant') { + throw "Effective log path access is $($fresh.Access.State): $($fresh.Access.Diagnostic)" + } + $targetPath = if ($state.Desired.PathMode -eq 'Preserve') { $fresh.Effective.LogFileName } else { $state.Desired.LogFileName } + $access = Get-WelaFirewallLogAccess -Path $targetPath if ($access.State -ne 'VerifiedExplicitGrant') { throw "Log path access is $($access.State): $($access.Diagnostic)" } $size = [Math]::Max([double]$state.Desired.MinimumSizeKiB, [Math]::Max([double]$fresh.Effective.LogMaxSizeKilobytes, [double]$fresh.Local.LogMaxSizeKilobytes)) $parameters = @{ Name = $state.Name; PolicyStore = 'PersistentStore'; LogAllowed = 'True'; LogBlocked = 'True'; LogMaxSizeKilobytes = [uint64]$size; ErrorAction = 'Stop' } diff --git a/tests/FirewallLogging.Tests.ps1 b/tests/FirewallLogging.Tests.ps1 index e9114a90..610c20b0 100644 --- a/tests/FirewallLogging.Tests.ps1 +++ b/tests/FirewallLogging.Tests.ps1 @@ -21,6 +21,7 @@ function Reset-Mocks { $script:writes = 0; $script:writeArguments = @(); $script:blockedAccess = $false $script:failRead = $false; $script:failWrite = $false; $script:gpo = $false; $script:onPrompt = $null $script:accessCalls = 0 + $script:unverifiedPaths = @() } function Get-NetFirewallProfile { param($Name, $PolicyStore, $ErrorAction) @@ -47,7 +48,7 @@ function Set-NetFirewallProfile { function Get-WelaFirewallLogAccess { param($Path) $script:accessCalls++ - [pscustomobject]@{ State = $(if ($script:blockedAccess) { 'Unknown' } else { 'VerifiedExplicitGrant' }); Path = $Path; Diagnostic = 'Mock service ACL'; ServiceAccount = 'NT AUTHORITY\LocalService'; ServiceStatus = 'Running' } + [pscustomobject]@{ State = $(if ($script:blockedAccess -or $script:unverifiedPaths -contains $Path) { 'Unknown' } else { 'VerifiedExplicitGrant' }); Path = $Path; Diagnostic = 'Mock service ACL'; ServiceAccount = 'NT AUTHORITY\LocalService'; ServiceStatus = 'Running' } } function Read-Host { param($Prompt) @@ -146,6 +147,33 @@ try { Set-WelaFirewallLoggingControls $context @((Get-WelaFirewallLoggingPlan)[0]) Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Operator changes during prompt prevent stale recovery snapshot writes' + foreach ($unverified in @($true, $false)) { + Reset-Mocks + $plan = @((Get-WelaFirewallLoggingPlan)[0]) + $newPath = 'C:\ChangedAfterPlan\Domain.log' + $script:effectiveProfiles.Domain.LogFileName = $newPath + $script:localProfiles.Domain.LogFileName = $newPath + if ($unverified) { $script:unverifiedPaths = @($newPath) } + $context = New-TestContext + Set-WelaFirewallLoggingControls $context $plan + Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') "Preserve mode refuses a path changed between planning and initial read (unverified=$unverified)" + Assert ($context.Results[0].Diagnostic -match 'changed after planning') 'Stale planned path produces an actionable failure' + Assert (-not (Test-Path -LiteralPath (Join-Path $context.BackupPath 'before.jsonl'))) 'Path drift is refused before creating a mutation journal entry' + } + + Reset-Mocks + $plan = @((Get-WelaFirewallLoggingPlan)[0]) + $script:onPrompt = { $script:unverifiedPaths = @($script:effectiveProfiles.Domain.LogFileName) } + $context = New-TestContext -Prompt + Set-WelaFirewallLoggingControls $context $plan + Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Newly unknown actual destination permissions prevent a write after confirmation' + + Reset-Mocks + $script:unverifiedPaths = @($script:effectiveProfiles.Domain.LogFileName) + $context = New-TestContext + Set-WelaFirewallLoggingControls $context @((Get-WelaFirewallLoggingPlan -PathMode CisV4)[0]) + Assert ($script:writes -eq 1 -and $context.Results[0].Status -eq 'Applied') 'Explicit CIS migration can replace an unverified old destination with a verified new destination' + Reset-Mocks $plan = @(Get-WelaFirewallLoggingPlan) $script:blockedAccess = $true