Files
WELA/tests/AuditRecovery.Tests.ps1
田中ザック Isaac Mathis 60006531be Restore selected audit changes from reviewed recovery evidence (#419)
* Add guarded restoration of selected completed audit writes

* Link changelog to PR 419

* Read recovery host name from Windows instead of environment overrides

* Require local fixed-drive recovery output paths
2026-09-20 19:30:49 +09:00

119 lines
10 KiB
PowerShell

$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/AuditRecovery.ps1')
$script:n=0
function Assert($Value,$Message) {if (-not $Value) {throw $Message};$script:n++}
function Throws($Action,$Pattern) {$message='';try {& $Action | Out-Null} catch {$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, received $message"}
function Get-WelaRecoveryHost {[pscustomobject][ordered]@{Computer='TEST';MachineGuid='11111111-1111-1111-1111-111111111111';ContextKey=$script:hostKey}}
$script:hostKey='test-context'
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-recovery-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $root
$journal=Join-Path $root 'before.jsonl';$original=Join-Path $root 'original.json'
$id='AuditPolicy/Process Creation';$prec='Registry/HKLM:\SYSTEM\CurrentControlSet\Control\Lsa/SCENoApplyLegacyAuditPolicy'
$script:state=3;$script:writes=0;$script:failWrite=$false;$script:badReadback=$false
$script:precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'}
function Get-WelaRegistryState {$script:precedence}
function Get-WelaRecoveryCurrent {param($Control) if ($Control.Kind -eq 'AuditPolicy') {$script:state} else {$script:precedence}}
function Set-WelaRecoveryCurrent {param($Control) if ($script:failWrite) {throw 'Injected write failure'};$script:writes++;if ($script:badReadback) {return};if ($Control.Kind -eq 'AuditPolicy') {$script:state=$Control.RecoverTo} else {$script:precedence=$Control.RecoverTo}}
function Save-Fixture {
param([switch]$WithPrecedence)
$script:entry=[pscustomobject][ordered]@{Version=1;ComputerName='TEST';RecordedUtc=[datetime]::UtcNow.ToString('o');Id=$id;Kind='AuditPolicy';Target=[pscustomobject]@{Guid='0cce922b-69ae-11d9-bed3-505054503030'};Before=0;Desired=[pscustomobject]@{Mask=1;Mode='minimum'}}
$script:final=[pscustomobject]@{Id=$entry.Id;Kind=$entry.Kind;Target=$entry.Target;Before=$entry.Before;Desired=$entry.Desired;After=3;Status='Applied';Diagnostic=''}
$entries=@($entry);$rows=@($final)
if ($WithPrecedence) {
$prior=[pscustomobject]@{KeyExists=$true;ValueExists=$false;Value=$null;Type=$null}
$pe=[pscustomobject]@{Version=1;ComputerName='TEST';RecordedUtc=[datetime]::UtcNow.ToString('o');Id=$prec;Kind='Registry';Target=[pscustomobject]@{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'};Before=$prior;Desired=[pscustomobject]@{Value=1;Type='DWord'}}
$entries=@($pe)+$entries
$rows=@([pscustomobject]@{Id=$pe.Id;Kind=$pe.Kind;Target=$pe.Target;Before=$pe.Before;Desired=$pe.Desired;After=$script:precedence;Status='Applied'})+$rows
}
($entries | ForEach-Object {$_ | ConvertTo-Json -Depth 20 -Compress}) | Set-Content -LiteralPath $journal -Encoding UTF8
[pscustomobject]@{DryRun=$false;ExitCode=0;Results=$rows} | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $original -Encoding UTF8
}
function New-PlanFile {
param([string[]]$Ids=@($id))
$destination=Join-Path $root ([guid]::NewGuid().ToString('N'))
$result=Invoke-WelaAuditRecovery -JournalPath $journal -OriginalResultsPath $original -ControlId $Ids -OutputPath $destination
Assert ($result.Status -eq 'Planned') 'Plan is read only.'
Join-Path $destination 'plan.json'
}
try {
for ($before=0;$before -le 3;$before++) {for ($desired=0;$desired -le 3;$desired++) {for ($after=0;$after -le 3;$after++) {
if (($after -band $before) -eq $before -and ($after -band $desired) -eq $desired) {
$target=Get-WelaRecoveryMaskTarget $before ([pscustomobject]@{Mask=$desired;Mode='minimum'}) $after
Assert (($target -band $before) -eq $before) 'Minimum undo preserves every original bit.'
$unrequested=$after -band (3 -bxor $desired)
Assert (($target -band $unrequested) -eq $unrequested) 'Minimum undo preserves independent added bits.'
Assert (($target -band ($desired -band (3 -bxor $before))) -eq 0) 'Minimum undo removes only requested additions.'
} else {Throws {Get-WelaRecoveryMaskTarget $before ([pscustomobject]@{Mask=$desired;Mode='minimum'}) $after} 'inconsistent'}
Assert ((Get-WelaRecoveryMaskTarget $before ([pscustomobject]@{Mask=$desired;Mode='exact'}) $desired) -eq $before) 'Exact undo returns original mask.'
}}}
Throws {Get-WelaRecoveryMaskTarget '0' ([pscustomobject]@{Mask=1;Mode='exact'}) 1} 'integer'
Throws {ConvertFrom-WelaRecoveryJson '{"Id":1,"i\u0064":2}'} 'Duplicate'
Throws {ConvertFrom-WelaRecoveryJson '{"Id":1,}'} 'strict JSON'
$rejectedOutput=Join-Path $root 'rejected-network-output'
$driveReader=(Get-Command Get-WelaRecoveryOutputDriveType).ScriptBlock
try {
function Get-WelaRecoveryOutputDriveType {[IO.DriveType]::Network}
Throws {New-WelaRecoveryOutput $rejectedOutput} 'local fixed drive'
Assert (-not (Test-Path -LiteralPath $rejectedOutput)) 'Mapped network drive observation blocks output before directory creation.'
function Get-WelaRecoveryOutputDriveType {[IO.DriveType]::Unknown}
Throws {New-WelaRecoveryOutput $rejectedOutput} 'local fixed drive'
Assert (-not (Test-Path -LiteralPath $rejectedOutput)) 'Unknown drive type cannot create recovery output.'
} finally {Set-Item Function:Get-WelaRecoveryOutputDriveType $driveReader}
Throws {New-WelaRecoveryOutput (Join-Path $root 'rejected:stream')} '.'
Assert (-not (Test-Path -LiteralPath (Join-Path $root 'rejected'))) 'Stream destination is rejected before creating a base object.'
Save-Fixture
$planFile=New-PlanFile
$dry=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun -Auto
Assert ($dry.Status -eq 'DryRun' -and $writes -eq 0 -and $dry.Results[0].Status -eq 'WouldRestore') 'DryRun writes nothing.'
$script:state=1
$drift=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'drift') -Auto
Assert ($drift.ExitCode -eq 1 -and $writes -eq 0 -and $drift.Results[0].Diagnostic -match 'drift') 'Intervening change refuses restoration.'
$script:state=3
$ok=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'restored') -Auto
Assert ($ok.ExitCode -eq 0 -and $state -eq 2 -and $writes -eq 1) 'Actual restore preserves independent Failure auditing.'
Assert (Test-Path -LiteralPath (Join-Path $root 'restored/001-before.json')) 'Durable pre-write receipt exists.'
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'again') -Auto
Assert ($again.Results[0].Status -eq 'AlreadyRecovered' -and $writes -eq 1) 'Recovery is idempotent.'
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'again') -Auto} 'new local'
$script:hostKey='drift';Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun} 'host changed';$script:hostKey='test-context'
Add-Content -LiteralPath $journal -Value ' '
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun} 'evidence changed'
Save-Fixture
$entry.ComputerName='OTHER';$entry | ConvertTo-Json -Depth 20 -Compress | Set-Content $journal
Throws {New-WelaRecoveryPlan $journal $original @($id)} 'wrong-host'
Save-Fixture
$final.Status='Failed';[pscustomobject]@{DryRun=$false;Results=@($final)} | ConvertTo-Json -Depth 20 | Set-Content $original
Throws {New-WelaRecoveryPlan $journal $original @($id)} 'Applied'
Save-Fixture
$entry.Target.Guid='0cce922c-69ae-11d9-bed3-505054503030';$entry | ConvertTo-Json -Depth 20 -Compress | Set-Content $journal
Throws {New-WelaRecoveryPlan $journal $original @($id)} 'mismatch'
Save-Fixture -WithPrecedence
Throws {New-WelaRecoveryPlan $journal $original @($prec)} 'every journaled'
$planFile=New-PlanFile @($id,$prec)
$script:state=3;$script:failWrite=$true
$failed=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'failed') -Auto
Assert ($failed.ExitCode -eq 1 -and $failed.Results[1].Diagnostic -match 'blocked' -and $precedence.Value -eq 1) 'Failed mask recovery blocks precedence restoration.'
$script:failWrite=$false;$script:badReadback=$true
$failed=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'readback') -Auto
Assert ($failed.ExitCode -eq 1 -and $failed.Results[0].Diagnostic -match 'readback') 'Ineffective write is a failure.'
$script:badReadback=$false
$ok=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'ordered') -Auto
Assert ($ok.ExitCode -eq 0 -and $state -eq 2 -and -not $precedence.ValueExists) 'Precedence absence restores only after masks.'
$payload=Get-WelaRecoveryFile $planFile
$tampered=ConvertFrom-WelaRecoveryJson $payload.Text;$tampered.Controls[0].RecoverTo=0
$tampered | ConvertTo-Json -Depth 24 | Set-Content $planFile
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun} 'independently rebuilt'
$ps=(Get-Process -Id $PID).Path
$old=$ErrorActionPreference;$ErrorActionPreference='Continue'
$output=& $ps -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -RecoveryAction Restore 2>&1;$code=$LASTEXITCODE
$ErrorActionPreference=$old
Assert ($code -ne 0 -and ($output -join ' ') -match 'require audit-recovery') 'Recovery options cannot dispatch unrelated configuration.'
Push-Location $root
try {Save-Fixture;$script:precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};$script:state=3;$null=Invoke-WelaAuditRecovery -JournalPath ./before.jsonl -OriginalResultsPath ./original.json -ControlId $id -OutputPath ./relative;Assert (Test-Path ./relative/plan.json) 'Relative paths follow PowerShell location.'} finally {Pop-Location}
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
$global:LASTEXITCODE=0
Write-Host "Audit recovery: $script:n assertions passed."