From 314d25e6a2369ff604e99575aac8d661c4721738 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:57:17 +0900 Subject: [PATCH] fix: validate native empty catalogue and exercise registry recovery refusals --- .github/workflows/registry-sacl-recovery.yml | 4 + scripts/RegistrySaclRecovery.ps1 | 19 ++- tests/RegistrySaclRecovery.Tests.ps1 | 125 +++++++++++++++++++ tests/RegistrySaclRecovery.Windows.Tests.ps1 | 31 +++++ 4 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 tests/RegistrySaclRecovery.Tests.ps1 diff --git a/.github/workflows/registry-sacl-recovery.yml b/.github/workflows/registry-sacl-recovery.yml index c4468833..569a6b18 100644 --- a/.github/workflows/registry-sacl-recovery.yml +++ b/.github/workflows/registry-sacl-recovery.yml @@ -28,11 +28,15 @@ jobs: if: matrix.engine == 'powershell' shell: powershell run: | + ./tests/RegistrySaclRecovery.Cli.Tests.ps1 + ./tests/RegistrySaclRecovery.Tests.ps1 ./tests/RegistrySaclRecovery.Windows.Tests.ps1 -AllowDisposableHiveWrite - name: Actual public registry lifecycle in PowerShell 7 if: matrix.engine == 'pwsh' shell: pwsh run: | + ./tests/RegistrySaclRecovery.Cli.Tests.ps1 + ./tests/RegistrySaclRecovery.Tests.ps1 ./tests/RegistrySaclRecovery.Windows.Tests.ps1 -AllowDisposableHiveWrite - name: Retain public receipts, actual XML and exact cleanup if: always() diff --git a/scripts/RegistrySaclRecovery.ps1 b/scripts/RegistrySaclRecovery.ps1 index 80d47d9f..bf5d6a76 100644 --- a/scripts/RegistrySaclRecovery.ps1 +++ b/scripts/RegistrySaclRecovery.ps1 @@ -18,6 +18,12 @@ function Get-WelaRegistryRecoverySources { } [pscustomobject]$sources } +function Assert-WelaRegistryRecoverySources { + param($Sources) + if($Sources -isnot [array]){throw 'Original source inventory must be an array.'} + foreach($entry in $Sources){Assert-WelaEvtxObject $entry @('Path','Sha256');Assert-WelaRegistryRecoveryText $entry @('Path','Sha256');if($entry.Sha256 -cnotmatch '^[a-f0-9]{64}$'){throw 'Original source fingerprint is malformed.'}} + Assert-WelaSelectedSaclSources $Sources +} function Get-WelaRegistryRecoveryContext { if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Registry SACL recovery requires native 64-bit Windows.'} foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}} @@ -45,10 +51,10 @@ function Assert-WelaRegistryRecoverySnapshot { if($ace.Ordinary -isnot [bool] -or ($null -ne $ace.Sid -and $ace.Sid -isnot [string])){throw 'Mistyped historical ACE metadata.'} foreach($name in @('Type','Flags','Mask')){Assert-WelaRegistryRecoveryNumber $ace.$name} } - Initialize-WelaRegistryRecoveryNative - $parsed=[Wela.RegistrySaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64)) + $parsed=Get-WelaRegistryRecoveryDescriptorObservation $Snapshot if((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)){throw 'Historical metadata differs from its native descriptor bytes.'} } +function Get-WelaRegistryRecoveryDescriptorObservation {param($Snapshot) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64))} function Assert-WelaRegistryRecoveryEmpty { param($Inventory,$Root) Assert-WelaEvtxObject $Inventory @('Status','Maximum','MaximumDepth','StartedUtc','CompletedUtc','Root','Entries','Diagnostics') @@ -80,11 +86,11 @@ function New-WelaRegistryRecoveryPlan { Assert-WelaEvtxObject $plan $planFields foreach($value in @($plan,$pending,$confirmed,$result)){Assert-WelaRegistryRecoveryNumber $value.SchemaVersion;if($value.SchemaVersion -ne 1){throw 'Unsupported original schema.'};Assert-WelaRegistryRecoveryText $value @('Kind')} Assert-WelaRegistryRecoveryText $plan @('Profile','GenerationReadiness') - if($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or -not $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1 -or $plan.Catalog -isnot [array] -or $plan.Catalog.Count -ne 0){throw 'Require one original selected registry target with explicit child consent.'} + if($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or -not $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1 -or ($null -ne $plan.Catalog -and ($plan.Catalog -isnot [array] -or $plan.Catalog.Count -ne 0))){throw 'Require one original selected registry target with explicit child consent.'} $row=$plan.Rows[0];Assert-WelaEvtxObject $row $rowFields;Assert-WelaRegistryRecoveryText $row @('Id','DefinitionKey','Status','Diagnostic') Assert-WelaRegistryRecoveryText $row.Definition @('Kind','Path','Inheritance','Propagation') if($row.Status -cne 'ChangeRequired' -or $row.Diagnostic -cne '' -or $null -ne $row.After -or $null -ne $row.DescendantsAfter -or $null -ne $row.DescendantVerification -or $row.Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $row.Definition.Kind -cne 'Registry' -or $row.Definition.Inheritance -cnotin @('None','ContainerInherit') -or $row.Definition.Propagation -cne 'None'){throw 'Original plan is not one supported registry root audit addition.'} - Assert-WelaSelectedSaclSources $plan.Sources + Assert-WelaRegistryRecoverySources $plan.Sources Assert-WelaRegistryRecoveryText $plan.Context @('Key','Computer') if((Get-WelaRegistryRecoveryKey $plan.Context) -cne (Get-WelaRegistryRecoveryKey $context.Selected) -or $plan.Context.Computer -cne $context.Host.Computer){throw 'Original host context differs from the actual recovery host.'} $catalog=Get-WelaSelectedSaclCatalog -Profile $plan.Profile -IncludeOptional:$plan.IncludeOptional -Context $context.Selected @@ -98,7 +104,7 @@ function New-WelaRegistryRecoveryPlan { foreach($receipt in @($pending,$confirmed)){ Assert-WelaEvtxObject $receipt $receiptFields;Assert-WelaRegistryRecoveryText $receipt @('Kind','State','Computer','ContextKey','Id','Ownership') if($receipt.Kind -cne 'WelaSelectedSaclReceipt' -or $receipt.Computer -cne $context.Host.Computer -or $receipt.ContextKey -cne $context.Selected.Key -or $receipt.Id -cne $row.Id -or $receipt.Ownership -cne 'Only the verified explicit selected-root addition; never descendant ACE ownership or bulk rollback authority.'){throw 'Original receipt scope or ownership differs.'} - Assert-WelaSelectedSaclSources $receipt.Sources + Assert-WelaRegistryRecoverySources $receipt.Sources foreach($name in @('Definition','Ace')){if((Get-WelaRegistryRecoveryKey $receipt.$name) -cne (Get-WelaRegistryRecoveryKey $row.$name)){throw 'Original receipt differs from selected plan.'}} Assert-WelaRegistryRecoverySnapshot $receipt.Before $row.Definition if((Get-WelaSelectedSaclSnapshotKey $receipt.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before)){throw 'Original before-state differs across records.'} @@ -111,7 +117,8 @@ function New-WelaRegistryRecoveryPlan { Assert-WelaEvtxObject $result @('SchemaVersion','Kind','ExitCode','DryRun','BackupPath','Plan','Results','GenerationReadiness','UsableRuleCredit');Assert-WelaRegistryRecoveryNumber $result.ExitCode;Assert-WelaRegistryRecoveryText $result @('BackupPath','GenerationReadiness') if($result.Kind -cne 'WelaSelectedSaclResult' -or $result.ExitCode -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -ne 1){throw 'Require one completed successful, non-dry-run operation.'} $applied=$result.Results[0];Assert-WelaEvtxObject $applied $rowFields;Assert-WelaRegistryRecoveryText $applied @('Id','DefinitionKey','Status','Diagnostic') - Assert-WelaEvtxObject $result.Plan $planFields;Assert-WelaRegistryRecoveryText $result.Plan @('Kind') + Assert-WelaEvtxObject $result.Plan $planFields;Assert-WelaRegistryRecoveryText $result.Plan @('Kind');Assert-WelaRegistryRecoveryNumber $result.Plan.SchemaVersion;if($result.Plan.SchemaVersion -ne 1){throw 'Unsupported completed plan schema.'} + foreach($value in @($plan,$result.Plan,$result)){Assert-WelaRegistryRecoveryText $value @('GenerationReadiness');Assert-WelaRegistryRecoveryNumber $value.UsableRuleCredit;if($value.GenerationReadiness -cne 'Conditional' -or $value.UsableRuleCredit -ne 0){throw 'Original evidence carries unsupported generation credit.'}} if($applied.Status -cne 'Applied' -or $applied.Diagnostic -cne '' -or $result.Plan.Kind -cne 'WelaSelectedSaclPlan' -or $result.Plan.Rows -isnot [array] -or $result.Plan.Rows.Count -ne 1 -or (Get-WelaRegistryRecoveryKey $applied) -cne (Get-WelaRegistryRecoveryKey $result.Plan.Rows[0]) -or $applied.Id -cne $row.Id -or $applied.DefinitionKey -cne $row.DefinitionKey){throw 'Completed result status, rows or scope disagree.'} foreach($name in @('Definition','Ace')){if((Get-WelaRegistryRecoveryKey $applied.$name) -cne (Get-WelaRegistryRecoveryKey $row.$name)){throw 'Completed selection differs from original plan.'}} foreach($name in @('Before','After')){Assert-WelaRegistryRecoverySnapshot $applied.$name $row.Definition;if((Get-WelaSelectedSaclSnapshotKey $applied.$name) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.$name)){throw 'Completed descriptor evidence disagrees.'}} diff --git a/tests/RegistrySaclRecovery.Tests.ps1 b/tests/RegistrySaclRecovery.Tests.ps1 new file mode 100644 index 00000000..deafab49 --- /dev/null +++ b/tests/RegistrySaclRecovery.Tests.ps1 @@ -0,0 +1,125 @@ +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -Force +foreach($name in @('WefArrival','EvtxRecovery','WecUpdate','TargetedSaclPlanning','SelectedSaclConfiguration','RegistrySaclRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +$script:count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Throws($Action,$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"} +function Clone($Value){ConvertFrom-WelaEvtxJson (ConvertTo-Json -InputObject $Value -Depth 32)} +function Save($Path,$Value){[IO.File]::WriteAllText($Path,(ConvertTo-Json -InputObject $Value -Depth 32),[Text.UTF8Encoding]::new($false))} +function Get-WelaSelectedSaclContext {[pscustomobject]@{Computer='fixture';Role='Client';Build=26100;Detail=[pscustomobject]@{UBR=1};Key='fixture-context'}} +$script:policies=@{};foreach($row in (Import-WelaAuditProfiles).catalog){$script:policies[$row.guid]=3} +function Get-WelaEffectiveAuditPolicy {$script:policies} +function Get-WelaAuditPrecedenceState {[pscustomobject]@{Registry=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1}}} +function Get-WelaSaclUserInventory {[pscustomobject]@{Users=@();Complete=$true;Diagnostics=@()}} +function Get-WelaSaclTargetObservation {throw 'Unselected targets must not be observed.'} +$catalog=Get-WelaSelectedSaclCatalog -Profile wela-2.2.0 -IncludeOptional -Context (Get-WelaSelectedSaclContext) +$fixtureSelection=@($catalog.Rows|Where-Object {$_.Definition.Scope -ceq 'registry'})[0] +$nativePath=Resolve-WelaSelectedSaclNativePath $fixtureSelection.Definition +$script:before=[pscustomobject]@{Path=$nativePath;Kind='Registry';Identity=($nativePath+':1000');IsDirectory=$false;DescriptorBase64='YmVmb3Jl';Owner='S-1-5-18';Group='S-1-5-18';DaclBase64='ZGFjbA==';ControlFlags=32788;SecurityInformation=511;DescriptorScope='WinSDK-defined sections 0x1ff; future sections unobserved';Aces=@([pscustomobject]@{Binary='b3RoZXI=';Type=2;Flags=64;Mask=1;Sid='S-1-5-18';Ordinary=$true})} +$script:after=$null;$script:current=$null;$script:scenario='';$script:mutations=0 +function Get-WelaSelectedSaclSnapshot {param($Definition) if($Definition.Path -cne $fixtureSelection.Definition.Path){throw 'Unselected target read.'};Clone $script:current} +function Get-WelaSelectedSaclChildNames {param($Definition,$Snapshot,$Maximum) [pscustomobject]@{Names=@();Truncated=$false}} +function Write-WelaSelectedSaclNative { + param($Definition,$Before,$Ace) + $script:current=Clone $Before;$script:current.Identity=$nativePath+':1001';$script:current.DescriptorBase64='YWZ0ZXI=' + $script:current.Aces+=@([pscustomobject]@{Binary='YWRkZWQ=';Type=2;Flags=$Ace.Flags;Mask=$Ace.Mask;Sid=$Ace.Sid;Ordinary=$true}) + $script:after=Clone $script:current;Clone $script:current +} +function Get-WelaRegistryRecoveryDescriptorObservation { + param($Snapshot) + $known=if($Snapshot.DescriptorBase64 -ceq $script:before.DescriptorBase64){Clone $script:before}elseif($Snapshot.DescriptorBase64 -ceq $script:after.DescriptorBase64){Clone $script:after}else{throw 'Unknown mocked native descriptor bytes.'} + $known.Identity=$Snapshot.Identity;$known +} +function Get-WelaRegistryRecoveryAddition {param($Before,$After,$Ace) if($Before.DescriptorBase64 -cne $script:before.DescriptorBase64 -or $After.DescriptorBase64 -cne $script:after.DescriptorBase64){throw 'Native descriptor append proof differs.'};'YWRkZWQ='} +function Get-WelaRegistryRecoverySnapshot {param($Definition) if($script:scenario -ceq 'children'){throw 'Recovery requires empty registry descendants.'};Clone $script:current} +$script:sourceReader=(Get-Command Get-WelaRegistryRecoverySources).ScriptBlock +function Get-WelaRegistryRecoverySources {$sources=&$script:sourceReader;if(($script:scenario -ceq 'source-after-pending' -and (Test-Path (Join-Path $script:out 'pending.json'))) -or ($script:scenario -ceq 'source-after-write' -and $script:mutations -gt 0)){$sources.'WELA.ps1'='0'*64};if($script:scenario -ceq 'plan-after-pending' -and (Test-Path (Join-Path $script:out 'pending.json'))){[IO.File]::AppendAllText($script:planPath,' ')};$sources} +function Get-WelaRegistryRecoveryContext { + $machine=if($script:scenario -ceq 'host-after-write' -and $script:mutations -gt 0){'00000000-0000-0000-0000-000000000002'}else{'00000000-0000-0000-0000-000000000001'} + $token=if($script:scenario -ceq 'token-after-write' -and $script:mutations -gt 0){'different-token'}else{'fixture-token'} + [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$machine};Selected=(Get-WelaSelectedSaclContext);Token=$token;AuditMasks='fixture59';Precedence='fixtureDWORD1'} +} +function Open-WelaRegistryRecoveryTarget { + param($Definition) + $object=[pscustomobject]@{WriteAttempted=$false;AfterObservation=$null} + $object|Add-Member ScriptMethod Remove { + param($Identity,$Descriptor,$Added) + Assert ((Test-Path (Join-Path $script:out 'pending.json')) -and $Identity -ceq $script:current.Identity -and $Descriptor -ceq $script:current.DescriptorBase64 -and $Added -ceq 'YWRkZWQ=') 'Durable intent and exact current removal arguments precede native adapter.' + if($script:scenario -ceq 'native-refusal'){throw 'Native prewrite refusal.'} + $this.WriteAttempted=$true;$script:mutations++;$script:current=Clone $script:before;$script:current.Identity=$nativePath+':1002';$this.AfterObservation=Clone $script:current + if($script:scenario -ceq 'native-partial'){throw 'Native write completed but after-state is unverified.'} + if($script:scenario -ceq 'original-after-write'){[IO.File]::AppendAllText($script:originalPath,' ')} + if($script:scenario -ceq 'artifact-after-write'){[IO.File]::AppendAllText((Join-Path $script:out 'pending.json'),' ')} + Clone $script:current + } + $object|Add-Member ScriptMethod Dispose {if($script:scenario -ceq 'dispose-failure'){throw 'Native privilege restore failed.'}} + $object +} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-registry-recovery-unit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +function New-Original { + $script:scenario='';$script:mutations=0;$script:current=Clone $script:before + $script:caseRoot=Join-Path $temp ([guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $script:caseRoot + $script:originalPath=Join-Path $script:caseRoot 'original.json';$script:journal=Join-Path $script:caseRoot 'journal';$script:resultPath=Join-Path $script:caseRoot 'result.json' + $null=Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -Ids $fixtureSelection.Id -IncludeOptional -IncludeChildren -ResultsPath $script:originalPath + $result=Invoke-WelaSelectedSacl -Action Configure -Profile wela-2.2.0 -Ids $fixtureSelection.Id -IncludeOptional -IncludeChildren -PlanPath $script:originalPath -BackupPath $script:journal -ResultsPath $script:resultPath -Auto + Assert ($result.Results[0].Status -ceq 'Applied') 'Original portable history comes from the real shared selected-SACL executor with only native boundaries replaced.' + $script:pendingPath=Join-Path $script:journal ($fixtureSelection.Id+'.pending.json');$script:confirmedPath=Join-Path $script:journal ($fixtureSelection.Id+'.confirmed.json') +} +function Build-Plan {New-WelaRegistryRecoveryPlan $script:originalPath $script:pendingPath $script:confirmedPath $script:resultPath} +function Prepare-Recovery { + New-Original + $script:review=Join-Path $script:caseRoot 'review' + $report=Invoke-WelaRegistrySaclRecovery -OriginalPlanPath $script:originalPath -PendingPath $script:pendingPath -ConfirmedPath $script:confirmedPath -OriginalResultsPath $script:resultPath -OutputPath $script:review + Assert ($report.Status -ceq 'ReviewRequired' -and -not $report.WriteAttempted) ('Plan failed: '+$report.Diagnostic) + $script:planPath=Join-Path $script:review 'plan.json';$script:hash=$report.PlanHash;$script:out=Join-Path $script:caseRoot 'restore' +} +function Restore-Review {param([switch]$OmitReduction,[switch]$OmitInheritance) Invoke-WelaRegistrySaclRecovery -Action Restore -PlanPath $script:planPath -PlanHash $script:hash -OutputPath $script:out -AllowAuditReduction:(-not $OmitReduction) -AllowInheritance:(-not $OmitInheritance)} +try{ + Prepare-Recovery;$result=Restore-Review + Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $script:mutations -eq 1 -and $result.ReadyRuleCredit -eq 0) ('Exact recovery failed: '+$result.Diagnostic) + Assert ((Test-Path (Join-Path $script:out 'pending.json')) -and (Test-Path (Join-Path $script:out 'confirmed.json'))) 'Separate durable intent and completion exist.' + foreach($artifact in $result.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $script:out $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained recovery hash matches real bytes.'} + $script:out=Join-Path $script:caseRoot 'replay';$replay=Restore-Review;Assert ($replay.Status -ceq 'Refused' -and -not $replay.WriteAttempted -and $script:mutations -eq 1) 'Recovered original plan cannot remove another ACE.' + foreach($case in @('reduction','inheritance')){Prepare-Recovery;$result=Restore-Review -OmitReduction:($case -ceq 'reduction') -OmitInheritance:($case -ceq 'inheritance');Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted -and -not(Test-Path (Join-Path $script:out 'pending.json'))) 'Each consent refuses before intent and mutation.'} + $mutations=@( + @{File='originalPath';Change={$args[0].Kind=$true};Pattern='mistyped'}, + @{File='originalPath';Change={$args[0].IncludeChildren=$false};Pattern='child consent'}, + @{File='originalPath';Change={$args[0].Rows[0].Status=$true};Pattern='mistyped'}, + @{File='originalPath';Change={$args[0].Rows[0].Definition.Kind=$true};Pattern='mistyped'}, + @{File='originalPath';Change={$args[0].Rows[0].Before.Kind=$true};Pattern='metadata|registry|catalog'}, + @{File='originalPath';Change={$args[0].Sources[0].Sha256=$true};Pattern='mistyped'}, + @{File='originalPath';Change={$args[0].Sources[0].Path=$true};Pattern='mistyped'}, + @{File='originalPath';Change={$args[0].Rows[0].Ace.Flags='194'};Pattern='integer'}, + @{File='originalPath';Change={$args[0].Rows[0].Before.Owner='S-1-1-0'};Pattern='metadata'}, + @{File='originalPath';Change={$args[0].Rows[0].DescendantsBefore.Status=$true};Pattern='mistyped'}, + @{File='originalPath';Change={$args[0].Rows[0].DescendantsBefore.Entries=@('child')};Pattern='empty'}, + @{File='originalPath';Change={$args[0].Rows[0].DescendantsBefore.Maximum=129};Pattern='empty'}, + @{File='pendingPath';Change={$args[0].State=$true};Pattern='mistyped'}, + @{File='pendingPath';Change={$args[0].ContextKey='other'};Pattern='scope'}, + @{File='pendingPath';Change={$args[0].After=$args[0].Before};Pattern='Pending'}, + @{File='confirmedPath';Change={$args[0].Kind=$true};Pattern='mistyped'}, + @{File='confirmedPath';Change={$args[0].Before.DaclBase64='changed'};Pattern='metadata'}, + @{File='confirmedPath';Change={$args[0].DescendantsAfter.Diagnostics=@('incomplete')};Pattern='empty'}, + @{File='confirmedPath';Change={$args[0].DescendantVerification.Status=$true};Pattern='mistyped'}, + @{File='resultPath';Change={$args[0].Results[0].Status=$true};Pattern='mistyped'}, + @{File='resultPath';Change={$args[0].DryRun=$true};Pattern='non-dry-run'}, + @{File='resultPath';Change={$args[0].ExitCode=$true};Pattern='integer'}, + @{File='resultPath';Change={$args[0].Plan.SchemaVersion=$true};Pattern='integer'}, + @{File='resultPath';Change={$args[0].BackupPath='somewhere-else'};Pattern='Receipt paths'}, + @{File='originalPath';Change={$args[0].CapturedUtc=[DateTime]::UtcNow.AddDays(1).ToString('o')};Pattern='timestamps'} + ) + foreach($test in $mutations){New-Original;$path=Get-Variable -Name $test.File -ValueOnly;$data=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText($path));&$test.Change $data;Save $path $data;Throws {Build-Plan} $test.Pattern;Assert ($script:mutations -eq 0) 'Invalid original history cannot reach a native writer.'} + foreach($case in @('descriptor','lastwrite','children')){Prepare-Recovery;if($case -ceq 'descriptor'){$script:current.DescriptorBase64='ZGlmZmVyZW50'}elseif($case -ceq 'lastwrite'){$script:current.Identity=$nativePath+':9999'}else{$script:scenario='children'};$result=Restore-Review;Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted) 'Current descriptor, benign-value last-write and child drift refuse recovery.'} + foreach($case in @('source-after-pending','plan-after-pending','native-refusal','native-partial','token-after-write','host-after-write','source-after-write','original-after-write','artifact-after-write','dispose-failure')){ + Prepare-Recovery;$script:scenario=$case;$result=Restore-Review + $attempted=$case -in @('native-partial','token-after-write','host-after-write','source-after-write','original-after-write','artifact-after-write','dispose-failure') + Assert ($result.ExitCode -eq 1 -and $result.WriteAttempted -eq $attempted -and $result.Status -ceq $(if($attempted){'WriteAttemptedUnverified'}else{'Refused'})) ("Failure state $case : "+$result.Diagnostic) + Assert (-not(Test-Path (Join-Path $script:out 'confirmed.json')) -and (Test-Path (Join-Path $script:out 'pending.json'))) 'Unverified operations retain intent but never confirmed completion.' + } + Prepare-Recovery;$forged=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText($script:planPath));$forged.AddedAce=$true;Save $script:planPath $forged;$script:hash=(Get-FileHash -LiteralPath $script:planPath).Hash.ToLowerInvariant();$result=Restore-Review;Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted) 'A freshly hashed forged instruction cannot replace the independently rebuilt plan.' + Prepare-Recovery;[IO.File]::AppendAllText($script:planPath,' ');$result=Restore-Review;Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted) 'Exact reviewed file hash refuses byte drift.' + New-Original;$text=[IO.File]::ReadAllText($script:originalPath);[IO.File]::WriteAllText($script:originalPath,($text -replace '"Kind"\s*:\s*"WelaSelectedSaclPlan"','"Kind": "WelaSelectedSaclPlan", "Kind": true'));Throws {Build-Plan} 'Duplicate|duplicate' + New-Original;$plan=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText($script:originalPath));$plan.CapturedUtc=([DateTimeOffset]::Parse([string]$plan.CapturedUtc)).UtcDateTime;Save $script:originalPath $plan;$null=Build-Plan;Assert $true 'Canonical UTC DateTime materialization remains supported.' +}finally{if(Test-Path -LiteralPath $temp){Remove-Item -LiteralPath $temp -Recurse -Force}} +Write-Host "Passed $script:count registry recovery assertions; only native/context boundaries mocked." +$global:LASTEXITCODE=0 diff --git a/tests/RegistrySaclRecovery.Windows.Tests.ps1 b/tests/RegistrySaclRecovery.Windows.Tests.ps1 index 655c5e63..00dae18a 100644 --- a/tests/RegistrySaclRecovery.Windows.Tests.ps1 +++ b/tests/RegistrySaclRecovery.Windows.Tests.ps1 @@ -89,6 +89,37 @@ try { Assert ((Read-Receipt 'restored/pending.json').State -ceq 'Pending' -and (Read-Receipt 'restored/confirmed.json').State -ceq 'Confirmed') 'Distinct durable intent and verified completion receipts exist.' $replay=Join-Path $root 'replay';Public 'replay' ($restoreArgs+@('-RegistryRecoveryOutputPath',$replay,'-RegistryRecoveryAllowAuditReduction','-RegistryRecoveryAllowInheritance')) 1 Assert ((Read-Receipt 'replay/manifest.json').Status -ceq 'Refused' -and -not (Read-Receipt 'replay/manifest.json').WriteAttempted) 'Old reviewed restore refuses replay.' + # A second genuine public addition creates fresh history before a benign value edit. + $secondPlan=Join-Path $root 'value-plan.json';$secondJournal=Join-Path $root 'value-journal';$secondResults=Join-Path $root 'value-results.json' + Public 'value-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$secondPlan)) + Public 'value-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$secondPlan,'-BackupPath',$secondJournal,'-ResultsPath',$secondResults,'-Auto')) + Assert ((Read-Receipt 'value-results.json').Results[0].Status -ceq 'Applied') 'Fresh recovery scenario starts from another genuine Applied addition.' + $valueAfter=Get-WelaSelectedSaclSnapshot $selected.Definition;$write=$hive.WriteProbe();Save 'benign-value-write.json' $write;$hive.AssertValues($true) + $valueNow=Get-WelaSelectedSaclSnapshot $selected.Definition + Assert ($valueNow.Identity -cne $valueAfter.Identity -and $valueNow.DescriptorBase64 -ceq $valueAfter.DescriptorBase64) 'One benign fixture value edit changes actual last-write identity without changing the SACL.' + $valueReview=Join-Path $root 'value-drift' + Public 'value-drift' @('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$secondPlan,'-RegistryRecoveryPendingPath',(Join-Path $secondJournal ($selected.Id+'.pending.json')),'-RegistryRecoveryConfirmedPath',(Join-Path $secondJournal ($selected.Id+'.confirmed.json')),'-RegistryRecoveryOriginalResultsPath',$secondResults,'-RegistryRecoveryOutputPath',$valueReview) 1 + $refusal=Read-Receipt 'value-drift/manifest.json';Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and $refusal.Diagnostic -match 'last-write identity') 'Benign value drift is refused without artificial historical-identity relaxation.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $valueNow)) 'Value-drift refusal preserves exact current native state.' + # Start a separate owned hive for child drift; never rewrite historical timestamps. + $firstSid=$hive.Sid;$hive.Dispose();Assert ((Key (Hives)) -ceq (Key $beforeHives)) 'First owned hive is unloaded before the next isolated scenario.' + Save 'first-hive-unloaded.json' ([pscustomobject]@{Sid=$firstSid;Loaded=$hive.Loaded;SeedCreated=$hive.SeedCreated}) + $hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'second-owned.dat'));$hive.Prepare();$hive.CreateRunOnce();$hive.AssertValues($false) + $providerPath='Registry::HKEY_USERS'+$hive.Sid+'SoftwareMicrosoftWindowsCurrentVersionRunOnce' + Public 'child-catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $root 'child-catalog.json')) + $childCatalog=Read-Receipt 'child-catalog.json';$childRows=@($childCatalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath});Assert ($childRows.Count -eq 1) 'Child scenario resolves only its separate owned catalog target.' + $childSelected=$childRows[0];$childPlan=Join-Path $root 'child-plan.json';$childJournal=Join-Path $root 'child-journal';$childResults=Join-Path $root 'child-results.json' + $childSelection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$childSelected.Id,'-IncludeOptional','-TargetSaclIncludeChildren') + Public 'child-plan' ($childSelection+@('-TargetSaclAction','Plan','-ResultsPath',$childPlan)) + Public 'child-configure' ($childSelection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$childPlan,'-BackupPath',$childJournal,'-ResultsPath',$childResults,'-Auto')) + Assert ((Read-Receipt 'child-results.json').Results[0].Status -ceq 'Applied') 'Child scenario also uses a genuine public Apply with empty historical descendants.' + $childKey=[Microsoft.Win32.Registry]::Users.CreateSubKey($hive.Sid+'SoftwareMicrosoftWindowsCurrentVersionRunOnceOwnedChild');$childKey.Dispose() + $childAfter=Get-WelaSelectedSaclSnapshot $childSelected.Definition;Save 'child-drift-native.json' $childAfter + $childReview=Join-Path $root 'child-drift' + Public 'child-drift' @('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$childPlan,'-RegistryRecoveryPendingPath',(Join-Path $childJournal ($childSelected.Id+'.pending.json')),'-RegistryRecoveryConfirmedPath',(Join-Path $childJournal ($childSelected.Id+'.confirmed.json')),'-RegistryRecoveryOriginalResultsPath',$childResults,'-RegistryRecoveryOutputPath',$childReview) 1 + $refusal=Read-Receipt 'child-drift/manifest.json';Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and $refusal.Diagnostic -match 'empty registry descendant') 'A real new child refuses recovery before any write.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $childSelected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $childAfter)) 'Child-drift refusal preserves exact current parent security state.' + $hive.AssertValues($false) Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'All public operations preserve prepared auditing.' Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'All native fixture security/backup/restore privilege attributes restored.' }catch{$failure=$_}finally {