mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-02 12:34:42 +02:00
192 lines
17 KiB
PowerShell
192 lines
17 KiB
PowerShell
$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'}
|
|
& $script:artifactWriter $Path $Value
|
|
}
|
|
$script:checks=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-transcript-recovery-test-'+[guid]::NewGuid().ToString('N'))
|
|
$null=New-Item -ItemType Directory $root
|
|
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
|
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"}
|
|
function Copy-Value($Value){Copy-WelaTranscriptRecoveryValue $Value}
|
|
function Typed($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})}}
|
|
function Get-WelaTranscriptRecoveryContext {[pscustomobject]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$script:machine};Reader='fixture-reader'}}
|
|
function Get-WelaTranscriptRecoverySources {[pscustomobject]@{Code=$script:code}}
|
|
function Assert-WelaTranscriptRecoveryLocalPath {param($Path) if(-not $Path -or $Path.StartsWith('\\')){throw 'local path fixture refusal'}}
|
|
function Get-WelaTranscriptRecoveryProtectedPolicy {return ,$script:protected}
|
|
function Get-WelaTranscriptCapability {[pscustomobject]@{Status='Supported';Views=@('Registry64','Registry32')}}
|
|
function Get-WelaTranscriptPolicy {param($Views) Copy-Value $script:policy}
|
|
function Get-WelaTranscriptDestination {param($Path) [pscustomobject]@{RequestedPath=$Path;Path=$Path;Status='Observed';ConfigureAllowed=$true;CreationTimeUtc='fixture';Acl=$script:acl}}
|
|
function Get-WelaTranscriptState {param($OutputDirectory) [pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=(Get-WelaTranscriptPolicy);Destination=(Get-WelaTranscriptDestination $OutputDirectory)}}
|
|
function Set-WelaTranscriptRecoveryValue {
|
|
param($Name,$Value)
|
|
$script:writes++
|
|
Assert (Test-Path (Join-Path $script:restoreOutput ('{0:d3}-pending.json' -f $script:writes))) 'each actual write has a durable pending receipt first'
|
|
if($script:writes -eq $script:failWrite){throw 'injected write failure'}
|
|
foreach($view in $script:policy){$view.Machine.$Name=Copy-Value $Value}
|
|
if($script:writes -eq $script:driftWrite){$script:protected=@('changed independent module policy')}
|
|
}
|
|
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: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
|
|
foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 1;$view.Machine.OutputDirectory=Typed 'C:\New' String}
|
|
$before=[pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=$beforePolicy;Destination=(Get-WelaTranscriptDestination 'C:\New')}
|
|
$after=Get-WelaTranscriptState 'C:\New'
|
|
$target=[pscustomobject]@{Hive='LocalMachine';SubKey='SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription';OutputDirectory='C:\New'}
|
|
$desired=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{Type='DWord';Value=1};OutputDirectory=[pscustomobject]@{Type='String';Value='C:\New'};EnableInvocationHeader='Preserve'}
|
|
$script:entry=[pscustomobject]@{Version=1;ComputerName='fixture';RecordedUtc=[datetime]::UtcNow.ToString('o');Id='PowerShellTranscription/CisV4L2';Kind='PowerShellTranscription';Before=$before;Target=$target;Desired=$desired}
|
|
$script:original=[pscustomobject]@{ExitCode=0;Failed=0;Skipped=0;DryRun=$false;Action='Configure';Scope='windows-powershell-transcription-policy-only';Results=@([pscustomobject]@{Id=$script:entry.Id;Kind=$script:entry.Kind;Before=$before;After=$after;Target=$target;Desired=$desired;Status='Applied'})}
|
|
Save-History
|
|
$script:restoreOutput=Join-Path $script:fixture 'restore'
|
|
}
|
|
function Save-History {
|
|
$script:journal=Join-Path $script:fixture 'before.jsonl';$script:originalPath=Join-Path $script:fixture 'original.json'
|
|
Get-WelaRecoveryKey $script:entry|Set-Content -LiteralPath $script:journal -Encoding UTF8
|
|
Get-WelaRecoveryKey $script:original|Set-Content -LiteralPath $script:originalPath -Encoding UTF8
|
|
}
|
|
function Plan-Fixture {
|
|
$script:planResult=Invoke-WelaTranscriptRecovery -JournalPath $script:journal -OriginalResultsPath $script:originalPath -OutputPath (Join-Path $script:fixture 'plan')
|
|
$script:planPath=Join-Path $script:planResult.OutputPath 'plan.json'
|
|
$script:restoreParameters=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planResult.PlanSha256;OutputPath=$script:restoreOutput;Auto=$true}
|
|
}
|
|
try {
|
|
New-Fixture;Plan-Fixture
|
|
Assert ($script:planResult.RequiresTemporarySuspension -and $script:writes -eq 0) 'plan exposes a required temporary suspension without changes'
|
|
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters} 'explicit.*TemporarySuspension'
|
|
Assert (-not (Test-Path $script:restoreOutput)) 'missing suspension consent creates no recovery output'
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
|
Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0 -and $script:writes -eq 3) 'enabled destination recovery completes three verified writes'
|
|
$confirmed=@(Get-ChildItem $script:restoreOutput '*-confirmed.json'|ForEach-Object {ConvertFrom-WelaRecoveryJson (Get-Content $_.FullName -Raw)})
|
|
Assert ($confirmed[0].Step.Name -eq 'EnableTranscripting' -and $confirmed[0].Step.Value.Value -eq 0 -and $confirmed[1].Step.Name -eq 'OutputDirectory' -and $confirmed[2].Step.Value.Value -eq 1) 'explicit suspension precedes destination and original enablement comes last'
|
|
Assert ($confirmed[0].Before[0].Machine.EnableTranscripting.Value -eq 1 -and $confirmed[0].After[0].Machine.EnableTranscripting.Value -eq 0) 'confirmed receipts retain distinct before/after step snapshots'
|
|
Assert ($script:policy[0].Machine.OutputDirectory.Value -eq 'C:\Old' -and $script:policy[0].Machine.EnableInvocationHeader.Value -eq 0) 'original directory restored and header retained'
|
|
Assert ($report.SigmaEvtxCredit -eq 0) 'recovery grants no EVTX credit'
|
|
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Current policy/destination differs'
|
|
foreach($before in @(0,$null)) {
|
|
New-Fixture $before;Plan-Fixture
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
|
Assert ($report.Status -eq 'Restored' -and $script:policy[0].Machine.EnableTranscripting.Value -eq $before) 'disabled or absent original enablement is recovered exactly'
|
|
Assert ($script:writes -eq $(if($null -eq $before){3}else{2})) 'only required ordered steps are written'
|
|
}
|
|
New-Fixture 0 $null;Plan-Fixture
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters
|
|
Assert ($report.Status -eq 'Restored' -and -not $script:policy[0].Machine.OutputDirectory.ValueExists -and $script:policy[0].Machine.OutputDirectory.KeyExists) 'absent output value restored with key retained'
|
|
New-Fixture 1 $null
|
|
Reject {Plan-Fixture} 'absent output directory requires'
|
|
New-Fixture 0 'C:\New';Plan-Fixture
|
|
$preview=$script:restoreParameters.Clone();$preview.Remove('OutputPath')
|
|
$report=Invoke-WelaTranscriptRecovery @preview -DryRun
|
|
Assert ($report.Status -eq 'WouldRestore' -and $script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) 'preview is read-only'
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters
|
|
Assert ($report.Status -eq 'Restored' -and $script:writes -eq 1) 'unchanged destination restores enablement only'
|
|
foreach($alter in @('wrong-host','failed','mismatch','type','duplicate','shared-view')) {
|
|
New-Fixture
|
|
switch($alter){
|
|
'wrong-host' {$script:entry.ComputerName='other'}
|
|
'failed' {$script:original.Results[0].Status='Failed'}
|
|
'mismatch' {$script:original.Results[0].Desired=Copy-Value $script:original.Results[0].Desired;$script:original.Results[0].Desired.EnableTranscripting.Value=0}
|
|
'type' {$script:entry.Before.Policy[0].Machine.EnableTranscripting=Typed '1' String;$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed '1' String}
|
|
'duplicate' {$script:original.Results += $script:original.Results[0]}
|
|
'shared-view' {$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed 0}
|
|
}
|
|
Save-History
|
|
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){
|
|
'source' {$script:code='changed'}
|
|
'host' {$script:machine='changed'}
|
|
'policy' {foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 0}}
|
|
'directory' {$script:acl='changed'}
|
|
'protected' {$script:protected=@('changed')}
|
|
'plan' {Add-Content -LiteralPath $script:planPath ' '}
|
|
}
|
|
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'differs|different'
|
|
Assert ($script:writes -eq 0) 'drift before restore causes no mutation'
|
|
}
|
|
New-Fixture;Plan-Fixture;$script:promptDrift=$true;$script:restoreParameters.Auto=$false
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
|
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'prompt-time typed policy drift blocks the first write'
|
|
New-Fixture;Plan-Fixture;$script:failWrite=2
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
|
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 2 -and $script:policy[0].Machine.EnableTranscripting.Value -eq 0 -and $script:policy[0].Machine.OutputDirectory.Value -eq 'C:\New') 'partial failure stops and reports the actual suspended state'
|
|
Assert ((Test-Path (Join-Path $script:restoreOutput '001-confirmed.json')) -and (Test-Path (Join-Path $script:restoreOutput '002-pending.json')) -and -not (Test-Path (Join-Path $script:restoreOutput '003-pending.json'))) 'partial receipts preserve confirmed versus uncertain steps'
|
|
New-Fixture;Plan-Fixture;$script:driftWrite=1
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
|
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 1 -and $report.Diagnostic -match 'Preserved PowerShell policy changed') 'independent policy drift after a write stops all later writes'
|
|
foreach($name in @('001-pending.json','001-confirmed.json')) {
|
|
New-Fixture;Plan-Fixture;$script:failArtifact=$name
|
|
$report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension
|
|
Assert ($report.ExitCode -eq 1 -and $script:writes -eq $(if($name -like '*pending*'){0}else{1})) 'durable receipt failure stops before any further native writes'
|
|
}
|
|
New-Fixture;Plan-Fixture;$script:failArtifact='result.json'
|
|
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'durable artifact failure'
|
|
Assert ($script:writes -eq 3 -and (Test-Path (Join-Path $script:restoreOutput '003-confirmed.json'))) 'result persistence failure fails outward while durable final confirmation remains'
|
|
Reject {ConvertFrom-WelaRecoveryJson '{"x":1,"X":2}'} 'Duplicate'
|
|
Reject {ConvertFrom-WelaRecoveryJson '{x:1}'} 'strict JSON'
|
|
Write-Host "Passed $script:checks transcription recovery assertions; no Windows policy changes."
|
|
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
|