Integrate reviewed WMI recovery with combined dev auditing batch

This commit is contained in:
Shirofune-Security committed 2026-09-22 15:06:51 +09:00
commit bfa286bf7d
46 files changed
+2449 -47

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
$cases=@(
@{Args=@('dns-client-probe','-Help');Code=0;Pattern='Fixed benign A lookup'},
@{Args=@('configure','-DnsClientProbeAction','Run','-Auto');Code=1;Pattern='require dns-client-probe'},
@{Args=@('dns-analytical','-DnsClientProbeResolver','127.0.0.1');Code=1;Pattern='only dedicated'},
@{Args=@('dns-client-probe','-Help','-Auto');Code=1;Pattern='only dedicated'},
@{Args=@('dns-client-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'},
@{Args=@('dns-client-probe','-Help','-WmiProbeAction','Run');Code=1;Pattern='only dedicated'},
@{Args=@('dns-client-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
@{Args=@('dns-client-probe','-DnsClientProbeResolver','example.com');Code=1;Pattern='canonical unicast IPv4'},
@{Args=@('dns-client-probe','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeAction','Run');Code=1;Pattern='Run requires a new output'},
@{Args=@('dns-client-probe','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath','unused');Code=1;Pattern='Plan writes no files'})
foreach($case in $cases){$ErrorActionPreference='Continue';$out=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $root 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $out -notmatch $case.Pattern){throw "Public CLI failed: $($case.Args -join ' ') [$code] $out"};$count++}
Write-Host "PASS: $count DNS Client public CLI checks.";$global:LASTEXITCODE=0
+11
View File
@@ -0,0 +1,11 @@
# Temporary native ABI diagnostic: called only inside the explicitly gated owned DNS fixture.
param([ValidateRange(0,4)][int]$Variant)
$ErrorActionPreference='Stop'
if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Disposable native fixture only.'}
$source=[IO.File]::ReadAllText((Join-Path $PSScriptRoot '../scripts/DnsClientProbeNative.cs'))
# Keep the fixed product query name/options and explicit loopback resolver. Vary only server buffer ABI.
$variants=@(@(1,0,0),@(96,0,0),@(1,2,0),@(1,0,53),@(96,2,53))
$v=$variants[$Variant]
$source=$source.Replace('BitConverter.GetBytes((uint)1).CopyTo(server,0);',('BitConverter.GetBytes((uint)'+$v[0]+').CopyTo(server,0);BitConverter.GetBytes((ushort)'+$v[1]+').CopyTo(server,12);server[35]='+$v[2]+';'))
Add-Type -TypeDefinition $source
[pscustomobject]@{Variant=$Variant;MaxCount=$v[0];Family=$v[1];Port=$v[2];Result=[Wela.DnsClientProbe.Native]::Query(('wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.'),'127.0.0.1')}|ConvertTo-Json -Depth 8 -Compress
+66
View File
@@ -0,0 +1,66 @@
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $ScriptRoot 'modules/AuditProfiles.psm1') -Force
foreach($name in @('WefArrival','AuditRecovery','ChannelRead','DnsClientProbe')){. (Join-Path $ScriptRoot ('scripts/'+$name+'.ps1'))}
$script:count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Throws($Code,$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs')
foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'}
foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'}
Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random'
foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal].GetMethod('SizeOf',[type[]]@([type])).Invoke($null,@($nativeType)) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])}
Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.'
$queryImport=[Wela.DnsClientProbe.Native].GetMethod('DnsQueryEx',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
Assert ($queryImport.ExactSpelling -and $queryImport.EntryPoint -ceq 'DnsQueryEx') 'Bind the documented DnsQueryEx export exactly; no W suffix with a different ABI.'
$clockImport=[Wela.DnsClientProbe.Native].GetMethod('GetSystemTimePreciseAsFileTime',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
Assert ($clockImport.ExactSpelling -and $clockImport.EntryPoint -ceq 'GetSystemTimePreciseAsFileTime') 'Precise native UTC has an exact entry point and no coarse fallback.'
$server=[Wela.DnsClientProbe.Native].GetMethod('BuildServerArray',[Reflection.BindingFlags]'NonPublic,Static').Invoke($null,@('192.0.2.53'))
Assert ($server.Length -eq 96 -and [BitConverter]::ToUInt32($server,0) -eq 1 -and [BitConverter]::ToUInt32($server,4) -eq 1 -and [BitConverter]::ToUInt16($server,32) -eq 2) 'SDK header/address storage and sample element counts are exact.'
Assert (([Net.IPAddress]::new([byte[]]$server[36..39])).ToString() -ceq '192.0.2.53') 'Explicit resolver address is encoded in network order.'
Assert (@(8..31 + 34..35 + 40..95|Where-Object {$server[$_] -ne 0}).Count -eq 0) 'Aggregate family/default DNS port and all reserved address bytes remain zero.'
Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
$state=[pscustomobject]@{Computer='host';Host=[pscustomobject]@{DomainJoined=$false;Domain='WORKGROUP'};Service='Running';Channel=[pscustomobject]@{State='Enabled';Name='Microsoft-Windows-DNS-Client/Operational';SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)';MetadataErrors=@{};Error=$null;IsEnabled=$true;MaximumSizeInBytes=1048576;LogMode='Circular'};Schema=[pscustomobject]@{State='Observed';Provider='Microsoft-Windows-DNS-Client';ProviderGuid='1c95126e-7eea-49a9-a3fe-a378b03ddb4d';ChannelType='Operational';Events=@([pscustomobject]@{Id=3008;Version=0;Channel='Microsoft-Windows-DNS-Client/Operational';Fields=$fields})}}
Assert ((Get-WelaDnsClientProbeStateKey $state).Length -gt 0) 'Exact schema prerequisite accepted.'
$state.Channel.MetadataErrors['LogMode']='denied';Throws {Get-WelaDnsClientProbeStateKey $state} 'fully observed';$state.Channel.MetadataErrors=@{}
$state.Schema.Events[0].Fields[0].InType='win:UInt32';Throws {Get-WelaDnsClientProbeStateKey $state} 'field/type';$state.Schema.Events[0].Fields[0].InType='win:UnicodeString'
$state.Schema.Events[0].Version=1;Throws {Get-WelaDnsClientProbeStateKey $state} 'version/channel';$state.Schema.Events[0].Version=0
$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.test.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9}
$xml=@'
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-DNS-Client" Guid="{1c95126e-7eea-49a9-a3fe-a378b03ddb4d}"/><EventID>3008</EventID><Version>0</Version><EventRecordID>10</EventRecordID><Channel>Microsoft-Windows-DNS-Client/Operational</Channel><Computer>host</Computer><TimeCreated SystemTime="2026-01-01T00:00:00.5000000Z"/><Execution ProcessID="123"/></System><EventData><Data Name="QueryName">wela-0123456789abcdef0123456789abcdef.wela.test.</Data><Data Name="QueryType">1</Data><Data Name="QueryOptions">0x2019ee</Data><Data Name="QueryStatus">0</Data><Data Name="QueryResults">192.0.2.1;</Data></EventData></Event>
'@
Assert (Test-WelaDnsClientProbeEvent $xml $operation $state) 'Exact synthetic native3008 shape matches.'
$mutations=@(
@('>3008<','>3006<'),@('<Version>0','<Version>1'),@('>10<','>9<'),@('>host<','>other<'),@('DNS-Client/Operational','DNS Client Events/Operational'),@('1c95126e','2c95126e'),@('Microsoft-Windows-DNS-Client"','Other-Provider"'),@('00:00:00.5000000Z','00:00:01.5000000Z'),@('ProcessID="123"','ProcessID="0"'),@('Name="QueryType">1','Name="QueryType">28'),@('Name="QueryStatus">0','Name="QueryStatus">9003'),@('0x2019ee','0x2019ec'),@('0123456789abcdef0123456789abcdef','ffffffffffffffffffffffffffffffff'),@('</EventData>','<Data Name="QueryName">duplicate</Data></EventData>'),@('</EventData>','<Data Name="Unknown">extra</Data></EventData>'),@('192.0.2.1;','<Nested/>'),@('<Event xmlns=','<!DOCTYPE Event [<!ENTITY x "no">]><Event xmlns='))
foreach($mutation in $mutations){Assert (-not(Test-WelaDnsClientProbeEvent ($xml.Replace($mutation[0],$mutation[1])) $operation $state)) "Reject mismatched native XML: $($mutation[0])"}
Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('ProcessID="123"','ProcessID="456"')) $operation $state) 'Emitter broker PID remains recorded without invented caller attribution.'
$operation.Query.Status=9003;Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('Name="QueryStatus">0','Name="QueryStatus">9003')) $operation $state) 'Typed NXDOMAIN completion differs from successful resolution.'
$operation.Query.Status=0
# Exercise report/cap/drift behavior; only native boundaries are mocked.
function Clone($Value){ConvertFrom-WelaRecoveryJson ($Value|ConvertTo-Json -Depth 20 -Compress)}
$token=[pscustomobject]@{Computer='host';ProcessId=123;UserSid='S-1-5-21-1-2-3-1001';UserName='HOST\Reader';TokenId='100';AuthenticationId='99';ModifiedId='200';GroupSids=@('S-1-1-0');GroupCount=1;PrivilegeCount=1;ElevatedAdministrator=$false;TokenType='Primary';Impersonation='Absent'}
$state|Add-Member NoteProperty Reader (Clone $token);$operation|Add-Member NoteProperty CallerBefore (Clone $token)
$script:mode='Success';$script:reads=0;$script:workerCalls=0
function Get-WelaDnsClientProbeState {$script:reads++;$copy=Clone $state;$copy.Reader.ModifiedId=[string](200+$script:reads);if($script:mode -eq 'Drift' -and $script:reads -gt 1){$copy.Computer='changed'};if($script:mode -eq 'Blocked'){$copy.Service='Stopped'};$copy}
function Start-WelaDnsClientProbeQuery {param($State,$Resolver,$QueryName);$script:workerCalls++;$operation}
function Read-WelaDnsClientProbeEvents {param($Operation);if($script:mode -eq 'ReadError'){throw 'native query denied'};[pscustomobject]@{Xml=$(if($script:mode -eq 'Missing'){@()}else{@($xml)});Capped=($script:mode -eq 'Cap');Query='synthetic bounded query';LogStatus=@([pscustomobject]@{LogName='Microsoft-Windows-DNS-Client/Operational';StatusCode=$(if($script:mode -eq 'DeniedStatus'){[int]5}else{[int]0})})}}
function Get-WelaChannelReader {$copy=Clone $token;if($script:mode -eq 'TokenDrift'){$copy.ModifiedId='changed'};$copy}
function Get-WelaDnsClientProbeWatermark {if($script:mode -eq 'Clear'){8}else{10}}
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-dns-fixtures-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
try{
$plan=Invoke-WelaDnsClientProbe -Resolver '127.0.0.1'
Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $script:workerCalls -eq 0 -and -not $plan.OutputPath) 'Default Plan never runs a DNS query or writes evidence.'
foreach($mode in @('Success','Blocked','Cap','ReadError','Drift','Clear','Missing','DeniedStatus','TokenDrift')){
$script:mode=$mode;$script:reads=0;$script:workerCalls=0;$directory=Join-Path $temp $mode
$result=Invoke-WelaDnsClientProbe -Action Run -Resolver '127.0.0.1' -OutputPath $directory -TimeoutSeconds 1
$manifest=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $directory 'manifest.json')))
Assert ($manifest.ReadyRuleCredit -eq 0 -and $manifest.ConfigurationChanges -eq 0 -and $manifest.RuleChannelMismatch -match 'DNS Client Events/Operational') 'Success and failure retain original channel mismatch and zero configuration/Sigma credit.'
Assert ($null -ne $manifest.After) 'Final observations survive failures.'
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $directory $artifact.Name)).Hash.ToLowerInvariant()) 'Manifest hashes match written bytes.'}
if($mode -eq 'Success'){Assert ($result.Status -ceq 'NativeDnsLookupObserved' -and $result.Matches -eq 1 -and $result.ExitCode -eq 0) 'Exact synthetic event yields the bounded observation.';Assert ([IO.File]::ReadAllText((Join-Path $directory 'event-1.xml')) -ceq $xml) 'Original XML retained unchanged.'}
else{Assert ($result.Status -ceq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) "Failure $mode remains unverified."}
if($mode -eq 'Blocked'){Assert ($script:workerCalls -eq 0) 'Missing prerequisites prevent the native operation.'}
}
Throws {Invoke-WelaDnsClientProbe -Action Run -Resolver '127.0.0.1' -OutputPath (Join-Path $temp 'Success')} 'new directory'
}finally{Remove-Item -LiteralPath $temp -Recurse -Force}
Write-Host "PASS: $script:count DNS Client validator/report/refusal assertions; native boundaries were mocked."
+75
View File
@@ -0,0 +1,75 @@
param([switch]$AllowDisposableDns,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
$ErrorActionPreference='Stop'
if(-not $AllowDisposableDns -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit DNS mutation opt-in on a disposable GitHub-hosted Windows runner is required.'}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $ScriptRoot 'modules/AuditProfiles.psm1') -Force
Import-Module (Join-Path $ScriptRoot 'modules/NativeProviders.psm1') -Force
foreach($name in @('Configuration','ControlApplicability','NativeProviderPacks','AuditRecovery','WefArrival','ChannelRead','DnsClientProbe')){. (Join-Path $ScriptRoot ('scripts/'+$name+'.ps1'))}
$script:count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
$os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_ComputerSystem
if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'}
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns'
$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel
if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'}
$null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10)
$installed=$false;$zoneCreated=$false;$channelChanged=$false;$passed=$false
function Invoke-Cli {
param([string[]]$Arguments,[int]$Expected=0)
$ErrorActionPreference='Continue'
try{$text=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
$text|ForEach-Object{Write-Host $_};$global:LASTEXITCODE=0
Assert ($code -eq $Expected) "Public DNS Client CLI exit $code, expected $Expected."
}
function Set-ChannelEnabled([bool]$Enabled){$out=& "$env:SystemRoot\System32\wevtutil.exe" sl $channel ('/e:'+([string]$Enabled).ToLowerInvariant()) 2>&1;if($LASTEXITCODE -ne 0){throw "Fixture channel update failed: $out"};$global:LASTEXITCODE=0}
$catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0]
Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12)
try {
$installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop
if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'}
Start-Service DNS -ErrorAction Stop
$ready=[Diagnostics.Stopwatch]::StartNew();do{try{$null=Get-DnsServerZone -ErrorAction Stop;break}catch{if($ready.Elapsed.TotalSeconds -gt 30){throw};Start-Sleep -Milliseconds 500}}while($true)
if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'}
if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'}
Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop;$zoneCreated=$true
Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::FromSeconds(1)) -ErrorAction Stop|Out-Null
$record=@(Get-DnsServerResourceRecord -ZoneName $zone -RRType A -ErrorAction Stop|Where-Object HostName -ceq '*')
Assert ($record.Count -eq 1 -and $record[0].RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Owned wildcard A record is exact.'
# The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used.
if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true}
$configured=Get-WelaNativeChannel $channel
Invoke-Cli @('dns-client-probe','-DnsClientProbeResolver','127.0.0.1')
$output=Join-Path $private 'evidence'
Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output)
$report=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json')))
Assert ($report.Status -ceq 'NativeDnsLookupObserved' -and $report.ExitCode -eq 0 -and $report.Matches -ge 1 -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) 'Actual native3008 correlation is observed without configuration/Sigma credit.'
Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.test\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.'
foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Evidence bytes match recorded SHA256.'}
foreach($file in Get-ChildItem -LiteralPath $output -Filter 'event-*.xml'){$xml=[IO.File]::ReadAllText($file.FullName);Assert (Test-WelaDnsClientProbeEvent $xml $report.Operation $report.Before) 'Actual persisted3008 XML matches the production validator.';Write-Host $xml}
Assert ((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -ceq (Get-WelaChannelReadKey $configured)) 'Product preserves the exact configured channel metadata.'
Assert ($report.RuleChannelMismatch -match 'DNS Client Events/Operational') 'Original rule-channel mismatch remains explicit.'
$passed=$true;Write-Host "PASS: $script:count native DNS Client checks through $TestEngine."
}catch{
Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace
if($zoneCreated){foreach($variant in 0..4){
$diagnostic=[Diagnostics.Process]::new();$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+(Join-Path $PSScriptRoot 'DnsClientProbe.Diagnostics.ps1')+'" -Variant '+$variant;$info.UseShellExecute=$false;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$diagnostic.StartInfo=$info
try{$null=$diagnostic.Start();$stdout=$diagnostic.StandardOutput.ReadToEndAsync();$stderr=$diagnostic.StandardError.ReadToEndAsync();if(-not $diagnostic.WaitForExit(20000)){throw 'Owned diagnostic worker timeout.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Diagnostic output timeout.'};Write-Host ('Owned ABI variant '+$variant+' exit '+$diagnostic.ExitCode);Write-Host $stdout.Result;Write-Host $stderr.Result}catch{Write-Host $_}finally{if(-not $diagnostic.HasExited){$diagnostic.Kill();$null=$diagnostic.WaitForExit(5000)};$diagnostic.Dispose()}
}}
# Small owned diagnostics only; avoid dumping unrelated channel payloads.
if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json','worker.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}}
throw
}finally{
$errors=@()
try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message}
if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}}
try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message}
$removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'}
if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}}
$null=Write-WelaArrivalArtifact $private 'cleanup.json' ($removal|ConvertTo-Json -Depth 6);$removal|ConvertTo-Json -Depth 6|Write-Host
if($errors.Count){throw "Disposable DNS cleanup failed; evidence retained at $private : $($errors -join '; ')"}
if($passed){Remove-Item -LiteralPath $private -Recurse -Force}
}
$global:LASTEXITCODE=0
+29 -7
View File
@@ -1,9 +1,12 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
$script:ScriptRoot=$repo
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
. (Join-Path $repo 'scripts/NativeValidation.ps1')
. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
. (Join-Path $repo 'scripts/WefArrival.ps1')
. (Join-Path $repo 'scripts/ChannelRead.ps1')
. (Join-Path $PSScriptRoot 'fixtures/EvtxRecovery.Fixture.ps1')
$script:checks=0
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
@@ -57,26 +60,37 @@ try {
Assert ((Read-WelaEvtxEvent ($fixture.Xml.Replace($change[0],$change[1]))).Key -cne $source.Event.Key) "Original event mutation stays unmatched: $($change[0])"
}
foreach($xml in @($fixture.Xml.Replace('</Event>','<UserData/></Event>'),$fixture.Xml.Replace('</Event>','<System/></Event>'),('<!DOCTYPE Event [<!ENTITY a SYSTEM "file:///etc/passwd">]>'+$fixture.Xml))) {Reject {Read-WelaEvtxEvent $xml} 'System|DTD'}
$script:scenario='match';$script:reads=0;$script:exports=0
function Get-WelaEvtxReader {
$script:scenario='match';$script:reads=0;$script:exports=0;$script:hostReads=0;$script:sourceReads=0
$script:realRecoverySources=(Get-Command Get-WelaEvtxRecoverySources).ScriptBlock
function Get-WelaEvtxReader {throw 'Recovery must not require the legacy administrator feature-inventory reader'}
function Get-WelaEvtxRecoverySources {
$script:sourceReads++;$value=& $script:realRecoverySources
if ($scenario -eq 'implementation-drift' -and $sourceReads -gt 1) {$value.'scripts/EvtxRecovery.ps1'='changed'}
$value
}
function Get-WelaEvtxRecoveryHost {
$script:hostReads++
[pscustomobject]@{Computer='reader01';Build=26100;UBR=$(if ($scenario -eq 'host-drift' -and $hostReads -gt 1) {2}else{1});ProductType=3;DomainRole=2;DomainJoined=$false;Domain='WORKGROUP';Edition='ServerStandard'}
}
function Get-WelaEvtxRecoveryReader {
$script:reads++
[pscustomobject]@{Computer='reader01';HostKey='WindowsServer2025';Reader=[pscustomobject]@{Sid=$(if ($scenario -eq 'reader-drift' -and $reads -gt 1) {'S-1-5-20'}else{'S-1-5-18'})}}
[pscustomobject]@{Computer='reader01';UserSid=$(if ($scenario -eq 'reader-drift' -and $reads -gt 1) {'S-1-5-20'}else{'S-1-5-18'});TokenId='one';AuthenticationId=$(if ($scenario -eq 'logon-drift' -and $reads -gt 1) {'different'}else{'logon'});ModifiedId=$(if ($scenario -eq 'token-drift' -and $reads -gt 1) {'changed'}else{'unchanged'});TokenType='Primary';Impersonation='Absent'}
}
function Get-WelaProbeState {ConvertTo-WelaEvtxState (Clone $fixture.Manifest.BeforeState)}
function Read-WelaEvtxNative {
param($Path,[switch]$Live,$Query)
if ($scenario -eq 'denied') {throw 'Native reader denied'}
if ($scenario -eq 'denied') {throw [UnauthorizedAccessException]::new('Native reader denied')}
if ($scenario -eq 'corrupt') {throw 'Invalid native EVTX format'}
if ($scenario -eq 'source-change') {Add-Content -LiteralPath (Join-Path $fixture.Directory 'event.xml') 'tampered'}
$events=@($fixture.Xml)
if ($scenario -eq 'empty' -and -not $Live) {$events=@()}
if ($scenario -eq 'duplicate') {$events=@($fixture.Xml,$fixture.Xml)}
if ($scenario -eq 'wrong') {$events=@($fixture.Xml.Replace('<EventRecordID>100','<EventRecordID>101'))}
[pscustomobject]@{Xml=$events;Limit=2}
[pscustomobject]@{Xml=$events;Limit=2;LogStatus=@([pscustomobject]@{LogName=$Path;StatusCode=0})}
}
function Export-WelaEvtxNative {param($Query,$Path) $script:exports++;Assert ($Query -match 'EventRecordID=100' -and $Query -match 'EventID=4688' -and $Query -match 'Security-Auditing') 'Export selects one source record only';[IO.File]::WriteAllBytes($Path,[byte[]](1,2,3,4))}
function Invoke-Case([string]$Name,[string]$Action='Verify') {
$script:reads=0;$script:scenario=$Name
$script:reads=0;$script:hostReads=0;$script:sourceReads=0;$script:scenario=$Name
$args=@{Action=$Action;ProbePath=$fixture.Directory;OutputPath=(Join-Path $temp ([guid]::NewGuid().ToString('N')))}
if ($Action -eq 'Verify') {$args.ArchivePath=$script:archive}
Invoke-WelaEvtxRecovery @args
@@ -84,12 +98,20 @@ try {
$script:archive=Join-Path $temp 'fixture.evtx';[IO.File]::WriteAllBytes($archive,[byte[]](1,2,3,4))
$result=Invoke-Case match
Assert ($result.Status -eq 'NativeEventRecovered' -and $result.ExitCode -eq 0 -and $result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0 -and $result.RecoveredEvents -eq 1) 'Exact recovery records presence and keeps readiness separate'
Assert ($result.SchemaVersion -eq 2 -and $result.ReaderStable -and $result.ReaderBefore.TokenType -eq 'Primary' -and $result.ReaderHostBefore.Computer -eq 'reader01' -and $result.SourceComputer -eq 'source01.lab.test') 'Version two distinguishes actual archive reader and source producer'
Assert ($result.FileReadAccess -eq 'Allowed' -and $result.NativeQuery -eq 'ExactEventRecovered' -and $result.ArchiveBytes -eq 4 -and $result.Sources.'scripts/ChannelReadNative.cs' -match '^[a-f0-9]{64}$') 'File permission, matched native query and implementation identity remain explicit'
Assert ($result.ArchiveSha256 -ceq (Get-FileHash -LiteralPath $archive).Hash.ToLowerInvariant()) 'Receipt hashes actual archive bytes'
Assert (Test-Path (Join-Path $result.OutputPath 'recovered-event.xml')) 'Recovered raw XML retained'
foreach ($case in @('empty','duplicate','wrong','denied','corrupt','reader-drift')) {
foreach ($case in @('empty','duplicate','wrong','denied','corrupt','reader-drift','token-drift','logon-drift','host-drift','implementation-drift')) {
$result=Invoke-Case $case
Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) "$case cannot establish recovery"
if ($case -in @('reader-drift','token-drift','logon-drift')) {Assert (-not $result.ReaderStable) 'Observed token/logon changes revoke reader stability'}
if ($case -eq 'denied') {Assert ($result.FileReadAccess -eq 'Allowed' -and $result.NativeQuery -eq 'Denied' -and $result.NativeError -eq 5 -and $result.FailureStage -eq 'ArchiveNativeQuery') 'Native query denial is distinct from a successfully opened file'}
}
Assert-WelaEvtxQueryStatus -Path 'C:\evidence\one.evtx' -LogStatus @([pscustomobject]@{LogName='C:\EVIDENCE\one.evtx';StatusCode=0});$checks++
foreach ($status in @(@(),@([pscustomobject]@{LogName='different.evtx';StatusCode=0}),@([pscustomobject]@{LogName='one.evtx';StatusCode='0'}))) {Reject {Assert-WelaEvtxQueryStatus -Path 'one.evtx' -LogStatus $status} 'incomplete|mismatched|mistyped'}
$statusError=$null;try {Assert-WelaEvtxQueryStatus -Path 'one.evtx' -LogStatus @([pscustomobject]@{LogName='one.evtx';StatusCode=5})} catch {$statusError=$_.Exception.NativeErrorCode}
Assert ($statusError -eq 5) 'Native query errors preserve the numeric code without localized parsing'
$result=Invoke-Case match Export
Assert ($result.Status -eq 'NativeEventRecovered' -and $exports -eq 1 -and (Test-Path $result.ArchivePath)) 'Export requires live source plus native reopening of output'
$result=Invoke-Case empty Export
+16 -7
View File
@@ -1,25 +1,32 @@
param([switch]$AllowDisposablePolicyWrite)
param([switch]$AllowDisposablePolicyWrite,[switch]$AllowDisposableAccount)
$ErrorActionPreference='Stop'
if ($env:OS -ne 'Windows_NT') { Write-Host 'Skipped: native Windows is required.'; exit 0 }
if (-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { throw 'This native event test requires explicit policy-write opt-in on a disposable GitHub-hosted runner.' }
if (-not $AllowDisposableAccount) {throw 'Explicit disposable-account opt-in is required for native archive-reader tests.'}
$repo=Split-Path $PSScriptRoot -Parent
$script:ScriptRoot=$repo
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
. (Join-Path $repo 'scripts/NativeValidation.ps1')
. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
. (Join-Path $repo 'scripts/WefArrival.ps1')
. (Join-Path $repo 'scripts/ChannelRead.ps1')
. (Join-Path $PSScriptRoot 'fixtures/EvtxReader.Windows.Fixture.ps1')
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
$guid='0cce922b-69ae-11d9-bed3-505054503030'
$controls=@(
[pscustomobject]@{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'},
[pscustomobject]@{Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit';Name='ProcessCreationIncludeCmdLine_Enabled'}
)
$beforeMask=(Get-WelaEffectiveAuditPolicy)[$guid]
$beforeMasks=Get-WelaEffectiveAuditPolicy
if ($beforeMasks.Count -ne 59) {throw 'Complete initial audit policy snapshot is unavailable.'}
$beforeMask=$beforeMasks[$guid]
foreach ($control in $controls) { $control | Add-Member NoteProperty Before (Get-WelaRegistryState -Path $control.Path -Name $control.Name) }
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-native-4688-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $root
$receipt=Join-Path $root 'policy-before.json'
[pscustomobject]@{AuditMask=$beforeMask;Controls=$controls} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receipt -Encoding UTF8
$touched=$false; $restored=$false
[pscustomobject]@{AuditMasks=$beforeMasks;Controls=$controls} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receipt -Encoding UTF8
$touched=$false; $restored=$false; $passed=$false
try {
$touched=$true
foreach ($control in $controls) {
@@ -39,15 +46,17 @@ try {
$export=Invoke-WelaEvtxRecovery -Action Export -ProbePath $destination -OutputPath (Join-Path $root 'export')
if ($export.ExitCode -ne 0 -or $export.Status -ne 'NativeEventRecovered') {throw ($export | ConvertTo-Json -Depth 24)}
$verify=Invoke-WelaEvtxRecovery -Action Verify -ProbePath $destination -ArchivePath $export.ArchivePath -OutputPath (Join-Path $root 'verify')
if ($verify.ExitCode -ne 0 -or $verify.Status -ne 'NativeEventRecovered' -or $verify.ReaderBefore.Reader.Sid -ne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) {throw ($verify | ConvertTo-Json -Depth 24)}
if ($verify.ExitCode -ne 0 -or $verify.Status -ne 'NativeEventRecovered' -or $verify.ReaderBefore.UserSid -ne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value -or -not $verify.ReaderStable) {throw ($verify | ConvertTo-Json -Depth 24)}
if ($verify.ArchiveSha256 -cne $export.ArchiveSha256 -or $verify.ReadyRuleCredit -ne 0) {throw 'Native readback lost artifact identity or claimed readiness.'}
# A natively generated empty EVTX must not be mistaken for recovered data.
$empty=Join-Path $root 'empty.evtx'
Export-WelaEvtxNative -Query '*[System[EventID=0 and Provider[@Name="Microsoft-Windows-Security-Auditing"]]]' -Path $empty
$emptyResult=Invoke-WelaEvtxRecovery -ProbePath $destination -ArchivePath $empty -OutputPath (Join-Path $root 'empty-check')
if ($emptyResult.ExitCode -ne 1 -or $emptyResult.Status -ne 'Unverified') {throw 'Empty native archive incorrectly accepted.'}
Invoke-WelaEvtxReaderFixture -ProbePath $destination -ArchivePath $export.ArchivePath -FixtureParent $root -EnginePath ((Get-Process -Id $PID).Path) -AllowDisposableAccount:$AllowDisposableAccount
Write-Host 'Native Security probe exported and recovered by actual reader from EVTX; empty native archive rejected.'
Write-Host "Native 4688 event observed on $($result.BeforeState.context.role) $($result.BeforeState.context.patch) under PowerShell $($PSVersionTable.PSVersion). Complete-rule, backend and other-role validation remain pending."
$passed=$true
} finally {
if ($touched) {
$errors=@()
@@ -64,11 +73,11 @@ try {
if (($after | ConvertTo-Json -Compress) -cne ($control.Before | ConvertTo-Json -Compress)) { throw "Registry restoration differs: $($control.Name)" }
} catch { $errors+=$_.Exception.Message }
}
try { if ((Get-WelaEffectiveAuditPolicy)[$guid] -ne $beforeMask) { throw 'Audit mask restoration differs.' } } catch { $errors+=$_.Exception.Message }
try {$afterMasks=Get-WelaEffectiveAuditPolicy;if ($afterMasks.Count -ne 59) {throw 'Final audit policy snapshot is incomplete.'};foreach ($id in $beforeMasks.Keys) {if ($afterMasks[$id] -ne $beforeMasks[$id]) {throw "Audit mask restoration differs: $id"}}} catch { $errors+=$_.Exception.Message }
$restored=$errors.Count -eq 0
if (-not $restored) { throw "Policy restoration failed; receipt retained at $receipt : $($errors -join '; ')" }
}
if ($restored) { Remove-Item -LiteralPath $root -Recurse -Force }
if ($restored -and $passed) { Remove-Item -LiteralPath $root -Recurse -Force } else {Write-Host "Incomplete native acceptance; fixture receipts retained at $root"}
}
$global:LASTEXITCODE=0
Write-Host 'Native EVTX export/reopen and exact policy restoration passed.'
+24
View File
@@ -0,0 +1,24 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path
$cases=@(
@{Args=@('file-sacl-recovery','-Help');Exit=0;Pattern='Usage: file-sacl-recovery'},
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-DryRun','-Help');Exit=0;Pattern='Usage:'},
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-Auto','-Help');Exit=0;Pattern='Usage:'},
@{Args=@('file-sacl-recovery','-DryRun','-Help');Exit=1;Pattern='DryRun'},
@{Args=@('configure','-FileSaclRecoveryAction','Restore','-Help');Exit=1;Pattern='require file-sacl-recovery'},
@{Args=@('targeted-sacl','-FileSaclRecoveryPlanPath','unread.json','-Help');Exit=1;Pattern='require file-sacl-recovery|targeted-sacl accepts only'},
@{Args=@('file-sacl-recovery','-TargetSaclIncludeChildren','-Help');Exit=1;Pattern='require targeted-sacl|dedicated'},
@{Args=@('file-sacl-recovery','-Role','Client','-Help');Exit=1;Pattern='dedicated'},
@{Args=@('file-sacl-recovery','-ResultsPath','unwritten.json','-Help');Exit=1;Pattern='dedicated'},
@{Args=@('file-sacl-recovery','-RecoveryPlanPath','unread.json','-Help');Exit=1;Pattern='dedicated'},
@{Args=@('file-sacl-recovery','-Auto');Exit=1;Pattern='Plan requires four original'},
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-Auto');Exit=1;Pattern='Restore requires PlanPath'},
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-DryRun','-FileSaclRecoveryOutputPath','unwritten-directory');Exit=1;Pattern='Restore requires PlanPath'},
@{Args=@('audit-recovery','-RecoveryAction','Restore','-DryRun','-Help');Exit=0;Pattern='Usage: audit-recovery'}
)
foreach($case in $cases){
$ErrorActionPreference='Continue';try{$text=@(& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
if($code -ne $case.Exit -or ($text -join "`n") -notmatch $case.Pattern){throw "Unexpected CLI result for $($case.Args -join ' '): $code / $text"}
}
Write-Host "File SACL recovery public CLI: $($cases.Count) checks passed."
$global:LASTEXITCODE=0
+106
View File
@@ -0,0 +1,106 @@
# Actual Windows security-descriptor parsing, without file or policy mutation.
$ErrorActionPreference='Stop'
if ($env:OS -ne 'Windows_NT') {Write-Host 'Skipped: Windows security descriptor runtime required.';exit 0}
$repo=Split-Path $PSScriptRoot -Parent
. (Join-Path $repo 'scripts/WefArrival.ps1')
. (Join-Path $repo 'scripts/FileSaclRecovery.ps1')
Initialize-WelaFileSaclRecoveryNative
$script:n=0
function Assert($Value,$Message) {if (-not $Value) {throw $Message};$script:n++}
function Throws($Action,$Pattern) {$message='';try {& $Action | Out-Null} catch {$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, received $message"}
function Encode($Descriptor) {$bytes=New-Object byte[] $Descriptor.BinaryLength;$Descriptor.GetBinaryForm($bytes,0);[Convert]::ToBase64String($bytes)}
function Clone($Descriptor) {[Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String((Encode $Descriptor)),0)}
function New-AuditAce([int]$Mask=1,[int]$Flags=64,[string]$Sid='S-1-1-0') {
[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]$Flags,[Security.AccessControl.AceQualifier]::SystemAudit,$Mask,[Security.Principal.SecurityIdentifier]::new($Sid),$false,$null)
}
function Add-AuditAce($Descriptor,$Ace) {
$copy=Clone $Descriptor
if ($null -eq $copy.SystemAcl) {$copy.SystemAcl=[Security.AccessControl.RawAcl]::new(2,1);$copy.SetFlags($copy.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)}
$copy.SystemAcl.InsertAce($copy.SystemAcl.Count,$Ace)
$copy
}
$base=[Security.AccessControl.RawSecurityDescriptor]::new('O:SYG:SYD:(A;;FA;;;SY)')
$before=Encode $base
foreach ($flags in @(64,128,192)) {
foreach ($sid in @('S-1-1-0','S-1-5-11')) {
$after=Add-AuditAce $base (New-AuditAce 1 $flags $sid)
$added=[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),$sid,1,$flags)
Assert ($added -ceq [Wela.FileSaclRecovery.Descriptor]::Bytes($after.SystemAcl[0])) 'Exactly the ordinary selected ACE is identified.'
$empty=Clone $after;$empty.SystemAcl.RemoveAce(0)
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $empty),$added)
Assert ($empty.SystemAcl.Count -eq 0 -and ($empty.ControlFlags -band 16) -ne 0 -and (Encode $empty) -cne $before) 'ACE removal preserves an empty present SACL without claiming historical representation equality.'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),$before,$added)} 'control'
$duplicate=Add-AuditAce $after (New-AuditAce 1 $flags $sid)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $duplicate),$sid,1,$flags)} 'exactly one'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicate),(Encode $after),$added)} 'no longer unique'
$unrelated=Add-AuditAce $after (New-AuditAce 2 128 'S-1-5-11')
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $unrelated),$sid,1,$flags)} 'more than one|exactly one'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $unrelated),$added)} 'Unrelated|Unexpected'
}
}
$old=Add-AuditAce $base (New-AuditAce 2 128 'S-1-5-11')
$after=Add-AuditAce $old (New-AuditAce)
$added=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $after),'S-1-1-0',1,64)
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $old),$added)
Assert ($old.SystemAcl.Count -eq 1) 'The original unrelated audit ACE remains after a valid removal.'
$lost=Add-AuditAce $base (New-AuditAce)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $lost),'S-1-1-0',1,64)} 'original ACE'
$missing=Clone $after;$missing.SystemAcl.RemoveAce(0);$missing.SystemAcl.RemoveAce(0)
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $missing),$added)} 'Unrelated audit ACEs'
$covering=Add-AuditAce $base (New-AuditAce 3 64)
$redundant=Add-AuditAce $covering (New-AuditAce)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $covering),(Encode $redundant),'S-1-1-0',1,64)} 'already covered'
foreach ($flags in @(0,16,65,80,129,208)) {Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-1-0',1,$flags)} 'explicit ordinary'}
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-5-18',1,64)} 'explicit ordinary'
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-1-0',0,64)} 'explicit ordinary'
# Both historical addition and removal must preserve non-audit descriptor fields.
foreach ($mutation in @('Owner','Group','Dacl','ControlFlags')) {
$changed=Clone $old
switch ($mutation) {
Owner {$changed.Owner=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')}
Group {$changed.Group=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')}
Dacl {$changed.DiscretionaryAcl.RemoveAce(0)}
ControlFlags {$changed.SetFlags($changed.ControlFlags -bor [Security.AccessControl.ControlFlags]::DiscretionaryAclProtected)}
}
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $changed),$added)} 'Owner|control|header|manager'
$withAddition=Add-AuditAce $changed (New-AuditAce)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $withAddition),'S-1-1-0',1,64)} 'Owner|control|header|manager'
}
# Resource-manager control is serialized only when its valid flag is present.
$rmBefore=Clone $old;$rmBefore.SetFlags($rmBefore.ControlFlags -bor [Security.AccessControl.ControlFlags]::RMControlValid);$rmBefore.ResourceManagerControl=1
$rmAfter=Add-AuditAce $rmBefore (New-AuditAce)
$rmChanged=Clone $rmBefore;$rmChanged.ResourceManagerControl=2
Assert ((Encode $rmChanged) -cne (Encode $rmBefore)) 'RMControl fixture changes actual serialized bytes with flags unchanged.'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $rmAfter),(Encode $rmChanged),$added)} 'control|header'
$rmChangedAddition=Add-AuditAce $rmChanged (New-AuditAce)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $rmBefore),(Encode $rmChangedAddition),'S-1-1-0',1,64)} 'control|header'
# ACL revision changes cannot hide behind unchanged ACE bytes.
$revised=Clone $old;$acl4=[Security.AccessControl.RawAcl]::new(4,$revised.SystemAcl.Count)
foreach ($entry in $revised.SystemAcl) {$acl4.InsertAce($acl4.Count,$entry)}
$revised.SystemAcl=$acl4;$revisedAddition=Add-AuditAce $revised (New-AuditAce)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $revisedAddition),'S-1-1-0',1,64)} 'revision'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $revised),$added)} 'revision'
# Duplicate unrelated entries retain their exact counts.
$duplicateOld=Add-AuditAce $old (New-AuditAce 2 128 'S-1-5-11')
$duplicateAfter=Add-AuditAce $duplicateOld (New-AuditAce)
$duplicateAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $duplicateOld),(Encode $duplicateAfter),'S-1-1-0',1,64)
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $duplicateOld),$duplicateAdded)
Assert ($duplicateOld.SystemAcl.Count -eq 2) 'Duplicate unrelated ACEs are preserved.'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $old),$duplicateAdded)} 'Unrelated audit ACEs'
# Windows can retain SACL_PRESENT with a null ACL after removing the sole ACE.
$sole=Add-AuditAce $base (New-AuditAce)
$soleAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $sole),'S-1-1-0',1,64)
$presentNull=Clone $sole;$presentNull.SystemAcl=$null
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $sole),(Encode $presentNull),$soleAdded)
Assert ([Wela.FileSaclRecovery.Descriptor]::SaclRepresentation((Encode $presentNull)) -ceq 'PresentNull') 'Sole-ACE removal can retain present-null SACL with exact control fields.'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $presentNull),$added)} 'lose unrelated'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $sole),(Encode $presentNull),'different-ACE')} 'no longer unique'
# Native object audit ACEs never qualify as the ordinary selected addition.
$objectBase=Clone $base;$objectBase.SystemAcl=[Security.AccessControl.RawAcl]::new(4,0);$objectBase.SetFlags($objectBase.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)
$objectAfter=Clone $objectBase
$objectAce=[Security.AccessControl.ObjectAce]::new([Security.AccessControl.AceFlags]64,[Security.AccessControl.AceQualifier]::SystemAudit,1,[Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[Security.AccessControl.ObjectAceFlags]::ObjectAceTypePresent,[guid]::NewGuid(),[guid]::Empty,$false,$null)
$objectAfter.SystemAcl.InsertAce(0,$objectAce)
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $objectBase),(Encode $objectAfter),'S-1-1-0',1,64)} 'exactly one'
Throws {[Wela.FileSaclRecovery.Descriptor]::Parse('not base64')} '.'
$global:LASTEXITCODE=0
Write-Host "File SACL recovery native descriptor guards: $script:n assertions passed."
+37
View File
@@ -0,0 +1,37 @@
$ErrorActionPreference='Stop'
$root=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
. (Join-Path $root 'scripts/WefArrival.ps1')
. (Join-Path $root 'scripts/EvtxRecovery.ps1')
. (Join-Path $root 'scripts/FileSaclRecovery.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"}
Initialize-WelaFileSaclRecoveryNative
Assert ([Wela.FileSaclRecovery.Descriptor]::SourceSha256 -ceq (Get-FileHash (Join-Path $root 'scripts/FileSaclRecoveryNative.cs')).Hash.ToLowerInvariant()) 'Compiled helper is bound to actual source bytes.'
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-recovery-json-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
try {
$path=Join-Path $temp 'input.json'
foreach($value in @('{"ExitCode":0}','{"text":"東京","SchemaVersion":1}')){
[IO.File]::WriteAllText($path,$value,[Text.UTF8Encoding]::new($false))
$input=Read-WelaFileSaclRecoveryInput $path
Assert ($input.Sha256 -ceq (Get-FileHash $path).Hash.ToLowerInvariant() -and $input.Bytes -eq ([IO.File]::ReadAllBytes($path)).Length) 'Strict evidence reader hashes actual UTF-8 bytes.'
}
$zero=ConvertFrom-WelaEvtxJson '{"ExitCode":0}'
Assert (($zero.ExitCode -is [int] -or $zero.ExitCode -is [long]) -and $zero.ExitCode -eq 0) 'Real JSON integer zero is accepted across engines.'
foreach($invalid in @('{"a":1,"a":2}','{"x":NaN}','{"x":1,}','{"x":true} trailing','')){
[IO.File]::WriteAllText($path,$invalid)
Throws {Read-WelaFileSaclRecoveryInput $path} 'JSON|json|byte|Unexpected|Invalid|Duplicate|custom-profile'
}
[IO.File]::WriteAllBytes($path,[byte[]]@(0xc3,0x28));Throws {Read-WelaFileSaclRecoveryInput $path} 'translate|valid|Unable'
$oversize=New-Object byte[] 4194305;[IO.File]::WriteAllBytes($path,$oversize);Throws {Read-WelaFileSaclRecoveryInput $path} 'four MiB'
$artifact=Write-WelaFileSaclRecoveryArtifact $temp 'pending.json' '{"state":"Pending"}'
Assert ($artifact.Bytes -gt 0 -and $artifact.Sha256 -ceq (Get-FileHash (Join-Path $temp 'pending.json')).Hash.ToLowerInvariant()) 'Durably flushed pending artifact is reopened and hashed.'
Throws {Write-WelaFileSaclRecoveryArtifact $temp 'pending.json' '{}'} 'exists'
foreach($arguments in @(@{},@{Action='Plan';PlanPath='x'},@{Action='Plan';Auto=$true},@{Action='Plan';DryRun=$true},@{Action='Restore'},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);DryRun=$true;Auto=$true},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);DryRun=$true;OutputPath='out'},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);OutputPath='out'})) {
Throws {Invoke-WelaFileSaclRecovery @arguments} 'requires'
}
if($env:OS -ne 'Windows_NT'){Throws {Get-WelaFileSaclRecoveryOperator} 'Windows'}
Write-Host "PASS: $script:count file recovery source, strict input, durable output and argument assertions. Native descriptor semantics run separately on Windows."
} finally {Remove-Item -LiteralPath $temp -Recurse -Force}
$global:LASTEXITCODE=0
+107
View File
@@ -0,0 +1,107 @@
param([switch]$AllowDisposableSaclWrite)
$ErrorActionPreference='Stop'
if(-not $AllowDisposableSaclWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'}
$root=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
. (Join-Path $root 'scripts/Configuration.ps1')
$script:count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Fingerprint($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')}
function Json($Path){Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json}
function Save($Path,$Value){[IO.File]::WriteAllText($Path,($Value|ConvertTo-Json -Depth 30),[Text.UTF8Encoding]::new($false))}
$policyBefore=Get-WelaEffectiveAuditPolicy
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceBefore=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
$nonce=[guid]::NewGuid().ToString('N');$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-recovery-'+$nonce);$copy=Join-Path $temp 'checkout'
$engine=(Get-Process -Id $PID).Path
$script:call=0
function Run-Wela {
param([string[]]$Arguments,[int]$Expected=0,[string]$Pattern='')
$script:call++;$log=Join-Path $temp ('call-'+$script:call+'.log')
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.UseShellExecute=$false;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
$all=@('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',(Join-Path $copy 'WELA.ps1'))+$Arguments
$start.Arguments=(@($all|ForEach-Object {'"'+$_.Replace('"','\"')+'"'}) -join ' ')
$process=[Diagnostics.Process]::new();$process.StartInfo=$start
try {$null=$process.Start();$out=$process.StandardOutput.ReadToEndAsync();$err=$process.StandardError.ReadToEndAsync();if(-not $process.WaitForExit(180000)){$process.Kill();throw 'Public recovery fixture command timed out.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($out,$err),10000)){throw 'Public fixture output capture timed out.'};$text=$out.Result+$err.Result;[IO.File]::WriteAllText($log,$text);Assert ($process.ExitCode -eq $Expected) "Public command failed with $($process.ExitCode), expected $Expected. $text";if($Pattern){Assert ($text -match $Pattern) "Expected diagnostic $Pattern. $text"}}finally{if($process.Id -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(10000)};$process.Dispose()}
}
$completed=$false
try {
$null=New-Item -ItemType Directory $copy -Force
foreach($name in @('WELA.ps1','config','scripts','modules')){Copy-Item -LiteralPath (Join-Path $root $name) -Destination $copy -Recurse}
# Only the owned disposable checkout gets this installed one-file catalog.
# Production command and receipt validation expose no arbitrary-target override.
$file=Join-Path $temp 'owned.txt';[IO.File]::WriteAllText($file,'owned recovery fixture')
$catalog=[pscustomobject]@{description='Owned disposable installed catalog';registry=@();files=@([pscustomobject]@{path=$file;inherit=$false;rights=@('ReadData');note='Owned leaf'});user_registry=@();user_files=@()}
Save (Join-Path $copy 'config/audit_sacl_targets.json') $catalog
Import-Module (Join-Path $copy 'modules/AuditProfiles.psm1') -Force
. (Join-Path $copy 'scripts/ControlApplicability.ps1')
. (Join-Path $copy 'scripts/TargetedSaclPlanning.ps1')
. (Join-Path $copy 'scripts/SelectedSaclConfiguration.ps1')
. (Join-Path $copy 'scripts/WefArrival.ps1')
. (Join-Path $copy 'scripts/EvtxRecovery.ps1')
. (Join-Path $copy 'scripts/FileSaclRecovery.ps1')
Initialize-WelaFileSaclRecoveryNative
Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 3 -Mode minimum
# ASD has the same explicit opt-in file prerequisite without WEF screenshot
# companion registry rows, so this isolated catalog can contain one file only.
$context=Get-WelaSelectedSaclContext
$target=@((Get-WelaSelectedSaclCatalog -Profile asd-native-2021-10 -IncludeOptional -Context $context).Rows)
Assert ($target.Count -eq 1 -and $target[0].Definition.Path -ceq $file) 'Installed fixture catalog selects only the owned leaf.'
$id=$target[0].Id;$definition=$target[0].Definition
foreach($case in @('empty','unrelated')){
$caseDir=Join-Path $temp $case;$null=New-Item -ItemType Directory $caseDir
if($case -eq 'unrelated'){
$beforeUnrelated=Get-WelaSelectedSaclSnapshot $definition
$other=[pscustomobject]@{Sid='S-1-5-11';Mask=2;Flags=64;RequiredPolicyMask=1}
$null=Write-WelaSelectedSaclNative $definition $beforeUnrelated $other
}
$before=Get-WelaSelectedSaclSnapshot $definition
$original=Join-Path $caseDir 'original.json';$backup=Join-Path $caseDir 'receipts';$configured=Join-Path $caseDir 'configured.json'
Run-Wela @('targeted-sacl','-TargetSaclAction','Plan','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$id,'-IncludeOptional','-ResultsPath',$original)
Run-Wela @('targeted-sacl','-TargetSaclAction','Configure','-TargetSaclPlanPath',$original,'-TargetSaclId',$id,'-IncludeOptional','-Auto','-BackupPath',$backup,'-ResultsPath',$configured)
$completedAddition=Json $configured
Assert ($completedAddition.Results[0].Status -ceq 'Applied' -and $completedAddition.ExitCode -eq 0) 'Original public Configure supplied genuine Applied result and receipt pair.'
$pending=Join-Path $backup ($id+'.pending.json');$confirmed=Join-Path $backup ($id+'.confirmed.json')
$afterAddition=Get-WelaSelectedSaclSnapshot $definition
$planDir=Join-Path $caseDir 'recovery-plan'
$planArgs=@('file-sacl-recovery','-FileSaclRecoveryOriginalPlanPath',$original,'-FileSaclRecoveryPendingPath',$pending,'-FileSaclRecoveryConfirmedPath',$confirmed,'-FileSaclRecoveryResultsPath',$configured)
Run-Wela ($planArgs+@('-FileSaclRecoveryOutputPath',$planDir))
$planPath=Join-Path $planDir 'plan.json';$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant();$plan=Json $planPath
Assert ($plan.Kind -ceq 'WelaFileSaclRecoveryPlan' -and $plan.Expected.Identity -ceq $before.Identity -and $plan.ReadyRuleCredit -eq 0) 'Recovery plan binds the original actual file identity without telemetry credit.'
$restore=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-FileSaclRecoveryPlanPath',$planPath,'-FileSaclRecoveryPlanHash',$hash)
Run-Wela ($restore+@('-DryRun'))
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $afterAddition)) 'Public dry run preserves the exact current full descriptor.'
if($case -eq 'empty'){
$saved=[IO.File]::ReadAllBytes($confirmed);$broken=Json $confirmed;$broken.State='Pending';Save $confirmed $broken
Run-Wela ($restore+@('-DryRun')) 1 'pending and confirmed'
[IO.File]::WriteAllBytes($confirmed,$saved)
$nativePath=Join-Path $copy 'scripts/FileSaclRecoveryNative.cs';$nativeBytes=[IO.File]::ReadAllBytes($nativePath);[IO.File]::AppendAllText($nativePath,"`n// owned source mismatch fixture`n")
Run-Wela ($restore+@('-DryRun')) 1 'stale or modified'
[IO.File]::WriteAllBytes($nativePath,$nativeBytes)
# A different file at the identical path must not inherit recovery authority.
$held=Join-Path $caseDir 'original-held.txt';Move-Item -LiteralPath $file -Destination $held;[IO.File]::WriteAllText($file,'replacement')
Run-Wela ($restore+@('-DryRun')) 1 'identity or descriptor differs'
Remove-Item -LiteralPath $file;Move-Item -LiteralPath $held -Destination $file
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $afterAddition)) 'Refused receipt/source/replacement cases did not alter the original descriptor.'
}
$out=Join-Path $caseDir 'restored'
Run-Wela ($restore+@('-Auto','-FileSaclRecoveryOutputPath',$out))
$result=Json (Join-Path $out 'result.json');$after=Get-WelaSelectedSaclSnapshot $definition
Assert ($result.SaclAfter -ceq [Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($after.DescriptorBase64)) 'Reported final SACL representation matches actual reopened native bytes.'
Write-Host ("Native SACL representation: "+$result.SaclBefore+' -> '+$result.SaclAfter)
Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $result.ExitCode -eq 0 -and $result.PolicyChanges -eq 0) 'Public recovery performs and verifies only the proven added ACE removal.'
[Wela.FileSaclRecovery.Descriptor]::Removed($afterAddition.DescriptorBase64,$after.DescriptorBase64,$plan.AddedAce)
Assert ($before.Identity -ceq $after.Identity -and $before.Owner -ceq $after.Owner -and $before.Group -ceq $after.Group -and $before.DaclBase64 -ceq $after.DaclBase64 -and $before.Aces.Count -eq $after.Aces.Count) 'Actual reopened leaf preserves identity, owner/group/DACL and unrelated ACE counts.'
foreach($artifact in $result.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Durable review and pre-write intent artifacts retain their recorded hashes.'}
Assert ((Json (Join-Path $out 'pending.json')).Before.DescriptorBase64 -ceq $afterAddition.DescriptorBase64) 'Pending receipt records the exact descriptor reviewed before removal.'
Run-Wela ($restore+@('-DryRun')) 1 'identity or descriptor differs'
Write-Host "PASS: actual public leaf recovery $case, original identity $($before.Identity), $($before.Aces.Count) unrelated ACEs preserved."
}
$completed=$true
} finally {
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $policyBefore['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact
if($precedenceBefore.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $precedenceBefore.Type -Value $precedenceBefore.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue}
Assert ((Fingerprint (Get-WelaEffectiveAuditPolicy)) -ceq (Fingerprint $policyBefore) -and ((Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)|ConvertTo-Json -Compress) -ceq ($precedenceBefore|ConvertTo-Json -Compress)) 'All 59 original policy masks and typed precedence restored.'
if($completed){Remove-Item -LiteralPath $temp -Recurse -Force;Write-Host "PASS: $script:count actual public file recovery assertions; only owned files and checkout removed."}else{Write-Host "Failed fixture evidence retained at $temp"}
}
$global:LASTEXITCODE=0
+82
View File
@@ -0,0 +1,82 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/AuditRecovery.ps1')
$script:n=0;$script:writes=0
function Assert($Value,$Message) {if (-not $Value) {throw $Message};$script:n++}
function Throws($Action,$Pattern) {$message='';try {& $Action | Out-Null} catch {$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, received $message"}
function Get-WelaRecoveryHost {[pscustomobject][ordered]@{Computer='TEST';MachineGuid='11111111-1111-1111-1111-111111111111';ContextKey='test'}}
function Get-WelaNamedRecoveryObservation {param($Target) $script:observation}
function Set-WelaNamedRecoveryValue {
param($Control)
Assert (Test-Path -LiteralPath (Join-Path $script:destination '001-before.json')) 'A durable receipt precedes mutation.'
Assert-WelaNamedRecoveryGuard $Control $script:observation
$script:writes++;$script:observation.Exists=$Control.RecoverTo.ValueExists;$script:observation.Value=$Control.RecoverTo.Value
}
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-named-recovery-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $root
$journal=Join-Path $root 'before.jsonl';$original=Join-Path $root 'original.json'
function Save-Fixture($Definition,$Before) {
$script:observation=[pscustomobject]@{Exists=$true;Value=1;ObjectName=('\REGISTRY\MACHINE\'+$Definition.Path.Substring(6));OtherValues='other';Children='children';Security='security';LastWrite='42'}
$script:entry=[pscustomobject]@{Version=1;ComputerName='TEST';RecordedUtc=[datetime]::UtcNow.ToString('o');Id=$Definition.Id;Kind='Registry';Target=[pscustomobject]@{Path=$Definition.Path;Name=$Definition.Name};Before=$Before;Desired=[pscustomobject]@{Value=1;Type='DWord'}}
$script:final=[pscustomobject]@{Id=$entry.Id;Kind='Registry';Target=$entry.Target;Before=$Before;Desired=$entry.Desired;After=(Get-WelaNamedRecoveryState $observation);Status='Applied'}
Save-Evidence
}
function Save-Evidence {
$entry | ConvertTo-Json -Depth 20 -Compress | Set-Content -LiteralPath $journal -Encoding UTF8
[pscustomobject]@{DryRun=$false;Results=@($final)} | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $original -Encoding UTF8
}
try {
$catalog=@(Get-WelaNamedRecoveryCatalog)
Assert ($catalog.Count -eq 3) 'Only three fixed logging switches are admitted.'
foreach ($definition in $catalog) {
foreach ($before in @(
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'},
[pscustomobject]@{KeyExists=$true;ValueExists=$false;Value=$null;Type=$null},
[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null}
)) {
Save-Fixture $definition $before
$count=$writes
$planned=Invoke-WelaAuditRecovery -JournalPath $journal -OriginalResultsPath $original -ControlId $definition.Id -OutputPath (Join-Path $root ([guid]::NewGuid().ToString('N')))
$planPath=Join-Path $planned.OutputPath 'plan.json'
Assert ($writes -eq $count -and $planned.Status -eq 'Planned') 'Planning does not mutate registry.'
$plan=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryFile $planPath).Text
Assert ($plan.Controls[0].Kind -eq 'NamedLoggingRegistry' -and $plan.Controls[0].RecoverTo.KeyExists -and $plan.Controls[0].OriginalKeyExisted -eq $before.KeyExists) 'Value-only recovery retains keys and reports original absence.'
$dry=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun
Assert ($dry.Results[0].Status -eq 'WouldRestore' -and $writes -eq $count) 'Dry-run has no mutation.'
foreach ($field in @('ObjectName','OtherValues','Children','Security')) {
$old=$observation.$field;$observation.$field='changed'
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun} 'independently rebuilt'
$observation.$field=$old
}
$script:destination=Join-Path $root ([guid]::NewGuid().ToString('N'))
$result=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath $destination -Auto
Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Restored' -and $writes -eq $count+1 -and $result.ReadyRuleCredit -eq 0) 'Selected typed value restores without readiness credit.'
$script:destination=Join-Path $root ([guid]::NewGuid().ToString('N'))
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath $destination -Auto
Assert ($again.Results[0].Status -eq 'AlreadyRecovered' -and $writes -eq $count+1) 'Observation of restored value is idempotent.'
}
}
$zero=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'}
foreach ($invalid in @(
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value='0';Type='DWord'},
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=2;Type='DWord'},
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='QWord'},
[pscustomobject]@{KeyExists=$false;ValueExists=$true;Value=0;Type='DWord'},
[pscustomobject]@{KeyExists=$true;ValueExists=$false;Value=0;Type=$null}
)) {Save-Fixture $catalog[0] $invalid;Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Only prior|inconsistent'}
Save-Fixture $catalog[0] $zero;$final.Status='Failed';Save-Evidence
Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Applied'
Save-Fixture $catalog[0] $zero;$entry.Target.Path+='\Other';Save-Evidence
Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Unsupported'
Save-Fixture $catalog[0] $zero;$entry.Desired.Value=$true;Save-Evidence
Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Unsupported'
Save-Fixture $catalog[0] $zero;$plan=New-WelaRecoveryPlan $journal $original @($entry.Id);$plan.NamedSources[0].Sha256='bad'
Throws {Assert-WelaRecoverySources $plan} 'implementation changed'
Throws {Open-WelaNamedRecoveryKey ([pscustomobject]@{Path='HKLM:\SOFTWARE\Other';Name='Unknown'})} 'Unknown'
Initialize-WelaNamedRecoveryNative
Assert ([Wela.NamedRegistryRecovery.Key]::SourceSha256 -eq (Get-FileHash (Join-Path $repo 'scripts/NamedRegistryRecoveryNative.cs')).Hash.ToLowerInvariant()) 'Compiled native helper binds exact source bytes.'
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
$global:LASTEXITCODE=0
Write-Host "Named registry recovery: $script:n assertions passed."
@@ -0,0 +1,72 @@
param([switch]$AllowDisposablePolicyWrite)
$ErrorActionPreference='Stop'
if ($env:OS -ne 'Windows_NT') {Write-Host 'Skipped: native Windows required.';exit 0}
if (-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') {throw 'Explicit opt-in on a disposable GitHub-hosted runner is required.'}
$repo=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
. (Join-Path $repo 'scripts/AuditRecovery.ps1')
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-native-named-recovery-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $temp
$catalog=@(Get-WelaNamedRecoveryCatalog)
$safety=@(foreach ($item in $catalog) {[pscustomobject]@{Definition=$item;Before=(Get-WelaRegistryState $item.Path $item.Name)}})
$created=New-Object 'System.Collections.Generic.List[string]'
foreach ($item in $catalog) {
$path=$item.Path
while (-not (Test-Path -LiteralPath $path)) {if (-not $created.Contains($path)) {$created.Add($path)};$path=$path.Substring(0,$path.LastIndexOf('\'))}
}
Write-WelaRecoveryArtifact (Join-Path $temp 'safety-before.json') $safety
$sentinel='WelaRecoveryFixture_'+[guid]::NewGuid().ToString('N');$sentinelPath=$null
try {
$sequence=0
foreach ($definition in $catalog) {
foreach ($absent in @($false,$true)) {
$sequence++;$case=Join-Path $temp ('case-'+$sequence);$null=New-Item -ItemType Directory $case
New-WelaRegistryKey $definition.Path
if ($absent) {Remove-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -ErrorAction SilentlyContinue}
else {$null=New-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -Value 0 -PropertyType DWord -Force}
$before=Get-WelaNamedRecoveryObservation $definition
$context=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $case 'backup')
Set-WelaRegistryControl -Context $context -Path $definition.Path -Name $definition.Name -Value 1 -Type DWord
$original=Join-Path $case 'original.json'
$report=Complete-WelaConfiguration -Context $context -ResultsPath $original
if ($report.ExitCode -ne 0 -or $report.Results[0].Status -ne 'Applied') {throw 'Native configuration did not create Applied evidence.'}
$plan=Invoke-WelaAuditRecovery -JournalPath (Join-Path $context.BackupPath 'before.jsonl') -OriginalResultsPath $original -ControlId $definition.Id -OutputPath (Join-Path $case 'plan')
$planPath=Join-Path $plan.OutputPath 'plan.json'
$dry=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun
if ($dry.Results[0].Status -ne 'WouldRestore' -or (Get-WelaRegistryState $definition.Path $definition.Name).Value -ne 1) {throw 'Native dry-run changed the selected value.'}
# A neighboring value change must block before any recovery mutation.
$sentinelPath=$definition.Path;$null=New-ItemProperty -LiteralPath $sentinelPath -Name $sentinel -Value 'owned-fixture' -PropertyType String
$refused=$false
try {$null=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun} catch {if ($_.Exception.Message -notmatch 'independently rebuilt') {throw};$refused=$true}
if (-not $refused -or (Get-WelaRegistryState $definition.Path $definition.Name).Value -ne 1) {throw 'Neighbor drift did not refuse safely.'}
Remove-ItemProperty -LiteralPath $sentinelPath -Name $sentinel;$sentinelPath=$null
$result=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath (Join-Path $case 'recovered') -Auto
$after=Get-WelaNamedRecoveryObservation $definition
if ($result.ExitCode -ne 0 -or $result.Results[0].Status -ne 'Restored' -or (Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $after)) -cne (Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $before)) -or -not [Wela.NamedRegistryRecovery.Key]::Preserved($before,$after)) {throw ($result | ConvertTo-Json -Depth 20)}
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath (Join-Path $case 'again') -Auto
if ($again.ExitCode -ne 0 -or $again.Results[0].Status -ne 'AlreadyRecovered') {throw 'Native named-value recovery is not idempotent.'}
Write-Host "Native named recovery passed: $($definition.Name), prior absence=$absent; typed value, neighboring values, children, owner/group/DACL preserved."
}
}
} finally {
$errors=@()
if ($sentinelPath) {try {Remove-ItemProperty -LiteralPath $sentinelPath -Name $sentinel -ErrorAction Stop} catch {$errors+=$_.Exception.Message}}
foreach ($saved in $safety) {
try {
$definition=$saved.Definition;$before=$saved.Before
if ($before.ValueExists) {$null=New-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -Value $before.Value -PropertyType $before.Type -Force}
elseif (Test-Path -LiteralPath $definition.Path) {Remove-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -ErrorAction SilentlyContinue}
} catch {$errors+=$_.Exception.Message}
}
foreach ($path in ($created | Sort-Object Length -Descending)) {
try {if (Test-Path -LiteralPath $path) {$key=Get-Item -LiteralPath $path;if ($key.GetValueNames().Count -or $key.GetSubKeyNames().Count) {throw "Owned fixture-created key is no longer empty: $path"};Remove-Item -LiteralPath $path -ErrorAction Stop}} catch {$errors+=$_.Exception.Message}
}
foreach ($saved in $safety) {
try {if ((Get-WelaRecoveryKey (Get-WelaRegistryState $saved.Definition.Path $saved.Definition.Name)) -cne (Get-WelaRecoveryKey $saved.Before)) {throw "Safety restoration differs: $($saved.Definition.Name)"}} catch {$errors+=$_.Exception.Message}
}
if ($errors.Count) {throw "Native registry safety restoration failed; evidence retained at $temp : $($errors -join '; ')"}
Remove-Item -LiteralPath $temp -Recurse -Force
}
$global:LASTEXITCODE=0
+72
View File
@@ -0,0 +1,72 @@
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1')
. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1')
. (Join-Path $script:ScriptRoot 'scripts/TranscriptProbe.ps1')
$script:passed=0
function Assert($condition,[string]$message){if(-not $condition){throw $message};$script:passed++}
function Rejects([scriptblock]$action){$caught=$false;try{&$action|Out-Null}catch{$caught=$true};Assert $caught 'Expected refusal'}
function Clone($object){$object|ConvertTo-Json -Depth 20|ConvertFrom-Json}
$header="**********************`nWindows PowerShell transcript start`nStart time: {0:yyyyMMddHHmmss}`nUsername: {1}`nRunAs User: {2}`nConfiguration Name: {3}`nMachine: {4} ({5})`nHost Application: {6}`nProcess ID: {7}`n{8}`n**********************"
$footer="**********************`nWindows PowerShell transcript end`nEnd time: {0:yyyyMMddHHmmss}`n**********************"
$operation=[pscustomobject]@{Resources=[pscustomobject]@{TranscriptPrologue=$header;TranscriptEpilogue=$footer};Nonce=('a'*32);ProcessId=123;HeaderUser='HOST\writer';BeforeToken=[pscustomobject]@{Name='HOST\writer'};Computer='HOST';OsVersion='Microsoft Windows NT 10.0.20348.0';CommandLine='"powershell.exe" fixed';HeaderCommandLine='powershell.exe fixed';EngineVersion='5.1.20348.1000';StartOffsetMinutes=0;LaunchedUtc='2026-09-21T00:00:00.1000000Z';StartedUtc='2026-09-21T00:00:01.0000000Z';CompletedUtc='2026-09-21T00:00:02.0000000Z';ExitedUtc='2026-09-21T00:00:03.0000000Z'}
function MakeText($op){
$begin=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptPrologue,@([DateTime]::new(2026,9,21,0,0,0),$op.HeaderUser,$op.BeforeToken.Name,'',$op.Computer,$op.OsVersion,$op.HeaderCommandLine,$op.ProcessId,('PSVersion: '+$op.EngineVersion+"`nPSEdition: Desktop`n")))
$end=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptEpilogue,[DateTime]::new(2026,9,21,0,0,2))
$begin+"`nWELA-TRANSCRIPT-BEGIN:"+$op.Nonce+':'+$op.ProcessId+"`nWELA-TRANSCRIPT-END:"+$op.Nonce+':'+$op.ProcessId+"`n"+$end+"`n"
}
$text=MakeText $operation
Assert (Test-WelaTranscriptProbeText $text $operation) 'Complete native transcript framing and markers match'
foreach($case in @(
$text.Replace('Process ID: 123','Process ID: 124'),
$text.Replace('Host Application: powershell.exe fixed','Host Application: powershell.exe other'),
$text.Replace('RunAs User: HOST\writer','RunAs User: HOST\other'),
$text.Replace('PSVersion: 5.1.20348.1000','PSVersion: 7.5.0'),
$text.Replace('PSEdition: Desktop','PSEdition: Core'),
$text.Replace('Windows PowerShell transcript end','Unfinished'),
$text.Replace('WELA-TRANSCRIPT-BEGIN:','PS>WELA-TRANSCRIPT-BEGIN:'),
$text.Replace('WELA-TRANSCRIPT-END:','PS>WELA-TRANSCRIPT-END:'),
$text.Replace('Start time: 20260921000000','Start time: 20250921000000'),
$text.Replace('End time: 20260921000002','End time: 20260921000020'),
$text.Replace(('WELA-TRANSCRIPT-END:'+('a'*32)+':123'),('WELA-TRANSCRIPT-END:'+('b'*32)+':123')),
$text.Replace('Configuration Name: ','Configuration Name: remote'),
($text+$text),
($text+'trailing payload')
)){Assert (-not (Test-WelaTranscriptProbeText $case $operation)) 'Incomplete, mismatched or ambiguous content has no transcript proof'}
$marker='WELA-TRANSCRIPT-END:'+('a'*32)+':123'
Assert (-not (Test-WelaTranscriptProbeText ($text.Replace($marker,($marker+"`n"+$marker))) $operation)) 'Duplicate standalone marker is rejected'
$translated=Clone $operation
$translated.Resources.TranscriptPrologue=$header.Replace('Windows PowerShell transcript start','Windows PowerShell トランスクリプト開始').Replace('Username:','ユーザー名:')
$translated.Resources.TranscriptEpilogue=$footer.Replace('Windows PowerShell transcript end','Windows PowerShell トランスクリプト終了')
Assert (Test-WelaTranscriptProbeText (MakeText $translated) $translated) 'Runtime-supplied localized resources drive matching'
foreach($encoding in @([Text.UTF8Encoding]::new($true),[Text.UnicodeEncoding]::new($false,$true),[Text.UnicodeEncoding]::new($true,$true))){$bytes=[byte[]]@($encoding.GetPreamble()+$encoding.GetBytes($text.Replace("`n","`r`n")));Assert ((ConvertFrom-WelaTranscriptProbeBytes $bytes) -ceq $text) 'Supported transcript byte encoding roundtrips'}
Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(0xc3,0x28))}
Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(65,0,66))}
$directory=[pscustomobject]@{Path='C:\T';Identity='id1';CreatedUtc='2026-09-21T00:00:00Z';WrittenUtc='2026-09-21T00:00:00Z';Attributes=16;Descriptor='acl';Length=0;Links=1}
$new=Clone $directory;$new.WrittenUtc='2026-09-21T00:01:00Z'
Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -ceq (Get-WelaTranscriptProbeObjectKey $new -Directory)) 'Directory child creation does not replace directory identity'
foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $directory;$changed.$field='changed';Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -cne (Get-WelaTranscriptProbeObjectKey $changed -Directory)) 'Directory identity/descriptor drift is visible'}
$oldFile=Clone $directory;$oldFile.Path='C:\T\20260921\old.txt';$oldFile.Attributes=32;$oldFile.Identity='old-file';$oldFile.Length=100
$inventory=[pscustomobject]@{Folders=@([pscustomobject]@{Date='20260921';Exists=$true;Observation=$directory});Files=@($oldFile)}
$appended=Clone $inventory;$appended.Files[0].Length=200;$appended.Files[0].WrittenUtc='2026-09-21T00:01:00Z'
Assert-WelaTranscriptProbeInventory $inventory $appended;Assert $true 'Existing active transcript append is preserved without reading it'
foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $inventory;$changed.Files[0].$field='changed';Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}}
$changed=Clone $inventory;$changed.Files=@();Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}
$changed=Clone $inventory;$changed.Folders[0].Exists=$false;Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}
# Pure typed-policy tests replace only local path resolution, not the policy decision.
function Resolve-WelaArrivalPath {param([string]$Path)if($Path -notmatch '^C:\\'){throw 'Fixture non-local path'};$Path}
function Value($value,$type){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=$value;Type=$type}}
$machine=[pscustomobject]@{EnableTranscripting=(Value 1 'DWord');OutputDirectory=(Value 'C:\T' 'String');EnableInvocationHeader=(Value 1 'DWord')}
$user=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};OutputDirectory=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};EnableInvocationHeader=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null}}
$policy=@([pscustomobject]@{View='Registry64';Machine=$machine;CurrentUser=$user},[pscustomobject]@{View='Registry32';Machine=$machine;CurrentUser=$user})
Assert-WelaTranscriptProbePolicy $policy 'C:\T';Assert $true 'Known enabled matching machine policy accepted'
foreach($field in @('EnableTranscripting','OutputDirectory','EnableInvocationHeader')){$changed=Clone $policy;$changed[0].Machine.$field.Type='Unknown';$changed[1].Machine.$field.Type='Unknown';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}}
$changed=Clone $policy;$changed[0].Machine.EnableTranscripting.Value=0;$changed[1].Machine.EnableTranscripting.Value=0;Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}
Rejects {Assert-WelaTranscriptProbePolicy $policy 'C:\Other'}
$changed=Clone $policy;$changed[1].Machine.OutputDirectory.Value='C:\Other';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}
Rejects {Assert-WelaTranscriptProbePolicy @($policy[0]) 'C:\T'}
$engine=(Get-Process -Id $PID).Path
foreach($case in @(@{Args=@('transcript-probe','-Help');Exit=0},@{Args=@('transcript-probe','-Help','-Auto');Exit=1},@{Args=@('transcript-probe','-Help','-TranscriptDirectory','C:\T');Exit=1},@{Args=@('transcript-probe','-Help','-DryRun');Exit=1},@{Args=@('help','-TranscriptProbeAction','Run');Exit=1})){
$old=$ErrorActionPreference;$ErrorActionPreference='Continue';try{$output=&$engine -NoProfile -File (Join-Path $script:ScriptRoot 'WELA.ps1') @($case.Args) 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
Assert ($code -eq $case.Exit) ('CLI boundary '+($case.Args -join ' ')+': '+($output|Out-String))
}
$global:LASTEXITCODE=0;Write-Host "Transcript probe fixtures passed: $script:passed"
+99
View File
@@ -0,0 +1,99 @@
param([switch]$AllowDisposableWriter,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
$ErrorActionPreference='Stop'
if(-not $AllowDisposableWriter -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable writer/policy/ACL opt-in on a GitHub-hosted Windows runner required.'}
$computer=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem
if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)){throw 'Refusing domain, DC or unknown runner.'}
$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root
. (Join-Path $root 'scripts/PowerShellTranscription.ps1')
$nonce=[guid]::NewGuid().ToString('N');$username='WelaT'+$nonce.Substring(0,12)
$fixture=Join-Path $env:RUNNER_TEMP ('wela-transcript-probe-'+$nonce);$null=New-Item -ItemType Directory $fixture
$codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot
foreach($path in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $root $path) -Destination $codeRoot -Recurse}
$readerHome=Join-Path $fixture 'writer';$destination=Join-Path $fixture 'transcripts';$null=New-Item -ItemType Directory $readerHome,$destination
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));$policyBefore|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $fixture 'policy-before.json') -Encoding UTF8
$ownedSid=$null;$policyTouched=$false;$passed=$false;$originalAcl=$null
function Restore-Policy {
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null
try{
$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription')
$key.SetValue('EnableTranscripting',0,[Microsoft.Win32.RegistryValueKind]::DWord)
foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')){
$value=$policyBefore[0].Machine.$name
if($value.ValueExists){$key.SetValue($name,$value.Value,[Microsoft.Win32.RegistryValueKind]([string]$value.Type))}else{$key.DeleteValue($name,$false)}
}
$remove=-not $policyBefore[0].Machine.EnableTranscripting.KeyExists -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0
$key.Dispose();$key=$null
if($remove){$base.DeleteSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$false)}
}finally{if($key){$key.Dispose()};$base.Dispose()}
if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne ($policyBefore|ConvertTo-Json -Depth 12 -Compress)){throw 'Exact typed transcription policy/key restoration failed.'}
}
function Invoke-ProbeAsOwnedUser([string]$Label,[int]$ExpectedExit,[ValidateSet('Plan','Run')][string]$Action='Run'){
$output=Join-Path $readerHome $Label
# Credentials are passed as a SecureString through the process API, never command-line text.
$arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" transcript-probe -TranscriptProbeAction '+$Action+' -TranscriptProbeDirectory "'+$destination+'"'+$(if($Action -eq 'Run'){' -TranscriptProbeOutputPath "'+$output+'"'}else{''})
# Own the process handle directly: Windows PowerShell's Start-Process can lose
# ExitCode for alternate-credential children after they exit.
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=$arguments
$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.WorkingDirectory=$readerHome
$start.UserName=$username;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true
$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome
$process=[Diagnostics.Process]::new();$process.StartInfo=$start;$started=$false
try{
if(-not $process.Start()){throw 'Native reader process did not start.'};$started=$true
$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(90000)){$process.Kill();$null=$process.WaitForExit(5000);throw 'Reader child exceeded 90 seconds.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Reader output pipes did not close within five seconds of process exit.'}
$exitCode=$process.ExitCode
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stdout')),$stdout.GetAwaiter().GetResult())
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stderr')),$stderr.GetAwaiter().GetResult())
}finally{
try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Reader child termination was not confirmed; no acceptance claim.'}}}finally{$process.Dispose()}
}
if($exitCode -ne $ExpectedExit){Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'));throw "Reader exit $exitCode expected $ExpectedExit"}
if($Action -eq 'Plan'){if(Test-Path -LiteralPath $output){throw 'Plan wrote explicit evidence output.'};return}
$report=Get-Content -LiteralPath (Join-Path $output 'result.json') -Raw|ConvertFrom-Json
if($report.ReadyRuleCredit -ne 0 -or $report.SigmaEvtxCredit -ne 0 -or $report.ConfigurationChanges -ne 0){throw 'Transcript report overclaims coverage or changed configuration.'}
$report
}
try{
$password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force
$user=New-LocalUser -Name $username -Password $password -Description ('WELA transcript '+$nonce.Substring(0,20)) -AccountNeverExpires
$ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
$acl=Get-Acl $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $fixture $acl
$acl=Get-Acl $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $readerHome $acl
$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false)
foreach($sid in @('S-1-5-18','S-1-5-32-544',$ownedSid)){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))}
Set-Acl $destination $acl;$originalAcl=Get-Acl $destination
$policyTouched=$true
Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $destination -Type String
Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord
# Exercise invocation headers while preserving the real original typed preference.
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true);try{$key.SetValue('EnableInvocationHeader',1,[Microsoft.Win32.RegistryValueKind]::DWord)}finally{$key.Dispose()}}finally{$base.Dispose()}
$configured=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress
Invoke-ProbeAsOwnedUser 'plan' 0 'Plan'
$allowed=Invoke-ProbeAsOwnedUser 'allowed' 0
if($allowed.Status -ne 'CompletedAutomaticTranscript' -or $allowed.WriterAuthorization -ne 'ObservedForThisChild' -or $allowed.Worker.BeforeToken.Sid -cne $ownedSid -or $allowed.ParentBefore.Sid -cne $ownedSid -or $allowed.Worker.BeforeToken.AuthenticationId -cne $allowed.ParentBefore.AuthenticationId -or @($allowed.Worker.BeforeToken.Groups|Where-Object Sid -eq 'S-1-5-32-544').Count){throw 'No completed automatic native5.1 transcript from the actual owned standard-user logon.'}
if($allowed.Worker.Engine -notlike '*\System32\WindowsPowerShell\v1.0\powershell.exe' -or $allowed.Worker.Edition -cne 'Desktop'){throw 'Wrong transcript engine.'}
$artifact=@($allowed.Artifacts|Where-Object Name -eq 'transcript.txt')
if($artifact.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $allowed.OutputPath 'transcript.txt') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact[0].Sha256){throw 'Transcript evidence hash mismatch.'}
if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured -or (Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Probe changed configured policy or root ACL.'}
$deny=Get-Acl $destination
$deny.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'Write','ContainerInherit,ObjectInherit','None','Deny'));Set-Acl $destination $deny
$deniedAcl=(Get-Acl $destination).Sddl
$denied=Invoke-ProbeAsOwnedUser 'denied' 1
if($denied.Status -eq 'CompletedAutomaticTranscript' -or $denied.WriterAuthorization -ne 'Unverified' -or $denied.Transcript){throw 'Denied writer gained positive transcript proof.'}
if((Get-Acl $destination).Sddl -cne $deniedAcl -or (@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured){throw 'Denied run changed the explicit deny or policy.'}
$passed=$true
}finally{
$errors=@()
if($policyTouched){try{Restore-Policy}catch{$errors+=[string]$_}}
if($originalAcl){try{Set-Acl $destination $originalAcl;if((Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Owned destination ACL restoration failed.'}}catch{$errors+=[string]$_}}
if($ownedSid){try{$current=Get-LocalUser -Name $username -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Account identity changed; refusing deletion.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$errors+=[string]$_}}
[pscustomobject]@{Passed=$passed;CleanupErrors=$errors;OwnedSid=$ownedSid;PolicyRestored=($errors.Count -eq 0);Scope='Actual local standard-user automatic native5.1 transcript and explicit denied writer; no UNC, PS7-session, collector or Sigma claim'}|ConvertTo-Json -Depth 6|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8
Write-Host "Native automatic transcript evidence: $fixture"
if($errors.Count){throw ($errors -join '; ')}
}
if(-not $passed){throw 'Native transcript acceptance incomplete.'}
+81
View File
@@ -0,0 +1,81 @@
# Test-only account/owned-file ACL fixture; never loaded by the product.
function Invoke-WelaEvtxReaderFixture {
param([string]$ProbePath,[string]$ArchivePath,[string]$FixtureParent,[string]$EnginePath,[switch]$AllowDisposableAccount)
if (-not $AllowDisposableAccount -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT') {throw 'Explicit disposable account/file-ACL opt-in on a GitHub-hosted Windows runner is required.'}
$computer=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem
if ($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)) {throw 'Archive reader fixture refuses domain/DC or unsupported hosts.'}
$repo=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$nonce=[guid]::NewGuid().ToString('N');$username='WelaE'+$nonce.Substring(0,12)
$fixture=New-WelaEvtxOutput -Path (Join-Path $FixtureParent ('archive-reader-'+$nonce)) -SourcePath $ProbePath
$codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot
foreach ($path in @('WELA.ps1','scripts','modules','config')) {Copy-Item -LiteralPath (Join-Path $repo $path) -Destination $codeRoot -Recurse}
$probe=Join-Path $fixture 'probe';Copy-Item -LiteralPath $ProbePath -Destination $probe -Recurse
$archive=Join-Path $fixture 'probe.evtx';Copy-Item -LiteralPath $ArchivePath -Destination $archive
$readerHome=Join-Path $fixture 'reader';$null=New-Item -ItemType Directory $readerHome
$archiveHash=(Get-FileHash -LiteralPath $archive).Hash.ToLowerInvariant();$archiveBytes=(Get-Item -LiteralPath $archive).Length
$source=Import-WelaEvtxProbe $probe
$sourceSid=([xml]$source.Files['event.xml'].Text).GetElementsByTagName('Data')|Where-Object {$_.GetAttribute('Name') -ceq 'SubjectUserSid'}|ForEach-Object InnerText
$ownedSid=$null;$passed=$false;$beforeArchiveAcl=$null;$counter=[pscustomobject]@{Count=0}
function Check($Value,[string]$Message) {if (-not $Value) {throw $Message};$counter.Count++}
function Read-AsOwnedUser([string]$Label,[int]$ExpectedExit) {
$output=Join-Path $readerHome $Label
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$EnginePath
$start.Arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" evtx-recovery -EvtxAction Verify -EvtxProbePath "'+$probe+'" -EvtxArchivePath "'+$archive+'" -EvtxOutputPath "'+$output+'"'
$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.WorkingDirectory=$readerHome
$start.UserName=$username;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true
$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome
$process=[Diagnostics.Process]::new();$process.StartInfo=$start;$started=$false
try {
if (-not $process.Start()) {throw 'Owned archive-reader process did not start.'};$started=$true
$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(90000)) {$process.Kill();$null=$process.WaitForExit(5000);throw 'Owned archive-reader process exceeded 90 seconds.'}
if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)) {throw 'Owned reader output pipes did not close.'}
$exitCode=$process.ExitCode
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stdout')),$stdout.GetAwaiter().GetResult())
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stderr')),$stderr.GetAwaiter().GetResult())
} finally {
try {if ($started -and -not $process.HasExited) {$process.Kill();if (-not $process.WaitForExit(5000)) {throw 'Owned reader termination was not confirmed.'}}} finally {$process.Dispose()}
}
if ($exitCode -ne $ExpectedExit) {Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'))|Write-Host;Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stdout'))|Write-Host;throw "Owned archive-reader exit $exitCode expected $ExpectedExit"}
$report=ConvertFrom-WelaEvtxJson (Get-Content -LiteralPath (Join-Path $output 'manifest.json') -Raw)
if ($report.SchemaVersion -ne 2 -or $report.ReaderBefore.UserSid -cne $ownedSid -or $report.ReaderBefore.ElevatedAdministrator -or $report.ReaderBefore.GroupSids -contains 'S-1-5-32-544' -or $report.ReaderBefore.GroupSids -contains 'S-1-5-32-573' -or $report.ReaderBefore.TokenType -cne 'Primary' -or $report.ReaderBefore.Impersonation -cne 'Absent') {throw 'Archive query did not use the owned standard-user primary token.'}
if (-not $report.ReaderStable -or (Get-WelaEvtxRecoveryKey $report.ReaderBefore) -cne (Get-WelaEvtxRecoveryKey $report.ReaderAfter) -or $report.ReadyRuleCredit -ne 0 -or $report.PolicyChanges -ne 0) {throw 'Reader token changed or the report overclaimed configuration/readiness.'}
$report
}
try {
$password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force
$user=New-LocalUser -Name $username -Password $password -Description ('WELA EVTX '+$nonce) -AccountNeverExpires
$ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
$acl=Get-Acl -LiteralPath $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $fixture -AclObject $acl
$acl=Get-Acl -LiteralPath $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $readerHome -AclObject $acl
# Only the owned copy is changed; source/producer ACLs and system logs remain intact.
$beforeArchiveAcl=(Get-Acl -LiteralPath $archive).Sddl
$deny=[Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadData','Deny')
$acl=Get-Acl -LiteralPath $archive;$acl.AddAccessRule($deny);Set-Acl -LiteralPath $archive -AclObject $acl
$denied=Read-AsOwnedUser 'denied' 1
Check ($denied.Status -eq 'Unverified' -and $denied.FileReadAccess -eq 'Denied' -and $denied.NativeError -eq 5 -and $denied.FailureStage -eq 'ArchiveFileOpen' -and $denied.NativeQuery -eq 'NotAttempted' -and $denied.RecoveredEvents -eq 0 -and $null -eq $denied.ArchiveSha256) 'Real file-read denial was misreported as native query or recovery success.'
Check (-not (Test-Path -LiteralPath (Join-Path $denied.OutputPath 'recovered-event.xml'))) 'Denied reader emitted a recovered event.'
$acl=Get-Acl -LiteralPath $archive;$acl.RemoveAccessRuleSpecific($deny);Set-Acl -LiteralPath $archive -AclObject $acl
Check ((Get-Acl -LiteralPath $archive).Sddl -ceq $beforeArchiveAcl) 'Owned archive ACL differs after removing only the fixture deny.'
$allowed=Read-AsOwnedUser 'allowed' 0
Check ($allowed.Status -eq 'NativeEventRecovered' -and $allowed.FileReadAccess -eq 'Allowed' -and $allowed.NativeQuery -eq 'ExactEventRecovered' -and $allowed.RecoveredEvents -eq 1) 'Fresh standard user did not recover the exact native event.'
Check ($allowed.ArchiveSha256 -ceq $archiveHash -and $allowed.ArchiveBytes -eq $archiveBytes) 'Owned reader recovered different archive bytes.'
Check ($allowed.NativeLogStatus.Count -eq 1 -and $allowed.NativeLogStatus[0].StatusCode -eq 0 -and $allowed.NativeLogStatus[0].LogName -ieq $archive) 'Native file-query status was not bound to the exact archive.'
Check ($denied.ReaderBefore.AuthenticationId -cne $allowed.ReaderBefore.AuthenticationId -and $denied.ReaderBefore.TokenId -cne $allowed.ReaderBefore.TokenId) 'Expected independent fresh logon and token identities.'
Check ($sourceSid -and $sourceSid -cne $allowed.ReaderBefore.UserSid -and $allowed.SourceComputer -ceq $source.Event.Computer) 'Archive reader and original producer identities were conflated.'
$recovered=[IO.File]::ReadAllText((Join-Path $allowed.OutputPath 'recovered-event.xml'))
Check ((Read-WelaEvtxEvent $recovered).Key -ceq $source.Event.Key) 'Independently reopened event differs from the producer probe.'
foreach ($artifact in $allowed.Artifacts) {Check ((Get-FileHash -LiteralPath (Join-Path $allowed.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Owned reader evidence hash differs.'}
Check ((Import-WelaEvtxProbe $probe).Fingerprint -ceq $source.Fingerprint -and (Get-FileHash -LiteralPath $archive).Hash.ToLowerInvariant() -ceq $archiveHash -and (Get-Acl -LiteralPath $archive).Sddl -ceq $beforeArchiveAcl) 'Read-only recovery changed source evidence or its file ACL.'
$passed=$true
} finally {
$errors=@()
if ($beforeArchiveAcl) {try {$acl=Get-Acl -LiteralPath $archive;$acl.SetSecurityDescriptorSddlForm($beforeArchiveAcl);Set-Acl -LiteralPath $archive -AclObject $acl;if ((Get-Acl -LiteralPath $archive).Sddl -cne $beforeArchiveAcl) {throw 'Owned archive ACL restoration differs.'}} catch {$errors+=[string]$_}}
if ($ownedSid) {try {$current=Get-LocalUser -Name $username -ErrorAction Stop;if ($current.SID.Value -cne $ownedSid) {throw 'Owned account identity changed; refusing deletion.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if (Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue) {throw 'Owned account remains.'}} catch {$errors+=[string]$_}}
[pscustomobject]@{Passed=$passed;Checks=$counter.Count;CleanupErrors=$errors;AccountSid=$ownedSid;ArchiveSha256=$archiveHash;Scope='Fresh standard-user file denial and exact native 4688 EVTX recovery; no channel/service-token, backend, archive-duration or Sigma claim'}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8
if ($errors.Count) {throw ($errors -join '; ')}
}
if (-not $passed) {throw 'Owned archive-reader acceptance incomplete.'}
Write-Host "Native EVTX standard-reader proof: $($counter.Count) assertions; fresh denied/allowed logons, exact 4688, original producer distinct, owned file ACL restored and account removed. Engine: $EnginePath"
}