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] 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){