From 3e9ba8c4038806af6618ca62d858f9ecdc4ec084 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:58:08 +0900 Subject: [PATCH] Create one reviewed exact-IP collector listener through a bounded native adapter --- .gitattributes | 2 + .github/workflows/wec-listener.yml | 16 +- scripts/WecListener.ps1 | 271 ++++++++++++++++++++++++++++ scripts/WecListenerPipeNative.cs | 19 ++ scripts/WecListenerWorker.ps1 | 13 ++ tests/WecListener.Tests.ps1 | 105 +++++++++++ tests/WecListener.Windows.Tests.ps1 | 124 +++++++++++++ 7 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 scripts/WecListener.ps1 create mode 100644 scripts/WecListenerPipeNative.cs create mode 100644 scripts/WecListenerWorker.ps1 create mode 100644 tests/WecListener.Tests.ps1 create mode 100644 tests/WecListener.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 190bc45a..eac7b035 100644 --- a/.gitattributes +++ b/.gitattributes @@ -68,3 +68,5 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WecState* text eol=lf /scripts/WecRuntime* text eol=lf /tests/WecState* text eol=lf + +/scripts/WecListener* text eol=lf diff --git a/.github/workflows/wec-listener.yml b/.github/workflows/wec-listener.yml index 957ce27e..f4f209cf 100644 --- a/.github/workflows/wec-listener.yml +++ b/.github/workflows/wec-listener.yml @@ -8,7 +8,7 @@ permissions: contents: read jobs: wec-listener: - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -17,14 +17,20 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Native listener checkpoint in Windows PowerShell 5.1 + - name: Actual public listener in Windows PowerShell 5.1 if: matrix.engine == 'powershell' shell: powershell - run: ./tests/WecListener.Checkpoint.Windows.Tests.ps1 -AllowDisposableListenerReplacement - - name: Native listener checkpoint in PowerShell 7 + run: | + ./tests/WecListener.Tests.ps1 + ./tests/WecListener.Cli.Tests.ps1 + ./tests/WecListener.Windows.Tests.ps1 -AllowDisposableListenerReplacement + - name: Actual public listener in PowerShell 7 if: matrix.engine == 'pwsh' shell: pwsh - run: ./tests/WecListener.Checkpoint.Windows.Tests.ps1 -AllowDisposableListenerReplacement + run: | + ./tests/WecListener.Tests.ps1 + ./tests/WecListener.Cli.Tests.ps1 + ./tests/WecListener.Windows.Tests.ps1 -AllowDisposableListenerReplacement - name: Retain native listener and cleanup evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 diff --git a/scripts/WecListener.ps1 b/scripts/WecListener.ps1 new file mode 100644 index 00000000..14fc989a --- /dev/null +++ b/scripts/WecListener.ps1 @@ -0,0 +1,271 @@ +# One reviewed exact-IP HTTP listener; native creation always runs in Windows PowerShell 5.1. +function Get-WelaListenerKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Get-WelaListenerSelection { + param($ComputerName,$LocalAddress) + if($ComputerName -isnot [string] -or $ComputerName -cnotmatch '^[A-Za-z0-9][A-Za-z0-9-]{0,62}$'){throw 'Select the actual local computer name.'} + if($LocalAddress -isnot [string] -or $LocalAddress -cnotmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}$'){throw 'Select one canonical assigned IPv4 address.'} + $pieces=$LocalAddress.Split('.') + foreach($part in $pieces){if([int]$part -gt 255 -or ([int]$part).ToString() -cne $part){throw 'Select one canonical assigned IPv4 address.'}} + if([int]$pieces[0] -in @(0,127) -or [int]$pieces[0] -ge 224 -or ($pieces[0] -eq '169' -and $pieces[1] -eq '254')){throw 'Unspecified, loopback, link-local and multicast/reserved addresses are unsupported.'} + [pscustomobject][ordered]@{ComputerName=$ComputerName.ToUpperInvariant();LocalAddress=$LocalAddress} +} +function ConvertFrom-WelaListenerXml { + param([string]$Xml) + if(-not $Xml -or $Xml.Length -gt 131072){throw 'Listener XML exceeds its bound or is absent.'} + $doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$ns='http://schemas.microsoft.com/wbem/wsman/1/config/listener' + if($root.LocalName -cne 'Listener' -or $root.NamespaceURI -cne $ns){throw 'Unexpected native listener root.'} + $fields=@('Address','Transport','Port','Hostname','Enabled','URLPrefix','CertificateThumbprint');$result=[ordered]@{};$policy=$false + foreach($node in @($root)+@($root.ChildNodes|Where-Object NodeType -eq Element)){ + foreach($attr in @($node.Attributes)){ + if($attr.NamespaceURI -eq 'http://www.w3.org/2000/xmlns/' -or ($node -eq $root -and $attr.NamespaceURI -eq 'http://www.w3.org/XML/1998/namespace' -and $attr.LocalName -eq 'lang')){continue} + if($attr.Name -cne 'Source' -or -not $attr.Value){throw 'Unsupported listener provenance attribute.'};$policy=$true + } + } + foreach($child in $root.ChildNodes){if($child.NodeType -eq 'ProcessingInstruction' -or ($child.NodeType -in @('Text','CDATA') -and -not [string]::IsNullOrWhiteSpace($child.Value))){throw 'Unsupported listener container text.'}} + foreach($child in @($root.ChildNodes|Where-Object NodeType -eq Element)){ + if($child.NamespaceURI -cne $ns -or $child.LocalName -cnotin ($fields+@('ListeningOn')) -or @($child.ChildNodes|Where-Object NodeType -in @('Element','ProcessingInstruction')).Count){throw 'Unsupported native listener field.'} + } + foreach($name in $fields){$nodes=@($root.ChildNodes|Where-Object {$_.NodeType -eq 'Element' -and $_.LocalName -ceq $name});if($nodes.Count -ne 1){throw "Listener field is absent or duplicated: $name"};$result[$name]=[string]$nodes[0].InnerText} + if(-not $result.Address -or $result.Address.Length -gt 256 -or $result.Transport -cnotin @('HTTP','HTTPS') -or $result.Port -cnotmatch '^[1-9][0-9]{0,4}$' -or [int]$result.Port -gt 65535 -or $result.Enabled -cnotin @('true','false') -or $result.Hostname.Length -gt 255 -or $result.URLPrefix -cnotmatch '^[A-Za-z0-9_]+(?:/[A-Za-z0-9_]+)*$' -or $result.CertificateThumbprint -cnotmatch '^(|[0-9A-Fa-f]{40})$'){throw 'Unsupported native listener values.'} + $listening=@($root.ChildNodes|Where-Object {$_.NodeType -eq 'Element' -and $_.LocalName -ceq 'ListeningOn'}|ForEach-Object InnerText|Sort-Object) + if($listening.Count -gt 64 -or @($listening|Sort-Object -Unique).Count -ne $listening.Count){throw 'Ambiguous or excessive ListeningOn addresses.'} + foreach($value in $listening){$ip=$null;if(-not [Net.IPAddress]::TryParse($value,[ref]$ip)){throw 'Invalid native ListeningOn address.'}} + $result.ListeningOn=$listening;$result.PolicyOwned=$policy;$result.XmlKey=Get-WelaWefXmlKey $root;$result.RawXml=$Xml + [pscustomobject]$result +} +function Read-WelaListenerInventory { + $values=@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -Enumerate -ErrorAction Stop|Select-Object -First 33) + if($values.Count -gt 32){throw 'Listener inventory exceeds 32 entries.'} + $seen=@{};$bytes=0 + $rows=@(foreach($value in $values){$row=ConvertFrom-WelaListenerXml ([string]$value.OuterXml);$bytes+=$row.RawXml.Length;$id=$row.Address+'|'+$row.Transport;if($seen.ContainsKey($id) -or $bytes -gt 1048576){throw 'Duplicate or oversized listener inventory.'};$seen[$id]=$true;$row}) + @($rows|Sort-Object Address,Transport) +} +function Assert-WelaListenerAbsent { + param([object[]]$Listeners,$Selection) + foreach($row in $Listeners){if($row.Transport -ceq 'HTTP' -and ($row.Address -ceq '*' -or $row.Port -ceq '5985' -or $row.Address -ieq ('IP:'+$Selection.LocalAddress))){throw 'Existing HTTP5985, wildcard or selected listener conflicts; existing listeners are never changed.'}} +} +function Assert-WelaListenerCreated { + param($Listener,$Selection) + $expected=@{Address=('IP:'+$Selection.LocalAddress);Transport='HTTP';Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''} + foreach($name in $expected.Keys){if($Listener.$name -isnot [string] -or $Listener.$name -cne $expected[$name]){throw "Created listener $name differs from the fixed selection."}} + if($Listener.PolicyOwned -isnot [bool] -or $Listener.PolicyOwned -or $Listener.ListeningOn.Count -ne 1 -or $Listener.ListeningOn[0] -cne $Selection.LocalAddress){throw 'Created listener must be local and listen on exactly the selected IPv4 address.'} +} +function Get-WelaListenerSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/WecListener.ps1','scripts/WecListenerWorker.ps1','scripts/WecListenerPipeNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/FirewallLoggingRecovery.ps1','modules/WefSubscriptions.psm1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + Get-WelaListenerKey $sources +} +function Get-WelaListenerReaderKey { + param($Reader) + Get-WelaListenerKey ([ordered]@{Computer=$Reader.Computer;UserSid=$Reader.UserSid;UserName=$Reader.UserName;AuthenticationId=$Reader.AuthenticationId;GroupSids=$Reader.GroupSids;GroupCount=$Reader.GroupCount;PrivilegeCount=$Reader.PrivilegeCount;ElevatedAdministrator=$Reader.ElevatedAdministrator;TokenType=$Reader.TokenType;Impersonation=$Reader.Impersonation}) +} +function Read-WelaListenerPolicy { + # Refuse policy-owned WinRM settings; observe both native registry views without writing keys. + $observations=@() + foreach($view in @([Microsoft.Win32.RegistryView]::Registry64,[Microsoft.Win32.RegistryView]::Registry32)){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,$view) + try { + $queue=@('SOFTWARE\Policies\Microsoft\Windows\WinRM');$visited=0 + while($queue.Count){$path=$queue[0];$queue=@($queue|Select-Object -Skip 1);$visited++;if($visited -gt 32){throw 'WinRM policy key bound exceeded.'};$key=$base.OpenSubKey($path,$false) + try{if($null -eq $key){$observations+=[pscustomobject]@{View=[string]$view;Path=$path;Exists=$false};continue};if($key.ValueCount){throw 'Policy-owned WinRM settings require manual review; no policy is overwritten.'};$children=@($key.GetSubKeyNames()|Sort-Object);$observations+=[pscustomobject]@{View=[string]$view;Path=$path;Exists=$true;Children=$children};foreach($child in $children){$queue+=($path+'\'+$child)}}finally{if($key){$key.Dispose()}} + } + }finally{$base.Dispose()} + } + Get-WelaListenerKey $observations +} +function Read-WelaListenerWinrm { + $values=@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config' -ErrorAction Stop) + if($values.Count -ne 1 -or -not $values[0].OuterXml -or $values[0].OuterXml.Length -gt 262144){throw 'WinRM configuration is missing, ambiguous or oversized.'} + $doc=Read-WelaWefXml ([string]$values[0].OuterXml) + if($doc.DocumentElement.LocalName -cne 'Config' -or $doc.DocumentElement.NamespaceURI -cne 'http://schemas.microsoft.com/wbem/wsman/1/config'){throw 'Unexpected native WinRM configuration.'} + [string]$doc.OuterXml +} +function Get-WelaListenerLocalState { + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'The actual non-impersonated elevated administrator is required.'} + $services=@(Get-Service -Name WinRM,Winmgmt,BFE,MpsSvc -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 4 -or @($services|Where-Object Status -cne 'Running').Count){throw 'WinRM, Winmgmt, BFE and MpsSvc must already be running; no service is started.'} + $hostState=Get-WelaChannelReadHost + if($hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100) -or $null -eq $hostState.UBR -or $hostState.UBR -lt 1){throw 'A reviewed patched native Server 2022/2025 standalone or member collector is required.'} + $guid=(Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;$parsed=[guid]::Empty + if($guid -isnot [string] -or -not [guid]::TryParse($guid,[ref]$parsed) -or $parsed -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'} + $nativeServices=@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='Winmgmt' OR Name='BFE' OR Name='MpsSvc'" -ErrorAction Stop|Sort-Object Name|Select-Object Name,State,StartMode) + if($nativeServices.Count -ne 5 -or @($nativeServices|Where-Object {$_.State -notin @('Running','Stopped') -or $_.StartMode -notin @('Auto','Manual','Disabled')}).Count){throw 'Complete stable collector service observations are required.'} + $addresses=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop|Sort-Object InterfaceIndex,IPAddress|Select-Object IPAddress,InterfaceIndex,PrefixLength,PrefixOrigin,SuffixOrigin,AddressState,SkipAsSource) + if($addresses.Count -lt 1 -or $addresses.Count -gt 128){throw 'Assigned address inventory is incomplete or excessive.'} + $state=[pscustomobject][ordered]@{Host=$hostState;MachineGuid=$parsed.ToString();Reader=$reader;Services=$nativeServices;Addresses=$addresses;Policy=Read-WelaListenerPolicy;WinrmXml=Read-WelaListenerWinrm;Listeners=@(Read-WelaListenerInventory)} + if((Get-WelaListenerKey (Get-WelaChannelReader)) -cne (Get-WelaListenerKey $reader)){throw 'Actual token changed during native observations.'} + $state +} +function Get-WelaListenerState { + $local=Get-WelaListenerLocalState;$native=Get-WelaFirewallRecoveryNativeSources;$profiles=@();$digests=@() + foreach($store in @('PersistentStore','ActiveStore')){ + $rows=@(NetSecurity\Get-NetFirewallProfile -PolicyStore $store -ErrorAction Stop|Sort-Object Name) + if($rows.Count -ne 3){throw 'All three firewall profiles must be observed in both stores.'} + foreach($row in $rows){$profiles+=[pscustomobject]@{Store=$store;Profile=ConvertTo-WelaFirewallRecoveryCim $row @('Status','StatusCode','PrimaryStatus','OperationalStatus','InstanceID','InstanceId')}} + $digests+=@(Get-WelaFirewallRecoveryRuleDigest $store) + } + $engine=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe';$worker=Join-Path $PSScriptRoot 'WecListenerWorker.ps1' + $cmd=Get-Command 'Microsoft.WSMan.Management\Get-WSManInstance' -CommandType Cmdlet -ErrorAction Stop;$assembly=$cmd.ImplementingType.Assembly.Location + if(-not $assembly -or $cmd.ModuleName -cne 'Microsoft.WSMan.Management'){throw 'Native WSMan reader source is unavailable.'} + [pscustomobject][ordered]@{Local=$local;Profiles=$profiles;Rules=$digests;NativeFirewall=$native;NativeReader=[ordered]@{Path=$assembly;Sha256=(Get-FileHash $assembly -Algorithm SHA256).Hash};Adapter=[ordered]@{Engine=$engine;EngineSha256=(Get-FileHash $engine -Algorithm SHA256).Hash;Worker=$worker;WorkerSha256=(Get-FileHash $worker -Algorithm SHA256).Hash};Sources=Get-WelaListenerSources} +} +function Get-WelaListenerReviewKey { + param($State,[switch]$ExcludeSelected,$Selection) + $copy=Get-WelaListenerKey $State|ConvertFrom-Json + $copy.Local.Reader.ProcessId=$null;$copy.Local.Reader.TokenId=$null;$copy.Local.Reader.ModifiedId=$null + if($ExcludeSelected){$copy.Local.Listeners=@($copy.Local.Listeners|Where-Object {-not($_.Address -ceq ('IP:'+$Selection.LocalAddress) -and $_.Transport -ceq 'HTTP')})} + Get-WelaListenerKey $copy +} +function Assert-WelaListenerSelectedHost { + param($State,$Selection) + if($State.Host.Computer.ToUpperInvariant() -cne $Selection.ComputerName -or @($State.Addresses|Where-Object {$_.IPAddress -ceq $Selection.LocalAddress -and [string]$_.AddressState -ceq 'Preferred'}).Count -ne 1){throw 'Selection must identify this actual computer and exactly one currently assigned Preferred IPv4 address.'} +} +function New-WelaListenerPayload { + '5985truewsman' +} +function Assert-WelaListenerPlan { + param($Plan) + Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Selection','StateKey','RecordedUtc') + if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -isnot [string] -or $Plan.Kind -cne 'WelaExactIpListenerPlan' -or $Plan.StateKey -isnot [string] -or -not $Plan.StateKey -or $Plan.StateKey.Length -gt 2097152){throw 'Unknown or mistyped listener plan.'} + Assert-WelaArrivalObject $Plan.Selection @('ComputerName','LocalAddress');$selection=Get-WelaListenerSelection $Plan.Selection.ComputerName $Plan.Selection.LocalAddress + if((Get-WelaListenerKey $selection) -cne (Get-WelaListenerKey $Plan.Selection)){throw 'Listener plan selection is not canonical.'};$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc +} +function Get-WelaListenerWorkerContextKey { + param($Local) + $copy=Get-WelaListenerKey $Local|ConvertFrom-Json + $copy.Reader=Get-WelaListenerReaderKey $Local.Reader + Get-WelaListenerKey $copy +} +function Invoke-WelaListenerWorkerRequest { + param([string]$RequestPath,[string]$RequestHash) + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNative51ListenerCreate';Status='Refused';NativeCreateAttempted=$false;ProcessId=$PID;Engine=[Diagnostics.Process]::GetCurrentProcess().MainModule.FileName;EngineVersion=$PSVersionTable.PSVersion.ToString();Reader=$null;Selection=$null;CreatedXml=$null;After=@();Diagnostic='';NativeHResult=$null} + $held=$null + try { + if($PSVersionTable.PSVersion.Major -ne 5 -or $RequestHash -cnotmatch '^[a-f0-9]{64}$'){throw 'A hashed fixed native Windows PowerShell5.1 request is required.'} + $file=Read-WelaWecUpdateFile $RequestPath;if($file.Hash -cne $RequestHash){throw 'Native request hash differs.'} + $request=ConvertFrom-WelaArrivalJson $file.Text + Assert-WelaArrivalObject $request @('SchemaVersion','Kind','Selection','ContextKey','Sources','EngineSha256','PayloadHash') + if(($request.SchemaVersion -isnot [int] -and $request.SchemaVersion -isnot [long]) -or $request.SchemaVersion -ne 1 -or $request.Kind -isnot [string] -or $request.Kind -cne 'WelaNative51ListenerRequest' -or $request.ContextKey -isnot [string] -or $request.Sources -isnot [string] -or $request.EngineSha256 -isnot [string] -or $request.PayloadHash -isnot [string] -or $request.PayloadHash -cnotmatch '^[a-f0-9]{64}$'){throw 'Unknown or mistyped native request.'} + Assert-WelaArrivalObject $request.Selection @('ComputerName','LocalAddress');$selection=Get-WelaListenerSelection $request.Selection.ComputerName $request.Selection.LocalAddress;$report.Selection=$selection + $expectedEngine=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe' + if($report.Engine -ine $expectedEngine -or (Get-FileHash $expectedEngine -Algorithm SHA256).Hash -cne $request.EngineSha256 -or (Get-WelaListenerSources) -cne $request.Sources){throw 'Native adapter engine or installed sources differ.'} + $payloadPath=Join-Path (Split-Path $file.Path -Parent) 'native-payload.xml';$payload=Read-WelaWecUpdateFile $payloadPath 4096 + if($payload.Hash -cne $request.PayloadHash -or $payload.Text -cne (New-WelaListenerPayload)){throw 'Native listener payload is not the exact fixed XML.'} + $held=[IO.File]::Open($payload.Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $local=Get-WelaListenerLocalState;$report.Reader=$local.Reader + Assert-WelaListenerSelectedHost $local $selection;Assert-WelaListenerAbsent $local.Listeners $selection + if((Get-WelaListenerWorkerContextKey $local) -cne $request.ContextKey -or (Read-WelaWecUpdateFile $RequestPath).Hash -cne $RequestHash -or (Read-WelaWecUpdateFile $payloadPath 4096).Hash -cne $request.PayloadHash -or (Get-WelaListenerSources) -cne $request.Sources){throw 'Fresh native adapter context, input or code differs.'} + $report.NativeCreateAttempted=$true + $created=@(Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=('IP:'+$selection.LocalAddress);Transport='HTTP'} -FilePath $payload.Path -ErrorAction Stop) + if($created.Count -ne 1 -or -not $created[0].OuterXml -or $created[0].OuterXml.Length -gt 32768){throw 'Native create response is incomplete or excessive.'};$report.CreatedXml=[string]$created[0].OuterXml + $after=Get-WelaListenerLocalState;$report.After=$after.Listeners + $chosen=@($after.Listeners|Where-Object {$_.Address -ceq ('IP:'+$selection.LocalAddress) -and $_.Transport -ceq 'HTTP'}) + if($chosen.Count -ne 1){throw 'Native creation did not produce exactly one selected listener.'};Assert-WelaListenerCreated $chosen[0] $selection + $after.Listeners=@($after.Listeners|Where-Object {-not($_.Address -ceq ('IP:'+$selection.LocalAddress) -and $_.Transport -ceq 'HTTP')}) + if((Get-WelaListenerWorkerContextKey $after) -cne $request.ContextKey -or (Get-WelaListenerKey $after.Reader) -cne (Get-WelaListenerKey $local.Reader) -or (Get-WelaListenerSources) -cne $request.Sources){throw 'Native adapter context, token, other listeners or source changed during creation.'} + $report.Status='Created' + }catch{$report.Status=if($report.NativeCreateAttempted){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message;$report.NativeHResult=$_.Exception.HResult;try{$report.After=@(Read-WelaListenerInventory)}catch{}} + finally{if($held){$held.Dispose()}} + $report +} +function Initialize-WelaListenerPipe { + $path=Join-Path $PSScriptRoot 'WecListenerPipeNative.cs';$bytes=[IO.File]::ReadAllBytes($path) + if($bytes.Length -gt 65536){throw 'Listener pipe source exceeds its bound.'};$hash=Get-WelaArrivalHash $bytes + if(-not('Wela.ListenerPipe.Bounded' -as [type])){ + $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Listener pipe source marker is missing or ambiguous.'} + Add-Type -TypeDefinition $source.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop + } + if([Wela.ListenerPipe.Bounded]::SourceSha256 -cne $hash){throw 'Loaded listener pipe helper differs from its source.'} +} +function Close-WelaListenerAdapterProcess { + param($Process,$Result) + # Cleanup must never discard Started=true after a possibly mutating child ran. + if($Result.Started){ + $exited=$false + try{$exited=$Process.HasExited}catch{$Result.Diagnostic+=' Adapter exit observation failed: '+$_.Exception.Message} + if(-not $exited){ + try{$Process.Kill()}catch{$Result.Diagnostic+=' Adapter termination request failed: '+$_.Exception.Message} + try{$exited=$Process.WaitForExit(5000)}catch{$Result.Diagnostic+=' Adapter termination wait failed: '+$_.Exception.Message} + } + $Result.TerminationConfirmed=[bool]$exited + if(-not $exited){$Result.Diagnostic+=' Adapter termination is unconfirmed.'} + } + try{$Process.Dispose()}catch{$Result.Diagnostic+=' Adapter resource cleanup failed: '+$_.Exception.Message} +} +function Assert-WelaListenerAdapterReceipt { + param($Receipt,$State,[int]$ProcessId,[int]$ExitCode) + Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Status','NativeCreateAttempted','ProcessId','Engine','EngineVersion','Reader','Selection','CreatedXml','After','Diagnostic','NativeHResult') + if(($receipt.SchemaVersion -isnot [int] -and $receipt.SchemaVersion -isnot [long]) -or $receipt.SchemaVersion -ne 1 -or $receipt.Kind -isnot [string] -or $receipt.Kind -cne 'WelaNative51ListenerCreate' -or $receipt.NativeCreateAttempted -isnot [bool] -or ($receipt.ProcessId -isnot [int] -and $receipt.ProcessId -isnot [long]) -or $receipt.ProcessId -ne $ProcessId -or $receipt.Engine -isnot [string] -or $receipt.Engine -ine $State.Adapter.Engine -or $receipt.EngineVersion -isnot [string] -or $receipt.EngineVersion -cnotmatch '^5\.1\.[0-9]+\.[0-9]+$' -or $receipt.Status -isnot [string] -or $receipt.Status -cnotin @('Created','Refused','CreateAttemptedUnverified') -or $receipt.Diagnostic -isnot [string]){throw 'Native adapter receipt has inconsistent identity or status.'} + if($receipt.Reader -and (Get-WelaListenerReaderKey $receipt.Reader) -cne (Get-WelaListenerReaderKey $State.Local.Reader)){throw 'Native adapter did not run under the reviewed actual account/logon.'} + if($receipt.Status -ceq 'Created' -and (-not $receipt.Reader -or -not $receipt.NativeCreateAttempted -or $ExitCode -ne 0 -or $receipt.Diagnostic)){throw 'Native adapter success receipt is incomplete.'} +} +function Start-WelaListenerAdapter { + param($State,[string]$RequestPath,[string]$RequestHash) + foreach($path in @($State.Adapter.Engine,$State.Adapter.Worker,$RequestPath)){if($path.Contains('"') -or $path.EndsWith('\') -or $path -match '[\x00-\x1f]'){throw 'Unsupported native adapter path.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Adapter.Engine + $info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$State.Adapter.Worker+'" -RequestPath "'+$RequestPath+'" -RequestHash '+$RequestHash + $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 + $result=[pscustomobject][ordered]@{Started=$false;ProcessId=$null;ExitCode=$null;TimedOut=$false;TerminationConfirmed=$false;Receipt=$null;Diagnostic=''};$process=[Diagnostics.Process]::new();$process.StartInfo=$info + try { + if(-not $process.Start()){throw 'Native listener adapter did not start.'};$result.Started=$true;$result.ProcessId=$process.Id + $stdout=[Wela.ListenerPipe.Bounded]::Read($process.StandardOutput,524288);$stderr=[Wela.ListenerPipe.Bounded]::Read($process.StandardError,65536) + if(-not $process.WaitForExit(45000)){$result.TimedOut=$true;throw 'Native listener adapter timed out; creation may have been attempted.'} + $result.ExitCode=$process.ExitCode + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Native listener adapter output drain timed out.'} + $text=$stdout.Result;$errorText=$stderr.Result + if($errorText -or $text.Length -gt 524288){throw 'Native adapter output is incomplete, excessive or contains errors.'} + $receipt=ConvertFrom-WelaArrivalJson $text + Assert-WelaListenerAdapterReceipt $receipt $State $result.ProcessId $result.ExitCode + $result.Receipt=$receipt + }catch{$result.Diagnostic=$_.Exception.Message} + finally{Close-WelaListenerAdapterProcess $process $result} + $result +} +function Invoke-WelaWecListener { + param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$ComputerName,[string]$LocalAddress,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath) + if($args.Count){throw 'Unknown listener arguments are not supported.'} + if($Action -eq 'Plan'){ + if(-not $ComputerName -or -not $LocalAddress -or -not $OutputPath -or $PSBoundParameters.ContainsKey('PlanPath') -or $PSBoundParameters.ContainsKey('PlanHash')){throw 'Plan requires the actual computer, assigned IPv4 and new output only.'} + $selection=Get-WelaListenerSelection $ComputerName $LocalAddress;$reviewedFile=$null + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-fA-F0-9]{64}$' -or -not $OutputPath -or $PSBoundParameters.ContainsKey('ComputerName') -or $PSBoundParameters.ContainsKey('LocalAddress')){throw 'Apply requires only a reviewed plan, SHA256 and new output.'} + $PlanHash=$PlanHash.ToLowerInvariant();$reviewedFile=Read-WelaWecUpdateFile $PlanPath + } + $source=if($reviewedFile){$reviewedFile.Path}else{Join-Path $script:ScriptRoot 'WELA.ps1'};$output=New-WelaArrivalOutput $OutputPath $source + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaExactIpListener';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;AdapterStarted=$false;NativeCreateAttempted=$null;Adapter=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;ServiceChanges=0;AuthenticationChanges=0;FirewallChanges=0;Scope='One new exact assigned-IPv4 HTTP5985/wsman listener through a fixed native Windows PowerShell5.1 adapter. Existing WinRM endpoints may use it. No remote connection, WEF delivery, packet acceptance, retention or Sigma proof. Sysmon excluded.'} + try { + $state=Get-WelaListenerState;$key=Get-WelaListenerReviewKey $state;$tokenKey=Get-WelaListenerKey $state.Local.Reader + if($Action -eq 'Apply'){ + if($reviewedFile.Hash -cne $PlanHash){throw 'Reviewed listener plan hash differs.'};$plan=ConvertFrom-WelaArrivalJson $reviewedFile.Text;Assert-WelaListenerPlan $plan + if($plan.StateKey -cne $key){throw 'Reviewed host/operator/code/listener/WinRM/firewall state differs.'};$selection=Get-WelaListenerSelection $plan.Selection.ComputerName $plan.Selection.LocalAddress;$report.PlanHash=$PlanHash + } + Assert-WelaListenerSelectedHost $state.Local $selection;Assert-WelaListenerAbsent $state.Local.Listeners $selection + if($Action -eq 'Plan'){ + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaExactIpListenerPlan';Selection=$selection;StateKey=$key;RecordedUtc=[DateTime]::UtcNow.ToString('o')};Assert-WelaListenerPlan $plan + $fresh=Get-WelaListenerState;if((Get-WelaListenerReviewKey $fresh) -cne $key -or (Get-WelaListenerKey $fresh.Local.Reader) -cne $tokenKey){throw 'Context changed during listener planning.'};Assert-WelaListenerAbsent $fresh.Local.Listeners $selection + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $reviewedFile.Text + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-create.json' ([ordered]@{Status='Pending';PlanHash=$PlanHash;Selection=$selection;State=$state;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 24) + $payload=Write-WelaWecUpdateArtifact $output 'native-payload.xml' (New-WelaListenerPayload);$report.Artifacts+=$payload + $request=[ordered]@{SchemaVersion=1;Kind='WelaNative51ListenerRequest';Selection=$selection;ContextKey=(Get-WelaListenerWorkerContextKey $state.Local);Sources=$state.Sources;EngineSha256=$state.Adapter.EngineSha256;PayloadHash=$payload.Sha256} + $artifact=Write-WelaWecUpdateArtifact $output 'native-request.json' ($request|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact + $fresh=Get-WelaListenerState;if((Get-WelaListenerReviewKey $fresh) -cne $key -or (Get-WelaListenerKey $fresh.Local.Reader) -cne $tokenKey -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Plan or actual state changed immediately before creation.'};Assert-WelaListenerAbsent $fresh.Local.Listeners $selection + $adapter=Start-WelaListenerAdapter $state (Join-Path $output 'native-request.json') $artifact.Sha256;$report.Adapter=$adapter;$report.AdapterStarted=$adapter.Started + if($adapter.Receipt){$report.NativeCreateAttempted=$adapter.Receipt.NativeCreateAttempted} + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'adapter-receipt.json' ($adapter|ConvertTo-Json -Depth 24) + $after=Get-WelaListenerState;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after-state.json' ($after|ConvertTo-Json -Depth 24) + if($adapter.Diagnostic -or -not $adapter.Receipt -or $adapter.Receipt.Status -cne 'Created' -or -not $adapter.Receipt.NativeCreateAttempted -or (Get-WelaListenerKey $adapter.Receipt.Selection) -cne (Get-WelaListenerKey $selection)){throw ('Native creation is unverified: '+$adapter.Diagnostic+' '+$adapter.Receipt.Diagnostic)} + $selected=@($after.Local.Listeners|Where-Object {$_.Address -ceq ('IP:'+$selection.LocalAddress) -and $_.Transport -ceq 'HTTP'});if($selected.Count -ne 1){throw 'Expected exactly one created listener.'};Assert-WelaListenerCreated $selected[0] $selection + if((Get-WelaListenerReviewKey $after -ExcludeSelected -Selection $selection) -cne $key -or (Get-WelaListenerKey $after.Local.Reader) -cne $tokenKey -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Host/token/code/plan, other listeners, WinRM or firewall configuration changed during creation.'} + $report.Status='CreatedAndVerified';$report.ExitCode=0 + } + }catch{ + $report.Status=if($report.AdapterStarted -and ($null -eq $report.NativeCreateAttempted -or $report.NativeCreateAttempted)){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message + if($report.AdapterStarted -and -not @($report.Artifacts|Where-Object Name -eq 'after-state.json').Count){try{$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after-state.json' ((Get-WelaListenerState)|ConvertTo-Json -Depth 24)}catch{}} + } + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report +} diff --git a/scripts/WecListenerPipeNative.cs b/scripts/WecListenerPipeNative.cs new file mode 100644 index 00000000..6bcbc6d5 --- /dev/null +++ b/scripts/WecListenerPipeNative.cs @@ -0,0 +1,19 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +namespace Wela.ListenerPipe { + public static class Bounded { + public const string SourceSha256 = "__WELA_SOURCE_SHA256__"; + public static async Task Read(TextReader reader, int maximumCharacters) { + if (reader == null || maximumCharacters < 1 || maximumCharacters > 1048576) throw new ArgumentException("Invalid bounded reader."); + var text = new StringBuilder(); var buffer = new char[1024]; + while (true) { + int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (count == 0) return text.ToString(); + if (count > maximumCharacters - text.Length) throw new InvalidDataException("Native listener adapter output exceeded its character bound."); + text.Append(buffer, 0, count); + } + } + } +} diff --git a/scripts/WecListenerWorker.ps1 b/scripts/WecListenerWorker.ps1 new file mode 100644 index 00000000..74dc2b43 --- /dev/null +++ b/scripts/WecListenerWorker.ps1 @@ -0,0 +1,13 @@ +param([string]$RequestPath,[string]$RequestHash) +$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +if($args.Count -or $PSVersionTable.PSVersion.Major -ne 5 -or -not [Environment]::Is64BitProcess){throw 'Only the fixed native Windows PowerShell 5.1 listener adapter is supported.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $script:ScriptRoot 'modules/WefSubscriptions.psm1') -Force +. (Join-Path $PSScriptRoot 'WefArrival.ps1') +. (Join-Path $PSScriptRoot 'WecUpdate.ps1') +. (Join-Path $PSScriptRoot 'ChannelRead.ps1') +. (Join-Path $PSScriptRoot 'WecListener.ps1') +$report=Invoke-WelaListenerWorkerRequest $RequestPath $RequestHash +$report|ConvertTo-Json -Depth 24 -Compress +if($report.Status -cne 'Created'){exit 1} diff --git a/tests/WecListener.Tests.ps1 b/tests/WecListener.Tests.ps1 new file mode 100644 index 00000000..239d30c6 --- /dev/null +++ b/tests/WecListener.Tests.ps1 @@ -0,0 +1,105 @@ +$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} +$selection=Get-WelaListenerSelection 'test-host' '192.0.2.10' +$xml='IP:192.0.2.10HTTP5985truewsman192.0.2.10' +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('5985',''),$xml.Replace('','true'),$xml.Replace('cfg:Port','cfg:Unknown'),$xml.Replace('http://schemas.microsoft.com/wbem/wsman/1/config/listener','urn:wrong'),$xml.Replace('',''),$xml.Replace('',''),$xml.Replace('',''),$xml.Replace('>true<','>True<'),$xml.Replace('>5985<','>05985<'),$xml.Replace('','192.0.2.10'),$xml.Replace('>192.0.2.10<','>not-an-address<'),(']>'+$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('',''));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='';Listeners=@($other)};Profiles=@('protected');Rules=@('digest');NativeFirewall='native';NativeReader='native-reader';Adapter=@{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';Reader=$reader;Selection=$selection;CreatedXml='';After=@($listener);Diagnostic='';NativeHResult=$null} +Assert-WelaListenerAdapterReceipt $receipt $baseline 123 0;$count++ +foreach($field in @('Kind','Engine','EngineVersion','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=''} + 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": 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." diff --git a/tests/WecListener.Windows.Tests.ps1 b/tests/WecListener.Windows.Tests.ps1 new file mode 100644 index 00000000..639670c4 --- /dev/null +++ b/tests/WecListener.Windows.Tests.ps1 @@ -0,0 +1,124 @@ +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) +$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.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.'} +}