From 23f88776bfc451ff249009c28b2ca950121b2d59 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:52:23 +0900 Subject: [PATCH 1/8] Add guarded single-profile firewall logging recovery --- .../workflows/firewall-logging-recovery.yml | 47 ++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 18 ++ docs/firewall-logging-recovery.md | 59 +++++ scripts/FirewallLoggingRecovery.ps1 | 248 ++++++++++++++++++ tests/FirewallLoggingRecovery.Cli.Tests.ps1 | 17 ++ tests/FirewallLoggingRecovery.Tests.ps1 | 99 +++++++ .../FirewallLoggingRecovery.Windows.Tests.ps1 | 81 ++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 12 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/firewall-logging-recovery.yml create mode 100644 docs/firewall-logging-recovery.md create mode 100644 scripts/FirewallLoggingRecovery.ps1 create mode 100644 tests/FirewallLoggingRecovery.Cli.Tests.ps1 create mode 100644 tests/FirewallLoggingRecovery.Tests.ps1 create mode 100644 tests/FirewallLoggingRecovery.Windows.Tests.ps1 diff --git a/.github/workflows/firewall-logging-recovery.yml b/.github/workflows/firewall-logging-recovery.yml new file mode 100644 index 00000000..13d69d21 --- /dev/null +++ b/.github/workflows/firewall-logging-recovery.yml @@ -0,0 +1,47 @@ +name: Guarded firewall logging recovery +on: + push: + paths: ['WELA.ps1', 'scripts/FirewallLogging*', 'scripts/Configuration.ps1', 'scripts/AuditRecovery.ps1', 'scripts/WefArrival.ps1', 'scripts/WecUpdate.ps1', 'scripts/ChannelRead*', 'tests/FirewallLoggingRecovery*', '.github/workflows/firewall-logging-recovery.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + firewall-recovery: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Focused and public CLI fixtures (powershell) + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/FirewallLoggingRecovery.Tests.ps1 + ./tests/FirewallLoggingRecovery.Cli.Tests.ps1 + - name: Disposable native configuration and recovery (powershell) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/FirewallLoggingRecovery.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Focused and public CLI fixtures (pwsh) + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/FirewallLoggingRecovery.Tests.ps1 + ./tests/FirewallLoggingRecovery.Cli.Tests.ps1 + - name: Disposable native configuration and recovery (pwsh) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/FirewallLoggingRecovery.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Retain native configuration and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: firewall-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-firewall-recovery-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..22fb4307 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a6ab89ad..829c5c86 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13c6111..cafad6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index fb027456..a7c809b3 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -22,6 +22,13 @@ [ValidateSet('Audit', 'Plan', 'Configure')][string]$FirewallAction = 'Audit', [ValidateSet('Preserve', 'CisV4')][string]$FirewallPathMode = 'Preserve', [ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384, + [ValidateSet('Plan','Restore')][string]$FirewallRecoveryAction = 'Plan', + [ValidateSet('Domain','Private','Public')][string]$FirewallRecoveryProfile, + [string]$FirewallRecoveryJournalPath, + [string]$FirewallRecoveryResultsPath, + [string]$FirewallRecoveryPlanPath, + [string]$FirewallRecoveryPlanHash, + [string]$FirewallRecoveryOutputPath, [string]$HtmlPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit', [ValidateSet('Audit', 'Plan', 'Configure', 'Rollback')][string]$AdSaclAction = 'Audit', @@ -177,6 +184,7 @@ Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorActi Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") +. (Join-Path $ScriptRoot "scripts/FirewallLoggingRecovery.ps1") . (Join-Path $ScriptRoot "scripts/NativeProviderPacks.ps1") . (Join-Path $ScriptRoot "scripts/DnsAnalytical.ps1") Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop @@ -1920,6 +1928,7 @@ Usage: ./WELA.ps1 firewall-logging -FirewallAction Audit -ResultsPath firewall.json ./WELA.ps1 firewall-logging -FirewallAction Plan -FirewallPathMode CisV4 ./WELA.ps1 firewall-logging -FirewallAction Configure -DryRun + ./WELA.ps1 firewall-recovery -Help # Firewall text logging is opt-in; it does not change firewall enforcement or rules. ./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json ./WELA.ps1 smb-auditing -SmbAction Plan @@ -1977,6 +1986,8 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +if ($Cmd -ne 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'FirewallRecovery*' }).Count) {throw 'FirewallRecovery options require firewall-recovery. No command was run.'} +if ($Cmd -eq 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'} if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ChannelReadName','ChannelReadOutputPath','Help') }).Count) { throw 'channel-read accepts only dedicated channel/output options. No command was run.' } @@ -2118,6 +2129,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { if ($DryRun -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and + -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and -not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and -not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and @@ -2404,6 +2416,12 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] WMI namespace auditing: $_" -ForegroundColor Red; exit 1 } } + 'firewall-recovery' { + if ($Help) {Write-Host 'Usage: firewall-recovery [-FirewallRecoveryAction Plan] -FirewallRecoveryProfile Domain|Private|Public -FirewallRecoveryJournalPath before.jsonl -FirewallRecoveryResultsPath results.json -FirewallRecoveryOutputPath new-directory; then -FirewallRecoveryAction Restore -FirewallRecoveryPlanPath plan.json -FirewallRecoveryPlanHash SHA256 -FirewallRecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/firewall-logging-recovery.md.';return} + $report=Invoke-WelaFirewallLoggingRecovery -Action $FirewallRecoveryAction -Profile $FirewallRecoveryProfile -JournalPath $FirewallRecoveryJournalPath -ResultsPath $FirewallRecoveryResultsPath -PlanPath $FirewallRecoveryPlanPath -PlanHash $FirewallRecoveryPlanHash -OutputPath $FirewallRecoveryOutputPath -Auto:$Auto -DryRun:$DryRun + Write-Host ($report | ConvertTo-Json -Depth 24) + if ($report.ExitCode -ne 0) {exit 1} + } 'firewall-logging' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 firewall-logging [-FirewallAction Audit|Plan|Configure] [-FirewallPathMode Preserve|CisV4] [-FirewallMinimumSizeKiB 16384..32767] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/docs/firewall-logging-recovery.md b/docs/firewall-logging-recovery.md new file mode 100644 index 00000000..4dfcaba8 --- /dev/null +++ b/docs/firewall-logging-recovery.md @@ -0,0 +1,59 @@ +# Guarded firewall text-log recovery + +`firewall-recovery` plans and explicitly restores the four local logging fields for **one** Domain, Private or Public profile from a completed WELA `firewall-logging -FirewallAction Configure` operation. It uses built-in Windows functionality; Sysmon is out of scope. It does not grant event-generation, delivery, retention or Sigma readiness credit. + +The restored fields are `LogAllowed`, `LogBlocked`, `LogMaxSizeKilobytes` and `LogFileName` in `PersistentStore`. The original values can disable logging or reduce its size: review the complete proposed tuple before restoring. Microsoft distinguishes local persistent settings from the resultant `ActiveStore` policy. Recovery reports the selected effective tuple separately and does not change its policy authority. See [Set-NetFirewallProfile](https://learn.microsoft.com/en-us/powershell/module/netsecurity/set-netfirewallprofile?view=windowsserver2025-ps). + +## Prepare and review + +Keep the genuine original `before.jsonl` and final results from [firewall logging configuration](firewall-logging.md). The selected row must have final status `Applied`, dedicated scope `firewall-text-logging-only`, a matching version-1 journal entry and matching original Before/Desired/Target values. Failed, partial, ambiguous and no-op operations are not automatically recoverable. + +Use elevated native 64-bit Windows PowerShell 5.1 or PowerShell 7 on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. The plan and restoration must use the same engine version, machine identity and actual elevated operator/logon context. Impersonation is refused. Original version-1 configuration journals recorded only the computer name, so they do **not** prove historical MachineGuid or operator identity. The operator must establish that the original evidence belongs to this installation; current identity binding starts with the recovery plan. + +Create new local output directories under an existing parent, outside the WELA source tree. WELA applies private output permissions and never overwrites an old evidence directory. + +```powershell +./WELA.ps1 firewall-recovery -FirewallRecoveryProfile Domain ` + -FirewallRecoveryJournalPath C:\Evidence\configure-backup\before.jsonl ` + -FirewallRecoveryResultsPath C:\Evidence\configure-results.json ` + -FirewallRecoveryOutputPath C:\Evidence\firewall-recovery-plan +``` + +Review `plan.json`, especially `Control.Expected` (the confirmed original local After values), `Control.RecoverTo` (the exact original local Before values), the selected profile, source hashes and preserved settings. Record the reported `PlanSha256` after review. Plan reads configuration and writes evidence only. + +```powershell +# Replace this placeholder with the SHA256 from the reviewed plan. +$reviewedHash = '<64 lowercase hexadecimal characters>' +./WELA.ps1 firewall-recovery -FirewallRecoveryAction Restore ` + -FirewallRecoveryPlanPath C:\Evidence\firewall-recovery-plan\plan.json ` + -FirewallRecoveryPlanHash $reviewedHash -DryRun + +./WELA.ps1 firewall-recovery -FirewallRecoveryAction Restore ` + -FirewallRecoveryPlanPath C:\Evidence\firewall-recovery-plan\plan.json ` + -FirewallRecoveryPlanHash $reviewedHash ` + -FirewallRecoveryOutputPath C:\Evidence\firewall-recovery-result +``` + +Restoration prompts before the single native setter. `-Auto` explicitly skips that prompt; it does not skip any evidence or state guards. Dry run creates no output directory and does not call a setter. An exact already restored tuple returns `AlreadyRestored` without another write. + +## Guards and outcomes + +WELA independently rebuilds the selected operation from unchanged journal/result bytes and checks the separately supplied plan hash. It accepts explicit local `True`/`False` logging flags, an integer size from 1 through 32767 KiB and an ordinary local path. Only `%SystemRoot%` and `%windir%` variables are supported. UNC/device paths, alternate streams, dot segments, wildcards, reparse paths and unknown values are refused. `NotConfigured` is documented for GPO use and requires manual review instead of automatic local replay. The original command's Preserve/CisV4 path and maximum-size behavior must explain the recorded After tuple exactly. + +The current local tuple must equal the selected confirmed After tuple, or the exact original tuple for idempotence. A new plan binds current host/operator context, source files and native NetSecurity module files. It preserves the other two profiles in both stores, every nonlogging field of the selected profiles, and bounded native rule/filter configuration fingerprints. Filters are queried separately because conditions are exposed through filter objects; see [Get-NetFirewallPortFilter](https://learn.microsoft.com/en-us/powershell/module/netsecurity/get-netfirewallportfilter?view=windowsserver2025-ps). Inventories cap each class/store at 4096 objects and 16 MiB of canonical data. Unknown native property types, unreadable inventories or caps refuse recovery. Volatile rule operational diagnostics are excluded from configuration fingerprints. + +After a durable `pending.json` receipt, WELA rechecks inputs and current state before the one fixed `Set-NetFirewallProfile -PolicyStore PersistentStore` call. It then verifies the exact local tuple, preserved configuration and fresh/final context, retaining `confirmed.json` and `result.json`. It never changes firewall enforcement, rule definitions, other profiles, Group Policy, destination ACLs, services or shares. There is no automatic rollback. + +| Result | Meaning | +| --- | --- | +| `Planned` / `WouldRestore` | Reviewable plan / read-only current guard checks passed. | +| `LocalLoggingRestored` | Exact selected local tuple and preserved configuration passed readback and final checks. | +| `AlreadyRestored` | Original local tuple is already present; no setter was called. | +| `Refused` | A prerequisite or guard failed before a setter was attempted. | +| `WriteAttemptedUnverified` | A setter was attempted but completion or subsequent verification failed. Preserve the receipts and investigate manually. | + +`EffectiveMatchesLocal` compares the selected effective and local tuples after restoration. False can represent an effective policy override; local success does not imply effective logging was restored. Destination write authorization, actual firewall text records, future policy refresh, forwarding and long-term retention need separate acceptance. Path checks do not prove destination writability or historical file identity. Native APIs do not offer an atomic transaction over all these inventories: observed drift fails closed, but concurrent external changes between reads cannot be excluded. + +## Validation + +Focused fixtures cover strict original evidence, typed values, changed plans, stale settings, operator/source drift, post-prompt changes, partial writes, preserved enforcement and local/effective separation. The gated disposable Windows workflow uses the public Configure command to produce genuine journals, then public Plan, dry run, drift refusal, Restore and idempotence on Server 2022/2025 under both engines. Its fixture changes only logging values and a new owned log directory, restores all original logging fields, and compares complete preserved native configuration before removing that directory. It never generates traffic or changes enforcement. Windows 11, domain policy refresh and backend acceptance remain separate deployment tests. diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 new file mode 100644 index 00000000..9db87b1e --- /dev/null +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -0,0 +1,248 @@ +# One selected completed firewall text-log operation; never replay enforcement or rules. +function Get-WelaFirewallRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} + +function ConvertTo-WelaFirewallRecoveryTuple { + param($Value,[switch]$Snapshot) + $fields=@('LogAllowed','LogBlocked','LogMaxSizeKilobytes','LogFileName') + if($Snapshot){$fields=@('Name')+$fields+@('Enabled')} + Assert-WelaArrivalObject $Value $fields + if($Value.LogAllowed -isnot [string] -or $Value.LogAllowed -cnotin @('True','False') -or + $Value.LogBlocked -isnot [string] -or $Value.LogBlocked -cnotin @('True','False')){throw 'Only explicit local True/False logging switches are recoverable; GPO NotConfigured requires manual review.'} + $size=$Value.LogMaxSizeKilobytes + if(($size -isnot [int] -and $size -isnot [long] -and $size -isnot [uint64] -and $size -isnot [uint32]) -or $size -lt 1 -or $size -gt 32767){throw 'Firewall logging size must be an integer from 1 through 32767 KiB.'} + $null=Resolve-WelaFirewallRecoveryLogPath $Value.LogFileName + if($Snapshot -and ($Value.Name -isnot [string] -or $Value.Name -cnotin @('Domain','Private','Public') -or $Value.Enabled -isnot [string] -or $Value.Enabled -cnotin @('True','False','NotConfigured'))){throw 'Invalid profile snapshot identity or enabled observation.'} + [pscustomobject][ordered]@{LogAllowed=$Value.LogAllowed;LogBlocked=$Value.LogBlocked;LogMaxSizeKilobytes=[long]$size;LogFileName=$Value.LogFileName} +} + +function Resolve-WelaFirewallRecoveryLogPath { + param($Path) + if($Path -isnot [string] -or -not $Path -or $Path.Length -gt 260 -or $Path -match '[\x00-\x1f*?\[\]]' -or $Path -match '(^|[\\/])\.\.?([\\/]|$)'){throw 'A bounded ordinary local firewall log path is required.'} + # Only native Windows directory variables have reviewed meaning in old paths. + $expanded=[regex]::Replace($Path,'(?i)%(systemroot|windir)%',[Text.RegularExpressions.MatchEvaluator]{param($m) [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)}) + if($expanded -notmatch '^[A-Za-z]:\\' -or $expanded -match '%' -or $expanded.Substring(2).Contains(':') -or $expanded.EndsWith('\') -or $expanded.Contains('/')){throw 'UNC/device/relative paths, unknown variables and alternate streams are unsupported.'} + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$null=Resolve-WelaArrivalPath $expanded} + $expanded +} + +function Get-WelaFirewallRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/FirewallLoggingRecovery.ps1','scripts/FirewallLogging.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash + } + [pscustomobject]$sources +} + +function Get-WelaFirewallRecoveryContext { + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'Firewall recovery requires the actual non-impersonated elevated administrator.'} + $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $build=[int]$os.BuildNumber + if(($os.ProductType -eq 1 -and $build -notin @(22000,22621,22631,26100,26200)) -or + ($os.ProductType -in @(2,3) -and $build -notin @(20348,26100)) -or $os.ProductType -notin @(1,2,3)){throw 'Unreviewed Windows host for firewall recovery.'} + $machine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Cryptography' MachineGuid + $guid=[guid]::Empty + if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'} + $revision=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR -ErrorAction Stop + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain + Reader=[pscustomobject]@{UserSid=$reader.UserSid;UserName=$reader.UserName;AuthenticationId=$reader.AuthenticationId;GroupSids=$reader.GroupSids;ElevatedAdministrator=$reader.ElevatedAdministrator;Impersonation=$reader.Impersonation} + Engine=$PSVersionTable.PSVersion.ToString()} +} + +function Get-WelaFirewallRecoveryNativeSources { + $base=[IO.Path]::GetFullPath((Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules/NetSecurity')) + $commands=@('Get-NetFirewallProfile','Set-NetFirewallProfile','Get-NetFirewallRule')+@('Port','Address','Application','Service','Interface','InterfaceType','Security' | ForEach-Object {"Get-NetFirewall${_}Filter"}) + foreach($name in $commands){ + $command=@(Get-Command "NetSecurity\$name" -ErrorAction Stop) + if($command.Count -ne 1 -or $command[0].Name -cne $name -or [IO.Path]::GetFullPath($command[0].Module.ModuleBase) -ine $base){throw "Native NetSecurity command source is unverified: $name"} + } + $files=@(Get-ChildItem -LiteralPath $base -File -Recurse -ErrorAction Stop | Where-Object Extension -in @('.psd1','.psm1','.cdxml','.dll','.ps1xml') | Sort-Object FullName) + if($files.Count -lt 1 -or $files.Count -gt 160){throw 'Unexpected native firewall module inventory.'} + $hashes=[ordered]@{} + foreach($file in $files){if($file.Length -gt 16MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unsupported firewall module source.'};$hashes[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash} + [pscustomobject]$hashes +} + +function ConvertTo-WelaFirewallRecoveryCim { + param($Value,[string[]]$Exclude=@()) + if(-not $Value.CimClass.CimClassName -or -not $Value.CimInstanceProperties){throw 'Native firewall CIM configuration is missing.'} + $properties=@($Value.CimInstanceProperties | Sort-Object Name) + if($properties.Count -gt 160){throw 'Native firewall property bound exceeded.'} + $result=[ordered]@{Class=[string]$Value.CimClass.CimClassName} + foreach($property in $properties){ + if($property.Name -in $Exclude){continue} + if($result.Contains($property.Name)){throw 'Duplicate native firewall property.'} + $valueData=$property.Value + if(@($valueData).Count -gt 256){throw 'Native firewall property array bound exceeded.'} + foreach($item in @($valueData)){ + if($null -ne $item -and $item -isnot [string] -and $item -isnot [bool] -and $item -isnot [byte] -and + $item -isnot [uint16] -and $item -isnot [uint32] -and $item -isnot [uint64] -and $item -isnot [int16] -and $item -isnot [int] -and $item -isnot [long]){throw "Unsupported native property type: $($property.Name)"} + if($item -is [string] -and $item.Length -gt 32768){throw 'Native firewall property string bound exceeded.'} + } + $result[$property.Name]=[pscustomobject]@{Type=$property.CimType.ToString();Value=$valueData} + } + [pscustomobject]$result +} + +function Get-WelaFirewallRecoveryRuleDigest { + param([ValidateSet('PersistentStore','ActiveStore')][string]$Store) + # Hash configuration fields; volatile operational diagnostics are not policy. + $volatile=@('PrimaryStatus','Status','StatusDescriptions','EnforcementStatus','OperationalStatus','CommunicationStatus','HealthState','OperatingStatus','DetailedStatus','TimeOfLastStateChange','InstallDate') + foreach($kind in @('Rule','PortFilter','AddressFilter','ApplicationFilter','ServiceFilter','InterfaceFilter','InterfaceTypeFilter','SecurityFilter')){ + $command="NetSecurity\Get-NetFirewall$kind" + $items=@(& $command -PolicyStore $Store -ErrorAction Stop | Select-Object -First 4097) + if($items.Count -gt 4096){throw "Firewall $Store $kind inventory exceeded 4096 objects; recovery is unverified."} + $keys=@(foreach($item in $items){Get-WelaFirewallRecoveryKey (ConvertTo-WelaFirewallRecoveryCim $item $volatile)}) | Sort-Object + $bytes=[Text.UTF8Encoding]::new($false).GetBytes((Get-WelaFirewallRecoveryKey @($keys))) + if($bytes.Length -gt 16MB){throw 'Firewall configuration inventory exceeds the byte bound.'} + [pscustomobject]@{Store=$Store;Kind=$kind;Count=$items.Count;Sha256=Get-WelaArrivalHash $bytes} + } +} + +function Get-WelaFirewallRecoveryState { + $context=Get-WelaFirewallRecoveryContext + $moduleSources=Get-WelaFirewallRecoveryNativeSources + $stores=[ordered]@{};$digests=@() + foreach($store in @('PersistentStore','ActiveStore')){ + $profiles=@(NetSecurity\Get-NetFirewallProfile -PolicyStore $store -ErrorAction Stop | Sort-Object Name) + if($profiles.Count -ne 3 -or @($profiles.Name | Sort-Object -Unique).Count -ne 3){throw 'Expected exactly three native firewall profiles.'} + $byName=[ordered]@{} + foreach($profile in $profiles){ + $snapshot=ConvertTo-WelaFirewallLoggingSnapshot $profile + $logging=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot + $byName[$snapshot.Name]=[pscustomobject]@{Logging=$logging;Preserved=ConvertTo-WelaFirewallRecoveryCim $profile @('LogAllowed','LogBlocked','LogMaxSizeKilobytes','LogFileName')} + } + $stores[$store]=[pscustomobject]$byName + $digests+=@(Get-WelaFirewallRecoveryRuleDigest $store) + } + [pscustomobject][ordered]@{Context=$context;Sources=Get-WelaFirewallRecoverySources;NativeSources=$moduleSources;Profiles=[pscustomobject]$stores;RuleConfiguration=$digests} +} + +function Get-WelaFirewallRecoveryInvariant { + param($State,[string]$Profile) + $copy=Get-WelaFirewallRecoveryKey $State | ConvertFrom-Json + $copy.Profiles.PersistentStore.$Profile.Logging=$null + $copy.Profiles.ActiveStore.$Profile.Logging=$null + Get-WelaFirewallRecoveryKey $copy +} + +function Read-WelaFirewallRecoveryEvidence { + param([string]$JournalPath,[string]$ResultsPath,[ValidateSet('Domain','Private','Public')][string]$Profile,[string]$Computer) + $journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath + $entries=@($journal.Text -split '\r?\n' | Where-Object {$_ -match '\S'} | ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) + $results=ConvertFrom-WelaRecoveryJson $resultFile.Text + if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'} + $seen=@{};$final=@{} + foreach($entry in $entries){ + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -cne 'FirewallTextLog' -or + $entry.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $seen.ContainsKey($entry.Id) -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $Computer){throw 'Unknown, duplicate or wrong-host firewall journal entry.'} + if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Journal timestamp is in the future.'} + $seen[$entry.Id]=$entry + } + foreach($row in $results.Results){ + if($row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'} + $final[$row.Id]=$row + } + $id="FirewallTextLog/$Profile" + if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'} + $entry=$seen[$id];$row=$final[$id] + foreach($field in @('Before','Desired','Target')){if((Get-WelaFirewallRecoveryKey $entry.$field) -cne (Get-WelaFirewallRecoveryKey $row.$field)){throw "Journal/result $field mismatch."}} + Assert-WelaArrivalObject $entry.Target @('Name','PolicyStore') + if($entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'} + Assert-WelaArrivalObject $entry.Desired @('LogAllowed','LogBlocked','MinimumSizeKiB','LogFileName','PathMode') + $desired=$entry.Desired + if($desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'} + foreach($snapshot in @($entry.Before.Local,$entry.Before.Effective,$row.After.Local,$row.After.Effective)){ + $null=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot + if($snapshot.Name -cne $Profile){throw 'Original snapshot profile differs from selected profile.'} + } + $before=ConvertTo-WelaFirewallRecoveryTuple $entry.Before.Local -Snapshot + $expected=ConvertTo-WelaFirewallRecoveryTuple $row.After.Local -Snapshot + $effective=ConvertTo-WelaFirewallRecoveryTuple $row.After.Effective -Snapshot + $requiredSize=[Math]::Max([long]$desired.MinimumSizeKiB,[Math]::Max([long]$entry.Before.Local.LogMaxSizeKilobytes,[long]$entry.Before.Effective.LogMaxSizeKilobytes)) + $path=if($desired.PathMode -ceq 'CisV4'){'%SystemRoot%\System32\LogFiles\Firewall\'+$Profile.ToLowerInvariant()+'fw.log'}else{$before.LogFileName} + if($expected.LogAllowed -cne 'True' -or $expected.LogBlocked -cne 'True' -or $expected.LogMaxSizeKilobytes -ne $requiredSize -or $expected.LogFileName -cne $path){throw 'Recorded local After is not the permitted original logging-only change.'} + $desiredPath=Resolve-WelaFirewallRecoveryLogPath $desired.LogFileName + $plannedPath=if($desired.PathMode -ceq 'CisV4'){Resolve-WelaFirewallRecoveryLogPath $path}else{Resolve-WelaFirewallRecoveryLogPath $entry.Before.Effective.LogFileName} + if($desiredPath -ine $plannedPath -or $effective.LogAllowed -cne 'True' -or $effective.LogBlocked -cne 'True' -or $effective.LogMaxSizeKilobytes -lt $desired.MinimumSizeKiB -or + (Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'} + if((Get-WelaFirewallRecoveryKey $before) -ceq (Get-WelaFirewallRecoveryKey $expected)){throw 'Selected evidence records no local logging change.'} + [pscustomobject][ordered]@{Id=$id;Profile=$Profile;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Hash};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Hash};Expected=$expected;RecoverTo=$before} +} + +function Set-WelaFirewallRecoveryLogging { + param([ValidateSet('Domain','Private','Public')][string]$Profile,$Tuple) + $values=ConvertTo-WelaFirewallRecoveryTuple $Tuple + NetSecurity\Set-NetFirewallProfile -Name $Profile -PolicyStore PersistentStore -LogAllowed $values.LogAllowed -LogBlocked $values.LogBlocked -LogMaxSizeKilobytes ([uint64]$values.LogMaxSizeKilobytes) -LogFileName $values.LogFileName -Confirm:$false -ErrorAction Stop +} + +function Assert-WelaFirewallRecoveryInputs { + param($Plan,[string]$PlanPath,[string]$PlanHash) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Reviewed recovery plan bytes changed.'} + $rebuilt=Read-WelaFirewallRecoveryEvidence $Plan.Control.Journal.Path $Plan.Control.OriginalResults.Path $Plan.Profile $Plan.State.Context.Computer + if((Get-WelaFirewallRecoveryKey $rebuilt) -cne (Get-WelaFirewallRecoveryKey $Plan.Control)){throw 'Original recovery evidence changed or no longer matches the plan.'} +} + +function Invoke-WelaFirewallLoggingRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$Profile,[string]$JournalPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$Auto,[switch]$DryRun) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if($Profile -cnotin @('Domain','Private','Public') -or -not $JournalPath -or -not $ResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $Auto -or $DryRun){throw 'Plan requires one profile, original journal/results and new output only.'} + }elseif($Profile -or $JournalPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or ($DryRun -and $OutputPath) -or (-not $DryRun -and -not $OutputPath)){throw 'Restore requires a reviewed plan/hash and new output, or DryRun without output.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFirewallLoggingRecovery';Action=$Action;Status='Refused';ExitCode=1;WriteAttempted=$false;Before=$null;After=$null;EffectiveMatchesLocal=$null;OutputPath=$null;Artifacts=@();PlanSha256=$null;Diagnostic='';ReadyRuleCredit=0;Scope='Restore four PersistentStore logging fields on one profile only; effective policy and event generation are separate.'} + try { + if($Action -eq 'Plan'){ + $state=Get-WelaFirewallRecoveryState + $control=Read-WelaFirewallRecoveryEvidence $JournalPath $ResultsPath $Profile $state.Context.Computer + $local=$state.Profiles.PersistentStore.$Profile.Logging + if((Get-WelaFirewallRecoveryKey $local) -cne (Get-WelaFirewallRecoveryKey $control.Expected)){throw 'Current local logging tuple differs from the completed original After state.'} + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFirewallLoggingRecoveryPlan';Profile=$Profile;Control=$control;State=$state;HistoricalIdentity='Version-1 configuration journals record only ComputerName; current MachineGuid and operator/logon bind this recovery plan, not historical authorship.'} + if((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -cne (Get-WelaFirewallRecoveryKey $state)){throw 'Current firewall context changed during planning.'} + $report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot + $planText=Get-WelaFirewallRecoveryKey $plan + if([Text.UTF8Encoding]::new($false).GetByteCount($planText) -gt 4MB){throw 'Recovery plan exceeds its input byte bound.'} + $artifact=Write-WelaWecUpdateArtifact $report.OutputPath 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanSha256=$artifact.Sha256 + $report.Before=$state;$report.Status='Planned';$report.ExitCode=0 + }else{ + $source=Read-WelaWecUpdateFile $PlanPath + if($source.Hash -cne $PlanHash){throw 'Reviewed plan SHA256 differs from the selected file.'} + $plan=ConvertFrom-WelaRecoveryJson $source.Text + Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Profile','Control','State','HistoricalIdentity') + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'} + $report.PlanSha256=$source.Hash + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $current=Get-WelaFirewallRecoveryState;$report.Before=$current + $invariant=Get-WelaFirewallRecoveryInvariant $plan.State $plan.Profile + if((Get-WelaFirewallRecoveryInvariant $current $plan.Profile) -cne $invariant){throw 'Host, operator, source, enforcement, other profile or rule configuration changed since planning.'} + $local=$current.Profiles.PersistentStore.($plan.Profile).Logging + $already=(Get-WelaFirewallRecoveryKey $local) -ceq (Get-WelaFirewallRecoveryKey $plan.Control.RecoverTo) + if(-not $already -and (Get-WelaFirewallRecoveryKey $local) -cne (Get-WelaFirewallRecoveryKey $plan.Control.Expected)){throw 'Selected local logging tuple drifted from the confirmed original After state.'} + if($DryRun){$report.Status=if($already){'AlreadyRestored'}else{'WouldRestore'};$report.ExitCode=0;return $report} + $report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot + $report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'reviewed-plan.json' $source.Text + if(-not $already){ + if(-not $Auto -and (Read-Host "Restore only $($plan.Profile) firewall logging fields to the reviewed original values? (y/N)") -cnotin @('y','Y')){throw 'Recovery declined; no setter was called.'} + $report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'pending.json' (Get-WelaFirewallRecoveryKey ([pscustomobject]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');PlanSha256=$source.Hash;Before=$current;RecoverTo=$plan.Control.RecoverTo})) + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $fresh=Get-WelaFirewallRecoveryState + if((Get-WelaFirewallRecoveryKey $fresh) -cne (Get-WelaFirewallRecoveryKey $current)){throw 'Context changed after confirmation/intent receipt; no recovery setter was called.'} + foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Durable recovery evidence changed before the setter.'}} + $report.WriteAttempted=$true + Set-WelaFirewallRecoveryLogging $plan.Profile $plan.Control.RecoverTo + } + $after=Get-WelaFirewallRecoveryState;$report.After=$after + if((Get-WelaFirewallRecoveryInvariant $after $plan.Profile) -cne $invariant -or + (Get-WelaFirewallRecoveryKey $after.Profiles.PersistentStore.($plan.Profile).Logging) -cne (Get-WelaFirewallRecoveryKey $plan.Control.RecoverTo)){throw 'Local logging restoration or preserved firewall context did not verify.'} + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $report.EffectiveMatchesLocal=(Get-WelaFirewallRecoveryKey $after.Profiles.ActiveStore.($plan.Profile).Logging) -ceq (Get-WelaFirewallRecoveryKey $after.Profiles.PersistentStore.($plan.Profile).Logging) + $report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'confirmed.json' (Get-WelaFirewallRecoveryKey ([pscustomobject]@{Status='LocalReadbackVerified';PlanSha256=$source.Hash;After=$after;WriteAttempted=$report.WriteAttempted;EffectiveMatchesLocal=$report.EffectiveMatchesLocal})) + $final=Get-WelaFirewallRecoveryState;$report.After=$final + if((Get-WelaFirewallRecoveryKey $final) -cne (Get-WelaFirewallRecoveryKey $after)){throw 'Final firewall context drifted after readback.'} + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $report.Status=if($already){'AlreadyRestored'}else{'LocalLoggingRestored'};$report.ExitCode=0 + } + }catch{$report.Status=if($report.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message} + if($report.OutputPath){$null=Write-WelaWecUpdateArtifact $report.OutputPath 'result.json' (Get-WelaFirewallRecoveryKey $report)} + $report +} diff --git a/tests/FirewallLoggingRecovery.Cli.Tests.ps1 b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..09d4733b --- /dev/null +++ b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 @@ -0,0 +1,17 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$n=0 +function Check([string[]]$Arguments,[string]$Pattern,[int]$Expected=1){ + $old=$ErrorActionPreference;$ErrorActionPreference='Continue' + try{$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0) -or ($output -join ' ') -notmatch $Pattern){throw "Unexpected CLI $($Arguments -join ' '): $code $output"};$script:n++ +} +Check @('firewall-recovery','-Help') 'FirewallRecoveryPlanHash' 0 +Check @('configure','-FirewallRecoveryProfile','Domain') 'require firewall-recovery' +Check @('firewall-recovery','-FirewallAction','Configure') 'dedicated' +Check @('firewall-recovery','-RecoveryAction','Restore') 'dedicated|require audit-recovery' +Check @('firewall-recovery','-FirewallRecoveryProfile','All') 'ValidateSet|does not belong' +Check @('firewall-recovery','-FirewallRecoveryAction','Plan','-Auto') 'requires one profile' +Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-DryRun') 'reviewed plan/hash' +Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath','missing','-FirewallRecoveryPlanHash',('a'*64),'-DryRun','-FirewallRecoveryOutputPath','must-not-exist') 'reviewed plan/hash' +$global:LASTEXITCODE=0 +Write-Host "Firewall recovery public CLI: $n checks passed." diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 new file mode 100644 index 00000000..da19f0ee --- /dev/null +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -0,0 +1,99 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +foreach($file in @('Configuration','FirewallLogging','AuditRecovery','WefArrival','WecUpdate','FirewallLoggingRecovery')){. (Join-Path $repo "scripts/$file.ps1")} +$script:assertions=0;$script:writes=0;$script:mode='';$script:prompt=$null +function Assert($Value,$Message){if(-not $Value){throw "FAIL: $Message"};$script:assertions++} +function Throws($Action,$Pattern){$message='';try{& $Action | Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; received $message"} +function Copy-Fixture($Value){Get-WelaFirewallRecoveryKey $Value | ConvertFrom-Json} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-firewall-fixture-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $root +$journal=Join-Path $root 'before.jsonl';$results=Join-Path $root 'original.json' +# Only the platform output-ACL boundary and native state/setter are replaced. +# Strict input parsing, durable artifact writes and all production orchestration run. +function New-WelaArrivalOutput {param($Path,$SourcePath) if(Test-Path -LiteralPath $Path){throw 'Output exists'};$null=New-Item -ItemType Directory -Path $Path;[IO.Path]::GetFullPath($Path)} +function Snapshot($Name,$Enabled='False',$Size=4096){[pscustomobject][ordered]@{Name=$Name;LogAllowed=$Enabled;LogBlocked=$Enabled;LogMaxSizeKilobytes=$Size;LogFileName="C:\Logs\$Name.log";Enabled='True'}} +function Reset { + $script:writes=0;$script:mode='';$script:prompt=$null + $before=[pscustomobject]@{Local=Snapshot Domain;Effective=Snapshot Domain} + $after=[pscustomobject]@{Local=Snapshot Domain True 16384;Effective=Snapshot Domain True 16384;Access=[pscustomobject]@{State='VerifiedExplicitGrant'}} + $script:entry=[pscustomobject][ordered]@{Version=1;ComputerName='TEST';RecordedUtc=[DateTime]::UtcNow.ToString('o');Id='FirewallTextLog/Domain';Kind='FirewallTextLog';Target=[pscustomobject]@{Name='Domain';PolicyStore='PersistentStore'};Before=$before;Desired=[pscustomobject]@{LogAllowed='True';LogBlocked='True';MinimumSizeKiB=16384;LogFileName='C:\Logs\Domain.log';PathMode='Preserve'}} + $script:row=[pscustomobject]@{Id=$entry.Id;Kind=$entry.Kind;Target=Copy-Fixture $entry.Target;Before=Copy-Fixture $entry.Before;Desired=Copy-Fixture $entry.Desired;After=$after;Status='Applied';Diagnostic=''} + $stores=[ordered]@{} + foreach($store in @('PersistentStore','ActiveStore')){ + $profiles=[ordered]@{} + foreach($name in @('Domain','Private','Public')){$profiles[$name]=[pscustomobject]@{Logging=ConvertTo-WelaFirewallRecoveryTuple (Snapshot $name True 16384) -Snapshot;Preserved=[pscustomobject]@{Enabled=$true;DefaultInboundAction='Block';Other='unchanged'}}} + $stores[$store]=[pscustomobject]$profiles + } + $script:state=[pscustomobject][ordered]@{Context=[pscustomobject]@{Computer='TEST';MachineGuid='actual-now';Reader='sid+logon';Engine='test'};Sources=[pscustomobject]@{Code='pinned'};NativeSources=[pscustomobject]@{Module='native'};Profiles=[pscustomobject]$stores;RuleConfiguration=@([pscustomobject]@{Store='PersistentStore';Sha256='rules'})} + Save +} +function Save { + [IO.File]::WriteAllText($journal,(Get-WelaFirewallRecoveryKey $entry),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($results,(Get-WelaFirewallRecoveryKey ([pscustomobject]@{DryRun=$false;Scope='firewall-text-logging-only';Results=@($row)})),[Text.UTF8Encoding]::new($false)) +} +function Get-WelaFirewallRecoveryState {Copy-Fixture $script:state} +function Read-Host {param($Prompt) if($script:prompt){& $script:prompt};'y'} +function Set-WelaFirewallRecoveryLogging { + param($Profile,$Tuple) + Assert ($Profile -ceq 'Domain') 'Setter receives only selected profile' + $pending=Get-Content -LiteralPath (Join-Path $script:restoreOutput 'pending.json') -Raw | ConvertFrom-Json + Assert ($pending.Status -ceq 'Pending' -and $pending.RecoverTo.LogAllowed -ceq 'False') 'Durable matching pending receipt precedes setter' + $script:writes++ + $script:state.Profiles.PersistentStore.Domain.Logging=Copy-Fixture $Tuple + if($script:mode -ne 'policy'){$script:state.Profiles.ActiveStore.Domain.Logging=Copy-Fixture $Tuple} + if($script:mode -eq 'throw'){throw 'Injected partial native setter failure'} + if($script:mode -eq 'enforcement'){$script:state.Profiles.PersistentStore.Domain.Preserved.Enabled=$false} +} +function Plan { + $out=Join-Path $root ([guid]::NewGuid().ToString('N')) + $r=Invoke-WelaFirewallLoggingRecovery -Profile Domain -JournalPath $journal -ResultsPath $results -OutputPath $out + Assert ($r.Status -ceq 'Planned' -and $r.ExitCode -eq 0) "Plan accepted: $($r.Diagnostic)" + $script:planPath=Join-Path $out 'plan.json';$script:planHash=$r.PlanSha256 +} +function Restore([switch]$Prompt,[switch]$DryRun){ + $script:restoreOutput=Join-Path $root ([guid]::NewGuid().ToString('N')) + $args=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planHash;Auto=(-not $Prompt);DryRun=$DryRun} + if(-not $DryRun){$args.OutputPath=$script:restoreOutput} + Invoke-WelaFirewallLoggingRecovery @args +} +try { + Reset + $e=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST + Assert ($e.RecoverTo.LogMaxSizeKilobytes -eq 4096 -and $e.Expected.LogAllowed -ceq 'True') 'Exact typed local recovery tuple' + foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'} + foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'} + foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} + $v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected' + foreach($change in @( + {$script:row.Status='Failed'},{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, + {$script:row.After.Local.LogMaxSizeKilobytes=20000},{$script:row.After.Local.LogFileName='C:\Other.log'}, + {$script:row.After.Access.State='Unknown'},{$script:entry.ComputerName='OTHER'}, + {$script:row.Before.Local.LogBlocked='True'},{$script:entry.Desired.MinimumSizeKiB='16384';$script:row.Desired=Copy-Fixture $entry.Desired} + )){Reset;& $change;Save;Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'required|Only|permitted|confirm|wrong-host|mismatch|Unsupported'} + Reset;[IO.File]::AppendAllText($journal,"`n"+(Get-WelaFirewallRecoveryKey $entry));Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'duplicate' + Reset;[IO.File]::WriteAllText($journal,'{"Version":1,"version":1}');Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'duplicate|Duplicate|collision' + Reset;Plan;$r=Restore -DryRun;Assert ($r.Status -ceq 'WouldRestore' -and $writes -eq 0 -and -not (Test-Path $restoreOutput)) 'Dry run has no writes or output' + $r=Restore;Assert ($r.Status -ceq 'LocalLoggingRestored' -and $r.ExitCode -eq 0 -and $writes -eq 1 -and $r.EffectiveMatchesLocal -and $r.ReadyRuleCredit -eq 0) "Exact restoration succeeded: $($r.Diagnostic)" + $r=Restore;Assert ($r.Status -ceq 'AlreadyRestored' -and $writes -eq 1) 'Idempotence never calls setter' + foreach($change in @( + {$script:state.Profiles.PersistentStore.Domain.Logging.LogMaxSizeKilobytes=24576}, + {$script:state.Profiles.PersistentStore.Private.Logging.LogBlocked='False'}, + {$script:state.Profiles.PersistentStore.Domain.Preserved.Enabled=$false}, + {$script:state.RuleConfiguration[0].Sha256='drift'},{$script:state.Context.Reader='another-logon'}, + {$script:state.Sources.Code='changed'},{$script:state.NativeSources.Module='changed'} + )){Reset;Plan;& $change;$r=Restore;Assert ($r.Status -ceq 'Refused' -and -not $r.WriteAttempted -and $writes -eq 0) 'Current drift blocks every setter'} + Reset;Plan;$script:prompt={$script:state.Profiles.PersistentStore.Domain.Logging.LogBlocked='False'};$r=Restore -Prompt + Assert ($r.Status -ceq 'Refused' -and $writes -eq 0 -and (Test-Path (Join-Path $restoreOutput 'pending.json'))) 'Fresh post-prompt guard preserves pending receipt without writing' + Reset;Plan;$entry.Before.Local.LogMaxSizeKilobytes=2048;$row.Before=Copy-Fixture $entry.Before;Save;$r=Restore + Assert ($r.Status -ceq 'Refused' -and $writes -eq 0) 'Changed original inputs block restore' + Reset;Plan;[IO.File]::AppendAllText($planPath,' ');$r=Restore;Assert ($r.Status -ceq 'Refused' -and $writes -eq 0) 'Changed reviewed plan bytes block restore' + Reset;Plan;$script:mode='throw';$r=Restore + Assert ($r.Status -ceq 'WriteAttemptedUnverified' -and $r.ExitCode -eq 1 -and $r.WriteAttempted -and (Test-Path (Join-Path $restoreOutput 'pending.json')) -and -not (Test-Path (Join-Path $restoreOutput 'confirmed.json'))) 'Partial setter failure stays unverified with durable intent, never automatic rollback' + Reset;Plan;$script:mode='enforcement';$r=Restore;Assert ($r.Status -ceq 'WriteAttemptedUnverified') 'Unexpected enforcement drift fails readback' + Reset;Plan;$script:mode='policy';$r=Restore;Assert ($r.Status -ceq 'LocalLoggingRestored' -and -not $r.EffectiveMatchesLocal) 'Local restoration is separate from unchanged effective override' + # CIM configuration hashes must retain enforcement/condition data and typed nulls. + $cim=[pscustomobject]@{CimClass=[pscustomobject]@{CimClassName='MSFT_NetFirewallRule'};CimInstanceProperties=@([pscustomobject]@{Name='Enabled';Value=1;CimType='UInt16'},[pscustomobject]@{Name='Status';Value='volatile';CimType='String'})} + $key=ConvertTo-WelaFirewallRecoveryCim $cim @('Status');Assert ($key.Enabled.Type -eq 'UInt16' -and -not $key.PSObject.Properties['Status']) 'Rule hash preserves typed configuration while excluding named diagnostics' + $cim.CimInstanceProperties[0].Value=[DateTime]::UtcNow;Throws {ConvertTo-WelaFirewallRecoveryCim $cim} 'Unsupported native property type' +} finally {Remove-Item -LiteralPath $root -Recurse -Force} +$global:LASTEXITCODE=0 +Write-Host "Firewall logging recovery: $script:assertions assertions passed." diff --git a/tests/FirewallLoggingRecovery.Windows.Tests.ps1 b/tests/FirewallLoggingRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..b2b369d0 --- /dev/null +++ b/tests/FirewallLoggingRecovery.Windows.Tests.ps1 @@ -0,0 +1,81 @@ +param([switch]$AllowDisposableLoggingWrite) +$ErrorActionPreference='Stop' +if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: native Windows required.';exit 0} +if(-not $AllowDisposableLoggingWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted logging-write opt-in is required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +foreach($file in @('Configuration','FirewallLogging','AuditRecovery','WefArrival','WecUpdate','ChannelRead','FirewallLoggingRecovery')){. (Join-Path $repo "scripts/$file.ps1")} +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-firewall-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $root +$logDirectory=Join-Path $root 'owned-logs';$null=New-Item -ItemType Directory -Path $logDirectory +$script:checks=0;$script:cliIndex=0;$before=$null;$cleanup=$false +function Assert-Native($Value,$Message){if(-not $Value){throw $Message};$script:checks++;Write-Host "PASS: $Message"} +function Save-Native($Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),(Get-WelaFirewallRecoveryKey $Value),[Text.UTF8Encoding]::new($false))} +function Invoke-FixtureCli([string[]]$Arguments,[int]$Expected=0){ + $script:cliIndex++;$old=$ErrorActionPreference;$ErrorActionPreference='Continue' + try{$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + $output | Out-File -LiteralPath (Join-Path $root ("cli-$script:cliIndex.txt")) -Encoding utf8 + if(-not (($Expected -eq 0 -and $code -eq 0) -or ($Expected -ne 0 -and $code -ne 0))){throw "Public $($Arguments[0]) exit $code (expected $Expected): $($output -join ' ')"} + Assert-Native $true "Public $($Arguments[0]) exit $code (expected $Expected)" +} +try { + $before=Get-WelaFirewallRecoveryState;Save-Native 'safety-before.json' $before + # All test-only changes are the four logging fields and a new owned directory. + # The product never changes destination ACLs, service state, enforcement or rules. + $acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false) + $owner=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$sid=$owner.User;$acl.SetOwner($sid)}finally{$owner.Dispose()} + $service=([Security.Principal.NTAccount]::new('NT SERVICE\mpssvc')).Translate([Security.Principal.SecurityIdentifier]) + foreach($principal in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-5-18'),[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($principal,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))} + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($service,'Modify','ContainerInherit,ObjectInherit','None','Allow')) + Set-Acl -LiteralPath $logDirectory -AclObject $acl + foreach($profile in @('Domain','Private','Public')){ + NetSecurity\Set-NetFirewallProfile -Name $profile -PolicyStore PersistentStore -LogAllowed False -LogBlocked False -LogMaxSizeKilobytes 4096 -LogFileName (Join-Path $logDirectory "$profile.log") -Confirm:$false -ErrorAction Stop + } + $prepared=Get-WelaFirewallRecoveryState;Save-Native 'prepared.json' $prepared + $original=Join-Path $root 'original.json';$backup=Join-Path $root 'configure-backup' + Invoke-FixtureCli @('firewall-logging','-FirewallAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',$original) + $originalReport=Get-Content -LiteralPath $original -Raw | ConvertFrom-Json + Assert-Native (@($originalReport.Results | Where-Object Status -ceq 'Applied').Count -eq 3) 'Public Configure produced three actual completed Applied journals' + $configured=Get-WelaFirewallRecoveryState;Save-Native 'configured.json' $configured + $planDirectory=Join-Path $root 'plan' + Invoke-FixtureCli @('firewall-recovery','-FirewallRecoveryProfile','Domain','-FirewallRecoveryJournalPath',(Join-Path $backup 'before.jsonl'),'-FirewallRecoveryResultsPath',$original,'-FirewallRecoveryOutputPath',$planDirectory) + $planPath=Join-Path $planDirectory 'plan.json';$planHash=(Get-FileHash -LiteralPath $planPath -Algorithm SHA256).Hash.ToLowerInvariant() + $restoreArgs=@('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath',$planPath,'-FirewallRecoveryPlanHash',$planHash) + Invoke-FixtureCli ($restoreArgs+@('-DryRun')) + Assert-Native ((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -ceq (Get-WelaFirewallRecoveryKey $configured)) 'Public dry run preserves complete native state' + NetSecurity\Set-NetFirewallProfile -Name Domain -PolicyStore PersistentStore -LogMaxSizeKilobytes 24576 -Confirm:$false -ErrorAction Stop + $drift=Get-WelaFirewallRecoveryState + Invoke-FixtureCli ($restoreArgs+@('-DryRun')) 1 + Assert-Native ((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -ceq (Get-WelaFirewallRecoveryKey $drift)) 'Changed confirmed After tuple is refused without mutation' + NetSecurity\Set-NetFirewallProfile -Name Domain -PolicyStore PersistentStore -LogMaxSizeKilobytes 16384 -Confirm:$false -ErrorAction Stop + $restoreDirectory=Join-Path $root 'restore' + Invoke-FixtureCli ($restoreArgs+@('-Auto','-FirewallRecoveryOutputPath',$restoreDirectory)) + $result=Get-Content -LiteralPath (Join-Path $restoreDirectory 'result.json') -Raw | ConvertFrom-Json + Assert-Native ($result.Status -ceq 'LocalLoggingRestored' -and $result.WriteAttempted -and $result.ReadyRuleCredit -eq 0) 'Public recovery confirms the actual local four-field tuple without Sigma credit' + $recovered=Get-WelaFirewallRecoveryState;Save-Native 'recovered.json' $recovered + Assert-Native ((Get-WelaFirewallRecoveryKey $recovered.Profiles.PersistentStore.Domain.Logging) -ceq (Get-WelaFirewallRecoveryKey $prepared.Profiles.PersistentStore.Domain.Logging)) 'Selected local logging tuple exactly matches its original before values' + Assert-Native ((Get-WelaFirewallRecoveryInvariant $recovered Domain) -ceq (Get-WelaFirewallRecoveryInvariant $configured Domain)) 'Enforcement, other profiles and both-store rule/filter configurations remain unchanged' + foreach($artifact in $result.Artifacts){Assert-Native ((Get-FileHash -LiteralPath (Join-Path $restoreDirectory $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) "Verified retained receipt $($artifact.Name)"} + $again=Join-Path $root 'again';Invoke-FixtureCli ($restoreArgs+@('-Auto','-FirewallRecoveryOutputPath',$again)) + $againResult=Get-Content -LiteralPath (Join-Path $again 'result.json') -Raw | ConvertFrom-Json + Assert-Native ($againResult.Status -ceq 'AlreadyRestored' -and -not $againResult.WriteAttempted) 'Public repeated recovery is idempotent without a setter' +} finally { + $errors=@() + if($before){ + foreach($profile in @('Domain','Private','Public')){ + try{Set-WelaFirewallRecoveryLogging $profile $before.Profiles.PersistentStore.$profile.Logging}catch{$errors+="$profile cleanup: $($_.Exception.Message)"} + } + try{$final=Get-WelaFirewallRecoveryState;Save-Native 'safety-after.json' $final;if((Get-WelaFirewallRecoveryKey $final) -cne (Get-WelaFirewallRecoveryKey $before)){throw 'Complete final native firewall configuration differs from original safety snapshot.'}}catch{$errors+=$_.Exception.Message} + } + # Service handles may briefly retain the old owned path after restoring all profiles. + if(-not $errors.Count){ + for($attempt=0;$attempt -lt 10;$attempt++){ + try{Remove-Item -LiteralPath $logDirectory -Recurse -Force -ErrorAction Stop;break}catch{if($attempt -eq 9){$errors+=$_.Exception.Message}else{Start-Sleep -Milliseconds 500}} + } + } + $cleanup=-not $errors.Count + Save-Native 'acceptance.json' ([pscustomobject]@{Build=$before.Context.Build;Engine=$PSVersionTable.PSVersion.ToString();Checks=$checks;CleanupVerified=$cleanup;CleanupErrors=$errors;Scope='Actual public Configure/Plan/Restore, drift refusal and idempotence; all original profile logging, enforcement and bounded native rule/filter configuration restored. No event/Sigma proof.'}) + if($errors.Count){throw "Fixture cleanup failed; evidence at $root : $($errors -join '; ')"} +} +$global:LASTEXITCODE=0 +Write-Host "PASS: $checks native firewall recovery checks; exact safety cleanup. Evidence: $root" diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..e632da5e 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 91d49131..c5517e53 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From 8c6a5974255c3acc81e7bde8b0242aa3bb7a4f41 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:55:25 +0900 Subject: [PATCH 2/8] Cover CIS recovery paths and bound retained native evidence --- .github/workflows/firewall-logging-recovery.yml | 8 +++++++- tests/FirewallLoggingRecovery.Tests.ps1 | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/firewall-logging-recovery.yml b/.github/workflows/firewall-logging-recovery.yml index 13d69d21..b6a7749f 100644 --- a/.github/workflows/firewall-logging-recovery.yml +++ b/.github/workflows/firewall-logging-recovery.yml @@ -42,6 +42,12 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: firewall-recovery-${{ matrix.os }}-${{ matrix.engine }} - path: ${{ runner.temp }}/wela-firewall-recovery-*/ + path: | + ${{ runner.temp }}/wela-firewall-recovery-*/*.json + ${{ runner.temp }}/wela-firewall-recovery-*/cli-*.txt + ${{ runner.temp }}/wela-firewall-recovery-*/configure-backup/ + ${{ runner.temp }}/wela-firewall-recovery-*/plan/ + ${{ runner.temp }}/wela-firewall-recovery-*/restore/ + ${{ runner.temp }}/wela-firewall-recovery-*/again/ if-no-files-found: warn retention-days: 7 diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index da19f0ee..0d1ef01a 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -59,6 +59,13 @@ try { Reset $e=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST Assert ($e.RecoverTo.LogMaxSizeKilobytes -eq 4096 -and $e.Expected.LogAllowed -ceq 'True') 'Exact typed local recovery tuple' + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){ + $entry.Desired.PathMode='CisV4';$entry.Desired.LogFileName='%SystemRoot%\System32\LogFiles\Firewall\domainfw.log';$row.Desired=Copy-Fixture $entry.Desired + $row.After.Local.LogFileName=$entry.Desired.LogFileName;$row.After.Effective.LogFileName=$entry.Desired.LogFileName;Save + $migration=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST + Assert ($migration.RecoverTo.LogFileName -ceq 'C:\Logs\Domain.log' -and $migration.Expected.LogFileName -ceq $entry.Desired.LogFileName) 'CIS migration preserves the exact original local recovery path' + Reset + } foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'} foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'} foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} From 43683b7aa35949f7950635f9b722626d73828b51 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:55:50 +0900 Subject: [PATCH 3/8] Link guarded recovery from firewall configuration guide --- docs/firewall-logging.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/firewall-logging.md b/docs/firewall-logging.md index d7290677..0c95595d 100644 --- a/docs/firewall-logging.md +++ b/docs/firewall-logging.md @@ -40,7 +40,9 @@ WELA does not attempt to broaden ACLs, resolve arbitrary group membership, imper Each snapshot also reports the firewall profile's `Enabled` value. A compliant logging configuration on a disabled/inactive profile is preparation for that profile, not proof of traffic events. WELA never changes that enforcement state. Text logs and Security EVTX audit events are separate sources; increasing an EVTX buffer does not configure these text logs, and a WEF subscription alone does not collect arbitrary text files. -## Manual recovery +## Guarded and manual recovery + +For one completed `Applied` operation with matching original journal and results, use the explicit [guarded firewall logging recovery](firewall-logging-recovery.md) Plan/Restore workflow. It verifies the current confirmed local After values, restores the original four local logging fields and preserves enforcement, other profiles and bounded native rule/filter configuration. Partial, ambiguous, drifted and unsupported operations still require manual investigation. There is no automatic rollback. Preserve `before.jsonl` and the results JSON. Before recovery, review failed versus applied controls, concurrent operator changes and GPO/MDM ownership. Restore the **local** snapshot, not the effective snapshot; applied policy may continue overriding it. Example for one reviewed journal entry: @@ -60,7 +62,7 @@ Do not blindly replay a journal: a failed write can have left the old state unto ## Validation and remaining integration evidence -The automated suite uses mocked firewall writes and temporary recovery files to check all profiles, larger limits, path preservation/CIS selection, effective-versus-local conflicts, idempotence, journal ordering, unknown permissions, read/write errors, prompt races and final drift. Windows CI runs these checks under PowerShell 5.1 and 7, plus actual read-only ActiveStore/PersistentStore and ACL inspection and a dry run. It does not alter runner firewall policy or generate traffic. +The original automated suite uses mocked firewall writes and temporary recovery files to check all profiles, larger limits, path preservation/CIS selection, effective-versus-local conflicts, idempotence, journal ordering, unknown permissions, read/write errors, prompt races and final drift. Its Windows smoke performs read-only ActiveStore/PersistentStore and ACL inspection and a dry run. The separate guarded-recovery workflow explicitly changes logging fields in a disposable owned fixture through the public Configure/Restore commands, then verifies exact restoration under PowerShell 5.1 and 7 on Server 2022/2025. Neither suite changes firewall enforcement or generates traffic. Before closing issue #375, capture evidence from an isolated Windows client/server lab: OS build, PowerShell version, WELA commit, before/after JSON, effective/local settings and service ACLs. On each applicable active network profile, generate one benign allowed connection and one controlled blocked connection against a disposable endpoint, confirm corresponding `ALLOW`/`DROP` text records and timestamps, and confirm the expected source path and parser in the actual collector. Test log creation and rotation under the actual service token, policy refresh/override behavior, and manual recovery. Do not weaken production filtering to create this evidence. These traffic/rotation/ingestion tests remain unperformed; no end-to-end detection claim is made. From e8b018d5c984c4277670029a645d03ae6081bb5d Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:56:45 +0900 Subject: [PATCH 4/8] Refuse ambiguous Windows path aliases during recovery --- scripts/FirewallLoggingRecovery.ps1 | 3 +++ tests/FirewallLoggingRecovery.Tests.ps1 | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 index 9db87b1e..b4b224bd 100644 --- a/scripts/FirewallLoggingRecovery.ps1 +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -21,6 +21,9 @@ function Resolve-WelaFirewallRecoveryLogPath { # Only native Windows directory variables have reviewed meaning in old paths. $expanded=[regex]::Replace($Path,'(?i)%(systemroot|windir)%',[Text.RegularExpressions.MatchEvaluator]{param($m) [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)}) if($expanded -notmatch '^[A-Za-z]:\\' -or $expanded -match '%' -or $expanded.Substring(2).Contains(':') -or $expanded.EndsWith('\') -or $expanded.Contains('/')){throw 'UNC/device/relative paths, unknown variables and alternate streams are unsupported.'} + foreach($segment in $expanded.Substring(3).Split([char]'\')){ + if(-not $segment -or $segment -match '[ .]$' -or $segment -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)'){throw 'Ambiguous path segments and Windows device aliases are unsupported.'} + } if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$null=Resolve-WelaArrivalPath $expanded} $expanded } diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index 0d1ef01a..f63a160d 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -68,7 +68,7 @@ try { } foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'} foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'} - foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} + foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\','C:\Logs\CON.log','C:\Logs\log.','C:\Logs\log ','C:\Logs\\log')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} $v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected' foreach($change in @( {$script:row.Status='Failed'},{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, From dd950c7358b7ecf654ad05d4d7b4c83543013ea4 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:11:21 +0900 Subject: [PATCH 5/8] Reject boolean coercion in completed recovery evidence --- scripts/FirewallLoggingRecovery.ps1 | 16 ++++++++-------- tests/FirewallLoggingRecovery.Tests.ps1 | 6 +++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 index b4b224bd..ae70db7b 100644 --- a/scripts/FirewallLoggingRecovery.ps1 +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -135,27 +135,27 @@ function Read-WelaFirewallRecoveryEvidence { $journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath $entries=@($journal.Text -split '\r?\n' | Where-Object {$_ -match '\S'} | ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) $results=ConvertFrom-WelaRecoveryJson $resultFile.Text - if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'} + if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -isnot [string] -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'} $seen=@{};$final=@{} foreach($entry in $entries){ - if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -cne 'FirewallTextLog' -or + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'FirewallTextLog' -or $entry.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $seen.ContainsKey($entry.Id) -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $Computer){throw 'Unknown, duplicate or wrong-host firewall journal entry.'} if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Journal timestamp is in the future.'} $seen[$entry.Id]=$entry } foreach($row in $results.Results){ - if($row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'} + if($row.Kind -isnot [string] -or $row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'} $final[$row.Id]=$row } $id="FirewallTextLog/$Profile" - if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'} + if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -isnot [string] -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'} $entry=$seen[$id];$row=$final[$id] foreach($field in @('Before','Desired','Target')){if((Get-WelaFirewallRecoveryKey $entry.$field) -cne (Get-WelaFirewallRecoveryKey $row.$field)){throw "Journal/result $field mismatch."}} Assert-WelaArrivalObject $entry.Target @('Name','PolicyStore') - if($entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'} + if($entry.Target.Name -isnot [string] -or $entry.Target.PolicyStore -isnot [string] -or $entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'} Assert-WelaArrivalObject $entry.Desired @('LogAllowed','LogBlocked','MinimumSizeKiB','LogFileName','PathMode') $desired=$entry.Desired - if($desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'} + if($desired.LogAllowed -isnot [string] -or $desired.LogBlocked -isnot [string] -or $desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'} foreach($snapshot in @($entry.Before.Local,$entry.Before.Effective,$row.After.Local,$row.After.Effective)){ $null=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot if($snapshot.Name -cne $Profile){throw 'Original snapshot profile differs from selected profile.'} @@ -169,7 +169,7 @@ function Read-WelaFirewallRecoveryEvidence { $desiredPath=Resolve-WelaFirewallRecoveryLogPath $desired.LogFileName $plannedPath=if($desired.PathMode -ceq 'CisV4'){Resolve-WelaFirewallRecoveryLogPath $path}else{Resolve-WelaFirewallRecoveryLogPath $entry.Before.Effective.LogFileName} if($desiredPath -ine $plannedPath -or $effective.LogAllowed -cne 'True' -or $effective.LogBlocked -cne 'True' -or $effective.LogMaxSizeKilobytes -lt $desired.MinimumSizeKiB -or - (Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'} + (Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -isnot [string] -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'} if((Get-WelaFirewallRecoveryKey $before) -ceq (Get-WelaFirewallRecoveryKey $expected)){throw 'Selected evidence records no local logging change.'} [pscustomobject][ordered]@{Id=$id;Profile=$Profile;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Hash};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Hash};Expected=$expected;RecoverTo=$before} } @@ -212,7 +212,7 @@ function Invoke-WelaFirewallLoggingRecovery { if($source.Hash -cne $PlanHash){throw 'Reviewed plan SHA256 differs from the selected file.'} $plan=ConvertFrom-WelaRecoveryJson $source.Text Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Profile','Control','State','HistoricalIdentity') - if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'} + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -isnot [string] -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'} $report.PlanSha256=$source.Hash Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash $current=Get-WelaFirewallRecoveryState;$report.Before=$current diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index f63a160d..b1baaa9b 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -71,7 +71,11 @@ try { foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\','C:\Logs\CON.log','C:\Logs\log.','C:\Logs\log ','C:\Logs\\log')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} $v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected' foreach($change in @( - {$script:row.Status='Failed'},{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, + {$script:row.Status='Failed'},{$script:row.Status=$true},{$script:row.After.Access.State=$true}, + {$script:entry.Target.Name=$true;$script:row.Target=Copy-Fixture $entry.Target}, + {$script:entry.Target.PolicyStore=$true;$script:row.Target=Copy-Fixture $entry.Target}, + {$script:entry.Desired.LogAllowed=$true;$script:row.Desired=Copy-Fixture $entry.Desired}, + {$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, {$script:row.After.Local.LogMaxSizeKilobytes=20000},{$script:row.After.Local.LogFileName='C:\Other.log'}, {$script:row.After.Access.State='Unknown'},{$script:entry.ComputerName='OTHER'}, {$script:row.Before.Local.LogBlocked='True'},{$script:entry.Desired.MinimumSizeKiB='16384';$script:row.Desired=Copy-Fixture $entry.Desired} From 83ab9c875239d426f26f93e283122f635b27cbce Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:15:02 +0900 Subject: [PATCH 6/8] Require running firewall providers before recovery observations --- docs/firewall-logging-recovery.md | 2 +- scripts/FirewallLoggingRecovery.ps1 | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/firewall-logging-recovery.md b/docs/firewall-logging-recovery.md index 4dfcaba8..370b57ba 100644 --- a/docs/firewall-logging-recovery.md +++ b/docs/firewall-logging-recovery.md @@ -8,7 +8,7 @@ The restored fields are `LogAllowed`, `LogBlocked`, `LogMaxSizeKilobytes` and `L Keep the genuine original `before.jsonl` and final results from [firewall logging configuration](firewall-logging.md). The selected row must have final status `Applied`, dedicated scope `firewall-text-logging-only`, a matching version-1 journal entry and matching original Before/Desired/Target values. Failed, partial, ambiguous and no-op operations are not automatically recoverable. -Use elevated native 64-bit Windows PowerShell 5.1 or PowerShell 7 on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. The plan and restoration must use the same engine version, machine identity and actual elevated operator/logon context. Impersonation is refused. Original version-1 configuration journals recorded only the computer name, so they do **not** prove historical MachineGuid or operator identity. The operator must establish that the original evidence belongs to this installation; current identity binding starts with the recovery plan. +Use elevated native 64-bit Windows PowerShell 5.1 or PowerShell 7 on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Winmgmt, MpsSvc and BFE must already be running before native provider reads. The plan and restoration must use the same engine version, machine identity and actual elevated operator/logon context. Impersonation is refused. Original version-1 configuration journals recorded only the computer name, so they do **not** prove historical MachineGuid or operator identity. The operator must establish that the original evidence belongs to this installation; current identity binding starts with the recovery plan. Create new local output directories under an existing parent, outside the WELA source tree. WELA applies private output permissions and never overwrites an old evidence directory. diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 index ae70db7b..06926410 100644 --- a/scripts/FirewallLoggingRecovery.ps1 +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -39,6 +39,10 @@ function Get-WelaFirewallRecoverySources { function Get-WelaFirewallRecoveryContext { $reader=Get-WelaChannelReader if(-not $reader.ElevatedAdministrator){throw 'Firewall recovery requires the actual non-impersonated elevated administrator.'} + # Observe service state before connecting to native WMI/NetSecurity providers. + # A read must not be used to start prerequisites implicitly. + $services=@(Get-Service -Name Winmgmt,MpsSvc,BFE -ErrorAction Stop | Sort-Object Name | ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services | Where-Object Status -cne 'Running').Count){throw 'Winmgmt, MpsSvc and BFE must already be running; recovery starts no services.'} $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop $build=[int]$os.BuildNumber @@ -48,7 +52,7 @@ function Get-WelaFirewallRecoveryContext { $guid=[guid]::Empty if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'} $revision=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR -ErrorAction Stop - [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain;Services=$services Reader=[pscustomobject]@{UserSid=$reader.UserSid;UserName=$reader.UserName;AuthenticationId=$reader.AuthenticationId;GroupSids=$reader.GroupSids;ElevatedAdministrator=$reader.ElevatedAdministrator;Impersonation=$reader.Impersonation} Engine=$PSVersionTable.PSVersion.ToString()} } From e4e60684ac27da775fa0db28ae2e28fd3f4b2335 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:15:30 +0900 Subject: [PATCH 7/8] Verify stopped providers are refused before connecting --- tests/FirewallLoggingRecovery.Tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index b1baaa9b..e78cb012 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -105,6 +105,15 @@ try { $cim=[pscustomobject]@{CimClass=[pscustomobject]@{CimClassName='MSFT_NetFirewallRule'};CimInstanceProperties=@([pscustomobject]@{Name='Enabled';Value=1;CimType='UInt16'},[pscustomobject]@{Name='Status';Value='volatile';CimType='String'})} $key=ConvertTo-WelaFirewallRecoveryCim $cim @('Status');Assert ($key.Enabled.Type -eq 'UInt16' -and -not $key.PSObject.Properties['Status']) 'Rule hash preserves typed configuration while excluding named diagnostics' $cim.CimInstanceProperties[0].Value=[DateTime]::UtcNow;Throws {ConvertTo-WelaFirewallRecoveryCim $cim} 'Unsupported native property type' + # A prerequisite read must not connect to WMI while its services are stopped. + $script:providerReads=0;$script:serviceStatus='Stopped' + function Get-WelaChannelReader {[pscustomobject]@{ElevatedAdministrator=$true}} + function Get-Service {param($Name,$ErrorAction) foreach($n in $Name){[pscustomobject]@{Name=$n;Status=$script:serviceStatus}}} + function Get-CimInstance {$script:providerReads++;throw 'Native provider boundary reached'} + Throws {Get-WelaFirewallRecoveryContext} 'must already be running' + Assert ($providerReads -eq 0) 'Stopped services are refused before any native provider connection' + $script:serviceStatus='Running';Throws {Get-WelaFirewallRecoveryContext} 'Native provider boundary reached' + Assert ($providerReads -eq 1) 'Running services permit the first native provider read' } finally {Remove-Item -LiteralPath $root -Recurse -Force} $global:LASTEXITCODE=0 Write-Host "Firewall logging recovery: $script:assertions assertions passed." From 69806573371452f7232ef4635a9ccee0f3d9026f Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:53 +0900 Subject: [PATCH 8/8] Reject unbound recovery options before command dispatch --- WELA.ps1 | 2 +- tests/FirewallLoggingRecovery.Cli.Tests.ps1 | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/WELA.ps1 b/WELA.ps1 index 3080e276..80e9072f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2013,7 +2013,7 @@ Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" if ($Cmd -ne 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'FirewallRecovery*' }).Count) {throw 'FirewallRecovery options require firewall-recovery. No command was run.'} -if ($Cmd -eq 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'} +if ($Cmd -eq 'firewall-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count)) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'} if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } if ($Cmd -ne 'smb-runtime' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'SmbRuntime*' }).Count) {throw 'SmbRuntime options require smb-runtime. No command was run.'} if ($Cmd -eq 'smb-runtime' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','SmbRuntimeAction','SmbRuntimeOutputPath','Auto','DryRun','Help') }).Count) {throw 'smb-runtime accepts only its dedicated options, Auto and DryRun. No command was run.'} diff --git a/tests/FirewallLoggingRecovery.Cli.Tests.ps1 b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 index 09d4733b..2fb1346a 100644 --- a/tests/FirewallLoggingRecovery.Cli.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 @@ -12,6 +12,7 @@ Check @('firewall-recovery','-RecoveryAction','Restore') 'dedicated|require audi Check @('firewall-recovery','-FirewallRecoveryProfile','All') 'ValidateSet|does not belong' Check @('firewall-recovery','-FirewallRecoveryAction','Plan','-Auto') 'requires one profile' Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-DryRun') 'reviewed plan/hash' +Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-WhatIf') 'dedicated options' Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath','missing','-FirewallRecoveryPlanHash',('a'*64),'-DryRun','-FirewallRecoveryOutputPath','must-not-exist') 'reviewed plan/hash' $global:LASTEXITCODE=0 Write-Host "Firewall recovery public CLI: $n checks passed."