diff --git a/.gitattributes b/.gitattributes index 893c0ce4..f6f78cf6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -81,3 +81,10 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf # Reviewed channel restoration binds exact installed source bytes. /scripts/ChannelRecovery.ps1 text eol=lf /tests/ChannelRecovery*.ps1 text eol=lf + +# Registry recovery plans bind identical source bytes across native hosts. +/scripts/RegistrySaclRecovery* text eol=lf +/tests/RegistrySaclRecovery* text eol=lf +/scripts/SelectedSaclDescendants.ps1 text eol=lf +/scripts/AuditRecovery.ps1 text eol=lf +/scripts/EvtxRecovery.ps1 text eol=lf diff --git a/.github/workflows/registry-sacl-recovery.yml b/.github/workflows/registry-sacl-recovery.yml new file mode 100644 index 00000000..c4468833 --- /dev/null +++ b/.github/workflows/registry-sacl-recovery.yml @@ -0,0 +1,43 @@ +name: Native reviewed registry SACL recovery +on: + push: + branches: ['**'] + paths: + - 'tests/RegistrySacl*' + - 'scripts/RegistrySaclRecovery*' + - 'scripts/SelectedSacl*' + - 'scripts/TargetedSaclPlanning.ps1' + - 'WELA.ps1' + - '.github/workflows/registry-sacl-recovery.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + registry-sacl-recovery: + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Actual public registry lifecycle in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/RegistrySaclRecovery.Windows.Tests.ps1 -AllowDisposableHiveWrite + - name: Actual public registry lifecycle in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/RegistrySaclRecovery.Windows.Tests.ps1 -AllowDisposableHiveWrite + - name: Retain public receipts, actual XML and exact cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: registry-sacl-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-registry-recovery-*/ + if-no-files-found: error diff --git a/WELA.ps1 b/WELA.ps1 index 7cbe077d..c24c20d4 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -104,6 +104,16 @@ [ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan', [string]$ProbeOutputPath, [ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15, + [ValidateSet('Plan','Restore')][string]$RegistryRecoveryAction = 'Plan', + [string]$RegistryRecoveryOriginalPlanPath, + [string]$RegistryRecoveryPendingPath, + [string]$RegistryRecoveryConfirmedPath, + [string]$RegistryRecoveryOriginalResultsPath, + [string]$RegistryRecoveryPlanPath, + [string]$RegistryRecoveryPlanHash, + [string]$RegistryRecoveryOutputPath, + [switch]$RegistryRecoveryAllowAuditReduction, + [switch]$RegistryRecoveryAllowInheritance, [ValidateSet('Audit','Plan','Configure')][string]$TargetSaclAction = 'Audit', [string]$TargetSaclProfile, [string[]]$TargetSaclId, @@ -272,6 +282,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") . (Join-Path $ScriptRoot "scripts/SelectedSaclConfiguration.ps1") +. (Join-Path $ScriptRoot "scripts/RegistrySaclRecovery.ps1") . (Join-Path $ScriptRoot "scripts/GpoAuditPackages.ps1") . (Join-Path $ScriptRoot "scripts/IntuneAuditExport.ps1") . (Join-Path $ScriptRoot "scripts/EvtxRecovery.ps1") @@ -1982,6 +1993,7 @@ Remove these options from automation wrappers; check WELA's exit code instead. Usage: ./WELA.ps1 dns-analytical -Help # Dedicated DNS Server direct-channel lifecycle ./WELA.ps1 wec-runtime -WecRuntimeId subscription-id -ResultsPath new-runtime.json + ./WELA.ps1 registry-sacl-recovery -Help # Reviewed removal of one proven registry audit ACE ./WELA.ps1 targeted-sacl -Help # Selected existing local SACL targets; read-only by default ./WELA.ps1 gpo-create -Help # Create only a new disabled, unlinked GPO from reviewed genuine backup ./WELA.ps1 gpo-package -GpoAction Plan -GpoProfile wela-2.2.0 -Role Client -Build 26100 @@ -2100,6 +2112,12 @@ if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ if ($Cmd -eq 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','DnsAction','DnsState','DnsRetention','DnsMinimumBytes','DnsArchiveMaximumBytes','AllowDnsTraceReset','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) { throw 'dns-analytical accepts only dedicated DNS lifecycle and report options. No command was run.' } +if ($Cmd -ne 'registry-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'RegistryRecovery*' }).Count) { + throw 'RegistryRecovery options require registry-sacl-recovery. No command was run.' +} +if ($Cmd -eq 'registry-sacl-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','RegistryRecoveryAction','RegistryRecoveryOriginalPlanPath','RegistryRecoveryPendingPath','RegistryRecoveryConfirmedPath','RegistryRecoveryOriginalResultsPath','RegistryRecoveryPlanPath','RegistryRecoveryPlanHash','RegistryRecoveryOutputPath','RegistryRecoveryAllowAuditReduction','RegistryRecoveryAllowInheritance','Help') }).Count)) { + throw 'registry-sacl-recovery accepts only its dedicated options. Use read-only Plan before explicitly consented Restore.' +} if ($Cmd -ne 'targeted-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'TargetSacl*' }).Count) { throw 'TargetSacl options require targeted-sacl. No command was run.' } @@ -2318,6 +2336,13 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'registry-sacl-recovery' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 registry-sacl-recovery -RegistryRecoveryOriginalPlanPath original-plan.json -RegistryRecoveryPendingPath target.pending.json -RegistryRecoveryConfirmedPath target.confirmed.json -RegistryRecoveryOriginalResultsPath original-results.json -RegistryRecoveryOutputPath new-review-directory. Restore: -RegistryRecoveryAction Restore -RegistryRecoveryPlanPath review/plan.json -RegistryRecoveryPlanHash sha256 -RegistryRecoveryOutputPath new-evidence-directory -RegistryRecoveryAllowAuditReduction -RegistryRecoveryAllowInheritance. One proven registry-root audit ACE only; historical/current descendants must be empty. Value/child/descriptor drift refuses. Pending evidence may describe a partial removal; no automatic rollback or atomic-tree guarantee. See docs/registry-sacl-recovery.md.'; return } + $report=Invoke-WelaRegistrySaclRecovery -Action $RegistryRecoveryAction -OriginalPlanPath $RegistryRecoveryOriginalPlanPath -PendingPath $RegistryRecoveryPendingPath -ConfirmedPath $RegistryRecoveryConfirmedPath -OriginalResultsPath $RegistryRecoveryOriginalResultsPath -PlanPath $RegistryRecoveryPlanPath -PlanHash $RegistryRecoveryPlanHash -OutputPath $RegistryRecoveryOutputPath -AllowAuditReduction:$RegistryRecoveryAllowAuditReduction -AllowInheritance:$RegistryRecoveryAllowInheritance + $report + if ($report.ExitCode -ne 0) { exit $report.ExitCode } + return + } 'targeted-sacl' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 targeted-sacl -TargetSaclProfile profile-id [-TargetSaclId id,...] [-TargetSaclAction Audit|Plan] [-IncludeOptional] [-TargetSaclIncludeChildren] [-ResultsPath new-plan.json]. Configure requires -TargetSaclAction Configure -TargetSaclPlanPath reviewed.json -TargetSaclId same-ids [-TargetSaclIncludeChildren] [-IncludeOptional] [-DryRun] [-Auto] [-BackupPath new-directory] [-ResultsPath new-results.json]. Existing local targets only; IncludeChildren requires a complete reviewed capture of at most 128 descendants per root, depth 16. See docs/selected-sacl-configuration.md.'; return } $report=Invoke-WelaSelectedSacl -Action $TargetSaclAction -Profile $TargetSaclProfile -Ids $TargetSaclId -PlanPath $TargetSaclPlanPath -IncludeOptional:$IncludeOptional -IncludeChildren:$TargetSaclIncludeChildren -DryRun:$DryRun -Auto:$Auto -BackupPath $BackupPath -ResultsPath $ResultsPath diff --git a/scripts/RegistrySaclRecovery.ps1 b/scripts/RegistrySaclRecovery.ps1 new file mode 100644 index 00000000..80d47d9f --- /dev/null +++ b/scripts/RegistrySaclRecovery.ps1 @@ -0,0 +1,183 @@ +# One proven selected-root audit ACE, only with empty historical/current descendants. +function Get-WelaRegistryRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 32 -Compress} +function Assert-WelaRegistryRecoveryText {param($Value,[string[]]$Names) foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped registry recovery text: '+$name)}}} +function Assert-WelaRegistryRecoveryNumber {param($Value) if($Value -isnot [int] -and $Value -isnot [long]){throw 'Registry recovery requires an integer.'}} +function Initialize-WelaRegistryRecoveryNative { + $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'RegistrySaclRecoveryNative.cs'));$hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.RegistrySaclRecovery.Descriptor' -as [type])){ + $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff);$marker='__WELA_REGISTRY_SACL_RECOVERY_SOURCE_SHA256__' + if(($source.Split(@($marker),[StringSplitOptions]::None)).Count -ne 2){throw 'Unexpected native registry recovery source binding.'} + Add-Type -TypeDefinition $source.Replace($marker,$hash) -ErrorAction Stop + } + if([Wela.RegistrySaclRecovery.Descriptor]::SourceSha256 -cne $hash){throw 'Loaded registry recovery code differs; start a fresh PowerShell process.'} +} +function Get-WelaRegistryRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/RegistrySaclRecovery.ps1','scripts/RegistrySaclRecoveryNative.cs','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1','scripts/TargetedSaclPlanning.ps1','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/CustomAuditProfiles.ps1','scripts/AuditRecovery.ps1','scripts/EvtxRecovery.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','modules/NativeProviders.psm1','config/audit_profiles.json','config/audit_sacl_targets.json','config/control_applicability.json','config/baselines.json')){ + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$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.'}} + Initialize-WelaRegistryRecoveryNative + $token=[Wela.RegistrySaclRecovery.TokenReader]::Snapshot();$identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Registry SACL recovery requires an elevated operator.'}}finally{$identity.Dispose()} + $actualMasks=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($id in @($actualMasks.Keys|Sort-Object)){$masks[$id]=$actualMasks[$id]} + [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);Selected=(Get-WelaSelectedSaclContext);Token=$token;AuditMasks=$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy)} +} +function Read-WelaRegistryRecoveryInput { + param([string]$Path) + $file=Read-WelaWecUpdateFile $Path 4194304 + if(-not $file.Text){throw 'Original recovery evidence is empty.'} + [pscustomobject]@{Path=$file.Path;Sha256=$file.Hash;Data=(ConvertFrom-WelaEvtxJson $file.Text)} +} +function Assert-WelaRegistryRecoverySnapshot { + param($Snapshot,$Definition) + Assert-WelaEvtxObject $Snapshot @('Path','Kind','Identity','IsDirectory','DescriptorBase64','Owner','Group','DaclBase64','ControlFlags','SecurityInformation','DescriptorScope','Aces') + Assert-WelaRegistryRecoveryText $Snapshot @('Path','Kind','Identity','DescriptorBase64','DescriptorScope') + $path=Resolve-WelaSelectedSaclNativePath $Definition + if($Snapshot.Kind -cne 'Registry' -or $Snapshot.Path -cne $path -or $Snapshot.IsDirectory -isnot [bool] -or $Snapshot.IsDirectory -or $Snapshot.Identity -cnotmatch ('^'+[regex]::Escape($path)+':[0-9]+$') -or $Snapshot.Aces -isnot [array]){throw 'Only exact historical registry snapshots are supported.'} + foreach($name in @('ControlFlags','SecurityInformation')){Assert-WelaRegistryRecoveryNumber $Snapshot.$name} + foreach($ace in $Snapshot.Aces){ + Assert-WelaEvtxObject $ace @('Binary','Type','Flags','Mask','Sid','Ordinary');Assert-WelaRegistryRecoveryText $ace @('Binary') + 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)) + if((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)){throw 'Historical metadata differs from its native descriptor bytes.'} +} +function Assert-WelaRegistryRecoveryEmpty { + param($Inventory,$Root) + Assert-WelaEvtxObject $Inventory @('Status','Maximum','MaximumDepth','StartedUtc','CompletedUtc','Root','Entries','Diagnostics') + Assert-WelaRegistryRecoveryText $Inventory @('Status');Assert-WelaRegistryRecoveryNumber $Inventory.Maximum;Assert-WelaRegistryRecoveryNumber $Inventory.MaximumDepth + if($Inventory.Status -cne 'Complete' -or $Inventory.Maximum -ne 128 -or $Inventory.MaximumDepth -ne 16 -or $Inventory.Entries -isnot [array] -or $Inventory.Entries.Count -ne 0 -or $Inventory.Diagnostics -isnot [array] -or $Inventory.Diagnostics.Count -ne 0 -or (Get-WelaSelectedSaclSnapshotKey $Inventory.Root) -cne (Get-WelaSelectedSaclSnapshotKey $Root)){throw 'Recovery requires complete exact empty historical/current descendants.'} + $start=ConvertTo-WelaArrivalUtc $Inventory.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Inventory.CompletedUtc + if($start -gt $end -or $end -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Descendant timestamps are invalid.'} +} +function Assert-WelaRegistryRecoveryDescendantOutcome { + param($Value) + Assert-WelaEvtxObject $Value @('Status','Scope','Ownership','Outcomes','Diagnostics');Assert-WelaRegistryRecoveryText $Value @('Status','Scope','Ownership') + if($Value.Status -cne 'Observed' -or $Value.Outcomes -isnot [array] -or $Value.Outcomes.Count -ne 0 -or $Value.Diagnostics -isnot [array] -or $Value.Diagnostics.Count -ne 0){throw 'Historical descendant verification is incomplete or nonempty.'} +} +function Get-WelaRegistryRecoverySnapshot { + param($Definition) + if($Definition.Kind -isnot [string] -or $Definition.Kind -cne 'Registry'){throw 'Only registry targets are supported.'} + Initialize-WelaRegistryRecoveryNative;$target=[Wela.RegistrySaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $Definition)) + try{$target.Read()}finally{$target.Dispose()} +} +function Get-WelaRegistryRecoveryAddition {param($Before,$After,$Ace) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Descriptor]::AddedAce($Before.DescriptorBase64,$After.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)} +function New-WelaRegistryRecoveryPlan { + param([string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath) + $context=Get-WelaRegistryRecoveryContext;$sources=Get-WelaRegistryRecoverySources + $files=[ordered]@{};foreach($entry in @(@('OriginalPlan',$OriginalPlanPath),@('Pending',$PendingPath),@('Confirmed',$ConfirmedPath),@('Results',$ResultsPath))){$files[$entry[0]]=Read-WelaRegistryRecoveryInput $entry[1]} + if(@($files.Values.Path|Sort-Object -Unique).Count -ne 4){throw 'Four distinct original evidence files are required.'} + $plan=$files.OriginalPlan.Data;$pending=$files.Pending.Data;$confirmed=$files.Confirmed.Data;$result=$files.Results.Data + $planFields=@('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory') + $rowFields=@('Id','DefinitionKey','Definition','Before','Ace','Status','Diagnostic','After','DescendantsBefore','DescendantsAfter','DescendantVerification') + 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.'} + $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-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 + $selection=@($catalog.Rows|Where-Object Id -CEQ $row.Id) + if($selection.Count -ne 1 -or $selection[0].DefinitionKey -cne $row.DefinitionKey -or (Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey -or (Get-WelaRegistryRecoveryKey $selection[0].Definition) -cne (Get-WelaRegistryRecoveryKey $row.Definition)){throw 'Original target is not the exact currently source-bound catalog selection.'} + Assert-WelaRegistryRecoverySnapshot $row.Before $row.Definition;Assert-WelaRegistryRecoveryEmpty $row.DescendantsBefore $row.Before + $ace=Get-WelaSelectedSaclAce $row.Definition $row.Before -IncludeChildren + Assert-WelaEvtxObject $row.Ace @('Sid','Mask','Flags','RequiredPolicyMask');Assert-WelaRegistryRecoveryText $row.Ace @('Sid');foreach($name in @('Mask','Flags','RequiredPolicyMask')){Assert-WelaRegistryRecoveryNumber $row.Ace.$name} + if((Get-WelaRegistryRecoveryKey $ace) -cne (Get-WelaRegistryRecoveryKey $row.Ace) -or $ace.Flags -notin @(64,128,192,66,130,194) -or (Test-WelaSelectedSaclAce $row.Before $ace)){throw 'Original ACE is mistyped, inherited or already covered.'} + $receiptFields=@('SchemaVersion','Kind','State','RecordedUtc','Computer','ContextKey','Id','Sources','Definition','Before','Ace','After','DescendantsBefore','DescendantsAfter','DescendantVerification','Ownership') + 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 + 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.'} + Assert-WelaRegistryRecoveryEmpty $receipt.DescendantsBefore $receipt.Before + } + if($pending.State -cne 'Pending' -or $null -ne $pending.After -or $null -ne $pending.DescendantsAfter -or $null -ne $pending.DescendantVerification -or $confirmed.State -cne 'Confirmed' -or $null -eq $confirmed.After -or (ConvertTo-WelaArrivalUtc $pending.RecordedUtc) -ne (ConvertTo-WelaArrivalUtc $confirmed.RecordedUtc)){throw 'A matching original Pending and Confirmed pair is required.'} + Assert-WelaRegistryRecoverySnapshot $confirmed.After $row.Definition;Assert-WelaRegistryRecoveryEmpty $confirmed.DescendantsAfter $confirmed.After;Assert-WelaRegistryRecoveryDescendantOutcome $confirmed.DescendantVerification + # Last-write metadata can change during the original SACL write; current state must match its exact observed After. + $added=Get-WelaRegistryRecoveryAddition $row.Before $confirmed.After $ace + 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') + 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.'}} + Assert-WelaRegistryRecoveryEmpty $applied.DescendantsBefore $applied.Before;Assert-WelaRegistryRecoveryEmpty $applied.DescendantsAfter $applied.After;Assert-WelaRegistryRecoveryDescendantOutcome $applied.DescendantVerification + foreach($name in @('Profile','IncludeOptional','IncludeChildren','Context','Sources')){if((Get-WelaRegistryRecoveryKey $result.Plan.$name) -cne (Get-WelaRegistryRecoveryKey $plan.$name)){throw 'Completed plan context differs from original selection.'}} + $backup=Resolve-WelaArrivalPath $result.BackupPath + if($files.Pending.Path -ine (Join-Path $backup ($row.Id+'.pending.json')) -or $files.Confirmed.Path -ine (Join-Path $backup ($row.Id+'.confirmed.json'))){throw 'Receipt paths do not match recorded backup directory.'} + $originalTime=ConvertTo-WelaArrivalUtc $plan.CapturedUtc;$configuredTime=ConvertTo-WelaArrivalUtc $result.Plan.CapturedUtc;$receiptTime=ConvertTo-WelaArrivalUtc $pending.RecordedUtc + if($originalTime -gt $configuredTime -or $configuredTime -gt $receiptTime -or $receiptTime -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Original timestamps are reversed or in the future.'} + $current=Get-WelaRegistryRecoverySnapshot $row.Definition + if((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)){throw 'Current registry last-write identity or full descriptor differs from completed After; value changes also require manual assessment.'} + $inputFiles=[ordered]@{};foreach($name in $files.Keys){$inputFiles[$name]=[pscustomobject]@{Path=$files[$name].Path;Sha256=$files[$name].Sha256}} + $recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistrySaclRecoveryPlan';Id=$row.Id;Context=$context;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;RequiresAuditReductionConsent=$true;RequiresInheritanceConsent=$true;HistoricalBinding='Original version1 records contain host context/source hashes, not authenticated historical operator identity. Registry path/last-write metadata cannot prove durable key identity.';Outcome='Remove one proven explicit registry-root audit ACE; preserve all other descriptor components and ACE order. Empty or null present SACL can remain. Empty-child observations are not an atomic tree guarantee.';ReadyRuleCredit=0} + Assert-WelaRegistryRecoveryFresh $recovery + $recovery +} +function Assert-WelaRegistryRecoveryBindings { + param($Plan) + if((Get-WelaRegistryRecoveryKey (Get-WelaRegistryRecoverySources)) -cne (Get-WelaRegistryRecoveryKey $Plan.Sources) -or (Get-WelaRegistryRecoveryKey (Get-WelaRegistryRecoveryContext)) -cne (Get-WelaRegistryRecoveryKey $Plan.Context)){throw 'Recovery source, host, logon, token or audit policy changed.'} + foreach($entry in $Plan.OriginalFiles.PSObject.Properties){if((Read-WelaRegistryRecoveryInput $entry.Value.Path).Sha256 -cne $entry.Value.Sha256){throw 'Original recovery evidence changed.'}} +} +function Assert-WelaRegistryRecoveryFresh {param($Plan) Assert-WelaRegistryRecoveryBindings $Plan;if((Get-WelaSelectedSaclSnapshotKey (Get-WelaRegistryRecoverySnapshot $Plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $Plan.Expected)){throw 'Reviewed registry identity/descriptor changed before removal.'}} +function Open-WelaRegistryRecoveryTarget {param($Definition) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $Definition))} +function Invoke-WelaRegistrySaclRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowAuditReduction,[switch]$AllowInheritance) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $OriginalPlanPath -or -not $PendingPath -or -not $ConfirmedPath -or -not $OriginalResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $AllowAuditReduction -or $AllowInheritance){throw 'Plan requires four original evidence paths and a new output directory only.'} + }elseif(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or -not $OutputPath -or $OriginalPlanPath -or $PendingPath -or $ConfirmedPath -or $OriginalResultsPath){throw 'Restore requires only reviewed plan path/hash, new output and both explicit consents.'} + $output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistrySaclRecovery';Action=$Action;Status='Refused';ExitCode=1;PlanHash=$null;WriteAttempted=$false;Before=$null;After=$null;OriginalDescriptorBytesMatch=$false;Artifacts=@();OutputPath=$output;Diagnostic='';ReadyRuleCredit=0;PolicyChanges=0;Scope='One proven registry-root audit ACE; complete empty historical/current descendants only. No full descriptor rollback, historical key identity, atomic-tree, event or Sigma claim.'} + $target=$null + try { + if($Action -eq 'Plan'){ + $plan=New-WelaRegistryRecoveryPlan $OriginalPlanPath $PendingPath $ConfirmedPath $OriginalResultsPath + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' (Get-WelaRegistryRecoveryKey $plan);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256 + Assert-WelaRegistryRecoveryFresh $plan + $report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + $inputFile=Read-WelaRegistryRecoveryInput $PlanPath + if($inputFile.Sha256 -cne $PlanHash){throw 'Reviewed recovery plan hash differs.'} + $plan=$inputFile.Data;Assert-WelaRegistryRecoveryText $plan @('Kind') + if($plan.Kind -cne 'WelaRegistrySaclRecoveryPlan'){throw 'Unsupported recovery plan kind.'} + $inputs=$plan.OriginalFiles;$rebuilt=New-WelaRegistryRecoveryPlan $inputs.OriginalPlan.Path $inputs.Pending.Path $inputs.Confirmed.Path $inputs.Results.Path + if((Get-WelaRegistryRecoveryKey $plan) -cne (Get-WelaRegistryRecoveryKey $rebuilt)){throw 'Reviewed recovery plan is stale or modified.'} + if(-not $AllowAuditReduction -or -not $AllowInheritance){throw 'Restore requires explicit AllowAuditReduction and AllowInheritance: selected auditing is reduced and concurrent children can be affected.'} + $report.PlanHash=$PlanHash;$report.Before=$plan.Expected + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' (Get-WelaRegistryRecoveryKey $plan) + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'pending.json' (Get-WelaRegistryRecoveryKey ([pscustomobject]@{Kind='WelaRegistrySaclRecoveryIntent';State='Pending';PlanHash=$PlanHash;Before=$plan.Expected;RemoveAce=$plan.AddedAce;AllowAuditReduction=[bool]$AllowAuditReduction;AllowInheritance=[bool]$AllowInheritance;RecordedUtc=[DateTime]::UtcNow.ToString('o')})) + Assert-WelaRegistryRecoveryFresh $plan + if((Read-WelaRegistryRecoveryInput $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed recovery plan changed immediately before write.'} + foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved recovery receipt changed before write.'}} + $target=Open-WelaRegistryRecoveryTarget $plan.Definition + try{$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)}finally{$report.WriteAttempted=$target.WriteAttempted;if($target.AfterObservation){$report.After=$target.AfterObservation}} + $target.Dispose();$target=$null + if($null -ne $report.After){$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' (Get-WelaRegistryRecoveryKey $report.After)} + Assert-WelaRegistryRecoveryBindings $plan + if((Get-WelaSelectedSaclSnapshotKey (Get-WelaRegistryRecoverySnapshot $plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)){throw 'Final registry identity/descriptor or empty-child state differs after removal.'} + if((Read-WelaRegistryRecoveryInput $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed plan changed after write.'} + foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved recovery receipt changed after write.'}} + $report.OriginalDescriptorBytesMatch=$report.After.DescriptorBase64 -ceq $plan.BeforeAddition.DescriptorBase64 + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'confirmed.json' (Get-WelaRegistryRecoveryKey ([pscustomobject]@{Kind='WelaRegistrySaclRecoveryConfirmation';State='Confirmed';PlanHash=$PlanHash;Before=$plan.Expected;After=$report.After;RemoveAce=$plan.AddedAce;RecordedUtc=[DateTime]::UtcNow.ToString('o')})) + $report.Status='AddedAceRemoved';$report.ExitCode=0 + } + }catch{$report.Status=if($report.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message} + finally{if($target){try{$target.Dispose()}catch{$report.Status='WriteAttemptedUnverified';$report.ExitCode=1;$report.Diagnostic+=' Native cleanup failed: '+$_.Exception.Message}}} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaRegistryRecoveryKey $report) + $report +} diff --git a/scripts/RegistrySaclRecoveryNative.cs b/scripts/RegistrySaclRecoveryNative.cs new file mode 100644 index 00000000..4df99c67 --- /dev/null +++ b/scripts/RegistrySaclRecoveryNative.cs @@ -0,0 +1,206 @@ +// Empty-key registry recovery: remove one proven explicit selected-root audit ACE. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +namespace Wela.RegistrySaclRecovery { + public sealed class Ace { public string Binary; public int Type,Flags,Mask; public string Sid; public bool Ordinary; } + public sealed class Snapshot { + public string Path,Kind,Identity; public bool IsDirectory; + public string DescriptorBase64,Owner,Group,DaclBase64; public int ControlFlags,SecurityInformation; + public string DescriptorScope; public Ace[] Aces; + } + public static class Descriptor { + public const string SourceSha256="__WELA_REGISTRY_SACL_RECOVERY_SOURCE_SHA256__"; + public static string Bytes(GenericAcl value) { if(value==null)return null;byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); } + public static string Bytes(GenericAce value) { byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); } + static string Sid(SecurityIdentifier value) {return value==null?null:value.Value;} + public static RawSecurityDescriptor Parse(string value) { + byte[] b=Convert.FromBase64String(value); + if(b.Length<20||b.Length>1048576||Convert.ToBase64String(b)!=value)throw new InvalidOperationException("Invalid or noncanonical descriptor bytes."); + RawSecurityDescriptor sd=new RawSecurityDescriptor(b,0); + return sd; + } + static Dictionary Counts(RawAcl acl) { + Dictionary counts=new Dictionary(StringComparer.Ordinal); + if(acl!=null)foreach(GenericAce ace in acl){string b=Bytes(ace);if(!counts.ContainsKey(b))counts[b]=0;counts[b]++;} + return counts; + } + static void Outside(RawSecurityDescriptor before,RawSecurityDescriptor after,bool allowPresence) { + int mask=allowPresence?~16:~0; + if(Sid(before.Owner)!=Sid(after.Owner)||Sid(before.Group)!=Sid(after.Group)||Bytes(before.DiscretionaryAcl)!=Bytes(after.DiscretionaryAcl)||before.ResourceManagerControl!=after.ResourceManagerControl||(((int)before.ControlFlags)&mask)!=(((int)after.ControlFlags)&mask))throw new InvalidOperationException("Owner, group, DACL or preserved control/header fields differ."); + } + public static string AddedAce(string beforeBytes,string afterBytes,string sid,int mask,int flags) { + if((sid!="S-1-1-0"&&sid!="S-1-5-11")||mask<=0||(flags!=64&&flags!=128&&flags!=192&&flags!=66&&flags!=130&&flags!=194))throw new InvalidOperationException("Only an explicit ordinary selected-root audit ACE is supported."); + RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,true); + if(after.SystemAcl==null||after.SystemAcl.Revision!=(before.SystemAcl==null?2:before.SystemAcl.Revision))throw new InvalidOperationException("SACL revision changed during the claimed addition."); + if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){CommonAce common=entry as CommonAce;if(common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit&&common.SecurityIdentifier.Value==sid&&(int)common.AceFlags==flags&&(common.AccessMask&mask)==mask)throw new InvalidOperationException("Original descriptor already covered the requested audit ACE.");} + string added=Bytes(new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null)); + Dictionary remaining=Counts(after.SystemAcl); + if(!remaining.ContainsKey(added)||remaining[added]!=1)throw new InvalidOperationException("Expected exactly one new matching audit ACE."); + remaining[added]--; + if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){string b=Bytes(entry);if(!remaining.ContainsKey(b)||remaining[b]<1)throw new InvalidOperationException("An original ACE was changed or removed.");remaining[b]--;} + foreach(int count in remaining.Values)if(count!=0)throw new InvalidOperationException("The completed operation changed more than one audit ACE."); + int oldCount=before.SystemAcl==null?0:before.SystemAcl.Count;for(int i=0;i expected=Counts(before.SystemAcl),actual=Counts(after.SystemAcl); + if(!expected.ContainsKey(added)||expected[added]!=1)throw new InvalidOperationException("The selected audit ACE is no longer unique."); + expected[added]--; + foreach(KeyValuePair entry in expected){int count=actual.ContainsKey(entry.Key)?actual[entry.Key]:0;if(count!=entry.Value)throw new InvalidOperationException("Unrelated audit ACEs changed during removal.");actual.Remove(entry.Key);} + if(actual.Count!=0)throw new InvalidOperationException("Unexpected ACE appeared during removal."); + List ordered=new List();foreach(GenericAce entry in before.SystemAcl)if(Bytes(entry)!=added)ordered.Add(Bytes(entry));if(after.SystemAcl!=null){for(int i=0;i entries=new List(); + if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});} + return new Snapshot {Path=path,Kind="Registry",Identity=identity,IsDirectory=false,DescriptorBase64=encoded,Owner=Sid(sd.Owner),Group=Sid(sd.Group),DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,SecurityInformation=511,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Aces=entries.ToArray()}; + } + } + sealed class Privilege : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;} + [StructLayout(LayoutKind.Sequential)] struct TokenPrivileges {public uint Count;public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr value); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required); + IntPtr token;TokenPrivileges previous; + public Privilege(){IntPtr thread; + if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated recovery is unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try{Luid luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());TokenPrivileges request=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is unavailable.");} + catch{CloseHandle(token);token=IntPtr.Zero;throw;} + } + public void Dispose(){if(token==IntPtr.Zero)return;try{TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} + } + public sealed class Target : IDisposable { + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string name,uint options,uint access,out IntPtr key); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,IntPtr data,ref uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryInfoKeyW(IntPtr key,IntPtr cls,IntPtr clsSize,IntPtr reserved,IntPtr subKeys,IntPtr maxSubKey,IntPtr maxClass,IntPtr values,IntPtr maxValueName,IntPtr maxValue,IntPtr securitySize,out long written); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegEnumKeyExW(IntPtr key,uint index,StringBuilder name,ref uint length,IntPtr reserved,IntPtr cls,IntPtr clsLength,IntPtr written); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + [DllImport("ntdll.dll")] static extern int NtQueryKey(IntPtr key,int cls,IntPtr information,uint length,out uint resultLength); + [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); + [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + [DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl); + readonly string path,nativePath;readonly List keys=new List();IntPtr handle;Privilege privilege; + public bool WriteAttempted {get;private set;}public Snapshot AfterObservation {get;private set;} + public Target(string path){ + this.path=path; + if(String.IsNullOrEmpty(path)||path.IndexOfAny(new char[]{'/','*','?','%','\0'})>=0)throw new InvalidOperationException("Exact local registry path required."); + string[] parts=path.Split('\\');IntPtr root; + if(parts[0]=="HKEY_LOCAL_MACHINE"){root=new IntPtr(unchecked((int)0x80000002));nativePath="\\REGISTRY\\MACHINE";} + else if(parts[0]=="HKEY_USERS"){root=new IntPtr(unchecked((int)0x80000003));nativePath="\\REGISTRY\\USER";} + else throw new InvalidOperationException("Only selected HKLM/HKU keys are supported."); + if(parts.Length<2)throw new InvalidOperationException("Hive roots cannot be recovered."); + for(int i=1;i65536)throw new InvalidOperationException("Native registry name query is unavailable."); + IntPtr buffer=Marshal.AllocHGlobal((int)required); + try{uint actual;status=NtQueryKey(handle,3,buffer,required,out actual);if(status!=0||actual>required)throw new InvalidOperationException("Native registry name query failed.");int size=Marshal.ReadInt32(buffer);if(size<2||size%2!=0||size>required-4)throw new InvalidOperationException("Native registry name is malformed.");string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,4),size/2);if(!String.Equals(name,nativePath,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Held registry name differs from the selected path.");}finally{Marshal.FreeHGlobal(buffer);} + long written;int error=RegQueryInfoKeyW(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out written);if(error!=0)throw new Win32Exception(error,"Registry last-write observation failed.");return path+":"+written; + } + public void AssertEmpty(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");StringBuilder name=new StringBuilder(256);uint size=256;int error=RegEnumKeyExW(handle,0,name,ref size,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero);if(error==0||error==234)throw new InvalidOperationException("Recovery requires an observed empty registry descendant inventory.");if(error!=259)throw new Win32Exception(error,"Registry child enumeration is unknown.");} + public Snapshot Read(){ + string identity=Check();AssertEmpty();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,4,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined registry descriptor read failed.");byte[] bytes; + try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid native descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);} + AssertEmpty();if(Check()!=identity)throw new InvalidOperationException("Registry last-write identity changed during observation.");return Descriptor.Observe(path,identity,bytes); + } + public Snapshot Remove(string expectedIdentity,string expectedDescriptor,string added){ + Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Reviewed registry identity or descriptor changed before removal."); + RawSecurityDescriptor sd=Descriptor.Parse(before.DescriptorBase64);int index=-1; + if(sd.SystemAcl!=null)for(int i=0;i=0;i--)RegCloseKey(keys[i]);keys.Clear();handle=IntPtr.Zero;}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}} + } + public sealed class Group { public string Sid; public uint Attributes; } + public sealed class TokenPrivilege { public string Luid; public uint Attributes; } + public sealed class Token { + public string Sid, Name, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource; + public Group[] Groups; public TokenPrivilege[] Privileges; + } + public static class TokenReader { + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;} + [StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;} + [StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;} + [StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;} + [StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed); + static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");} + static IntPtr Read(IntPtr token,int cls,out int length) { + GetTokenInformation(token,cls,IntPtr.Zero,0,out length); + if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information."); + IntPtr data=Marshal.AllocHGlobal(length); + if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);} + return data; + } + static Token ReadToken(IntPtr token,string source) { + Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();} + int length;IntPtr p=Read(token,10,out length); + try {if(length4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List groups=new List();for(int i=0;iString.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);} + p=Read(token,3,out length); + try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List privileges=new List();for(int i=0;iString.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);} + return result; + } + [DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token); + public static Token Snapshot() { + IntPtr thread=IntPtr.Zero,process=IntPtr.Zero; + if(!OpenThreadToken(GetCurrentThread(),8,true,out thread)){int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);} + try { + if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error()); + if(IsTokenRestricted(process)||(thread!=IntPtr.Zero&&IsTokenRestricted(thread)))throw new InvalidOperationException("Restricted tokens are unsupported."); + Token primary=ReadToken(process,"Process"); + if(thread!=IntPtr.Zero)throw new InvalidOperationException("Impersonated recovery is unsupported."); + return primary; + } finally {if(process!=IntPtr.Zero)CloseHandle(process);if(thread!=IntPtr.Zero)CloseHandle(thread);} + } + } +} diff --git a/tests/RegistrySaclRecovery.Cli.Tests.ps1 b/tests/RegistrySaclRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..35271a45 --- /dev/null +++ b/tests/RegistrySaclRecovery.Cli.Tests.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path +$cases=@( + @{Args=@('registry-sacl-recovery','-Help');Code=0;Pattern='One proven registry-root audit ACE'}, + @{Args=@('registry-sacl-recovery','unexpected','-Help');Code=1;Pattern='arguments|dedicated options'}, + @{Args=@('registry-sacl-recovery','-WhatIf','-Help');Code=1;Pattern='arguments|dedicated options'}, + @{Args=@('registry-sacl-recovery','-DryRun','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('registry-sacl-recovery','-Auto','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('help','-RegistryRecoveryAction','Restore');Code=1;Pattern='require registry-sacl-recovery'}, + @{Args=@('registry-sacl-recovery');Code=1;Pattern='four original evidence paths'}, + @{Args=@('registry-sacl-recovery','-RegistryRecoveryAction','Restore');Code=1;Pattern='reviewed plan path/hash'}, + @{Args=@('registry-sacl-recovery','-RegistryRecoveryAllowAuditReduction');Code=1;Pattern='four original evidence paths'} +) +foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}} +Write-Host "Passed $($cases.Count) public registry recovery CLI assertions.";$global:LASTEXITCODE=0 diff --git a/tests/RegistrySaclRecovery.Windows.Tests.ps1 b/tests/RegistrySaclRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..655c5e63 --- /dev/null +++ b/tests/RegistrySaclRecovery.Windows.Tests.ps1 @@ -0,0 +1,114 @@ +# Mutating test fixture only: public WELA never loads hives or prepares audit policy. +param([switch]$AllowDisposableHiveWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableHiveWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted native Windows fixture only.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration','RegistrySaclRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1') +Initialize-WelaWmiProbeNative +Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop +$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-recovery-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot +$files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files +function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))} +function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))} +function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +$script:assertions=0 +function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++} + +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks; +public static class WelaRegistryRecoveryFixturePipe { + public static async Task Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi character bound.");text.Append(buffer,0,n);}} +} +'@ +function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){ + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $script:ScriptRoot 'WELA.ps1'))+$Arguments + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try{ + if(-not $process.Start()){throw 'Public process did not start.'};$started=$true + $stdout=[WelaRegistryRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaRegistryRecoveryFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'} + $text=$stdout.Result+"`n"+$stderr.Result;Save ($Name+'-output.json') $text + Assert ($process.ExitCode -eq $Expected) ("Public $Name exited $($process.ExitCode), expected $Expected : "+$text) + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:cleanupErrors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:cleanupErrors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:cleanupErrors+=$_.Exception.Message}};if(-not $exited){$script:cleanupErrors+='Owned public process termination unconfirmed.'}} + $process.Dispose() + } +} +$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030' +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy' +$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName +Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence +$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false +try { + Assert ($beforeMasks.Count -eq 59) 'All 59 original audit masks observed.' + $hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false) + $providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce' + Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Target=$providerPath}) + Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only fixture-owned hive was mounted.' + $policyTouched=$true;Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1;Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact + $preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName) + Public 'catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $root 'catalog.json')) + $catalog=Read-Receipt 'catalog.json';$selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath}) + Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Definition.Kind -ceq 'Registry') 'Exactly one real catalog target in owned HKU hive.' + $selected=$selectedRows[0];Save 'selected.json' $selected + Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null + try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$before=$target.Read();$seeded=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()} + Save 'before-public.json' $seeded + $originalPlan=Join-Path $root 'original-plan.json';$backup=Join-Path $root 'original-journal';$originalResults=Join-Path $root 'original-results.json' + $selection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional','-TargetSaclIncludeChildren') + Public 'original-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$originalPlan)) + Public 'original-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$originalPlan,'-BackupPath',$backup,'-ResultsPath',$originalResults,'-Auto')) + $result=Read-Receipt 'original-results.json';Assert ($result.Results[0].Status -is [string] -and $result.Results[0].Status -ceq 'Applied') 'Recovery starts from actual completed public Configure.' + $applied=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'applied-native.json' $applied;$hive.AssertValues($false) + $review=Join-Path $root 'review';$pending=Join-Path $backup ($selected.Id+'.pending.json');$confirmed=Join-Path $backup ($selected.Id+'.confirmed.json') + $reviewArgs=@('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$originalPlan,'-RegistryRecoveryPendingPath',$pending,'-RegistryRecoveryConfirmedPath',$confirmed,'-RegistryRecoveryOriginalResultsPath',$originalResults) + Public 'recovery-plan' ($reviewArgs+@('-RegistryRecoveryOutputPath',$review)) + $manifest=Read-Receipt 'review/manifest.json';Assert ($manifest.Status -ceq 'ReviewRequired' -and -not $manifest.WriteAttempted -and $manifest.PlanHash -ceq (Get-FileHash -LiteralPath (Join-Path $review 'plan.json')).Hash.ToLowerInvariant()) 'Actual recovery Plan binds independently checked exact bytes without writes.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $applied)) 'Recovery planning preserves exact native state.' + $restoreArgs=@('registry-sacl-recovery','-RegistryRecoveryAction','Restore','-RegistryRecoveryPlanPath',(Join-Path $review 'plan.json'),'-RegistryRecoveryPlanHash',$manifest.PlanHash) + foreach($missing in @('AuditReduction','Inheritance')){ + $out=Join-Path $root ('missing-'+$missing);$consent=if($missing -ceq 'AuditReduction'){'-RegistryRecoveryAllowInheritance'}else{'-RegistryRecoveryAllowAuditReduction'} + Public ('missing-'+$missing) ($restoreArgs+@('-RegistryRecoveryOutputPath',$out,$consent)) 1 + $refusal=Read-Receipt ('missing-'+$missing+'/manifest.json');Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and -not(Test-Path -LiteralPath (Join-Path $out 'pending.json'))) 'Each explicit reduction/inheritance consent is required before durable intent or removal.' + } + $restoredDir=Join-Path $root 'restored' + Public 'restore' ($restoreArgs+@('-RegistryRecoveryOutputPath',$restoredDir,'-RegistryRecoveryAllowAuditReduction','-RegistryRecoveryAllowInheritance')) + $restored=Read-Receipt 'restored/manifest.json';Save 'restored-native.json' (Get-WelaSelectedSaclSnapshot $selected.Definition) + Assert ($restored.Status -ceq 'AddedAceRemoved' -and $restored.WriteAttempted -and $restored.PolicyChanges -eq 0 -and $restored.ReadyRuleCredit -eq 0) 'Actual public recovery removes one proven ACE with no audit-policy or rule credit.' + $native=Get-WelaSelectedSaclSnapshot $selected.Definition;Initialize-WelaRegistryRecoveryNative + [Wela.RegistrySaclRecovery.Descriptor]::Removed($applied.DescriptorBase64,$native.DescriptorBase64,(Read-Receipt 'review/plan.json').AddedAce) + Assert ($native.Aces.Count -eq $seeded.Aces.Count -and $native.Aces[0].Binary -ceq $seeded.Aces[0].Binary -and $native.Owner -ceq $seeded.Owner -and $native.Group -ceq $seeded.Group -and $native.DaclBase64 -ceq $seeded.DaclBase64) 'Independent native observation retains unrelated audit ACE, owner/group/DACL.' + $hive.AssertValues($false) + foreach($artifact in $restored.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $restoredDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Durable actual recovery artifact hash verified.'} + 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.' + 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 { + if($policyTouched){ + try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message} + try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message} + } + try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message} + $hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false + $afterHives=$null;$afterToken=$null;$masks=$null;$afterPrecedence=$null + try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='HKU verification: '+$_.Exception.Message} + try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message} + try{$masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message} + try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message} + if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}} + $cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$masks;AfterPrecedence=$afterPrecedence} + Save 'cleanup.json' $cleanup + $artifacts=@(Get-ChildItem -LiteralPath $root -Recurse -File |Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) + Save 'artifact-hashes.json' $artifacts +} +if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))} +Write-Host "Passed $script:assertions actual public registry SACL recovery assertions; all cleanup confirmed. Evidence: $root" +$global:LASTEXITCODE=0