# 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')) { $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 -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) 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 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.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 -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 -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 -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)" } 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=Copy-WelaTranscriptRecoveryValue $after.Policy foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) { $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 }} $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 -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 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=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 { 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=(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)=Copy-WelaTranscriptRecoveryValue $step.Value} Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 $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} } 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 }