From 2301bf1e8519ed6c5a0bc1d4e69e4aa10a1ac965 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:03:17 +0900 Subject: [PATCH] Bind native listener adapter modules and normalize cross-engine context JSON --- docs/wec-listener.md | 4 ++-- scripts/WecListener.ps1 | 11 +++++++++-- tests/WecListener.Tests.ps1 | 8 +++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/wec-listener.md b/docs/wec-listener.md index f9d819d1..9671ae89 100644 --- a/docs/wec-listener.md +++ b/docs/wec-listener.md @@ -18,14 +18,14 @@ Plan writes review artifacts, including `plan.json` and `manifest.json` with `Pl The fixed desired listener is `Address=IP:`, transport `HTTP`, port `5985`, URL prefix `wsman`, enabled, with blank hostname and certificate thumbprint. Wildcard listeners, any existing HTTP5985 listener and an existing selected Address/Transport pair prevent creation. WELA leaves those listeners in place for manual review. It does not narrow, replace, disable or remove an existing endpoint. -The plan binds the actual machine, operator/logon context, assigned address, implementation and original WinRM configuration/policy/listeners and firewall observations. Apply checks the reviewed hash and fresh context, writes pending evidence before its single creation attempt, then checks actual native configuration and `ListeningOn`. The fixed local creation worker uses the trusted native Windows PowerShell 5.1 engine under both Windows PowerShell 5.1 and PowerShell 7 hosts, with no execution-policy override. Its actual process, token and engine are retained as evidence. A host that cannot run this fixed adapter must resolve that prerequisite before applying. +The plan binds the actual machine, operator/logon context, assigned address, implementation and original WinRM configuration/policy/listeners and firewall observations. Apply checks the reviewed hash and fresh context, writes pending evidence before its single creation attempt, then checks actual native configuration and `ListeningOn`. The fixed local creation worker uses the trusted native Windows PowerShell 5.1 engine under both Windows PowerShell 5.1 and PowerShell 7 hosts, with the fixed native 5.1 module directory and no execution-policy override. Its actual process, token and engine are retained as evidence. A host that cannot run this fixed adapter must resolve that prerequisite before applying. | Result | Meaning | | --- | --- | | `ReviewRequired` | Plan artifacts are ready for review; no listener was created. | | `CreatedAndVerified` | The new listener and expected native readback were observed, with the required preservation checks. | | `Refused` | Preconditions, evidence or context failed before a creation attempt. | -| `CreateAttemptedUnverified` | Creation was attempted but the final state could not be completely verified. Review the pending/native evidence and current listeners before taking further action. | +| `CreateAttemptedUnverified` | The adapter started and creation was attempted or cannot be ruled out; the final state could not be completely verified. Review the pending/native evidence and current listeners before taking further action. | No atomic Windows compare-and-set is available; another administrator or policy process can race observation and creation. There is no automatic rollback. An interrupted process can leave pending evidence and a created listener without a completed report. Use the retained original and current snapshots to identify what changed; this command never deletes a listener as a recovery shortcut. diff --git a/scripts/WecListener.ps1 b/scripts/WecListener.ps1 index 14fc989a..ab917cb9 100644 --- a/scripts/WecListener.ps1 +++ b/scripts/WecListener.ps1 @@ -1,5 +1,11 @@ # 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-WelaListenerKey { + param($Value) + # Windows PowerShell 5.1 escapes these HTML characters even with default JSON settings. + # Normalize the same spelling in both engines before binding nested context strings. + $json=ConvertTo-Json -InputObject $Value -Depth 24 -Compress + $json.Replace('<','\u003c').Replace('>','\u003e').Replace('&','\u0026').Replace("'",'\u0027') +} 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.'} @@ -30,7 +36,7 @@ function ConvertFrom-WelaListenerXml { $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 + $result.ListeningOn=$listening;$result.PolicyOwned=$policy;$result.RawXml=$Xml [pscustomobject]$result } function Read-WelaListenerInventory { @@ -206,6 +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.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/tests/WecListener.Tests.ps1 b/tests/WecListener.Tests.ps1 index 239d30c6..094564ab 100644 --- a/tests/WecListener.Tests.ps1 +++ b/tests/WecListener.Tests.ps1 @@ -8,6 +8,12 @@ $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='&';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='IP:192.0.2.10HTTP5985truewsman192.0.2.10' Assert ($selection.ComputerName -ceq 'TEST-HOST') 'Actual computer selection is canonical.' @@ -87,7 +93,7 @@ try { 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 '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()