From d1d40b4a258f8f3b028c0681e9e81cec7f6dca63 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:42:52 +0900 Subject: [PATCH 01/25] Add reviewed recovery of completed event-log size and retention changes --- .github/workflows/eventlog-recovery.yml | 47 ++++++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 21 ++++ docs/eventlog-recovery.md | 32 ++++++ scripts/EventLogRecovery.ps1 | 134 +++++++++++++++++++++++ tests/EventLogRecovery.Cli.Tests.ps1 | 15 +++ tests/EventLogRecovery.Tests.ps1 | 57 ++++++++++ tests/EventLogRecovery.Windows.Tests.ps1 | 70 ++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 12 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/eventlog-recovery.yml create mode 100644 docs/eventlog-recovery.md create mode 100644 scripts/EventLogRecovery.ps1 create mode 100644 tests/EventLogRecovery.Cli.Tests.ps1 create mode 100644 tests/EventLogRecovery.Tests.ps1 create mode 100644 tests/EventLogRecovery.Windows.Tests.ps1 diff --git a/.github/workflows/eventlog-recovery.yml b/.github/workflows/eventlog-recovery.yml new file mode 100644 index 00000000..ee305b7d --- /dev/null +++ b/.github/workflows/eventlog-recovery.yml @@ -0,0 +1,47 @@ +name: Guarded event-log size and mode recovery +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + eventlog-recovery: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixtures and public guards in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/EventLogRecovery.Tests.ps1 + ./tests/EventLogRecovery.Cli.Tests.ps1 + - name: Native channel restoration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/EventLogRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/EventLogRecovery.Tests.ps1 + ./tests/EventLogRecovery.Cli.Tests.ps1 + - name: Native channel restoration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/EventLogRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: eventlog-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-event-recovery-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..47eb549a 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/eventlog-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..d895678b 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@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..fe8796de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@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..41934b98 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -104,6 +104,15 @@ [string]$EvtxProbePath, [string]$EvtxArchivePath, [string]$EvtxOutputPath, + [ValidateSet('Plan','Restore')][string]$EventRecoveryAction = 'Plan', + [string]$EventRecoveryJournalPath, + [string]$EventRecoveryOriginalResultsPath, + [string]$EventRecoveryLog, + [string]$EventRecoveryPlanPath, + [string]$EventRecoveryPlanHash, + [string]$EventRecoveryOutputPath, + [switch]$EventRecoveryAllowShrink, + [switch]$EventRecoveryAllowRetentionChange, [ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan', [string]$RecoveryJournalPath, [string]$RecoveryOriginalResultsPath, @@ -174,6 +183,7 @@ Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction S Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") +. (Join-Path $ScriptRoot "scripts/EventLogRecovery.ps1") Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") @@ -1961,6 +1971,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 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence ./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event @@ -2037,6 +2048,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 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'} +if ($Cmd -eq 'eventlog-recovery' -and @($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-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecUpdate*'}).Count) {throw 'WecUpdate options require wec-update.'} if ($Cmd -eq 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecUpdateAction','WecUpdateId','WecUpdateSourceSid','WecUpdateQueryPath','WecUpdateDescription','WecUpdatePlanPath','WecUpdatePlanHash','WecUpdateOutputPath','Help')}).Count) {throw 'wec-update accepts only dedicated options.'} if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecRuntime*'}).Count) { @@ -2237,6 +2250,14 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {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} + $map=@{EventRecoveryJournalPath='JournalPath';EventRecoveryOriginalResultsPath='OriginalResultsPath';EventRecoveryLog='Log';EventRecoveryPlanPath='PlanPath';EventRecoveryPlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaEventLogRecovery @arguments;$report + if($report.ExitCode){exit $report.ExitCode} + } 'wec-update' { if ($Help) {Write-Host 'Usage: wec-update [-WecUpdateAction Plan] -WecUpdateId ID -WecUpdateSourceSid SID -WecUpdateQueryPath query.xml -WecUpdateDescription text -WecUpdateOutputPath new-directory; then Apply with -WecUpdatePlanPath reviewed-plan.json -WecUpdatePlanHash SHA256 -WecUpdateOutputPath new-directory. Only query/description on already disabled subscriptions. See docs/wec-update.md.';return} $arguments=@{Action=$WecUpdateAction;OutputPath=$WecUpdateOutputPath} diff --git a/docs/eventlog-recovery.md b/docs/eventlog-recovery.md new file mode 100644 index 00000000..ee986922 --- /dev/null +++ b/docs/eventlog-recovery.md @@ -0,0 +1,32 @@ +# Reviewed event-log size and retention recovery + +`eventlog-recovery` restores the size and retention mode immediately before one completed WELA profile operation. It supports administrative and operational channels in the bundled event-log profiles on reviewed Windows 11 / Server 2022 and 2025 builds. This is part of issues #379 and #365; it does not recover records already lost. + +Use the original `before.jsonl` and final results from `configure-eventlogs` or the same profile helper used by `configure`. The selected result must be `Applied`, with both the initial and `ImmediatePreWrite` journal entries and matching final `BeforeWrite`. Failed, overridden, incomplete, legacy scalar writes and unexplained changes require manual investigation. Other journaled controls are not restored. + +```powershell +./WELA.ps1 eventlog-recovery -EventRecoveryJournalPath C:\Evidence\original\before.jsonl ` + -EventRecoveryOriginalResultsPath C:\Evidence\original-results.json ` + -EventRecoveryLog ForwardedEvents -EventRecoveryOutputPath C:\Evidence\recovery-plan + +# Inspect plan.json: current and original sizes, modes, channel guard and consent flags. +# Supply the exact PlanHash shown by Plan after reviewing that file. +./WELA.ps1 eventlog-recovery -EventRecoveryAction Restore ` + -EventRecoveryPlanPath C:\Evidence\recovery-plan\plan.json ` + -EventRecoveryPlanHash '' -EventRecoveryOutputPath C:\Evidence\recovery-run ` + -EventRecoveryAllowShrink -EventRecoveryAllowRetentionChange +``` + +The last two switches are separate consent for the effects actually identified by the plan. Omit them when inapplicable. **Shrinking can discard existing events.** Changing to Circular allows older records to be overwritten; changing to Retain can discard incoming records when full; leaving AutoBackup stops automatic archival. Review storage and recovery requirements before consenting. Plan writes review evidence but changes no Windows settings. Restore does not export or clear logs, restore an archive, alter channel enablement/ACL/path/provider settings or restart services. + +Each output must be a fresh directory on a local fixed drive, with an existing parent. Evidence is protected for the current operator, Administrators and SYSTEM. The plan is bound to the actual current host/MachineGuid, operator logon, original input bytes and implementation/catalog hashes. Use the same checkout and elevated operator logon for Restore. The original version-1 journal records only historical ComputerName: current host bindings and hashes do not authenticate that history. + +The plan is rebuilt from original evidence on Restore. Minimum-size writes are checked against the immediate-prewrite size so an independent increase during prompting is preserved. An unexplained larger final size is refused. Current size/mode/enable state must match the confirmed post-configuration state. Current channel path, ACL, isolation, type, owning provider and classic-log flag are captured when planning and must remain unchanged. Live event count and EVTX file allocation are intentionally not treated as configuration guards. + +Restore flushes a Pending receipt before one fixed local `wevtutil sl` operation, rechecks the inputs and current channel, changes only the required size/mode arguments, and records final native readback. There is no atomic compare-and-set in this interface. A concurrent policy refresh or writer can still intervene, and verified values do not prove persistence. + +`RestoredAndVerified` means the requested size/mode and preserved configuration matched during readback. `Refused` means no native write was attempted. `RestoreAttemptedUnverified` means a write may have partly succeeded; inspect the Pending receipt and any after-state evidence before further action. Automatic rollback and replay against the already-restored state are refused. A fatal evidence-write error may leave only a Pending receipt; keep it for investigation. + +The Windows fixture uses genuine public configuration of the disposable runner's ForwardedEvents channel, then public planning, consent refusal, actual drift refusal, restoration and replay refusal. It restores the original channel configuration and compares every original audit mask. Matrices cover Server 2022/2025 and PowerShell 5.1/7; the test proves configuration behavior, not historical record preservation, achieved retention, forwarding or Sigma readiness. Sysmon is excluded. + +Reference: [Microsoft wevtutil size, retention and auto-backup options](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). diff --git a/scripts/EventLogRecovery.ps1 b/scripts/EventLogRecovery.ps1 new file mode 100644 index 00000000..6b7279c2 --- /dev/null +++ b/scripts/EventLogRecovery.ps1 @@ -0,0 +1,134 @@ +# Restore one completed profile size/mode write; never replay arbitrary wevtutil arguments. +function Get-WelaEventRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/AuditRecovery.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() + } + $sources|ConvertTo-Json -Compress +} +function Get-WelaEventRecoveryContext { + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'An elevated native Windows operator is required.'} + [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}} +} +function Read-WelaEventRecoveryChannel { + param([string]$Log) + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Log) + try { + if($channel.LogName -cne $Log -or [string]$channel.LogType -notin @('Administrative','Operational')){throw 'An exact administrative or operational channel is required.'} + [pscustomobject][ordered]@{ + Log=$channel.LogName;MaximumSizeInBytes=[long]$channel.MaximumSizeInBytes;LogMode=[string]$channel.LogMode + Guard=[ordered]@{IsEnabled=[bool]$channel.IsEnabled;LogType=[string]$channel.LogType;Isolation=[string]$channel.LogIsolation;Path=[string]$channel.LogFilePath;SecurityDescriptor=[string]$channel.SecurityDescriptor;Provider=[string]$channel.OwningProviderName;Classic=[bool]$channel.IsClassicLog} + } + }finally{$channel.Dispose()} +} +function Assert-WelaEventRecoveryState { + param($State,[string]$Log) + if($State.Log -cne $Log -or $State.ReadStatus -cne 'Available' -or $State.Diagnostic -cne '' -or $State.IsEnabled -isnot [bool] -or + ($State.MaximumSizeInBytes -isnot [int] -and $State.MaximumSizeInBytes -isnot [long]) -or $State.MaximumSizeInBytes -lt 1048576 -or $State.MaximumSizeInBytes -gt 2199023255552 -or $State.MaximumSizeInBytes % 65536 -ne 0 -or $State.LogMode -cnotin @('Circular','Retain','AutoBackup')){throw 'Original channel state is unavailable, mistyped or unsupported.'} +} +function Get-WelaEventRecoveryPair {param($Value) [pscustomobject][ordered]@{MaximumSizeInBytes=[long]$Value.MaximumSizeInBytes;LogMode=[string]$Value.LogMode}} +function Get-WelaEventRecoveryDefinition { + param([string]$JournalPath,[string]$ResultsPath,[string]$Log) + $catalog=Import-WelaEventLogProfiles + if($Log -cnotin @($catalog.profiles.controls.log)){throw 'Select an exact channel in the bundled event-log profiles.'} + $context=Get-WelaEventRecoveryContext + $journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath + $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 $resultFile.Text + if($result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -gt 2048 -or $result.Scope -cnotin @('native-windows-configuration','event-log-size-and-mode-only')){throw 'Expected original non-dry-run event-log configuration results.'} + $id='EventLog/'+$Log+'/ProfileSettings' + $rows=@($result.Results|Where-Object Id -eq $id);$matching=@($entries|Where-Object Id -eq $id) + if($rows.Count -ne 1 -or $matching.Count -ne 2){throw 'Exactly one result and its original/immediate-prewrite journal pair are required.'} + $row=$rows[0];$initial=$matching[0];$fresh=$matching[1] + if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'} + foreach($entry in $matching){ + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'} + $time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'} + } + if((ConvertTo-WelaArrivalUtc $fresh.RecordedUtc) -lt (ConvertTo-WelaArrivalUtc $initial.RecordedUtc)){throw 'Journal times are reversed.'} + foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $initial.$field) -cne (Get-WelaRecoveryKey $row.$field)){throw "Original/result $field differs."}} + Assert-WelaArrivalObject $initial.Target @('Log','Profile');Assert-WelaArrivalObject $fresh.Target @('Log') + if($initial.Target.Log -cne $Log -or $fresh.Target.Log -cne $Log -or $initial.Target.Profile -isnot [string]){throw 'Contradictory channel identity.'} + $profile=Get-WelaEventLogProfile $initial.Target.Profile;$control=@($profile.controls|Where-Object log -ceq $Log) + if($control.Count -ne 1){throw 'Channel is not selected by the original bundled profile.'} + Assert-WelaArrivalObject $initial.Desired @('MaximumSizeInBytes','SizeMode','LogMode') + if((Get-WelaRecoveryKey $initial.Desired) -cne (Get-WelaRecoveryKey $fresh.Desired) -or $initial.Desired.SizeMode -cnotin @('Exact','Minimum') -or ($null -ne $initial.Desired.LogMode -and $initial.Desired.LogMode -cne $control[0].mode) -or + ($initial.Desired.MaximumSizeInBytes -isnot [int] -and $initial.Desired.MaximumSizeInBytes -isnot [long]) -or $initial.Desired.MaximumSizeInBytes -ne (ConvertTo-WelaEventLogBytes $control[0].minimumBytes)){throw 'Desired configuration differs from the canonical profile operation.'} + foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log} + if((Get-WelaRecoveryKey $fresh.Before) -cne (Get-WelaRecoveryKey $row.BeforeWrite)){throw 'Immediate prewrite and final BeforeWrite evidence differ.'} + if($row.After.IsEnabled -ne $fresh.Before.IsEnabled){throw 'Channel enable state changed during original operation.'} + $bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max($fresh.Before.MaximumSizeInBytes,$initial.Desired.MaximumSizeInBytes)} + $mode=if($null -ne $initial.Desired.LogMode){$initial.Desired.LogMode}else{$fresh.Before.LogMode} + if($row.After.MaximumSizeInBytes -ne $bytes -or $row.After.LogMode -cne $mode){throw 'Final state includes unexplained drift beyond the original size/mode write.'} + $expected=Get-WelaEventRecoveryPair $row.After;$recover=Get-WelaEventRecoveryPair $fresh.Before + if((Get-WelaRecoveryKey $expected) -ceq (Get-WelaRecoveryKey $recover)){throw 'No completed size/mode change exists to recover.'} + [pscustomobject][ordered]@{ + Log=$Log;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$resultFile.Path;Hash=$resultFile.Hash} + Expected=$expected;RecoverTo=$recover;ExpectedEnabled=$row.After.IsEnabled + RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresModeConsent=($recover.LogMode -cne $expected.LogMode) + HistoricalIdentity='Version1 records bind historical ComputerName only. Current host/logon and source hashes do not authenticate historical ownership or configuration.' + } +} +function Assert-WelaEventRecoveryCurrent { + param($Definition,$Observed,$Guard) + if($Observed.Log -cne $Definition.Log -or $Observed.Guard.IsEnabled -ne $Definition.ExpectedEnabled -or + (Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $Observed)) -cne (Get-WelaRecoveryKey $Definition.Expected) -or + ($null -ne $Guard -and (Get-WelaRecoveryKey $Observed.Guard) -cne (Get-WelaRecoveryKey $Guard))){throw 'Current channel size, mode, identity or preserved properties differ from reviewed post-configuration state.'} +} +function Set-WelaEventRecoveryChannel { + param($Definition) + $arguments=@('sl',$Definition.Log) + if($Definition.RecoverTo.MaximumSizeInBytes -ne $Definition.Expected.MaximumSizeInBytes){$arguments+='/ms:'+ $Definition.RecoverTo.MaximumSizeInBytes} + if($Definition.RecoverTo.LogMode -cne $Definition.Expected.LogMode){ + switch($Definition.RecoverTo.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')};default{throw 'Unsupported recovery mode.'}} + } + if($arguments.Count -le 2){throw 'No fixed recovery argument was selected.'} + $null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'wevtutil.exe') -Arguments $arguments +} +function Invoke-WelaEventLogRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Log,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowRetentionChange) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Log -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowRetentionChange){throw 'Plan requires original journal/results, exact channel and new output; restore-only options are not accepted.'} + $source=Read-WelaWecUpdateFile $JournalPath + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Log){throw 'Restore requires only reviewed plan path/hash, new output and applicable explicit loss/retention consent.'} + $source=Read-WelaWecUpdateFile $PlanPath + } + $output=New-WelaArrivalOutput $OutputPath $source.Path + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed profile size/mode operation. Shrinking or changing retention may discard events or stop archival; existing records and sustained retention are not proven. Sysmon excluded.'} + try{ + $context=Get-WelaEventRecoveryContext;$contextKey=Get-WelaRecoveryKey $context;$sources=Get-WelaEventRecoverySources + if($Action -eq 'Plan'){ + $definition=Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log + $observed=Read-WelaEventRecoveryChannel $Log;Assert-WelaEventRecoveryCurrent $definition $observed $null + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard} + if((Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log)) -cne (Get-WelaRecoveryKey $definition) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources){throw 'Input, host or code changed while planning.'} + Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $Log) $plan.Guard + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }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') + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaEventLogRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or code differs.'} + $definition=Get-WelaEventRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Log + if((Get-WelaRecoveryKey $definition) -cne (Get-WelaRecoveryKey $plan.Definition)){throw 'Recovery plan differs from independently rebuilt original evidence.'} + if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Restoring the original smaller buffer requires explicit AllowShrink; existing events may be discarded.'} + if($definition.RequiresModeConsent -and -not $AllowRetentionChange){throw 'Restoring a different retention mode requires explicit AllowRetentionChange.'} + Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard + $report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-restore.json' ([ordered]@{Status='Pending';Definition=$definition;Guard=$plan.Guard;Context=$context;AllowShrink=[bool]$AllowShrink;AllowRetentionChange=[bool]$AllowRetentionChange;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaEventRecoverySources) -cne $sources -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Log)) -cne (Get-WelaRecoveryKey $definition)){throw 'Plan, source, context or original evidence changed immediately before restore.'} + 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-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard + $report.NativeWriteAttempted=$true;Set-WelaEventRecoveryChannel $definition + $report.After=Read-WelaEventRecoveryChannel $definition.Log + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' ($report.After|ConvertTo-Json -Depth 12) + if((Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $report.After)) -cne (Get-WelaRecoveryKey $definition.RecoverTo) -or (Get-WelaRecoveryKey $report.After.Guard) -cne (Get-WelaRecoveryKey $plan.Guard) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Restored size/mode or preserved properties, context or sources differ.'} + $report.Status='RestoredAndVerified';$report.ExitCode=0 + } + }catch{$report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24) + $report +} diff --git a/tests/EventLogRecovery.Cli.Tests.ps1 b/tests/EventLogRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..eb362ace --- /dev/null +++ b/tests/EventLogRecovery.Cli.Tests.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('eventlog-recovery','-Help');Code=0;Pattern='AllowShrink'}, + @{Args=@('configure','-EventRecoveryAction','Restore','-Auto');Code=1;Pattern='require eventlog-recovery'}, + @{Args=@('eventlog-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('eventlog-recovery','-EventRecoveryOutputPath','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 "Event-log recovery CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/EventLogRecovery.Tests.ps1 b/tests/EventLogRecovery.Tests.ps1 new file mode 100644 index 00000000..b4099f80 --- /dev/null +++ b/tests/EventLogRecovery.Tests.ps1 @@ -0,0 +1,57 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/AuditRecovery.ps1" +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/EventLogConfiguration.ps1" +. "$repo/scripts/EventLogRecovery.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST' +function Get-WelaEventRecoveryContext {[pscustomobject][ordered]@{Host=[ordered]@{Computer='TEST';MachineGuid='1'};Reader='S-1-5-21-fixture'}} +function Get-WelaEventLogState {param($Log);[pscustomobject]@{Log=$Log;ReadStatus='Available';MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;FileSize=123;IsEnabled=$false;Diagnostic=''}} +function Invoke-WelaNative {param($FilePath,$Arguments);foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:bytes=[long]$arg.Substring(4)}};if($Arguments -contains '/ab:true'){$script:mode='AutoBackup'}} +function Read-WelaEventRecoveryChannel {param($Log);$script:reads++;if($script:scenario -eq 'fresh-drift' -and $script:reads -eq 2){$script:bytes+=65536};[pscustomobject]@{Log=$Log;MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;Guard=[ordered]@{IsEnabled=$false;Path='Original';SecurityDescriptor=$script:acl}}} +function Set-WelaEventRecoveryChannel {param($Definition);Assert (Test-Path $script:pending) 'Pending receipt precedes write';$script:writes++;if($script:scenario -eq 'native-fail'){throw 'native failure'};if($script:scenario -ne 'false-success'){$script:bytes=$Definition.RecoverTo.MaximumSizeInBytes;$script:mode=$Definition.RecoverTo.LogMode};if($script:scenario -eq 'preservation'){$script:acl='changed'}} +try { + foreach($case in @('ok','no-shrink','no-mode','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','historical-drift')){ + $script:scenario='';$script:bytes=33554432L;$script:mode='Retain';$script:acl='Original';$script:writes=0;$script:reads=0 + $dir=Join-Path $root $case;$null=New-Item -ItemType Directory $dir + $context=New-WelaConfigurationContext -Auto -BackupPath "$dir/journal" + Set-WelaEventLogProfileControls -Context $context -Profile 'asd-collector-archive-2021-10' -ApplyLogMode + $result=Complete-WelaConfiguration -Context $context -Scope 'event-log-size-and-mode-only' -ResultsPath "$dir/original.json" + Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Genuine configuration callback creates completed evidence' + $plan=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/plan" + Assert ($plan.Status -eq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $case : $($plan.Diagnostic)" + Assert ($script:writes -eq 0) 'Plan never restores' + $planPath="$dir/plan/plan.json";$hash=$plan.PlanHash + if($case -eq 'hash'){$hash='a'*64} + if($case -in @('tamper','duplicate')){ + $text=[IO.File]::ReadAllText($planPath) + if($case -eq 'tamper'){$text=$text.Replace('33554432','67108864')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')} + [IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant() + } + if($case -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')} + if($case -eq 'historical-drift'){ + $original=Get-Content "$dir/original.json" -Raw|ConvertFrom-Json;$original.Results[0].After.MaximumSizeInBytes+=65536;$original|ConvertTo-Json -Depth 15|Set-Content "$dir/original.json" + $bad=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/bad-plan" + Assert ($bad.Status -eq 'Refused' -and $bad.Diagnostic -match 'unexplained drift') 'Independent postwrite buffer growth cannot be undone as WELA-owned change' + } + $script:scenario=$case;$script:reads=0;$script:pending="$dir/restore/before-restore.json" + if($case -eq 'drift'){$script:bytes+=65536} + $restore=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/restore" -AllowShrink:($case -ne 'no-shrink') -AllowRetentionChange:($case -ne 'no-mode') + Assert (($restore.ExitCode -eq 0) -eq ($case -eq 'ok')) "Restore $case : $($restore.Diagnostic)" + Assert (Test-Path "$dir/restore/manifest.json") 'Manifest retained' + if($case -eq 'ok'){ + Assert ($script:bytes -eq 33554432 -and $script:mode -eq 'Retain' -and $restore.Status -eq 'RestoredAndVerified' -and $restore.ReadyRuleCredit -eq 0) 'Original immediate-prewrite size/mode restored' + $replay=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowRetentionChange + Assert ($replay.Status -eq 'Refused' -and $script:writes -eq 1) 'Old post-configuration plan is not replayed' + }elseif($case -in @('native-fail','false-success','preservation')){Assert ($restore.Status -eq 'RestoreAttemptedUnverified' -and $script:writes -eq 1) 'Partial failure explicit'} + else{Assert ($script:writes -eq 0 -and -not $restore.NativeWriteAttempted) 'Refusal occurs before write'} + } +}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item $root -Recurse -Force} +Write-Host "Event-log recovery passed: $count assertions." diff --git a/tests/EventLogRecovery.Windows.Tests.ps1 b/tests/EventLogRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..300f46b8 --- /dev/null +++ b/tests/EventLogRecovery.Windows.Tests.ps1 @@ -0,0 +1,70 @@ +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 hosted Windows 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 +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ControlApplicability.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/AuditRecovery.ps1" +. "$repo/scripts/ChannelRead.ps1" +. "$repo/scripts/EventLogRecovery.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +$engine=(Get-Process -Id $PID).Path +function Invoke-RecoveryFixtureCli {param([string[]]$Arguments,[int]$Expected=0) + $prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0)){throw "Public CLI $code : $($lines -join ' ')"} +} +$log='ForwardedEvents';$before=Read-WelaEventRecoveryChannel $log;$policies=Get-WelaEffectiveAuditPolicy +$root=Join-Path $env:RUNNER_TEMP ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$primary=$null +try{ + $null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:33554432','/rt:true','/ab:false') + $prepared=Read-WelaEventRecoveryChannel $log + Assert ((Get-WelaRecoveryKey $prepared.Guard) -ceq (Get-WelaRecoveryKey $before.Guard)) 'Preparation preserves enable/path/ACL/provider fields' + Invoke-RecoveryFixtureCli @('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto','-BackupPath',"$root/journal",'-ResultsPath',"$root/original.json") + $original=Get-Content "$root/original.json" -Raw|ConvertFrom-Json + Assert ($original.Results.Count -eq 1 -and $original.Results[0].Status -eq 'Applied') 'Genuine public Configure evidence' + $configured=Read-WelaEventRecoveryChannel $log + Assert ($configured.MaximumSizeInBytes -eq 2147483648 -and $configured.LogMode -eq 'AutoBackup') 'Native configured size/mode observed' + Invoke-RecoveryFixtureCli @('eventlog-recovery','-EventRecoveryJournalPath',"$root/journal/before.jsonl",'-EventRecoveryOriginalResultsPath',"$root/original.json",'-EventRecoveryLog',$log,'-EventRecoveryOutputPath',"$root/plan") + $plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json + Assert ($plan.Status -eq 'ReviewRequired' -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Public Plan makes no channel changes' + $apply=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryPlanPath',"$root/plan/plan.json",'-EventRecoveryPlanHash',$plan.PlanHash) + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/without-consent")) 1 + $refused=Get-Content "$root/without-consent/manifest.json" -Raw|ConvertFrom-Json + Assert ($refused.Status -eq 'Refused' -and -not $refused.NativeWriteAttempted) 'Shrinking requires independent explicit consent' + # Actual concurrent-size drift, then exact fixture restoration, exercises public refusal. + $null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147549184') + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/drift",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1 + $drift=Get-Content "$root/drift/manifest.json" -Raw|ConvertFrom-Json + Assert ($drift.Status -eq 'Refused' -and -not $drift.NativeWriteAttempted) 'Actual native size drift refuses restoration' + $null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147483648') + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/restored",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) + $restored=Get-Content "$root/restored/manifest.json" -Raw|ConvertFrom-Json + Assert ($restored.Status -eq 'RestoredAndVerified' -and $restored.NativeWriteAttempted -and $restored.ReadyRuleCredit -eq 0) 'Native public restoration verified' + Assert ((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $prepared)) 'Exact prepared size/mode and all preserved fields restored' + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/replay",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1 + $replay=Get-Content "$root/replay/manifest.json" -Raw|ConvertFrom-Json + Assert ($replay.Status -eq 'Refused' -and -not $replay.NativeWriteAttempted) 'Consumed plan cannot overwrite recovered state' + Write-Host "Native event-log recovery passed $count assertions; no record preservation or sustained retention claim." +}catch{$primary=$_} +finally{ + $errorText='' + try{ + $arguments=@('sl',$log,('/ms:'+$before.MaximumSizeInBytes)) + switch($before.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')}} + $null=Invoke-WelaNative wevtutil.exe $arguments + if((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -cne (Get-WelaRecoveryKey $before)){throw 'Original channel configuration differs after cleanup.'} + $now=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($now[$guid] -ne $policies[$guid]){throw 'Original audit mask changed.'}} + }catch{$errorText=$_.Exception.Message} + $cleanup=[ordered]@{CleanupVerified=($errorText -eq '');Before=$before;After=(Read-WelaEventRecoveryChannel $log);AuditMasksCompared=$policies.Count;Diagnostic=$errorText} + $cleanup|ConvertTo-Json -Depth 12|Set-Content "$root/cleanup.json" -Encoding UTF8 + if($errorText){throw "Cleanup failed: $errorText; primary: $primary"} + Write-Host 'Original channel size/mode, enable/path/ACL/provider fields and all audit masks restored.' +} +if($primary){throw $primary} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..6a049ae4 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@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..825c81f5 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From e88d8ec85dbc852344b6cb430f22ef218a85b447 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:45:17 +0900 Subject: [PATCH 02/25] Select 64-bit size comparison on Windows PowerShell 5.1 --- scripts/EventLogRecovery.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/EventLogRecovery.ps1 b/scripts/EventLogRecovery.ps1 index 6b7279c2..59a672e9 100644 --- a/scripts/EventLogRecovery.ps1 +++ b/scripts/EventLogRecovery.ps1 @@ -59,7 +59,7 @@ function Get-WelaEventRecoveryDefinition { foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log} if((Get-WelaRecoveryKey $fresh.Before) -cne (Get-WelaRecoveryKey $row.BeforeWrite)){throw 'Immediate prewrite and final BeforeWrite evidence differ.'} if($row.After.IsEnabled -ne $fresh.Before.IsEnabled){throw 'Channel enable state changed during original operation.'} - $bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max($fresh.Before.MaximumSizeInBytes,$initial.Desired.MaximumSizeInBytes)} + $bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max([long]$fresh.Before.MaximumSizeInBytes,[long]$initial.Desired.MaximumSizeInBytes)} $mode=if($null -ne $initial.Desired.LogMode){$initial.Desired.LogMode}else{$fresh.Before.LogMode} if($row.After.MaximumSizeInBytes -ne $bytes -or $row.After.LogMode -cne $mode){throw 'Final state includes unexplained drift beyond the original size/mode write.'} $expected=Get-WelaEventRecoveryPair $row.After;$recover=Get-WelaEventRecoveryPair $fresh.Before From 63b65e2447571568d857589bc3ad6c0d7ee0b726 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:48:32 +0900 Subject: [PATCH 03/25] Add reviewed native transcription policy recovery --- .github/workflows/release.yml | 2 +- .github/workflows/transcription-recovery.yml | 45 ++++ CHANGELOG-Japanese.md | 3 + CHANGELOG.md | 2 + WELA.ps1 | 19 +- docs/powershell-transcription.md | 2 + docs/transcription-recovery.md | 51 ++++ scripts/TranscriptionRecovery.ps1 | 233 ++++++++++++++++++ tests/TranscriptionRecovery.Tests.ps1 | 139 +++++++++++ tests/TranscriptionRecovery.Windows.Tests.ps1 | 116 +++++++++ website/docs/resources/changelog.ja.md | 3 + website/docs/resources/changelog.md | 2 + 12 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/transcription-recovery.yml create mode 100644 docs/transcription-recovery.md create mode 100644 scripts/TranscriptionRecovery.ps1 create mode 100644 tests/TranscriptionRecovery.Tests.ps1 create mode 100644 tests/TranscriptionRecovery.Windows.Tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..3acc784a 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/powershell-transcription.md, ./docs/transcription-recovery.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/transcription-recovery.yml b/.github/workflows/transcription-recovery.yml new file mode 100644 index 00000000..c8c189fc --- /dev/null +++ b/.github/workflows/transcription-recovery.yml @@ -0,0 +1,45 @@ +name: Native transcription policy recovery +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/TranscriptionRecovery.ps1' + - 'scripts/PowerShellTranscription.ps1' + - 'scripts/AuditRecovery.ps1' + - 'tests/TranscriptionRecovery*' + - '.github/workflows/transcription-recovery.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + transcription-recovery: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Focused and native public CLI recovery in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/TranscriptionRecovery.Tests.ps1 + ./tests/TranscriptionRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Focused and native public CLI recovery in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/TranscriptionRecovery.Tests.ps1 + ./tests/TranscriptionRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain native policy and cleanup evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: transcription-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-transcription-recovery-*/ + if-no-files-found: error diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a6ab89ad..cf5689b8 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,9 @@ **改善:** +- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@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..afd235d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@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..1ffb4d00 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -104,6 +104,13 @@ [string]$EvtxProbePath, [string]$EvtxArchivePath, [string]$EvtxOutputPath, + [ValidateSet('Plan','Restore')][string]$TranscriptRecoveryAction = 'Plan', + [string]$TranscriptRecoveryJournalPath, + [string]$TranscriptRecoveryOriginalResultsPath, + [string]$TranscriptRecoveryPlanPath, + [string]$TranscriptRecoveryPlanHash, + [string]$TranscriptRecoveryOutputPath, + [switch]$TranscriptRecoveryAllowTemporarySuspension, [ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan', [string]$RecoveryJournalPath, [string]$RecoveryOriginalResultsPath, @@ -192,6 +199,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/EventMeasurement.ps1") . (Join-Path $ScriptRoot "scripts/GpoCreation.ps1") . (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1") +. (Join-Path $ScriptRoot "scripts/TranscriptionRecovery.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 $PowerShellPolicyRoots = @( @@ -1928,6 +1936,7 @@ Usage: ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 transcription-recovery -Help ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml @@ -2026,6 +2035,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'} if ($Cmd -eq 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EvtxAction','EvtxProbePath','EvtxArchivePath','EvtxOutputPath','Help')}).Count) {throw 'evtx-recovery accepts only its dedicated options. No command was run.'} +if ($Cmd -ne 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'TranscriptRecovery*'}).Count) {throw 'TranscriptRecovery options require transcription-recovery.'} +if ($Cmd -eq 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','TranscriptRecoveryAction','TranscriptRecoveryJournalPath','TranscriptRecoveryOriginalResultsPath','TranscriptRecoveryPlanPath','TranscriptRecoveryPlanHash','TranscriptRecoveryOutputPath','TranscriptRecoveryAllowTemporarySuspension','Auto','DryRun','Help')}).Count) {throw 'transcription-recovery accepts only its dedicated options, Auto and DryRun.'} if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'} if ($Cmd -eq 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','RecoveryAction','RecoveryJournalPath','RecoveryOriginalResultsPath','RecoveryControlId','RecoveryPlanPath','RecoveryOutputPath','Auto','DryRun','Help')}).Count) {throw 'audit-recovery accepts only dedicated recovery options, Auto and DryRun. No command was run.'} @@ -2115,7 +2126,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -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 +if ($DryRun -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and @@ -2231,6 +2242,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'transcription-recovery' { + if ($Help) {Write-Host 'Usage: transcription-recovery -TranscriptRecoveryAction Plan -TranscriptRecoveryJournalPath before.jsonl -TranscriptRecoveryOriginalResultsPath results.json -TranscriptRecoveryOutputPath new-directory; Restore uses -TranscriptRecoveryPlanPath, -TranscriptRecoveryPlanHash and new -TranscriptRecoveryOutputPath [-Auto] [-TranscriptRecoveryAllowTemporarySuspension]. DryRun omits output. See docs/transcription-recovery.md.';return} + $report=Invoke-WelaTranscriptRecovery -Action $TranscriptRecoveryAction -JournalPath $TranscriptRecoveryJournalPath -OriginalResultsPath $TranscriptRecoveryOriginalResultsPath -PlanPath $TranscriptRecoveryPlanPath -PlanHash $TranscriptRecoveryPlanHash -OutputPath $TranscriptRecoveryOutputPath -AllowTemporarySuspension:$TranscriptRecoveryAllowTemporarySuspension -Auto:$Auto -DryRun:$DryRun + $report | ConvertTo-Json -Depth 24 | Write-Output + exit ([int]$report.ExitCode) + } 'audit-recovery' { if ($Help) {Write-Host 'Usage: audit-recovery [-RecoveryAction Plan] -RecoveryJournalPath before.jsonl -RecoveryOriginalResultsPath results.json -RecoveryControlId IDs -RecoveryOutputPath new-directory; then -RecoveryAction Restore -RecoveryPlanPath reviewed-plan.json -RecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/audit-recovery.md.';return} $report=Invoke-WelaAuditRecovery -Action $RecoveryAction -JournalPath $RecoveryJournalPath -OriginalResultsPath $RecoveryOriginalResultsPath -ControlId $RecoveryControlId -PlanPath $RecoveryPlanPath -OutputPath $RecoveryOutputPath -Auto:$Auto -DryRun:$DryRun diff --git a/docs/powershell-transcription.md b/docs/powershell-transcription.md index 3905d869..e310b299 100644 --- a/docs/powershell-transcription.md +++ b/docs/powershell-transcription.md @@ -60,6 +60,8 @@ One configuration control journals the original typed machine/current-user value `Applied`/`AlreadyCompliant` mean the machine policy and directory observations passed these checks. They do not prove transcript generation or access for another identity. `Failed` covers read/write problems, unsafe/unknown destination state and verification errors; `Overridden` covers later detected policy drift. `Skipped` includes dry runs and operator-declined changes. Exit 0 means no failed or overridden controls, including runs with skips; it is not a transcript-generation or CIS-wide compliance result. +For a completed `Applied` local-directory configuration, [transcription-recovery](transcription-recovery.md) provides reviewed typed restoration with durable receipts and explicit temporary-suspension consent. Failed/partial configuration runs and unsupported original values still require manual review. + If a later write fails, an earlier `OutputDirectory` write can remain. Review `before.jsonl`, the current policy and the authoritative GPO/MDM source. To recover, restore **only** `OutputDirectory` and `EnableTranscripting` from `Before.Policy[0].Machine`, preserving each original value's registry type; remove a value when its original `ValueExists` was false. If necessary, temporarily set `EnableTranscripting` to DWORD `0` while restoring the previous location, then restore its original value/type or absence last. Leave invocation-header and unrelated values untouched. Remove a newly created `Transcription` key only if it was originally absent and is still empty; do not delete a whole policy subtree or restore old ACLs over later changes. The journal contains policy paths/security information and should be protected as administrator recovery data. ## Evidence and limits diff --git a/docs/transcription-recovery.md b/docs/transcription-recovery.md new file mode 100644 index 00000000..7c38673b --- /dev/null +++ b/docs/transcription-recovery.md @@ -0,0 +1,51 @@ +# Recover Windows PowerShell transcription policy + +`transcription-recovery` reviews and restores the two machine values changed by one completed `powershell-transcription -TranscriptionAction Configure` run. It requires that run's original `before.jsonl` and final JSON result, exactly one `Applied` control named `PowerShellTranscription/CisV4L2`, and current policy/directory observations that still match its final `After` evidence. Failed, partial, skipped and already-compliant configuration records require manual review. + +```powershell +./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Plan ` + -TranscriptRecoveryJournalPath C:\Recovery\original\before.jsonl ` + -TranscriptRecoveryOriginalResultsPath C:\Recovery\original-result.json ` + -TranscriptRecoveryOutputPath C:\Recovery\new-plan + +# Review every step in plan.json, including RequiresTemporarySuspension. +$reviewedHash = (Get-FileHash C:\Recovery\new-plan\plan.json -Algorithm SHA256).Hash.ToLowerInvariant() +./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Restore ` + -TranscriptRecoveryPlanPath C:\Recovery\new-plan\plan.json ` + -TranscriptRecoveryPlanHash $reviewedHash -DryRun ` + -TranscriptRecoveryAllowTemporarySuspension + +./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Restore ` + -TranscriptRecoveryPlanPath C:\Recovery\new-plan\plan.json ` + -TranscriptRecoveryPlanHash $reviewedHash ` + -TranscriptRecoveryOutputPath C:\Recovery\new-attempt ` + -TranscriptRecoveryAllowTemporarySuspension -Auto +``` + +Omit `-TranscriptRecoveryAllowTemporarySuspension` when the reviewed plan does not require it. `-Auto` accepts the ordinary confirmation; it never supplies suspension consent. `DryRun` validates all bindings and consent, returns the proposed steps and creates no directory. Plan and real Restore require new private output directories. All evidence and transcript directory paths must be literal absolute paths on local fixed drives. UNC paths, mapped drives, alternate streams and observed reparse components are rejected. + +## Supported restoration and ordering + +The target is the existing shared machine registry key `HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription`. The command supports original `OutputDirectory` REG_SZ values or absence, and original `EnableTranscripting` DWORD `0`, DWORD `1`, or absence. Other original types and values require manual recovery. Both Registry64 and Registry32 must agree. Recovery retains the existing key, removes only values that were originally absent, and never deletes policy subtrees. + +When the original enablement was DWORD `0`, recovery restores that disabled state before changing the destination. If a destination change is followed by restoring DWORD `1` or removing the enablement value, the plan requires explicit temporary suspension: write DWORD `0`, restore the destination, then restore the original enablement or absence. A failure may leave that temporary disabled state in place; the command reports this as an incomplete attempt and stops subsequent writes. It does not silently re-enable with an unverified destination. An originally absent destination is supported only with original DWORD `0`; enabled/default-user destinations require manual recovery. + +Computer policy takes precedence over user policy, and policy-enabled transcription applies to PowerShell sessions. Removing a machine value can expose user/default policy; the command restores the recorded registry state without asserting session adoption. Manual `Start-Transcript` remains possible when automatic policy transcription is disabled. [Microsoft Windows PowerShell policy documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1). `HKLM\SOFTWARE\Policies` is shared across the registry views; recovery writes through Registry64 once and verifies both observations. [Microsoft WOW64 registry documentation](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). + +Existing sessions are not stopped or restarted. Transcript files, their ACLs, shares, retention, collection, module logging, script-block logging, invocation-header preferences, current-user policy and all unrelated PowerShell policy values are preserved. The command inventories the other machine/current-user PowerShell policy tree with explicit bounds and stops when it changes. + +## Evidence and failure handling + +The reviewed plan binds original file hashes, the exact current host/MachineGuid and OS context, the elevated primary-token user/group/logon observations, current implementation hashes, both registry views, and the old/new directory observations. Restore verifies the separately supplied plan hash and independently rebuilds the plan from its original evidence and current observations. It checks those bindings after the prompt, before each write, during readback and at completion. A changed directory, policy, reader, source, plan or implementation stops the run. + +Version-1 Configure journals record only the historical `ComputerName`. Current MachineGuid/logon/code bindings do **not** establish historical identity or authenticate supplied records. Hashes establish consistency. Keep original evidence and the reviewed hash under administrator control, review the authoritative GPO/MDM policy separately, and do not treat local registry restoration as proof of policy ownership or persistence. + +Each mutation has a flushed, new `NNN-pending.json` receipt written before it and a separate `NNN-confirmed.json` only after verified readback. `result.json` contains actual observed final policy and confirmed steps. A pending receipt without confirmation is an uncertain step; inspect current native policy and preserve all receipts before manual recovery. A write may have succeeded even when its readback/receipt failed. Failed attempts and replay after a completed restore are refused by the original final-state guard; this command does not resume partial attempts or accept a new baseline silently. Failure to persist the result fails outward while existing evidence remains. + +These are bounded point-in-time checks, not an atomic registry/filesystem lock. Another administrator or policy refresh may change state after a check. Private output guards observe ACL and directory identity metadata; they do not provide adversarial filesystem locking or central storage authorization proof. + +## Validation scope + +Portable tests exercise typed restoration, absent values, ordering, consent, preview, unsupported history, duplicate JSON, plan/source/host/directory/policy drift, prompt-time races and partial failures. The explicitly gated disposable native matrix targets Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7 as WELA hosts. It performs actual public Configure/Plan/Restore, checks native typed values and preserved policy, captures receipts, tests real drift refusal, and restores the fixture's exact original policy in `finally`. A fresh Windows PowerShell 5.1 session checks a benign transcript marker at the restored private local destination. Artifacts retain that fixture evidence and `cleanup.json`; PowerShell 7 remains only a WELA host. + +`Restored` means the selected typed registry values passed final verification. Production transcript generation, existing/future session behavior, other identities, client/DC roles, central read/modify authorization, collection and retention remain separate validation. The report grants `SigmaEvtxCredit=0`; transcript text is separate from 4103/4104 EVTX. This advances recovery for [issue #376](https://github.com/Yamato-Security/WELA/issues/376) without completing its central authorization/ingestion acceptance. diff --git a/scripts/TranscriptionRecovery.ps1 b/scripts/TranscriptionRecovery.ps1 new file mode 100644 index 00000000..f193853d --- /dev/null +++ b/scripts/TranscriptionRecovery.ps1 @@ -0,0 +1,233 @@ +# Explicit recovery of one completed Windows PowerShell transcription policy write. +function Get-WelaTranscriptRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/TranscriptionRecovery.ps1','scripts/PowerShellTranscription.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Get-WelaTranscriptRecoveryContext { + $hostState=Get-WelaRecoveryHost + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'Transcription recovery requires an elevated administrator primary token.'} + # A reviewed plan can be consumed by a new process in the same logon session. + [pscustomobject][ordered]@{Host=$hostState;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,ElevatedAdministrator,TokenType,Impersonation)} +} +function Assert-WelaTranscriptRecoveryLocalPath { + param([string]$Path) + Test-WelaTranscriptDirectoryPath $Path + if($Path -notmatch '^[A-Za-z]:\\' -or (Get-WelaRecoveryOutputDriveType ([IO.Path]::GetPathRoot($Path))) -ne [IO.DriveType]::Fixed){throw 'Transcription recovery supports ordinary local fixed-drive paths only; UNC and mapped drives require manual recovery.'} +} +function Read-WelaTranscriptRecoveryFile { + param([string]$Path) + Assert-WelaTranscriptRecoveryLocalPath $Path + Get-WelaRecoveryFile $Path +} +function Get-WelaTranscriptRecoveryProtectedPolicy { + # Inventory the complete PowerShell policy tree, excluding only the two owned + # machine values. No policy, header, module/script-block or user writes occur. + $rows=New-Object 'System.Collections.Generic.List[object]' + foreach($hive in @('LocalMachine','CurrentUser')) { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$hive,[Microsoft.Win32.RegistryView]::Registry64) + try { + $queue=New-Object 'System.Collections.Generic.Queue[string]';$queue.Enqueue('') + while($queue.Count) { + $relative=$queue.Dequeue();$path='SOFTWARE\Policies\Microsoft\Windows\PowerShell'+$relative + $key=$base.OpenSubKey($path,$false) + try { + $values=@();$children=@() + if($null -ne $key) { + $children=@($key.GetSubKeyNames()|Sort-Object) + foreach($name in ($key.GetValueNames()|Sort-Object)) { + if($hive -eq 'LocalMachine' -and $relative -eq '\Transcription' -and $name -in @('EnableTranscripting','OutputDirectory')){continue} + $values += [pscustomobject][ordered]@{Name=$name;Type=$key.GetValueKind($name).ToString();Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)} + } + } + $rows.Add([pscustomobject][ordered]@{Hive=$hive;Path=$relative;Exists=($null -ne $key);Values=$values;Children=$children}) + if($rows.Count -gt 128 -or $queue.Count+$children.Count -gt 128 -or $relative.Length -gt 1024 -or $values.Count -gt 256){throw 'PowerShell policy inventory exceeded bounded recovery scope.'} + foreach($child in $children){$queue.Enqueue($relative+'\'+$child)} + } finally {if($key){$key.Dispose()}} + } + } finally {$base.Dispose()} + } + $result=@($rows.ToArray()) + if((Get-WelaRecoveryKey $result).Length -gt 1048576){throw 'PowerShell policy inventory exceeded 1 MiB.'} + return ,$result +} +function Assert-WelaTranscriptRecoveryValue { + param($Value,[string]$Name) + if($null -eq $Value -or $Value.KeyExists -isnot [bool] -or $Value.ValueExists -isnot [bool]){throw 'Missing typed transcription value state.'} + if(-not $Value.ValueExists) { + if($null -ne $Value.Type -or $null -ne $Value.Value){throw 'Absent transcription value has inconsistent state.'} + } elseif(-not $Value.KeyExists){throw 'A present transcription value requires an existing key.'} + elseif($Name -eq 'EnableTranscripting') { + if($Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)){throw 'Only DWORD 0/1 or absent enablement can be restored; other types require manual recovery.'} + } elseif($Value.Type -cne 'String' -or $Value.Value -isnot [string] -or -not $Value.Value){throw 'Only a nonempty REG_SZ or absent output directory can be restored.'} +} +function Get-WelaTranscriptRecoveryTypedKey { + param($Value) + Get-WelaRecoveryKey ($Value|Select-Object ValueExists,Type,Value) +} +function Get-WelaTranscriptRecoveryDestinations { + param([string[]]$Paths) + foreach($path in ($Paths|Sort-Object -Unique)) { + Assert-WelaTranscriptRecoveryLocalPath $path + $directory=Get-WelaTranscriptDestination $path + if(-not $directory.ConfigureAllowed -or $directory.Status -cne 'Observed'){throw "Recovery destination cannot be verified: $($directory.Diagnostic)"} + $directory + } +} +function New-WelaTranscriptRecoveryPlan { + param([string]$JournalPath,[string]$OriginalResultsPath) + $context=Get-WelaTranscriptRecoveryContext;$sources=Get-WelaTranscriptRecoverySources + $journal=Read-WelaTranscriptRecoveryFile $JournalPath;$resultFile=Read-WelaTranscriptRecoveryFile $OriginalResultsPath + $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) + $results=ConvertFrom-WelaRecoveryJson $resultFile.Text + if($entries.Count -ne 1 -or $results.Results -isnot [array] -or $results.Results.Count -ne 1 -or $results.DryRun -isnot [bool] -or $results.DryRun -or + $results.ExitCode -ne 0 -or $results.Failed -ne 0 -or $results.Skipped -ne 0 -or $results.Action -cne 'Configure' -or $results.Scope -cne 'windows-powershell-transcription-policy-only'){throw 'Recovery requires one completed Applied transcription Configure journal/result, without other controls or partial outcomes.'} + $entry=$entries[0];$last=$results.Results[0] + if($entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or + $entry.Kind -cne 'PowerShellTranscription' -or $last.Status -cne 'Applied' -or $last.Id -cne $entry.Id -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'} + $time=[datetimeoffset]::MinValue + if($entry.RecordedUtc -isnot [string] -or $entry.RecordedUtc -notmatch '(Z|\+00:00)$' -or -not [datetimeoffset]::TryParse($entry.RecordedUtc,[ref]$time) -or $time -gt [datetimeoffset]::UtcNow.AddMinutes(1)){throw 'Original journal requires a valid UTC timestamp.'} + foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $entry.$field) -cne (Get-WelaRecoveryKey $last.$field)){throw "Original journal/result $field differs."}} + if($entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or $entry.Desired.EnableTranscripting.Value -ne 1 -or + $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or $entry.Desired.EnableInvocationHeader -cne 'Preserve'){throw 'Unsupported transcription target or desired state.'} + $before=$entry.Before;$after=$last.After + foreach($snapshot in @($before,$after)) { + if($snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or + $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -cne 'Registry32'){throw 'Both canonical shared registry views are required.'} + Test-WelaTranscriptSharedPolicy $snapshot.Policy + foreach($name in @('EnableTranscripting','OutputDirectory')){Assert-WelaTranscriptRecoveryValue $snapshot.Policy[0].Machine.$name $name} + } + if(-not (Test-WelaTranscriptConfigured $after $entry.Target.OutputDirectory)){throw 'Final transcription policy was not the requested enabled state.'} + if((Get-WelaRecoveryKey $before.Policy[0].CurrentUser) -cne (Get-WelaRecoveryKey $after.Policy[0].CurrentUser) -or + (Get-WelaTranscriptRecoveryTypedKey $before.Policy[0].Machine.EnableInvocationHeader) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableInvocationHeader)){throw 'Original configuration did not preserve user/header policy.'} + $current=Get-WelaTranscriptState $entry.Target.OutputDirectory + if((Get-WelaRecoveryKey $current.Policy) -cne (Get-WelaRecoveryKey $after.Policy) -or (Get-WelaRecoveryKey $current.Destination) -cne (Get-WelaRecoveryKey $after.Destination)){throw 'Current policy/destination differs from the original final After state.'} + $target=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $after.Policy) + foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) { + $view.Machine.$name=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $before.Policy[0].Machine.$name) + # Keep the existing key; absence recovery removes only the selected value. + $view.Machine.$name.KeyExists=$true + }} + $prior=$before.Policy[0].Machine;$paths=@([string]$entry.Target.OutputDirectory) + if($prior.OutputDirectory.ValueExists){$paths += [string]$prior.OutputDirectory.Value} + elseif(-not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0)) { + throw 'Restoring an absent output directory requires explicit prior DWORD 0; user/default destinations require manual recovery.' + } + $directories=@(Get-WelaTranscriptRecoveryDestinations $paths) + $outputChanges=(Get-WelaTranscriptRecoveryTypedKey $prior.OutputDirectory) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.OutputDirectory) + $suspend=$outputChanges -and -not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0) + $steps=New-Object 'System.Collections.Generic.List[object]' + if($outputChanges) { + $off=if($suspend){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'}}else{$target[0].Machine.EnableTranscripting} + $steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$off;Purpose=$(if($suspend){'Explicit temporary suspension'}else{'Restore disabled state before destination'})}) + $steps.Add([pscustomobject]@{Name='OutputDirectory';Value=$target[0].Machine.OutputDirectory;Purpose='Restore original destination value or absence'}) + if($suspend){$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'})} + } elseif((Get-WelaTranscriptRecoveryTypedKey $prior.EnableTranscripting) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableTranscripting)) { + $steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'}) + } + if(-not $steps.Count){throw 'Original Applied evidence contains no recoverable typed changes.'} + $protected=Get-WelaTranscriptRecoveryProtectedPolicy + [pscustomobject][ordered]@{Kind='WelaTranscriptionRecoveryPlan';SchemaVersion=1;Context=$context;Sources=$sources; + Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Sha256}; + ExpectedPolicy=$after.Policy;RecoverTo=$target;Directories=$directories;ProtectedPolicy=$protected;RequiresTemporarySuspension=[bool]$suspend;Steps=@($steps.ToArray()); + HistoricalIdentity='Version-1 configuration journals record ComputerName only. Current host/reader/code bindings do not authenticate historical identity or evidence.';SigmaEvtxCredit=0} +} +function Assert-WelaTranscriptRecoveryBindings { + param($Plan,$Policy,[string]$PlanPath,[string]$PlanHash) + foreach($source in @($Plan.Journal,$Plan.OriginalResults)){if((Read-WelaTranscriptRecoveryFile $source.Path).Sha256 -cne $source.Sha256){throw 'Original transcription recovery evidence changed.'}} + if($PlanPath -and (Read-WelaTranscriptRecoveryFile $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed transcription recovery plan changed.'} + if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryContext)) -cne (Get-WelaRecoveryKey $Plan.Context) -or (Get-WelaRecoveryKey (Get-WelaTranscriptRecoverySources)) -cne (Get-WelaRecoveryKey $Plan.Sources)){throw 'Actual host, reader or recovery implementation changed.'} + if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -cne (Get-WelaRecoveryKey $Plan.ProtectedPolicy)){throw 'Preserved PowerShell policy changed; recovery stopped.'} + if((Get-WelaRecoveryKey @(Get-WelaTranscriptRecoveryDestinations @($Plan.Directories.RequestedPath))) -cne (Get-WelaRecoveryKey $Plan.Directories)){throw 'A reviewed transcript directory changed.'} + $capability=Get-WelaTranscriptCapability + if($capability.Status -cne 'Supported'){throw 'Windows PowerShell capability changed.'} + $current=@(Get-WelaTranscriptPolicy $capability.Views);Test-WelaTranscriptSharedPolicy $current + if((Get-WelaRecoveryKey $current) -cne (Get-WelaRecoveryKey $Policy)){throw 'Current typed transcription policy drifted from the expected recovery step.'} +} +function Set-WelaTranscriptRecoveryValue { + param([ValidateSet('EnableTranscripting','OutputDirectory')][string]$Name,$Value) + Assert-WelaTranscriptRecoveryValue $Value $Name + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + $key=$null + try { + $key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true) + if($null -eq $key){throw 'Existing transcription key disappeared; it will not be recreated.'} + if($Value.ValueExists){$key.SetValue($Name,$Value.Value,[Microsoft.Win32.RegistryValueKind]([string]$Value.Type))} + else{$key.DeleteValue($Name,$false)} + $key.Flush() + } finally {if($key){$key.Dispose()};$base.Dispose()} +} +function Write-WelaTranscriptRecoveryArtifact { + param($Directory,[string]$Name,$Value) + $fresh=Get-WelaTranscriptDestination $Directory.RequestedPath + if(-not $fresh.ConfigureAllowed -or (Get-WelaRecoveryKey $fresh) -cne (Get-WelaRecoveryKey $Directory)){throw 'Private recovery output directory changed.'} + Write-WelaRecoveryArtifact (Join-Path $Directory.Path $Name) $Value +} +function Invoke-WelaTranscriptRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowTemporarySuspension,[switch]$Auto,[switch]$DryRun) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan') { + if($PlanPath -or $PlanHash -or $Auto -or $DryRun -or $AllowTemporarySuspension){throw 'Plan takes original journal/results and new output only; consent flags are Restore-only.'} + $plan=New-WelaTranscriptRecoveryPlan $JournalPath $OriginalResultsPath + Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy + Assert-WelaTranscriptRecoveryLocalPath $OutputPath + $output=New-WelaRecoveryOutput $OutputPath + $outputObservation=Get-WelaTranscriptDestination $output + Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan + $hash=(Read-WelaTranscriptRecoveryFile (Join-Path $output 'plan.json')).Sha256 + return [pscustomobject]@{Status='Planned';ExitCode=0;OutputPath=$output;PlanSha256=$hash;RequiresTemporarySuspension=$plan.RequiresTemporarySuspension;SigmaEvtxCredit=0} + } + if($JournalPath -or $OriginalResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[0-9a-f]{64}$'){throw 'Restore consumes a reviewed plan path, its exact SHA-256 and a new output directory.'} + $source=Read-WelaTranscriptRecoveryFile $PlanPath + if($source.Sha256 -cne $PlanHash){throw 'Supplied reviewed plan hash differs.'} + $plan=ConvertFrom-WelaRecoveryJson $source.Text + if($plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or $plan.SchemaVersion -ne 1){throw 'Unsupported transcription recovery plan.'} + $rebuilt=New-WelaTranscriptRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path + if((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)){throw 'Reviewed plan differs from independently rebuilt original evidence and current observations.'} + Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy $source.Path $source.Sha256 + if($plan.RequiresTemporarySuspension -and -not $AllowTemporarySuspension){throw 'Restoring this destination requires explicit -TranscriptRecoveryAllowTemporarySuspension consent, including for preview.'} + if($DryRun) { + if($OutputPath){throw 'DryRun writes no directory; omit OutputPath.'} + return [pscustomobject]@{Status='WouldRestore';ExitCode=0;DryRun=$true;Steps=$plan.Steps;SigmaEvtxCredit=0} + } + Assert-WelaTranscriptRecoveryLocalPath $OutputPath + $output=New-WelaRecoveryOutput $OutputPath + $outputObservation=Get-WelaTranscriptDestination $output + $report=[pscustomobject][ordered]@{Status='Failed';ExitCode=1;OutputPath=$output;PlanSha256=$source.Sha256;Steps=@();Before=$plan.ExpectedPolicy;After=$null;Diagnostic='';SigmaEvtxCredit=0;Scope='Two typed Windows PowerShell machine transcription values only; no transcript, session adoption, central collection or policy persistence proof.'} + $expected=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $plan.ExpectedPolicy) + try { + if(-not $Auto -and (Read-Host 'Restore the reviewed transcription values, including any explicitly consented temporary suspension? (y/N)') -cnotin @('y','Y')){$report.Status='Declined';$report.ExitCode=0} + else { + Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan + $sequence=0 + foreach($step in $plan.Steps) { + $sequence++ + Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 + $receipt=[pscustomobject]@{Sequence=$sequence;Status='Pending';RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Step=$step;Before=(ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $expected));After=$null} + Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-pending.json' -f $sequence) $receipt + Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 + Set-WelaTranscriptRecoveryValue $step.Name $step.Value + foreach($view in $expected){$view.Machine.($step.Name)=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $step.Value)} + Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 + $receipt.Status='Confirmed';$receipt.After=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $expected) + Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-confirmed.json' -f $sequence) $receipt + $report.Steps += [pscustomobject]@{Sequence=$sequence;Name=$step.Name;Status='Confirmed';Value=$step.Value} + } + Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256 + $report.Status='Restored';$report.ExitCode=0 + } + } catch {$report.Diagnostic=$_.Exception.Message} + try { + $report.After=@(Get-WelaTranscriptPolicy (Get-WelaTranscriptCapability).Views) + if($report.Status -eq 'Restored') { + if((Get-WelaRecoveryKey $report.After) -cne (Get-WelaRecoveryKey $plan.RecoverTo)){throw 'Final returned policy differs from the recovery target.'} + Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256 + } + }catch{$report.Diagnostic+=' Final policy verification failed: '+$_.Exception.Message;$report.Status='Failed';$report.ExitCode=1} + # A failed result write fails outward; pending/confirmed receipts remain intact. + Write-WelaTranscriptRecoveryArtifact $outputObservation 'result.json' $report + return $report +} diff --git a/tests/TranscriptionRecovery.Tests.ps1 b/tests/TranscriptionRecovery.Tests.ps1 new file mode 100644 index 00000000..cdeb5eb1 --- /dev/null +++ b/tests/TranscriptionRecovery.Tests.ps1 @@ -0,0 +1,139 @@ +$ErrorActionPreference='Stop' +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1') +. (Join-Path $script:ScriptRoot 'scripts/AuditRecovery.ps1') +. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1') +. (Join-Path $script:ScriptRoot 'scripts/TranscriptionRecovery.ps1') +$script:artifactWriter=(Get-Command Write-WelaRecoveryArtifact).ScriptBlock +function Write-WelaRecoveryArtifact { + param($Path,$Value) + if($script:failArtifact -and [IO.Path]::GetFileName($Path) -eq $script:failArtifact){throw 'injected durable artifact failure'} + & $script:artifactWriter $Path $Value +} +$script:checks=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-transcript-recovery-test-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory $root +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"} +function Copy-Value($Value){ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $Value)} +function Typed($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})}} +function Get-WelaTranscriptRecoveryContext {[pscustomobject]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$script:machine};Reader='fixture-reader'}} +function Get-WelaTranscriptRecoverySources {[pscustomobject]@{Code=$script:code}} +function Assert-WelaTranscriptRecoveryLocalPath {param($Path) if(-not $Path -or $Path.StartsWith('\\')){throw 'local path fixture refusal'}} +function Get-WelaTranscriptRecoveryProtectedPolicy {return ,$script:protected} +function Get-WelaTranscriptCapability {[pscustomobject]@{Status='Supported';Views=@('Registry64','Registry32')}} +function Get-WelaTranscriptPolicy {param($Views) Copy-Value $script:policy} +function Get-WelaTranscriptDestination {param($Path) [pscustomobject]@{RequestedPath=$Path;Path=$Path;Status='Observed';ConfigureAllowed=$true;CreationTimeUtc='fixture';Acl=$script:acl}} +function Get-WelaTranscriptState {param($OutputDirectory) [pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=(Get-WelaTranscriptPolicy);Destination=(Get-WelaTranscriptDestination $OutputDirectory)}} +function Set-WelaTranscriptRecoveryValue { + param($Name,$Value) + $script:writes++ + Assert (Test-Path (Join-Path $script:restoreOutput ('{0:d3}-pending.json' -f $script:writes))) 'each actual write has a durable pending receipt first' + if($script:writes -eq $script:failWrite){throw 'injected write failure'} + foreach($view in $script:policy){$view.Machine.$Name=Copy-Value $Value} + if($script:writes -eq $script:driftWrite){$script:protected=@('changed independent module policy')} +} +function Read-Host {param($Prompt) if($script:promptDrift){$script:policy[0].Machine.EnableInvocationHeader=Typed 1;$script:policy[1].Machine.EnableInvocationHeader=Typed 1};'y'} +function New-Fixture($Enable=1,$Directory='C:\Old') { + $script:machine='stable';$script:code='stable';$script:acl='private';$script:protected=@('module','script-block','unrelated');$script:writes=0;$script:failWrite=-1;$script:driftWrite=-1;$script:promptDrift=$false;$script:failArtifact=$null + $script:fixture=Join-Path $root ([guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $script:fixture + $beforePolicy=@(foreach($view in @('Registry64','Registry32')){[pscustomobject]@{View=$view;Machine=[pscustomobject]@{EnableTranscripting=(Typed $Enable);OutputDirectory=(Typed $Directory String);EnableInvocationHeader=(Typed 0)};CurrentUser=[pscustomobject]@{EnableTranscripting=(Typed $null);OutputDirectory=(Typed $null);EnableInvocationHeader=(Typed $null)}}}) + $script:policy=Copy-Value $beforePolicy + foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 1;$view.Machine.OutputDirectory=Typed 'C:\New' String} + $before=[pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=$beforePolicy;Destination=(Get-WelaTranscriptDestination 'C:\New')} + $after=Get-WelaTranscriptState 'C:\New' + $target=[pscustomobject]@{Hive='LocalMachine';SubKey='SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription';OutputDirectory='C:\New'} + $desired=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{Type='DWord';Value=1};OutputDirectory=[pscustomobject]@{Type='String';Value='C:\New'};EnableInvocationHeader='Preserve'} + $script:entry=[pscustomobject]@{Version=1;ComputerName='fixture';RecordedUtc=[datetime]::UtcNow.ToString('o');Id='PowerShellTranscription/CisV4L2';Kind='PowerShellTranscription';Before=$before;Target=$target;Desired=$desired} + $script:original=[pscustomobject]@{ExitCode=0;Failed=0;Skipped=0;DryRun=$false;Action='Configure';Scope='windows-powershell-transcription-policy-only';Results=@([pscustomobject]@{Id=$script:entry.Id;Kind=$script:entry.Kind;Before=$before;After=$after;Target=$target;Desired=$desired;Status='Applied'})} + Save-History + $script:restoreOutput=Join-Path $script:fixture 'restore' +} +function Save-History { + $script:journal=Join-Path $script:fixture 'before.jsonl';$script:originalPath=Join-Path $script:fixture 'original.json' + Get-WelaRecoveryKey $script:entry|Set-Content -LiteralPath $script:journal -Encoding UTF8 + Get-WelaRecoveryKey $script:original|Set-Content -LiteralPath $script:originalPath -Encoding UTF8 +} +function Plan-Fixture { + $script:planResult=Invoke-WelaTranscriptRecovery -JournalPath $script:journal -OriginalResultsPath $script:originalPath -OutputPath (Join-Path $script:fixture 'plan') + $script:planPath=Join-Path $script:planResult.OutputPath 'plan.json' + $script:restoreParameters=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planResult.PlanSha256;OutputPath=$script:restoreOutput;Auto=$true} +} +try { + New-Fixture;Plan-Fixture + Assert ($script:planResult.RequiresTemporarySuspension -and $script:writes -eq 0) 'plan exposes a required temporary suspension without changes' + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters} 'explicit.*TemporarySuspension' + Assert (-not (Test-Path $script:restoreOutput)) 'missing suspension consent creates no recovery output' + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0 -and $script:writes -eq 3) 'enabled destination recovery completes three verified writes' + $confirmed=@(Get-ChildItem $script:restoreOutput '*-confirmed.json'|ForEach-Object {ConvertFrom-WelaRecoveryJson (Get-Content $_.FullName -Raw)}) + Assert ($confirmed[0].Step.Name -eq 'EnableTranscripting' -and $confirmed[0].Step.Value.Value -eq 0 -and $confirmed[1].Step.Name -eq 'OutputDirectory' -and $confirmed[2].Step.Value.Value -eq 1) 'explicit suspension precedes destination and original enablement comes last' + Assert ($confirmed[0].Before[0].Machine.EnableTranscripting.Value -eq 1 -and $confirmed[0].After[0].Machine.EnableTranscripting.Value -eq 0) 'confirmed receipts retain distinct before/after step snapshots' + Assert ($script:policy[0].Machine.OutputDirectory.Value -eq 'C:\Old' -and $script:policy[0].Machine.EnableInvocationHeader.Value -eq 0) 'original directory restored and header retained' + Assert ($report.SigmaEvtxCredit -eq 0) 'recovery grants no EVTX credit' + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Current policy/destination differs' + foreach($before in @(0,$null)) { + New-Fixture $before;Plan-Fixture + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.Status -eq 'Restored' -and $script:policy[0].Machine.EnableTranscripting.Value -eq $before) 'disabled or absent original enablement is recovered exactly' + Assert ($script:writes -eq $(if($null -eq $before){3}else{2})) 'only required ordered steps are written' + } + New-Fixture 0 $null;Plan-Fixture + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters + Assert ($report.Status -eq 'Restored' -and -not $script:policy[0].Machine.OutputDirectory.ValueExists -and $script:policy[0].Machine.OutputDirectory.KeyExists) 'absent output value restored with key retained' + New-Fixture 1 $null + Reject {Plan-Fixture} 'absent output directory requires' + New-Fixture 0 'C:\New';Plan-Fixture + $preview=$script:restoreParameters.Clone();$preview.Remove('OutputPath') + $report=Invoke-WelaTranscriptRecovery @preview -DryRun + Assert ($report.Status -eq 'WouldRestore' -and $script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) 'preview is read-only' + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters + Assert ($report.Status -eq 'Restored' -and $script:writes -eq 1) 'unchanged destination restores enablement only' + foreach($alter in @('wrong-host','failed','mismatch','type','duplicate','shared-view')) { + New-Fixture + switch($alter){ + 'wrong-host' {$script:entry.ComputerName='other'} + 'failed' {$script:original.Results[0].Status='Failed'} + 'mismatch' {$script:original.Results[0].Desired=Copy-Value $script:original.Results[0].Desired;$script:original.Results[0].Desired.EnableTranscripting.Value=0} + 'type' {$script:entry.Before.Policy[0].Machine.EnableTranscripting=Typed '1' String;$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed '1' String} + 'duplicate' {$script:original.Results += $script:original.Results[0]} + 'shared-view' {$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed 0} + } + Save-History + Reject {Plan-Fixture} 'history|Applied|differs|DWORD|shared|Shared' + Assert ($script:writes -eq 0) 'unsupported or inconsistent source evidence never mutates' + } + foreach($alter in @('source','host','policy','directory','protected','plan')) { + New-Fixture;Plan-Fixture + switch($alter){ + 'source' {$script:code='changed'} + 'host' {$script:machine='changed'} + 'policy' {foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 0}} + 'directory' {$script:acl='changed'} + 'protected' {$script:protected=@('changed')} + 'plan' {Add-Content -LiteralPath $script:planPath ' '} + } + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'differs|different' + Assert ($script:writes -eq 0) 'drift before restore causes no mutation' + } + New-Fixture;Plan-Fixture;$script:promptDrift=$true;$script:restoreParameters.Auto=$false + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'prompt-time typed policy drift blocks the first write' + New-Fixture;Plan-Fixture;$script:failWrite=2 + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 2 -and $script:policy[0].Machine.EnableTranscripting.Value -eq 0 -and $script:policy[0].Machine.OutputDirectory.Value -eq 'C:\New') 'partial failure stops and reports the actual suspended state' + Assert ((Test-Path (Join-Path $script:restoreOutput '001-confirmed.json')) -and (Test-Path (Join-Path $script:restoreOutput '002-pending.json')) -and -not (Test-Path (Join-Path $script:restoreOutput '003-pending.json'))) 'partial receipts preserve confirmed versus uncertain steps' + New-Fixture;Plan-Fixture;$script:driftWrite=1 + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 1 -and $report.Diagnostic -match 'Preserved PowerShell policy changed') 'independent policy drift after a write stops all later writes' + foreach($name in @('001-pending.json','001-confirmed.json')) { + New-Fixture;Plan-Fixture;$script:failArtifact=$name + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq $(if($name -like '*pending*'){0}else{1})) 'durable receipt failure stops before any further native writes' + } + New-Fixture;Plan-Fixture;$script:failArtifact='result.json' + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'durable artifact failure' + Assert ($script:writes -eq 3 -and (Test-Path (Join-Path $script:restoreOutput '003-confirmed.json'))) 'result persistence failure fails outward while durable final confirmation remains' + Reject {ConvertFrom-WelaRecoveryJson '{"x":1,"X":2}'} 'Duplicate' + Reject {ConvertFrom-WelaRecoveryJson '{x:1}'} 'strict JSON' + Write-Host "Passed $script:checks transcription recovery assertions; no Windows policy changes." +} finally {Remove-Item -LiteralPath $root -Recurse -Force} diff --git a/tests/TranscriptionRecovery.Windows.Tests.ps1 b/tests/TranscriptionRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..9cf84743 --- /dev/null +++ b/tests/TranscriptionRecovery.Windows.Tests.ps1 @@ -0,0 +1,116 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference='Stop' +if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: actual Windows transcription recovery requires Windows.';exit 0} +if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'This native mutation fixture requires explicit consent on a disposable GitHub-hosted runner.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +foreach($file in @('Configuration','AuditRecovery','PowerShellTranscription','TranscriptionRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$file+'.ps1'))} +$script:checks=0 +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +$root=New-WelaRecoveryOutput (Join-Path $env:RUNNER_TEMP ('wela-transcription-recovery-'+[guid]::NewGuid().ToString('N'))) +$before=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) +$protectedBefore=Get-WelaTranscriptRecoveryProtectedPolicy +Write-WelaRecoveryArtifact (Join-Path $root 'original-policy.json') $before +Write-WelaRecoveryArtifact (Join-Path $root 'original-protected-policy.json') $protectedBefore +$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) +$policyRoot='SOFTWARE\Policies\Microsoft\Windows\PowerShell' +$originalParents=@{} +foreach($path in @($policyRoot,($policyRoot+'\Transcription'))){$key=$base.OpenSubKey($path);$originalParents[$path]=($null -ne $key);if($key){$key.Dispose()}} +$base.Dispose() +$hostExe=Join-Path $PSHOME $(if($PSVersionTable.PSEdition -eq 'Desktop'){'powershell.exe'}else{'pwsh.exe'}) +$native51=Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe' +$restored=$false;$touched=$false +function Set-FixtureValue([string]$Name,$Value,[string]$Type='DWord') { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + $key=$base.CreateSubKey($policyRoot+'\Transcription') + try{if($null -eq $Value){$key.DeleteValue($Name,$false)}else{$key.SetValue($Name,$Value,[Microsoft.Win32.RegistryValueKind]$Type)};$key.Flush()}finally{$key.Dispose();$base.Dispose()} +} +function Invoke-WelaTranscriptFixtureCli { + param([string[]]$Parameters,[string]$Log,[switch]$ExpectFailure) + $global:LASTEXITCODE=$null + $priorPreference=$ErrorActionPreference + try { + $ErrorActionPreference='Continue' + & $hostExe -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Parameters *> $Log + $code=$global:LASTEXITCODE + } finally {$ErrorActionPreference=$priorPreference} + if($ExpectFailure){Assert ($null -ne $code -and $code -ne 0) 'native public CLI refuses unsupported or stale recovery'} + elseif($code -ne 0){throw "Public CLI failed ($code): $(Get-Content $Log -Raw)"} + $global:LASTEXITCODE=0 +} +try { + foreach($scenario in @('Enabled','DisabledAbsentDirectory','AbsentEnablement','Drift')) { + $case=New-WelaRecoveryOutput (Join-Path $root $scenario) + $old=New-WelaRecoveryOutput (Join-Path $case 'old-transcripts') + $new=New-WelaRecoveryOutput (Join-Path $case 'new-transcripts') + $touched=$true + Set-FixtureValue EnableTranscripting 0 + Set-FixtureValue OutputDirectory $(if($scenario -eq 'DisabledAbsentDirectory'){$null}else{$old}) String + Set-FixtureValue EnableTranscripting $(if($scenario -eq 'AbsentEnablement'){$null}elseif($scenario -eq 'DisabledAbsentDirectory'){0}else{1}) + $caseBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) + $preserved=Get-WelaTranscriptRecoveryProtectedPolicy + Write-WelaRecoveryArtifact (Join-Path $case 'fixture-before.json') $caseBefore + $backup=Join-Path $case 'configure-backup';$original=Join-Path $case 'configure-result.json' + Invoke-WelaTranscriptFixtureCli @('powershell-transcription','-TranscriptionAction','Configure','-TranscriptDirectory',$new,'-Auto','-BackupPath',$backup,'-ResultsPath',$original) (Join-Path $case 'configure.log') + $configured=ConvertFrom-WelaRecoveryJson (Get-Content $original -Raw) + Assert ($configured.Results.Count -eq 1 -and $configured.Results[0].Status -eq 'Applied') 'actual public Configure creates the exact completed composite history' + $planDirectory=Join-Path $case 'plan';$planPath=Join-Path $planDirectory 'plan.json' + Invoke-WelaTranscriptFixtureCli @('transcription-recovery','-TranscriptRecoveryJournalPath',(Join-Path $backup 'before.jsonl'),'-TranscriptRecoveryOriginalResultsPath',$original,'-TranscriptRecoveryOutputPath',$planDirectory) (Join-Path $case 'plan.log') + $plan=ConvertFrom-WelaRecoveryJson (Get-Content $planPath -Raw) + $planHash=(Get-FileHash $planPath -Algorithm SHA256).Hash.ToLowerInvariant() + Assert ($plan.Context.Reader.UserSid -and $plan.Sources.'scripts/TranscriptionRecovery.ps1' -and $plan.SigmaEvtxCredit -eq 0) 'native plan binds reader/code and grants no EVTX credit' + $restoreDirectory=Join-Path $case 'restore' + $restoreArguments=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanPath',$planPath,'-TranscriptRecoveryPlanHash',$planHash,'-TranscriptRecoveryOutputPath',$restoreDirectory,'-Auto') + if($scenario -eq 'Drift') { + Set-FixtureValue EnableTranscripting 0 + Invoke-WelaTranscriptFixtureCli ($restoreArguments+@('-TranscriptRecoveryAllowTemporarySuspension')) (Join-Path $case 'drift-refusal.log') -ExpectFailure + Assert (-not (Test-Path $restoreDirectory) -and (Get-WelaTranscriptRegistryValue -Name EnableTranscripting).Value -eq 0) 'actual changed native policy is preserved before any output/write' + continue + } + if($plan.RequiresTemporarySuspension) { + Invoke-WelaTranscriptFixtureCli $restoreArguments (Join-Path $case 'consent-refusal.log') -ExpectFailure + Assert (-not (Test-Path $restoreDirectory) -and (Get-WelaTranscriptRegistryValue -Name EnableTranscripting).Value -eq 1) 'no suspension consent preserves the enabled policy' + $restoreArguments += '-TranscriptRecoveryAllowTemporarySuspension' + } + $previewArguments=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanPath',$planPath,'-TranscriptRecoveryPlanHash',$planHash,'-DryRun') + if($plan.RequiresTemporarySuspension){$previewArguments += '-TranscriptRecoveryAllowTemporarySuspension'} + Invoke-WelaTranscriptFixtureCli $previewArguments (Join-Path $case 'preview.log') + Assert ((Get-WelaRecoveryKey @(Get-WelaTranscriptPolicy @('Registry64','Registry32'))) -ceq (Get-WelaRecoveryKey $plan.ExpectedPolicy)) 'actual public preview leaves both native registry views unchanged' + Invoke-WelaTranscriptFixtureCli $restoreArguments (Join-Path $case 'restore.log') + $report=ConvertFrom-WelaRecoveryJson (Get-Content (Join-Path $restoreDirectory 'result.json') -Raw) + Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0) 'actual public Restore completes' + Assert ((Get-WelaRecoveryKey @(Get-WelaTranscriptPolicy @('Registry64','Registry32'))) -ceq (Get-WelaRecoveryKey $caseBefore)) 'native restore matches original typed policy including value absence in both views' + Assert ((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -ceq (Get-WelaRecoveryKey $preserved)) 'all other machine/user PowerShell policy remains exact' + $pending=@(Get-ChildItem $restoreDirectory '*-pending.json');$confirmed=@(Get-ChildItem $restoreDirectory '*-confirmed.json') + Assert ($pending.Count -eq $plan.Steps.Count -and $confirmed.Count -eq $plan.Steps.Count) 'every actual native write has separate durable pending and confirmed receipts' + if($scenario -eq 'Enabled') { + $marker='WELA_RECOVERED_TRANSCRIPT_'+[guid]::NewGuid().ToString('N') + & $native51 -NoLogo -NoProfile -Command "Write-Output '$marker'" *> (Join-Path $case 'benign-session.log') + Assert ($LASTEXITCODE -eq 0) 'fresh built-in Windows PowerShell session completes after recovery' + $matching=@(Get-ChildItem -LiteralPath $old -Recurse -File -Filter '*.txt'|Where-Object {(Get-Content $_.FullName -Raw).Contains($marker)}) + Assert ($matching.Count -eq 1) 'one real fresh Windows PowerShell transcript contains the benign marker at the restored destination' + Write-WelaRecoveryArtifact (Join-Path $case 'transcript-marker.json') ([pscustomobject]@{Marker=$marker;Path=$matching[0].FullName;Sha256=(Get-FileHash $matching[0].FullName).Hash;Scope='Disposable local fixture only; no production/central assertion'}) + } + } +} finally { + if($touched) { + Set-FixtureValue EnableTranscripting 0 + foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')) { + $value=$before[0].Machine.$name + Set-FixtureValue $name $(if($value.ValueExists){$value.Value}else{$null}) $(if($value.ValueExists){$value.Type}else{'DWord'}) + } + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + try { + foreach($path in @(($policyRoot+'\Transcription'),$policyRoot)) { + if($originalParents[$path]){continue} + $key=$base.OpenSubKey($path) + $empty=$null -ne $key -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0 + if($key){$key.Dispose()};if($empty){$base.DeleteSubKey($path,$false)} + } + } finally {$base.Dispose()} + } + $after=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) + $restored=(Get-WelaRecoveryKey $after) -ceq (Get-WelaRecoveryKey $before) -and (Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -ceq (Get-WelaRecoveryKey $protectedBefore) + Write-WelaRecoveryArtifact (Join-Path $root 'cleanup.json') ([pscustomobject]@{CleanupVerified=$restored;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();Computer=$env:COMPUTERNAME;After=$after}) + if(-not $restored){throw "Exact native policy cleanup failed; retained private evidence at $root"} +} +Write-Host "Passed $script:checks actual native transcription recovery assertions; exact policy cleanup verified. Evidence: $root" diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..aabe0385 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,9 @@ **改善:** +- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@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..744d3e47 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From 718ef8c91d88d2696071678fa842cb57efc3f4fb Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:51:31 +0900 Subject: [PATCH 04/25] Add fixed offline CAPI2 chain source probe --- .gitattributes | 3 + .github/workflows/capi2-probe.yml | 46 ++++++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 13 +++ docs/capi2-probe.md | 34 ++++++ scripts/Capi2Probe.ps1 | 147 +++++++++++++++++++++++++ scripts/Capi2ProbeNative.cs | 41 +++++++ scripts/Capi2ProbeWorker.ps1 | 27 +++++ tests/Capi2Probe.Cli.Tests.ps1 | 14 +++ tests/Capi2Probe.Tests.ps1 | 41 +++++++ tests/Capi2Probe.Windows.Tests.ps1 | 62 +++++++++++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 15 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/capi2-probe.yml create mode 100644 docs/capi2-probe.md create mode 100644 scripts/Capi2Probe.ps1 create mode 100644 scripts/Capi2ProbeNative.cs create mode 100644 scripts/Capi2ProbeWorker.ps1 create mode 100644 tests/Capi2Probe.Cli.Tests.ps1 create mode 100644 tests/Capi2Probe.Tests.ps1 create mode 100644 tests/Capi2Probe.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 7f0dff24..fd80ff1a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -58,3 +58,6 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WmiNamespaceAuditing.ps1 text eol=lf /scripts/WefArrival.ps1 text eol=lf /tests/WmiProbe*.ps1 text eol=lf + +/scripts/Capi2Probe* text eol=lf +/tests/Capi2Probe* text eol=lf diff --git a/.github/workflows/capi2-probe.yml b/.github/workflows/capi2-probe.yml new file mode 100644 index 00000000..76505212 --- /dev/null +++ b/.github/workflows/capi2-probe.yml @@ -0,0 +1,46 @@ +name: Native offline CAPI2 chain probe +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + capi2-probe: + 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: Portable and public guards in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/Capi2Probe.Tests.ps1 + ./tests/Capi2Probe.Cli.Tests.ps1 + - name: Genuine public CAPI2 probe in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Portable and public guards in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/Capi2Probe.Tests.ps1 + ./tests/Capi2Probe.Cli.Tests.ps1 + - name: Genuine public CAPI2 probe in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Retain native XML, generated public certificates and cleanup evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: capi2-probe-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-capi2-native-*/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..528c7e26 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/capi2-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 a6ab89ad..cb81fee3 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 - `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..8a1cc20c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. - 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..e90ecf5f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -42,6 +42,9 @@ [ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit', [string]$AppLockerPolicyPath, [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List', + [ValidateSet('Plan','Run')][string]$Capi2ProbeAction = 'Plan', + [string]$Capi2ProbeOutputPath, + [ValidateRange(1,30)][int]$Capi2ProbeTimeoutSeconds = 15, [ValidateSet('Plan','Run')][string]$WmiProbeAction = 'Plan', [string]$WmiProbeNamespace, [string]$WmiProbeOutputPath, @@ -167,6 +170,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") +. (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop @@ -1962,6 +1966,7 @@ Usage: ./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 wec-update -Help # Review query/description updates on a disabled subscription + ./WELA.ps1 capi2-probe -Help # Fixed offline chain and matched CAPI2 event11 evidence ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence ./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event ./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector @@ -2045,6 +2050,8 @@ if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -li if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecRuntimeId','WecRuntimeMaximumSources','ResultsPath','Help')}).Count) { throw 'wec-runtime accepts only selected runtime IDs, source cap and a new result path. No command was run.' } +if ($Cmd -ne 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Capi2Probe*'}).Count) {throw 'Capi2Probe options require capi2-probe.'} +if ($Cmd -eq 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','Capi2ProbeAction','Capi2ProbeOutputPath','Capi2ProbeTimeoutSeconds','Help')}).Count) {throw 'capi2-probe accepts only dedicated probe options.'} if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'} if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'} if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'} @@ -2246,6 +2253,12 @@ switch ($Cmd.ToLower()) { $report if($report.ExitCode){exit $report.ExitCode} } + 'capi2-probe' { + if ($Help) {Write-Host 'Usage: capi2-probe [-Capi2ProbeAction Plan|Run] [-Capi2ProbeOutputPath new-private-directory] [-Capi2ProbeTimeoutSeconds 1..30]. Fixed offline ephemeral certificate-chain build; requires an enabled readable CAPI2 channel. No configuration, trust, TLS or Sigma claim. See docs/capi2-probe.md.';return} + $report=Invoke-WelaCapi2Probe -Action $Capi2ProbeAction -OutputPath $Capi2ProbeOutputPath -TimeoutSeconds $Capi2ProbeTimeoutSeconds + $report + if($report.ExitCode){exit $report.ExitCode} + } 'wmi-probe' { if ($Help) {Write-Host 'Usage: wmi-probe [-WmiProbeAction Plan|Run] -WmiProbeNamespace root\default [-WmiProbeOutputPath new-private-directory] [-WmiProbeTimeoutSeconds 1..30]. Fixed local read only; requires existing matching SACL and auditing. No policy changes, remote access or Sigma credit. See docs/wmi-probe.md.';return} $report=Invoke-WelaWmiProbe -Action $WmiProbeAction -Namespace $WmiProbeNamespace -OutputPath $WmiProbeOutputPath -TimeoutSeconds $WmiProbeTimeoutSeconds diff --git a/docs/capi2-probe.md b/docs/capi2-probe.md new file mode 100644 index 00000000..7638b91d --- /dev/null +++ b/docs/capi2-probe.md @@ -0,0 +1,34 @@ +# Fixed local CAPI2 certificate-chain probe + +`capi2-probe` measures one built-in source on Windows Server 2022/2025: an offline native chain build for a newly generated ephemeral self-signed certificate, followed by one matching CAPI2 Operational event 11. The expected chain outcome is an untrusted root. Success means that this local operation and event were observed; it does not mean that the certificate is trusted. + +```powershell +./WELA.ps1 capi2-probe -Capi2ProbeAction Plan +./WELA.ps1 capi2-probe -Capi2ProbeAction Run -Capi2ProbeOutputPath C:\Evidence\new-capi2-probe +``` + +Plan reads prerequisites and creates no files. Run requires a new directory on a local fixed drive; its evidence directory blocks inherited broad access. Use an existing token that can read `Microsoft-Windows-CAPI2/Operational`. The channel must already be enabled. The probe does not change channel configuration, audit policy, services, certificate stores, trust settings or reader permissions. Existing native-channel configuration commands remain separate. + +Run launches the same PowerShell executable in a fresh worker with a parent-generated nonce and a twenty-second process deadline. The worker creates an unnamed ephemeral Microsoft Software Key Storage Provider RSA-2048 key, signs an in-memory certificate with `CN=WelaCapi2Probe_` and a ten-minute validity interval, then calls `CertGetCertificateChain` once. The certificate has no AIA, CRL or other extensions. The key is disposed and never exported; the retained PEM/DER contains only the public certificate. + +The fixed native flags are `0x80002104`: `CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL`, `CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY`, `CERT_CHAIN_DISABLE_AIA` and `CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE`. No revocation-check request, additional store, custom trust engine or end-certificate caching is selected. These per-call flags prevent network retrieval by the chain operation; no machine-wide network or trust policy is altered. + +Evidence must agree on the actual worker PID, caller SID/logon/group context, before/after token observations, generated DER/subject/thumbprint/SHA-256 and nonce. A native precise UTC interval surrounds the chain call; the event must fall within those exact inclusive bounds and after the observed channel record boundary. The matcher requires provider GUID, channel, event11 version0, native task/opcode/keywords, source computer, security SID, certificate references, offline flags, one certificate element and the expected untrusted-root result. Incomplete, ambiguous, capped or changed-context evidence remains `Unverified` and exits nonzero. + +Collection waits up to 15 seconds by default (`-Capi2ProbeTimeoutSeconds 1..30`), queries at most 64 candidates and requires exactly one match. The bundle retains before/after context, worker operation including public certificate DER, public certificate PEM, raw matched event XML and artifact hashes. Failed matching retains up to four bounded candidate XML records. These local hashes detect altered artifacts; they are not a remote attestation or signed chain of custody. + +This probe grants no ready-rule credit. It does not exercise TLS, remote connections, revocation retrieval, certificate enrollment, WEF delivery, the existing CAPI2 pack's event70 mapping, a Sigma rule or backend translation. Issues #386 and #367 have broader remaining acceptance criteria. Sysmon and external telemetry are excluded. + +## Validation + +`tests/Capi2Probe.Tests.ps1` validates certificate binding, native-result constraints, prerequisite guards, exact XML source/field checks and UTC boundaries with portable fixtures. `tests/Capi2Probe.Cli.Tests.ps1` checks public option isolation. Synthetic fixtures do not prove Windows telemetry. + +`tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite` is restricted to opted-in disposable GitHub-hosted standalone Server 2022/2025. It invokes three independent public probes under Windows PowerShell 5.1 and PowerShell 7. Only the fixture may temporarily enable CAPI2; it retains the original and restored channel configuration, checks CurrentUser/LocalMachine My, Root and CA inventories, preserves all probe bundles and writes cleanup evidence even on failure. Native results must be assessed from the current workflow artifacts. + +## Microsoft API references + +- [CertGetCertificateChain flags and ownership](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certgetcertificatechain) +- [CERT_CHAIN_PARA](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_chain_para), [CERT_CHAIN_CONTEXT](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_chain_context), [CERT_SIMPLE_CHAIN](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_simple_chain) and [CERT_TRUST_STATUS](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_trust_status) +- [Unnamed CngKey creation is ephemeral](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.cngkey.create) +- [CertificateRequest.CreateSelfSigned](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.certificaterequest.createselfsigned) +- [GetSystemTimePreciseAsFileTime](https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemtimepreciseasfiletime) diff --git a/scripts/Capi2Probe.ps1 b/scripts/Capi2Probe.ps1 new file mode 100644 index 00000000..0f4d8f26 --- /dev/null +++ b/scripts/Capi2Probe.ps1 @@ -0,0 +1,147 @@ +# Explicit fixed local CAPI2 source measurement. No channel, key-store or trust-policy writes. +function Initialize-WelaCapi2ProbeNative { + Initialize-WelaWmiProbeNative + $source=Join-Path $PSScriptRoot 'Capi2ProbeNative.cs';$hash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + if(-not ('Wela.Capi2Probe.Native' -as [type])){Add-Type -Path $source -ErrorAction Stop;$script:WelaCapi2ProbeNativeHash=$hash} + if($script:WelaCapi2ProbeNativeHash -cne $hash){throw 'Loaded CAPI2 helper differs from its source; start a fresh session.'} +} +function Get-WelaCapi2ProbeSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/Capi2Probe.ps1','scripts/Capi2ProbeWorker.ps1','scripts/Capi2ProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256).Hash.ToLowerInvariant()} + $sources|ConvertTo-Json -Compress +} +function Get-WelaCapi2ProbeChannel { + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-CAPI2/Operational') + try{[pscustomobject][ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()} +} +function Get-WelaCapi2ProbeState { + Initialize-WelaCapi2ProbeNative + $token=[Wela.WmiProbe.Native]::Snapshot();$hostState=Get-WelaChannelReadHost + $provider=[Diagnostics.Eventing.Reader.ProviderMetadata]::new('Microsoft-Windows-CAPI2') + try{$event=@($provider.Events|Where-Object Id -eq 11);$metadata=[pscustomobject]@{Name=$provider.Name;Guid=$provider.Id.ToString();Event11Versions=@($event|ForEach-Object Version);LogNames=@($provider.LogLinks|ForEach-Object LogName|Sort-Object)}}finally{$provider.Dispose()} + $engine=(Get-Process -Id $PID).Path + $state=[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Token=$token;Channel=(Get-WelaCapi2ProbeChannel);Provider=$metadata;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaCapi2ProbeSources)} + if((Get-WelaWmiProbeTokenKey $token) -cne (Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()))){throw 'Token changed during CAPI2 prerequisite observation.'} + $state +} +function Get-WelaCapi2ProbeStateKey { + param($State) + if($State.Host.Build -notin @(20348,26100) -or $State.Host.ProductType -notin @(2,3) -or -not $State.Host.UBR -or $State.Host.Computer -cne $State.Computer){throw 'CAPI2 probe requires an observed Server 2022/2025 build and patch context.'} + if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-CAPI2/Operational' -or $State.Channel.Type -cne 'Operational' -or $State.Channel.Provider -cne 'Microsoft-Windows-CAPI2' -or -not $State.Channel.SecurityDescriptor){throw 'CAPI2 Operational must already be enabled with an observed descriptor.'} + if($State.Provider.Name -cne 'Microsoft-Windows-CAPI2' -or $State.Provider.Guid -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or @($State.Provider.Event11Versions).Count -ne 1 -or $State.Provider.Event11Versions[0] -ne 0 -or $State.Channel.Name -cnotin $State.Provider.LogNames){throw 'Unreviewed CAPI2 provider or event11 schema version.'} + $null=Get-WelaWmiProbeTokenKey $State.Token + $State|ConvertTo-Json -Depth 16 -Compress +} +function Get-WelaCapi2ProbeWatermark { + $latest=Read-WelaChannelLatest 'Microsoft-Windows-CAPI2/Operational' + if($latest.Status -eq 'ReadAllowedEmpty'){return [long]0} + if($latest.Status -ne 'EventObserved'){throw ('CAPI2 is not readable: '+$latest.Status+' '+$latest.Diagnostic)} + [long]$latest.Event.RecordId +} +function Assert-WelaCapi2ProbeCertificate { + param($Operation,[string]$Nonce) + if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or $Operation.Nonce -cne $Nonce -or $Operation.KeyEphemeral -isnot [bool] -or -not $Operation.KeyEphemeral -or $Operation.CertificateDerBase64 -isnot [string] -or $Operation.CertificateDerBase64.Length -gt 12000){throw 'Unexpected generated certificate identity.'} + $der=[Convert]::FromBase64String($Operation.CertificateDerBase64) + if($der.Length -lt 128 -or $der.Length -gt 8192){throw 'Certificate DER exceeds its evidence bound.'} + $certificate=[Security.Cryptography.X509Certificates.X509Certificate2]::new($der) + try{ + if($certificate.Subject -cne ('CN=WelaCapi2Probe_'+$Nonce) -or $certificate.Issuer -cne $certificate.Subject -or $Operation.Subject -cne $certificate.Subject -or $Operation.Thumbprint -cne $certificate.Thumbprint -or $certificate.Extensions.Count -ne 0 -or $certificate.HasPrivateKey -or $certificate.SignatureAlgorithm.Value -cne '1.2.840.113549.1.1.11' -or $certificate.PublicKey.Oid.Value -cne '1.2.840.113549.1.1.1'){throw 'Certificate DER does not describe the fixed ephemeral self-signed probe.'} + $rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate) + try{if($rsa.get_KeySize() -ne 2048){throw 'Unexpected probe RSA key size.'}}finally{$rsa.Dispose()} + $start=ConvertTo-WelaArrivalUtc $Operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Operation.CompletedUtc + if($certificate.NotBefore.ToUniversalTime() -gt $start.UtcDateTime -or $certificate.NotAfter.ToUniversalTime() -lt $end.UtcDateTime -or ($certificate.NotAfter-$certificate.NotBefore).TotalMinutes -gt 11){throw 'Certificate validity does not cover the bounded operation.'} + if($Operation.Chain.Flags -ne 2147492100 -or $Operation.Chain.ErrorStatus -ne 32 -or $Operation.Chain.Chains -ne 1 -or $Operation.Chain.Elements -ne 1){throw 'Expected one offline untrusted self-signed native chain.'} + }finally{$certificate.Dispose()} + ,$der +} +function Start-WelaCapi2ProbeBuild { + param($State) + if((Get-WelaCapi2ProbeStateKey (Get-WelaCapi2ProbeState)) -cne (Get-WelaCapi2ProbeStateKey $State)){throw 'CAPI2 prerequisites changed before the operation.'} + $watermark=Get-WelaCapi2ProbeWatermark;$nonce=[guid]::NewGuid().ToString('N') + $worker=Join-Path $PSScriptRoot 'Capi2ProbeWorker.ps1' + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$nonce + $info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding + $process=$null + try{ + $launch=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow();$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'Fixed CAPI2 worker exceeded twenty seconds.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'Fixed worker output did not complete.'} + if($output.Result.Length -gt 262144 -or $errors.Result.Length -gt 65536){throw 'Worker output exceeded its evidence bound.'} + if($process.ExitCode -ne 0 -or $errors.Result){throw ('Fixed CAPI2 worker failed: '+$errors.Result)} + $operation=ConvertFrom-WelaArrivalJson $output.Result + if($operation.ProcessId -ne $process.Id -or $operation.ProcessName -ine [IO.Path]::GetFileName($State.Engine)){throw 'Worker process identity differs.'} + $interval=Assert-WelaWmiProbeInterval $operation $launch ([DateTimeOffset][Wela.WmiProbe.Native]::UtcNow()) + $operation.StartedUtc=$interval.Start.UtcDateTime.ToString('o');$operation.CompletedUtc=$interval.End.UtcDateTime.ToString('o') + $der=Assert-WelaCapi2ProbeCertificate $operation $nonce + if((Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token differs from caller or changed during operation.'} + $operation|Add-Member NoteProperty RecordIdBefore $watermark + $operation|Add-Member NoteProperty CertificateSha256 (Get-WelaArrivalHash $der) + $operation + }finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}} +} +function Read-WelaCapi2ProbeEvents { + param($Operation) + $query="*[System[Provider[@Name='Microsoft-Windows-CAPI2'] and EventID=11 and EventRecordID>$($Operation.RecordIdBefore) and Execution[@ProcessID='$($Operation.ProcessId)'] and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]" + $records=@();$xml=@() + try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-CAPI2/Operational' -FilterXPath $query -MaxEvents 64 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}} + foreach($record in $records){$text=[string]$record.ToXml();if($text.Length -gt 131072){throw 'CAPI2 event exceeds 128 KiB characters.'};$xml+=$text} + [pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 64);Query=$query;MaximumEvents=64} + }finally{foreach($record in $records){$record.Dispose()}} +} +function Test-WelaCapi2ProbeEvent { + param([string]$Xml,$Operation,$State) + $reader=$null + try{ + if($Xml.Length -gt 131072){return $false} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','Level','Task','Opcode','Keywords','EventRecordID','Channel','Computer','TimeCreated','Execution','Security')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-CAPI2' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or $system.EventID.InnerText -cne '11' -or $system.Version.InnerText -cne '0' -or $system.Level.InnerText -cne '2' -or $system.Task.InnerText -cne '11' -or $system.Opcode.InnerText -cne '2' -or $system.Keywords.InnerText -ine '0x4000000000000003' -or $system.Channel.InnerText -cne 'Microsoft-Windows-CAPI2/Operational' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain} + if($system.Computer.InnerText -notin $computers -or $system.Execution.GetAttribute('ProcessID') -cne [string]$Operation.ProcessId -or $system.Security.GetAttribute('UserID') -cne $Operation.BeforeToken.Sid){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false} + $data=$doc.SelectSingleNode('/e:Event/e:UserData',$ns) + # Namespace and exact paths are pinned to native event11, never a recursive name search. + if(@($data.ChildNodes|Where-Object NodeType -eq Element).Count -ne 1){return $false} + $chain=$data.SelectNodes('e:CertGetCertificateChain',$ns);if($chain.Count -ne 1){return $false};$chain=$chain[0] + $fields=@{};foreach($name in @('Certificate','Flags','ChainEngineInfo','CertificateChain','EventAuxInfo','Result')){$nodes=$chain.SelectNodes("e:$name",$ns);if($nodes.Count -ne 1){return $false};$fields[$name]=$nodes[0]} + $cert=$fields.Certificate + if($cert.GetAttribute('fileRef') -cne ($Operation.Thumbprint+'.cer') -or $cert.GetAttribute('subjectName') -cne ('WelaCapi2Probe_'+$Operation.Nonce) -or $fields.Flags.GetAttribute('value') -ine '80002104' -or $fields.ChainEngineInfo.GetAttribute('context') -cne 'user' -or $fields.EventAuxInfo.GetAttribute('ProcessName') -ine $Operation.ProcessName -or $fields.Result.GetAttribute('value') -ine '800B0109'){return $false} + $error=$fields.CertificateChain.SelectNodes('e:TrustStatus/e:ErrorStatus',$ns);$elements=$fields.CertificateChain.SelectNodes('e:ChainElement',$ns) + if($error.Count -ne 1 -or $error[0].GetAttribute('value') -cne '20' -or $elements.Count -ne 1){return $false} + $elementCert=$elements[0].SelectNodes('e:Certificate',$ns);$elementError=$elements[0].SelectNodes('e:TrustStatus/e:ErrorStatus',$ns) + if($elementCert.Count -ne 1 -or $elementCert[0].GetAttribute('fileRef') -cne $cert.GetAttribute('fileRef') -or $elementCert[0].GetAttribute('subjectName') -cne $cert.GetAttribute('subjectName') -or $elementError.Count -ne 1 -or $elementError[0].GetAttribute('value') -cne '20'){return $false} + return $true + }catch{return $false}finally{if($reader){$reader.Dispose()}} +} +function Invoke-WelaCapi2Probe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new Capi2ProbeOutputPath; Plan creates no files.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaOfflineCapi2ChainProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;ChannelChanges=0;StoreChanges=0;TrustPolicyChanges=0;ReadyRuleCredit=0;Scope='One fixed local ephemeral certificate-chain build and matching CAPI2 event11 only. Untrusted self-signed outcome expected; no TLS, revocation, remote, forwarding, catalog event70 or Sigma/backend validation. Sysmon excluded.'} + if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot} + try{ + $before=Get-WelaCapi2ProbeState;$report.Before=$before;$key=Get-WelaCapi2ProbeStateKey $before;$null=Get-WelaCapi2ProbeWatermark + if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 20) + $operation=Start-WelaCapi2ProbeBuild $before;$report.Operation=$operation + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 16) + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'certificate.pem' ("-----BEGIN CERTIFICATE-----`n"+$operation.CertificateDerBase64+"`n-----END CERTIFICATE-----`n") + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() + do{$batch=Read-WelaCapi2ProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count + if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'The 64-event query cap was reached or completeness is unknown.'} + $matches=@($batch.Xml|Where-Object {Test-WelaCapi2ProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.Matches=$matches.Count + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Expected exactly one matching CAPI2 event11 in the fixed operation interval.'} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0] + if((Get-WelaCapi2ProbeWatermark) -lt $operation.RecordIdBefore){throw 'CAPI2 record boundary moved backwards; continuity is unknown.'} + $after=Get-WelaCapi2ProbeState;$report.After=$after;if((Get-WelaCapi2ProbeStateKey $after) -cne $key){throw 'Host, token, provider, channel or implementation changed during collection.'} + $report.Status='LocalChainEventObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaCapi2ProbeState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}};if($report.OutputPath -and $report.After){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 20)}} + if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)} + $report +} diff --git a/scripts/Capi2ProbeNative.cs b/scripts/Capi2ProbeNative.cs new file mode 100644 index 00000000..43874bc8 --- /dev/null +++ b/scripts/Capi2ProbeNative.cs @@ -0,0 +1,41 @@ +// Fixed offline chain build. No certificate/key store or policy writes. +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +namespace Wela.Capi2Probe { + public sealed class ChainResult { public uint Flags, ErrorStatus, InfoStatus, Chains, Elements; } + public static class Native { + public const uint OfflineFlags=0x80002104; // cache-only URL/revocation, no AIA, no auth-root auto-update + [StructLayout(LayoutKind.Sequential)] struct Usage { public uint Count; public IntPtr Oids; } + [StructLayout(LayoutKind.Sequential)] struct Match { public uint Type; public Usage Usage; } + [StructLayout(LayoutKind.Sequential)] struct Parameters { + public uint Size; public Match RequestedUsage,RequestedIssuancePolicy; + public uint UrlTimeout; public int CheckFreshness; public uint Freshness; + public IntPtr CacheResync,StrongSign; public uint StrongFlags; + } + // Both CERT_CHAIN_CONTEXT and CERT_SIMPLE_CHAIN have this documented prefix. + [StructLayout(LayoutKind.Sequential)] struct ChainPrefix { public uint Size,Error,Info,Count; public IntPtr Entries; } + [DllImport("crypt32.dll",ExactSpelling=true,SetLastError=true)] static extern IntPtr CertCreateCertificateContext(uint encoding,byte[] encoded,uint length); + [DllImport("crypt32.dll",ExactSpelling=true,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool CertGetCertificateChain(IntPtr engine,IntPtr certificate,IntPtr time,IntPtr additionalStore,ref Parameters parameters,uint flags,IntPtr reserved,out IntPtr chain); + [DllImport("crypt32.dll",ExactSpelling=true)] static extern void CertFreeCertificateChain(IntPtr chain); + [DllImport("crypt32.dll",ExactSpelling=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool CertFreeCertificateContext(IntPtr certificate); + public static ChainResult Build(byte[] der) { + if(IntPtr.Size!=8 || Marshal.SizeOf(typeof(Parameters))!=96 || Marshal.SizeOf(typeof(ChainPrefix))!=24)throw new InvalidOperationException("Unsupported native chain structure layout."); + if(der==null || der.Length<128 || der.Length>8192)throw new ArgumentException("Certificate DER exceeds the fixed bound."); + IntPtr certificate=CertCreateCertificateContext(1,der,(uint)der.Length),chain=IntPtr.Zero; + if(certificate==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + Parameters p=new Parameters();p.Size=(uint)Marshal.SizeOf(typeof(Parameters));p.UrlTimeout=1000; + // No revocation-check request, additional store, custom trust engine, or caching of the end certificate. + if(!CertGetCertificateChain(IntPtr.Zero,certificate,IntPtr.Zero,IntPtr.Zero,ref p,OfflineFlags,IntPtr.Zero,out chain))throw new Win32Exception(Marshal.GetLastWin32Error()); + if(chain==IntPtr.Zero)throw new InvalidOperationException("Native chain context is absent."); + ChainPrefix c=(ChainPrefix)Marshal.PtrToStructure(chain,typeof(ChainPrefix)); + if(c.Size<24 || c.Count!=1 || c.Entries==IntPtr.Zero)throw new InvalidOperationException("Unexpected native chain shape."); + IntPtr simple=Marshal.ReadIntPtr(c.Entries);if(simple==IntPtr.Zero)throw new InvalidOperationException("Native simple chain is absent."); + ChainPrefix s=(ChainPrefix)Marshal.PtrToStructure(simple,typeof(ChainPrefix)); + if(s.Size<24 || s.Count!=1 || s.Entries==IntPtr.Zero || s.Error!=c.Error)throw new InvalidOperationException("Unexpected native simple chain shape."); + return new ChainResult {Flags=OfflineFlags,ErrorStatus=c.Error,InfoStatus=c.Info,Chains=c.Count,Elements=s.Count}; + } finally {if(chain!=IntPtr.Zero)CertFreeCertificateChain(chain);CertFreeCertificateContext(certificate);} + } + } +} diff --git a/scripts/Capi2ProbeWorker.ps1 b/scripts/Capi2ProbeWorker.ps1 new file mode 100644 index 00000000..0e79352d --- /dev/null +++ b/scripts/Capi2ProbeWorker.ps1 @@ -0,0 +1,27 @@ +# Fixed local operation. The parent bounds this process to twenty seconds. +param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce) +$ErrorActionPreference='Stop' +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +. (Join-Path $PSScriptRoot 'WmiProbe.ps1') +. (Join-Path $PSScriptRoot 'Capi2Probe.ps1') +Initialize-WelaCapi2ProbeNative +$before=[Wela.WmiProbe.Native]::Snapshot() +$key=$null;$rsa=$null;$certificate=$null +try { + $parameters=[Security.Cryptography.CngKeyCreationParameters]::new() + $parameters.Provider=[Security.Cryptography.CngProvider]::MicrosoftSoftwareKeyStorageProvider + $parameters.Parameters.Add([Security.Cryptography.CngProperty]::new('Length',[BitConverter]::GetBytes([int]2048),[Security.Cryptography.CngPropertyOptions]::None)) + $key=[Security.Cryptography.CngKey]::Create([Security.Cryptography.CngAlgorithm]::Rsa,$null,$parameters) + if(-not $key.IsEphemeral -or $key.KeyName){throw 'The generated CNG key is not ephemeral.'} + $rsa=[Security.Cryptography.RSACng]::new($key) + $request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$Nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) + $now=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow() + $certificate=$request.CreateSelfSigned($now.AddMinutes(-5),$now.AddMinutes(5)) + $der=$certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert) + $started=[Wela.WmiProbe.Native]::UtcNow() + $chain=[Wela.Capi2Probe.Native]::Build($der) + $completed=[Wela.WmiProbe.Native]::UtcNow() + $after=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed during the chain build.'} + [pscustomobject]@{Nonce=$Nonce;CertificateDerBase64=[Convert]::ToBase64String($der);Thumbprint=$certificate.Thumbprint;Subject=$certificate.Subject;KeyEphemeral=$key.IsEphemeral;ProcessId=$PID;ProcessName=[IO.Path]::GetFileName((Get-Process -Id $PID).Path);StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');Clock='GetSystemTimePreciseAsFileTime';BeforeToken=$before;AfterToken=$after;Chain=$chain}|ConvertTo-Json -Depth 12 -Compress +}finally{if($certificate){$certificate.Dispose()};if($rsa){$rsa.Dispose()};if($key){$key.Dispose()}} diff --git a/tests/Capi2Probe.Cli.Tests.ps1 b/tests/Capi2Probe.Cli.Tests.ps1 new file mode 100644 index 00000000..f77f8ed3 --- /dev/null +++ b/tests/Capi2Probe.Cli.Tests.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('capi2-probe','-Help');Code=0;Pattern='Fixed offline'}, + @{Args=@('configure','-Capi2ProbeAction','Run','-Auto');Code=1;Pattern='require capi2-probe'}, + @{Args=@('wmi-auditing','-Capi2ProbeAction','Run');Code=1;Pattern='require capi2-probe'}, + @{Args=@('capi2-probe','-Help','-WmiAction','Configure');Code=1;Pattern='only dedicated'}, + @{Args=@('capi2-probe','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('capi2-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('capi2-probe','-Help','-ResultsPath','unused');Code=1;Pattern='only dedicated'}, + @{Args=@('capi2-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('capi2-probe','-Capi2ProbeAction','Run');Code=1;Pattern='new Capi2ProbeOutputPath'}) +foreach($case in $cases){$ErrorActionPreference='Continue';$output=& $engine -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') [$code] $output"};$count++} +Write-Host "PASS: $count CAPI2 probe public CLI checks." +$global:LASTEXITCODE=0 diff --git a/tests/Capi2Probe.Tests.ps1 b/tests/Capi2Probe.Tests.ps1 new file mode 100644 index 00000000..a4f394e1 --- /dev/null +++ b/tests/Capi2Probe.Tests.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WmiProbe.ps1" +. "$repo/scripts/Capi2Probe.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action,$Message){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed $Message} +function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24)} +$nonce='0123456789abcdef0123456789abcdef';$now=[DateTime]::UtcNow +$token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='HOST\user';AuthenticationId='0x1234';AuthenticationType='NTLM';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()} +$state=[pscustomobject]@{Computer='HOST';Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}} +$rsa=[Security.Cryptography.RSA]::Create();$rsa.KeySize=2048;$cert=$null +try{ + $request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) + $cert=$request.CreateSelfSigned(([DateTimeOffset]$now).AddMinutes(-5),([DateTimeOffset]$now).AddMinutes(5)) + $operation=[pscustomobject]@{Nonce=$nonce;CertificateDerBase64=[Convert]::ToBase64String($cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert));Subject=$cert.Subject;Thumbprint=$cert.Thumbprint;KeyEphemeral=$true;ProcessId=5678;ProcessName='pwsh.exe';StartedUtc=$now.AddSeconds(-1).ToString('o');CompletedUtc=$now.AddSeconds(1).ToString('o');Clock='GetSystemTimePreciseAsFileTime';RecordIdBefore=10;BeforeToken=$token;AfterToken=$token;Chain=[pscustomobject]@{Flags=2147492100;ErrorStatus=32;Chains=1;Elements=1}} + $der=Assert-WelaCapi2ProbeCertificate $operation $nonce;Assert ($der.Length -gt 128) 'Generated test DER is validated.' +}finally{if($cert){$cert.Dispose()};$rsa.Dispose()} +foreach($field in @('Nonce','Subject','Thumbprint','CertificateDerBase64')){$bad=Clone $operation;$bad.$field='wrong';Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject certificate $field mismatch"} +foreach($value in @($false,'true',$null)){$bad=Clone $operation;$bad.KeyEphemeral=$value;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Ephemeral key evidence must be true Boolean.'} +foreach($field in @('Flags','ErrorStatus','Chains','Elements')){$bad=Clone $operation;$bad.Chain.$field=0;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject unexpected native chain $field"} +$bad=Clone $operation;$bad.CompletedUtc=$now.AddMinutes(20).ToString('o');Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Certificate must cover operation.' +Assert ([bool](Get-WelaCapi2ProbeStateKey $state)) 'Exact observed prerequisites accepted.' +foreach($edit in @({param($s)$s.Host.Build=19045},{param($s)$s.Host.UBR=$null},{param($s)$s.Host.ProductType=1},{param($s)$s.Host.Computer='OTHER'},{param($s)$s.Channel.Enabled=$false},{param($s)$s.Channel.Enabled='true'},{param($s)$s.Channel.Type='Analytical'},{param($s)$s.Channel.Provider='Other'},{param($s)$s.Channel.SecurityDescriptor=$null},{param($s)$s.Provider.Guid=[guid]::Empty.ToString()},{param($s)$s.Provider.Event11Versions=@(1)},{param($s)$s.Provider.Event11Versions=@(0,0)},{param($s)$s.Provider.LogNames=@('Security')})){$bad=Clone $state;&$edit $bad;Reject {Get-WelaCapi2ProbeStateKey $bad} 'Reject incomplete or unsupported prerequisites.'} +$xml=@" +11021120x400000000000000311Microsoft-Windows-CAPI2/OperationalHOST +"@ +Assert (Test-WelaCapi2ProbeEvent $xml $operation $state) 'Exact source/certificate/PID/token/time/chain fixture matches.' +$changes=@( + @('Name="Microsoft-Windows-CAPI2"','Name="Other"'),@('5bbca4a8-b209-48dc-a8c7-b23d3e5216fb','00000000-0000-0000-0000-000000000000'),@('11','70'),@('0','1'),@('2','4'),@('11','10'),@('2','1'),@('0x4000000000000003','0x4000000000000001'),@('11','10'),@('11','x'),@('ProcessID="5678"','ProcessID="5679"'),@( ('UserID="'+$token.Sid+'"'), 'UserID="S-1-5-18"'),@('HOST','OTHER'),@('80002104','80000104'),@('800B0109','0'),@('value="20"','value="0"'),@('context="user"','context="machine"'),@('ProcessName="pwsh.exe"','ProcessName="other.exe"'),@($operation.Thumbprint,('0'*40)),@($nonce,('f'*32)),@('',''),@('',''),@('0','00'),@('',''),@('',''),@(']>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + if(-not(Test-Path "$out/manifest.json")){throw ('Public probe did not retain a manifest: '+$cli)} + $manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText("$out/manifest.json")) + Write-Host ($manifest|ConvertTo-Json -Depth 24) + foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml')){Write-Host ([IO.File]::ReadAllText($file.FullName))} + Assert ($code -eq 0 -and $manifest.Status -eq 'LocalChainEventObserved' -and $manifest.ExitCode -eq 0) ('Actual CAPI2 probe failed: '+$manifest.Diagnostic+' '+$cli) + Assert ($manifest.Matches -eq 1 -and $manifest.ChannelChanges -eq 0 -and $manifest.StoreChanges -eq 0 -and $manifest.TrustPolicyChanges -eq 0 -and $manifest.ReadyRuleCredit -eq 0) 'Bounded event evidence grants no configuration or Sigma claim.' + Assert ($manifest.Operation.Nonce -notin $nonces -and $manifest.Operation.Thumbprint -notin $thumbprints) 'Independent public invocation generated a fresh nonce and certificate.' + $nonces+=$manifest.Operation.Nonce;$thumbprints+=$manifest.Operation.Thumbprint + $der=Assert-WelaCapi2ProbeCertificate $manifest.Operation $manifest.Operation.Nonce + Assert ((Get-WelaArrivalHash $der) -ceq $manifest.Operation.CertificateSha256) 'Actual DER matches retained SHA256.' + Assert (Test-WelaCapi2ProbeEvent ([IO.File]::ReadAllText("$out/event.xml")) $manifest.Operation $manifest.Before) 'Actual event11 matches certificate, nonce, PID, SID, UTC, offline flags and expected chain outcome.' + foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Artifact hash verifies.'} + Assert ((Key (Get-WelaCapi2ProbeChannel)) -ceq (Key $enabled)) 'Public probe preserved channel configuration.' + Assert ((Key (Read-Stores)) -ceq (Key $originalStores)) 'CurrentUser and LocalMachine My/Root/CA certificate inventories preserved.' + Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Private evidence blocks inherited broad access.' + } +}catch{$failure=$_} +finally{ + try{$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($original.Name);try{if($channel.IsEnabled -ne $original.Enabled){$channel.IsEnabled=$original.Enabled;$channel.SaveChanges()}}finally{$channel.Dispose()};$restored=Get-WelaCapi2ProbeChannel;$null=Write-WelaArrivalArtifact $root 'channel-restored.json' ($restored|ConvertTo-Json);if((Key $restored) -cne (Key $original)){throw 'Original channel configuration was not restored.'}}catch{$cleanupErrors+='Channel restoration: '+$_.Exception.Message} + try{$storesAfter=Read-Stores;$null=Write-WelaArrivalArtifact $root 'stores-after.json' ($storesAfter|ConvertTo-Json -Depth 8);if((Key $storesAfter) -cne (Key $originalStores)){throw 'Certificate store inventory changed.'}}catch{$cleanupErrors+='Store observation: '+$_.Exception.Message} + $null=Write-WelaArrivalArtifact $root 'cleanup.json' ([pscustomobject]@{ChangedEnabled=$changed;Failure=$(if($failure){$failure.Exception.Message}else{$null});CleanupErrors=$cleanupErrors;ChannelRestored=($cleanupErrors.Count -eq 0);Complete=($null -eq $failure -and $cleanupErrors.Count -eq 0);Evidence=$root}|ConvertTo-Json) +} +if($failure){throw $failure};if($cleanupErrors.Count){throw ($cleanupErrors -join '; ')} +Write-Host "PASS: $script:count actual CAPI2 assertions across $ProbeRuns independent public runs; original channel restored and selected certificate inventories preserved." +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..e51895c2 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 - `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..53c32037 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From 23f88776bfc451ff249009c28b2ca950121b2d59 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:52:23 +0900 Subject: [PATCH 05/25] Add guarded single-profile firewall logging recovery --- .../workflows/firewall-logging-recovery.yml | 47 ++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 18 ++ docs/firewall-logging-recovery.md | 59 +++++ scripts/FirewallLoggingRecovery.ps1 | 248 ++++++++++++++++++ tests/FirewallLoggingRecovery.Cli.Tests.ps1 | 17 ++ tests/FirewallLoggingRecovery.Tests.ps1 | 99 +++++++ .../FirewallLoggingRecovery.Windows.Tests.ps1 | 81 ++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 12 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/firewall-logging-recovery.yml create mode 100644 docs/firewall-logging-recovery.md create mode 100644 scripts/FirewallLoggingRecovery.ps1 create mode 100644 tests/FirewallLoggingRecovery.Cli.Tests.ps1 create mode 100644 tests/FirewallLoggingRecovery.Tests.ps1 create mode 100644 tests/FirewallLoggingRecovery.Windows.Tests.ps1 diff --git a/.github/workflows/firewall-logging-recovery.yml b/.github/workflows/firewall-logging-recovery.yml new file mode 100644 index 00000000..13d69d21 --- /dev/null +++ b/.github/workflows/firewall-logging-recovery.yml @@ -0,0 +1,47 @@ +name: Guarded firewall logging recovery +on: + push: + paths: ['WELA.ps1', 'scripts/FirewallLogging*', 'scripts/Configuration.ps1', 'scripts/AuditRecovery.ps1', 'scripts/WefArrival.ps1', 'scripts/WecUpdate.ps1', 'scripts/ChannelRead*', 'tests/FirewallLoggingRecovery*', '.github/workflows/firewall-logging-recovery.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + firewall-recovery: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Focused and public CLI fixtures (powershell) + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/FirewallLoggingRecovery.Tests.ps1 + ./tests/FirewallLoggingRecovery.Cli.Tests.ps1 + - name: Disposable native configuration and recovery (powershell) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/FirewallLoggingRecovery.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Focused and public CLI fixtures (pwsh) + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/FirewallLoggingRecovery.Tests.ps1 + ./tests/FirewallLoggingRecovery.Cli.Tests.ps1 + - name: Disposable native configuration and recovery (pwsh) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/FirewallLoggingRecovery.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Retain native configuration and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: firewall-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-firewall-recovery-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..22fb4307 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a6ab89ad..829c5c86 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13c6111..cafad6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index fb027456..a7c809b3 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -22,6 +22,13 @@ [ValidateSet('Audit', 'Plan', 'Configure')][string]$FirewallAction = 'Audit', [ValidateSet('Preserve', 'CisV4')][string]$FirewallPathMode = 'Preserve', [ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384, + [ValidateSet('Plan','Restore')][string]$FirewallRecoveryAction = 'Plan', + [ValidateSet('Domain','Private','Public')][string]$FirewallRecoveryProfile, + [string]$FirewallRecoveryJournalPath, + [string]$FirewallRecoveryResultsPath, + [string]$FirewallRecoveryPlanPath, + [string]$FirewallRecoveryPlanHash, + [string]$FirewallRecoveryOutputPath, [string]$HtmlPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit', [ValidateSet('Audit', 'Plan', 'Configure', 'Rollback')][string]$AdSaclAction = 'Audit', @@ -177,6 +184,7 @@ Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorActi Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") +. (Join-Path $ScriptRoot "scripts/FirewallLoggingRecovery.ps1") . (Join-Path $ScriptRoot "scripts/NativeProviderPacks.ps1") . (Join-Path $ScriptRoot "scripts/DnsAnalytical.ps1") Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop @@ -1920,6 +1928,7 @@ Usage: ./WELA.ps1 firewall-logging -FirewallAction Audit -ResultsPath firewall.json ./WELA.ps1 firewall-logging -FirewallAction Plan -FirewallPathMode CisV4 ./WELA.ps1 firewall-logging -FirewallAction Configure -DryRun + ./WELA.ps1 firewall-recovery -Help # Firewall text logging is opt-in; it does not change firewall enforcement or rules. ./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json ./WELA.ps1 smb-auditing -SmbAction Plan @@ -1977,6 +1986,8 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +if ($Cmd -ne 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'FirewallRecovery*' }).Count) {throw 'FirewallRecovery options require firewall-recovery. No command was run.'} +if ($Cmd -eq 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'} if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ChannelReadName','ChannelReadOutputPath','Help') }).Count) { throw 'channel-read accepts only dedicated channel/output options. No command was run.' } @@ -2118,6 +2129,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { if ($DryRun -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and + -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and -not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and -not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and @@ -2404,6 +2416,12 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] WMI namespace auditing: $_" -ForegroundColor Red; exit 1 } } + 'firewall-recovery' { + if ($Help) {Write-Host 'Usage: firewall-recovery [-FirewallRecoveryAction Plan] -FirewallRecoveryProfile Domain|Private|Public -FirewallRecoveryJournalPath before.jsonl -FirewallRecoveryResultsPath results.json -FirewallRecoveryOutputPath new-directory; then -FirewallRecoveryAction Restore -FirewallRecoveryPlanPath plan.json -FirewallRecoveryPlanHash SHA256 -FirewallRecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/firewall-logging-recovery.md.';return} + $report=Invoke-WelaFirewallLoggingRecovery -Action $FirewallRecoveryAction -Profile $FirewallRecoveryProfile -JournalPath $FirewallRecoveryJournalPath -ResultsPath $FirewallRecoveryResultsPath -PlanPath $FirewallRecoveryPlanPath -PlanHash $FirewallRecoveryPlanHash -OutputPath $FirewallRecoveryOutputPath -Auto:$Auto -DryRun:$DryRun + Write-Host ($report | ConvertTo-Json -Depth 24) + if ($report.ExitCode -ne 0) {exit 1} + } 'firewall-logging' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 firewall-logging [-FirewallAction Audit|Plan|Configure] [-FirewallPathMode Preserve|CisV4] [-FirewallMinimumSizeKiB 16384..32767] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/docs/firewall-logging-recovery.md b/docs/firewall-logging-recovery.md new file mode 100644 index 00000000..4dfcaba8 --- /dev/null +++ b/docs/firewall-logging-recovery.md @@ -0,0 +1,59 @@ +# Guarded firewall text-log recovery + +`firewall-recovery` plans and explicitly restores the four local logging fields for **one** Domain, Private or Public profile from a completed WELA `firewall-logging -FirewallAction Configure` operation. It uses built-in Windows functionality; Sysmon is out of scope. It does not grant event-generation, delivery, retention or Sigma readiness credit. + +The restored fields are `LogAllowed`, `LogBlocked`, `LogMaxSizeKilobytes` and `LogFileName` in `PersistentStore`. The original values can disable logging or reduce its size: review the complete proposed tuple before restoring. Microsoft distinguishes local persistent settings from the resultant `ActiveStore` policy. Recovery reports the selected effective tuple separately and does not change its policy authority. See [Set-NetFirewallProfile](https://learn.microsoft.com/en-us/powershell/module/netsecurity/set-netfirewallprofile?view=windowsserver2025-ps). + +## Prepare and review + +Keep the genuine original `before.jsonl` and final results from [firewall logging configuration](firewall-logging.md). The selected row must have final status `Applied`, dedicated scope `firewall-text-logging-only`, a matching version-1 journal entry and matching original Before/Desired/Target values. Failed, partial, ambiguous and no-op operations are not automatically recoverable. + +Use elevated native 64-bit Windows PowerShell 5.1 or PowerShell 7 on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. The plan and restoration must use the same engine version, machine identity and actual elevated operator/logon context. Impersonation is refused. Original version-1 configuration journals recorded only the computer name, so they do **not** prove historical MachineGuid or operator identity. The operator must establish that the original evidence belongs to this installation; current identity binding starts with the recovery plan. + +Create new local output directories under an existing parent, outside the WELA source tree. WELA applies private output permissions and never overwrites an old evidence directory. + +```powershell +./WELA.ps1 firewall-recovery -FirewallRecoveryProfile Domain ` + -FirewallRecoveryJournalPath C:\Evidence\configure-backup\before.jsonl ` + -FirewallRecoveryResultsPath C:\Evidence\configure-results.json ` + -FirewallRecoveryOutputPath C:\Evidence\firewall-recovery-plan +``` + +Review `plan.json`, especially `Control.Expected` (the confirmed original local After values), `Control.RecoverTo` (the exact original local Before values), the selected profile, source hashes and preserved settings. Record the reported `PlanSha256` after review. Plan reads configuration and writes evidence only. + +```powershell +# Replace this placeholder with the SHA256 from the reviewed plan. +$reviewedHash = '<64 lowercase hexadecimal characters>' +./WELA.ps1 firewall-recovery -FirewallRecoveryAction Restore ` + -FirewallRecoveryPlanPath C:\Evidence\firewall-recovery-plan\plan.json ` + -FirewallRecoveryPlanHash $reviewedHash -DryRun + +./WELA.ps1 firewall-recovery -FirewallRecoveryAction Restore ` + -FirewallRecoveryPlanPath C:\Evidence\firewall-recovery-plan\plan.json ` + -FirewallRecoveryPlanHash $reviewedHash ` + -FirewallRecoveryOutputPath C:\Evidence\firewall-recovery-result +``` + +Restoration prompts before the single native setter. `-Auto` explicitly skips that prompt; it does not skip any evidence or state guards. Dry run creates no output directory and does not call a setter. An exact already restored tuple returns `AlreadyRestored` without another write. + +## Guards and outcomes + +WELA independently rebuilds the selected operation from unchanged journal/result bytes and checks the separately supplied plan hash. It accepts explicit local `True`/`False` logging flags, an integer size from 1 through 32767 KiB and an ordinary local path. Only `%SystemRoot%` and `%windir%` variables are supported. UNC/device paths, alternate streams, dot segments, wildcards, reparse paths and unknown values are refused. `NotConfigured` is documented for GPO use and requires manual review instead of automatic local replay. The original command's Preserve/CisV4 path and maximum-size behavior must explain the recorded After tuple exactly. + +The current local tuple must equal the selected confirmed After tuple, or the exact original tuple for idempotence. A new plan binds current host/operator context, source files and native NetSecurity module files. It preserves the other two profiles in both stores, every nonlogging field of the selected profiles, and bounded native rule/filter configuration fingerprints. Filters are queried separately because conditions are exposed through filter objects; see [Get-NetFirewallPortFilter](https://learn.microsoft.com/en-us/powershell/module/netsecurity/get-netfirewallportfilter?view=windowsserver2025-ps). Inventories cap each class/store at 4096 objects and 16 MiB of canonical data. Unknown native property types, unreadable inventories or caps refuse recovery. Volatile rule operational diagnostics are excluded from configuration fingerprints. + +After a durable `pending.json` receipt, WELA rechecks inputs and current state before the one fixed `Set-NetFirewallProfile -PolicyStore PersistentStore` call. It then verifies the exact local tuple, preserved configuration and fresh/final context, retaining `confirmed.json` and `result.json`. It never changes firewall enforcement, rule definitions, other profiles, Group Policy, destination ACLs, services or shares. There is no automatic rollback. + +| Result | Meaning | +| --- | --- | +| `Planned` / `WouldRestore` | Reviewable plan / read-only current guard checks passed. | +| `LocalLoggingRestored` | Exact selected local tuple and preserved configuration passed readback and final checks. | +| `AlreadyRestored` | Original local tuple is already present; no setter was called. | +| `Refused` | A prerequisite or guard failed before a setter was attempted. | +| `WriteAttemptedUnverified` | A setter was attempted but completion or subsequent verification failed. Preserve the receipts and investigate manually. | + +`EffectiveMatchesLocal` compares the selected effective and local tuples after restoration. False can represent an effective policy override; local success does not imply effective logging was restored. Destination write authorization, actual firewall text records, future policy refresh, forwarding and long-term retention need separate acceptance. Path checks do not prove destination writability or historical file identity. Native APIs do not offer an atomic transaction over all these inventories: observed drift fails closed, but concurrent external changes between reads cannot be excluded. + +## Validation + +Focused fixtures cover strict original evidence, typed values, changed plans, stale settings, operator/source drift, post-prompt changes, partial writes, preserved enforcement and local/effective separation. The gated disposable Windows workflow uses the public Configure command to produce genuine journals, then public Plan, dry run, drift refusal, Restore and idempotence on Server 2022/2025 under both engines. Its fixture changes only logging values and a new owned log directory, restores all original logging fields, and compares complete preserved native configuration before removing that directory. It never generates traffic or changes enforcement. Windows 11, domain policy refresh and backend acceptance remain separate deployment tests. diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 new file mode 100644 index 00000000..9db87b1e --- /dev/null +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -0,0 +1,248 @@ +# One selected completed firewall text-log operation; never replay enforcement or rules. +function Get-WelaFirewallRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} + +function ConvertTo-WelaFirewallRecoveryTuple { + param($Value,[switch]$Snapshot) + $fields=@('LogAllowed','LogBlocked','LogMaxSizeKilobytes','LogFileName') + if($Snapshot){$fields=@('Name')+$fields+@('Enabled')} + Assert-WelaArrivalObject $Value $fields + if($Value.LogAllowed -isnot [string] -or $Value.LogAllowed -cnotin @('True','False') -or + $Value.LogBlocked -isnot [string] -or $Value.LogBlocked -cnotin @('True','False')){throw 'Only explicit local True/False logging switches are recoverable; GPO NotConfigured requires manual review.'} + $size=$Value.LogMaxSizeKilobytes + if(($size -isnot [int] -and $size -isnot [long] -and $size -isnot [uint64] -and $size -isnot [uint32]) -or $size -lt 1 -or $size -gt 32767){throw 'Firewall logging size must be an integer from 1 through 32767 KiB.'} + $null=Resolve-WelaFirewallRecoveryLogPath $Value.LogFileName + if($Snapshot -and ($Value.Name -isnot [string] -or $Value.Name -cnotin @('Domain','Private','Public') -or $Value.Enabled -isnot [string] -or $Value.Enabled -cnotin @('True','False','NotConfigured'))){throw 'Invalid profile snapshot identity or enabled observation.'} + [pscustomobject][ordered]@{LogAllowed=$Value.LogAllowed;LogBlocked=$Value.LogBlocked;LogMaxSizeKilobytes=[long]$size;LogFileName=$Value.LogFileName} +} + +function Resolve-WelaFirewallRecoveryLogPath { + param($Path) + if($Path -isnot [string] -or -not $Path -or $Path.Length -gt 260 -or $Path -match '[\x00-\x1f*?\[\]]' -or $Path -match '(^|[\\/])\.\.?([\\/]|$)'){throw 'A bounded ordinary local firewall log path is required.'} + # Only native Windows directory variables have reviewed meaning in old paths. + $expanded=[regex]::Replace($Path,'(?i)%(systemroot|windir)%',[Text.RegularExpressions.MatchEvaluator]{param($m) [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)}) + if($expanded -notmatch '^[A-Za-z]:\\' -or $expanded -match '%' -or $expanded.Substring(2).Contains(':') -or $expanded.EndsWith('\') -or $expanded.Contains('/')){throw 'UNC/device/relative paths, unknown variables and alternate streams are unsupported.'} + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$null=Resolve-WelaArrivalPath $expanded} + $expanded +} + +function Get-WelaFirewallRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/FirewallLoggingRecovery.ps1','scripts/FirewallLogging.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash + } + [pscustomobject]$sources +} + +function Get-WelaFirewallRecoveryContext { + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'Firewall recovery requires the actual non-impersonated elevated administrator.'} + $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $build=[int]$os.BuildNumber + if(($os.ProductType -eq 1 -and $build -notin @(22000,22621,22631,26100,26200)) -or + ($os.ProductType -in @(2,3) -and $build -notin @(20348,26100)) -or $os.ProductType -notin @(1,2,3)){throw 'Unreviewed Windows host for firewall recovery.'} + $machine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Cryptography' MachineGuid + $guid=[guid]::Empty + if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'} + $revision=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR -ErrorAction Stop + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain + Reader=[pscustomobject]@{UserSid=$reader.UserSid;UserName=$reader.UserName;AuthenticationId=$reader.AuthenticationId;GroupSids=$reader.GroupSids;ElevatedAdministrator=$reader.ElevatedAdministrator;Impersonation=$reader.Impersonation} + Engine=$PSVersionTable.PSVersion.ToString()} +} + +function Get-WelaFirewallRecoveryNativeSources { + $base=[IO.Path]::GetFullPath((Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules/NetSecurity')) + $commands=@('Get-NetFirewallProfile','Set-NetFirewallProfile','Get-NetFirewallRule')+@('Port','Address','Application','Service','Interface','InterfaceType','Security' | ForEach-Object {"Get-NetFirewall${_}Filter"}) + foreach($name in $commands){ + $command=@(Get-Command "NetSecurity\$name" -ErrorAction Stop) + if($command.Count -ne 1 -or $command[0].Name -cne $name -or [IO.Path]::GetFullPath($command[0].Module.ModuleBase) -ine $base){throw "Native NetSecurity command source is unverified: $name"} + } + $files=@(Get-ChildItem -LiteralPath $base -File -Recurse -ErrorAction Stop | Where-Object Extension -in @('.psd1','.psm1','.cdxml','.dll','.ps1xml') | Sort-Object FullName) + if($files.Count -lt 1 -or $files.Count -gt 160){throw 'Unexpected native firewall module inventory.'} + $hashes=[ordered]@{} + foreach($file in $files){if($file.Length -gt 16MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unsupported firewall module source.'};$hashes[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash} + [pscustomobject]$hashes +} + +function ConvertTo-WelaFirewallRecoveryCim { + param($Value,[string[]]$Exclude=@()) + if(-not $Value.CimClass.CimClassName -or -not $Value.CimInstanceProperties){throw 'Native firewall CIM configuration is missing.'} + $properties=@($Value.CimInstanceProperties | Sort-Object Name) + if($properties.Count -gt 160){throw 'Native firewall property bound exceeded.'} + $result=[ordered]@{Class=[string]$Value.CimClass.CimClassName} + foreach($property in $properties){ + if($property.Name -in $Exclude){continue} + if($result.Contains($property.Name)){throw 'Duplicate native firewall property.'} + $valueData=$property.Value + if(@($valueData).Count -gt 256){throw 'Native firewall property array bound exceeded.'} + foreach($item in @($valueData)){ + if($null -ne $item -and $item -isnot [string] -and $item -isnot [bool] -and $item -isnot [byte] -and + $item -isnot [uint16] -and $item -isnot [uint32] -and $item -isnot [uint64] -and $item -isnot [int16] -and $item -isnot [int] -and $item -isnot [long]){throw "Unsupported native property type: $($property.Name)"} + if($item -is [string] -and $item.Length -gt 32768){throw 'Native firewall property string bound exceeded.'} + } + $result[$property.Name]=[pscustomobject]@{Type=$property.CimType.ToString();Value=$valueData} + } + [pscustomobject]$result +} + +function Get-WelaFirewallRecoveryRuleDigest { + param([ValidateSet('PersistentStore','ActiveStore')][string]$Store) + # Hash configuration fields; volatile operational diagnostics are not policy. + $volatile=@('PrimaryStatus','Status','StatusDescriptions','EnforcementStatus','OperationalStatus','CommunicationStatus','HealthState','OperatingStatus','DetailedStatus','TimeOfLastStateChange','InstallDate') + foreach($kind in @('Rule','PortFilter','AddressFilter','ApplicationFilter','ServiceFilter','InterfaceFilter','InterfaceTypeFilter','SecurityFilter')){ + $command="NetSecurity\Get-NetFirewall$kind" + $items=@(& $command -PolicyStore $Store -ErrorAction Stop | Select-Object -First 4097) + if($items.Count -gt 4096){throw "Firewall $Store $kind inventory exceeded 4096 objects; recovery is unverified."} + $keys=@(foreach($item in $items){Get-WelaFirewallRecoveryKey (ConvertTo-WelaFirewallRecoveryCim $item $volatile)}) | Sort-Object + $bytes=[Text.UTF8Encoding]::new($false).GetBytes((Get-WelaFirewallRecoveryKey @($keys))) + if($bytes.Length -gt 16MB){throw 'Firewall configuration inventory exceeds the byte bound.'} + [pscustomobject]@{Store=$Store;Kind=$kind;Count=$items.Count;Sha256=Get-WelaArrivalHash $bytes} + } +} + +function Get-WelaFirewallRecoveryState { + $context=Get-WelaFirewallRecoveryContext + $moduleSources=Get-WelaFirewallRecoveryNativeSources + $stores=[ordered]@{};$digests=@() + foreach($store in @('PersistentStore','ActiveStore')){ + $profiles=@(NetSecurity\Get-NetFirewallProfile -PolicyStore $store -ErrorAction Stop | Sort-Object Name) + if($profiles.Count -ne 3 -or @($profiles.Name | Sort-Object -Unique).Count -ne 3){throw 'Expected exactly three native firewall profiles.'} + $byName=[ordered]@{} + foreach($profile in $profiles){ + $snapshot=ConvertTo-WelaFirewallLoggingSnapshot $profile + $logging=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot + $byName[$snapshot.Name]=[pscustomobject]@{Logging=$logging;Preserved=ConvertTo-WelaFirewallRecoveryCim $profile @('LogAllowed','LogBlocked','LogMaxSizeKilobytes','LogFileName')} + } + $stores[$store]=[pscustomobject]$byName + $digests+=@(Get-WelaFirewallRecoveryRuleDigest $store) + } + [pscustomobject][ordered]@{Context=$context;Sources=Get-WelaFirewallRecoverySources;NativeSources=$moduleSources;Profiles=[pscustomobject]$stores;RuleConfiguration=$digests} +} + +function Get-WelaFirewallRecoveryInvariant { + param($State,[string]$Profile) + $copy=Get-WelaFirewallRecoveryKey $State | ConvertFrom-Json + $copy.Profiles.PersistentStore.$Profile.Logging=$null + $copy.Profiles.ActiveStore.$Profile.Logging=$null + Get-WelaFirewallRecoveryKey $copy +} + +function Read-WelaFirewallRecoveryEvidence { + param([string]$JournalPath,[string]$ResultsPath,[ValidateSet('Domain','Private','Public')][string]$Profile,[string]$Computer) + $journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath + $entries=@($journal.Text -split '\r?\n' | Where-Object {$_ -match '\S'} | ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) + $results=ConvertFrom-WelaRecoveryJson $resultFile.Text + if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'} + $seen=@{};$final=@{} + foreach($entry in $entries){ + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -cne 'FirewallTextLog' -or + $entry.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $seen.ContainsKey($entry.Id) -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $Computer){throw 'Unknown, duplicate or wrong-host firewall journal entry.'} + if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Journal timestamp is in the future.'} + $seen[$entry.Id]=$entry + } + foreach($row in $results.Results){ + if($row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'} + $final[$row.Id]=$row + } + $id="FirewallTextLog/$Profile" + if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'} + $entry=$seen[$id];$row=$final[$id] + foreach($field in @('Before','Desired','Target')){if((Get-WelaFirewallRecoveryKey $entry.$field) -cne (Get-WelaFirewallRecoveryKey $row.$field)){throw "Journal/result $field mismatch."}} + Assert-WelaArrivalObject $entry.Target @('Name','PolicyStore') + if($entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'} + Assert-WelaArrivalObject $entry.Desired @('LogAllowed','LogBlocked','MinimumSizeKiB','LogFileName','PathMode') + $desired=$entry.Desired + if($desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'} + foreach($snapshot in @($entry.Before.Local,$entry.Before.Effective,$row.After.Local,$row.After.Effective)){ + $null=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot + if($snapshot.Name -cne $Profile){throw 'Original snapshot profile differs from selected profile.'} + } + $before=ConvertTo-WelaFirewallRecoveryTuple $entry.Before.Local -Snapshot + $expected=ConvertTo-WelaFirewallRecoveryTuple $row.After.Local -Snapshot + $effective=ConvertTo-WelaFirewallRecoveryTuple $row.After.Effective -Snapshot + $requiredSize=[Math]::Max([long]$desired.MinimumSizeKiB,[Math]::Max([long]$entry.Before.Local.LogMaxSizeKilobytes,[long]$entry.Before.Effective.LogMaxSizeKilobytes)) + $path=if($desired.PathMode -ceq 'CisV4'){'%SystemRoot%\System32\LogFiles\Firewall\'+$Profile.ToLowerInvariant()+'fw.log'}else{$before.LogFileName} + if($expected.LogAllowed -cne 'True' -or $expected.LogBlocked -cne 'True' -or $expected.LogMaxSizeKilobytes -ne $requiredSize -or $expected.LogFileName -cne $path){throw 'Recorded local After is not the permitted original logging-only change.'} + $desiredPath=Resolve-WelaFirewallRecoveryLogPath $desired.LogFileName + $plannedPath=if($desired.PathMode -ceq 'CisV4'){Resolve-WelaFirewallRecoveryLogPath $path}else{Resolve-WelaFirewallRecoveryLogPath $entry.Before.Effective.LogFileName} + if($desiredPath -ine $plannedPath -or $effective.LogAllowed -cne 'True' -or $effective.LogBlocked -cne 'True' -or $effective.LogMaxSizeKilobytes -lt $desired.MinimumSizeKiB -or + (Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'} + if((Get-WelaFirewallRecoveryKey $before) -ceq (Get-WelaFirewallRecoveryKey $expected)){throw 'Selected evidence records no local logging change.'} + [pscustomobject][ordered]@{Id=$id;Profile=$Profile;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Hash};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Hash};Expected=$expected;RecoverTo=$before} +} + +function Set-WelaFirewallRecoveryLogging { + param([ValidateSet('Domain','Private','Public')][string]$Profile,$Tuple) + $values=ConvertTo-WelaFirewallRecoveryTuple $Tuple + NetSecurity\Set-NetFirewallProfile -Name $Profile -PolicyStore PersistentStore -LogAllowed $values.LogAllowed -LogBlocked $values.LogBlocked -LogMaxSizeKilobytes ([uint64]$values.LogMaxSizeKilobytes) -LogFileName $values.LogFileName -Confirm:$false -ErrorAction Stop +} + +function Assert-WelaFirewallRecoveryInputs { + param($Plan,[string]$PlanPath,[string]$PlanHash) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Reviewed recovery plan bytes changed.'} + $rebuilt=Read-WelaFirewallRecoveryEvidence $Plan.Control.Journal.Path $Plan.Control.OriginalResults.Path $Plan.Profile $Plan.State.Context.Computer + if((Get-WelaFirewallRecoveryKey $rebuilt) -cne (Get-WelaFirewallRecoveryKey $Plan.Control)){throw 'Original recovery evidence changed or no longer matches the plan.'} +} + +function Invoke-WelaFirewallLoggingRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$Profile,[string]$JournalPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$Auto,[switch]$DryRun) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if($Profile -cnotin @('Domain','Private','Public') -or -not $JournalPath -or -not $ResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $Auto -or $DryRun){throw 'Plan requires one profile, original journal/results and new output only.'} + }elseif($Profile -or $JournalPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or ($DryRun -and $OutputPath) -or (-not $DryRun -and -not $OutputPath)){throw 'Restore requires a reviewed plan/hash and new output, or DryRun without output.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFirewallLoggingRecovery';Action=$Action;Status='Refused';ExitCode=1;WriteAttempted=$false;Before=$null;After=$null;EffectiveMatchesLocal=$null;OutputPath=$null;Artifacts=@();PlanSha256=$null;Diagnostic='';ReadyRuleCredit=0;Scope='Restore four PersistentStore logging fields on one profile only; effective policy and event generation are separate.'} + try { + if($Action -eq 'Plan'){ + $state=Get-WelaFirewallRecoveryState + $control=Read-WelaFirewallRecoveryEvidence $JournalPath $ResultsPath $Profile $state.Context.Computer + $local=$state.Profiles.PersistentStore.$Profile.Logging + if((Get-WelaFirewallRecoveryKey $local) -cne (Get-WelaFirewallRecoveryKey $control.Expected)){throw 'Current local logging tuple differs from the completed original After state.'} + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFirewallLoggingRecoveryPlan';Profile=$Profile;Control=$control;State=$state;HistoricalIdentity='Version-1 configuration journals record only ComputerName; current MachineGuid and operator/logon bind this recovery plan, not historical authorship.'} + if((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -cne (Get-WelaFirewallRecoveryKey $state)){throw 'Current firewall context changed during planning.'} + $report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot + $planText=Get-WelaFirewallRecoveryKey $plan + if([Text.UTF8Encoding]::new($false).GetByteCount($planText) -gt 4MB){throw 'Recovery plan exceeds its input byte bound.'} + $artifact=Write-WelaWecUpdateArtifact $report.OutputPath 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanSha256=$artifact.Sha256 + $report.Before=$state;$report.Status='Planned';$report.ExitCode=0 + }else{ + $source=Read-WelaWecUpdateFile $PlanPath + if($source.Hash -cne $PlanHash){throw 'Reviewed plan SHA256 differs from the selected file.'} + $plan=ConvertFrom-WelaRecoveryJson $source.Text + Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Profile','Control','State','HistoricalIdentity') + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'} + $report.PlanSha256=$source.Hash + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $current=Get-WelaFirewallRecoveryState;$report.Before=$current + $invariant=Get-WelaFirewallRecoveryInvariant $plan.State $plan.Profile + if((Get-WelaFirewallRecoveryInvariant $current $plan.Profile) -cne $invariant){throw 'Host, operator, source, enforcement, other profile or rule configuration changed since planning.'} + $local=$current.Profiles.PersistentStore.($plan.Profile).Logging + $already=(Get-WelaFirewallRecoveryKey $local) -ceq (Get-WelaFirewallRecoveryKey $plan.Control.RecoverTo) + if(-not $already -and (Get-WelaFirewallRecoveryKey $local) -cne (Get-WelaFirewallRecoveryKey $plan.Control.Expected)){throw 'Selected local logging tuple drifted from the confirmed original After state.'} + if($DryRun){$report.Status=if($already){'AlreadyRestored'}else{'WouldRestore'};$report.ExitCode=0;return $report} + $report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot + $report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'reviewed-plan.json' $source.Text + if(-not $already){ + if(-not $Auto -and (Read-Host "Restore only $($plan.Profile) firewall logging fields to the reviewed original values? (y/N)") -cnotin @('y','Y')){throw 'Recovery declined; no setter was called.'} + $report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'pending.json' (Get-WelaFirewallRecoveryKey ([pscustomobject]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');PlanSha256=$source.Hash;Before=$current;RecoverTo=$plan.Control.RecoverTo})) + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $fresh=Get-WelaFirewallRecoveryState + if((Get-WelaFirewallRecoveryKey $fresh) -cne (Get-WelaFirewallRecoveryKey $current)){throw 'Context changed after confirmation/intent receipt; no recovery setter was called.'} + foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Durable recovery evidence changed before the setter.'}} + $report.WriteAttempted=$true + Set-WelaFirewallRecoveryLogging $plan.Profile $plan.Control.RecoverTo + } + $after=Get-WelaFirewallRecoveryState;$report.After=$after + if((Get-WelaFirewallRecoveryInvariant $after $plan.Profile) -cne $invariant -or + (Get-WelaFirewallRecoveryKey $after.Profiles.PersistentStore.($plan.Profile).Logging) -cne (Get-WelaFirewallRecoveryKey $plan.Control.RecoverTo)){throw 'Local logging restoration or preserved firewall context did not verify.'} + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $report.EffectiveMatchesLocal=(Get-WelaFirewallRecoveryKey $after.Profiles.ActiveStore.($plan.Profile).Logging) -ceq (Get-WelaFirewallRecoveryKey $after.Profiles.PersistentStore.($plan.Profile).Logging) + $report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'confirmed.json' (Get-WelaFirewallRecoveryKey ([pscustomobject]@{Status='LocalReadbackVerified';PlanSha256=$source.Hash;After=$after;WriteAttempted=$report.WriteAttempted;EffectiveMatchesLocal=$report.EffectiveMatchesLocal})) + $final=Get-WelaFirewallRecoveryState;$report.After=$final + if((Get-WelaFirewallRecoveryKey $final) -cne (Get-WelaFirewallRecoveryKey $after)){throw 'Final firewall context drifted after readback.'} + Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash + $report.Status=if($already){'AlreadyRestored'}else{'LocalLoggingRestored'};$report.ExitCode=0 + } + }catch{$report.Status=if($report.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message} + if($report.OutputPath){$null=Write-WelaWecUpdateArtifact $report.OutputPath 'result.json' (Get-WelaFirewallRecoveryKey $report)} + $report +} diff --git a/tests/FirewallLoggingRecovery.Cli.Tests.ps1 b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..09d4733b --- /dev/null +++ b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 @@ -0,0 +1,17 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$n=0 +function Check([string[]]$Arguments,[string]$Pattern,[int]$Expected=1){ + $old=$ErrorActionPreference;$ErrorActionPreference='Continue' + try{$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0) -or ($output -join ' ') -notmatch $Pattern){throw "Unexpected CLI $($Arguments -join ' '): $code $output"};$script:n++ +} +Check @('firewall-recovery','-Help') 'FirewallRecoveryPlanHash' 0 +Check @('configure','-FirewallRecoveryProfile','Domain') 'require firewall-recovery' +Check @('firewall-recovery','-FirewallAction','Configure') 'dedicated' +Check @('firewall-recovery','-RecoveryAction','Restore') 'dedicated|require audit-recovery' +Check @('firewall-recovery','-FirewallRecoveryProfile','All') 'ValidateSet|does not belong' +Check @('firewall-recovery','-FirewallRecoveryAction','Plan','-Auto') 'requires one profile' +Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-DryRun') 'reviewed plan/hash' +Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath','missing','-FirewallRecoveryPlanHash',('a'*64),'-DryRun','-FirewallRecoveryOutputPath','must-not-exist') 'reviewed plan/hash' +$global:LASTEXITCODE=0 +Write-Host "Firewall recovery public CLI: $n checks passed." diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 new file mode 100644 index 00000000..da19f0ee --- /dev/null +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -0,0 +1,99 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +foreach($file in @('Configuration','FirewallLogging','AuditRecovery','WefArrival','WecUpdate','FirewallLoggingRecovery')){. (Join-Path $repo "scripts/$file.ps1")} +$script:assertions=0;$script:writes=0;$script:mode='';$script:prompt=$null +function Assert($Value,$Message){if(-not $Value){throw "FAIL: $Message"};$script:assertions++} +function Throws($Action,$Pattern){$message='';try{& $Action | Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; received $message"} +function Copy-Fixture($Value){Get-WelaFirewallRecoveryKey $Value | ConvertFrom-Json} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-firewall-fixture-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $root +$journal=Join-Path $root 'before.jsonl';$results=Join-Path $root 'original.json' +# Only the platform output-ACL boundary and native state/setter are replaced. +# Strict input parsing, durable artifact writes and all production orchestration run. +function New-WelaArrivalOutput {param($Path,$SourcePath) if(Test-Path -LiteralPath $Path){throw 'Output exists'};$null=New-Item -ItemType Directory -Path $Path;[IO.Path]::GetFullPath($Path)} +function Snapshot($Name,$Enabled='False',$Size=4096){[pscustomobject][ordered]@{Name=$Name;LogAllowed=$Enabled;LogBlocked=$Enabled;LogMaxSizeKilobytes=$Size;LogFileName="C:\Logs\$Name.log";Enabled='True'}} +function Reset { + $script:writes=0;$script:mode='';$script:prompt=$null + $before=[pscustomobject]@{Local=Snapshot Domain;Effective=Snapshot Domain} + $after=[pscustomobject]@{Local=Snapshot Domain True 16384;Effective=Snapshot Domain True 16384;Access=[pscustomobject]@{State='VerifiedExplicitGrant'}} + $script:entry=[pscustomobject][ordered]@{Version=1;ComputerName='TEST';RecordedUtc=[DateTime]::UtcNow.ToString('o');Id='FirewallTextLog/Domain';Kind='FirewallTextLog';Target=[pscustomobject]@{Name='Domain';PolicyStore='PersistentStore'};Before=$before;Desired=[pscustomobject]@{LogAllowed='True';LogBlocked='True';MinimumSizeKiB=16384;LogFileName='C:\Logs\Domain.log';PathMode='Preserve'}} + $script:row=[pscustomobject]@{Id=$entry.Id;Kind=$entry.Kind;Target=Copy-Fixture $entry.Target;Before=Copy-Fixture $entry.Before;Desired=Copy-Fixture $entry.Desired;After=$after;Status='Applied';Diagnostic=''} + $stores=[ordered]@{} + foreach($store in @('PersistentStore','ActiveStore')){ + $profiles=[ordered]@{} + foreach($name in @('Domain','Private','Public')){$profiles[$name]=[pscustomobject]@{Logging=ConvertTo-WelaFirewallRecoveryTuple (Snapshot $name True 16384) -Snapshot;Preserved=[pscustomobject]@{Enabled=$true;DefaultInboundAction='Block';Other='unchanged'}}} + $stores[$store]=[pscustomobject]$profiles + } + $script:state=[pscustomobject][ordered]@{Context=[pscustomobject]@{Computer='TEST';MachineGuid='actual-now';Reader='sid+logon';Engine='test'};Sources=[pscustomobject]@{Code='pinned'};NativeSources=[pscustomobject]@{Module='native'};Profiles=[pscustomobject]$stores;RuleConfiguration=@([pscustomobject]@{Store='PersistentStore';Sha256='rules'})} + Save +} +function Save { + [IO.File]::WriteAllText($journal,(Get-WelaFirewallRecoveryKey $entry),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($results,(Get-WelaFirewallRecoveryKey ([pscustomobject]@{DryRun=$false;Scope='firewall-text-logging-only';Results=@($row)})),[Text.UTF8Encoding]::new($false)) +} +function Get-WelaFirewallRecoveryState {Copy-Fixture $script:state} +function Read-Host {param($Prompt) if($script:prompt){& $script:prompt};'y'} +function Set-WelaFirewallRecoveryLogging { + param($Profile,$Tuple) + Assert ($Profile -ceq 'Domain') 'Setter receives only selected profile' + $pending=Get-Content -LiteralPath (Join-Path $script:restoreOutput 'pending.json') -Raw | ConvertFrom-Json + Assert ($pending.Status -ceq 'Pending' -and $pending.RecoverTo.LogAllowed -ceq 'False') 'Durable matching pending receipt precedes setter' + $script:writes++ + $script:state.Profiles.PersistentStore.Domain.Logging=Copy-Fixture $Tuple + if($script:mode -ne 'policy'){$script:state.Profiles.ActiveStore.Domain.Logging=Copy-Fixture $Tuple} + if($script:mode -eq 'throw'){throw 'Injected partial native setter failure'} + if($script:mode -eq 'enforcement'){$script:state.Profiles.PersistentStore.Domain.Preserved.Enabled=$false} +} +function Plan { + $out=Join-Path $root ([guid]::NewGuid().ToString('N')) + $r=Invoke-WelaFirewallLoggingRecovery -Profile Domain -JournalPath $journal -ResultsPath $results -OutputPath $out + Assert ($r.Status -ceq 'Planned' -and $r.ExitCode -eq 0) "Plan accepted: $($r.Diagnostic)" + $script:planPath=Join-Path $out 'plan.json';$script:planHash=$r.PlanSha256 +} +function Restore([switch]$Prompt,[switch]$DryRun){ + $script:restoreOutput=Join-Path $root ([guid]::NewGuid().ToString('N')) + $args=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planHash;Auto=(-not $Prompt);DryRun=$DryRun} + if(-not $DryRun){$args.OutputPath=$script:restoreOutput} + Invoke-WelaFirewallLoggingRecovery @args +} +try { + Reset + $e=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST + Assert ($e.RecoverTo.LogMaxSizeKilobytes -eq 4096 -and $e.Expected.LogAllowed -ceq 'True') 'Exact typed local recovery tuple' + foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'} + foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'} + foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} + $v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected' + foreach($change in @( + {$script:row.Status='Failed'},{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, + {$script:row.After.Local.LogMaxSizeKilobytes=20000},{$script:row.After.Local.LogFileName='C:\Other.log'}, + {$script:row.After.Access.State='Unknown'},{$script:entry.ComputerName='OTHER'}, + {$script:row.Before.Local.LogBlocked='True'},{$script:entry.Desired.MinimumSizeKiB='16384';$script:row.Desired=Copy-Fixture $entry.Desired} + )){Reset;& $change;Save;Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'required|Only|permitted|confirm|wrong-host|mismatch|Unsupported'} + Reset;[IO.File]::AppendAllText($journal,"`n"+(Get-WelaFirewallRecoveryKey $entry));Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'duplicate' + Reset;[IO.File]::WriteAllText($journal,'{"Version":1,"version":1}');Throws {Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST} 'duplicate|Duplicate|collision' + Reset;Plan;$r=Restore -DryRun;Assert ($r.Status -ceq 'WouldRestore' -and $writes -eq 0 -and -not (Test-Path $restoreOutput)) 'Dry run has no writes or output' + $r=Restore;Assert ($r.Status -ceq 'LocalLoggingRestored' -and $r.ExitCode -eq 0 -and $writes -eq 1 -and $r.EffectiveMatchesLocal -and $r.ReadyRuleCredit -eq 0) "Exact restoration succeeded: $($r.Diagnostic)" + $r=Restore;Assert ($r.Status -ceq 'AlreadyRestored' -and $writes -eq 1) 'Idempotence never calls setter' + foreach($change in @( + {$script:state.Profiles.PersistentStore.Domain.Logging.LogMaxSizeKilobytes=24576}, + {$script:state.Profiles.PersistentStore.Private.Logging.LogBlocked='False'}, + {$script:state.Profiles.PersistentStore.Domain.Preserved.Enabled=$false}, + {$script:state.RuleConfiguration[0].Sha256='drift'},{$script:state.Context.Reader='another-logon'}, + {$script:state.Sources.Code='changed'},{$script:state.NativeSources.Module='changed'} + )){Reset;Plan;& $change;$r=Restore;Assert ($r.Status -ceq 'Refused' -and -not $r.WriteAttempted -and $writes -eq 0) 'Current drift blocks every setter'} + Reset;Plan;$script:prompt={$script:state.Profiles.PersistentStore.Domain.Logging.LogBlocked='False'};$r=Restore -Prompt + Assert ($r.Status -ceq 'Refused' -and $writes -eq 0 -and (Test-Path (Join-Path $restoreOutput 'pending.json'))) 'Fresh post-prompt guard preserves pending receipt without writing' + Reset;Plan;$entry.Before.Local.LogMaxSizeKilobytes=2048;$row.Before=Copy-Fixture $entry.Before;Save;$r=Restore + Assert ($r.Status -ceq 'Refused' -and $writes -eq 0) 'Changed original inputs block restore' + Reset;Plan;[IO.File]::AppendAllText($planPath,' ');$r=Restore;Assert ($r.Status -ceq 'Refused' -and $writes -eq 0) 'Changed reviewed plan bytes block restore' + Reset;Plan;$script:mode='throw';$r=Restore + Assert ($r.Status -ceq 'WriteAttemptedUnverified' -and $r.ExitCode -eq 1 -and $r.WriteAttempted -and (Test-Path (Join-Path $restoreOutput 'pending.json')) -and -not (Test-Path (Join-Path $restoreOutput 'confirmed.json'))) 'Partial setter failure stays unverified with durable intent, never automatic rollback' + Reset;Plan;$script:mode='enforcement';$r=Restore;Assert ($r.Status -ceq 'WriteAttemptedUnverified') 'Unexpected enforcement drift fails readback' + Reset;Plan;$script:mode='policy';$r=Restore;Assert ($r.Status -ceq 'LocalLoggingRestored' -and -not $r.EffectiveMatchesLocal) 'Local restoration is separate from unchanged effective override' + # CIM configuration hashes must retain enforcement/condition data and typed nulls. + $cim=[pscustomobject]@{CimClass=[pscustomobject]@{CimClassName='MSFT_NetFirewallRule'};CimInstanceProperties=@([pscustomobject]@{Name='Enabled';Value=1;CimType='UInt16'},[pscustomobject]@{Name='Status';Value='volatile';CimType='String'})} + $key=ConvertTo-WelaFirewallRecoveryCim $cim @('Status');Assert ($key.Enabled.Type -eq 'UInt16' -and -not $key.PSObject.Properties['Status']) 'Rule hash preserves typed configuration while excluding named diagnostics' + $cim.CimInstanceProperties[0].Value=[DateTime]::UtcNow;Throws {ConvertTo-WelaFirewallRecoveryCim $cim} 'Unsupported native property type' +} finally {Remove-Item -LiteralPath $root -Recurse -Force} +$global:LASTEXITCODE=0 +Write-Host "Firewall logging recovery: $script:assertions assertions passed." diff --git a/tests/FirewallLoggingRecovery.Windows.Tests.ps1 b/tests/FirewallLoggingRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..b2b369d0 --- /dev/null +++ b/tests/FirewallLoggingRecovery.Windows.Tests.ps1 @@ -0,0 +1,81 @@ +param([switch]$AllowDisposableLoggingWrite) +$ErrorActionPreference='Stop' +if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: native Windows required.';exit 0} +if(-not $AllowDisposableLoggingWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted logging-write opt-in is required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +foreach($file in @('Configuration','FirewallLogging','AuditRecovery','WefArrival','WecUpdate','ChannelRead','FirewallLoggingRecovery')){. (Join-Path $repo "scripts/$file.ps1")} +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-firewall-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $root +$logDirectory=Join-Path $root 'owned-logs';$null=New-Item -ItemType Directory -Path $logDirectory +$script:checks=0;$script:cliIndex=0;$before=$null;$cleanup=$false +function Assert-Native($Value,$Message){if(-not $Value){throw $Message};$script:checks++;Write-Host "PASS: $Message"} +function Save-Native($Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),(Get-WelaFirewallRecoveryKey $Value),[Text.UTF8Encoding]::new($false))} +function Invoke-FixtureCli([string[]]$Arguments,[int]$Expected=0){ + $script:cliIndex++;$old=$ErrorActionPreference;$ErrorActionPreference='Continue' + try{$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + $output | Out-File -LiteralPath (Join-Path $root ("cli-$script:cliIndex.txt")) -Encoding utf8 + if(-not (($Expected -eq 0 -and $code -eq 0) -or ($Expected -ne 0 -and $code -ne 0))){throw "Public $($Arguments[0]) exit $code (expected $Expected): $($output -join ' ')"} + Assert-Native $true "Public $($Arguments[0]) exit $code (expected $Expected)" +} +try { + $before=Get-WelaFirewallRecoveryState;Save-Native 'safety-before.json' $before + # All test-only changes are the four logging fields and a new owned directory. + # The product never changes destination ACLs, service state, enforcement or rules. + $acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false) + $owner=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$sid=$owner.User;$acl.SetOwner($sid)}finally{$owner.Dispose()} + $service=([Security.Principal.NTAccount]::new('NT SERVICE\mpssvc')).Translate([Security.Principal.SecurityIdentifier]) + foreach($principal in @($sid,[Security.Principal.SecurityIdentifier]::new('S-1-5-18'),[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($principal,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))} + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($service,'Modify','ContainerInherit,ObjectInherit','None','Allow')) + Set-Acl -LiteralPath $logDirectory -AclObject $acl + foreach($profile in @('Domain','Private','Public')){ + NetSecurity\Set-NetFirewallProfile -Name $profile -PolicyStore PersistentStore -LogAllowed False -LogBlocked False -LogMaxSizeKilobytes 4096 -LogFileName (Join-Path $logDirectory "$profile.log") -Confirm:$false -ErrorAction Stop + } + $prepared=Get-WelaFirewallRecoveryState;Save-Native 'prepared.json' $prepared + $original=Join-Path $root 'original.json';$backup=Join-Path $root 'configure-backup' + Invoke-FixtureCli @('firewall-logging','-FirewallAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',$original) + $originalReport=Get-Content -LiteralPath $original -Raw | ConvertFrom-Json + Assert-Native (@($originalReport.Results | Where-Object Status -ceq 'Applied').Count -eq 3) 'Public Configure produced three actual completed Applied journals' + $configured=Get-WelaFirewallRecoveryState;Save-Native 'configured.json' $configured + $planDirectory=Join-Path $root 'plan' + Invoke-FixtureCli @('firewall-recovery','-FirewallRecoveryProfile','Domain','-FirewallRecoveryJournalPath',(Join-Path $backup 'before.jsonl'),'-FirewallRecoveryResultsPath',$original,'-FirewallRecoveryOutputPath',$planDirectory) + $planPath=Join-Path $planDirectory 'plan.json';$planHash=(Get-FileHash -LiteralPath $planPath -Algorithm SHA256).Hash.ToLowerInvariant() + $restoreArgs=@('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath',$planPath,'-FirewallRecoveryPlanHash',$planHash) + Invoke-FixtureCli ($restoreArgs+@('-DryRun')) + Assert-Native ((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -ceq (Get-WelaFirewallRecoveryKey $configured)) 'Public dry run preserves complete native state' + NetSecurity\Set-NetFirewallProfile -Name Domain -PolicyStore PersistentStore -LogMaxSizeKilobytes 24576 -Confirm:$false -ErrorAction Stop + $drift=Get-WelaFirewallRecoveryState + Invoke-FixtureCli ($restoreArgs+@('-DryRun')) 1 + Assert-Native ((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -ceq (Get-WelaFirewallRecoveryKey $drift)) 'Changed confirmed After tuple is refused without mutation' + NetSecurity\Set-NetFirewallProfile -Name Domain -PolicyStore PersistentStore -LogMaxSizeKilobytes 16384 -Confirm:$false -ErrorAction Stop + $restoreDirectory=Join-Path $root 'restore' + Invoke-FixtureCli ($restoreArgs+@('-Auto','-FirewallRecoveryOutputPath',$restoreDirectory)) + $result=Get-Content -LiteralPath (Join-Path $restoreDirectory 'result.json') -Raw | ConvertFrom-Json + Assert-Native ($result.Status -ceq 'LocalLoggingRestored' -and $result.WriteAttempted -and $result.ReadyRuleCredit -eq 0) 'Public recovery confirms the actual local four-field tuple without Sigma credit' + $recovered=Get-WelaFirewallRecoveryState;Save-Native 'recovered.json' $recovered + Assert-Native ((Get-WelaFirewallRecoveryKey $recovered.Profiles.PersistentStore.Domain.Logging) -ceq (Get-WelaFirewallRecoveryKey $prepared.Profiles.PersistentStore.Domain.Logging)) 'Selected local logging tuple exactly matches its original before values' + Assert-Native ((Get-WelaFirewallRecoveryInvariant $recovered Domain) -ceq (Get-WelaFirewallRecoveryInvariant $configured Domain)) 'Enforcement, other profiles and both-store rule/filter configurations remain unchanged' + foreach($artifact in $result.Artifacts){Assert-Native ((Get-FileHash -LiteralPath (Join-Path $restoreDirectory $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) "Verified retained receipt $($artifact.Name)"} + $again=Join-Path $root 'again';Invoke-FixtureCli ($restoreArgs+@('-Auto','-FirewallRecoveryOutputPath',$again)) + $againResult=Get-Content -LiteralPath (Join-Path $again 'result.json') -Raw | ConvertFrom-Json + Assert-Native ($againResult.Status -ceq 'AlreadyRestored' -and -not $againResult.WriteAttempted) 'Public repeated recovery is idempotent without a setter' +} finally { + $errors=@() + if($before){ + foreach($profile in @('Domain','Private','Public')){ + try{Set-WelaFirewallRecoveryLogging $profile $before.Profiles.PersistentStore.$profile.Logging}catch{$errors+="$profile cleanup: $($_.Exception.Message)"} + } + try{$final=Get-WelaFirewallRecoveryState;Save-Native 'safety-after.json' $final;if((Get-WelaFirewallRecoveryKey $final) -cne (Get-WelaFirewallRecoveryKey $before)){throw 'Complete final native firewall configuration differs from original safety snapshot.'}}catch{$errors+=$_.Exception.Message} + } + # Service handles may briefly retain the old owned path after restoring all profiles. + if(-not $errors.Count){ + for($attempt=0;$attempt -lt 10;$attempt++){ + try{Remove-Item -LiteralPath $logDirectory -Recurse -Force -ErrorAction Stop;break}catch{if($attempt -eq 9){$errors+=$_.Exception.Message}else{Start-Sleep -Milliseconds 500}} + } + } + $cleanup=-not $errors.Count + Save-Native 'acceptance.json' ([pscustomobject]@{Build=$before.Context.Build;Engine=$PSVersionTable.PSVersion.ToString();Checks=$checks;CleanupVerified=$cleanup;CleanupErrors=$errors;Scope='Actual public Configure/Plan/Restore, drift refusal and idempotence; all original profile logging, enforcement and bounded native rule/filter configuration restored. No event/Sigma proof.'}) + if($errors.Count){throw "Fixture cleanup failed; evidence at $root : $($errors -join '; ')"} +} +$global:LASTEXITCODE=0 +Write-Host "PASS: $checks native firewall recovery checks; exact safety cleanup. Evidence: $root" diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..e632da5e 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 91d49131..c5517e53 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From 7184918f660442201f12ac8b321a232b6b93e1a5 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:53:39 +0900 Subject: [PATCH 06/25] Create an unnamed CNG key through typed native helper --- scripts/Capi2ProbeNative.cs | 8 ++++++++ scripts/Capi2ProbeWorker.ps1 | 5 +---- tests/Capi2Probe.Tests.ps1 | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/Capi2ProbeNative.cs b/scripts/Capi2ProbeNative.cs index 43874bc8..bfb93f64 100644 --- a/scripts/Capi2ProbeNative.cs +++ b/scripts/Capi2ProbeNative.cs @@ -2,9 +2,17 @@ using System; using System.ComponentModel; using System.Runtime.InteropServices; +using System.Security.Cryptography; namespace Wela.Capi2Probe { public sealed class ChainResult { public uint Flags, ErrorStatus, InfoStatus, Chains, Elements; } public static class Native { + public static CngKey CreateEphemeralRsa() { + CngKeyCreationParameters parameters=new CngKeyCreationParameters(); + parameters.Provider=CngProvider.MicrosoftSoftwareKeyStorageProvider; + parameters.Parameters.Add(new CngProperty("Length",BitConverter.GetBytes(2048),CngPropertyOptions.None)); + // Literal null is essential: PowerShell converts a null string argument to empty. + return CngKey.Create(CngAlgorithm.Rsa,null,parameters); + } public const uint OfflineFlags=0x80002104; // cache-only URL/revocation, no AIA, no auth-root auto-update [StructLayout(LayoutKind.Sequential)] struct Usage { public uint Count; public IntPtr Oids; } [StructLayout(LayoutKind.Sequential)] struct Match { public uint Type; public Usage Usage; } diff --git a/scripts/Capi2ProbeWorker.ps1 b/scripts/Capi2ProbeWorker.ps1 index 0e79352d..a99ae510 100644 --- a/scripts/Capi2ProbeWorker.ps1 +++ b/scripts/Capi2ProbeWorker.ps1 @@ -8,10 +8,7 @@ Initialize-WelaCapi2ProbeNative $before=[Wela.WmiProbe.Native]::Snapshot() $key=$null;$rsa=$null;$certificate=$null try { - $parameters=[Security.Cryptography.CngKeyCreationParameters]::new() - $parameters.Provider=[Security.Cryptography.CngProvider]::MicrosoftSoftwareKeyStorageProvider - $parameters.Parameters.Add([Security.Cryptography.CngProperty]::new('Length',[BitConverter]::GetBytes([int]2048),[Security.Cryptography.CngPropertyOptions]::None)) - $key=[Security.Cryptography.CngKey]::Create([Security.Cryptography.CngAlgorithm]::Rsa,$null,$parameters) + $key=[Wela.Capi2Probe.Native]::CreateEphemeralRsa() if(-not $key.IsEphemeral -or $key.KeyName){throw 'The generated CNG key is not ephemeral.'} $rsa=[Security.Cryptography.RSACng]::new($key) $request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$Nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) diff --git a/tests/Capi2Probe.Tests.ps1 b/tests/Capi2Probe.Tests.ps1 index a4f394e1..e59d4343 100644 --- a/tests/Capi2Probe.Tests.ps1 +++ b/tests/Capi2Probe.Tests.ps1 @@ -10,7 +10,7 @@ function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth $nonce='0123456789abcdef0123456789abcdef';$now=[DateTime]::UtcNow $token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='HOST\user';AuthenticationId='0x1234';AuthenticationType='NTLM';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()} $state=[pscustomobject]@{Computer='HOST';Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}} -$rsa=[Security.Cryptography.RSA]::Create();$rsa.KeySize=2048;$cert=$null +if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$rsa=[Security.Cryptography.RSACng]::new(2048)}else{$rsa=[Security.Cryptography.RSA]::Create();$rsa.KeySize=2048};$cert=$null try{ $request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) $cert=$request.CreateSelfSigned(([DateTimeOffset]$now).AddMinutes(-5),([DateTimeOffset]$now).AddMinutes(5)) From 2e329c7f2fd5a794b188f76111f53121ae1cfd22 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:54:51 +0900 Subject: [PATCH 07/25] Preserve policy arrays through Windows PowerShell JSON roundtrips --- scripts/TranscriptionRecovery.ps1 | 25 ++++++++++++++++++------- tests/TranscriptionRecovery.Tests.ps1 | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/TranscriptionRecovery.ps1 b/scripts/TranscriptionRecovery.ps1 index f193853d..3bf45a38 100644 --- a/scripts/TranscriptionRecovery.ps1 +++ b/scripts/TranscriptionRecovery.ps1 @@ -1,4 +1,12 @@ # Explicit recovery of one completed Windows PowerShell transcription policy write. +function Copy-WelaTranscriptRecoveryValue { + param($Value) + # Windows PowerShell 5.1 annotates a root array emitted by ConvertFrom-Json; + # serializing that annotated array can introduce synthetic value/count keys. + # Keep arrays nested during the JSON roundtrip and emit their actual items. + $holder=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey ([pscustomobject]@{Data=$Value})) + $holder.Data +} function Get-WelaTranscriptRecoverySources { $sources=[ordered]@{} foreach($name in @('WELA.ps1','scripts/TranscriptionRecovery.ps1','scripts/PowerShellTranscription.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1')) { @@ -96,7 +104,10 @@ function New-WelaTranscriptRecoveryPlan { $before=$entry.Before;$after=$last.After foreach($snapshot in @($before,$after)) { if($snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or - $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -cne 'Registry32'){throw 'Both canonical shared registry views are required.'} + $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -cne 'Registry32'){ + $policyType=if($null -eq $snapshot.Policy){''}else{$snapshot.Policy.GetType().FullName} + throw "Both canonical shared registry views are required. Capability=$($snapshot.Capability.Status); PolicyType=$policyType; Count=$(@($snapshot.Policy).Count); Views=$(@($snapshot.Policy.View) -join ','); Observation=$(Get-WelaRecoveryKey $snapshot)" + } Test-WelaTranscriptSharedPolicy $snapshot.Policy foreach($name in @('EnableTranscripting','OutputDirectory')){Assert-WelaTranscriptRecoveryValue $snapshot.Policy[0].Machine.$name $name} } @@ -105,9 +116,9 @@ function New-WelaTranscriptRecoveryPlan { (Get-WelaTranscriptRecoveryTypedKey $before.Policy[0].Machine.EnableInvocationHeader) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableInvocationHeader)){throw 'Original configuration did not preserve user/header policy.'} $current=Get-WelaTranscriptState $entry.Target.OutputDirectory if((Get-WelaRecoveryKey $current.Policy) -cne (Get-WelaRecoveryKey $after.Policy) -or (Get-WelaRecoveryKey $current.Destination) -cne (Get-WelaRecoveryKey $after.Destination)){throw 'Current policy/destination differs from the original final After state.'} - $target=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $after.Policy) + $target=Copy-WelaTranscriptRecoveryValue $after.Policy foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) { - $view.Machine.$name=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $before.Policy[0].Machine.$name) + $view.Machine.$name=Copy-WelaTranscriptRecoveryValue $before.Policy[0].Machine.$name # Keep the existing key; absence recovery removes only the selected value. $view.Machine.$name.KeyExists=$true }} @@ -197,7 +208,7 @@ function Invoke-WelaTranscriptRecovery { $output=New-WelaRecoveryOutput $OutputPath $outputObservation=Get-WelaTranscriptDestination $output $report=[pscustomobject][ordered]@{Status='Failed';ExitCode=1;OutputPath=$output;PlanSha256=$source.Sha256;Steps=@();Before=$plan.ExpectedPolicy;After=$null;Diagnostic='';SigmaEvtxCredit=0;Scope='Two typed Windows PowerShell machine transcription values only; no transcript, session adoption, central collection or policy persistence proof.'} - $expected=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $plan.ExpectedPolicy) + $expected=Copy-WelaTranscriptRecoveryValue $plan.ExpectedPolicy try { if(-not $Auto -and (Read-Host 'Restore the reviewed transcription values, including any explicitly consented temporary suspension? (y/N)') -cnotin @('y','Y')){$report.Status='Declined';$report.ExitCode=0} else { @@ -206,13 +217,13 @@ function Invoke-WelaTranscriptRecovery { foreach($step in $plan.Steps) { $sequence++ Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 - $receipt=[pscustomobject]@{Sequence=$sequence;Status='Pending';RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Step=$step;Before=(ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $expected));After=$null} + $receipt=[pscustomobject]@{Sequence=$sequence;Status='Pending';RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Step=$step;Before=(Copy-WelaTranscriptRecoveryValue $expected);After=$null} Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-pending.json' -f $sequence) $receipt Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 Set-WelaTranscriptRecoveryValue $step.Name $step.Value - foreach($view in $expected){$view.Machine.($step.Name)=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $step.Value)} + foreach($view in $expected){$view.Machine.($step.Name)=Copy-WelaTranscriptRecoveryValue $step.Value} Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 - $receipt.Status='Confirmed';$receipt.After=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $expected) + $receipt.Status='Confirmed';$receipt.After=Copy-WelaTranscriptRecoveryValue $expected Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-confirmed.json' -f $sequence) $receipt $report.Steps += [pscustomobject]@{Sequence=$sequence;Name=$step.Name;Status='Confirmed';Value=$step.Value} } diff --git a/tests/TranscriptionRecovery.Tests.ps1 b/tests/TranscriptionRecovery.Tests.ps1 index cdeb5eb1..30f8b086 100644 --- a/tests/TranscriptionRecovery.Tests.ps1 +++ b/tests/TranscriptionRecovery.Tests.ps1 @@ -14,7 +14,7 @@ $script:checks=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-transcript-re $null=New-Item -ItemType Directory $root function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"} -function Copy-Value($Value){ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $Value)} +function Copy-Value($Value){Copy-WelaTranscriptRecoveryValue $Value} function Typed($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})}} function Get-WelaTranscriptRecoveryContext {[pscustomobject]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$script:machine};Reader='fixture-reader'}} function Get-WelaTranscriptRecoverySources {[pscustomobject]@{Code=$script:code}} From 8c6a5974255c3acc81e7bde8b0242aa3bb7a4f41 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:55:25 +0900 Subject: [PATCH 08/25] Cover CIS recovery paths and bound retained native evidence --- .github/workflows/firewall-logging-recovery.yml | 8 +++++++- tests/FirewallLoggingRecovery.Tests.ps1 | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/firewall-logging-recovery.yml b/.github/workflows/firewall-logging-recovery.yml index 13d69d21..b6a7749f 100644 --- a/.github/workflows/firewall-logging-recovery.yml +++ b/.github/workflows/firewall-logging-recovery.yml @@ -42,6 +42,12 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: firewall-recovery-${{ matrix.os }}-${{ matrix.engine }} - path: ${{ runner.temp }}/wela-firewall-recovery-*/ + path: | + ${{ runner.temp }}/wela-firewall-recovery-*/*.json + ${{ runner.temp }}/wela-firewall-recovery-*/cli-*.txt + ${{ runner.temp }}/wela-firewall-recovery-*/configure-backup/ + ${{ runner.temp }}/wela-firewall-recovery-*/plan/ + ${{ runner.temp }}/wela-firewall-recovery-*/restore/ + ${{ runner.temp }}/wela-firewall-recovery-*/again/ if-no-files-found: warn retention-days: 7 diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index da19f0ee..0d1ef01a 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -59,6 +59,13 @@ try { Reset $e=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST Assert ($e.RecoverTo.LogMaxSizeKilobytes -eq 4096 -and $e.Expected.LogAllowed -ceq 'True') 'Exact typed local recovery tuple' + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){ + $entry.Desired.PathMode='CisV4';$entry.Desired.LogFileName='%SystemRoot%\System32\LogFiles\Firewall\domainfw.log';$row.Desired=Copy-Fixture $entry.Desired + $row.After.Local.LogFileName=$entry.Desired.LogFileName;$row.After.Effective.LogFileName=$entry.Desired.LogFileName;Save + $migration=Read-WelaFirewallRecoveryEvidence $journal $results Domain TEST + Assert ($migration.RecoverTo.LogFileName -ceq 'C:\Logs\Domain.log' -and $migration.Expected.LogFileName -ceq $entry.Desired.LogFileName) 'CIS migration preserves the exact original local recovery path' + Reset + } foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'} foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'} foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} From 43683b7aa35949f7950635f9b722626d73828b51 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:55:50 +0900 Subject: [PATCH 09/25] Link guarded recovery from firewall configuration guide --- docs/firewall-logging.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/firewall-logging.md b/docs/firewall-logging.md index d7290677..0c95595d 100644 --- a/docs/firewall-logging.md +++ b/docs/firewall-logging.md @@ -40,7 +40,9 @@ WELA does not attempt to broaden ACLs, resolve arbitrary group membership, imper Each snapshot also reports the firewall profile's `Enabled` value. A compliant logging configuration on a disabled/inactive profile is preparation for that profile, not proof of traffic events. WELA never changes that enforcement state. Text logs and Security EVTX audit events are separate sources; increasing an EVTX buffer does not configure these text logs, and a WEF subscription alone does not collect arbitrary text files. -## Manual recovery +## Guarded and manual recovery + +For one completed `Applied` operation with matching original journal and results, use the explicit [guarded firewall logging recovery](firewall-logging-recovery.md) Plan/Restore workflow. It verifies the current confirmed local After values, restores the original four local logging fields and preserves enforcement, other profiles and bounded native rule/filter configuration. Partial, ambiguous, drifted and unsupported operations still require manual investigation. There is no automatic rollback. Preserve `before.jsonl` and the results JSON. Before recovery, review failed versus applied controls, concurrent operator changes and GPO/MDM ownership. Restore the **local** snapshot, not the effective snapshot; applied policy may continue overriding it. Example for one reviewed journal entry: @@ -60,7 +62,7 @@ Do not blindly replay a journal: a failed write can have left the old state unto ## Validation and remaining integration evidence -The automated suite uses mocked firewall writes and temporary recovery files to check all profiles, larger limits, path preservation/CIS selection, effective-versus-local conflicts, idempotence, journal ordering, unknown permissions, read/write errors, prompt races and final drift. Windows CI runs these checks under PowerShell 5.1 and 7, plus actual read-only ActiveStore/PersistentStore and ACL inspection and a dry run. It does not alter runner firewall policy or generate traffic. +The original automated suite uses mocked firewall writes and temporary recovery files to check all profiles, larger limits, path preservation/CIS selection, effective-versus-local conflicts, idempotence, journal ordering, unknown permissions, read/write errors, prompt races and final drift. Its Windows smoke performs read-only ActiveStore/PersistentStore and ACL inspection and a dry run. The separate guarded-recovery workflow explicitly changes logging fields in a disposable owned fixture through the public Configure/Restore commands, then verifies exact restoration under PowerShell 5.1 and 7 on Server 2022/2025. Neither suite changes firewall enforcement or generates traffic. Before closing issue #375, capture evidence from an isolated Windows client/server lab: OS build, PowerShell version, WELA commit, before/after JSON, effective/local settings and service ACLs. On each applicable active network profile, generate one benign allowed connection and one controlled blocked connection against a disposable endpoint, confirm corresponding `ALLOW`/`DROP` text records and timestamps, and confirm the expected source path and parser in the actual collector. Test log creation and rotation under the actual service token, policy refresh/override behavior, and manual recovery. Do not weaken production filtering to create this evidence. These traffic/rotation/ingestion tests remain unperformed; no end-to-end detection claim is made. From a5f674e3f8e480507cfb915b2a158140c3e143aa Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:56:04 +0900 Subject: [PATCH 10/25] Sign public certificate without copying its ephemeral private key --- docs/capi2-probe.md | 2 +- scripts/Capi2ProbeWorker.ps1 | 4 +++- tests/Capi2Probe.Tests.ps1 | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/capi2-probe.md b/docs/capi2-probe.md index 7638b91d..b5d14535 100644 --- a/docs/capi2-probe.md +++ b/docs/capi2-probe.md @@ -30,5 +30,5 @@ This probe grants no ready-rule credit. It does not exercise TLS, remote connect - [CertGetCertificateChain flags and ownership](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certgetcertificatechain) - [CERT_CHAIN_PARA](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_chain_para), [CERT_CHAIN_CONTEXT](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_chain_context), [CERT_SIMPLE_CHAIN](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_simple_chain) and [CERT_TRUST_STATUS](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_trust_status) - [Unnamed CngKey creation is ephemeral](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.cngkey.create) -- [CertificateRequest.CreateSelfSigned](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.certificaterequest.createselfsigned) +- [CertificateRequest.Create with a signature generator](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.certificaterequest.create) - [GetSystemTimePreciseAsFileTime](https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemtimepreciseasfiletime) diff --git a/scripts/Capi2ProbeWorker.ps1 b/scripts/Capi2ProbeWorker.ps1 index a99ae510..f48bb370 100644 --- a/scripts/Capi2ProbeWorker.ps1 +++ b/scripts/Capi2ProbeWorker.ps1 @@ -13,7 +13,9 @@ try { $rsa=[Security.Cryptography.RSACng]::new($key) $request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$Nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) $now=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow() - $certificate=$request.CreateSelfSigned($now.AddMinutes(-5),$now.AddMinutes(5)) + $generator=[Security.Cryptography.X509Certificates.X509SignatureGenerator]::CreateForRSA($rsa,[Security.Cryptography.RSASignaturePadding]::Pkcs1) + $certificate=$request.Create($request.SubjectName,$generator,$now.AddMinutes(-5),$now.AddMinutes(5),[guid]::NewGuid().ToByteArray()) + if($certificate.HasPrivateKey){throw 'Only a public certificate is expected.'} $der=$certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert) $started=[Wela.WmiProbe.Native]::UtcNow() $chain=[Wela.Capi2Probe.Native]::Build($der) diff --git a/tests/Capi2Probe.Tests.ps1 b/tests/Capi2Probe.Tests.ps1 index e59d4343..3fbc035d 100644 --- a/tests/Capi2Probe.Tests.ps1 +++ b/tests/Capi2Probe.Tests.ps1 @@ -37,5 +37,26 @@ $null=Assert-WelaWmiProbeInterval $operation ([DateTimeOffset]$now.AddSeconds(-2 $bad=Clone $operation;$bad.Clock='UtcNow';Reject {Assert-WelaWmiProbeInterval $bad ([DateTimeOffset]$now.AddSeconds(-2)) ([DateTimeOffset]$now.AddSeconds(2))} 'Require precise native clock.' Reject {Invoke-WelaCapi2Probe -Action Run} 'Run requires a new private output path.' Reject {Invoke-WelaCapi2Probe -Action Plan -OutputPath unused} 'Plan creates no files.' +# Lifecycle fixtures test failure receipts and no-operation planning independently of Windows telemetry. +$script:FixtureState=$state;$script:FixtureOperation=$operation;$script:FixtureXml=$xml;$script:FixtureMode='success';$script:FixtureStateReads=0;$script:FixtureActions=0 +function Get-WelaCapi2ProbeState {$script:FixtureStateReads++;$value=Clone $script:FixtureState;if($script:FixtureMode -eq 'drift' -and $script:FixtureStateReads -gt 1){$value.Host.UBR++};$value} +function Get-WelaCapi2ProbeWatermark {if($script:FixtureMode -eq 'denied'){throw 'Reader denied.'};if($script:FixtureMode -eq 'rollback' -and $script:FixtureActions){return [long]0};[long]11} +function Start-WelaCapi2ProbeBuild {param($State);$script:FixtureActions++;if($script:FixtureMode -eq 'worker'){throw 'Bounded worker failed.'};Clone $script:FixtureOperation} +function Read-WelaCapi2ProbeEvents {param($Operation);$items=@($script:FixtureXml);if($script:FixtureMode -eq 'none'){$items=@($script:FixtureXml.Replace('800B0109','0'))};if($script:FixtureMode -eq 'duplicate'){$items=@($script:FixtureXml,$script:FixtureXml)};[pscustomobject]@{Xml=$items;Capped=($script:FixtureMode -eq 'cap');Query='fixed-fixture';MaximumEvents=64}} +$planned=Invoke-WelaCapi2Probe +Assert ($planned.Status -eq 'PrerequisitesObserved' -and $script:FixtureActions -eq 0 -and -not $planned.OutputPath) 'Plan observes prerequisites without operation or files.' +$private=Join-Path ([IO.Path]::GetTempPath()) ('wela-capi2-fixture-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $private +try{ + foreach($mode in @('success','none','duplicate','cap','drift','rollback','worker','denied')){ + $script:FixtureMode=$mode;$script:FixtureStateReads=0;$script:FixtureActions=0 + $out=Join-Path $private $mode;$result=Invoke-WelaCapi2Probe -Action Run -OutputPath $out -TimeoutSeconds 1 + if($mode -eq 'success'){Assert ($result.Status -eq 'LocalChainEventObserved' -and $result.ExitCode -eq 0 -and (Test-Path "$out/event.xml")) 'Success retains one exact matched event.'} + else{Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) ('Failure remains explicit: '+$mode)} + Assert (Test-Path "$out/manifest.json") 'Every started bundle retains its manifest.' + Assert ($script:FixtureActions -le 1 -and $result.ChannelChanges -eq 0 -and $result.StoreChanges -eq 0 -and $result.TrustPolicyChanges -eq 0 -and $result.ReadyRuleCredit -eq 0) 'No operation retry or configuration/coverage credit.' + foreach($artifact in $result.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Lifecycle artifact hash matches.'} + } + Reject {Invoke-WelaCapi2Probe -Action Run -OutputPath (Join-Path $private 'success')} 'Existing evidence cannot be overwritten.' +}finally{Remove-Item -LiteralPath $private -Recurse -Force} Write-Host "PASS: $count portable CAPI2 assertions. No native event proof is claimed." $global:LASTEXITCODE=0 From e8b018d5c984c4277670029a645d03ae6081bb5d Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:56:45 +0900 Subject: [PATCH 11/25] Refuse ambiguous Windows path aliases during recovery --- scripts/FirewallLoggingRecovery.ps1 | 3 +++ tests/FirewallLoggingRecovery.Tests.ps1 | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 index 9db87b1e..b4b224bd 100644 --- a/scripts/FirewallLoggingRecovery.ps1 +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -21,6 +21,9 @@ function Resolve-WelaFirewallRecoveryLogPath { # Only native Windows directory variables have reviewed meaning in old paths. $expanded=[regex]::Replace($Path,'(?i)%(systemroot|windir)%',[Text.RegularExpressions.MatchEvaluator]{param($m) [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)}) if($expanded -notmatch '^[A-Za-z]:\\' -or $expanded -match '%' -or $expanded.Substring(2).Contains(':') -or $expanded.EndsWith('\') -or $expanded.Contains('/')){throw 'UNC/device/relative paths, unknown variables and alternate streams are unsupported.'} + foreach($segment in $expanded.Substring(3).Split([char]'\')){ + if(-not $segment -or $segment -match '[ .]$' -or $segment -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)'){throw 'Ambiguous path segments and Windows device aliases are unsupported.'} + } if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$null=Resolve-WelaArrivalPath $expanded} $expanded } diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index 0d1ef01a..f63a160d 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -68,7 +68,7 @@ try { } foreach($bad in @('NotConfigured','true','1')){$v=Copy-Fixture $e.RecoverTo;$v.LogAllowed=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'True/False'} foreach($bad in @('4096',0,32768,$true,1.5)){$v=Copy-Fixture $e.RecoverTo;$v.LogMaxSizeKilobytes=$bad;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'integer'} - foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} + foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\','C:\Logs\CON.log','C:\Logs\log.','C:\Logs\log ','C:\Logs\\log')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} $v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected' foreach($change in @( {$script:row.Status='Failed'},{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, From 1572d2b128c08be42d410f47b670428de86ebfdc Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:03:42 +0900 Subject: [PATCH 12/25] Pin observed CAPI2 XML and require existing running services --- docs/capi2-probe.md | 4 ++-- scripts/Capi2Probe.ps1 | 22 ++++++++++++++++++++-- tests/Capi2Probe.Tests.ps1 | 8 ++++---- tests/Capi2Probe.Windows.Tests.ps1 | 8 ++++---- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/capi2-probe.md b/docs/capi2-probe.md index b5d14535..62231670 100644 --- a/docs/capi2-probe.md +++ b/docs/capi2-probe.md @@ -7,7 +7,7 @@ ./WELA.ps1 capi2-probe -Capi2ProbeAction Run -Capi2ProbeOutputPath C:\Evidence\new-capi2-probe ``` -Plan reads prerequisites and creates no files. Run requires a new directory on a local fixed drive; its evidence directory blocks inherited broad access. Use an existing token that can read `Microsoft-Windows-CAPI2/Operational`. The channel must already be enabled. The probe does not change channel configuration, audit policy, services, certificate stores, trust settings or reader permissions. Existing native-channel configuration commands remain separate. +Plan reads prerequisites and creates no files. Run requires a new directory on a local fixed drive; its evidence directory blocks inherited broad access. Use an existing token that can read `Microsoft-Windows-CAPI2/Operational`. The channel must already be enabled. Winmgmt, CryptSvc and EventLog must already be running; observing the host or building the chain may not implicitly start them. The probe does not change channel configuration, audit policy, services, certificate stores, trust settings or reader permissions. Existing native-channel configuration commands remain separate. Run launches the same PowerShell executable in a fresh worker with a parent-generated nonce and a twenty-second process deadline. The worker creates an unnamed ephemeral Microsoft Software Key Storage Provider RSA-2048 key, signs an in-memory certificate with `CN=WelaCapi2Probe_` and a ten-minute validity interval, then calls `CertGetCertificateChain` once. The certificate has no AIA, CRL or other extensions. The key is disposed and never exported; the retained PEM/DER contains only the public certificate. @@ -23,7 +23,7 @@ This probe grants no ready-rule credit. It does not exercise TLS, remote connect `tests/Capi2Probe.Tests.ps1` validates certificate binding, native-result constraints, prerequisite guards, exact XML source/field checks and UTC boundaries with portable fixtures. `tests/Capi2Probe.Cli.Tests.ps1` checks public option isolation. Synthetic fixtures do not prove Windows telemetry. -`tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite` is restricted to opted-in disposable GitHub-hosted standalone Server 2022/2025. It invokes three independent public probes under Windows PowerShell 5.1 and PowerShell 7. Only the fixture may temporarily enable CAPI2; it retains the original and restored channel configuration, checks CurrentUser/LocalMachine My, Root and CA inventories, preserves all probe bundles and writes cleanup evidence even on failure. Native results must be assessed from the current workflow artifacts. +`tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite` is restricted to opted-in disposable GitHub-hosted standalone Server 2022/2025. It invokes three independent public probes under Windows PowerShell 5.1 and PowerShell 7. Only the fixture may temporarily enable CAPI2; it retains the original and restored channel configuration, checks CurrentUser/LocalMachine My, Root and CA inventories, preserves all probe bundles and writes cleanup evidence even on failure. Native results must be assessed from the current workflow artifacts. The first complete native checkpoint at `a5f674e` passed all four matrix jobs with 40 assertions and three independent public probes per job ([workflow evidence](https://github.com/Shirofune-Security/WELA/actions/runs/35580490435)). All twelve public certificate identities, sixty artifact hashes and four original/restored channel and selected-store inventories were independently checked. ## Microsoft API references diff --git a/scripts/Capi2Probe.ps1 b/scripts/Capi2Probe.ps1 index 0f4d8f26..aef959d4 100644 --- a/scripts/Capi2Probe.ps1 +++ b/scripts/Capi2Probe.ps1 @@ -16,16 +16,19 @@ function Get-WelaCapi2ProbeChannel { } function Get-WelaCapi2ProbeState { Initialize-WelaCapi2ProbeNative + $services=@(Get-Service -Name Winmgmt,CryptSvc,EventLog -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'Winmgmt, CryptSvc and EventLog must already be running; the probe starts no service.'} $token=[Wela.WmiProbe.Native]::Snapshot();$hostState=Get-WelaChannelReadHost $provider=[Diagnostics.Eventing.Reader.ProviderMetadata]::new('Microsoft-Windows-CAPI2') try{$event=@($provider.Events|Where-Object Id -eq 11);$metadata=[pscustomobject]@{Name=$provider.Name;Guid=$provider.Id.ToString();Event11Versions=@($event|ForEach-Object Version);LogNames=@($provider.LogLinks|ForEach-Object LogName|Sort-Object)}}finally{$provider.Dispose()} $engine=(Get-Process -Id $PID).Path - $state=[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Token=$token;Channel=(Get-WelaCapi2ProbeChannel);Provider=$metadata;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaCapi2ProbeSources)} + $state=[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Services=$services;Token=$token;Channel=(Get-WelaCapi2ProbeChannel);Provider=$metadata;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaCapi2ProbeSources)} if((Get-WelaWmiProbeTokenKey $token) -cne (Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()))){throw 'Token changed during CAPI2 prerequisite observation.'} $state } function Get-WelaCapi2ProbeStateKey { param($State) + if(@($State.Services).Count -ne 3 -or (@($State.Services.Name|Sort-Object) -join ',') -cne 'CryptSvc,EventLog,Winmgmt' -or @($State.Services|Where-Object Status -cne 'Running').Count){throw 'Required native services must already be running.'} if($State.Host.Build -notin @(20348,26100) -or $State.Host.ProductType -notin @(2,3) -or -not $State.Host.UBR -or $State.Host.Computer -cne $State.Computer){throw 'CAPI2 probe requires an observed Server 2022/2025 build and patch context.'} if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-CAPI2/Operational' -or $State.Channel.Type -cne 'Operational' -or $State.Channel.Provider -cne 'Microsoft-Windows-CAPI2' -or -not $State.Channel.SecurityDescriptor){throw 'CAPI2 Operational must already be enabled with an observed descriptor.'} if($State.Provider.Name -cne 'Microsoft-Windows-CAPI2' -or $State.Provider.Guid -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or @($State.Provider.Event11Versions).Count -ne 1 -or $State.Provider.Event11Versions[0] -ne 0 -or $State.Channel.Name -cnotin $State.Provider.LogNames){throw 'Unreviewed CAPI2 provider or event11 schema version.'} @@ -89,6 +92,13 @@ function Read-WelaCapi2ProbeEvents { [pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 64);Query=$query;MaximumEvents=64} }finally{foreach($record in $records){$record.Dispose()}} } +function Test-WelaCapi2XmlChildren { + param($Node,[string[]]$Names) + $children=@($Node.ChildNodes|Where-Object NodeType -eq Element) + if($children.Count -ne $Names.Count -or @($Node.ChildNodes|Where-Object {$_.NodeType -notin @('Element','Whitespace')}).Count){return $false} + foreach($name in $Names){if(@($children|Where-Object {$_.LocalName -ceq $name -and $_.NamespaceURI -ceq 'http://schemas.microsoft.com/win/2004/08/events/event'}).Count -ne 1){return $false}} + $true +} function Test-WelaCapi2ProbeEvent { param([string]$Xml,$Operation,$State) $reader=$null @@ -107,13 +117,21 @@ function Test-WelaCapi2ProbeEvent { # Namespace and exact paths are pinned to native event11, never a recursive name search. if(@($data.ChildNodes|Where-Object NodeType -eq Element).Count -ne 1){return $false} $chain=$data.SelectNodes('e:CertGetCertificateChain',$ns);if($chain.Count -ne 1){return $false};$chain=$chain[0] - $fields=@{};foreach($name in @('Certificate','Flags','ChainEngineInfo','CertificateChain','EventAuxInfo','Result')){$nodes=$chain.SelectNodes("e:$name",$ns);if($nodes.Count -ne 1){return $false};$fields[$name]=$nodes[0]} + $names=@('Certificate','ExtendedKeyUsage','URLRetrievalTimeout','Flags','ChainEngineInfo','CertificateChain','EventAuxInfo','CorrelationAuxInfo','Result') + if(-not(Test-WelaCapi2XmlChildren $chain $names)){return $false} + $fields=@{};foreach($name in $names){$nodes=$chain.SelectNodes("e:$name",$ns);if($nodes.Count -ne 1){return $false};$fields[$name]=$nodes[0]} + if($fields.ExtendedKeyUsage.HasChildNodes -or $fields.URLRetrievalTimeout.InnerText -cne 'PT1S' -or -not(Test-WelaCapi2XmlChildren $fields.CertificateChain @('TrustStatus','ChainElement'))){return $false} + foreach($flag in @('CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL','CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY','CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE','CERT_CHAIN_DISABLE_AIA')){if($fields.Flags.GetAttribute($flag) -cne 'true'){return $false}} + if($fields.EventAuxInfo.HasAttribute('impersonateToken') -and $fields.EventAuxInfo.GetAttribute('impersonateToken') -cne $Operation.BeforeToken.Sid){return $false} $cert=$fields.Certificate if($cert.GetAttribute('fileRef') -cne ($Operation.Thumbprint+'.cer') -or $cert.GetAttribute('subjectName') -cne ('WelaCapi2Probe_'+$Operation.Nonce) -or $fields.Flags.GetAttribute('value') -ine '80002104' -or $fields.ChainEngineInfo.GetAttribute('context') -cne 'user' -or $fields.EventAuxInfo.GetAttribute('ProcessName') -ine $Operation.ProcessName -or $fields.Result.GetAttribute('value') -ine '800B0109'){return $false} $error=$fields.CertificateChain.SelectNodes('e:TrustStatus/e:ErrorStatus',$ns);$elements=$fields.CertificateChain.SelectNodes('e:ChainElement',$ns) if($error.Count -ne 1 -or $error[0].GetAttribute('value') -cne '20' -or $elements.Count -ne 1){return $false} $elementCert=$elements[0].SelectNodes('e:Certificate',$ns);$elementError=$elements[0].SelectNodes('e:TrustStatus/e:ErrorStatus',$ns) if($elementCert.Count -ne 1 -or $elementCert[0].GetAttribute('fileRef') -cne $cert.GetAttribute('fileRef') -or $elementCert[0].GetAttribute('subjectName') -cne $cert.GetAttribute('subjectName') -or $elementError.Count -ne 1 -or $elementError[0].GetAttribute('value') -cne '20'){return $false} + if(-not(Test-WelaCapi2XmlChildren $elements[0] @('Certificate','SignatureAlgorithm','PublicKeyAlgorithm','TrustStatus','ApplicationUsage','IssuanceUsage'))){return $false} + $signature=$elements[0].SelectSingleNode('e:SignatureAlgorithm',$ns);$publicKey=$elements[0].SelectSingleNode('e:PublicKeyAlgorithm',$ns) + if($signature.GetAttribute('oid') -cne '1.2.840.113549.1.1.11' -or $signature.GetAttribute('hashName') -cne 'SHA256' -or $signature.GetAttribute('publicKeyName') -cne 'RSA' -or $publicKey.GetAttribute('oid') -cne '1.2.840.113549.1.1.1' -or $publicKey.GetAttribute('publicKeyLength') -cne '2048'){return $false} return $true }catch{return $false}finally{if($reader){$reader.Dispose()}} } diff --git a/tests/Capi2Probe.Tests.ps1 b/tests/Capi2Probe.Tests.ps1 index 3fbc035d..bf2cce06 100644 --- a/tests/Capi2Probe.Tests.ps1 +++ b/tests/Capi2Probe.Tests.ps1 @@ -9,7 +9,7 @@ function Reject([scriptblock]$Action,$Message){$failed=$false;try{&$Action|Out-N function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24)} $nonce='0123456789abcdef0123456789abcdef';$now=[DateTime]::UtcNow $token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='HOST\user';AuthenticationId='0x1234';AuthenticationType='NTLM';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()} -$state=[pscustomobject]@{Computer='HOST';Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}} +$state=[pscustomobject]@{Computer='HOST';Services=@([pscustomobject]@{Name='CryptSvc';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}} if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$rsa=[Security.Cryptography.RSACng]::new(2048)}else{$rsa=[Security.Cryptography.RSA]::Create();$rsa.KeySize=2048};$cert=$null try{ $request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) @@ -22,13 +22,13 @@ foreach($value in @($false,'true',$null)){$bad=Clone $operation;$bad.KeyEphemera foreach($field in @('Flags','ErrorStatus','Chains','Elements')){$bad=Clone $operation;$bad.Chain.$field=0;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject unexpected native chain $field"} $bad=Clone $operation;$bad.CompletedUtc=$now.AddMinutes(20).ToString('o');Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Certificate must cover operation.' Assert ([bool](Get-WelaCapi2ProbeStateKey $state)) 'Exact observed prerequisites accepted.' -foreach($edit in @({param($s)$s.Host.Build=19045},{param($s)$s.Host.UBR=$null},{param($s)$s.Host.ProductType=1},{param($s)$s.Host.Computer='OTHER'},{param($s)$s.Channel.Enabled=$false},{param($s)$s.Channel.Enabled='true'},{param($s)$s.Channel.Type='Analytical'},{param($s)$s.Channel.Provider='Other'},{param($s)$s.Channel.SecurityDescriptor=$null},{param($s)$s.Provider.Guid=[guid]::Empty.ToString()},{param($s)$s.Provider.Event11Versions=@(1)},{param($s)$s.Provider.Event11Versions=@(0,0)},{param($s)$s.Provider.LogNames=@('Security')})){$bad=Clone $state;&$edit $bad;Reject {Get-WelaCapi2ProbeStateKey $bad} 'Reject incomplete or unsupported prerequisites.'} +foreach($edit in @({param($s)$s.Services[0].Status='Stopped'},{param($s)$s.Services=@()},{param($s)$s.Host.Build=19045},{param($s)$s.Host.UBR=$null},{param($s)$s.Host.ProductType=1},{param($s)$s.Host.Computer='OTHER'},{param($s)$s.Channel.Enabled=$false},{param($s)$s.Channel.Enabled='true'},{param($s)$s.Channel.Type='Analytical'},{param($s)$s.Channel.Provider='Other'},{param($s)$s.Channel.SecurityDescriptor=$null},{param($s)$s.Provider.Guid=[guid]::Empty.ToString()},{param($s)$s.Provider.Event11Versions=@(1)},{param($s)$s.Provider.Event11Versions=@(0,0)},{param($s)$s.Provider.LogNames=@('Security')})){$bad=Clone $state;&$edit $bad;Reject {Get-WelaCapi2ProbeStateKey $bad} 'Reject incomplete or unsupported prerequisites.'} $xml=@" -11021120x400000000000000311Microsoft-Windows-CAPI2/OperationalHOST +11021120x400000000000000311Microsoft-Windows-CAPI2/OperationalHOSTPT1S "@ Assert (Test-WelaCapi2ProbeEvent $xml $operation $state) 'Exact source/certificate/PID/token/time/chain fixture matches.' $changes=@( - @('Name="Microsoft-Windows-CAPI2"','Name="Other"'),@('5bbca4a8-b209-48dc-a8c7-b23d3e5216fb','00000000-0000-0000-0000-000000000000'),@('11','70'),@('0','1'),@('2','4'),@('11','10'),@('2','1'),@('0x4000000000000003','0x4000000000000001'),@('11','10'),@('11','x'),@('ProcessID="5678"','ProcessID="5679"'),@( ('UserID="'+$token.Sid+'"'), 'UserID="S-1-5-18"'),@('HOST','OTHER'),@('80002104','80000104'),@('800B0109','0'),@('value="20"','value="0"'),@('context="user"','context="machine"'),@('ProcessName="pwsh.exe"','ProcessName="other.exe"'),@($operation.Thumbprint,('0'*40)),@($nonce,('f'*32)),@('',''),@('',''),@('0','00'),@('',''),@('',''),@(']>11','70'),@('0','1'),@('2','4'),@('11','10'),@('2','1'),@('0x4000000000000003','0x4000000000000001'),@('11','10'),@('11','x'),@('ProcessID="5678"','ProcessID="5679"'),@( ('UserID="'+$token.Sid+'"'), 'UserID="S-1-5-18"'),@('HOST','OTHER'),@('80002104','80000104'),@('800B0109','0'),@('value="20"','value="0"'),@('context="user"','context="machine"'),@('ProcessName="pwsh.exe"','ProcessName="other.exe"'),@($operation.Thumbprint,('0'*40)),@($nonce,('f'*32)),@('',''),@('',''),@('0','00'),@('',''),@('PT1S','PT2S'),@('CERT_CHAIN_DISABLE_AIA="true"','CERT_CHAIN_DISABLE_AIA="false"'),@('',''),@(']> Date: Mon, 21 Sep 2026 18:06:27 +0900 Subject: [PATCH 13/25] Require exact generated certificate DER bytes --- scripts/Capi2Probe.ps1 | 1 + tests/Capi2Probe.Tests.ps1 | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/Capi2Probe.ps1 b/scripts/Capi2Probe.ps1 index aef959d4..b7651196 100644 --- a/scripts/Capi2Probe.ps1 +++ b/scripts/Capi2Probe.ps1 @@ -48,6 +48,7 @@ function Assert-WelaCapi2ProbeCertificate { if($der.Length -lt 128 -or $der.Length -gt 8192){throw 'Certificate DER exceeds its evidence bound.'} $certificate=[Security.Cryptography.X509Certificates.X509Certificate2]::new($der) try{ + if([Convert]::ToBase64String($certificate.RawData) -cne $Operation.CertificateDerBase64){throw 'Public certificate evidence must contain exactly one canonical DER object.'} if($certificate.Subject -cne ('CN=WelaCapi2Probe_'+$Nonce) -or $certificate.Issuer -cne $certificate.Subject -or $Operation.Subject -cne $certificate.Subject -or $Operation.Thumbprint -cne $certificate.Thumbprint -or $certificate.Extensions.Count -ne 0 -or $certificate.HasPrivateKey -or $certificate.SignatureAlgorithm.Value -cne '1.2.840.113549.1.1.11' -or $certificate.PublicKey.Oid.Value -cne '1.2.840.113549.1.1.1'){throw 'Certificate DER does not describe the fixed ephemeral self-signed probe.'} $rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate) try{if($rsa.get_KeySize() -ne 2048){throw 'Unexpected probe RSA key size.'}}finally{$rsa.Dispose()} diff --git a/tests/Capi2Probe.Tests.ps1 b/tests/Capi2Probe.Tests.ps1 index bf2cce06..e9e136af 100644 --- a/tests/Capi2Probe.Tests.ps1 +++ b/tests/Capi2Probe.Tests.ps1 @@ -17,6 +17,7 @@ try{ $operation=[pscustomobject]@{Nonce=$nonce;CertificateDerBase64=[Convert]::ToBase64String($cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert));Subject=$cert.Subject;Thumbprint=$cert.Thumbprint;KeyEphemeral=$true;ProcessId=5678;ProcessName='pwsh.exe';StartedUtc=$now.AddSeconds(-1).ToString('o');CompletedUtc=$now.AddSeconds(1).ToString('o');Clock='GetSystemTimePreciseAsFileTime';RecordIdBefore=10;BeforeToken=$token;AfterToken=$token;Chain=[pscustomobject]@{Flags=2147492100;ErrorStatus=32;Chains=1;Elements=1}} $der=Assert-WelaCapi2ProbeCertificate $operation $nonce;Assert ($der.Length -gt 128) 'Generated test DER is validated.' }finally{if($cert){$cert.Dispose()};$rsa.Dispose()} +$bad=Clone $operation;$bad.CertificateDerBase64=[Convert]::ToBase64String(([byte[]]([Convert]::FromBase64String($operation.CertificateDerBase64)+[byte[]]@(0))));Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Reject trailing data after the actual certificate DER.' foreach($field in @('Nonce','Subject','Thumbprint','CertificateDerBase64')){$bad=Clone $operation;$bad.$field='wrong';Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject certificate $field mismatch"} foreach($value in @($false,'true',$null)){$bad=Clone $operation;$bad.KeyEphemeral=$value;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Ephemeral key evidence must be true Boolean.'} foreach($field in @('Flags','ErrorStatus','Chains','Elements')){$bad=Clone $operation;$bad.Chain.$field=0;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject unexpected native chain $field"} From ec21453cf24619552e709a53fb1d95b4fe44960b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:11:13 +0900 Subject: [PATCH 14/25] Bind strict receipt parser into CAPI2 source evidence --- .gitattributes | 2 ++ scripts/Capi2Probe.ps1 | 2 +- tests/Capi2Probe.Tests.ps1 | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index fd80ff1a..dce06776 100644 --- a/.gitattributes +++ b/.gitattributes @@ -61,3 +61,5 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/Capi2Probe* text eol=lf /tests/Capi2Probe* text eol=lf + +/scripts/CustomAuditProfiles.ps1 text eol=lf diff --git a/scripts/Capi2Probe.ps1 b/scripts/Capi2Probe.ps1 index b7651196..c06e5947 100644 --- a/scripts/Capi2Probe.ps1 +++ b/scripts/Capi2Probe.ps1 @@ -7,7 +7,7 @@ function Initialize-WelaCapi2ProbeNative { } function Get-WelaCapi2ProbeSources { $sources=[ordered]@{} - foreach($name in @('WELA.ps1','scripts/Capi2Probe.ps1','scripts/Capi2ProbeWorker.ps1','scripts/Capi2ProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256).Hash.ToLowerInvariant()} + foreach($name in @('WELA.ps1','scripts/Capi2Probe.ps1','scripts/Capi2ProbeWorker.ps1','scripts/Capi2ProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256).Hash.ToLowerInvariant()} $sources|ConvertTo-Json -Compress } function Get-WelaCapi2ProbeChannel { diff --git a/tests/Capi2Probe.Tests.ps1 b/tests/Capi2Probe.Tests.ps1 index e9e136af..d61734ab 100644 --- a/tests/Capi2Probe.Tests.ps1 +++ b/tests/Capi2Probe.Tests.ps1 @@ -7,6 +7,8 @@ $count=0 function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} function Reject([scriptblock]$Action,$Message){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed $Message} function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24)} +$sources=Get-WelaCapi2ProbeSources|ConvertFrom-Json +Assert ($sources.'scripts/CustomAuditProfiles.ps1' -ceq (Get-FileHash -LiteralPath "$repo/scripts/CustomAuditProfiles.ps1" -Algorithm SHA256).Hash.ToLowerInvariant()) 'Strict worker-receipt parser implementation is included in source identity.' $nonce='0123456789abcdef0123456789abcdef';$now=[DateTime]::UtcNow $token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='HOST\user';AuthenticationId='0x1234';AuthenticationType='NTLM';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()} $state=[pscustomobject]@{Computer='HOST';Services=@([pscustomobject]@{Name='CryptSvc';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}} From dd950c7358b7ecf654ad05d4d7b4c83543013ea4 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:11:21 +0900 Subject: [PATCH 15/25] Reject boolean coercion in completed recovery evidence --- scripts/FirewallLoggingRecovery.ps1 | 16 ++++++++-------- tests/FirewallLoggingRecovery.Tests.ps1 | 6 +++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 index b4b224bd..ae70db7b 100644 --- a/scripts/FirewallLoggingRecovery.ps1 +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -135,27 +135,27 @@ function Read-WelaFirewallRecoveryEvidence { $journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath $entries=@($journal.Text -split '\r?\n' | Where-Object {$_ -match '\S'} | ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) $results=ConvertFrom-WelaRecoveryJson $resultFile.Text - if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'} + if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -isnot [string] -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'} $seen=@{};$final=@{} foreach($entry in $entries){ - if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -cne 'FirewallTextLog' -or + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'FirewallTextLog' -or $entry.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $seen.ContainsKey($entry.Id) -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $Computer){throw 'Unknown, duplicate or wrong-host firewall journal entry.'} if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Journal timestamp is in the future.'} $seen[$entry.Id]=$entry } foreach($row in $results.Results){ - if($row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'} + if($row.Kind -isnot [string] -or $row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'} $final[$row.Id]=$row } $id="FirewallTextLog/$Profile" - if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'} + if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -isnot [string] -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'} $entry=$seen[$id];$row=$final[$id] foreach($field in @('Before','Desired','Target')){if((Get-WelaFirewallRecoveryKey $entry.$field) -cne (Get-WelaFirewallRecoveryKey $row.$field)){throw "Journal/result $field mismatch."}} Assert-WelaArrivalObject $entry.Target @('Name','PolicyStore') - if($entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'} + if($entry.Target.Name -isnot [string] -or $entry.Target.PolicyStore -isnot [string] -or $entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'} Assert-WelaArrivalObject $entry.Desired @('LogAllowed','LogBlocked','MinimumSizeKiB','LogFileName','PathMode') $desired=$entry.Desired - if($desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'} + if($desired.LogAllowed -isnot [string] -or $desired.LogBlocked -isnot [string] -or $desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'} foreach($snapshot in @($entry.Before.Local,$entry.Before.Effective,$row.After.Local,$row.After.Effective)){ $null=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot if($snapshot.Name -cne $Profile){throw 'Original snapshot profile differs from selected profile.'} @@ -169,7 +169,7 @@ function Read-WelaFirewallRecoveryEvidence { $desiredPath=Resolve-WelaFirewallRecoveryLogPath $desired.LogFileName $plannedPath=if($desired.PathMode -ceq 'CisV4'){Resolve-WelaFirewallRecoveryLogPath $path}else{Resolve-WelaFirewallRecoveryLogPath $entry.Before.Effective.LogFileName} if($desiredPath -ine $plannedPath -or $effective.LogAllowed -cne 'True' -or $effective.LogBlocked -cne 'True' -or $effective.LogMaxSizeKilobytes -lt $desired.MinimumSizeKiB -or - (Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'} + (Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -isnot [string] -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'} if((Get-WelaFirewallRecoveryKey $before) -ceq (Get-WelaFirewallRecoveryKey $expected)){throw 'Selected evidence records no local logging change.'} [pscustomobject][ordered]@{Id=$id;Profile=$Profile;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Hash};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Hash};Expected=$expected;RecoverTo=$before} } @@ -212,7 +212,7 @@ function Invoke-WelaFirewallLoggingRecovery { if($source.Hash -cne $PlanHash){throw 'Reviewed plan SHA256 differs from the selected file.'} $plan=ConvertFrom-WelaRecoveryJson $source.Text Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Profile','Control','State','HistoricalIdentity') - if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'} + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -isnot [string] -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'} $report.PlanSha256=$source.Hash Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash $current=Get-WelaFirewallRecoveryState;$report.Before=$current diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index f63a160d..b1baaa9b 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -71,7 +71,11 @@ try { foreach($path in @('\\host\share\log','C:\Logs\..\other.log','C:\Logs\log:stream','C:\Logs\*.log','%TEMP%\log','C:relative.log','C:\Logs\','C:\Logs\CON.log','C:\Logs\log.','C:\Logs\log ','C:\Logs\\log')){Throws {Resolve-WelaFirewallRecoveryLogPath $path} 'path|unsupported|streams'} $v=Copy-Fixture $e.RecoverTo;$v|Add-Member Extra 1;Throws {ConvertTo-WelaFirewallRecoveryTuple $v} 'Unexpected' foreach($change in @( - {$script:row.Status='Failed'},{$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, + {$script:row.Status='Failed'},{$script:row.Status=$true},{$script:row.After.Access.State=$true}, + {$script:entry.Target.Name=$true;$script:row.Target=Copy-Fixture $entry.Target}, + {$script:entry.Target.PolicyStore=$true;$script:row.Target=Copy-Fixture $entry.Target}, + {$script:entry.Desired.LogAllowed=$true;$script:row.Desired=Copy-Fixture $entry.Desired}, + {$script:entry.Target.PolicyStore='ActiveStore';$script:row.Target=Copy-Fixture $entry.Target}, {$script:row.After.Local.LogMaxSizeKilobytes=20000},{$script:row.After.Local.LogFileName='C:\Other.log'}, {$script:row.After.Access.State='Unknown'},{$script:entry.ComputerName='OTHER'}, {$script:row.Before.Local.LogBlocked='True'},{$script:entry.Desired.MinimumSizeKiB='16384';$script:row.Desired=Copy-Fixture $entry.Desired} From 3ee0d7b6bd61ff797906c3eeec737c2752cadbfc Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:14:13 +0900 Subject: [PATCH 16/25] Require typed completion and identity fields in recovery evidence --- scripts/EventLogRecovery.ps1 | 13 +++++++++++++ tests/EventLogRecovery.Tests.ps1 | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/scripts/EventLogRecovery.ps1 b/scripts/EventLogRecovery.ps1 index 59a672e9..e42424e1 100644 --- a/scripts/EventLogRecovery.ps1 +++ b/scripts/EventLogRecovery.ps1 @@ -22,8 +22,13 @@ function Read-WelaEventRecoveryChannel { } }finally{$channel.Dispose()} } +function Assert-WelaEventRecoveryText { + param($Value,[string[]]$Names) + foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped recovery text field: '+$name)}} +} function Assert-WelaEventRecoveryState { param($State,[string]$Log) + Assert-WelaEventRecoveryText $State @('Log','ReadStatus','Diagnostic','LogMode') if($State.Log -cne $Log -or $State.ReadStatus -cne 'Available' -or $State.Diagnostic -cne '' -or $State.IsEnabled -isnot [bool] -or ($State.MaximumSizeInBytes -isnot [int] -and $State.MaximumSizeInBytes -isnot [long]) -or $State.MaximumSizeInBytes -lt 1048576 -or $State.MaximumSizeInBytes -gt 2199023255552 -or $State.MaximumSizeInBytes % 65536 -ne 0 -or $State.LogMode -cnotin @('Circular','Retain','AutoBackup')){throw 'Original channel state is unavailable, mistyped or unsupported.'} } @@ -37,23 +42,30 @@ function Get-WelaEventRecoveryDefinition { $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 $resultFile.Text + Assert-WelaEventRecoveryText $result @('Scope') if($result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -gt 2048 -or $result.Scope -cnotin @('native-windows-configuration','event-log-size-and-mode-only')){throw 'Expected original non-dry-run event-log configuration results.'} $id='EventLog/'+$Log+'/ProfileSettings' $rows=@($result.Results|Where-Object Id -eq $id);$matching=@($entries|Where-Object Id -eq $id) if($rows.Count -ne 1 -or $matching.Count -ne 2){throw 'Exactly one result and its original/immediate-prewrite journal pair are required.'} $row=$rows[0];$initial=$matching[0];$fresh=$matching[1] + Assert-WelaEventRecoveryText $row @('Status','Kind','Id') + Assert-WelaEventRecoveryText $fresh @('Phase') if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'} foreach($entry in $matching){ + Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id','RecordedUtc') if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'} $time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'} } if((ConvertTo-WelaArrivalUtc $fresh.RecordedUtc) -lt (ConvertTo-WelaArrivalUtc $initial.RecordedUtc)){throw 'Journal times are reversed.'} foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $initial.$field) -cne (Get-WelaRecoveryKey $row.$field)){throw "Original/result $field differs."}} Assert-WelaArrivalObject $initial.Target @('Log','Profile');Assert-WelaArrivalObject $fresh.Target @('Log') + Assert-WelaEventRecoveryText $initial.Target @('Log','Profile');Assert-WelaEventRecoveryText $fresh.Target @('Log') if($initial.Target.Log -cne $Log -or $fresh.Target.Log -cne $Log -or $initial.Target.Profile -isnot [string]){throw 'Contradictory channel identity.'} $profile=Get-WelaEventLogProfile $initial.Target.Profile;$control=@($profile.controls|Where-Object log -ceq $Log) if($control.Count -ne 1){throw 'Channel is not selected by the original bundled profile.'} Assert-WelaArrivalObject $initial.Desired @('MaximumSizeInBytes','SizeMode','LogMode') + Assert-WelaEventRecoveryText $initial.Desired @('SizeMode');Assert-WelaEventRecoveryText $fresh.Desired @('SizeMode') + if($null -ne $initial.Desired.LogMode){Assert-WelaEventRecoveryText $initial.Desired @('LogMode')} if((Get-WelaRecoveryKey $initial.Desired) -cne (Get-WelaRecoveryKey $fresh.Desired) -or $initial.Desired.SizeMode -cnotin @('Exact','Minimum') -or ($null -ne $initial.Desired.LogMode -and $initial.Desired.LogMode -cne $control[0].mode) -or ($initial.Desired.MaximumSizeInBytes -isnot [int] -and $initial.Desired.MaximumSizeInBytes -isnot [long]) -or $initial.Desired.MaximumSizeInBytes -ne (ConvertTo-WelaEventLogBytes $control[0].minimumBytes)){throw 'Desired configuration differs from the canonical profile operation.'} foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log} @@ -111,6 +123,7 @@ function Invoke-WelaEventLogRecovery { }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-WelaEventRecoveryText $plan @('Kind','ContextKey','Sources') if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaEventLogRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or code differs.'} $definition=Get-WelaEventRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Log if((Get-WelaRecoveryKey $definition) -cne (Get-WelaRecoveryKey $plan.Definition)){throw 'Recovery plan differs from independently rebuilt original evidence.'} diff --git a/tests/EventLogRecovery.Tests.ps1 b/tests/EventLogRecovery.Tests.ps1 index b4099f80..b487bcec 100644 --- a/tests/EventLogRecovery.Tests.ps1 +++ b/tests/EventLogRecovery.Tests.ps1 @@ -53,5 +53,25 @@ try { }elseif($case -in @('native-fail','false-success','preservation')){Assert ($restore.Status -eq 'RestoreAttemptedUnverified' -and $script:writes -eq 1) 'Partial failure explicit'} else{Assert ($script:writes -eq 0 -and -not $restore.NativeWriteAttempted) 'Refusal occurs before write'} } + # Reject PowerShell boolean-to-string comparison coercion in completed evidence. + $goodResult=[IO.File]::ReadAllText("$root/ok/original.json");$goodJournal=[IO.File]::ReadAllText("$root/ok/journal/before.jsonl") + foreach($field in @('Status','Kind','Id','Scope','ComputerName','Phase','StateLog','ReadStatus','TargetLog','DesiredMode')){ + $r=ConvertFrom-WelaArrivalJson $goodResult;$j=@($goodJournal -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_}) + switch($field){ + Status {$r.Results[0].Status=$true} + Kind {$r.Results[0].Kind=$true} + Id {$r.Results[0].Id=$true} + Scope {$r.Scope=$true} + ComputerName {$j[0].ComputerName=$true} + Phase {$j[1].Phase=$true} + StateLog {$r.Results[0].After.Log=$true} + ReadStatus {$r.Results[0].After.ReadStatus=$true} + TargetLog {$j[0].Target.Log=$true;$r.Results[0].Target.Log=$true} + DesiredMode {$j[0].Desired.SizeMode=$true;$r.Results[0].Desired.SizeMode=$true} + } + $r|ConvertTo-Json -Depth 20|Set-Content "$root/typed-result.json" + @($j|ForEach-Object {$_|ConvertTo-Json -Depth 20 -Compress})|Set-Content "$root/typed-journal.jsonl" + Reject {Get-WelaEventRecoveryDefinition "$root/typed-journal.jsonl" "$root/typed-result.json" ForwardedEvents} 'mistyped recovery text|Exactly one result' + } }finally{$env:COMPUTERNAME=$oldComputer;Remove-Item $root -Recurse -Force} Write-Host "Event-log recovery passed: $count assertions." From 83ab9c875239d426f26f93e283122f635b27cbce Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:15:02 +0900 Subject: [PATCH 17/25] Require running firewall providers before recovery observations --- docs/firewall-logging-recovery.md | 2 +- scripts/FirewallLoggingRecovery.ps1 | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/firewall-logging-recovery.md b/docs/firewall-logging-recovery.md index 4dfcaba8..370b57ba 100644 --- a/docs/firewall-logging-recovery.md +++ b/docs/firewall-logging-recovery.md @@ -8,7 +8,7 @@ The restored fields are `LogAllowed`, `LogBlocked`, `LogMaxSizeKilobytes` and `L Keep the genuine original `before.jsonl` and final results from [firewall logging configuration](firewall-logging.md). The selected row must have final status `Applied`, dedicated scope `firewall-text-logging-only`, a matching version-1 journal entry and matching original Before/Desired/Target values. Failed, partial, ambiguous and no-op operations are not automatically recoverable. -Use elevated native 64-bit Windows PowerShell 5.1 or PowerShell 7 on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. The plan and restoration must use the same engine version, machine identity and actual elevated operator/logon context. Impersonation is refused. Original version-1 configuration journals recorded only the computer name, so they do **not** prove historical MachineGuid or operator identity. The operator must establish that the original evidence belongs to this installation; current identity binding starts with the recovery plan. +Use elevated native 64-bit Windows PowerShell 5.1 or PowerShell 7 on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Winmgmt, MpsSvc and BFE must already be running before native provider reads. The plan and restoration must use the same engine version, machine identity and actual elevated operator/logon context. Impersonation is refused. Original version-1 configuration journals recorded only the computer name, so they do **not** prove historical MachineGuid or operator identity. The operator must establish that the original evidence belongs to this installation; current identity binding starts with the recovery plan. Create new local output directories under an existing parent, outside the WELA source tree. WELA applies private output permissions and never overwrites an old evidence directory. diff --git a/scripts/FirewallLoggingRecovery.ps1 b/scripts/FirewallLoggingRecovery.ps1 index ae70db7b..06926410 100644 --- a/scripts/FirewallLoggingRecovery.ps1 +++ b/scripts/FirewallLoggingRecovery.ps1 @@ -39,6 +39,10 @@ function Get-WelaFirewallRecoverySources { function Get-WelaFirewallRecoveryContext { $reader=Get-WelaChannelReader if(-not $reader.ElevatedAdministrator){throw 'Firewall recovery requires the actual non-impersonated elevated administrator.'} + # Observe service state before connecting to native WMI/NetSecurity providers. + # A read must not be used to start prerequisites implicitly. + $services=@(Get-Service -Name Winmgmt,MpsSvc,BFE -ErrorAction Stop | Sort-Object Name | ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services | Where-Object Status -cne 'Running').Count){throw 'Winmgmt, MpsSvc and BFE must already be running; recovery starts no services.'} $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop $build=[int]$os.BuildNumber @@ -48,7 +52,7 @@ function Get-WelaFirewallRecoveryContext { $guid=[guid]::Empty if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'} $revision=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR -ErrorAction Stop - [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain;Services=$services Reader=[pscustomobject]@{UserSid=$reader.UserSid;UserName=$reader.UserName;AuthenticationId=$reader.AuthenticationId;GroupSids=$reader.GroupSids;ElevatedAdministrator=$reader.ElevatedAdministrator;Impersonation=$reader.Impersonation} Engine=$PSVersionTable.PSVersion.ToString()} } From e4e60684ac27da775fa0db28ae2e28fd3f4b2335 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:15:30 +0900 Subject: [PATCH 18/25] Verify stopped providers are refused before connecting --- tests/FirewallLoggingRecovery.Tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/FirewallLoggingRecovery.Tests.ps1 b/tests/FirewallLoggingRecovery.Tests.ps1 index b1baaa9b..e78cb012 100644 --- a/tests/FirewallLoggingRecovery.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Tests.ps1 @@ -105,6 +105,15 @@ try { $cim=[pscustomobject]@{CimClass=[pscustomobject]@{CimClassName='MSFT_NetFirewallRule'};CimInstanceProperties=@([pscustomobject]@{Name='Enabled';Value=1;CimType='UInt16'},[pscustomobject]@{Name='Status';Value='volatile';CimType='String'})} $key=ConvertTo-WelaFirewallRecoveryCim $cim @('Status');Assert ($key.Enabled.Type -eq 'UInt16' -and -not $key.PSObject.Properties['Status']) 'Rule hash preserves typed configuration while excluding named diagnostics' $cim.CimInstanceProperties[0].Value=[DateTime]::UtcNow;Throws {ConvertTo-WelaFirewallRecoveryCim $cim} 'Unsupported native property type' + # A prerequisite read must not connect to WMI while its services are stopped. + $script:providerReads=0;$script:serviceStatus='Stopped' + function Get-WelaChannelReader {[pscustomobject]@{ElevatedAdministrator=$true}} + function Get-Service {param($Name,$ErrorAction) foreach($n in $Name){[pscustomobject]@{Name=$n;Status=$script:serviceStatus}}} + function Get-CimInstance {$script:providerReads++;throw 'Native provider boundary reached'} + Throws {Get-WelaFirewallRecoveryContext} 'must already be running' + Assert ($providerReads -eq 0) 'Stopped services are refused before any native provider connection' + $script:serviceStatus='Running';Throws {Get-WelaFirewallRecoveryContext} 'Native provider boundary reached' + Assert ($providerReads -eq 1) 'Running services permit the first native provider read' } finally {Remove-Item -LiteralPath $root -Recurse -Force} $global:LASTEXITCODE=0 Write-Host "Firewall logging recovery: $script:assertions assertions passed." From 6c6edc7a6894a19273a70d6b76933676efc565ee Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:15:45 +0900 Subject: [PATCH 19/25] Preserve supported UTC timestamp parsing in recovery history --- scripts/EventLogRecovery.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/EventLogRecovery.ps1 b/scripts/EventLogRecovery.ps1 index e42424e1..36e8bd5a 100644 --- a/scripts/EventLogRecovery.ps1 +++ b/scripts/EventLogRecovery.ps1 @@ -52,7 +52,7 @@ function Get-WelaEventRecoveryDefinition { Assert-WelaEventRecoveryText $fresh @('Phase') if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'} foreach($entry in $matching){ - Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id','RecordedUtc') + Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id') if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'} $time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'} } From fa720f1454c0b0dc3c0df66c9bd53cfc8f6d264f Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:17:23 +0900 Subject: [PATCH 20/25] Bind the reviewed native host gate and context dependency --- docs/eventlog-recovery.md | 2 +- scripts/EventLogRecovery.ps1 | 5 +++-- tests/EventLogRecovery.Tests.ps1 | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/eventlog-recovery.md b/docs/eventlog-recovery.md index ee986922..4337b580 100644 --- a/docs/eventlog-recovery.md +++ b/docs/eventlog-recovery.md @@ -19,7 +19,7 @@ Use the original `before.jsonl` and final results from `configure-eventlogs` or The last two switches are separate consent for the effects actually identified by the plan. Omit them when inapplicable. **Shrinking can discard existing events.** Changing to Circular allows older records to be overwritten; changing to Retain can discard incoming records when full; leaving AutoBackup stops automatic archival. Review storage and recovery requirements before consenting. Plan writes review evidence but changes no Windows settings. Restore does not export or clear logs, restore an archive, alter channel enablement/ACL/path/provider settings or restart services. -Each output must be a fresh directory on a local fixed drive, with an existing parent. Evidence is protected for the current operator, Administrators and SYSTEM. The plan is bound to the actual current host/MachineGuid, operator logon, original input bytes and implementation/catalog hashes. Use the same checkout and elevated operator logon for Restore. The original version-1 journal records only historical ComputerName: current host bindings and hashes do not authenticate that history. +Each output must be a fresh directory on a local fixed drive, with an existing parent. Evidence is protected for the current operator, Administrators and SYSTEM. The plan is bound to the actual current host/MachineGuid, operator logon, original input bytes and implementation/catalog hashes. Use the same checkout and elevated operator logon for Restore. Winmgmt and EventLog must already be running; host observations use the existing reviewed-build gate. The original version-1 journal records only historical ComputerName: current host bindings and hashes do not authenticate that history. The plan is rebuilt from original evidence on Restore. Minimum-size writes are checked against the immediate-prewrite size so an independent increase during prompting is preserved. An unexplained larger final size is refused. Current size/mode/enable state must match the confirmed post-configuration state. Current channel path, ACL, isolation, type, owning provider and classic-log flag are captured when planning and must remain unchanged. Live event count and EVTX file allocation are intentionally not treated as configuration guards. diff --git a/scripts/EventLogRecovery.ps1 b/scripts/EventLogRecovery.ps1 index 36e8bd5a..1bceaeb4 100644 --- a/scripts/EventLogRecovery.ps1 +++ b/scripts/EventLogRecovery.ps1 @@ -1,15 +1,16 @@ # Restore one completed profile size/mode write; never replay arbitrary wevtutil arguments. function Get-WelaEventRecoverySources { $sources=[ordered]@{} - foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){ + foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/AuditRecovery.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() } $sources|ConvertTo-Json -Compress } function Get-WelaEventRecoveryContext { + foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}} $reader=Get-WelaChannelReader if(-not $reader.ElevatedAdministrator){throw 'An elevated native Windows operator is required.'} - [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}} + [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}} } function Read-WelaEventRecoveryChannel { param([string]$Log) diff --git a/tests/EventLogRecovery.Tests.ps1 b/tests/EventLogRecovery.Tests.ps1 index b487bcec..08b92b9b 100644 --- a/tests/EventLogRecovery.Tests.ps1 +++ b/tests/EventLogRecovery.Tests.ps1 @@ -10,6 +10,8 @@ Import-Module "$repo/modules/EventLogSettings.psm1" -Force $count=0 function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +$sources=ConvertFrom-WelaArrivalJson (Get-WelaEventRecoverySources) +Assert ($sources.'scripts/ControlApplicability.ps1' -ceq (Get-FileHash "$repo/scripts/ControlApplicability.ps1").Hash.ToLowerInvariant()) 'Actual host identity/context implementation is fingerprinted.' $root=Join-Path ([IO.Path]::GetTempPath()) ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root $oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST' function Get-WelaEventRecoveryContext {[pscustomobject][ordered]@{Host=[ordered]@{Computer='TEST';MachineGuid='1'};Reader='S-1-5-21-fixture'}} From 22d3a131807e49fe586819054dd4c38cd5fa52e7 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:19:57 +0900 Subject: [PATCH 21/25] Reject coerced transcription recovery history discriminators --- docs/transcription-recovery.md | 2 +- scripts/TranscriptionRecovery.ps1 | 30 +++++++++------ tests/TranscriptionRecovery.Tests.ps1 | 54 ++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/docs/transcription-recovery.md b/docs/transcription-recovery.md index 7c38673b..ff4386c6 100644 --- a/docs/transcription-recovery.md +++ b/docs/transcription-recovery.md @@ -1,6 +1,6 @@ # Recover Windows PowerShell transcription policy -`transcription-recovery` reviews and restores the two machine values changed by one completed `powershell-transcription -TranscriptionAction Configure` run. It requires that run's original `before.jsonl` and final JSON result, exactly one `Applied` control named `PowerShellTranscription/CisV4L2`, and current policy/directory observations that still match its final `After` evidence. Failed, partial, skipped and already-compliant configuration records require manual review. +`transcription-recovery` reviews and restores the two machine values changed by one completed `powershell-transcription -TranscriptionAction Configure` run. It requires that run's original `before.jsonl` and final JSON result, exactly one `Applied` control named `PowerShellTranscription/CisV4L2`, and current policy/directory observations that still match its final `After` evidence. Status, control, target and registry-type discriminators require actual strings; schema and outcome counters require integers. Boolean values cannot stand in for those fields. Failed, partial, skipped and already-compliant configuration records require manual review. ```powershell ./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Plan ` diff --git a/scripts/TranscriptionRecovery.ps1 b/scripts/TranscriptionRecovery.ps1 index 3bf45a38..ea21bf7b 100644 --- a/scripts/TranscriptionRecovery.ps1 +++ b/scripts/TranscriptionRecovery.ps1 @@ -69,8 +69,8 @@ function Assert-WelaTranscriptRecoveryValue { if($null -ne $Value.Type -or $null -ne $Value.Value){throw 'Absent transcription value has inconsistent state.'} } elseif(-not $Value.KeyExists){throw 'A present transcription value requires an existing key.'} elseif($Name -eq 'EnableTranscripting') { - if($Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)){throw 'Only DWORD 0/1 or absent enablement can be restored; other types require manual recovery.'} - } elseif($Value.Type -cne 'String' -or $Value.Value -isnot [string] -or -not $Value.Value){throw 'Only a nonempty REG_SZ or absent output directory can be restored.'} + if($Value.Type -isnot [string] -or $Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)){throw 'Only DWORD 0/1 or absent enablement can be restored; other types require manual recovery.'} + } elseif($Value.Type -isnot [string] -or $Value.Type -cne 'String' -or $Value.Value -isnot [string] -or -not $Value.Value){throw 'Only a nonempty REG_SZ or absent output directory can be restored.'} } function Get-WelaTranscriptRecoveryTypedKey { param($Value) @@ -91,20 +91,26 @@ function New-WelaTranscriptRecoveryPlan { $journal=Read-WelaTranscriptRecoveryFile $JournalPath;$resultFile=Read-WelaTranscriptRecoveryFile $OriginalResultsPath $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) $results=ConvertFrom-WelaRecoveryJson $resultFile.Text + foreach($field in @('ExitCode','Failed','Skipped')) { + if(($results.$field -isnot [int] -and $results.$field -isnot [long]) -or $results.$field -ne 0){throw 'Completed transcription history requires integer zero exit/failure/skipped counters.'} + } if($entries.Count -ne 1 -or $results.Results -isnot [array] -or $results.Results.Count -ne 1 -or $results.DryRun -isnot [bool] -or $results.DryRun -or - $results.ExitCode -ne 0 -or $results.Failed -ne 0 -or $results.Skipped -ne 0 -or $results.Action -cne 'Configure' -or $results.Scope -cne 'windows-powershell-transcription-policy-only'){throw 'Recovery requires one completed Applied transcription Configure journal/result, without other controls or partial outcomes.'} + $results.Action -isnot [string] -or $results.Action -cne 'Configure' -or $results.Scope -isnot [string] -or $results.Scope -cne 'windows-powershell-transcription-policy-only'){throw 'Recovery requires one completed Applied transcription Configure journal/result, without other controls or partial outcomes.'} $entry=$entries[0];$last=$results.Results[0] - if($entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or - $entry.Kind -cne 'PowerShellTranscription' -or $last.Status -cne 'Applied' -or $last.Id -cne $entry.Id -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'} - $time=[datetimeoffset]::MinValue - if($entry.RecordedUtc -isnot [string] -or $entry.RecordedUtc -notmatch '(Z|\+00:00)$' -or -not [datetimeoffset]::TryParse($entry.RecordedUtc,[ref]$time) -or $time -gt [datetimeoffset]::UtcNow.AddMinutes(1)){throw 'Original journal requires a valid UTC timestamp.'} + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or + $entry.Id -isnot [string] -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'PowerShellTranscription' -or + $last.Status -isnot [string] -or $last.Status -cne 'Applied' -or $last.Id -isnot [string] -or $last.Id -cne $entry.Id -or $last.Kind -isnot [string] -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'} + $time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc + if($time -gt [datetimeoffset]::UtcNow.AddMinutes(1)){throw 'Original journal requires a valid UTC timestamp.'} foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $entry.$field) -cne (Get-WelaRecoveryKey $last.$field)){throw "Original journal/result $field differs."}} - if($entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or $entry.Desired.EnableTranscripting.Value -ne 1 -or - $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or $entry.Desired.EnableInvocationHeader -cne 'Preserve'){throw 'Unsupported transcription target or desired state.'} + if($entry.Target.Hive -isnot [string] -or $entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -isnot [string] -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Target.OutputDirectory -isnot [string] -or + $entry.Desired.EnableTranscripting.Type -isnot [string] -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or ($entry.Desired.EnableTranscripting.Value -isnot [int] -and $entry.Desired.EnableTranscripting.Value -isnot [long]) -or $entry.Desired.EnableTranscripting.Value -ne 1 -or + $entry.Desired.OutputDirectory.Type -isnot [string] -or $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -isnot [string] -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or + $entry.Desired.EnableInvocationHeader -isnot [string] -or $entry.Desired.EnableInvocationHeader -cne 'Preserve'){throw 'Unsupported transcription target or desired state.'} $before=$entry.Before;$after=$last.After foreach($snapshot in @($before,$after)) { - if($snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or - $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -cne 'Registry32'){ + if($snapshot.Capability.Status -isnot [string] -or $snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or + $snapshot.Policy[0].View -isnot [string] -or $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -isnot [string] -or $snapshot.Policy[1].View -cne 'Registry32'){ $policyType=if($null -eq $snapshot.Policy){''}else{$snapshot.Policy.GetType().FullName} throw "Both canonical shared registry views are required. Capability=$($snapshot.Capability.Status); PolicyType=$policyType; Count=$(@($snapshot.Policy).Count); Views=$(@($snapshot.Policy.View) -join ','); Observation=$(Get-WelaRecoveryKey $snapshot)" } @@ -195,7 +201,7 @@ function Invoke-WelaTranscriptRecovery { $source=Read-WelaTranscriptRecoveryFile $PlanPath if($source.Sha256 -cne $PlanHash){throw 'Supplied reviewed plan hash differs.'} $plan=ConvertFrom-WelaRecoveryJson $source.Text - if($plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or $plan.SchemaVersion -ne 1){throw 'Unsupported transcription recovery plan.'} + if($plan.Kind -isnot [string] -or $plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or ($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1){throw 'Unsupported transcription recovery plan.'} $rebuilt=New-WelaTranscriptRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path if((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)){throw 'Reviewed plan differs from independently rebuilt original evidence and current observations.'} Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy $source.Path $source.Sha256 diff --git a/tests/TranscriptionRecovery.Tests.ps1 b/tests/TranscriptionRecovery.Tests.ps1 index 30f8b086..b7b471f6 100644 --- a/tests/TranscriptionRecovery.Tests.ps1 +++ b/tests/TranscriptionRecovery.Tests.ps1 @@ -2,9 +2,18 @@ $ErrorActionPreference='Stop' $script:ScriptRoot=Split-Path $PSScriptRoot -Parent . (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1') . (Join-Path $script:ScriptRoot 'scripts/AuditRecovery.ps1') +. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1') . (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1') . (Join-Path $script:ScriptRoot 'scripts/TranscriptionRecovery.ps1') $script:artifactWriter=(Get-Command Write-WelaRecoveryArtifact).ScriptBlock +$script:jsonReader=(Get-Command ConvertFrom-WelaRecoveryJson).ScriptBlock +function ConvertFrom-WelaRecoveryJson { + param($Text) + $value=& $script:jsonReader $Text + # Older PowerShell 7 JSON readers materialize an explicit UTC timestamp. + if($script:legacyJsonDate -and $value.RecordedUtc -is [string]){$value.RecordedUtc=[datetime]::Parse($value.RecordedUtc,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind)} + $value +} function Write-WelaRecoveryArtifact { param($Path,$Value) if($script:failArtifact -and [IO.Path]::GetFileName($Path) -eq $script:failArtifact){throw 'injected durable artifact failure'} @@ -34,7 +43,7 @@ function Set-WelaTranscriptRecoveryValue { } function Read-Host {param($Prompt) if($script:promptDrift){$script:policy[0].Machine.EnableInvocationHeader=Typed 1;$script:policy[1].Machine.EnableInvocationHeader=Typed 1};'y'} function New-Fixture($Enable=1,$Directory='C:\Old') { - $script:machine='stable';$script:code='stable';$script:acl='private';$script:protected=@('module','script-block','unrelated');$script:writes=0;$script:failWrite=-1;$script:driftWrite=-1;$script:promptDrift=$false;$script:failArtifact=$null + $script:machine='stable';$script:code='stable';$script:acl='private';$script:protected=@('module','script-block','unrelated');$script:writes=0;$script:failWrite=-1;$script:driftWrite=-1;$script:promptDrift=$false;$script:failArtifact=$null;$script:legacyJsonDate=$false $script:fixture=Join-Path $root ([guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $script:fixture $beforePolicy=@(foreach($view in @('Registry64','Registry32')){[pscustomobject]@{View=$view;Machine=[pscustomobject]@{EnableTranscripting=(Typed $Enable);OutputDirectory=(Typed $Directory String);EnableInvocationHeader=(Typed 0)};CurrentUser=[pscustomobject]@{EnableTranscripting=(Typed $null);OutputDirectory=(Typed $null);EnableInvocationHeader=(Typed $null)}}}) $script:policy=Copy-Value $beforePolicy @@ -102,6 +111,49 @@ try { Reject {Plan-Fixture} 'history|Applied|differs|DWORD|shared|Shared' Assert ($script:writes -eq 0) 'unsupported or inconsistent source evidence never mutates' } + foreach($alter in @('status','action','scope','id','kind','result-id','result-kind','version','exit','failed-count','skipped-count','hive','subkey','target-directory','desired-enable-type','desired-enable-value','desired-output-type','desired-output-value','header-intent','capability','view64','view32','before-enable-type','before-output-type')) { + New-Fixture + switch($alter){ + 'status' {$script:original.Results[0].Status=$true} + 'action' {$script:original.Action=$true} + 'scope' {$script:original.Scope=$true} + 'id' {$script:entry.Id=$true;$script:original.Results[0].Id=$true} + 'kind' {$script:entry.Kind=$true;$script:original.Results[0].Kind=$true} + 'result-id' {$script:original.Results[0].Id=$true} + 'result-kind' {$script:original.Results[0].Kind=$true} + 'version' {$script:entry.Version=$true} + 'exit' {$script:original.ExitCode=$false} + 'failed-count' {$script:original.Failed=$false} + 'skipped-count' {$script:original.Skipped=$false} + 'hive' {$script:entry.Target.Hive=$true} + 'subkey' {$script:entry.Target.SubKey=$true} + 'target-directory' {$script:entry.Target.OutputDirectory=$true} + 'desired-enable-type' {$script:entry.Desired.EnableTranscripting.Type=$true} + 'desired-enable-value' {$script:entry.Desired.EnableTranscripting.Value=$true} + 'desired-output-type' {$script:entry.Desired.OutputDirectory.Type=$true} + 'desired-output-value' {$script:entry.Desired.OutputDirectory.Value=$true} + 'header-intent' {$script:entry.Desired.EnableInvocationHeader=$true} + 'capability' {$script:entry.Before.Capability.Status=$true} + 'view64' {$script:entry.Before.Policy[0].View=$true} + 'view32' {$script:entry.Before.Policy[1].View=$true} + 'before-enable-type' {foreach($view in $script:entry.Before.Policy){$view.Machine.EnableTranscripting.Type=$true}} + 'before-output-type' {foreach($view in $script:entry.Before.Policy){$view.Machine.OutputDirectory.Type=$true}} + } + Save-History + Reject {Plan-Fixture} 'history|Applied|Unsupported|registry views|DWORD|REG_SZ' + Assert ($script:writes -eq 0 -and -not (Test-Path (Join-Path $script:fixture 'plan'))) "Boolean $alter evidence is rejected before plan creation or mutation" + } + New-Fixture;$script:legacyJsonDate=$true;Plan-Fixture + Assert ($script:planResult.Status -eq 'Planned' -and $script:writes -eq 0) 'explicit UTC DateTime from older PowerShell JSON readers remains valid history' + Reject {ConvertTo-WelaArrivalUtc ([datetime]::SpecifyKind([datetime]::Now,[DateTimeKind]::Unspecified))} 'explicit UTC' + foreach($alter in @('Kind','SchemaVersion')) { + New-Fixture;Plan-Fixture + $tampered=ConvertFrom-WelaRecoveryJson (Get-Content -LiteralPath $script:planPath -Raw);$tampered.$alter=$true + Get-WelaRecoveryKey $tampered|Set-Content -LiteralPath $script:planPath -Encoding UTF8 + $script:restoreParameters.PlanHash=(Get-FileHash -LiteralPath $script:planPath -Algorithm SHA256).Hash.ToLowerInvariant() + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Unsupported transcription recovery plan' + Assert ($script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) "Boolean reviewed plan $alter is rejected before output or mutation" + } foreach($alter in @('source','host','policy','directory','protected','plan')) { New-Fixture;Plan-Fixture switch($alter){ From 69806573371452f7232ef4635a9ccee0f3d9026f Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:53 +0900 Subject: [PATCH 22/25] Reject unbound recovery options before command dispatch --- WELA.ps1 | 2 +- tests/FirewallLoggingRecovery.Cli.Tests.ps1 | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/WELA.ps1 b/WELA.ps1 index 3080e276..80e9072f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2013,7 +2013,7 @@ Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" if ($Cmd -ne 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'FirewallRecovery*' }).Count) {throw 'FirewallRecovery options require firewall-recovery. No command was run.'} -if ($Cmd -eq 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'} +if ($Cmd -eq 'firewall-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count)) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'} if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } if ($Cmd -ne 'smb-runtime' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'SmbRuntime*' }).Count) {throw 'SmbRuntime options require smb-runtime. No command was run.'} if ($Cmd -eq 'smb-runtime' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','SmbRuntimeAction','SmbRuntimeOutputPath','Auto','DryRun','Help') }).Count) {throw 'smb-runtime accepts only its dedicated options, Auto and DryRun. No command was run.'} diff --git a/tests/FirewallLoggingRecovery.Cli.Tests.ps1 b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 index 09d4733b..2fb1346a 100644 --- a/tests/FirewallLoggingRecovery.Cli.Tests.ps1 +++ b/tests/FirewallLoggingRecovery.Cli.Tests.ps1 @@ -12,6 +12,7 @@ Check @('firewall-recovery','-RecoveryAction','Restore') 'dedicated|require audi Check @('firewall-recovery','-FirewallRecoveryProfile','All') 'ValidateSet|does not belong' Check @('firewall-recovery','-FirewallRecoveryAction','Plan','-Auto') 'requires one profile' Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-DryRun') 'reviewed plan/hash' +Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-WhatIf') 'dedicated options' Check @('firewall-recovery','-FirewallRecoveryAction','Restore','-FirewallRecoveryPlanPath','missing','-FirewallRecoveryPlanHash',('a'*64),'-DryRun','-FirewallRecoveryOutputPath','must-not-exist') 'reviewed plan/hash' $global:LASTEXITCODE=0 Write-Host "Firewall recovery public CLI: $n checks passed." From dc1e354475592bf6f08f4f85d5ede0e3885415b6 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:54 +0900 Subject: [PATCH 23/25] Reject unbound preview and positional probe arguments --- WELA.ps1 | 2 +- tests/Capi2Probe.Cli.Tests.ps1 | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/WELA.ps1 b/WELA.ps1 index 74e38d78..4cb795f7 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2083,7 +2083,7 @@ if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -no throw 'wec-runtime accepts only selected runtime IDs, source cap and a new result path. No command was run.' } if ($Cmd -ne 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Capi2Probe*'}).Count) {throw 'Capi2Probe options require capi2-probe.'} -if ($Cmd -eq 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','Capi2ProbeAction','Capi2ProbeOutputPath','Capi2ProbeTimeoutSeconds','Help')}).Count) {throw 'capi2-probe accepts only dedicated probe options.'} +if ($Cmd -eq 'capi2-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','Capi2ProbeAction','Capi2ProbeOutputPath','Capi2ProbeTimeoutSeconds','Help')}).Count)) {throw 'capi2-probe accepts only dedicated probe options.'} if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'} if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'} if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'} diff --git a/tests/Capi2Probe.Cli.Tests.ps1 b/tests/Capi2Probe.Cli.Tests.ps1 index f77f8ed3..b6ae9292 100644 --- a/tests/Capi2Probe.Cli.Tests.ps1 +++ b/tests/Capi2Probe.Cli.Tests.ps1 @@ -1,6 +1,9 @@ $ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$preview=Join-Path ([IO.Path]::GetTempPath()) ('wela-capi2-whatif-'+[guid]::NewGuid().ToString('N')) $cases=@( @{Args=@('capi2-probe','-Help');Code=0;Pattern='Fixed offline'}, + @{Args=@('capi2-probe','-Capi2ProbeAction','Run','-Capi2ProbeOutputPath',$preview,'-WhatIf');Code=1;Pattern='only dedicated'}, + @{Args=@('capi2-probe','-Help','unexpected-positional-value');Code=1;Pattern='only dedicated'}, @{Args=@('configure','-Capi2ProbeAction','Run','-Auto');Code=1;Pattern='require capi2-probe'}, @{Args=@('wmi-auditing','-Capi2ProbeAction','Run');Code=1;Pattern='require capi2-probe'}, @{Args=@('capi2-probe','-Help','-WmiAction','Configure');Code=1;Pattern='only dedicated'}, @@ -10,5 +13,6 @@ $cases=@( @{Args=@('capi2-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, @{Args=@('capi2-probe','-Capi2ProbeAction','Run');Code=1;Pattern='new Capi2ProbeOutputPath'}) foreach($case in $cases){$ErrorActionPreference='Continue';$output=& $engine -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') [$code] $output"};$count++} +if(Test-Path -LiteralPath $preview){throw 'Unsupported preview created a probe output directory.'};$count++ Write-Host "PASS: $count CAPI2 probe public CLI checks." $global:LASTEXITCODE=0 From 2d208f457ad16c16c001a6e2e5984caefd08d6da Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:24:06 +0900 Subject: [PATCH 24/25] Refuse unknown recovery options before native restoration --- WELA.ps1 | 2 +- tests/EventLogRecovery.Cli.Tests.ps1 | 1 + tests/EventLogRecovery.Windows.Tests.ps1 | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/WELA.ps1 b/WELA.ps1 index f0416433..99e3fa65 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2077,7 +2077,7 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { } 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 @($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 -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.'} if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'} if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} diff --git a/tests/EventLogRecovery.Cli.Tests.ps1 b/tests/EventLogRecovery.Cli.Tests.ps1 index eb362ace..290214ed 100644 --- a/tests/EventLogRecovery.Cli.Tests.ps1 +++ b/tests/EventLogRecovery.Cli.Tests.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent $engine=(Get-Process -Id $PID).Path;$count=0 $cases=@( + @{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated'}, @{Args=@('eventlog-recovery','-Help');Code=0;Pattern='AllowShrink'}, @{Args=@('configure','-EventRecoveryAction','Restore','-Auto');Code=1;Pattern='require eventlog-recovery'}, @{Args=@('eventlog-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, diff --git a/tests/EventLogRecovery.Windows.Tests.ps1 b/tests/EventLogRecovery.Windows.Tests.ps1 index 300f46b8..67934da7 100644 --- a/tests/EventLogRecovery.Windows.Tests.ps1 +++ b/tests/EventLogRecovery.Windows.Tests.ps1 @@ -34,6 +34,8 @@ try{ $plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json Assert ($plan.Status -eq 'ReviewRequired' -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Public Plan makes no channel changes' $apply=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryPlanPath',"$root/plan/plan.json",'-EventRecoveryPlanHash',$plan.PlanHash) + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/unknown-option",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange','-WhatIf')) 1 + Assert (-not (Test-Path "$root/unknown-option") -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Unknown WhatIf refuses before output or native restoration' Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/without-consent")) 1 $refused=Get-Content "$root/without-consent/manifest.json" -Raw|ConvertFrom-Json Assert ($refused.Status -eq 'Refused' -and -not $refused.NativeWriteAttempted) 'Shrinking requires independent explicit consent' From ab69ce3c4b737dbbab70dcf094111d406f24fb94 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:09:06 +0900 Subject: [PATCH 25/25] Explain disabled transcription after interrupted recovery --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- WELA.ps1 | 2 +- docs/transcription-recovery.md | 4 +++- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index e179b8ee..8475d414 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security) +- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb49dfe..f7241c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) +- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index e8dc8326..9a6f3ff8 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2276,7 +2276,7 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) {exit $report.ExitCode} } 'transcription-recovery' { - if ($Help) {Write-Host 'Usage: transcription-recovery -TranscriptRecoveryAction Plan -TranscriptRecoveryJournalPath before.jsonl -TranscriptRecoveryOriginalResultsPath results.json -TranscriptRecoveryOutputPath new-directory; Restore uses -TranscriptRecoveryPlanPath, -TranscriptRecoveryPlanHash and new -TranscriptRecoveryOutputPath [-Auto] [-TranscriptRecoveryAllowTemporarySuspension]. DryRun omits output. See docs/transcription-recovery.md.';return} + if ($Help) {Write-Host 'Usage: transcription-recovery -TranscriptRecoveryAction Plan -TranscriptRecoveryJournalPath before.jsonl -TranscriptRecoveryOriginalResultsPath results.json -TranscriptRecoveryOutputPath new-directory; Restore uses -TranscriptRecoveryPlanPath, -TranscriptRecoveryPlanHash and new -TranscriptRecoveryOutputPath [-Auto] [-TranscriptRecoveryAllowTemporarySuspension]. DryRun omits output. Temporary suspension can leave machine transcription disabled after an error, drift refusal or process termination; there is no automatic rollback or re-enable. Inspect receipts, current policy and the destination before manual recovery. See docs/transcription-recovery.md.';return} $report=Invoke-WelaTranscriptRecovery -Action $TranscriptRecoveryAction -JournalPath $TranscriptRecoveryJournalPath -OriginalResultsPath $TranscriptRecoveryOriginalResultsPath -PlanPath $TranscriptRecoveryPlanPath -PlanHash $TranscriptRecoveryPlanHash -OutputPath $TranscriptRecoveryOutputPath -AllowTemporarySuspension:$TranscriptRecoveryAllowTemporarySuspension -Auto:$Auto -DryRun:$DryRun $report | ConvertTo-Json -Depth 24 | Write-Output exit ([int]$report.ExitCode) diff --git a/docs/transcription-recovery.md b/docs/transcription-recovery.md index ff4386c6..d7ae0830 100644 --- a/docs/transcription-recovery.md +++ b/docs/transcription-recovery.md @@ -28,7 +28,9 @@ Omit `-TranscriptRecoveryAllowTemporarySuspension` when the reviewed plan does n The target is the existing shared machine registry key `HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription`. The command supports original `OutputDirectory` REG_SZ values or absence, and original `EnableTranscripting` DWORD `0`, DWORD `1`, or absence. Other original types and values require manual recovery. Both Registry64 and Registry32 must agree. Recovery retains the existing key, removes only values that were originally absent, and never deletes policy subtrees. -When the original enablement was DWORD `0`, recovery restores that disabled state before changing the destination. If a destination change is followed by restoring DWORD `1` or removing the enablement value, the plan requires explicit temporary suspension: write DWORD `0`, restore the destination, then restore the original enablement or absence. A failure may leave that temporary disabled state in place; the command reports this as an incomplete attempt and stops subsequent writes. It does not silently re-enable with an unverified destination. An originally absent destination is supported only with original DWORD `0`; enabled/default-user destinations require manual recovery. +When the original enablement was DWORD `0`, recovery restores that disabled state before changing the destination. If a destination change is followed by restoring DWORD `1` or removing the enablement value, the plan requires explicit temporary suspension: write DWORD `0`, restore the destination, then restore the original enablement or absence. An originally absent destination is supported only with original DWORD `0`; enabled/default-user destinations require manual recovery. + +**Temporary suspension can leave machine transcription disabled.** By supplying `-TranscriptRecoveryAllowTemporarySuspension`, you accept that a write error, drift refusal or terminated process after the disable step and before final restoration can leave `EnableTranscripting=0`, even when the recovery target enables transcription. There is no automatic rollback or re-enable. A handled failure reports an incomplete attempt and stops subsequent writes; a terminated process may leave only pending/confirmed receipts without a final result. Inspect those receipts and the current native policy, verify the destination, and manually recover the intended enablement before relying on automatic transcription again. Do not re-enable blindly with an unverified destination. Computer policy takes precedence over user policy, and policy-enabled transcription applies to PowerShell sessions. Removing a machine value can expose user/default policy; the command restores the recorded registry state without asserting session adoption. Manual `Start-Transcript` remains possible when automatic policy transcription is disabled. [Microsoft Windows PowerShell policy documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1). `HKLM\SOFTWARE\Policies` is shared across the registry views; recovery writes through Registry64 once and verifies both observations. [Microsoft WOW64 registry documentation](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 04e1a875..e18e0ccc 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security) +- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 55e29b72..5f0f8c12 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) +- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security)