mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-01 03:54:44 +02:00
* 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
119 lines
10 KiB
PowerShell
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."
|