mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-07 23:14:45 +02:00
Merge pull request #447 from Shirofune-Security/feat/375-firewall-log-recovery
Add guarded single-profile firewall logging recovery
This commit is contained in:
13 files changed
+616
-3
No files matched your search
@@ -0,0 +1,53 @@
|
||||
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-*/*.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
|
||||
@@ -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, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-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, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/
|
||||
|
||||
- name: Set Artifact Name
|
||||
if: contains(matrix.info.os, 'windows') == true
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security)
|
||||
|
||||
- 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。
|
||||
|
||||
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
|
||||
|
||||
@@ -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)
|
||||
|
||||
- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed.
|
||||
|
||||
- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
|
||||
|
||||
@@ -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('Plan','Activate')][string]$SmbRuntimeAction = 'Plan',
|
||||
@@ -219,6 +226,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
|
||||
@@ -1968,6 +1976,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
|
||||
@@ -2033,6 +2042,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 ($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.'}
|
||||
@@ -2188,6 +2199,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object {
|
||||
if ($DryRun -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -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 'smb-runtime' -and $SmbRuntimeAction -eq 'Activate') -and
|
||||
-not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and
|
||||
@@ -2518,6 +2530,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]'
|
||||
|
||||
@@ -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. 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.
|
||||
|
||||
```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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# 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.'}
|
||||
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
|
||||
}
|
||||
|
||||
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.'}
|
||||
# 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
|
||||
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;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()}
|
||||
}
|
||||
|
||||
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 -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 -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 -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 -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 -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 -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.'}
|
||||
}
|
||||
$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 -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}
|
||||
}
|
||||
|
||||
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 -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
|
||||
$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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
$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','-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."
|
||||
@@ -0,0 +1,119 @@
|
||||
$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'
|
||||
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\','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: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}
|
||||
)){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'
|
||||
# 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."
|
||||
@@ -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"
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security)
|
||||
|
||||
- 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。
|
||||
|
||||
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
|
||||
|
||||
@@ -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)
|
||||
|
||||
- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed.
|
||||
|
||||
- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user