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)