From 0c51232a406cdef838c1ebc2dbca717ddc4832a6 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:52:16 +0900 Subject: [PATCH] Add reviewed recovery of one completed native channel operation --- .gitattributes | 4 + .github/workflows/channel-recovery.yml | 41 ++++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 21 +++ docs/channel-recovery.md | 40 +++++ docs/native-channel-access.md | 2 +- scripts/ChannelRecovery.ps1 | 185 ++++++++++++++++++++++++ tests/ChannelRecovery.Cli.Tests.ps1 | 16 ++ tests/ChannelRecovery.Tests.ps1 | 97 +++++++++++++ tests/ChannelRecovery.Windows.Tests.ps1 | 108 ++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 14 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/channel-recovery.yml create mode 100644 docs/channel-recovery.md create mode 100644 scripts/ChannelRecovery.ps1 create mode 100644 tests/ChannelRecovery.Cli.Tests.ps1 create mode 100644 tests/ChannelRecovery.Tests.ps1 create mode 100644 tests/ChannelRecovery.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 190bc45a..917cf52b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -68,3 +68,7 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WecState* text eol=lf /scripts/WecRuntime* text eol=lf /tests/WecState* text eol=lf + +# Reviewed channel restoration binds exact installed source bytes. +/scripts/ChannelRecovery.ps1 text eol=lf +/tests/ChannelRecovery*.ps1 text eol=lf diff --git a/.github/workflows/channel-recovery.yml b/.github/workflows/channel-recovery.yml new file mode 100644 index 00000000..3df83b6a --- /dev/null +++ b/.github/workflows/channel-recovery.yml @@ -0,0 +1,41 @@ +name: Reviewed native channel recovery +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + channel-recovery: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Focused and native public recovery on Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/ChannelRecovery.Tests.ps1 + ./tests/ChannelRecovery.Cli.Tests.ps1 + ./tests/ChannelRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Focused and native public recovery on PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/ChannelRecovery.Tests.ps1 + ./tests/ChannelRecovery.Cli.Tests.ps1 + ./tests/ChannelRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Retain native original, restoration and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: channel-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-channel-recovery-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d039e5b..085a1391 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, ./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/ + 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/channel-recovery.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 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index d0e1cea8..c1a0d4cc 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 完了済みの標準チャネル設定を1件ずつ復元する `channel-recovery` を追加しました。元の記録と結果、確認済み計画ハッシュ、現在の記述子と設定を検証し、縮小・無効化・追加した読み取り権限の撤回には個別の同意を要求します。項目ごとの永続記録を残し、元の操作で変更した項目だけを復元します。公開 Configure/Restore の標準 Windows テストで拒否・途中失敗・元設定への後始末を確認し、イベント消失・保存期間・転送・Sigma 対応は別に扱います。(関連 #367、#365) (@Shirofune-Security) + - Windows PowerShell 5.1 と PowerShell 7、使い捨ての Server 2022/2025 で標準チャネル設定の公開CLIを検証するテストを追加しました。有効化・サイズ・CAPI2読み取り専用権限の適用、既存記述子と大きいバッファーの保持、変更前記録、DryRun、再実行時の無変更、元設定への復元を確認し、ハッシュ付きの証拠を保存します。転送・保存期間・Sigmaの検証は別途必要です。 (@Shirofune-Security) - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08966619..11e1d57b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `channel-recovery` for one completed native channel-settings operation. Strict journal/result reconstruction, reviewed plan hashes, exact current descriptor/settings, separate shrink/disable/read-revocation consent and per-field durable receipts restore only originally changed fields. Native public Configure/Restore tests cover refusal, partial outcomes and exact fixture cleanup; event loss, retention, forwarding and Sigma readiness remain separate. (Related #367, #365) (@Shirofune-Security) + - Added disposable Server 2022/2025 validation of public native channel configuration under Windows PowerShell 5.1 and PowerShell 7. Tests apply enable/size controls and the explicit CAPI2 read-only grant, verify descriptor preservation, journals, larger buffers, DryRun and idempotence, and retain hashed native evidence with exact fixture cleanup. Forwarding, retention-duration and Sigma validation remain separate. (@Shirofune-Security) - 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) diff --git a/WELA.ps1 b/WELA.ps1 index fd44c7a9..9a7e80e7 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -126,6 +126,16 @@ [string]$TranscriptRecoveryPlanHash, [string]$TranscriptRecoveryOutputPath, [switch]$TranscriptRecoveryAllowTemporarySuspension, + [ValidateSet('Plan','Restore')][string]$ChannelRecoveryAction = 'Plan', + [string]$ChannelRecoveryJournalPath, + [string]$ChannelRecoveryOriginalResultsPath, + [string]$ChannelRecoveryChannel, + [string]$ChannelRecoveryPlanPath, + [string]$ChannelRecoveryPlanHash, + [string]$ChannelRecoveryOutputPath, + [switch]$ChannelRecoveryAllowShrink, + [switch]$ChannelRecoveryAllowDisable, + [switch]$ChannelRecoveryAllowRevoke, [ValidateSet('Plan','Restore')][string]$EventRecoveryAction = 'Plan', [string]$EventRecoveryJournalPath, [string]$EventRecoveryOriginalResultsPath, @@ -223,6 +233,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorActio Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/EventLogRecovery.ps1") +. (Join-Path $ScriptRoot "scripts/ChannelRecovery.ps1") Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") @@ -2021,6 +2032,7 @@ Usage: ./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness ./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes ./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart + ./WELA.ps1 channel-recovery -Help # Review one completed channel-settings operation ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription @@ -2108,6 +2120,8 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' } } +if ($Cmd -ne 'channel-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'ChannelRecovery*'}).Count) {throw 'ChannelRecovery options require channel-recovery.'} +if ($Cmd -eq 'channel-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','ChannelRecoveryAction','ChannelRecoveryJournalPath','ChannelRecoveryOriginalResultsPath','ChannelRecoveryChannel','ChannelRecoveryPlanPath','ChannelRecoveryPlanHash','ChannelRecoveryOutputPath','ChannelRecoveryAllowShrink','ChannelRecoveryAllowDisable','ChannelRecoveryAllowRevoke','Help')}).Count)) {throw 'channel-recovery accepts only dedicated options.'} if ($Cmd -ne 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'} if ($Cmd -eq 'eventlog-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EventRecoveryAction','EventRecoveryJournalPath','EventRecoveryOriginalResultsPath','EventRecoveryLog','EventRecoveryPlanPath','EventRecoveryPlanHash','EventRecoveryOutputPath','EventRecoveryAllowShrink','EventRecoveryAllowRetentionChange','Help')}).Count)) {throw 'eventlog-recovery accepts only dedicated options.'} if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} @@ -2326,6 +2340,13 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'channel-recovery' { + if ($Help) {Write-Host 'Usage: channel-recovery [-ChannelRecoveryAction Plan] -ChannelRecoveryJournalPath before.jsonl -ChannelRecoveryOriginalResultsPath results.json -ChannelRecoveryChannel exact-channel -ChannelRecoveryOutputPath new-directory; then Restore with -ChannelRecoveryPlanPath plan.json -ChannelRecoveryPlanHash SHA256 -ChannelRecoveryOutputPath new-directory and applicable -ChannelRecoveryAllowShrink / -ChannelRecoveryAllowDisable / -ChannelRecoveryAllowRevoke. Shrink may discard records; disable stops generation; revoke may interrupt readers. See docs/channel-recovery.md.';return} + $arguments=@{Action=$ChannelRecoveryAction;OutputPath=$ChannelRecoveryOutputPath;AllowShrink=$ChannelRecoveryAllowShrink;AllowDisable=$ChannelRecoveryAllowDisable;AllowRevoke=$ChannelRecoveryAllowRevoke} + $map=@{ChannelRecoveryJournalPath='JournalPath';ChannelRecoveryOriginalResultsPath='OriginalResultsPath';ChannelRecoveryChannel='Channel';ChannelRecoveryPlanPath='PlanPath';ChannelRecoveryPlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaChannelRecovery @arguments;$report;exit $report.ExitCode + } 'eventlog-recovery' { if ($Help) {Write-Host 'Usage: eventlog-recovery [-EventRecoveryAction Plan] -EventRecoveryJournalPath before.jsonl -EventRecoveryOriginalResultsPath results.json -EventRecoveryLog channel -EventRecoveryOutputPath new-directory; then Restore with -EventRecoveryPlanPath plan.json -EventRecoveryPlanHash SHA256 -EventRecoveryOutputPath new-directory and applicable -EventRecoveryAllowShrink / -EventRecoveryAllowRetentionChange. See docs/eventlog-recovery.md.';return} $arguments=@{Action=$EventRecoveryAction;OutputPath=$EventRecoveryOutputPath;AllowShrink=$EventRecoveryAllowShrink;AllowRetentionChange=$EventRecoveryAllowRetentionChange} diff --git a/docs/channel-recovery.md b/docs/channel-recovery.md new file mode 100644 index 00000000..d7c0e9e4 --- /dev/null +++ b/docs/channel-recovery.md @@ -0,0 +1,40 @@ +# Reviewed native channel recovery + +`channel-recovery` restores **one completed `channel-settings` operation** on one exact channel from the bundled Microsoft WEF Appendix C profile. It can restore the original enabled state and size, and remove only the exact Event Log Readers read ACE that operation added. It does not restore other configuration commands, partially completed original writes, event records, subscriptions or arbitrary channels. Sysmon is excluded. + +```powershell +./WELA.ps1 channel-recovery -ChannelRecoveryJournalPath C:\WELA\original\before.jsonl ` + -ChannelRecoveryOriginalResultsPath C:\WELA\original-results.json ` + -ChannelRecoveryChannel 'Microsoft-Windows-CAPI2/Operational' ` + -ChannelRecoveryOutputPath C:\WELA\recovery-plan + +# Inspect plan.json and manifest.json; independently retain manifest PlanHash. +./WELA.ps1 channel-recovery -ChannelRecoveryAction Restore ` + -ChannelRecoveryPlanPath C:\WELA\recovery-plan\plan.json ` + -ChannelRecoveryPlanHash REVIEWED_SHA256 ` + -ChannelRecoveryOutputPath C:\WELA\recovery-run ` + -ChannelRecoveryAllowShrink -ChannelRecoveryAllowDisable -ChannelRecoveryAllowRevoke +``` + +Supply only the consent switches the reviewed plan requires. **Shrinking can discard records; disabling stops channel generation; removing a read grant can interrupt collection.** These are separate decisions. Plan is read-only apart from new protected evidence files. Restore accepts a reviewed plan/hash and a new output directory; `-Auto`, `-DryRun`, `-WhatIf` and unrelated command options are rejected. There is no automatic rollback or continuation after a partial failure. + +The original journal must contain exactly one matching entry, with the same typed `Before`, `Desired` and target as one `Applied` result. The command independently rebuilds the enable/minimum-size/read-grant transformation from the current bundled profile. Unknown schemas, Boolean values in text/size fields, duplicate JSON properties, mismatched journals, unexplained post-write changes, missing read-grant authorization and unchanged operations are refused. The selected operation may be recovered even when another channel failed during the original invocation; it must itself be completed and fully consistent. + +Current settings must exactly match the original confirmed after-state. For descriptors, equality means the complete binary descriptor, including owner, group, SACL, DACL, resource-manager control and all ACE bytes/order. The canonical read-grant planner must reproduce the exact original addition, and SDDL conversion must round-trip without loss. An unrelated new ACE or another changed setting requires manual review; recovery never removes it. A previous read grant that was already present is preserved. + +Only originally changed fields are written, in size, descriptor, then enablement order. Each write has a flushed pending receipt, a fresh complete settings/metadata check and actual primary-token check, native `wevtutil` exit validation, independent readback, and a confirmed receipt. Other channel properties, including retention, path, provider parameters and isolation, must remain unchanged. A final read checks the full target. Recovery changes no other channel, audit policy, group membership, service or forwarding configuration. + +| Result | Meaning | +| --- | --- | +| `ReviewRequired` | A new plan and hash were retained; no native write occurred. | +| `Refused` | Evidence, consent or current context did not authorize a write. | +| `RestoredAndVerified` | Every selected original field was restored and observed with preservation checks. | +| `RestoreAttemptedUnverified` | At least one native write was attempted; inspect pending, observed, confirmed and failure-state receipts before manual action. | + +`ConfirmedFields` identifies steps whose immediate readback succeeded; a later failure does not establish that those settings stayed unchanged. Loss of power, process termination or output-storage failure can leave pending evidence without a final manifest. A read-grant removal could succeed even if the caller subsequently cannot read metadata; that remains unverified, without rollback. There is no atomic Windows compare-and-set, so another administrator can race the final check. + +Inputs and outputs must use ordinary local paths supported by WELA's protected recovery artifact helpers. Current host identity, actual operator/logon, source files and native executable/reader assembly hashes are bound to the plan. Plan and Restore must use the same installed implementation and PowerShell version. **Old version-1 journals contain only historical ComputerName; current guards do not authenticate historical ownership.** Treat original evidence as trusted operator records. Updating WELA invalidates older review plans; create and review a new plan rather than editing its fingerprints. + +Windows validation uses explicit disposable GitHub-hosted Server 2022/2025 fixtures under Windows PowerShell 5.1 and PowerShell 7. The fixture calls public Configure then Plan/Restore for actual CAPI2 enable/size changes, with and without the optional read ACE. It tests each missing consent, actual later size drift, replay, unsupported preview options, other-channel preservation, retained hashes, and exact original fixture configuration/all audit masks at cleanup. Shrinking during fixture cleanup can discard intervening records. Windows 11, domain/DC/CA forwarding identities, event generation, persistence through policy refresh, retention duration and backend Sigma evaluation remain separate acceptance work. No rule-readiness credit is granted. + +See [native channel configuration](native-channel-access.md) for original journal creation and [actual channel reads](channel-read.md) for separate current-token query evidence. [Microsoft's `wevtutil` contract](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil) documents channel enablement, maximum size and channel access; buffer configuration is not a retention guarantee. diff --git a/docs/native-channel-access.md b/docs/native-channel-access.md index 877b29ff..f1e75c16 100644 --- a/docs/native-channel-access.md +++ b/docs/native-channel-access.md @@ -30,7 +30,7 @@ The planner uses [RawSecurityDescriptor](https://learn.microsoft.com/en-us/dotne The shared configuration runner writes `before.jsonl` before each native mutation, capturing the original enabled state, exact size, full descriptor and retention mode. A fresh read must match both the plan and the journal snapshot before `wevtutil sl` executes. Only changed `/e:true`, `/ms:...` and explicitly authorized `/ca:...` arguments are sent. Native failure, failed readback, descriptor mismatch and final drift produce a nonzero result. There is no atomic Windows compare-and-set; another writer can still race the final check. Re-run after policy refresh to check persistence. No automatic rollback occurs. -Recovery is manual: review each journal `Before` against the current settings, identify the affected channel, and restore only the intended previous values using `wevtutil sl "CHANNEL" /e:true|false /ms:ORIGINAL_BYTES /ca:"ORIGINAL_SDDL"`. Pass the descriptor as one argument in PowerShell, for example `& wevtutil.exe sl $entry.Target.Channel ("/ca:" + $entry.Before.SecurityDescriptor)` after loading and reviewing the relevant JSONL entry. Restoring a smaller limit can discard events. Existing retention is not intentionally modified; investigate any changed mode before choosing recovery actions. Use a new backup directory for each run. +For a completed `channel-settings` operation, use [reviewed single-channel recovery](channel-recovery.md) to reconstruct the original changed fields, require exact current after-state and request separate shrink/disable/read-revocation consent. Partially completed originals and later drift still require manual review. For manual recovery, review each journal `Before` against the current settings, identify the affected channel, and restore only the intended previous values using `wevtutil sl "CHANNEL" /e:true|false /ms:ORIGINAL_BYTES /ca:"ORIGINAL_SDDL"`. Pass the descriptor as one argument in PowerShell, for example `& wevtutil.exe sl $entry.Target.Channel ("/ca:" + $entry.Before.SecurityDescriptor)` after loading and reviewing the relevant JSONL entry. Restoring a smaller limit can discard events. Existing retention is not intentionally modified; investigate any changed mode before choosing recovery actions. Use a new backup directory for each run. ## Native WEF prerequisites and validation diff --git a/scripts/ChannelRecovery.ps1 b/scripts/ChannelRecovery.ps1 new file mode 100644 index 00000000..b0942a53 --- /dev/null +++ b/scripts/ChannelRecovery.ps1 @@ -0,0 +1,185 @@ +# Restore one completed canonical channel-settings operation; never replay arbitrary arguments. +function Get-WelaChannelRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Assert-WelaChannelRecoveryText {param($Value,[string[]]$Names) foreach($name in $Names){if($Value.$name -isnot [string]){throw "Missing or mistyped channel recovery text: $name"}}} +function Get-WelaChannelRecoveryDescriptorKey { + param([string]$Sddl) + if(-not $Sddl -or $Sddl.Length -gt 131072){throw 'A bounded full channel descriptor is required.'} + $descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($Sddl) + $bytes=New-Object byte[] $descriptor.BinaryLength;$descriptor.GetBinaryForm($bytes,0) + $round=[Security.AccessControl.RawSecurityDescriptor]::new($descriptor.GetSddlForm('All')) + $other=New-Object byte[] $round.BinaryLength;$round.GetBinaryForm($other,0) + if([Convert]::ToBase64String($bytes) -cne [Convert]::ToBase64String($other)){throw 'Channel descriptor cannot round-trip losslessly.'} + [Convert]::ToBase64String($bytes) +} +function Get-WelaChannelRecoveryTuple { + param($Value) + [pscustomobject][ordered]@{IsEnabled=$Value.IsEnabled;MaximumSizeInBytes=$Value.MaximumSizeInBytes;LogMode=$Value.LogMode;SecurityDescriptor=$Value.SecurityDescriptor} +} +function Get-WelaChannelRecoveryTupleKey { + param($Value) + Get-WelaChannelRecoveryKey ([ordered]@{IsEnabled=$Value.IsEnabled;MaximumSizeInBytes=$Value.MaximumSizeInBytes;LogMode=$Value.LogMode;Descriptor=(Get-WelaChannelRecoveryDescriptorKey $Value.SecurityDescriptor)}) +} +function Assert-WelaChannelRecoverySnapshot { + param($Value,[string]$Channel) + Assert-WelaArrivalObject $Value @('Name','State','IsEnabled','LogMode','SecurityDescriptor','MaximumSizeInBytes','ProviderNames','MetadataErrors','Error') + Assert-WelaChannelRecoveryText $Value @('Name','State','LogMode','SecurityDescriptor') + if($Value.Name -cne $Channel -or $Value.IsEnabled -isnot [bool] -or $Value.State -cne $(if($Value.IsEnabled){'Enabled'}else{'Disabled'}) -or + ($Value.MaximumSizeInBytes -isnot [int] -and $Value.MaximumSizeInBytes -isnot [long]) -or $Value.MaximumSizeInBytes -lt 1048576 -or $Value.MaximumSizeInBytes -gt 2199023255552 -or $Value.MaximumSizeInBytes % 65536 -ne 0 -or + $Value.LogMode -cnotin @('Circular','Retain','AutoBackup') -or $null -ne $Value.Error -or $null -eq $Value.MetadataErrors -or @($Value.MetadataErrors.PSObject.Properties).Count -ne 0 -or $Value.ProviderNames -isnot [array] -or $Value.ProviderNames.Count -gt 64){throw 'Original channel snapshot is incomplete, mistyped or unsupported.'} + foreach($name in $Value.ProviderNames){if($name -isnot [string] -or -not $name -or $name.Length -gt 512){throw 'Invalid original provider name.'}} + $null=Get-WelaChannelRecoveryDescriptorKey $Value.SecurityDescriptor +} +function Get-WelaChannelRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/ChannelRecovery.ps1','scripts/NativeChannelConfiguration.ps1','modules/NativeChannelAccess.psm1','modules/NativeProviders.psm1','modules/EventLogSettings.psm1','config/native_channel_profile.json','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){ + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){foreach($path in @((Join-Path ([Environment]::SystemDirectory) 'wevtutil.exe'),[Diagnostics.Eventing.Reader.EventLogConfiguration].Assembly.Location)){$sources[$path]=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}} + Get-WelaChannelRecoveryKey $sources +} +function Get-WelaChannelRecoveryContext { + foreach($name in @('EventLog','Winmgmt')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'EventLog and Winmgmt must already be running; recovery starts no services.'}} + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'The actual non-impersonated elevated administrator is required.'} + [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids};Engine=$PSVersionTable.PSVersion.ToString()} +} +function Read-WelaChannelRecoveryState { + param([string]$Channel) + $native=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Channel) + try { + if($native.LogName -cne $Channel -or [string]$native.LogType -cnotin @('Administrative','Operational')){throw 'An exact built-in administrative or operational channel is required.'} + $guard=[ordered]@{} + foreach($name in @('LogType','LogIsolation','LogFilePath','OwningProviderName','IsClassicLog','ProviderLevel','ProviderKeywords','ProviderBufferSize','ProviderMinimumNumberOfBuffers','ProviderMaximumNumberOfBuffers','ProviderLatency','ProviderControlGuid')){ + $value=$native.$name;$guard[$name]=if($null -eq $value){$null}else{[string]$value} + } + $tuple=[pscustomobject][ordered]@{IsEnabled=[bool]$native.IsEnabled;MaximumSizeInBytes=[long]$native.MaximumSizeInBytes;LogMode=[string]$native.LogMode;SecurityDescriptor=[string]$native.SecurityDescriptor} + $null=Get-WelaChannelRecoveryDescriptorKey $tuple.SecurityDescriptor + [pscustomobject][ordered]@{Channel=$native.LogName;Settings=$tuple;Guard=[pscustomobject]$guard} + }finally{$native.Dispose()} +} +function Get-WelaChannelRecoveryDefinition { + param([string]$JournalPath,[string]$OriginalResultsPath,[string]$Channel) + $profile=Get-WelaNativeChannelProfile;$controls=@($profile.controls|Where-Object channel -ceq $Channel) + if($controls.Count -ne 1){throw 'Select one exact channel from the bundled Microsoft WEF Appendix C profile.'};$control=$controls[0] + $context=Get-WelaChannelRecoveryContext + $journal=Read-WelaWecUpdateFile $JournalPath;$file=Read-WelaWecUpdateFile $OriginalResultsPath + $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_}) + if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'} + $result=ConvertFrom-WelaArrivalJson $file.Text + Assert-WelaChannelRecoveryText $result @('Scope','Action','ChannelProfile') + if($result.Scope -cne 'native-channel-settings-only' -or $result.Action -cne 'Configure' -or $result.ChannelProfile -cne $profile.id -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.GrantEventLogReadersRequested -isnot [bool] -or $result.Results -isnot [array] -or $result.Results.Count -gt 64){throw 'Expected original non-dry-run public channel-settings Configure results.'} + foreach($name in @('ExitCode','Failed','Skipped')){if(($result.$name -isnot [int] -and $result.$name -isnot [long]) -or $result.$name -lt 0){throw 'Original result counters must be nonnegative integers.'}} + $id='NativeChannel/'+$Channel+'/Settings';$seen=@{} + foreach($row in $result.Results){Assert-WelaChannelRecoveryText $row @('Id');if($seen.ContainsKey($row.Id)){throw 'Duplicate original result ID.'};$seen[$row.Id]=$true} + $rows=@($result.Results|Where-Object Id -ceq $id);$matching=@($entries|Where-Object Id -ceq $id) + if($rows.Count -ne 1 -or $matching.Count -ne 1){throw 'Exactly one completed result and its original journal entry are required.'} + $row=$rows[0];$entry=$matching[0] + Assert-WelaChannelRecoveryText $row @('Id','Kind','Status','Diagnostic');Assert-WelaChannelRecoveryText $entry @('ComputerName','Id','Kind') + if($row.Kind -cne 'NativeChannel' -or $row.Status -cne 'Applied' -or $entry.Kind -cne 'NativeChannel' -or $entry.PSObject.Properties['Phase'] -or + ($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer){throw 'Only one completed Applied native-channel operation on this named host is recoverable.'} + if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'} + foreach($field in @('Before','Desired','Target')){if((Get-WelaChannelRecoveryKey $entry.$field) -cne (Get-WelaChannelRecoveryKey $row.$field)){throw "Original journal/result $field differs."}} + Assert-WelaArrivalObject $row.Target @('Channel','Profile');Assert-WelaChannelRecoveryText $row.Target @('Channel','Profile') + if($row.Target.Channel -cne $Channel -or $row.Target.Profile -cne $profile.id){throw 'Original target does not match the canonical channel/profile.'} + foreach($state in @($row.Before,$row.After)){Assert-WelaChannelRecoverySnapshot $state $Channel} + $desired=$row.Desired;Assert-WelaArrivalObject $desired @('IsEnabled','SourceExampleBytes','RoundedMinimumBytes','MaximumSizeInBytes','LogMode','SecurityDescriptor','AccessChangeRequested') + Assert-WelaChannelRecoveryText $desired @('LogMode','SecurityDescriptor') + foreach($name in @('SourceExampleBytes','RoundedMinimumBytes','MaximumSizeInBytes')){if($desired.$name -isnot [int] -and $desired.$name -isnot [long]){throw 'Desired byte counts must be integers.'}} + if($desired.IsEnabled -isnot [bool] -or $desired.AccessChangeRequested -isnot [bool]){throw 'Desired switches must be Booleans.'} + $minimum=ConvertTo-WelaEventLogBytes $control.sourceExampleBytes + $acl=$row.Before.SecurityDescriptor;$revoke=$false;$accessRequested=[bool]($result.GrantEventLogReadersRequested -and $control.readerSid) + if($accessRequested){ + $access=Get-WelaChannelAccessPlan $acl + if($access.State -cnotin @('GrantPresent','GrantRequired')){throw 'Original descriptor has no reviewed read-grant transformation.'} + if($access.State -ceq 'GrantRequired'){$acl=$access.ProposedDescriptor;$revoke=$true} + } + $expected=[pscustomobject][ordered]@{IsEnabled=$(if($null -eq $control.enabled){$row.Before.IsEnabled}else{$control.enabled});MaximumSizeInBytes=[math]::Max([long]$row.Before.MaximumSizeInBytes,[long]$minimum);LogMode=$row.Before.LogMode;SecurityDescriptor=$acl} + if($desired.SourceExampleBytes -ne $control.sourceExampleBytes -or $desired.RoundedMinimumBytes -ne $minimum -or $desired.AccessChangeRequested -ne $accessRequested -or + (Get-WelaChannelRecoveryTupleKey $desired) -cne (Get-WelaChannelRecoveryTupleKey $expected) -or (Get-WelaChannelRecoveryTupleKey $row.After) -cne (Get-WelaChannelRecoveryTupleKey $expected) -or + (Get-WelaChannelRecoveryKey $row.Before.ProviderNames) -cne (Get-WelaChannelRecoveryKey $row.After.ProviderNames)){throw 'Completed operation includes an unexplained change beyond canonical enable/size/read-grant settings.'} + $recover=Get-WelaChannelRecoveryTuple $row.Before;$fields=@() + if($recover.MaximumSizeInBytes -ne $expected.MaximumSizeInBytes){$fields+='MaximumSizeInBytes'} + if((Get-WelaChannelRecoveryDescriptorKey $recover.SecurityDescriptor) -cne (Get-WelaChannelRecoveryDescriptorKey $expected.SecurityDescriptor)){$fields+='SecurityDescriptor'} + if($recover.IsEnabled -ne $expected.IsEnabled){$fields+='IsEnabled'} + if(-not $fields.Count){throw 'No completed channel setting change exists to recover.'} + [pscustomobject][ordered]@{Channel=$Channel;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$file.Path;Hash=$file.Hash};Expected=$expected;RecoverTo=$recover;Fields=$fields + RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresDisableConsent=($expected.IsEnabled -and -not $recover.IsEnabled);RequiresRevokeConsent=$revoke + HistoricalIdentity='Version1 journals bind historical ComputerName only. Current identity/source guards do not authenticate historical ownership. Recovery requires unchanged recorded post-state; no unrelated ACE is removed.'} +} +function Assert-WelaChannelRecoveryCurrent { + param($Definition,$Observed,$Expected,$Guard) + if($Observed.Channel -cne $Definition.Channel -or (Get-WelaChannelRecoveryTupleKey $Observed.Settings) -cne (Get-WelaChannelRecoveryTupleKey $Expected) -or + ($null -ne $Guard -and (Get-WelaChannelRecoveryKey $Observed.Guard) -cne (Get-WelaChannelRecoveryKey $Guard))){throw 'Current channel settings, descriptor or preserved metadata differ from the reviewed state.'} +} +function Set-WelaChannelRecoveryField { + param($Definition,[string]$Field) + $argument=switch -CaseSensitive ($Field){ + 'MaximumSizeInBytes' {'/ms:'+$Definition.RecoverTo.MaximumSizeInBytes} + 'SecurityDescriptor' {'/ca:'+$Definition.RecoverTo.SecurityDescriptor} + 'IsEnabled' {'/e:'+$Definition.RecoverTo.IsEnabled.ToString().ToLowerInvariant()} + default {throw 'Unsupported channel recovery field.'} + } + $null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::SystemDirectory) 'wevtutil.exe') -Arguments @('sl',$Definition.Channel,$argument) +} +function Invoke-WelaChannelRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Channel,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowDisable,[switch]$AllowRevoke) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Channel -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowDisable -or $AllowRevoke){throw 'Plan requires original journal/results, exact channel and new output only.'} + $source=Read-WelaWecUpdateFile $JournalPath + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Channel){throw 'Restore requires reviewed plan/hash, new output and applicable explicit consent only.'} + $source=Read-WelaWecUpdateFile $PlanPath + } + $output=New-WelaArrivalOutput $OutputPath $source.Path + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaChannelRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;ConfirmedFields=@();After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed channel-settings operation. Shrink can discard events; disable stops generation; read-grant removal can interrupt readers. No automatic rollback, event/retention/forwarding proof or Sigma credit. Sysmon excluded.'} + try { + $context=Get-WelaChannelRecoveryContext;$contextKey=Get-WelaChannelRecoveryKey $context;$sources=Get-WelaChannelRecoverySources + if($Action -eq 'Plan'){ + $definition=Get-WelaChannelRecoveryDefinition $JournalPath $OriginalResultsPath $Channel + $observed=Read-WelaChannelRecoveryState $Channel;Assert-WelaChannelRecoveryCurrent $definition $observed $definition.Expected $null + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaChannelRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard} + }else{ + if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'} + $plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard');Assert-WelaChannelRecoveryText $plan @('Kind','ContextKey','Sources') + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaChannelRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or sources differ.'} + $definition=Get-WelaChannelRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Channel + if((Get-WelaChannelRecoveryKey $definition) -cne (Get-WelaChannelRecoveryKey $plan.Definition)){throw 'Plan differs from independently rebuilt original evidence.'} + if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Explicit AllowShrink is required; shrinking can discard events.'} + if($definition.RequiresDisableConsent -and -not $AllowDisable){throw 'Explicit AllowDisable is required; disabling stops channel generation.'} + if($definition.RequiresRevokeConsent -and -not $AllowRevoke){throw 'Explicit AllowRevoke is required; removing the added read ACE can interrupt readers.'} + } + if((Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Channel)) -cne (Get-WelaChannelRecoveryKey $definition) -or (Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryContext)) -cne $contextKey -or (Get-WelaChannelRecoverySources) -cne $sources){throw 'Original evidence, actual host/operator or source changed.'} + Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $definition.Expected $plan.Guard + if($Action -eq 'Plan'){ + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + $report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text + $expected=Get-WelaChannelRecoveryTuple $definition.Expected;$token=Get-WelaChannelRecoveryKey (Get-WelaChannelReader);$index=0 + foreach($field in $definition.Fields){ + $index++;$pendingName=('pending-{0}-{1}.json' -f $index,$field) + $report.Artifacts+=Write-WelaWecUpdateArtifact $output $pendingName ([ordered]@{Status='Pending';Field=$field;Expected=$expected;RecoverTo=$definition.RecoverTo;Guard=$plan.Guard;PlanHash=$PlanHash;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 16) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaChannelRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $definition.Journal.Path).Hash -cne $definition.Journal.Hash -or (Read-WelaWecUpdateFile $definition.OriginalResults.Path).Hash -cne $definition.OriginalResults.Hash){throw 'Reviewed plan, sources or original evidence changed before write.'} + foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}} + Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $expected $plan.Guard + if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token){throw 'Actual current token changed before native write.'} + $report.NativeWriteAttempted=$true;Set-WelaChannelRecoveryField $definition $field + $expected.$field=$definition.RecoverTo.$field + $report.After=Read-WelaChannelRecoveryState $definition.Channel + $report.Artifacts+=Write-WelaWecUpdateArtifact $output ('observed-'+$index+'.json') ($report.After|ConvertTo-Json -Depth 16) + Assert-WelaChannelRecoveryCurrent $definition $report.After $expected $plan.Guard + if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token -or (Get-WelaChannelRecoverySources) -cne $sources){throw 'Actual token or source changed during native write/readback.'} + $report.Artifacts+=Write-WelaWecUpdateArtifact $output ('confirmed-'+$index+'.json') ([ordered]@{Status='Confirmed';Field=$field;After=$report.After;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 16) + $report.ConfirmedFields+=$field + } + Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $definition.RecoverTo $plan.Guard + if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token){throw 'Actual token changed before final confirmation.'} + if((Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryContext)) -cne $contextKey -or (Get-WelaChannelRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Final host/operator, source or reviewed plan changed.'} + $report.Status='RestoredAndVerified';$report.ExitCode=0 + } + }catch{ + $report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message + if($report.NativeWriteAttempted){try{$report.After=Read-WelaChannelRecoveryState $definition.Channel;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failure-state.json' ($report.After|ConvertTo-Json -Depth 16)}catch{}} + } + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report +} diff --git a/tests/ChannelRecovery.Cli.Tests.ps1 b/tests/ChannelRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..ec1bf731 --- /dev/null +++ b/tests/ChannelRecovery.Cli.Tests.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('channel-recovery','-ChannelRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help');Code=0;Pattern='AllowShrink'}, + @{Args=@('configure','-ChannelRecoveryAction','Restore','-Auto');Code=1;Pattern='require channel-recovery'}, + @{Args=@('channel-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help','-Auto');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-ChannelRecoveryAction','Restore','-ChannelRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('channel-recovery','-ChannelRecoveryOutputPath','not-created');Code=1;Pattern='Plan requires'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "Channel recovery CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/ChannelRecovery.Tests.ps1 b/tests/ChannelRecovery.Tests.ps1 new file mode 100644 index 00000000..849e4a0e --- /dev/null +++ b/tests/ChannelRecovery.Tests.ps1 @@ -0,0 +1,97 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/NativeChannelConfiguration.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/ChannelRecovery.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Copy-State($Value){ConvertFrom-WelaArrivalJson (Get-WelaChannelRecoveryKey $Value)} +function Get-WelaChannelRecoveryContext {[pscustomobject]@{Host=[ordered]@{Computer='TEST';MachineGuid='owned-host'};Reader='owned-logon'}} +function Get-WelaChannelReader {[pscustomobject]@{UserSid='TEST';TokenId='token';ModifiedId=$script:token}} +# Portable tests exercise authority, reconstruction and write ordering; real descriptor +# bytes are exercised separately by the native public Configure/Restore workflow. +function Get-WelaChannelRecoveryDescriptorKey {param($Sddl) if($Sddl -cnotin @('original','original+read','foreign')){throw 'Invalid fixture descriptor'};$Sddl} +function Get-WelaChannelAccessPlan {param($SecurityDescriptor) if($SecurityDescriptor -ceq 'original'){[pscustomobject]@{State='GrantRequired';ProposedDescriptor='original+read'}}else{[pscustomobject]@{State='GrantPresent'}}} +function Test-WelaChannelDescriptorEqual {param($First,$Second) $First -ceq $Second} +function Get-WelaNativeChannel {param($Name) [pscustomobject][ordered]@{Name=$Name;State=$(if($script:settings.IsEnabled){'Enabled'}else{'Disabled'});IsEnabled=$script:settings.IsEnabled;LogMode=$script:settings.LogMode;SecurityDescriptor=$script:settings.SecurityDescriptor;MaximumSizeInBytes=$script:settings.MaximumSizeInBytes;ProviderNames=@('Microsoft-Windows-CAPI2');MetadataErrors=[pscustomobject]@{};Error=$null}} +function Invoke-WelaNative {param($FilePath,$Arguments) foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:settings.MaximumSizeInBytes=[long]$arg.Substring(4)};if($arg -like '/ca:*'){$script:settings.SecurityDescriptor=$arg.Substring(4)};if($arg -ceq '/e:true'){$script:settings.IsEnabled=$true}}} +function Read-WelaChannelRecoveryState { + param($Channel) + $script:reads++ + if($script:case -eq 'fresh-drift' -and $script:reads -eq 2){$script:settings.MaximumSizeInBytes+=65536} + [pscustomobject]@{Channel=$Channel;Settings=(Copy-State $script:settings);Guard=[ordered]@{Path=$script:path;Provider='CAPI2';Other='preserved'}} +} +function Set-WelaChannelRecoveryField { + param($Definition,$Field) + $script:writes++ + Assert (Test-Path (Join-Path $script:output ('pending-'+$script:writes+'-'+$Field+'.json'))) 'Durable per-field pending receipt precedes each write.' + if($script:case -eq 'native-fail' -and $script:writes -eq 2){throw 'Native second write failed'} + if($script:case -ne 'false-success'){$script:settings.$Field=$Definition.RecoverTo.$Field} + if($script:case -eq 'preservation'){$script:path='changed'} + if($script:case -eq 'token'){$script:token='changed'} +} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-channel-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST';$channel='Microsoft-Windows-CAPI2/Operational' +function Original([string]$Dir,[bool]$Grant=$true){ + $script:settings=[pscustomobject][ordered]@{IsEnabled=$false;MaximumSizeInBytes=1048576L;LogMode='Circular';SecurityDescriptor='original'};$script:case='';$script:token='stable';$script:path='preserved';$script:reads=0;$script:writes=0 + $profile=Get-WelaNativeChannelProfile;$plans=@(Get-WelaNativeChannelPlan -Profile $profile -GrantEventLogReaders:$Grant|Where-Object {$_.Definition.channel -ceq $channel}) + $context=New-WelaConfigurationContext -Auto -BackupPath "$Dir/journal" + Set-WelaNativeChannelControls $context $plans $profile.id + $r=Complete-WelaConfiguration $context -Scope 'native-channel-settings-only' + $r|Add-Member NoteProperty Action Configure;$r|Add-Member NoteProperty ChannelProfile $profile.id;$r|Add-Member NoteProperty GrantEventLogReadersRequested $Grant + $r|ConvertTo-Json -Depth 20|Set-Content "$Dir/original.json" -Encoding UTF8 + Assert ($r.Results.Count -eq 1 -and $r.Results[0].Status -ceq 'Applied') 'Actual shared configuration callbacks produce original journal/result evidence.' +} +try { + foreach($scenario in @('ok','no-grant','no-shrink','no-disable','no-revoke','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','token')){ + $dir=Join-Path $root $scenario;$null=New-Item -ItemType Directory $dir;Original $dir ($scenario -ne 'no-grant') + $plan=Invoke-WelaChannelRecovery -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Channel $channel -OutputPath "$dir/plan" + Assert ($plan.Status -ceq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $scenario : $($plan.Diagnostic)" + Assert ($script:writes -eq 0) 'Plan does not mutate.' + $planPath="$dir/plan/plan.json";$hash=$plan.PlanHash + if($scenario -eq 'hash'){$hash='f'*64} + if($scenario -in @('tamper','duplicate')){$text=[IO.File]::ReadAllText($planPath);if($scenario -eq 'tamper'){$text=$text.Replace('1048576','2097152')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')};[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()} + if($scenario -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')} + if($scenario -eq 'drift'){$script:settings.SecurityDescriptor='foreign'} + $script:case=$scenario;$script:reads=0;$script:output="$dir/restore" + $r=Invoke-WelaChannelRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath $script:output -AllowShrink:($scenario -ne 'no-shrink') -AllowDisable:($scenario -ne 'no-disable') -AllowRevoke:($scenario -notin @('no-revoke','no-grant')) + Assert (($r.ExitCode -eq 0) -eq ($scenario -in @('ok','no-grant'))) "Restore $scenario : $($r.Diagnostic)" + Assert ($r.ReadyRuleCredit -eq 0 -and (Test-Path "$dir/restore/manifest.json")) 'Outcome evidence is retained without Sigma credit.' + if($scenario -in @('ok','no-grant')){ + Assert ($script:settings.SecurityDescriptor -ceq 'original' -and -not $script:settings.IsEnabled -and $script:settings.MaximumSizeInBytes -eq 1048576 -and $r.Status -ceq 'RestoredAndVerified') 'Original changed fields restored.' + Assert ($r.ConfirmedFields.Count -eq $(if($scenario -eq 'ok'){3}else{2})) 'Only originally changed fields are written and confirmed.' + $again=Invoke-WelaChannelRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowDisable -AllowRevoke + Assert ($again.Status -ceq 'Refused') 'Completed old plan cannot be replayed.' + }elseif($scenario -in @('native-fail','false-success','preservation','token')){ + Assert ($r.Status -ceq 'RestoreAttemptedUnverified' -and $script:writes -gt 0) 'Possible partial write is explicit; no rollback is inferred.' + if($scenario -eq 'native-fail'){Assert ($r.ConfirmedFields.Count -eq 1 -and $script:settings.MaximumSizeInBytes -eq 1048576 -and $script:settings.SecurityDescriptor -ceq 'original+read' -and $script:settings.IsEnabled) 'Second-write failure retains one confirmed step and stops before disable.'} + }else{Assert ($r.Status -ceq 'Refused' -and $script:writes -eq 0) 'Unreviewed or drifted input refuses before write.'} + foreach($artifact in $r.Artifacts){Assert ((Get-FileHash (Join-Path $r.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Receipt hashes match retained bytes.'} + } + $dir=Join-Path $root 'history';$null=New-Item -ItemType Directory $dir;Original $dir + $savedResult=[IO.File]::ReadAllText("$dir/original.json");$savedJournal=[IO.File]::ReadAllText("$dir/journal/before.jsonl") + $cases=@('Status','Kind','Id','Action','Scope','ChannelProfile','Channel','Profile','Version','ComputerName','State','IsEnabled','MaximumSizeInBytes','LogMode','SecurityDescriptor','AfterDrift','DesiredDrift','ExtraAce','NoGrantAuthority','DuplicateJournal') + foreach($bad in $cases){ + $r=ConvertFrom-WelaArrivalJson $savedResult;$e=ConvertFrom-WelaArrivalJson $savedJournal + switch($bad){ + {$_ -in @('Status','Kind','Id')} {$r.Results[0].$bad=$true} + {$_ -in @('Action','Scope','ChannelProfile')} {$r.$bad=$true} + {$_ -in @('Channel','Profile')} {$e.Target.$bad=$true;$r.Results[0].Target.$bad=$true} + {$_ -in @('Version','ComputerName')} {$e.$bad=$true} + {$_ -in @('State','IsEnabled','MaximumSizeInBytes','LogMode','SecurityDescriptor')} {$e.Before.$bad=if($bad -eq 'IsEnabled'){'false'}else{$true};$r.Results[0].Before=Copy-State $e.Before} + 'AfterDrift' {$r.Results[0].After.MaximumSizeInBytes+=65536} + 'DesiredDrift' {$e.Desired.MaximumSizeInBytes+=65536;$r.Results[0].Desired=Copy-State $e.Desired} + 'ExtraAce' {$e.Desired.SecurityDescriptor='foreign';$r.Results[0].Desired=Copy-State $e.Desired;$r.Results[0].After.SecurityDescriptor='foreign'} + 'NoGrantAuthority' {$r.GrantEventLogReadersRequested=$false} + } + $r|ConvertTo-Json -Depth 20|Set-Content "$dir/original.json" -Encoding UTF8 + $text=$e|ConvertTo-Json -Depth 20 -Compress;if($bad -eq 'DuplicateJournal'){$text+="`n"+$text};[IO.File]::WriteAllText("$dir/journal/before.jsonl",$text) + $p=Invoke-WelaChannelRecovery -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Channel $channel -OutputPath "$dir/reject-$bad" + Assert ($p.Status -ceq 'Refused' -and -not $p.NativeWriteAttempted) "History $bad rejected before any write: $($p.Diagnostic)" + } +}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count channel recovery authority/order/partial-outcome assertions. Native descriptors require the Windows fixture." diff --git a/tests/ChannelRecovery.Windows.Tests.ps1 b/tests/ChannelRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..dcb2e45a --- /dev/null +++ b/tests/ChannelRecovery.Windows.Tests.ps1 @@ -0,0 +1,108 @@ +param([switch]$AllowDisposableChannelWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows channel-write opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +Import-Module "$repo/modules/NativeProviders.psm1" -Force +Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/NativeChannelConfiguration.ps1" +$count=0;$errors=@();$primary=$null +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Read-Raw([string]$Name){$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml');$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.LoadXml(($r.Output -join "`n"));return ,$doc} +function Guard-Raw($Xml){$copy=$Xml.CloneNode($true);$copy.DocumentElement.RemoveAttribute('enabled');$copy.DocumentElement.RemoveAttribute('channelAccess');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml} +function Save($Name,$Value){$Value|ConvertTo-Json -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +Add-Type -TypeDefinition @' +using System; using System.IO; using System.Text; using System.Threading.Tasks; +public static class WelaChannelRecoveryFixturePipe { + public static async Task Read(TextReader reader) { + var text=new StringBuilder(); var buffer=new char[1024]; + while(true) { int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false); if(n==0)return text.ToString(); + if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeded 1Mi characters.");text.Append(buffer,0,n); } + } +} +'@ +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-channel-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){ + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',"$repo/WELA.ps1")+$Arguments + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try { + if(-not $process.Start()){throw 'Public process did not start'};$started=$true + $stdout=[WelaChannelRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaChannelRecoveryFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(120000)){throw 'Public command exceeded two minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public command output drain timed out.'} + $text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text) + Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text" + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed'}} + try{$process.Dispose()}catch{$script:errors+=$_.Exception.Message} + } +} +$profile=Get-WelaNativeChannelProfile;$before=@{};$raw=@{};$policies=Get-WelaEffectiveAuditPolicy +foreach($control in $profile.controls){$name=$control.channel;$before[$name]=Get-WelaNativeChannel $name;if(Test-WelaNativeChannelSnapshot $before[$name]){$raw[$name]=Read-Raw $name}} +$capi='Microsoft-Windows-CAPI2/Operational';$app='Microsoft-Windows-AppLocker/EXE and DLL' +$expectedConfigure=if(@($before.Values|Where-Object State -eq 'Not installed').Count){1}else{0} +Save 'before.json' $before;$rawText=@{};foreach($name in $raw.Keys){$rawText[$name]=$raw[$name].OuterXml};Save 'raw-before.json' $rawText +try { + $os=Get-CimInstance Win32_OperatingSystem + Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server 2022/2025 required.' + Assert ($raw.ContainsKey($capi) -and $raw.ContainsKey($app)) 'Readable CAPI2 and AppLocker channels required.' + Assert (@($before.Values|Where-Object State -notin @('Enabled','Disabled','Not installed')).Count -eq 0) 'Unreadable original settings refuse fixture mutation.' + # Owned disposable preparation removes only this group read ACE, preserving every + # captured original byte for final cleanup. Product recovery never uses this shortcut. + $descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($before[$capi].SecurityDescriptor) + for($i=$descriptor.DiscretionaryAcl.Count-1;$i -ge 0;$i--){$ace=$descriptor.DiscretionaryAcl[$i];if($ace -is [Security.AccessControl.CommonAce] -and $ace.AceQualifier -eq 'AccessAllowed' -and $ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and ($ace.AccessMask -band 1)){$descriptor.DiscretionaryAcl.RemoveAce($i)}} + $withoutRead=$descriptor.GetSddlForm('All');Assert ((Get-WelaChannelAccessPlan $withoutRead).State -ceq 'GrantRequired') 'Actual descriptor permits one lossless read-only grant.' + $null=Invoke-WelaNative wevtutil.exe @('sl',$app,'/ms:2147483648') + foreach($scenario in @('grant','no-grant')){ + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false','/ms:1048576',('/ca:'+$withoutRead)) + $prepared=Get-WelaNativeChannel $capi;Save ($scenario+'-prepared.json') $prepared + $journal=Join-Path $root ($scenario+'-original-journal');$resultPath=Join-Path $root ($scenario+'-original.json') + $options=@('channel-settings','-ChannelAction','Configure','-Auto','-BackupPath',$journal,'-ResultsPath',$resultPath);if($scenario -ceq 'grant'){$options+='-GrantEventLogReaders'} + Public ($scenario+'-configure') $options $expectedConfigure + $original=Get-Content $resultPath -Raw|ConvertFrom-Json;$selected=@($original.Results|Where-Object {$_.Target.Channel -ceq $capi}) + Assert ($selected.Count -eq 1 -and $selected[0].Status -ceq 'Applied') 'Public Configure supplies a genuinely Applied selected operation.' + $configured=Get-WelaNativeChannel $capi;Assert ($configured.IsEnabled -and $configured.MaximumSizeInBytes -eq 102432768) 'Actual enable and size changes observed.' + $others=@{};foreach($name in $raw.Keys){if($name -cne $capi){$others[$name]=(Read-Raw $name).OuterXml}} + $planDir=Join-Path $root ($scenario+'-plan') + Public ($scenario+'-plan') @('channel-recovery','-ChannelRecoveryJournalPath',"$journal/before.jsonl",'-ChannelRecoveryOriginalResultsPath',$resultPath,'-ChannelRecoveryChannel',$capi,'-ChannelRecoveryOutputPath',$planDir) + $plan=Get-Content "$planDir/manifest.json" -Raw|ConvertFrom-Json + Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.NativeWriteAttempted -and (Get-FileHash "$planDir/plan.json").Hash.ToLowerInvariant() -ceq $plan.PlanHash) 'Public Plan is read-only with an independently checked exact hash.' + $restoreArgs=@('channel-recovery','-ChannelRecoveryAction','Restore','-ChannelRecoveryPlanPath',"$planDir/plan.json",'-ChannelRecoveryPlanHash',$plan.PlanHash) + $consents=@('-ChannelRecoveryAllowShrink','-ChannelRecoveryAllowDisable');if($scenario -ceq 'grant'){$consents+='-ChannelRecoveryAllowRevoke'} + foreach($consent in $consents){ + $refuseDir=Join-Path $root ($scenario+'-missing-'+$consent.TrimStart('-')) + Public ($scenario+'-missing-'+$consent.TrimStart('-')) ($restoreArgs+@('-ChannelRecoveryOutputPath',$refuseDir)+@($consents|Where-Object {$_ -cne $consent})) 1 + $r=Get-Content "$refuseDir/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted -and (Test-WelaNativeChannelSnapshotEqual $configured (Get-WelaNativeChannel $capi))) 'Each required consent refuses before native write.' + } + $whatIf=Join-Path $root ($scenario+'-whatif');Public ($scenario+'-whatif') ($restoreArgs+@('-ChannelRecoveryOutputPath',$whatIf,'-WhatIf')+$consents) 1 + Assert (-not (Test-Path $whatIf)) 'Unsupported preview option refuses before dispatch/output.' + # Real native drift between reviewed plan and Restore must not be undone. + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,('/ms:'+($configured.MaximumSizeInBytes+65536))) + $drift=Join-Path $root ($scenario+'-drift');Public ($scenario+'-drift') ($restoreArgs+@('-ChannelRecoveryOutputPath',$drift)+$consents) 1 + $r=Get-Content "$drift/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted -and (Get-WelaNativeChannel $capi).MaximumSizeInBytes -eq ($configured.MaximumSizeInBytes+65536)) 'Actual native drift refuses without overwriting the later setting.' + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,('/ms:'+$configured.MaximumSizeInBytes)) + $restoredDir=Join-Path $root ($scenario+'-restore');Public ($scenario+'-restore') ($restoreArgs+@('-ChannelRecoveryOutputPath',$restoredDir)+$consents) + $r=Get-Content "$restoredDir/manifest.json" -Raw|ConvertFrom-Json + Assert ($r.Status -ceq 'RestoredAndVerified' -and $r.NativeWriteAttempted -and $r.ConfirmedFields.Count -eq $(if($scenario -ceq 'grant'){3}else{2})) 'Every originally changed field has verified durable restoration.' + Assert (Test-WelaNativeChannelSnapshotEqual $prepared (Get-WelaNativeChannel $capi)) 'Actual original enable/size/descriptor/retention tuple restored.' + Assert ((Guard-Raw (Read-Raw $capi)) -ceq (Guard-Raw $raw[$capi])) 'All other raw selected-channel configuration fields preserved.' + foreach($name in $others.Keys){Assert ((Read-Raw $name).OuterXml -ceq $others[$name]) 'Recovery does not touch another profile channel.'} + foreach($artifact in $r.Artifacts){Assert ((Get-FileHash (Join-Path $restoredDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Restoration artifact hash matches actual bytes.'} + $replay=Join-Path $root ($scenario+'-replay');Public ($scenario+'-replay') ($restoreArgs+@('-ChannelRecoveryOutputPath',$replay)+$consents) 1 + $r=Get-Content "$replay/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted) 'Restored old plan refuses replay.' + } + Write-Host "PASS: $count actual public channel Configure/Restore assertions." +}catch{$primary=$_;Write-Host $_;Get-ChildItem -LiteralPath $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host ([IO.File]::ReadAllText($_.FullName))}} +finally { + foreach($name in $raw.Keys){try{$s=$before[$name];$null=Invoke-WelaNative wevtutil.exe @('sl',$name,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor));if(-not (Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $name)) -or (Read-Raw $name).OuterXml -cne $raw[$name].OuterXml){throw 'Original full channel metadata differs after fixture cleanup'}}catch{$errors+=$name+': '+$_.Exception.Message}} + try{$current=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($policies[$guid] -ne $current[$guid]){$errors+='Audit policy changed: '+$guid}}}catch{$errors+=$_.Exception.Message} + Save 'cleanup.json' ([ordered]@{Complete=($errors.Count -eq 0);Errors=$errors;OriginalChannels=@($raw.Keys);AuditMasksCompared=$policies.Count;PrimaryError=[string]$primary;EventRecordsRestored=$false;Boundary='Fixture restores exact original configuration; shrinking may discard intervening records. No retention or forwarding proof.'}) +} +$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}}) +Save 'acceptance.json' ([ordered]@{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;ReadyRuleCredit=0}) +if($errors.Count){throw ('Cleanup failed: '+($errors -join '; '))};if($primary){throw $primary};exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 708d6ab9..3341c749 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 完了済みの標準チャネル設定を1件ずつ復元する `channel-recovery` を追加しました。元の記録と結果、確認済み計画ハッシュ、現在の記述子と設定を検証し、縮小・無効化・追加した読み取り権限の撤回には個別の同意を要求します。項目ごとの永続記録を残し、元の操作で変更した項目だけを復元します。公開 Configure/Restore の標準 Windows テストで拒否・途中失敗・元設定への後始末を確認し、イベント消失・保存期間・転送・Sigma 対応は別に扱います。(関連 #367、#365) (@Shirofune-Security) + - Windows PowerShell 5.1 と PowerShell 7、使い捨ての Server 2022/2025 で標準チャネル設定の公開CLIを検証するテストを追加しました。有効化・サイズ・CAPI2読み取り専用権限の適用、既存記述子と大きいバッファーの保持、変更前記録、DryRun、再実行時の無変更、元設定への復元を確認し、ハッシュ付きの証拠を保存します。転送・保存期間・Sigmaの検証は別途必要です。 (@Shirofune-Security) - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 49cf7543..bc606631 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `channel-recovery` for one completed native channel-settings operation. Strict journal/result reconstruction, reviewed plan hashes, exact current descriptor/settings, separate shrink/disable/read-revocation consent and per-field durable receipts restore only originally changed fields. Native public Configure/Restore tests cover refusal, partial outcomes and exact fixture cleanup; event loss, retention, forwarding and Sigma readiness remain separate. (Related #367, #365) (@Shirofune-Security) + - Added disposable Server 2022/2025 validation of public native channel configuration under Windows PowerShell 5.1 and PowerShell 7. Tests apply enable/size controls and the explicit CAPI2 read-only grant, verify descriptor preservation, journals, larger buffers, DryRun and idempotence, and retain hashed native evidence with exact fixture cleanup. Forwarding, retention-duration and Sigma validation remain separate. (@Shirofune-Security) - 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)