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)