Reject coerced transcription recovery history discriminators

This commit is contained in:
Shirofune-Security committed 2026-09-21 18:19:57 +09:00
1 parent 2e329c7f2f
commit 22d3a13180
3 files changed
+72 -14

No files matched your search

+1 -1
View File
@@ -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 `
+18 -12
View File
@@ -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){'<null>'}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
+53 -1
View File
@@ -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){