Merge commit '39e8ce1' into test/373-native-registry-sacl-lifecycle

This commit is contained in:
Shirofune-Security committed 2026-09-22 07:29:23 +09:00
commit 99cd1ea492
37 files changed
+2258 -2

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
$engine=Join-Path $PSHOME $(if($PSEdition -eq 'Core'){if($env:OS -eq 'Windows_NT'){'pwsh.exe'}else{'pwsh'}}else{'powershell.exe'})
$count=0
function Check-Command([string]$Arguments,[int]$ExpectedExit,[string]$Pattern) {
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments='-NoProfile -File "'+(Join-Path $repo 'WELA.ps1')+'" '+$Arguments;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try {
$started=$process.Start();$out=$process.StandardOutput.ReadToEndAsync();$err=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(30000)){throw 'CLI timeout'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($out,$err),5000)){throw 'CLI output timeout'}
$text=$out.GetAwaiter().GetResult()+$err.GetAwaiter().GetResult()
if(($process.ExitCode -eq 0) -ne ($ExpectedExit -eq 0) -or $text -notmatch $Pattern){throw "CLI mismatch: $Arguments ; Exit=$($process.ExitCode) ; $text"};$script:count++
}finally{if($started -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(5000)};$process.Dispose()}
}
Check-Command 'applocker-script-probe -Help' 0 'existing Script AuditOnly'
Check-Command 'help' 0 'applocker-script-probe'
Check-Command 'version -AppLockerScriptAction Run' 1 'AppLockerScript options require'
Check-Command 'applocker-script-probe -AppLockerScriptAction Run -WhatIf' 1 'only its dedicated'
Check-Command 'applocker-script-probe -AppLockerScriptAction Run -DryRun' 1 'only its dedicated'
Check-Command 'applocker-script-probe -Auto' 1 'only its dedicated'
Check-Command 'applocker-script-probe -AppLockerProbeAction Run' 1 'only its dedicated'
Check-Command 'applocker-script-probe -AppLockerScriptAction Run' 1 'Run requires'
Check-Command 'applocker-script-probe -AppLockerScriptAction Plan -AppLockerScriptOutputPath ignored' 1 'Run requires'
Write-Host "AppLocker Script public CLI fixtures passed: $count checks."
+88
View File
@@ -0,0 +1,88 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
. "$repo/scripts/AppLockerReadiness.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/AppLockerScriptProbe.ps1"
# Mocking Get-Service skips the cmdlet's normal .NET Framework assembly load.
if(-not ('System.ServiceProcess.ServiceControllerStatus' -as [type])){Add-Type -AssemblyName System.ServiceProcess -ErrorAction Stop}
$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
# Prove stopped service refusal happens before the actual state reader reaches CIM.
$script:missingService='';$script:scm=@();$script:cimCalls=0
function Get-Service {param($Name);$script:scm+=$Name;[pscustomobject]@{Name=$Name;Status=$(if($Name -ceq $script:missingService){[ServiceProcess.ServiceControllerStatus]::Stopped}else{[ServiceProcess.ServiceControllerStatus]::Running})}}
function Get-WelaAppLockerHost {$script:cimCalls++;throw 'CIM boundary reached after SCM checks'}
foreach($name in @('Winmgmt','EventLog','AppIDSvc')){
$script:missingService=$name;$script:scm=@();$script:cimCalls=0
Reject {Get-WelaAppLockerScriptState} ($name+' must already be running')
Assert ($script:cimCalls -eq 0) 'Stopped service refusal precedes all CIM observations'
}
$script:missingService='';$script:scm=@();$script:cimCalls=0
Reject {Get-WelaAppLockerScriptState} 'CIM boundary reached after SCM checks'
Assert (($script:scm -join ',') -ceq 'Winmgmt,EventLog,AppIDSvc' -and $script:cimCalls -eq 1) 'All direct SCM checks precede the first CIM observation'
$policy='<AppLockerPolicy Version="1"><RuleCollection Type="Script" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Windows" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
$state=[pscustomobject]@{Services=@([pscustomobject]@{Name='Winmgmt';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='AppIDSvc';Status='Running'});Host=[pscustomobject]@{Status='Candidate';Is64BitProcess=$true;PartOfDomain=$false;ProductType=3;Build=20348};MachineGuid='11111111-1111-1111-1111-111111111111';Management=[pscustomobject]@{Status='Observed'};Computer='TEST';Domain='WORKGROUP';Reader=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000'};EffectivePolicy=[pscustomobject]@{Status='Observed';Policy=(ConvertFrom-WelaAppLockerXml $policy)};Service=[pscustomobject]@{Status='Observed';State='Running';StartMode='Manual'};Channel=[pscustomobject]@{Name='Microsoft-Windows-AppLocker/MSI and Script';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Source='C:\Windows\System32\cmd.ps1';SourceHash=('a'*64)}
$state|Add-Member NoteProperty LocalPolicy ($state.EffectivePolicy|ConvertTo-Json -Depth 20|ConvertFrom-Json)
$null=Get-WelaAppLockerScriptStateKey $state;Assert $true 'Valid audit-only prereqs'
foreach($parent in @('Host','LocalPolicy','EffectivePolicy','Service','Management')){
$saved=$state.$parent.Status;$state.$parent.Status=$true
Reject {Get-WelaAppLockerScriptStateKey $state} 'typed string'
$state.$parent.Status=$saved
}
$state.Services[0].Status=$true;Reject {Get-WelaAppLockerScriptStateKey $state} 'preflight';$state.Services[0].Status='Running'
foreach($mode in @('Enabled','NotConfigured')){$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml ($policy.Replace('AuditOnly',$mode));Reject {Get-WelaAppLockerScriptStateKey $state} 'AuditOnly'}
$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml $policy
$state.Service.State='Stopped';Reject {Get-WelaAppLockerScriptStateKey $state} 'already be running';$state.Service.State='Running'
$state.Channel.Enabled=$false;Reject {Get-WelaAppLockerScriptStateKey $state} 'enabled';$state.Channel.Enabled=$true
$process=[pscustomobject]@{ScriptPath='C:\Temp\wela-owned.ps1';ProcessId=1234;UserSid=$state.Reader.Sid;StartedUtc='2026-09-01T00:00:00.0000000Z';CompletedUtc='2026-09-01T00:00:02.0000000Z'}
$event='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-AppLocker" Guid="{cbda4dbf-8d5d-4f69-9578-be14aa540d22}"/><EventID>8006</EventID><Version>0</Version><EventRecordID>42</EventRecordID><TimeCreated SystemTime="2026-09-01T00:00:01.0000000Z"/><Channel>Microsoft-Windows-AppLocker/MSI and Script</Channel><Computer>TEST</Computer></System><UserData><RuleAndFileData xmlns="http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0"><PolicyName>SCRIPT</PolicyName><TargetUser>S-1-5-21-1-2-3-1000</TargetUser><TargetProcessId>1234</TargetProcessId><FilePath>C:\Temp\wela-owned.ps1</FilePath></RuleAndFileData></UserData></Event>'
$end=[long]41
Assert (Test-WelaAppLockerScriptEvent $event $process $state $end) 'Exact fixture must match'
Assert (Test-WelaAppLockerScriptEvent ($event.Replace('8006','8005')) $process $state $end) 'Allowed event matches but has distinct EventId'
$mutations=@(@('8006','8007'),@('1234','1235'),@('S-1-5-21-1-2-3-1000','S-1-5-21-1-2-3-1001'),@('C:\Temp\wela-owned.ps1','C:\Temp\other.ps1'),@('<PolicyName>SCRIPT','<PolicyName>DLL'),@('<Computer>TEST','<Computer>OTHER'),@('cbda4dbf','abda4dbf'),@('<Version>0','<Version>1'),@('00:00:01.0000000Z','00:00:03.0000000Z'),@('</System>','<EventID>8006</EventID></System>'),@('</RuleAndFileData>','<TargetUser>S-1-1-0</TargetUser></RuleAndFileData>'))
foreach($pair in $mutations){$bad=$event.Replace($pair[0],$pair[1]);Assert ($bad -cne $event) 'Mutation changed fixture';Assert (-not(Test-WelaAppLockerScriptEvent $bad $process $state $end)) ('Reject '+$pair[0])}
Assert (-not(Test-WelaAppLockerScriptEvent ('<!DOCTYPE Event [<!ENTITY x SYSTEM "file:///etc/passwd">]>'+$event) $process $state $end)) 'DTD rejected'
Reject {Invoke-WelaAppLockerScriptProbe -Action Run} 'requires'
Reject {Invoke-WelaAppLockerScriptProbe -Action Plan -OutputPath ignored} 'requires'
Assert (-not(Test-WelaAppLockerScriptEvent $event $process $state 42)) 'Previously observed record is rejected'
Assert (-not(Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:02.0000001Z')) $process $state $end)) 'A 100ns late record is rejected without clock padding'
Assert (Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:00.0000000Z')) $process $state $end) 'Exact inclusive start is accepted'
Assert (Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:02.0000000Z')) $process $state $end) 'Exact inclusive completion is accepted'
$worker=[IO.File]::ReadAllText("$repo/scripts/AppLockerScriptWorker.ps1")
$text=New-WelaAppLockerScriptText $worker ('a'*32)
Assert ($text -notlike '*__WELA_SCRIPT_NONCE__*') 'All three fixed nonce placeholders replaced'
Reject {New-WelaAppLockerScriptText $worker ('a'*31+"'" )} 'nonce'
Reject {New-WelaAppLockerScriptText ($worker+'__WELA_SCRIPT_NONCE__') ('a'*32)} 'template'
$tokens=$null;$errors=$null;$null=[Management.Automation.Language.Parser]::ParseInput($text,[ref]$tokens,[ref]$errors)
Assert (-not $errors.Count) 'Generated worker parses'
# Use the production orchestration with mocked native read/launch boundaries.
# These fixtures never stand in for actual native success; the Windows matrix does that.
$script:ScriptRoot=$repo;$script:scenario='ok';$script:reads=0;$script:state=$state;$script:event=$event
$script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000';AuthenticationId='0x123';Groups=@();Privileges=@();TokenId='0x456';ModifiedId='0x789'}
function Initialize-WelaAppLockerScriptNative {}
function Get-WelaAppLockerScriptReader {if($script:scenario -eq 'reader-drift' -and $script:reads -gt 2){$script:token.ModifiedId='0xabc'};$script:token|ConvertTo-Json -Depth 8|ConvertFrom-Json}
function Get-WelaAppLockerScriptUtcNow {([DateTimeOffset]::Parse('2026-09-01T00:00:00Z')).UtcDateTime}
function Get-WelaAppLockerScriptState {$script:reads++;if($script:scenario -eq 'drift' -and $script:reads -gt 2){$script:state.Service.StartMode='Auto'};$script:state|ConvertTo-Json -Depth 20|ConvertFrom-Json}
function Read-WelaAppLockerScriptBoundary {41}
function Start-WelaAppLockerScriptProcess {
param($Root,$State,$Reader,$SourcesKey)
if($script:scenario -eq 'reader-drift'){$script:token.ModifiedId='0xabc'}
$artifact=Write-WelaArrivalArtifact $Root 'fixed.ps1' 'fixed'
[pscustomobject]@{ScriptPath=(Join-Path $Root 'fixed.ps1');ScriptSha256=$artifact.Sha256;ScriptArtifact=$artifact;Nonce=('a'*32)}
}
function Read-WelaAppLockerScriptEvents {param($Boundary);if($script:scenario -eq 'denied'){throw [UnauthorizedAccessException]::new('Native query denied')};[pscustomobject]@{Xml=if($script:scenario -eq 'duplicate'){@($script:event,$script:event)}elseif($script:scenario -eq 'absent'){@()}else{@($script:event)};Complete=($script:scenario -ne 'cap')}}
function Test-WelaAppLockerScriptEvent {$true}
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-script-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
try {
foreach($scenario in @('ok','duplicate','drift','cap','denied','absent','reader-drift')){
$script:scenario=$scenario;$script:reads=0;$state.Service.StartMode='Manual';$script:token.ModifiedId='0x789'
$result=Invoke-WelaAppLockerScriptProbe Run (Join-Path $root $scenario) 1
Assert ($result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0) 'No readiness or configuration credit'
Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Expected outcome $scenario : $($result.Diagnostic)"
Assert (($result.Status -ceq 'NativeScriptEventObserved') -eq ($scenario -eq 'ok')) 'Only complete success receives observed status'
Assert (Test-Path (Join-Path $result.OutputPath 'manifest.json')) 'Success/failure manifest retained'
}
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "AppLocker Script fixtures passed: $count assertions."
@@ -0,0 +1,134 @@
param([switch]$AllowDisposablePolicyWrite)
$ErrorActionPreference='Stop'
if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: Windows required.';exit 0}
if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted policy-write opt-in required.'}
function Refresh-DisposableComputerPolicy {
$info=New-Object Diagnostics.ProcessStartInfo
$info.FileName=Join-Path ([Environment]::SystemDirectory) 'gpupdate.exe';$info.Arguments='/target:computer /force /wait:30';$info.UseShellExecute=$false
$process=[Diagnostics.Process]::Start($info)
try {if(-not $process.WaitForExit(60000)){$process.Kill();throw 'Disposable computer policy refresh exceeded 60 seconds.'};if($process.ExitCode -ne 0){throw ('Disposable computer policy refresh failed: '+$process.ExitCode)}} finally {$process.Dispose()}
}
function Stop-DisposablePolicyConverter {
$task=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop
if($task.State -in @('Running','Queued')) {Stop-ScheduledTask -InputObject $task -ErrorAction Stop}
$deadline=[DateTime]::UtcNow.AddSeconds(15)
do {$task=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop;if($task.State -in @('Ready','Disabled')){return};Start-Sleep -Milliseconds 200}while([DateTime]::UtcNow -lt $deadline)
throw 'The verified borrowed PolicyConverter task did not become idle.'
}
function Run-DisposablePolicyConverter {
# The Task Scheduler CIM provider can retain stale LastRunTime on Server2022.
# Follow the actual COM Run instance and native completion state instead.
$scheduler=$null;$folder=$null;$registered=$null;$instance=$null;$running=$null;$definition=$null;$settings=$null
try {
$scheduler=New-Object -ComObject 'Schedule.Service';$scheduler.Connect()
$folder=$scheduler.GetFolder('\Microsoft\Windows\AppID');$registered=$folder.GetTask('PolicyConverter')
$definition=$registered.Definition;$settings=$definition.Settings
if(-not $settings.AllowDemandStart){throw 'The verified PolicyConverter task does not allow an on-demand invocation.'}
$running=$registered.GetInstances(0)
if($running.Count -ne 0 -or $registered.State -ne 3){throw 'The verified borrowed PolicyConverter task must be idle before invocation.'}
$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($running);$running=$null
$instance=$registered.Run($null)
if($null -eq $instance -or [string]::IsNullOrWhiteSpace($instance.InstanceGuid)){throw 'Native PolicyConverter did not return a task instance identity.'}
$instanceId=[string]$instance.InstanceGuid;$deadline=[DateTime]::UtcNow.AddSeconds(30)
do {
$running=$registered.GetInstances(0)
try {$idle=$running.Count -eq 0 -and $registered.State -eq 3}finally{$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($running);$running=$null}
if($idle){if($registered.LastTaskResult -ne 0){throw ('Native policy conversion failed: '+$registered.LastTaskResult)};Write-Host ('Native PolicyConverter instance completed: '+$instanceId);return}
Start-Sleep -Milliseconds 200
}while([DateTime]::UtcNow -lt $deadline)
Stop-DisposablePolicyConverter
throw 'The owned native PolicyConverter instance did not complete within thirty seconds.'
}finally{foreach($item in @($running,$instance,$settings,$definition,$registered,$folder,$scheduler)){if($null -ne $item -and [Runtime.InteropServices.Marshal]::IsComObject($item)){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($item)}}}
}
$repo=Split-Path $PSScriptRoot -Parent
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/AppLockerReadiness.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/AppLockerScriptProbe.ps1"
$script:ScriptRoot=$repo
$root=Join-Path $env:RUNNER_TEMP ('wela-applocker-script-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
Write-Host ('Native fixture process session: '+[Diagnostics.Process]::GetCurrentProcess().SessionId)
$converter=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop
$converterBefore=Export-ScheduledTask -InputObject $converter -ErrorAction Stop
$converterDisabled=$converter.State -eq 'Disabled';$converterChanged=$false
$actions=@($converter.Actions)
if($converter.State -notin @('Disabled','Ready') -or $actions.Count -ne 1 -or [Environment]::ExpandEnvironmentVariables($actions[0].Execute).Trim('"') -ine (Join-Path ([Environment]::SystemDirectory) 'appidpolicyconverter.exe') -or $actions[0].Arguments){throw ('Only the unchanged native PolicyConverter action is permitted: '+($actions|ConvertTo-Json -Depth 8))}
$before=Get-WelaAppLockerReadiness
if($before.Host.PartOfDomain -or $before.Management.Status -ne 'Observed' -or $before.LocalPolicy.Status -ne 'Observed' -or $before.EffectiveGpPolicy.Status -ne 'Observed' -or $before.LocalPolicy.Policy.TotalRules -ne 0 -or $before.EffectiveGpPolicy.Policy.TotalRules -ne 0 -or $before.LocalPolicy.Policy.HasUnknownPolicyData -or $before.EffectiveGpPolicy.Policy.HasUnknownPolicyData){Write-Host ($before | ConvertTo-Json -Depth 16);throw 'Disposable test requires empty, understood local/effective policies on a non-domain disposable host.'}
$backup=Join-Path $root 'policy-before.xml';[IO.File]::WriteAllText($backup,$before.LocalPolicy.Policy.Xml)
[IO.File]::WriteAllText((Join-Path $root 'prerequisites-before.json'),($before | ConvertTo-Json -Depth 16))
$fixture='<AppLockerPolicy Version="1"><RuleCollection Type="Script" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Disposable Windows path only" Description="Owned native event fixture" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
$policyPath=Join-Path $root 'fixture.xml';[IO.File]::WriteAllText($policyPath,$fixture)
$log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script');$enabled=$log.IsEnabled;$touched=$false;$cleanup=@();$primary=$null
try {
$touched=$true
# Test-only preparation under explicit disposable-host and empty-GP gates.
# Hosted images contain enrollment/provider keys: preserve them and CSP Unknown.
# The production importer must continue to reject those observations.
$preparedUtc=[DateTime]::UtcNow
Set-AppLockerPolicy -XmlPolicy $policyPath -ErrorAction Stop
if($before.Service.StartMode -eq 'Disabled'){throw 'Test will not change protected AppIDSvc startup mode.'}
if($before.Service.State -ne 'Running'){Start-Service AppIDSvc -ErrorAction Stop}
$log.IsEnabled=$true;$log.SaveChanges()
if($converterDisabled){$converterChanged=$true;$null=Enable-ScheduledTask -InputObject $converter -ErrorAction Stop}
Refresh-DisposableComputerPolicy
Run-DisposablePolicyConverter
$applied=$false;$applyDeadline=[DateTime]::UtcNow.AddSeconds(30)
do {
$records=@()
try {try{$records=@(Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-AppLocker/EXE and DLL';Id=8001;StartTime=$preparedUtc} -MaxEvents 10 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};$applied=$records.Count -gt 0} finally {foreach($record in $records){$record.Dispose()}}
if($applied){break};Start-Sleep -Milliseconds 250
} while([DateTime]::UtcNow -lt $applyDeadline)
if(-not $applied){throw 'No native 8001 policy-applied event after disposable GP refresh.'}
Write-Host 'Native 8001 policy-applied evidence observed after disposable GP refresh.'
# Wait for actual effective audit-only policy, without treating elapsed time as success.
$deadline=[DateTime]::UtcNow.AddSeconds(30)
do {$state=Get-WelaAppLockerScriptState;$ready=$false;try{$null=Get-WelaAppLockerScriptStateKey $state;$ready=$true}catch{};if($ready){break};Start-Sleep -Milliseconds 500}while([DateTime]::UtcNow -lt $deadline)
foreach($decision in @('WouldBlock','Allowed')) {
if($decision -eq 'Allowed'){
[IO.File]::WriteAllText($policyPath,$fixture.Replace('%WINDIR%\*','*'))
Set-AppLockerPolicy -XmlPolicy $policyPath -ErrorAction Stop
Refresh-DisposableComputerPolicy;Run-DisposablePolicyConverter
$expected=ConvertFrom-WelaAppLockerXml ([IO.File]::ReadAllText($policyPath))
$actual=Get-WelaAppLockerPolicySnapshot Effective
if($actual.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $actual.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $expected.Xml)){throw 'Actual allowed Script policy differs from the disposable fixture.'}
}
$probe=& "$repo/WELA.ps1" applocker-script-probe -AppLockerScriptAction Run -AppLockerScriptOutputPath (Join-Path $root ('evidence-'+$decision)) -AppLockerScriptTimeoutSeconds 30
$expectedId=if($decision -eq 'Allowed'){8005}else{8006}
if($probe.ExitCode -or $probe.Status -cne 'NativeScriptEventObserved' -or $probe.EventId -ne $expectedId){throw ($probe|ConvertTo-Json -Depth 32)}
if($probe.ReadyRuleCredit -ne 0 -or $probe.PolicyChanges -ne 0){throw 'Unsupported policy/credit claim.'}
foreach($artifact in $probe.Artifacts){if((Get-FileHash (Join-Path $probe.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Artifact hash mismatch'}}
Write-Host "Native AppLocker $expectedId observed via public CLI under PowerShell $($PSVersionTable.PSVersion), build $($probe.Before.Host.Build). Zero Sigma credit."
}
} catch {
$primary=$_;Write-Host $_
Get-ChildItem -LiteralPath $root -Recurse -Filter 'candidate-*.xml'|ForEach-Object {Write-Host ([IO.File]::ReadAllText($_.FullName))}
# Read-only diagnostic independent of the production XPath filter and parser.
try {
$recent=@(Get-WinEvent -LogName 'Microsoft-Windows-AppLocker/MSI and Script' -MaxEvents 12 -ErrorAction Stop)
try {foreach($record in $recent){Write-Host ('Recent native channel XML: '+$record.ToXml())}} finally {foreach($record in $recent){$record.Dispose()}}
} catch {Write-Host ('Recent native channel read: '+$_.Exception.Message)}
Get-CimInstance Win32_SystemDriver -Filter "Name='AppID'" | Select-Object Name,State,StartMode | ConvertTo-Json | Write-Host
try {Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json | Write-Host}catch{Write-Host ('AppID task read: '+$_.Exception.Message)}
try {$nativeLog=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script');try{$nativeLog | Select-Object IsEnabled,LogType,ProviderLevel,ProviderKeywords,LogIsolation | ConvertTo-Json | Write-Host}finally{$nativeLog.Dispose()}}catch{Write-Host ('Channel metadata read: '+$_.Exception.Message)}
Write-Host ((Get-WelaAppLockerPolicySnapshot Effective) | ConvertTo-Json -Depth 12)
}
finally {
if($touched){
try {Stop-DisposablePolicyConverter}catch{$cleanup+=$_.Exception.Message}
try {Set-AppLockerPolicy -XmlPolicy $backup -ErrorAction Stop;Refresh-DisposableComputerPolicy;if($converterChanged -or -not $converterDisabled){Run-DisposablePolicyConverter};$restored=Get-WelaAppLockerPolicySnapshot Local;if($restored.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $restored.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $before.LocalPolicy.Policy.Xml)){throw 'Local policy restoration differs'};$effectiveRestored=Get-WelaAppLockerPolicySnapshot Effective;if($effectiveRestored.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $effectiveRestored.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $before.EffectiveGpPolicy.Policy.Xml)){throw 'Effective GP policy restoration differs'}}catch{$cleanup+=$_.Exception.Message}
try {Stop-DisposablePolicyConverter;if($converterChanged){$null=Disable-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop};$taskAfter=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop;if($taskAfter.State -ne $(if($converterDisabled){'Disabled'}else{'Ready'}) -or (Export-ScheduledTask -InputObject $taskAfter -ErrorAction Stop) -cne $converterBefore){throw 'Native PolicyConverter task definition was not restored'}}catch{$cleanup+=$_.Exception.Message}
try {$log.IsEnabled=$enabled;$log.SaveChanges();$verify=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($log.LogName);try{if($verify.IsEnabled -ne $enabled){throw 'Channel restoration differs'}}finally{$verify.Dispose()}}catch{$cleanup+=$_.Exception.Message}
if($before.Service.State -ne 'Running') {try {Stop-Service AppIDSvc -ErrorAction Stop}catch{Write-Host 'Protected AppIDSvc could not stop; startup mode was untouched. The disposable hosted VM is discarded after this job.'}}
$afterService=Get-WelaAppLockerService;if($afterService.StartMode -ne $before.Service.StartMode){$cleanup+='AppIDSvc startup mode changed'}
}
$log.Dispose()
}
if($cleanup.Count){throw ('Native cleanup failed: '+($cleanup -join '; '))}
if($primary){throw $primary}
$cleanupReceipt=[pscustomobject]@{Head=$env:GITHUB_SHA;Engine=[string]$PSVersionTable.PSVersion;PolicyRestored=$true;ChannelRestored=$true;TaskRestored=$true;ServiceBefore=$before.Service;ServiceAfter=(Get-WelaAppLockerService);ServiceStateRestored=($before.Service.State -ceq (Get-WelaAppLockerService).State);ServiceStartupPreserved=($before.Service.StartMode -ceq (Get-WelaAppLockerService).StartMode);ProtectedServiceBoundary='If AppIDSvc refuses Stop, its running state is left for disposable VM teardown; no full service-state rollback claim.'}
[IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanupReceipt|ConvertTo-Json -Depth 8))
Write-Host 'Original local/effective GP policy, channel enablement and converter task restored; service startup mode preserved.'
$global:LASTEXITCODE=0
+70
View File
@@ -0,0 +1,70 @@
# Public process-boundary regression: no mocked dispatcher or Windows writers.
$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
$engine = (Get-Process -Id $PID).Path
$count = 0
$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-cli-arguments-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $root
function Assert($Value, $Message) { if (-not $Value) { throw $Message }; $script:count++ }
function Invoke-Case([string[]]$Arguments, [int]$Expected, [string]$Pattern) {
$prior = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
$output = & $engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1 | Out-String
$code = $LASTEXITCODE
} finally { $ErrorActionPreference = $prior }
Assert ($code -eq $Expected -and $output -match $Pattern) "Unexpected public CLI exit/output [$code]: $output"
}
$isWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
function Read-NativeState {
$logs = @('Security','System','Application','ForwardedEvents','Microsoft-Windows-CAPI2/Operational')
$state = [ordered]@{ Audit = Get-WelaEffectiveAuditPolicy; Channels = @() }
foreach ($name in $logs) { $state.Channels += Get-WelaNativeChannel $name }
return ($state | ConvertTo-Json -Depth 12 -Compress)
}
try {
if ($isWindowsHost) {
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
Import-Module "$repo/modules/NativeProviders.psm1" -Force
$before = Read-NativeState
}
# These previously reached legacy writers, including the profile fast path.
$commands = @(
@('configure','-Auto'),
@('configure','-Profile','wela-2.2.0','-Auto'),
@('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto'),
@('configure-sacl','-Auto'),
@('channel-settings','-ChannelAction','Configure','-GrantEventLogReaders','-Auto'),
@('powershell-transcription','-TranscriptionAction','Configure','-Auto'),
@('firewall-logging','-FirewallAction','Configure','-Auto'),
@('smb-auditing','-SmbAction','Configure','-Auto'),
@('audit-integrity','-IntegrityAction','Configure','-Auto'),
@('provider-packs','-ProviderAction','Configure','-Auto'),
@('wec-collector','-WefAction','Configure','-Auto'),
@('audit-settings','-Help')
)
foreach ($command in $commands) {
foreach ($unknown in @('-WhatIf','-DryRnu')) {
Invoke-Case ($command + @('-BackupPath',"$root/journal",'-ResultsPath',"$root/result.json",$unknown)) 1 'Unsupported trailing arguments'
Assert (-not (Test-Path "$root/journal") -and -not (Test-Path "$root/result.json")) 'Rejected arguments must not create journals/results'
}
}
# Unknown argument values are deliberately omitted from WELA's diagnostic.
Invoke-Case @('configure','-Auto','-UnrecognizedOption','opaque-value') 1 'Unsupported trailing arguments'
Invoke-Case @('configure','-Help','-WhatIf:$false') 1 'Unsupported trailing arguments'
Invoke-Case @('-WhatIf','configure','-Auto') 1 'Unsupported trailing arguments'
# Preserve documented named/positional binding, help, abbreviations and DryRun.
Invoke-Case @('configure','-Help','-Auto','-DryRun') 0 'Read live state'
Invoke-Case @('-Cmd','configure','-Help') 0 'Usage:'
Invoke-Case @('configure','std','-Help') 0 'Usage:'
Invoke-Case @('configure','-Hel') 0 'Usage:'
Invoke-Case @('profiles') 0 'wela-2.2.0'
Invoke-Case @('failed-logon-probe','-FailedLogonAction','Run','-WhatIf') 1 'only dedicated'
if ($isWindowsHost) {
Assert ((Read-NativeState) -ceq $before) 'Actual audit masks and native channel settings must remain unchanged'
$evidence = [ordered]@{ Status='Passed'; Engine=$PSVersionTable.PSVersion.ToString(); OS=[Environment]::OSVersion.Version.ToString(); StateUnchanged=$true; Before=($before|ConvertFrom-Json); After=((Read-NativeState)|ConvertFrom-Json) }
if ($env:RUNNER_TEMP) { $evidence | ConvertTo-Json -Depth 16 | Set-Content (Join-Path $env:RUNNER_TEMP 'wela-cli-arguments.json') -Encoding UTF8 }
}
Write-Host "PASS: $count public CLI argument assertions."
} finally { Remove-Item -LiteralPath $root -Recurse -Force }
$global:LASTEXITCODE = 0
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path
$cases=@(
@{Args=@('file-access-probe','-Help');Code=0;Pattern='Reads one byte and discards it'},
@{Args=@('file-access-probe','-FileProbeAction','Run','-WhatIf');Code=1;Pattern='dedicated options'},
@{Args=@('file-access-probe','extra','-Help');Code=1;Pattern='dedicated options'},
@{Args=@('file-access-probe','-Auto','-Help');Code=1;Pattern='dedicated options'},
@{Args=@('file-access-probe','-DryRun','-Help');Code=1;Pattern='dedicated options'},
@{Args=@('help','-FileProbeAction','Run');Code=1;Pattern='require file-access-probe'},
@{Args=@('file-access-probe','-FileProbePath','\\host\share\file');Code=1;Pattern='exact ordinary'},
@{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeAction','Run');Code=1;Pattern='Run requires'},
@{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeOutputPath','never-created');Code=1;Pattern='Plan creates no output'}
)
foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}}
Write-Host "Passed $($cases.Count) public file-access CLI assertions.";$global:LASTEXITCODE=0
+98
View File
@@ -0,0 +1,98 @@
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
foreach($name in @('WefArrival','FileAccessProbe')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
$script:checks=0
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"}
function Copy-Value($Value){(ConvertFrom-WelaArrivalJson (Get-WelaFileProbeKey ([pscustomobject]@{Data=$Value}))).Data}
function New-Fixture {
$script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='FIXTURE\Reader';AuthenticationId='0x1234';AuthenticationType='Negotiate';ImpersonationLevel='None';TokenSource='Process';Groups=@([pscustomobject]@{Sid='S-1-1-0';Attributes=7});Privileges=@([pscustomobject]@{Luid='0x8';Attributes=0})}
$script:reader=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;UserSid=$script:token.Sid;UserName=$script:token.Name;TokenId='1';AuthenticationId='4660';ModifiedId='2';GroupSids=@('S-1-1-0');GroupCount=1;PrivilegeCount=1;ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent'}
$script:state=[pscustomobject]@{Computer='FIXTURE';Host=[pscustomobject]@{ProductType=3;Build=20348;DomainJoined=$false;Domain='WORKGROUP'};MachineGuid='01234567-89ab-cdef-0123-456789abcdef';Services=@([pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='RpcSs';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Reader=($script:reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=(Copy-Value $script:token);File=[pscustomobject]@{Path='C:\Fixture\ReadCase.TxT';NativePath='\Device\HarddiskVolume5\Fixture\ReadCase.TxT';Identity='1:2:3:1339999';Size=32;LastWriteUtc='2026-09-20T00:00:00.0000000Z';DescriptorBase64='AA==';StateKey=('a'*64);Attributes=32;Links=1;SecurityInformation=511;Aces=@([pscustomobject]@{Type=2;Flags=64;Mask=1;Sid='S-1-1-0';Ordinary=$true;Binary='AA=='})};AuditPolicies=[pscustomobject]@{'0CCE921D-69AE-11D9-BED3-505054503030'=1};Precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};Channel=[pscustomobject]@{Name='Security';Enabled=$true;SecurityDescriptor='O:SYG:SYD:'};Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';EngineHash=('b'*64);Sources=[pscustomobject]@{Source=('c'*64)}}
$script:nonce='d'*32
$script:operation=[pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$script:nonce;ProcessId=1234;Executable=$script:state.Engine;FilePath=$script:state.File.Path;BeforeReader=(Copy-Value $script:reader);AfterReader=(Copy-Value $script:reader);BeforeToken=(Copy-Value $script:token);AfterToken=(Copy-Value $script:token);Read=[pscustomobject]@{Clock='GetSystemTimePreciseAsFileTime';Phase='OneByteReadAndHeldIdentityReadback';Succeeded=$true;ReadCalls=1;BytesRead=1;HandleId='0x888';BeforeKey=('a'*64);AfterKey=('a'*64);StartedUtc='2026-09-21T00:00:00.0001000Z';ReadReturnedUtc='2026-09-21T00:00:00.0001600Z';CompletedUtc='2026-09-21T00:00:00.0002000Z'};RecordIdBefore=10}
$script:reads=0;$script:batchMode='match';$script:afterDrift=$false;$script:workerFailure=$false;$script:failArtifact=$null
}
function Native-Xml {
@"
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4663</EventID><Version>1</Version><Level>0</Level><Task>12800</Task><Opcode>0</Opcode><Keywords>0x8020000000000000</Keywords><TimeCreated SystemTime="2026-09-21T00:00:00.0001500Z"/><EventRecordID>11</EventRecordID><Channel>Security</Channel><Computer>FIXTURE</Computer></System><EventData><Data Name="SubjectUserSid">S-1-5-21-1-2-3-1001</Data><Data Name="SubjectUserName">Reader</Data><Data Name="SubjectDomainName">FIXTURE</Data><Data Name="SubjectLogonId">0x1234</Data><Data Name="ObjectServer">Security</Data><Data Name="ObjectType">File</Data><Data Name="ObjectName">C:\Fixture\ReadCase.TxT</Data><Data Name="HandleId">0x888</Data><Data Name="AccessList">%%4416</Data><Data Name="AccessMask">0x1</Data><Data Name="ProcessId">0x4d2</Data><Data Name="ProcessName">C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Data><Data Name="ResourceAttributes">-</Data></EventData></Event>
"@
}
New-Fixture
$null=Get-WelaFileProbeStateKey $script:state;Assert $true 'complete native prerequisites are accepted'
Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z');Assert $true 'one precise same-token byte read is accepted'
foreach($path in @('','relative.txt','\\host\share\file','C:\a:stream','C:\a\..\file','C:\a\file.','C:\a\file ','C:\a\file*','C:\a\','C:\a\\file','C:/file',('C:\'+('a'*240)))){Reject {Assert-WelaFileProbePath $path} 'exact ordinary'}
$scopeSource=[pscustomobject]@{Path='C:\WELA\WELA.ps1'};$scopeEngine=[pscustomobject]@{Path='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'}
foreach($path in @('C:\Data\ordinary.txt','C:\WELA-other\ordinary.txt')){$selected=[pscustomobject]@{Path=$path;Links=1;Attributes=32};Assert-WelaFileProbeScopeObservation $path $selected $scopeSource $scopeEngine;Assert $true 'ordinary targets outside the canonical implementation tree are allowed'}
foreach($path in @('C:\WELA\WELA.ps1','c:\wela\scripts\FileAccessProbeNative.cs',$scopeEngine.Path)){$selected=[pscustomobject]@{Path=$path;Links=1;Attributes=32};Reject {Assert-WelaFileProbeScopeObservation $path $selected $scopeSource $scopeEngine} 'source tree|active PowerShell engine'}
foreach($mode in @('alias','links','reparse','typed-links')){$selected=[pscustomobject]@{Path='C:\Data\ordinary.txt';Links=1;Attributes=32};$inputPath=$selected.Path;switch($mode){'alias' {$inputPath='C:\Alias\ordinary.txt'};'links' {$selected.Links=2};'reparse' {$selected.Attributes=1024};'typed-links' {$selected.Links=$true}};Reject {Assert-WelaFileProbeScopeObservation $inputPath $selected $scopeSource $scopeEngine} 'ordinary canonical single-link'}
foreach($name in @('computer','machine','host-build','host-product','joined','service','token-source','token-sid','token-group','token-privilege','file-path','native-path','file-key','descriptor','size','links','sections','ace-ordinary','ace-type','ace-mask','ace-sid','channel-name','channel-enabled','precedence-type','precedence-value','mask','reader-type','reader-impersonation','engine','source')){
New-Fixture
switch($name){
'computer' {$script:state.Computer=$true};'machine' {$script:state.MachineGuid=$true};'host-build' {$script:state.Host.Build=$true};'host-product' {$script:state.Host.ProductType=$true};'joined' {$script:state.Host.DomainJoined='false'}
'service' {$script:state.Services[0].Status=$true};'token-source' {$script:state.Token.TokenSource=$true};'token-sid' {$script:state.Token.Sid=$true};'token-group' {$script:state.Token.Groups[0].Attributes=$true};'token-privilege' {$script:state.Token.Privileges[0].Attributes=$true}
'file-path' {$script:state.File.Path=$true};'native-path' {$script:state.File.NativePath=$true};'file-key' {$script:state.File.StateKey=$true};'descriptor' {$script:state.File.DescriptorBase64=$true};'size' {$script:state.File.Size=$true};'links' {$script:state.File.Links=$true};'sections' {$script:state.File.SecurityInformation=$true}
'ace-ordinary' {$script:state.File.Aces[0].Ordinary='true'};'ace-type' {$script:state.File.Aces[0].Type=$true};'ace-mask' {$script:state.File.Aces[0].Mask=$true};'ace-sid' {$script:state.File.Aces[0].Sid=$true}
'channel-name' {$script:state.Channel.Name=$true};'channel-enabled' {$script:state.Channel.Enabled='true'};'precedence-type' {$script:state.Precedence.Type=$true};'precedence-value' {$script:state.Precedence.Value=$true};'mask' {$script:state.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030'=$true}
'reader-type' {$script:state.Reader.TokenType=$true};'reader-impersonation' {$script:state.Reader.Impersonation=$true};'engine' {$script:state.Engine=$true};'source' {$script:state.Sources.Source=$true}
}
Reject {Get-WelaFileProbeStateKey $script:state} 'required|Incomplete|complete|ordinary|Unknown|Missing|Malformed|must already'
}
foreach($name in @('deny-only','disabled-group','inherit-only','failure-ace','callback','wrong-right','wrong-sid')){
New-Fixture
switch($name){'deny-only' {$script:state.Token.Groups[0].Attributes=16};'disabled-group' {$script:state.Token.Groups[0].Attributes=0};'inherit-only' {$script:state.File.Aces[0].Flags=72};'failure-ace' {$script:state.File.Aces[0].Flags=128};'callback' {$script:state.File.Aces[0].Ordinary=$false};'wrong-right' {$script:state.File.Aces[0].Mask=2};'wrong-sid' {$script:state.File.Aces[0].Sid='S-1-5-18'}}
Reject {Get-WelaFileProbeStateKey $script:state} 'No existing ordinary success ReadData'
}
foreach($name in @('kind','nonce','pid','executable','path','clock','phase-type','phase-name','return-before','return-after','success','calls','bytes','handle','before','after','token-drift','reader-drift','pre-launch','post-observed','reverse')){
New-Fixture
switch($name){'kind' {$script:operation.Kind=$true};'nonce' {$script:operation.Nonce=$true};'pid' {$script:operation.ProcessId=$true};'executable' {$script:operation.Executable=$true};'path' {$script:operation.FilePath=$true};'clock' {$script:operation.Read.Clock=$true};'phase-type' {$script:operation.Read.Phase=$true};'phase-name' {$script:operation.Read.Phase='Other'};'return-before' {$script:operation.Read.ReadReturnedUtc='2026-09-21T00:00:00.0000999Z'};'return-after' {$script:operation.Read.ReadReturnedUtc='2026-09-21T00:00:00.0002001Z'};'success' {$script:operation.Read.Succeeded='true'};'calls' {$script:operation.Read.ReadCalls=$true};'bytes' {$script:operation.Read.BytesRead=$true};'handle' {$script:operation.Read.HandleId='0x0'};'before' {$script:operation.Read.BeforeKey=$true};'after' {$script:operation.Read.AfterKey='e'*64};'token-drift' {$script:operation.AfterToken.Privileges[0].Attributes=2};'reader-drift' {$script:operation.AfterReader.ModifiedId='999'};'pre-launch' {$script:operation.Read.StartedUtc='2026-09-20T23:59:59Z'};'post-observed' {$script:operation.Read.CompletedUtc='2026-09-21T00:00:02Z'};'reverse' {$script:operation.Read.CompletedUtc='2026-09-21T00:00:00Z'}}
Reject {Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z')} 'authority|identity|receipt|Expected|token|interval'
}
New-Fixture;$xml=Native-Xml
Assert (Test-WelaFileProbeEvent $xml $script:operation $script:state) 'actual-schema source fixture matches all attribution fields'
foreach($change in @(@('4663','4662'),@('<Version>1','<Version>0'),@('0x8020000000000000','0x8010000000000000'),@('<Task>12800','<Task>1'),@('>FIXTURE</Computer>','>OTHER</Computer>'),@('>0x1</Data>','>0x2</Data>'),@('>0x888</Data>','>0x889</Data>'),@('>0x4d2</Data>','>0x4d3</Data>'),@('>0x1234</Data>','>0x1235</Data>'),@('>File</Data>','>Key</Data>'),@('ReadCase.TxT','Other.txt'),@('>%%4416</Data>','>%%4417</Data>'),@('0001500Z','0000999Z'),@('0001500Z','0002001Z'),@('<EventRecordID>11','<EventRecordID>10'),@('1001</Data>','1002</Data>'),@('v1.0\powershell.exe','v1.0\other.exe'))){Assert (-not(Test-WelaFileProbeEvent $xml.Replace($change[0],$change[1]) $script:operation $script:state)) "mismatched event $($change[0]) is refused"}
Assert (Test-WelaFileProbeEvent $xml.Replace('ReadCase.TxT','readcase.txt').Replace('System32','SYSTEM32').Replace('%%4416',' %%4416 ') $script:operation $script:state) 'Windows path casing and native access-list whitespace do not change identity/right'
Assert (Test-WelaFileProbeEvent $xml.Replace($script:state.File.Path,$script:state.File.NativePath.ToLowerInvariant()) $script:operation $script:state) 'the exact same-handle observed NT path is accepted case-insensitively'
foreach($wrong in @('\Device\HarddiskVolume6\Fixture\ReadCase.TxT','\Device\HarddiskVolume5\Elsewhere\ReadCase.TxT','\Device\HarddiskVolume5\Fixture\Other.TxT')){Assert (-not(Test-WelaFileProbeEvent $xml.Replace($script:state.File.Path,$wrong) $script:operation $script:state)) 'other NT volumes and paths remain rejected'}
Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z','0001800Z') $script:operation $script:state) 'an event after ReadFile returns but inside actual held-identity readback phase remains attributable'
foreach($time in @('0001000Z','0002000Z')){Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z',$time) $script:operation $script:state) 'exact measured phase boundaries are inclusive'}
Assert (-not(Test-WelaFileProbeEvent $xml.Replace('</EventData>','<Data Name="AccessMask">0x1</Data></EventData>') $script:operation $script:state)) 'duplicate XML authority is refused'
Assert (-not(Test-WelaFileProbeEvent ('<!DOCTYPE Event [<!ENTITY x "x">]>'+$xml) $script:operation $script:state)) 'DTD evidence is refused'
Assert (-not(Test-WelaFileProbeEvent ('<wrapper>'+$xml+'</wrapper>') $script:operation $script:state)) 'wrapped event is refused'
$script:state.File.LastWriteUtc=[datetime]::SpecifyKind([datetime]'2026-09-20T00:00:00',[DateTimeKind]::Utc);$null=Get-WelaFileProbeStateKey $script:state
$script:operation.Read.StartedUtc=[datetime]::SpecifyKind([datetime]'2026-09-21T00:00:00.0001000',[DateTimeKind]::Utc)
Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z');Assert $true 'older PowerShell UTC DateTime observations remain valid'
# Compile the exact retained bytes even on portable hosts; invoke no native API.
function Initialize-WelaWmiProbeNative {}
Initialize-WelaFileProbeNative
Assert ([Wela.FileAccessProbe.FileHandle]::SourceSha256 -ceq (Get-FileHash (Join-Path $script:ScriptRoot 'scripts/FileAccessProbeNative.cs')).Hash.ToLowerInvariant()) 'compiled helper carries the SHA256 of the exact decoded source bytes'
Initialize-WelaFileProbeNative;Assert $true 'identical compiled helper binding is reusable'
Remove-Item Function:Initialize-WelaWmiProbeNative
$sources=Get-WelaFileProbeSources
foreach($name in @('scripts/CustomAuditProfiles.ps1','scripts/ChannelReadNative.cs','scripts/Configuration.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')){Assert ($sources.$name -ceq (Get-FileHash (Join-Path $script:ScriptRoot $name)).Hash.ToLowerInvariant()) 'actual transitive dependency fingerprint is included'}
$script:writer=(Get-Command Write-WelaFileProbeArtifact).ScriptBlock
function Get-WelaFileProbeOutputKey {param($Path) 'fixture-private-output'}
function Write-WelaFileProbeArtifact {param($Root,$OutputKey,$Name,$Text) if($Name -eq $script:failArtifact){throw 'injected durable artifact failure'};& $script:writer $Root $OutputKey $Name $Text}
function Get-WelaFileProbeState {param($Path) Copy-Value $script:state}
function Start-WelaFileProbeRead {param($State,$RequestPath,$Nonce) $script:reads++;Assert (Test-Path (Join-Path (Split-Path $RequestPath) 'intent.json')) 'durable intent precedes each worker attempt';if($script:workerFailure){throw 'worker failed after a possible attempt'};$operation=Copy-Value $script:operation;$operation.Nonce=$Nonce;if($script:afterDrift){$script:state.Sources.Source='f'*64};$operation}
function Read-WelaFileProbeEvents {param($Operation) $xml=Native-Xml;$items=if($script:batchMode -eq 'empty'){@()}elseif($script:batchMode -eq 'duplicate'){@($xml,$xml)}else{@($xml)};[pscustomobject]@{Xml=$items;Capped=($script:batchMode -eq 'capped');Query='fixture'}}
function Get-WelaFileProbeWatermark {11}
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-probe-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
try {
New-Fixture;$report=Invoke-WelaFileAccessProbe -FilePath $script:state.File.Path
Assert ($report.Status -ceq 'PrerequisitesObserved' -and $script:reads -eq 0 -and -not $report.OutputPath) 'Plan observes prerequisites without files or byte reads'
New-Fixture;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'success')
Assert ($report.Status -ceq 'FileReadObserved' -and $script:reads -eq 1 -and $report.Matches -eq 1 -and $report.Artifacts.Count -eq 5 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'successful report retains five hashed metadata/XML artifacts and no byte content'
foreach($mode in @('capped','duplicate','empty')){New-Fixture;$script:batchMode=$mode;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root $mode) -TimeoutSeconds 1;Assert ($report.Status -ceq 'Unverified' -and $report.ExitCode -eq 1) 'capped, duplicated or absent source evidence remains unverified'}
New-Fixture;$script:afterDrift=$true;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'drift')
Assert ($report.Status -ceq 'Unverified' -and $report.Diagnostic -match 'changed during the probe') 'late implementation drift prevents event readiness even after a matched record'
New-Fixture;$script:workerFailure=$true;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'worker-failure')
Assert ($report.Status -ceq 'Unverified' -and (Test-Path (Join-Path $root 'worker-failure/intent.json')) -and -not(Test-Path (Join-Path $root 'worker-failure/operation.json'))) 'uncertain worker attempt retains intent without fabricating completion'
New-Fixture;$script:failArtifact='intent.json';$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'intent-failure')
Assert ($report.ExitCode -eq 1 -and $script:reads -eq 0) 'failed durable intent prevents worker launch'
New-Fixture;$script:failArtifact='manifest.json'
Reject {Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'manifest-failure')} 'durable artifact failure'
Assert ((Test-Path (Join-Path $root 'manifest-failure/operation.json')) -and (Test-Path (Join-Path $root 'manifest-failure/event.xml'))) 'manifest persistence failure fails outward while completed evidence remains'
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "Passed $script:checks file-access probe assertions; no Windows settings or file data changed."
+71
View File
@@ -0,0 +1,71 @@
param([switch]$AllowDisposablePolicyWrite)
$ErrorActionPreference='Stop'
if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Only an explicitly permitted disposable GitHub-hosted native Windows runner is supported.'}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
Initialize-WelaFileProbeNative;Initialize-WelaSelectedSaclNative
$engine=(Get-Process -Id $PID).Path;$script:checks=0
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
function Policy-Key($Policy){$ordered=[ordered]@{};foreach($key in @($Policy.Keys|Sort-Object)){$ordered[$key]=$Policy[$key]};Get-WelaFileProbeKey $ordered}
function Invoke-PublicFileProbe([string[]]$Arguments,[string]$Log,[bool]$Success=$true){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0};$text=$lines -join "`n";[IO.File]::WriteAllText($Log,$text,[Text.UTF8Encoding]::new($false));if(($Success -and $code -ne 0) -or (-not $Success -and $code -eq 0)){throw "Unexpected public CLI result $code : $text"};$start=$text.IndexOf('{');if($start -lt 0){throw 'Public CLI returned no JSON report.'};ConvertFrom-WelaArrivalJson $text.Substring($start)}
function Add-OwnedReadSacl([string]$Path){$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null;try{$target=[Wela.SelectedSacl.Target]::new('FileSystem',$Path);$before=$target.Read();$null=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-1-0',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}}
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-file-access-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
# The hosted runner's data volume can classify4663 as Removable Storage (Task12812).
# Own an ordinary private system-volume directory for the strict File System Task12800 fixture.
$targetRoot=New-WelaArrivalOutput (Join-Path (Join-Path $env:SystemRoot 'Temp') ('wela-file-access-targets-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
$file=Join-Path $targetRoot 'ReadCase.TxT';$plain=Join-Path $targetRoot 'WithoutAudit.txt'
[IO.File]::WriteAllText($file,'WELA owned harmless file probe fixture.',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($plain,'WELA owned file without a matching audit ACE.',[Text.UTF8Encoding]::new($false))
$fileHash=(Get-FileHash $file).Hash;$beforePolicies=Get-WelaEffectiveAuditPolicy;$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$beforePrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
$originalToken=Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot());$changed=$false;$cleanupErrors=@()
[IO.File]::WriteAllText((Join-Path $root 'original-audit-policy.json'),(Policy-Key $beforePolicies),[Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText((Join-Path $root 'original-precedence.json'),(Get-WelaFileProbeKey $beforePrecedence),[Text.UTF8Encoding]::new($false))
try {
$changed=$true;Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type DWord -Value 1
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact
Add-OwnedReadSacl $file
$before=Get-WelaFileProbeSnapshot $file;$beforeKey=$before.StateKey
$plan=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$file.ToLowerInvariant()) (Join-Path $root 'plan.log')
Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $plan.Before.File.Path -ieq $file -and $plan.Before.File.StateKey -ceq $beforeKey) 'public Plan accepts Windows path casing and verifies the exact existing file SACL/policy'
foreach($index in 1..2){
$output=Join-Path $root ('run-'+$index)
$report=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file.ToLowerInvariant(),'-FileProbeOutputPath',$output) (Join-Path $root ('run-'+$index+'.log'))
Assert ($report.Status -ceq 'FileReadObserved' -and $report.Matches -eq 1) 'public one-byte read produced exactly one attributable actual4663'
Assert ($report.Operation.Read.Phase -ceq 'OneByteReadAndHeldIdentityReadback' -and (ConvertTo-WelaArrivalUtc $report.Operation.Read.StartedUtc) -le (ConvertTo-WelaArrivalUtc $report.Operation.Read.ReadReturnedUtc) -and (ConvertTo-WelaArrivalUtc $report.Operation.Read.ReadReturnedUtc) -le (ConvertTo-WelaArrivalUtc $report.Operation.Read.CompletedUtc)) 'actual phase retains separate ordered precise read-start, ReadFile-return and held-identity-readback completion timestamps'
Assert ($report.Operation.Read.ReadCalls -eq 1 -and $report.Operation.Read.BytesRead -eq 1 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'one byte is read without retaining contents or granting Sigma credit'
Assert ($report.Before.File.StateKey -ceq $beforeKey -and $report.After.File.StateKey -ceq $beforeKey -and (Get-FileHash $file).Hash -ceq $fileHash) 'existing file data, native identity and full descriptor remain unchanged'
Assert (Test-WelaFileProbeEvent ([IO.File]::ReadAllText((Join-Path $output 'event.xml'))) $report.Operation $report.Before) 'retained native XML matches operation PID, handle, SID, logon, path, right and measured read/readback phase'
foreach($artifact in $report.Artifacts){Assert ((Get-FileHash (Join-Path $output $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'retained artifact hash matches its public manifest'}
}
$missing=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$plain) (Join-Path $root 'missing-sacl.log') $false
Assert ($missing.Status -ceq 'Unverified' -and $missing.Diagnostic -match 'No existing ordinary success ReadData' -and $null -eq $missing.Operation) 'real missing SACL refuses access without adding an ACE'
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 0 -Mode exact
$disabled=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file,'-FileProbeOutputPath',(Join-Path $root 'disabled-policy')) (Join-Path $root 'disabled-policy.log') $false
Assert ($disabled.Status -ceq 'Unverified' -and $disabled.Diagnostic -match 'File System success auditing' -and $null -eq $disabled.Operation) 'real disabled auditing refuses the byte read'
Assert (-not(Test-Path (Join-Path $root 'disabled-policy/intent.json'))) 'failed prerequisites produce no pending read intent'
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact
foreach($target in @((Join-Path $script:ScriptRoot 'WELA.ps1'),$engine)){
$refused=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$target) (Join-Path $root ('scope-refusal-'+[guid]::NewGuid().ToString('N')+'.log')) $false
Assert ($refused.Status -ceq 'Unverified' -and $null -eq $refused.Before -and $refused.Diagnostic -match 'source tree|active PowerShell engine') 'actual implementation/engine targets are refused before prerequisite hashes'
}
$oldState=Get-WelaFileProbeState $file
$replacement=Join-Path $targetRoot 'Replacement.txt';[IO.File]::WriteAllText($replacement,'WELA owned replacement.',[Text.UTF8Encoding]::new($false));Add-OwnedReadSacl $replacement
Remove-Item -LiteralPath $file -Force;Move-Item -LiteralPath $replacement -Destination $file
$message='';try{Start-WelaFileProbeRead $oldState (Join-Path $root 'not-used.json') ([guid]::NewGuid().ToString('N'))|Out-Null}catch{$message=$_.Exception.Message}
Assert ($message -match 'drifted before worker launch') 'real replaced native file identity refuses a stale preflight before launching a worker'
# Metadata-only desired access does not enforce data/delete sharing restrictions.
# Acquire READ_DATA for this fixture lock check without issuing a ReadFile call.
$held=[Wela.FileAccessProbe.FileHandle]::new($file,$true)
try{$denied=$false;try{Remove-Item -LiteralPath $file -Force -ErrorAction Stop}catch{$denied=$true};Assert $denied 'held native data-capable handle prevents deletion of the selected file'}finally{$held.Dispose()}
} finally {
if($changed){try{Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $beforePolicies['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact;if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}}catch{$cleanupErrors+=$_.Exception.Message}}
$auditRestored=(Policy-Key (Get-WelaEffectiveAuditPolicy)) -ceq (Policy-Key $beforePolicies)
$precedenceRestored=(Get-WelaFileProbeKey (Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)) -ceq (Get-WelaFileProbeKey $beforePrecedence)
$tokenRestored=(Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())) -ceq $originalToken
try{Remove-Item -LiteralPath $targetRoot -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+=$_.Exception.Message}
$cleanup=[pscustomobject]@{Complete=($auditRestored -and $precedenceRestored -and $tokenRestored -and -not(Test-Path $targetRoot) -and $cleanupErrors.Count -eq 0);AuditPoliciesRestored=$auditRestored;PrecedenceRestored=$precedenceRestored;TokenRestored=$tokenRestored;OwnedTargetsRemoved=(-not(Test-Path $targetRoot));Errors=$cleanupErrors;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();AfterAuditPolicies=(Get-WelaEffectiveAuditPolicy);AfterPrecedence=(Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)}
[IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanup|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false))
if(-not $cleanup.Complete){throw "Native file-probe cleanup incomplete: $($cleanup|ConvertTo-Json -Compress -Depth 10)"}
}
Write-Host "Passed $script:checks actual native file-access assertions; all59 audit masks, typed precedence, privileges and owned-target cleanup verified. Evidence: $root"
$global:LASTEXITCODE=0
@@ -0,0 +1,94 @@
param([switch]$AllowDisposableListenerReplacement)
$ErrorActionPreference='Stop'
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableListenerReplacement -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows listener replacement opt-in required.'}
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/NativeChannelConfiguration.ps1"
. "$repo/scripts/WefDeployment.ps1"
$root=Join-Path $env:RUNNER_TEMP ('wela-listener-checkpoint-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
function Save($Name,$Value){$Value|ConvertTo-Json -Depth 20|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
function ReadListeners {
@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -Enumerate -ErrorAction Stop|ForEach-Object {
[pscustomobject][ordered]@{Address=[string]$_.Address;Transport=[string]$_.Transport;Port=[string]$_.Port;Hostname=[string]$_.Hostname;Enabled=[string]$_.Enabled;URLPrefix=[string]$_.URLPrefix;CertificateThumbprint=[string]$_.CertificateThumbprint;ListeningOn=@($_.ListeningOn|ForEach-Object {[string]$_}|Sort-Object);RawXml=$_.OuterXml}
}|Sort-Object Address,Transport)
}
function Key($Value){ConvertTo-Json -InputObject @($Value|Select-Object Address,Transport,Port,Hostname,Enabled,URLPrefix,CertificateThumbprint,ListeningOn) -Depth 10 -Compress}
function ReadServices {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='MpsSvc' OR Name='BFE'"|Sort-Object Name|Select-Object Name,StartMode,State)}
function ReadFirewall {@(NetSecurity\Get-NetFirewallRule -PolicyStore ActiveStore|Sort-Object Name|Select-Object Name,Enabled,Profile,Direction,Action,PolicyStoreSourceType)}
$adapter=Join-Path $root 'checkpoint-native51.ps1'
@'
param([string]$ListenerAddress,[string]$PayloadPath)
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
$r=[ordered]@{EngineMajor=$PSVersionTable.PSVersion.Major;Engine=$PSVersionTable.PSVersion.ToString();ProcessId=$PID;UserSid=$sid;Status='Failed';Xml='';Diagnostic=''}
try {
if($PSVersionTable.PSVersion.Major -ne 5 -or $ListenerAddress -notmatch '^(\*|IP:[0-9.]+)$'){throw 'Only fixture native5.1 HTTP selectors are supported.'}
$held=[IO.File]::Open($PayloadPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
try {$v=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$ListenerAddress;Transport='HTTP'} -FilePath $PayloadPath -ErrorAction Stop;$r.Xml=[string]$v.OuterXml;$r.Status='Created'}finally{$held.Dispose()}
}catch{$r.Diagnostic=$_.ToString()}
$r|ConvertTo-Json -Compress
if($r.Status -ne 'Created'){exit 1}
'@|Set-Content -LiteralPath $adapter -Encoding UTF8
function NewCheckpointListener($Selector,$Values) {
$doc=[Xml.XmlDocument]::new();$element=$doc.CreateElement('cfg','Listener','http://schemas.microsoft.com/wbem/wsman/1/config/listener');$null=$doc.AppendChild($element)
foreach($name in @('Port','Hostname','Enabled','URLPrefix','CertificateThumbprint')){$child=$doc.CreateElement('cfg',$name,$element.NamespaceURI);$child.InnerText=[string]$Values[$name];$null=$element.AppendChild($child)}
$payload=Join-Path $root ('native-listener-'+[guid]::NewGuid().ToString('N')+'.xml');[IO.File]::WriteAllText($payload,$doc.OuterXml,[Text.UTF8Encoding]::new($false))
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe'
$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$adapter+'" -ListenerAddress "'+$Selector.Address+'" -PayloadPath "'+$payload+'"'
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false)
$process=[Diagnostics.Process]::new();$process.StartInfo=$info
try {
if(-not $process.Start()){throw 'Native5.1 adapter did not start.'};$childId=$process.Id;$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(20000)){throw 'Native5.1 adapter timed out.'};$text=$stdout.Result;$errorText=$stderr.Result
if($errorText -or $text.Length -gt 65536){throw 'Unexpected native adapter output.'}
$receipt=$text|ConvertFrom-Json;Save ('adapter-'+[guid]::NewGuid().ToString('N')+'.json') $receipt
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
Assert ($receipt.EngineMajor -eq 5 -and $receipt.ProcessId -eq $childId -and $receipt.UserSid -ceq $sid) 'Actual native5.1 child identity must match the invoking account and observed PID.'
if($process.ExitCode -ne 0 -or $receipt.Status -cne 'Created'){throw $receipt.Diagnostic}
[string]$receipt.Xml
}finally{if($process.Id -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(5000)};$process.Dispose()}
}
$services=ReadServices;$firewall=ReadFirewall;$original=$null;$removed=@();$created=$false;$failure=$null;$cleanupErrors=@();$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
try {
$os=Get-CimInstance Win32_OperatingSystem;Assert ($os.BuildNumber -in @('20348','26100') -and $os.ProductType -eq 3) 'Standalone Server 2022/2025 fixture required.'
$s=@($services|Where-Object Name -eq 'WinRM');Assert ($s.Count -eq 1 -and $s[0].StartMode -in @('Auto','Manual') -and $s[0].State -in @('Running','Stopped')) 'Stable non-disabled WinRM required.'
if($s[0].State -ne 'Running'){Start-Service WinRM -ErrorAction Stop}
$original=ReadListeners;Save 'listeners-original.json' $original;Save 'services-original.json' $services;Save 'firewall-original.json' $firewall
# Replacement is a fixture-only, disposable-VM operation. Product must refuse overlaps.
foreach($listener in @($original|Where-Object Transport -eq 'HTTP')){
Assert ($listener.Address -match '^(\*|IP:[0-9.]+)$' -and $listener.Port -eq '5985' -and $listener.URLPrefix -eq 'wsman' -and $listener.Enabled -in @('true','false') -and -not $listener.CertificateThumbprint -and $listener.RawXml -notmatch 'Source="GPO"') 'Only ordinary local HTTP fixture listeners can be temporarily replaced.'
Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$listener.Address;Transport='HTTP'} -ErrorAction Stop
$removed+=$listener
}
$ip=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4|Where-Object {$_.AddressState -eq 'Preferred' -and $_.IPAddress -notmatch '^(127\.|169\.254\.|0\.)'}|Sort-Object IPAddress|Select-Object -First 1).IPAddress
Assert ([bool]$ip) 'An assigned preferred IPv4 address is required.';$selector=@{Address='IP:'+$ip;Transport='HTTP'}
$values=@{Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''}
Save 'selection.json' @{Selector=$selector;Values=$values}
$created=$true
$result=NewCheckpointListener $selector $values
Save 'native-create.json' @{Xml=$result}
$after=ReadListeners;Save 'listeners-created.json' $after;$chosen=@($after|Where-Object {$_.Address -ceq $selector.Address -and $_.Transport -ceq 'HTTP'})
Assert ($chosen.Count -eq 1) 'Exactly one assigned-IP listener must exist.'
Assert ($chosen[0].Port -ceq '5985' -and $chosen[0].Enabled -ceq 'true' -and $chosen[0].URLPrefix -ceq 'wsman' -and -not $chosen[0].CertificateThumbprint -and -not $chosen[0].Hostname) 'Every fixed listener property must match.'
Assert ($chosen[0].ListeningOn.Count -eq 1 -and $chosen[0].ListeningOn[0] -ceq $ip) 'Actual ListeningOn must contain exactly the selected IPv4 address.'
$duplicateRejected=$false;$duplicateError=''
try {$null=NewCheckpointListener $selector $values}catch{$duplicateRejected=$true;$duplicateError=$_.ToString()}
Save 'collision.json' @{Rejected=$duplicateRejected;Diagnostic=$duplicateError};Assert $duplicateRejected 'Windows must reject creating the same listener selector twice.'
Assert ((Key (ReadListeners)) -ceq (Key $after)) 'Rejected collision must preserve the listener definition.'
$prereq=@(Get-WelaWefCollectorPrerequisites ([pscustomobject]@{CollectorFqdn='fixture.invalid';ListenerAddress=$selector.Address;IngressRuleName='WELA-checkpoint-does-not-exist';IngressLocalAddresses=@($ip);IngressRemoteAddresses=@('192.0.2.0/24')}))
Save 'collector-prerequisite.json' $prereq;$field=@($prereq|Where-Object Name -eq 'Existing matching HTTP listener');Assert ($field.Count -eq 1 -and $field[0].Verified) 'Existing collector prerequisite must recognize the actual exact-IP listener.'
Write-Host "PASS: $count native listener checkpoint assertions. No WEF delivery proof."
}catch{$failure=$_.ToString();Write-Host $failure;throw}finally{
if($created){try {Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ErrorAction Stop}catch{$cleanupErrors+=$_.ToString()}}
foreach($listener in $removed){try {$null=NewCheckpointListener @{Address=$listener.Address;Transport=$listener.Transport} @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint}}catch{$cleanupErrors+=$_.ToString()}}
$restored=$null;$listenersOk=$false;$firewallOk=$false;$servicesOk=$false
try {$restored=ReadListeners;Save 'listeners-restored.json' $restored;$listenersOk=$null -ne $original -and (Key $original) -ceq (Key $restored)}catch{$cleanupErrors+=$_.ToString()}
try {if(@($services|Where-Object Name -eq 'WinRM')[0].State -eq 'Stopped'){Stop-Service WinRM -ErrorAction Stop};$endServices=ReadServices;Save 'services-restored.json' $endServices;$servicesOk=($services|ConvertTo-Json -Compress) -ceq ($endServices|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
try {$endFirewall=ReadFirewall;Save 'firewall-restored.json' $endFirewall;$firewallOk=($firewall|ConvertTo-Json -Compress) -ceq ($endFirewall|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;ListenersRestored=$listenersOk;ServicesRestored=$servicesOk;FirewallPreserved=$firewallOk;Complete=($listenersOk -and $servicesOk -and $firewallOk -and -not $cleanupErrors.Count);DisposableBoundary='Fixture temporarily replaced ordinary original HTTP listeners and restored their captured configuration; product creation must refuse overlap.'}
if(-not $listenersOk -or -not $servicesOk -or -not $firewallOk -or $cleanupErrors.Count){throw 'Native checkpoint cleanup incomplete; inspect retained artifacts.'}
}
+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-listener-cli-'+[guid]::NewGuid().ToString('N'))
$cases=@(
@{Args=@('wec-listener','-Help');Code=0;Pattern='HTTP5985'},
@{Args=@('wec-listener','-Help','-Auto');Code=1;Pattern='only dedicated'},
@{Args=@('wec-listener','-Help','-DryRun');Code=1;Pattern='only dedicated'},
@{Args=@('wec-listener','-WecListenerAction','Apply','-WhatIf');Code=1;Pattern='only dedicated'},
@{Args=@('wec-listener','-Help','-Typo');Code=1;Pattern='only dedicated'},
@{Args=@('wec-listener','-Help','-ResultsPath',$root);Code=1;Pattern='only dedicated'},
@{Args=@('wec-listener','-Help','-WecIngressAction','Apply');Code=1;Pattern='only dedicated'},
@{Args=@('configure','-WecListenerAction','Apply','-Auto');Code=1;Pattern='WecListener options require'},
@{Args=@('wec-listener');Code=1;Pattern='Plan requires'},
@{Args=@('wec-listener','-WecListenerComputerName','placeholder','-WecListenerLocalAddress','192.0.2.10','-WecListenerOutputPath',$root,'-WecListenerPlanPath','unused');Code=1;Pattern='Plan requires'},
@{Args=@('wec-listener','-WecListenerAction','Apply','-WecListenerOutputPath',$root);Code=1;Pattern='Apply requires'},
@{Args=@('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath','unused','-WecListenerPlanHash',('a'*64),'-WecListenerOutputPath',$root,'-WecListenerComputerName','placeholder');Code=1;Pattern='Apply requires'}
)
foreach($case in $cases){
$prior=$ErrorActionPreference
try{$ErrorActionPreference='Continue';$output=&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed [$code]: $output"};$count++
if(Test-Path -LiteralPath $root){throw 'Rejected CLI inputs must not create an output directory.'}
}
Write-Host "PASS: $count public WEC listener CLI checks. No Windows settings changed."
$global:LASTEXITCODE=0
+112
View File
@@ -0,0 +1,112 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/WecListener.ps1"
$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Reject([scriptblock]$Action,[string]$Pattern='.'){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected refusal $Pattern, got: $message; input: $bad; action: $Action"}
function Copy-TestListener($Value){Get-WelaListenerKey $Value|ConvertFrom-Json}
$special=[pscustomobject]@{Xml='<a x="v">&</a>';Name="O'Neil"}
$specialKey=Get-WelaListenerKey $special
Assert ($specialKey -notmatch "[<>&']" -and $specialKey.Contains('\u003c') -and $specialKey.Contains('\u0027')) 'Context JSON spelling is consistent across native5.1 and host7.'
Assert (($specialKey|ConvertFrom-Json).Xml -ceq $special.Xml -and ($specialKey|ConvertFrom-Json).Name -ceq $special.Name) 'Canonical JSON escaping preserves exact values.'
Assert ((Get-WelaListenerKey (Copy-TestListener ([pscustomobject]@{Nested=$specialKey}))) -ceq (Get-WelaListenerKey ([pscustomobject]@{Nested=$specialKey}))) 'Nested context JSON keeps its reviewed value.'
$selection=Get-WelaListenerSelection 'test-host' '192.0.2.10'
$xml='<cfg:Listener xmlns:cfg="http://schemas.microsoft.com/wbem/wsman/1/config/listener" xml:lang="en-US"><cfg:Address>IP:192.0.2.10</cfg:Address><cfg:Transport>HTTP</cfg:Transport><cfg:Port>5985</cfg:Port><cfg:Hostname/><cfg:Enabled>true</cfg:Enabled><cfg:URLPrefix>wsman</cfg:URLPrefix><cfg:CertificateThumbprint/><cfg:ListeningOn>192.0.2.10</cfg:ListeningOn></cfg:Listener>'
Assert ($selection.ComputerName -ceq 'TEST-HOST') 'Actual computer selection is canonical.'
foreach($bad in @('*','IP:192.0.2.10','192.0.2.10/32','192.0.2.0/24','192.0.2.01','010.1.2.3','127.0.0.1','0.0.0.0','169.254.1.2','224.0.0.1','255.255.255.255','256.1.2.3','1.2.3','example.test','::1','',' 192.0.2.10')){Reject {Get-WelaListenerSelection 'TEST' $bad}}
foreach($bad in @('','test.example','*','-TEST','TEST HOST','TEST/OTHER')){Reject {Get-WelaListenerSelection $bad '192.0.2.10'}}
Reject {Get-WelaListenerSelection $true '192.0.2.10'};Reject {Get-WelaListenerSelection 'TEST' $true}
$listener=ConvertFrom-WelaListenerXml $xml;Assert-WelaListenerCreated $listener $selection;$count++
Assert ($listener.ListeningOn.Count -eq 1 -and -not $listener.PolicyOwned) 'Actual native shape has exact address and local provenance.'
foreach($bad in @($xml.Replace('<cfg:Port>5985</cfg:Port>',''),$xml.Replace('</cfg:Listener>','<cfg:Enabled>true</cfg:Enabled></cfg:Listener>'),$xml.Replace('cfg:Port','cfg:Unknown'),$xml.Replace('http://schemas.microsoft.com/wbem/wsman/1/config/listener','urn:wrong'),$xml.Replace('<cfg:Hostname/>','<cfg:Hostname unexpected="x"/>'),$xml.Replace('<cfg:Hostname/>','<cfg:Hostname><cfg:Nested/></cfg:Hostname>'),$xml.Replace('<cfg:Hostname/>','<?unexpected data?><cfg:Hostname/>'),$xml.Replace('>true<','>True<'),$xml.Replace('>5985<','>05985<'),$xml.Replace('</cfg:Listener>','<cfg:ListeningOn>192.0.2.10</cfg:ListeningOn></cfg:Listener>'),$xml.Replace('>192.0.2.10<','>not-an-address<'),('<!DOCTYPE x [<!ENTITY e SYSTEM "file:///does-not-exist">]>'+$xml))){Reject {ConvertFrom-WelaListenerXml $bad}}
foreach($name in @('Address','Transport','Port','Hostname','Enabled','URLPrefix','CertificateThumbprint')){$copy=Copy-TestListener $listener;$copy.$name='unexpected';Reject {Assert-WelaListenerCreated $copy $selection} 'differs'}
$copy=Copy-TestListener $listener;$copy.ListeningOn=@('192.0.2.10','192.0.2.11');Reject {Assert-WelaListenerCreated $copy $selection} 'exactly'
$copy=Copy-TestListener $listener;$copy.PolicyOwned=$true;Reject {Assert-WelaListenerCreated $copy $selection} 'local'
$copy=Copy-TestListener $listener;$copy.PolicyOwned='False';Reject {Assert-WelaListenerCreated $copy $selection} 'local'
$owned=ConvertFrom-WelaListenerXml ($xml.Replace('<cfg:Port>','<cfg:Port Source="GPO">'));Assert $owned.PolicyOwned 'Native GPO provenance remains explicit.'
Reject {Assert-WelaListenerAbsent @($listener) $selection} 'Existing'
$copy=Copy-TestListener $listener;$copy.Address='*';$copy.Port='6000';Reject {Assert-WelaListenerAbsent @($copy) $selection} 'Existing'
$copy=Copy-TestListener $listener;$copy.Address='IP:192.0.2.11';Reject {Assert-WelaListenerAbsent @($copy) $selection} 'Existing'
$copy=Copy-TestListener $listener;$copy.Port='6000';Reject {Assert-WelaListenerAbsent @($copy) $selection} 'Existing'
$other=Copy-TestListener $listener;$other.Address='*';$other.Transport='HTTPS';$other.Port='5986';$other.ListeningOn=@('192.0.2.10');Assert-WelaListenerAbsent @($other) $selection;$count++
$reader=[pscustomobject][ordered]@{Computer='TEST-HOST';ProcessId=100;UserSid='S-1-5-21-1-2-3-1001';UserName='TEST-HOST\operator';TokenId='111';ModifiedId='222';AuthenticationId='333';GroupSids=@('S-1-5-32-544');GroupCount=1;PrivilegeCount=20;ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent'}
$baseline=[pscustomobject][ordered]@{Local=[pscustomobject][ordered]@{Host=@{Computer='TEST-HOST';Build=26100;UBR=123;ProductType=3;DomainRole=2};MachineGuid='00000000-0000-0000-0000-000000000001';Reader=$reader;Services=@(@{Name='WinRM';State='Running';StartMode='Auto'});Addresses=@(@{IPAddress='192.0.2.10';AddressState='Preferred'});Policy='empty';WinrmXml='<Config/>';Listeners=@($other)};Profiles=@('protected');Rules=@('digest');NativeFirewall='native';NativeReader='native-reader';Adapter=@{ModulePath='native51-modules';Engine='native51';EngineSha256='a'*64;Worker='fixed-worker';WorkerSha256='b'*64};Sources='sources'}
$changed=Copy-TestListener $baseline;$changed.Local.Reader.ProcessId=101;$changed.Local.Reader.TokenId='different';$changed.Local.Reader.ModifiedId='other';Assert ((Get-WelaListenerReviewKey $changed) -ceq (Get-WelaListenerReviewKey $baseline)) 'Plans permit separate processes in the same actual logon.'
$changed.Local.Reader.AuthenticationId='444';Assert ((Get-WelaListenerReviewKey $changed) -cne (Get-WelaListenerReviewKey $baseline)) 'Different logon requires a new plan.'
Assert-WelaListenerSelectedHost $baseline.Local $selection;$count++
$changed=Copy-TestListener $baseline;$changed.Local.Addresses[0].AddressState='Tentative';Reject {Assert-WelaListenerSelectedHost $changed.Local $selection} 'Preferred'
$changed=Copy-TestListener $baseline;$changed.Local.Host.Computer='OTHER';Reject {Assert-WelaListenerSelectedHost $changed.Local $selection} 'actual'
$plan=[pscustomobject]@{SchemaVersion=1;Kind='WelaExactIpListenerPlan';Selection=$selection;StateKey=(Get-WelaListenerReviewKey $baseline);RecordedUtc='2026-09-21T00:00:00Z'};Assert-WelaListenerPlan $plan;$count++
foreach($field in @('SchemaVersion','Kind','StateKey')){$bad=Copy-TestListener $plan;$bad.$field=$true;Reject {Assert-WelaListenerPlan $bad} 'mistyped'}
$bad=Copy-TestListener $plan;$bad.Selection.ComputerName='test-host';Reject {Assert-WelaListenerPlan $bad} 'canonical'
$bad=Copy-TestListener $plan;$bad|Add-Member NoteProperty Extra true;Reject {Assert-WelaListenerPlan $bad}
Initialize-WelaListenerPipe
$textReader=[IO.StringReader]::new('bounded');try{$task=[Wela.ListenerPipe.Bounded]::Read($textReader,7);Assert ($task.GetAwaiter().GetResult() -ceq 'bounded') 'Bounded stream reads complete content.'}finally{$textReader.Dispose()}
$textReader=[IO.StringReader]::new('x'*65536);try{Reject {$task=[Wela.ListenerPipe.Bounded]::Read($textReader,1024);$task.GetAwaiter().GetResult()} 'bound'}finally{$textReader.Dispose()}
foreach($failure in @('kill','wait','dispose')){
$fake=[pscustomobject]@{HasExited=$false;Mode=$failure}
$fake|Add-Member ScriptMethod Kill {if($this.Mode -eq 'kill'){throw 'Natural-exit race'}}
$fake|Add-Member ScriptMethod WaitForExit {param($Timeout);if($this.Mode -eq 'wait'){throw 'Wait failed'};return $true}
$fake|Add-Member ScriptMethod Dispose {if($this.Mode -eq 'dispose'){throw 'Dispose failed'}}
$result=[pscustomobject]@{Started=$true;TerminationConfirmed=$false;Diagnostic=''};Close-WelaListenerAdapterProcess $fake $result
Assert ($result.Started -and $result.Diagnostic) "Possible creation remains recorded after $failure cleanup failure."
Assert ($result.TerminationConfirmed -eq ($failure -ne 'wait')) 'Termination certainty is separately retained.'
}
$receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaNative51ListenerCreate';Status='Created';NativeCreateAttempted=$true;ProcessId=123;Engine='native51';EngineVersion='5.1.26100.1';ModulePath='native51-modules';Reader=$reader;Selection=$selection;CreatedXml='<EPR/>';After=@($listener);Diagnostic='';NativeHResult=$null}
Assert-WelaListenerAdapterReceipt $receipt $baseline 123 0;$count++
$bad=Copy-TestListener $receipt;$bad.ModulePath='unexpected-search-root';Reject {Assert-WelaListenerAdapterReceipt $bad $baseline 123 0} 'identity|status'
foreach($field in @('Kind','Engine','EngineVersion','ModulePath','Status','ProcessId','SchemaVersion')){$bad=Copy-TestListener $receipt;$bad.$field=$true;Reject {Assert-WelaListenerAdapterReceipt $bad $baseline 123 0} 'identity|status'}
$bad=Copy-TestListener $receipt;$bad.Reader.AuthenticationId='OTHER';Reject {Assert-WelaListenerAdapterReceipt $bad $baseline 123 0} 'logon'
Reject {Assert-WelaListenerAdapterReceipt $receipt $baseline 124 0} 'identity'
Reject {Assert-WelaListenerAdapterReceipt $receipt $baseline 123 1} 'success'
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-listener-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$script:mode='ok';$script:reads=0;$script:starts=0;$script:created=$false
function Get-WelaListenerState {
$script:reads++;$state=Copy-TestListener $baseline
if($script:created){$state.Local.Listeners=@((Copy-TestListener $other),(Copy-TestListener $listener))}
if($script:mode -eq 'race' -and $script:reads -eq 2){$state.Local.WinrmXml='<Changed/>'}
if($script:created -and $script:mode -eq 'firewall-drift'){$state.Rules=@('changed')}
if($script:created -and $script:mode -eq 'token-drift'){$state.Local.Reader.ModifiedId='changed'}
if($script:created -and $script:mode -eq 'broader'){$state.Local.Listeners[1].ListeningOn=@('192.0.2.10','192.0.2.11')}
$state
}
function Start-WelaListenerAdapter {
param($State,$RequestPath,$RequestHash)
$script:starts++;$dir=Split-Path $RequestPath -Parent
$intent=Get-Content (Join-Path $dir 'before-create.json') -Raw|ConvertFrom-Json
Assert ($intent.Status -ceq 'Pending' -and (Read-WelaWecUpdateFile $RequestPath).Hash -ceq $RequestHash -and (Get-Content (Join-Path $dir 'native-payload.xml') -Raw) -ceq (New-WelaListenerPayload)) 'Durable intent and fixed payload precede adapter startup.'
if($script:mode -eq 'timeout'){return [pscustomobject]@{Started=$true;Receipt=$null;Diagnostic='timeout';TerminationConfirmed=$false}}
$reply=Copy-TestListener $receipt
if($script:mode -eq 'refused'){$reply.Status='Refused';$reply.NativeCreateAttempted=$false;$reply.Diagnostic='fresh worker context differs'}else{$script:created=$true}
if($script:mode -eq 'native-error'){$reply.Status='CreateAttemptedUnverified';$reply.Diagnostic='native failed'}
[pscustomobject]@{Started=$true;Receipt=$reply;Diagnostic=$(if($script:mode -eq 'cleanup-error'){'cleanup failed'}else{''});TerminationConfirmed=$true}
}
try {
foreach($scenario in @('ok','hash','schema','duplicate-json','context','race','refused','timeout','native-error','firewall-drift','token-drift','broader','cleanup-error','replay')){
$script:mode='ok';$script:reads=0;$script:starts=0;$script:created=$false
$planned=Invoke-WelaWecListener -ComputerName 'TEST-HOST' -LocalAddress '192.0.2.10' -OutputPath (Join-Path $root ($scenario+'-plan'))
Assert ($planned.Status -ceq 'ReviewRequired' -and $planned.ExitCode -eq 0 -and $script:starts -eq 0) "Plan reads only: $($planned.Diagnostic)"
$path=Join-Path $planned.OutputPath 'plan.json';$hash=$planned.PlanHash
if($scenario -eq 'hash'){$hash='f'*64}
if($scenario -in @('schema','duplicate-json','context')){
$text=[IO.File]::ReadAllText($path)
if($scenario -eq 'schema'){$text=$text -replace '"SchemaVersion"\s*:\s*1','"SchemaVersion": true'}
if($scenario -eq 'duplicate-json'){$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}
if($scenario -eq 'context'){$text=$text.Replace('TEST-HOST','OTHER-HOST')}
[IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant()
}
$script:mode=$scenario;$script:reads=0;$applied=Invoke-WelaWecListener Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root ($scenario+'-apply'))
Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','replay'))) "Outcome $scenario : $($applied.Diagnostic)"
Assert ($applied.ReadyRuleCredit -eq 0 -and $applied.ServiceChanges -eq 0 -and $applied.FirewallChanges -eq 0 -and (Test-Path (Join-Path $applied.OutputPath 'manifest.json'))) 'No unrelated changes or detection credit; final receipt retained.'
foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $applied.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Receipt artifact hash matches actual bytes.'}
if($scenario -in @('hash','schema','duplicate-json','context','race')){Assert ($script:starts -eq 0 -and $applied.Status -ceq 'Refused') 'Refusal precedes any adapter start.'}
if($scenario -in @('timeout','native-error','firewall-drift','token-drift','broader','cleanup-error')){Assert ($applied.AdapterStarted -and $applied.Status -ceq 'CreateAttemptedUnverified') 'Possible native creation is never mislabeled Refused.'}
if($scenario -eq 'refused'){Assert ($applied.Status -ceq 'Refused' -and $applied.NativeCreateAttempted -eq $false) 'Authenticated native refusal before create stays distinct.'}
if($scenario -eq 'replay'){$again=Invoke-WelaWecListener Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay-again');Assert ($again.Status -ceq 'Refused' -and $script:starts -eq 1) 'Applied plan cannot be replayed.'}
}
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "PASS: $count focused WEC listener assertions. No native listener proof is claimed."
+129
View File
@@ -0,0 +1,129 @@
param([switch]$AllowDisposableListenerReplacement)
$ErrorActionPreference='Stop'
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableListenerReplacement -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows listener replacement opt-in required.'}
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/NativeChannelConfiguration.ps1"
. "$repo/scripts/WefDeployment.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/ChannelRead.ps1"
. "$repo/scripts/FirewallLoggingRecovery.ps1"
. "$repo/scripts/WecListener.ps1"
$engine=(Get-Process -Id $PID).Path
function Public([string[]]$Arguments,[int]$Code=0){
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1|Out-String;$actual=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
if($actual -ne $Code){Write-Host $text;Get-ChildItem $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host (Get-Content $_.FullName -Raw)};throw "Public command exit $actual differs from expected $Code"}
}
$root=Join-Path $env:RUNNER_TEMP ('wela-listener-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
function Save($Name,$Value){$Value|ConvertTo-Json -Depth 20|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
function ReadListeners {
@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -Enumerate -ErrorAction Stop|ForEach-Object {
[pscustomobject][ordered]@{Address=[string]$_.Address;Transport=[string]$_.Transport;Port=[string]$_.Port;Hostname=[string]$_.Hostname;Enabled=[string]$_.Enabled;URLPrefix=[string]$_.URLPrefix;CertificateThumbprint=[string]$_.CertificateThumbprint;ListeningOn=@($_.ListeningOn|ForEach-Object {[string]$_}|Sort-Object);RawXml=$_.OuterXml}
}|Sort-Object Address,Transport)
}
function Key($Value){ConvertTo-Json -InputObject @($Value|Select-Object Address,Transport,Port,Hostname,Enabled,URLPrefix,CertificateThumbprint,ListeningOn) -Depth 10 -Compress}
function ReadServices {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='MpsSvc' OR Name='BFE'"|Sort-Object Name|Select-Object Name,StartMode,State)}
function ReadFirewall {@(NetSecurity\Get-NetFirewallRule -PolicyStore ActiveStore|Sort-Object Name|Select-Object Name,Enabled,Profile,Direction,Action,PolicyStoreSourceType)}
$adapter=Join-Path $root 'checkpoint-native51.ps1'
@'
param([string]$ListenerAddress,[string]$PayloadPath)
$env:PSModulePath=[IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules')
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
$r=[ordered]@{EngineMajor=$PSVersionTable.PSVersion.Major;Engine=$PSVersionTable.PSVersion.ToString();ProcessId=$PID;UserSid=$sid;Status='Failed';Xml='';Diagnostic=''}
try {
if($PSVersionTable.PSVersion.Major -ne 5 -or $ListenerAddress -notmatch '^(\*|IP:[0-9.]+)$'){throw 'Only fixture native5.1 HTTP selectors are supported.'}
$held=[IO.File]::Open($PayloadPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
try {$v=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$ListenerAddress;Transport='HTTP'} -FilePath $PayloadPath -ErrorAction Stop;$r.Xml=[string]$v.OuterXml;$r.Status='Created'}finally{$held.Dispose()}
}catch{$r.Diagnostic=$_.ToString()}
$r|ConvertTo-Json -Compress
if($r.Status -ne 'Created'){exit 1}
'@|Set-Content -LiteralPath $adapter -Encoding UTF8
function NewCheckpointListener($Selector,$Values) {
$doc=[Xml.XmlDocument]::new();$element=$doc.CreateElement('cfg','Listener','http://schemas.microsoft.com/wbem/wsman/1/config/listener');$null=$doc.AppendChild($element)
foreach($name in @('Port','Hostname','Enabled','URLPrefix','CertificateThumbprint')){$child=$doc.CreateElement('cfg',$name,$element.NamespaceURI);$child.InnerText=[string]$Values[$name];$null=$element.AppendChild($child)}
$payload=Join-Path $root ('native-listener-'+[guid]::NewGuid().ToString('N')+'.xml');[IO.File]::WriteAllText($payload,$doc.OuterXml,[Text.UTF8Encoding]::new($false))
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe'
$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$adapter+'" -ListenerAddress "'+$Selector.Address+'" -PayloadPath "'+$payload+'"'
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false)
Initialize-WelaListenerPipe
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$result=[pscustomobject]@{Started=$false;TerminationConfirmed=$false;Diagnostic=''}
try {
if(-not $process.Start()){throw 'Native5.1 adapter did not start.'};$result.Started=$true;$childId=$process.Id;$stdout=[Wela.ListenerPipe.Bounded]::Read($process.StandardOutput,65536);$stderr=[Wela.ListenerPipe.Bounded]::Read($process.StandardError,65536)
if(-not $process.WaitForExit(20000)){throw 'Native5.1 adapter timed out.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Fixture adapter output drain timed out.'};$text=$stdout.Result;$errorText=$stderr.Result
if($errorText -or $text.Length -gt 65536){throw 'Unexpected native adapter output.'}
$receipt=$text|ConvertFrom-Json;Save ('adapter-'+[guid]::NewGuid().ToString('N')+'.json') $receipt
$identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()}
Assert ($receipt.EngineMajor -eq 5 -and $receipt.ProcessId -eq $childId -and $receipt.UserSid -ceq $sid) 'Actual native5.1 child identity must match the invoking account and observed PID.'
if($process.ExitCode -ne 0 -or $receipt.Status -cne 'Created'){throw $receipt.Diagnostic}
[string]$receipt.Xml
}finally{Close-WelaListenerAdapterProcess $process $result;if($result.Diagnostic){$script:cleanupErrors+=$result.Diagnostic;Write-Host $result.Diagnostic}}
}
$services=ReadServices;$firewall=ReadFirewall;$original=$null;$fullOriginal=$null;$removed=@();$created=$false;$failure=$null;$cleanupErrors=@();$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
try {
$os=Get-CimInstance Win32_OperatingSystem;Assert ($os.BuildNumber -in @('20348','26100') -and $os.ProductType -eq 3) 'Standalone Server 2022/2025 fixture required.'
$s=@($services|Where-Object Name -eq 'WinRM');Assert ($s.Count -eq 1 -and $s[0].StartMode -in @('Auto','Manual') -and $s[0].State -in @('Running','Stopped')) 'Stable non-disabled WinRM required.'
if($s[0].State -ne 'Running'){Start-Service WinRM -ErrorAction Stop}
$original=ReadListeners;$fullOriginal=Get-WelaListenerState;Save 'complete-original.json' $fullOriginal;Save 'listeners-original.json' $original;Save 'services-original.json' $services;Save 'firewall-original.json' $firewall
# Replacement is a fixture-only, disposable-VM operation. Product must refuse overlaps.
foreach($listener in @($original|Where-Object Transport -eq 'HTTP')){
Assert ($listener.Address -match '^(\*|IP:[0-9.]+)$' -and $listener.Port -eq '5985' -and $listener.URLPrefix -eq 'wsman' -and $listener.Enabled -in @('true','false') -and -not $listener.CertificateThumbprint -and $listener.RawXml -notmatch 'Source="GPO"') 'Only ordinary local HTTP fixture listeners can be temporarily replaced.'
Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$listener.Address;Transport='HTTP'} -ErrorAction Stop
$removed+=$listener
}
$ip=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4|Where-Object {$_.AddressState -eq 'Preferred' -and $_.IPAddress -notmatch '^(127\.|169\.254\.|0\.)'}|Sort-Object IPAddress|Select-Object -First 1).IPAddress
Assert ([bool]$ip) 'An assigned preferred IPv4 address is required.';$selector=@{Address='IP:'+$ip;Transport='HTTP'}
$values=@{Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''}
Save 'selection.json' @{Selector=$selector;Values=$values}
$planDir=Join-Path $root 'public-plan';$applyDir=Join-Path $root 'public-apply'
Public @('wec-listener','-WecListenerComputerName',[Environment]::MachineName,'-WecListenerLocalAddress',$ip,'-WecListenerOutputPath',$planDir)
$plan=Get-Content (Join-Path $planDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.AdapterStarted) 'Public Plan makes no native create attempt.'
$planPath=Join-Path $planDir 'plan.json';Assert ((Get-FileHash $planPath).Hash.ToLowerInvariant() -ceq $plan.PlanHash) 'Public plan hash is exact.'
$badDir=Join-Path $root 'bad-hash'
Public @('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath',$planPath,'-WecListenerPlanHash',('f'*64),'-WecListenerOutputPath',$badDir) 1
$bad=Get-Content (Join-Path $badDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($bad.Status -ceq 'Refused' -and -not $bad.AdapterStarted) 'Wrong plan hash refuses before adapter startup.'
$created=$true
Public @('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath',$planPath,'-WecListenerPlanHash',$plan.PlanHash,'-WecListenerOutputPath',$applyDir)
$applied=Get-Content (Join-Path $applyDir 'manifest.json') -Raw|ConvertFrom-Json
Assert ($applied.Status -ceq 'CreatedAndVerified' -and $applied.AdapterStarted -and $applied.NativeCreateAttempted -and $applied.Adapter.TerminationConfirmed -and $applied.Adapter.Receipt.EngineVersion -match '^5\.1\.') 'Public Apply uses the verified native5.1 adapter and confirms native creation.'
Assert ($applied.Adapter.Receipt.ModulePath -ceq [IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules')) 'Actual adapter startup retains only the fixed native5.1 module directory.'
Assert ($applied.Adapter.Receipt.ProcessId -eq $applied.Adapter.ProcessId -and $applied.Adapter.Receipt.Reader.UserSid -eq $fullOriginal.Local.Reader.UserSid -and $applied.Adapter.Receipt.Reader.AuthenticationId -eq $fullOriginal.Local.Reader.AuthenticationId) 'Actual native worker PID/account/logon is bound.'
Assert ($applied.ReadyRuleCredit -eq 0 -and $applied.ServiceChanges -eq 0 -and $applied.AuthenticationChanges -eq 0 -and $applied.FirewallChanges -eq 0) 'No unrelated configuration changes or detection credit.'
foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $applyDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained public artifact hash matches.'}
$replayDir=Join-Path $root 'replay'
Public @('wec-listener','-WecListenerAction','Apply','-WecListenerPlanPath',$planPath,'-WecListenerPlanHash',$plan.PlanHash,'-WecListenerOutputPath',$replayDir) 1
$replay=Get-Content (Join-Path $replayDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($replay.Status -ceq 'Refused' -and -not $replay.AdapterStarted) 'Actual existing listener and changed context refuse replay.'
$overlapDir=Join-Path $root 'overlap'
Public @('wec-listener','-WecListenerComputerName',[Environment]::MachineName,'-WecListenerLocalAddress',$ip,'-WecListenerOutputPath',$overlapDir) 1
$overlap=Get-Content (Join-Path $overlapDir 'manifest.json') -Raw|ConvertFrom-Json;Assert ($overlap.Status -ceq 'Refused' -and -not $overlap.AdapterStarted) 'Public Plan refuses an existing HTTP5985 listener.'
$after=ReadListeners;Save 'listeners-created.json' $after;$chosen=@($after|Where-Object {$_.Address -ceq $selector.Address -and $_.Transport -ceq 'HTTP'})
Assert ($chosen.Count -eq 1) 'Exactly one assigned-IP listener must exist.'
Assert ($chosen[0].Port -ceq '5985' -and $chosen[0].Enabled -ceq 'true' -and $chosen[0].URLPrefix -ceq 'wsman' -and -not $chosen[0].CertificateThumbprint -and -not $chosen[0].Hostname) 'Every fixed listener property must match.'
Assert ($chosen[0].ListeningOn.Count -eq 1 -and $chosen[0].ListeningOn[0] -ceq $ip) 'Actual ListeningOn must contain exactly the selected IPv4 address.'
$duplicateRejected=$false;$duplicateError=''
try {$null=NewCheckpointListener $selector $values}catch{$duplicateRejected=$true;$duplicateError=$_.ToString()}
Save 'collision.json' @{Rejected=$duplicateRejected;Diagnostic=$duplicateError};Assert $duplicateRejected 'Windows must reject creating the same listener selector twice.'
Assert ((Key (ReadListeners)) -ceq (Key $after)) 'Rejected collision must preserve the listener definition.'
$prereq=@(Get-WelaWefCollectorPrerequisites ([pscustomobject]@{CollectorFqdn='fixture.invalid';ListenerAddress=$selector.Address;IngressRuleName='WELA-checkpoint-does-not-exist';IngressLocalAddresses=@($ip);IngressRemoteAddresses=@('192.0.2.0/24')}))
Save 'collector-prerequisite.json' $prereq;$field=@($prereq|Where-Object Name -eq 'Existing matching HTTP listener');Assert ($field.Count -eq 1 -and $field[0].Verified) 'Existing collector prerequisite must recognize the actual exact-IP listener.'
Write-Host "PASS: $count actual public listener assertions. No WEF delivery proof."
}catch{$failure=$_.ToString();Write-Host $failure;throw}finally{
try {if($created -and @(ReadListeners|Where-Object {$_.Address -ceq $selector.Address -and $_.Transport -ceq 'HTTP'}).Count){Microsoft.WSMan.Management\Remove-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ErrorAction Stop}}catch{$cleanupErrors+=$_.ToString()}
foreach($listener in $removed){try {$null=NewCheckpointListener @{Address=$listener.Address;Transport=$listener.Transport} @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint}}catch{$cleanupErrors+=$_.ToString()}}
$restored=$null;$listenersOk=$false;$firewallOk=$false;$servicesOk=$false;$configurationOk=$false
try {$restored=ReadListeners;Save 'listeners-restored.json' $restored;$listenersOk=$null -ne $original -and (Key $original) -ceq (Key $restored)}catch{$cleanupErrors+=$_.ToString()}
try {$fullRestored=Get-WelaListenerState;Save 'complete-restored.json' $fullRestored;$configurationOk=(Get-WelaListenerReviewKey $fullOriginal) -ceq (Get-WelaListenerReviewKey $fullRestored)}catch{$cleanupErrors+=$_.ToString()}
try {if(@($services|Where-Object Name -eq 'WinRM')[0].State -eq 'Stopped'){Stop-Service WinRM -ErrorAction Stop};$endServices=ReadServices;Save 'services-restored.json' $endServices;$servicesOk=($services|ConvertTo-Json -Compress) -ceq ($endServices|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
try {$endFirewall=ReadFirewall;Save 'firewall-restored.json' $endFirewall;$firewallOk=($firewall|ConvertTo-Json -Compress) -ceq ($endFirewall|ConvertTo-Json -Compress)}catch{$cleanupErrors+=$_.ToString()}
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;FullConfigurationPreserved=$configurationOk;ListenersRestored=$listenersOk;ServicesRestored=$servicesOk;FirewallPreserved=$firewallOk;Complete=($configurationOk -and $listenersOk -and $servicesOk -and $firewallOk -and -not $cleanupErrors.Count);DisposableBoundary='Fixture temporarily replaced ordinary original HTTP listeners and restored their captured configuration; product creation must refuse overlap.'}
if(-not $configurationOk -or -not $listenersOk -or -not $servicesOk -or -not $firewallOk -or $cleanupErrors.Count){throw 'Native checkpoint cleanup incomplete; inspect retained artifacts.'}
}
# Negative public CLI probes intentionally return 1; successful complete cleanup ends the fixture with 0.
exit 0