diff --git a/docs/wec-listener.md b/docs/wec-listener.md index 9671ae89..e898a090 100644 --- a/docs/wec-listener.md +++ b/docs/wec-listener.md @@ -1,6 +1,6 @@ # Reviewed local WEC HTTP listener -`wec-listener` plans and creates one new native WinRM listener for the WEC collector prerequisites. It supports an explicitly selected IPv4 address assigned to the actual local Server 2022/2025 standalone or member server. WinRM, WMI and the firewall services must already be running. Domain controllers, remote hosts and listener updates are outside this command's scope. +`wec-listener` plans and creates one new native WinRM listener for the WEC collector prerequisites. It supports an explicitly selected IPv4 address assigned to the actual local Server 2022/2025 standalone or member server. WinRM, WMI and the firewall services must already be running. Existing WinRM policy values require manual review and cause refusal. Domain controllers, remote hosts and listener updates are outside this command's scope. ```powershell # Use an actual assigned local IPv4 address and a new private directory. diff --git a/scripts/WecListener.ps1 b/scripts/WecListener.ps1 index ab917cb9..9f536807 100644 --- a/scripts/WecListener.ps1 +++ b/scripts/WecListener.ps1 @@ -114,7 +114,7 @@ function Get-WelaListenerState { $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} + [pscustomobject][ordered]@{Local=$local;Profiles=$profiles;Rules=$digests;NativeFirewall=$native;NativeReader=[ordered]@{Path=$assembly;Sha256=(Get-FileHash $assembly -Algorithm SHA256).Hash};Adapter=[ordered]@{ModulePath=[IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules');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) @@ -145,7 +145,7 @@ function Get-WelaListenerWorkerContextKey { } 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} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNative51ListenerCreate';Status='Refused';NativeCreateAttempted=$false;ProcessId=$PID;Engine=[Diagnostics.Process]::GetCurrentProcess().MainModule.FileName;EngineVersion=$PSVersionTable.PSVersion.ToString();ModulePath=[string]$env:PSModulePath;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.'} @@ -202,8 +202,8 @@ function Close-WelaListenerAdapterProcess { } 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.'} + Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Status','NativeCreateAttempted','ProcessId','Engine','EngineVersion','ModulePath','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.ModulePath -isnot [string] -or $receipt.ModulePath -cne $State.Adapter.ModulePath -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.'} } @@ -212,7 +212,7 @@ function Start-WelaListenerAdapter { 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.EnvironmentVariables['PSModulePath']=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules' + $info.EnvironmentVariables['PSModulePath']=$State.Adapter.ModulePath $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 diff --git a/scripts/WecListenerWorker.ps1 b/scripts/WecListenerWorker.ps1 index 74dc2b43..1a8186f1 100644 --- a/scripts/WecListenerWorker.ps1 +++ b/scripts/WecListenerWorker.ps1 @@ -1,4 +1,5 @@ param([string]$RequestPath,[string]$RequestHash) +$env:PSModulePath=[IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules') $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 diff --git a/tests/WecListener.Tests.ps1 b/tests/WecListener.Tests.ps1 index 094564ab..6c257336 100644 --- a/tests/WecListener.Tests.ps1 +++ b/tests/WecListener.Tests.ps1 @@ -34,7 +34,7 @@ $copy=Copy-TestListener $listener;$copy.Address='IP:192.0.2.11';Reject {Assert-W $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'} +$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=@{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++ @@ -56,9 +56,10 @@ foreach($failure in @('kill','wait','dispose')){ 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} +$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='';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.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' diff --git a/tests/WecListener.Windows.Tests.ps1 b/tests/WecListener.Windows.Tests.ps1 index 639670c4..7ef5c835 100644 --- a/tests/WecListener.Windows.Tests.ps1 +++ b/tests/WecListener.Windows.Tests.ps1 @@ -31,6 +31,7 @@ function ReadFirewall {@(NetSecurity\Get-NetFirewallRule -PolicyStore ActiveStor $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=''} @@ -91,6 +92,7 @@ try { 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.'} @@ -122,3 +124,6 @@ try { 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