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] 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."