From 504e36c15e8c741cdb3d8450e112aac367daabdb Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:14:53 +0900 Subject: [PATCH 01/21] Checkpoint exact-IP WinRM listener creation on native Windows --- .github/workflows/wec-listener.yml | 35 +++++++++++ .../WecListener.Checkpoint.Windows.Tests.ps1 | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 .github/workflows/wec-listener.yml create mode 100644 tests/WecListener.Checkpoint.Windows.Tests.ps1 diff --git a/.github/workflows/wec-listener.yml b/.github/workflows/wec-listener.yml new file mode 100644 index 00000000..957ce27e --- /dev/null +++ b/.github/workflows/wec-listener.yml @@ -0,0 +1,35 @@ +name: Reviewed exact-IP collector listener +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wec-listener: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Native listener checkpoint 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 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecListener.Checkpoint.Windows.Tests.ps1 -AllowDisposableListenerReplacement + - name: Retain native listener and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: wec-listener-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-listener-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/tests/WecListener.Checkpoint.Windows.Tests.ps1 b/tests/WecListener.Checkpoint.Windows.Tests.ps1 new file mode 100644 index 00000000..f357c63d --- /dev/null +++ b/tests/WecListener.Checkpoint.Windows.Tests.ps1 @@ -0,0 +1,60 @@ +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 'winrm/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)} +$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.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 'winrm/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=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'winrm/config/Listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop + Save 'native-create.json' @{Xml=$result.OuterXml} + $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=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'winrm/config/Listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop}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 'winrm/config/Listener' -SelectorSet $selector -ErrorAction Stop}catch{$cleanupErrors+=$_.ToString()}} + foreach($listener in $removed){try {$null=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'winrm/config/Listener' -SelectorSet @{Address=$listener.Address;Transport=$listener.Transport} -ValueSet @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint} -ErrorAction Stop}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.'} +} From 4df3bb4c79c63437f319bd01615f09e2ede78698 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:16:44 +0900 Subject: [PATCH 02/21] Reject unknown CLI arguments before legacy command dispatch --- .github/workflows/cli-arguments.yml | 35 +++++++++++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 7 +++ tests/CliArguments.Tests.ps1 | 70 ++++++++++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 7 files changed, 120 insertions(+) create mode 100644 .github/workflows/cli-arguments.yml create mode 100644 tests/CliArguments.Tests.ps1 diff --git a/.github/workflows/cli-arguments.yml b/.github/workflows/cli-arguments.yml new file mode 100644 index 00000000..ba0108bd --- /dev/null +++ b/.github/workflows/cli-arguments.yml @@ -0,0 +1,35 @@ +name: Public CLI unknown argument rejection +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + cli-arguments: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Windows PowerShell 5.1 process and native state checks + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/CliArguments.Tests.ps1 + - name: PowerShell 7 process and native state checks + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/CliArguments.Tests.ps1 + - name: Retain native before and after observations + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cli-arguments-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-cli-arguments.json + if-no-files-found: error + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 4c13f51b..27bac3e5 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bfbecd..fefc6cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 34d6797b..6aeead0f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2188,6 +2188,13 @@ if ($Cmd -ne 'ldap-diagnostics' -and @($PSBoundParameters.Keys | Where-Object { throw 'LDAP options require the dedicated ldap-diagnostics command. No command was run.' } +# Plain scripts retain unknown named options in $args. Check them before every +# dispatch, including the profile shortcut, so an unsupported -WhatIf or typo +# cannot accidentally reach a writer. Keep dedicated option diagnostics above. +if ($args.Count -gt 0) { + throw 'Unsupported trailing arguments. Check -Help for documented options; no command was run.' +} + if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'configure') -and -not $Help) { Invoke-WelaProfileCommand -Command $Cmd.ToLower() return diff --git a/tests/CliArguments.Tests.ps1 b/tests/CliArguments.Tests.ps1 new file mode 100644 index 00000000..9f28df52 --- /dev/null +++ b/tests/CliArguments.Tests.ps1 @@ -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 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index de508ee6..2da25b19 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 4ca9dbe7..79787a76 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) From 8ac4c45653618559d7d5e814f5925d3ae5fc73a5 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:21 +0900 Subject: [PATCH 03/21] Add fixed native AppLocker Script probe and disposable evidence matrix --- .github/workflows/applocker-script-probe.yml | 46 +++++ WELA.ps1 | 13 ++ scripts/AppLockerScriptNative.cs | 79 +++++++ scripts/AppLockerScriptProbe.ps1 | 206 +++++++++++++++++++ scripts/AppLockerScriptWorker.ps1 | 7 + tests/AppLockerScriptProbe.Cli.Tests.ps1 | 25 +++ tests/AppLockerScriptProbe.Tests.ps1 | 67 ++++++ tests/AppLockerScriptProbe.Windows.Tests.ps1 | 134 ++++++++++++ 8 files changed, 577 insertions(+) create mode 100644 .github/workflows/applocker-script-probe.yml create mode 100644 scripts/AppLockerScriptNative.cs create mode 100644 scripts/AppLockerScriptProbe.ps1 create mode 100644 scripts/AppLockerScriptWorker.ps1 create mode 100644 tests/AppLockerScriptProbe.Cli.Tests.ps1 create mode 100644 tests/AppLockerScriptProbe.Tests.ps1 create mode 100644 tests/AppLockerScriptProbe.Windows.Tests.ps1 diff --git a/.github/workflows/applocker-script-probe.yml b/.github/workflows/applocker-script-probe.yml new file mode 100644 index 00000000..87a8a1a3 --- /dev/null +++ b/.github/workflows/applocker-script-probe.yml @@ -0,0 +1,46 @@ +name: Native AppLocker Script probe +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + applocker-script-probe: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixtures in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/AppLockerScriptProbe.Tests.ps1 + ./tests/AppLockerScriptProbe.Cli.Tests.ps1 + - name: Native probe in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/AppLockerScriptProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Fixtures in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/AppLockerScriptProbe.Tests.ps1 + ./tests/AppLockerScriptProbe.Cli.Tests.ps1 + - name: Native probe in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/AppLockerScriptProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain bounded native evidence and cleanup receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: applocker-script-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-applocker-script-native-*/** + retention-days: 7 + if-no-files-found: warn diff --git a/WELA.ps1 b/WELA.ps1 index 26475edf..4eedd022 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -144,6 +144,9 @@ [switch]$AllowDnsTraceReset, [string[]]$WecRuntimeId, [ValidateRange(1,512)][int]$WecRuntimeMaximumSources=128, + [ValidateSet('Plan','Run')][string]$AppLockerScriptAction = 'Plan', + [string]$AppLockerScriptOutputPath, + [ValidateRange(1,30)][int]$AppLockerScriptTimeoutSeconds = 15, [ValidateSet('Plan','Run')][string]$AppLockerProbeAction = 'Plan', [string]$AppLockerProbeOutputPath, [ValidateRange(1,30)][int]$AppLockerProbeTimeoutSeconds = 15, @@ -182,6 +185,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1") +. (Join-Path $ScriptRoot "scripts/AppLockerScriptProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") @@ -1989,6 +1993,7 @@ Usage: ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence + ./WELA.ps1 applocker-script-probe -Help # Collect a fixed native Script8005/8006 event ./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event ./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector ./WELA.ps1 native-validation -Help # Collect a fixed native 4688 probe without changing policy @@ -2079,6 +2084,8 @@ if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -no } if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'} if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'} +if ($Cmd -ne 'applocker-script-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'AppLockerScript*'}).Count) {throw 'AppLockerScript options require applocker-script-probe.'} +if ($Cmd -eq 'applocker-script-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','AppLockerScriptAction','AppLockerScriptOutputPath','AppLockerScriptTimeoutSeconds','Help')}).Count)) {throw 'applocker-script-probe accepts only its dedicated options.'} if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'} if ($Cmd -eq 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds','Help')}).Count) {throw 'applocker-probe accepts only its dedicated options.'} if ($Cmd -ne 'wef-arrival' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('ArrivalProbePath','ArrivalOutputPath')}).Count) { @@ -2302,6 +2309,12 @@ switch ($Cmd.ToLower()) { $report if($report.ExitCode){exit $report.ExitCode} } + 'applocker-script-probe' { + if ($Help) {Write-Host 'Usage: applocker-script-probe [-AppLockerScriptAction Plan|Run] [-AppLockerScriptOutputPath new-private-directory] [-AppLockerScriptTimeoutSeconds 1..30]. Requires existing Script AuditOnly policy, running AppIDSvc and enabled MSI and Script channel. Fixed native Windows PowerShell5.1 script, no policy changes or Sigma credit. See docs/applocker-script-probe.md.';return} + $report=Invoke-WelaAppLockerScriptProbe -Action $AppLockerScriptAction -OutputPath $AppLockerScriptOutputPath -TimeoutSeconds $AppLockerScriptTimeoutSeconds + $report + if($report.ExitCode){exit $report.ExitCode} + } 'applocker-probe' { if ($Help) {Write-Host 'Usage: applocker-probe [-AppLockerProbeAction Plan|Run] [-AppLockerProbeOutputPath new-private-directory] [-AppLockerProbeTimeoutSeconds 1..30]. Requires existing EXE audit-only policy, running AppIDSvc and enabled channel. Run launches a fixed native cmd.exe copy and collects one exact AppLocker event. See docs/applocker-probe.md.';return} $report=Invoke-WelaAppLockerProbe -Action $AppLockerProbeAction -OutputPath $AppLockerProbeOutputPath -TimeoutSeconds $AppLockerProbeTimeoutSeconds diff --git a/scripts/AppLockerScriptNative.cs b/scripts/AppLockerScriptNative.cs new file mode 100644 index 00000000..6ba554b1 --- /dev/null +++ b/scripts/AppLockerScriptNative.cs @@ -0,0 +1,79 @@ +// Read-only process-token observations. No privilege or authorization changes. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +namespace Wela.AppLockerScript { + public sealed class Group { public string Sid; public uint Attributes; } + public sealed class Privilege { public string Luid; public uint Attributes; } + public sealed class Token { + public string Sid, Name, TokenId, ModifiedId, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource; + public Group[] Groups; public Privilege[] Privileges; + } + public static class Native { + public const string SourceSha256="__WELA_SOURCE_SHA256__"; + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;} + [StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;} + [StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;} + [StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;} + [StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed); + static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");} + static IntPtr Read(IntPtr token,int cls,out int length) { + GetTokenInformation(token,cls,IntPtr.Zero,0,out length); + if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information."); + IntPtr data=Marshal.AllocHGlobal(length); + if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);} + return data; + } + static Token ReadToken(IntPtr token,string source) { + Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();} + int length;IntPtr p=Read(token,10,out length); + try {if(length4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List groups=new List();for(int i=0;iString.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);} + p=Read(token,3,out length); + try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List privileges=new List();for(int i=0;iString.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);} + return result; + } + public static Token Child(IntPtr handle) { + IntPtr token=IntPtr.Zero; + if(!OpenProcessToken(handle,8,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {if(IsTokenRestricted(token))throw new InvalidOperationException("Restricted child token unsupported.");return ReadToken(token,"ChildProcess");}finally{CloseHandle(token);} + } + [DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token); + public static Token Snapshot() { + IntPtr thread=IntPtr.Zero,process=IntPtr.Zero; + if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated callers are unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {if(IsTokenRestricted(process))throw new InvalidOperationException("Restricted caller unsupported.");return ReadToken(process,"Process");}finally{CloseHandle(process);} + } + [StructLayout(LayoutKind.Sequential)] struct FileInformation { + public uint Attributes;public System.Runtime.InteropServices.ComTypes.FILETIME Creation,Access,Write; + public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow; + } + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr file,out FileInformation info); + public static string FileId(IntPtr handle) { + FileInformation info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + if((info.Attributes&0x410)!=0)throw new InvalidOperationException("One ordinary non-reparse file identity is required."); + return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8"); + } + public static async System.Threading.Tasks.Task ReadLineBoundedAsync(System.IO.StreamReader reader,int limit) { + char[] buffer=new char[1];System.Text.StringBuilder text=new System.Text.StringBuilder(); + while(true){int count=await reader.ReadAsync(buffer,0,1).ConfigureAwait(false);if(count==0)throw new InvalidOperationException("Owned child ended before its ready marker.");if(buffer[0]=='\n')return text.ToString().TrimEnd('\r');if(text.Length>=limit)throw new InvalidOperationException("Owned child line exceeds the bound.");text.Append(buffer[0]);} + } + public static async System.Threading.Tasks.Task ReadBoundedAsync(System.IO.StreamReader reader,int limit) { + char[] buffer=new char[256];System.Text.StringBuilder text=new System.Text.StringBuilder(); + while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(text.Length+count>limit)throw new InvalidOperationException("Owned child output exceeds the bound.");text.Append(buffer,0,count);} + } + } +} diff --git a/scripts/AppLockerScriptProbe.ps1 b/scripts/AppLockerScriptProbe.ps1 new file mode 100644 index 00000000..2925b0fa --- /dev/null +++ b/scripts/AppLockerScriptProbe.ps1 @@ -0,0 +1,206 @@ +# One fixed native Windows PowerShell script. Never prepares policy/services/channels. +function Get-WelaAppLockerScriptKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress } +function Get-WelaAppLockerScriptSources { + $sources=[ordered]@{} + foreach($path in @('WELA.ps1','scripts/AppLockerScriptProbe.ps1','scripts/AppLockerScriptNative.cs','scripts/AppLockerScriptWorker.ps1','scripts/AppLockerReadiness.ps1','scripts/WefArrival.ps1')) { + $sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Initialize-WelaAppLockerScriptNative { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'A native 64-bit Windows process is required.'} + $bytes=[IO.File]::ReadAllBytes((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptNative.cs')) + $hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.AppLockerScript.Native' -as [type])) { + $text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if(([regex]::Matches($text,'__WELA_SOURCE_SHA256__')).Count -ne 1){throw 'Unexpected native source fingerprint placeholder.'} + Add-Type -TypeDefinition $text.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop + } + if([Wela.AppLockerScript.Native]::SourceSha256 -cne $hash){throw 'Loaded helper differs from current source; start a fresh PowerShell process.'} +} +function Get-WelaAppLockerScriptReader { [Wela.AppLockerScript.Native]::Snapshot() } +function Get-WelaAppLockerScriptUtcNow { [Wela.AppLockerScript.Native]::UtcNow() } +function Get-WelaAppLockerScriptExecutionPolicy { + $values=[ordered]@{InheritedProcessValue=$env:PSExecutionPolicyPreference;Machine=@();User=@()} + foreach($scope in @('Machine','User')) { + $base=if($scope -eq 'Machine'){[Microsoft.Win32.Registry]::LocalMachine}else{[Microsoft.Win32.Registry]::CurrentUser} + foreach($path in @('SOFTWARE\Policies\Microsoft\Windows\PowerShell','SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell')) { + $key=$base.OpenSubKey($path,$false) + try { + foreach($name in @('EnableScripts','ExecutionPolicy')) { + $present=$null -ne $key -and $name -cin @($key.GetValueNames()) + $values[$scope]+=[pscustomobject]@{Path=$path;Name=$name;Present=[bool]$present;Kind=$(if($present){[string]$key.GetValueKind($name)}else{$null});Value=$(if($present){$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}else{$null})} + } + }finally{if($key){$key.Dispose()}} + } + } + [pscustomobject]$values +} +function Get-WelaAppLockerScriptState { + $hostState=Get-WelaAppLockerHost + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop + $machine=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script') + try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;LogFilePath=$channel.LogFilePath}}finally{$channel.Dispose()} + $source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe') + [pscustomobject][ordered]@{Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} +} +function Get-WelaAppLockerScriptStateKey { + param($State) + if($State.Host.Status -cne 'Candidate' -or $State.Host.Is64BitProcess -isnot [bool] -or -not $State.Host.Is64BitProcess -or $State.Host.ProductType -notin @(1,3) -or ($State.Host.ProductType -eq 1 -and $State.Host.Build -notin @(22000,22621,22631,26100,26200)) -or ($State.Host.ProductType -eq 3 -and $State.Host.Build -notin @(20348,26100))){throw 'A reviewed native Windows 11 or Server 2022/2025 member host is required; DCs are excluded.'} + foreach($policy in @($State.LocalPolicy,$State.EffectivePolicy)){if($policy.Status -cne 'Observed' -or $policy.Policy.HasUnknownPolicyData -isnot [bool] -or $policy.Policy.HasUnknownPolicyData){throw 'Local and effective GP policy must be readable and understood.'}} + $collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -CEQ 'Script') + if($collection.Count -ne 1 -or $collection[0].EnforcementMode -cne 'AuditOnly' -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'} + if($State.Service.Status -cne 'Observed' -or $State.Service.State -cne 'Running'){throw 'AppIDSvc must already be running.'} + if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or -not $State.Channel.SecurityDescriptor){throw 'The native MSI and Script channel must already be enabled and readable.'} + if($State.Management.Status -cne 'Observed' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or -not $State.MachineGuid -or -not $State.Computer){throw 'Incomplete management, machine or source observation.'} + Get-WelaAppLockerScriptKey $State +} +function Get-WelaAppLockerScriptAuthorizationKey { + param($Token) + if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $null -eq $Token.Groups -or $null -eq $Token.Privileges){throw 'Incomplete actual token evidence.'} + Get-WelaAppLockerScriptKey ([ordered]@{Sid=$Token.Sid;AuthenticationId=$Token.AuthenticationId;Groups=$Token.Groups;Privileges=$Token.Privileges}) +} +function New-WelaAppLockerScriptText { + param([string]$Template,[string]$Nonce) + if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or ([regex]::Matches($Template,'__WELA_SCRIPT_NONCE__')).Count -ne 3){throw 'Unexpected fixed worker template or nonce.'} + $Template.Replace('__WELA_SCRIPT_NONCE__',$Nonce) +} +function Read-WelaAppLockerScriptBoundary { + $reader=$null;$record=$null + try { + $query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$query.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1 + $record=$reader.ReadEvent([TimeSpan]::FromSeconds(5)) + $status=@($reader.LogStatus) + if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native channel query status.'} + if($null -eq $record){return [long]0} + if($record.LogName -cne $status[0].LogName -or $record.RecordId -le 0){throw 'Invalid native record boundary.'} + [long]$record.RecordId + }finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} +} +function Start-WelaAppLockerScriptProcess { + param([string]$Root,$State,$Reader,[string]$SourcesKey) + $nonce=[guid]::NewGuid().ToString('N');$path=Join-Path $Root ('wela-script-'+$nonce+'.ps1') + $template=[IO.File]::ReadAllText((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptWorker.ps1')) + $scriptBytes=[Text.UTF8Encoding]::new($false).GetBytes((New-WelaAppLockerScriptText $template $nonce)) + $artifact=Write-WelaArrivalArtifact $Root ([IO.Path]::GetFileName($path)) ([Text.UTF8Encoding]::new($false).GetString($scriptBytes)) + $source=$null;$scriptFile=$null;$process=$null;$started=$false + try { + $source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $scriptFile=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $sourceId=[Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle());$scriptId=[Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) + if((Get-FileHash -LiteralPath $State.Source -Algorithm SHA256).Hash.ToLowerInvariant() -cne $State.SourceHash -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'The held native source or generated script differs before launch.'} + if((Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $SourcesKey){throw 'Source changed before script launch.'} + $readerKey=Get-WelaAppLockerScriptKey $Reader + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed before launch.'} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Source;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$path+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardInput=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.WorkingDirectory=$Root + $process=[Diagnostics.Process]::new();$process.StartInfo=$info + $start=(Get-WelaAppLockerScriptUtcNow) + $started=$process.Start();if(-not $started){throw 'The fixed script process did not start.'} + $stderr=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardError,4096) + $ready=[Wela.AppLockerScript.Native]::ReadLineBoundedAsync($process.StandardOutput,256) + if(-not $ready.Wait(30000)){throw 'The fixed script did not reach its ready marker within thirty seconds.'} + $line=$ready.GetAwaiter().GetResult() + if($line -cnotmatch ('^WELA_SCRIPT_READY_'+$nonce+'\|(FullLanguage|ConstrainedLanguage)\|5\.1\.[0-9.]+$')){throw ('Unexpected fixed script ready marker: '+$line)} + $child=[Wela.AppLockerScript.Native]::Child($process.Handle) + if((Get-WelaAppLockerScriptAuthorizationKey $child) -cne (Get-WelaAppLockerScriptAuthorizationKey $Reader)){throw 'The actual child primary/logon authorization differs from the caller.'} + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed while the fixed child started.'} + $stdout=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardOutput,4096) + $process.StandardInput.WriteLine('WELA_SCRIPT_GO_'+$nonce);$process.StandardInput.Close() + if(-not $process.WaitForExit(10000)){throw 'The fixed child did not complete within ten seconds of release.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'The fixed child output did not complete within five seconds.'} + $out=$stdout.GetAwaiter().GetResult();$err=$stderr.GetAwaiter().GetResult();$end=(Get-WelaAppLockerScriptUtcNow) + if($process.ExitCode -ne 0 -or $out.TrimEnd("`r","`n") -cne ('WELA_SCRIPT_COMPLETE_'+$nonce) -or $err){throw ('The fixed script did not complete correctly. Exit='+$process.ExitCode+' Error='+$err)} + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey -or [Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle()) -cne $sourceId -or [Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) -cne $scriptId){throw 'Reader or held file identity changed during script execution.'} + [pscustomobject][ordered]@{ProcessId=$process.Id;UserSid=$Reader.Sid;ChildToken=$child;NativePowerShell=$State.Source;NativePowerShellSha256=$State.SourceHash;NativePowerShellFileId=$sourceId;ScriptPath=$path;ScriptSha256=$artifact.Sha256;ScriptFileId=$scriptId;ScriptArtifact=$artifact;Nonce=$nonce;Arguments=$info.Arguments;StartedUtc=$start.ToString('o');CompletedUtc=$end.ToString('o');Clock='GetSystemTimePreciseAsFileTime';Ready=$line;Marker=$out.TrimEnd("`r","`n");ExitCode=$process.ExitCode} + }finally{ + if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}} + if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()} + } +} +function Read-WelaAppLockerScriptEvents { + param([long]$Boundary) + $query="*[System[Provider[@Name='Microsoft-Windows-AppLocker'] and (EventID=8005 or EventID=8006) and EventRecordID>$Boundary]]" + $reader=$null;$record=$null;$xml=@();$bytes=0 + try { + $nativeQuery=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$nativeQuery.ReverseDirection=$false;$nativeQuery.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($nativeQuery);$reader.BatchSize=16 + while($null -ne ($record=$reader.ReadEvent([TimeSpan]::FromSeconds(2)))) { + try{$text=$record.ToXml();$bytes+=[Text.Encoding]::UTF8.GetByteCount($text);if($xml.Count -ge 255 -or $bytes -gt 1048576){throw 'Native script query reached its 256-event/one-MiB cap.'};$xml+=$text}finally{$record.Dispose();$record=$null} + } + $status=@($reader.LogStatus);if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native script query status.'} + [pscustomobject]@{Xml=$xml;Query=$query;Bytes=$bytes;Complete=$true} + }finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} +} + +function Test-WelaAppLockerScriptEvent { + param([string]$Xml,$Process,$State,[long]$Boundary) + $reader=$null + try { + $settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader) + $ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$ns.AddNamespace('a','http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0') + if ($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData/a:RuleAndFileData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count) {return $false} + $system=@{};foreach ($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated')) {$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if ($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-AppLocker' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine 'cbda4dbf-8d5d-4f69-9578-be14aa540d22' -or $system.EventID.InnerText -cnotin @('8005','8006') -or $system.Version.InnerText -cne '0' -or $system.EventRecordID.InnerText -notmatch '^[1-9][0-9]*$' -or $system.Channel.InnerText -cne 'Microsoft-Windows-AppLocker/MSI and Script') {return $false} + $computers=@($State.Computer);if($State.Host.PartOfDomain){$computers+=$State.Computer+'.'+$State.Domain};if($system.Computer.InnerText -notin $computers){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime') + if($time.UtcDateTime -lt ([DateTimeOffset]::Parse($Process.StartedUtc)).UtcDateTime -or $time.UtcDateTime -gt (ConvertTo-WelaArrivalUtc $Process.CompletedUtc).UtcDateTime -or [long]$system.EventRecordID.InnerText -le $Boundary){return $false} + $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:UserData/a:RuleAndFileData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns.LookupNamespace('a') -or $data.ContainsKey($node.LocalName) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$node.LocalName]=$node.InnerText} + if($data.PolicyName -cne 'SCRIPT' -or $data.TargetUser -cne $Process.UserSid -or $data.TargetProcessId -notmatch '^[1-9][0-9]*$' -or [long]$data.TargetProcessId -ne $Process.ProcessId){return $false} + # AppLocker may render the exact Windows directory through this documented path variable. + $eventPath=$data.FilePath + if($eventPath -imatch '^%OSDRIVE%\\'){$eventPath=[IO.Path]::GetPathRoot($State.Source).TrimEnd('\')+$eventPath.Substring(9)} + return $eventPath -ieq $Process.ScriptPath + } catch {return $false} finally {if($reader){$reader.Dispose()}} +} +function Invoke-WelaAppLockerScriptProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new AppLockerScriptOutputPath; Plan does not write files.'} + Initialize-WelaAppLockerScriptNative + $sources=Get-WelaAppLockerScriptSources;$sourceKey=Get-WelaAppLockerScriptKey $sources + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAppLockerScriptProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=(Get-WelaAppLockerScriptUtcNow).ToString('o');Sources=$sources;Before=$null;After=$null;ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After output/context preparation, through child execution and actual event queries; final metadata is checked separately';Boundary=$null;Process=$null;EventId=$null;Decision=$null;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;ReadyRuleCredit=0;CspPolicyState='Unknown';Scope='One fixed native Windows PowerShell5.1 Script-collection event. Other engines, collections, forwarding and Sigma/backend validation are not tested. Sysmon excluded.'} + try { + $before=Get-WelaAppLockerScriptState;$report.Before=$before;$key=Get-WelaAppLockerScriptStateKey $before + if($Action -eq 'Plan'){$report.ReaderBefore=(Get-WelaAppLockerScriptReader);$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.OutputPath=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot + if((Get-WelaAppLockerScriptStateKey (Get-WelaAppLockerScriptState)) -cne $key){throw 'Context changed during output preparation.'} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24) + $reader=(Get-WelaAppLockerScriptReader);$report.ReaderBefore=$reader;$readerKey=Get-WelaAppLockerScriptKey $reader + $report.Boundary=Read-WelaAppLockerScriptBoundary + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during the actual boundary query.'} + $process=Start-WelaAppLockerScriptProcess -Root $report.OutputPath -State $before -Reader $reader -SourcesKey $sourceKey;$report.Process=$process + $report.Artifacts+= $process.ScriptArtifact + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'process.json' ($process|ConvertTo-Json -Depth 16) + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@();$batch=$null + do { + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed before script event query.'} + $batch=Read-WelaAppLockerScriptEvents $report.Boundary + if($batch.Complete -isnot [bool] -or -not $batch.Complete){throw 'Script query completeness is unknown.'} + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during script event query.'} + $matches=@($batch.Xml|Where-Object{Test-WelaAppLockerScriptEvent $_ $process $before $report.Boundary}) + if($matches.Count -gt 1){throw 'Multiple exact script records make the result ambiguous.'} + if($matches.Count -eq 1){break} + Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.ReaderAfter=(Get-WelaAppLockerScriptReader) + if((Get-WelaAppLockerScriptKey $report.ReaderAfter) -cne $readerKey){throw 'Reader changed before completion of the actual query interval.'} + if($matches.Count -ne 1){ + # Retain only bounded candidates bearing the owned unique script name. + $owned=@($batch.Xml|Where-Object{$_ -like ('*wela-script-'+$process.Nonce+'.ps1*')}|Select-Object -First 4) + for($i=0;$i -lt $owned.Count;$i++){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $owned[$i]} + throw 'No exact native Script8005/8006 event arrived within the timeout.' + } + $report.After=Get-WelaAppLockerScriptState + if((Get-WelaAppLockerScriptStateKey $report.After) -cne $key -or (Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $sourceKey -or (Get-FileHash -LiteralPath $process.ScriptPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $process.ScriptSha256){throw 'Host, policy, service, channel, execution-policy observation or implementation changed.'} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 24) + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0] + $event=[xml]$matches[0];$report.EventId=[int]$event.Event.System.EventID + $report.Decision=if($report.EventId -eq 8005){'Allowed'}else{'AllowedWouldBlockIfEnforced'} + $report.Status='NativeScriptEventObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + if($report.OutputPath){$json=$report|ConvertTo-Json -Depth 32;if([Text.Encoding]::UTF8.GetByteCount($json) -gt 2097152){throw 'The script probe report exceeded its two-MiB bound.'};$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' $json} + $report +} diff --git a/scripts/AppLockerScriptWorker.ps1 b/scripts/AppLockerScriptWorker.ps1 new file mode 100644 index 00000000..2ebb46fe --- /dev/null +++ b/scripts/AppLockerScriptWorker.ps1 @@ -0,0 +1,7 @@ +# Fixed locally generated script; no external inputs or configuration writes. +$ErrorActionPreference = 'Stop' +[Console]::Out.WriteLine(('WELA_SCRIPT_READY___WELA_SCRIPT_NONCE__|' + $ExecutionContext.SessionState.LanguageMode + '|' + $PSVersionTable.PSVersion)) +$release = [Console]::In.ReadLine() +if ($release -cne 'WELA_SCRIPT_GO___WELA_SCRIPT_NONCE__') { exit 17 } +[Console]::Out.WriteLine('WELA_SCRIPT_COMPLETE___WELA_SCRIPT_NONCE__') +exit 0 diff --git a/tests/AppLockerScriptProbe.Cli.Tests.ps1 b/tests/AppLockerScriptProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..fc7a575d --- /dev/null +++ b/tests/AppLockerScriptProbe.Cli.Tests.ps1 @@ -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." diff --git a/tests/AppLockerScriptProbe.Tests.ps1 b/tests/AppLockerScriptProbe.Tests.ps1 new file mode 100644 index 00000000..971e915d --- /dev/null +++ b/tests/AppLockerScriptProbe.Tests.ps1 @@ -0,0 +1,67 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +. "$repo/scripts/AppLockerReadiness.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/AppLockerScriptProbe.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 $Pattern; got $message"} +$policy='' +$state=[pscustomobject]@{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($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='8006042Microsoft-Windows-AppLocker/MSI and ScriptTESTSCRIPTS-1-5-21-1-2-3-10001234C:\Temp\wela-owned.ps1' +$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'),@('SCRIPT','DLL'),@('TEST','OTHER'),@('cbda4dbf','abda4dbf'),@('0','1'),@('00:00:01.0000000Z','00:00:03.0000000Z'),@('','8006'),@('','S-1-1-0')) +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 (']>'+$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." diff --git a/tests/AppLockerScriptProbe.Windows.Tests.ps1 b/tests/AppLockerScriptProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..8f0eaef4 --- /dev/null +++ b/tests/AppLockerScriptProbe.Windows.Tests.ps1 @@ -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='' +$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 From b018135e7317f279e53ce27b725222e0b4011408 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:50 +0900 Subject: [PATCH 04/21] Use canonical WSMan listener resource URI across PowerShell engines --- tests/WecListener.Checkpoint.Windows.Tests.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/WecListener.Checkpoint.Windows.Tests.ps1 b/tests/WecListener.Checkpoint.Windows.Tests.ps1 index f357c63d..b05d45b8 100644 --- a/tests/WecListener.Checkpoint.Windows.Tests.ps1 +++ b/tests/WecListener.Checkpoint.Windows.Tests.ps1 @@ -10,7 +10,7 @@ Import-Module "$repo/modules/WefSubscriptions.psm1" -Force $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 'winrm/config/Listener' -Enumerate -ErrorAction Stop|ForEach-Object { + @(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) } @@ -27,7 +27,7 @@ try { # Replacement is a fixture-only, disposable-VM operation. Product must refuse overlaps. foreach($listener in @($original|Where-Object Transport -eq 'HTTP')){ Assert ($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 'winrm/config/Listener' -SelectorSet @{Address=$listener.Address;Transport='HTTP'} -ErrorAction Stop + 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 @@ -35,22 +35,22 @@ try { $values=@{Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''} Save 'selection.json' @{Selector=$selector;Values=$values} $created=$true - $result=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'winrm/config/Listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop + $result=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop Save 'native-create.json' @{Xml=$result.OuterXml} $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=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'winrm/config/Listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop}catch{$duplicateRejected=$true;$duplicateError=$_.ToString()} + try {$null=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop}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 'winrm/config/Listener' -SelectorSet $selector -ErrorAction Stop}catch{$cleanupErrors+=$_.ToString()}} - foreach($listener in $removed){try {$null=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'winrm/config/Listener' -SelectorSet @{Address=$listener.Address;Transport=$listener.Transport} -ValueSet @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint} -ErrorAction Stop}catch{$cleanupErrors+=$_.ToString()}} + 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=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$listener.Address;Transport=$listener.Transport} -ValueSet @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint} -ErrorAction Stop}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()} From 3919ba5d4a54cfcd894266f4c72418b9f9271109 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:25:02 +0900 Subject: [PATCH 05/21] Document supported CLI preview options --- docs/configuration-results.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration-results.md b/docs/configuration-results.md index a3551c61..0f1575c8 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -18,6 +18,8 @@ or result-file error also exits with status 1. .\WELA.ps1 configure -Auto -ResultsPath .\results.json ``` +Unknown named options and other arguments left unbound by PowerShell are rejected before command dispatch. This includes unsupported `-WhatIf`, `-Confirm` and misspelled `-DryRun` options, even with `-Auto`. Use each command's `-Help` for its supported preview options; `-DryRun` is accepted only where documented. Valid positional binding and PowerShell's unambiguous parameter abbreviations remain supported. + Keep the complete WELA directory, including `scripts/Configuration.ps1`. Choose a recovery path whose parent directory is writable only by the operators who manage these settings. The backup directory must not already exist. Without `-BackupPath`, From ef64b08bd2cb33b932f3dc59db394f8d8582520c Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:26:34 +0900 Subject: [PATCH 06/21] Document Script evidence boundaries and retain bounded startup diagnostics --- .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 ++ CHANGELOG.md | 2 ++ docs/applocker-probe.md | 2 +- docs/applocker-script-probe.md | 33 ++++++++++++++++++++++++++ scripts/AppLockerScriptProbe.ps1 | 10 +++++--- website/docs/resources/changelog.ja.md | 2 ++ website/docs/resources/changelog.md | 2 ++ 8 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 docs/applocker-script-probe.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c74297b3..472b3159 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index c85e4a21..9ba629c1 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) + - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) - `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2933c992..e1d6184b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) + - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) - Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security) diff --git a/docs/applocker-probe.md b/docs/applocker-probe.md index ef99242f..431d5174 100644 --- a/docs/applocker-probe.md +++ b/docs/applocker-probe.md @@ -13,7 +13,7 @@ Run copies native System32 `cmd.exe` into the protected output directory with a A bounded query requires exactly one native AppLocker 8002 (allowed) or 8003 (allowed, would block under enforcement) with the expected provider, version, computer, EXE collection, actual user SID, owned process ID, exact file path and time window. The report retains the distinct event ID; 8002 does not demonstrate a would-block decision. Policy, service, channel, reader, host and executable bytes are checked before and after. Denied, absent, capped, duplicate or drifted results fail with a retained diagnostic. Component hashes establish consistency, not authenticity or a signature. -`NativeExeEventObserved` proves only this event in this local channel at this time. It grants **zero Sigma readiness credit**. Scripts, MSI, DLL, packaged applications, forwarding, translated queries and backend matches require separate evidence. Client builds and managed/CSP deployments require lab acceptance beyond hosted-server CI. +`NativeExeEventObserved` proves only this event in this local channel at this time. It grants **zero Sigma readiness credit**. The [separate Script probe](applocker-script-probe.md) collects Windows PowerShell5.1 Script8005/8006 evidence. MSI, DLL, packaged applications, forwarding, translated queries and backend matches require separate evidence. Client builds and managed/CSP deployments require lab acceptance beyond hosted-server CI. The Windows test explicitly opts into temporary changes on disposable GitHub-hosted Server 2022/2025 VMs under Windows PowerShell 5.1 and PowerShell 7. It accepts only a non-domain host with initially empty, understood local/effective GP policies, prepares one AuditOnly policy through the native cmdlet in the test fixture, temporarily enables/runs the existing verified native PolicyConverter task when disabled, runs a bounded computer Group Policy refresh and requires native 8001 policy-application evidence followed by a real 8003. Hosted images can contain enrollment/provider keys; these are recorded and preserved, and CSP policy remains Unknown. This fixture tests the probe, not production importer acceptance: the production importer continues to block observed management entries. It restores and refreshes the original local policy, verifies both local and effective GP snapshots, and restores channel enablement and the exact PolicyConverter task definition/enabled setting with no task invocation left running or queued, and leaves service startup mode untouched. If Windows refuses to stop its protected AppIDSvc, the test records that running-state boundary and relies on disposal of the VM; it does not claim service-state rollback. Never run that fixture on a production host. diff --git a/docs/applocker-script-probe.md b/docs/applocker-script-probe.md new file mode 100644 index 00000000..33311d7d --- /dev/null +++ b/docs/applocker-script-probe.md @@ -0,0 +1,33 @@ +# Native AppLocker Script probe + +Related to #381. `applocker-script-probe` runs one fixed, locally generated `.ps1` file through native 64-bit Windows PowerShell 5.1 and looks for its actual AppLocker Script-collection decision. WELA itself can run in Windows PowerShell 5.1 or PowerShell 7. This command adds no AppLocker policy, starts no service, changes no execution policy or channel, and grants no Sigma readiness credit. Sysmon is out of scope. + +```powershell +.\WELA.ps1 applocker-script-probe +.\WELA.ps1 applocker-script-probe -AppLockerScriptAction Run -AppLockerScriptOutputPath C:\Evidence\new-script-probe -AppLockerScriptTimeoutSeconds 30 +``` + +Plan reads prerequisites without launching a child or writing files. Run requires a new private directory on a local fixed drive, with an existing parent. Only reviewed Windows 11 builds and Server 2022/2025 member hosts are accepted; domain controllers are excluded. Client and managed-environment acceptance remains separate from hosted-server CI. + +The effective Group Policy Script collection must already contain rules in `AuditOnly` mode. Local and effective GP policy, management observations, AppIDSvc state and MSI and Script channel configuration must be readable. AppIDSvc must already run and the channel must already be enabled. AppLocker CSP policy remains **Unknown**: the native GP cmdlets do not enumerate that authority. Existing enforcement in other collections is preserved and can prevent the fixed native host from starting. + +Run creates only the reviewed worker template with a fresh filename and nonce. It launches System32's `WindowsPowerShell\v1.0\powershell.exe` with `-NoLogo -NoProfile -NonInteractive -File`; there is no operator-supplied command, profile loading or execution-policy override. The existing execution policy must permit that locally generated unsigned file. For example, Restricted or AllSigned may prevent completion; that is an unverified result. The report records existing native execution-policy registry values and the inherited process preference, without equating these observations to all application-control authorities. + +The worker emits its fixed ready marker, actual Windows PowerShell 5.1 version and language mode, waits for its fixed release marker, emits completion and exits. Before releasing it, WELA observes the real child primary token and compares its user, logon LUID, group attributes and privilege attributes with the actual current caller. Impersonated or restricted callers are refused. Caller token identity and modification state are checked throughout child execution and event queries. Metadata and output preparation precede that interval; final configuration observations are checked separately. + +Native PowerShell and generated script files are held read-locked during execution, with SHA256 and volume/file identity checks. Source fingerprints bind the compiled helper to its exact source bytes and are rechecked before launch and after collection. These are consistency observations, not a signature or protection from a local administrator. The generated file is retained with the evidence. + +The query starts from an actual current record boundary in `Microsoft-Windows-AppLocker/MSI and Script`. A match requires the reviewed provider GUID, event version, channel, computer, Script collection, actual user SID, child PID, exact unique script path and a native precise-UTC timestamp within the actual process interval. It accepts exactly one of these separate outcomes: + +| Event | Report decision | Meaning | +|---|---|---| +| 8005 | `Allowed` | A Script rule allowed this file. | +| 8006 | `AllowedWouldBlockIfEnforced` | The audit-only Script policy would block this file if enforced. | + +8005 does not prove a would-block decision. 8007, MSI events sharing the channel, other processes, older records, stale paths, duplicates, unknown versions and timestamps outside the exact interval are rejected. Missing, denied, incomplete, capped or drifted results remain `Unverified` with a nonzero exit code. The bounded query refuses its 256-event or one-MiB cap; only matched XML or at most four candidates containing the owned filename are exported. Child startup is bounded to thirty seconds, completion to ten seconds after release, output drain to five seconds and event polling to the selected 1–30 seconds; individual native event reads have finite timeouts. Owned child termination is attempted and checked on failure. Raw process output is bounded. + +`NativeScriptEventObserved` means only that this one local Windows PowerShell 5.1 script generated the retained event under the observed context. It does not prove PowerShell 7 script behavior, other Script formats, MSI/DLL/packaged-app coverage, enforcement behavior, forwarding or backend rule matches. The [separate EXE probe](applocker-probe.md) covers EXE events. + +The dedicated Windows workflow requires explicit opt-in on disposable non-domain GitHub-hosted Server 2022/2025 VMs, each under both WELA host engines. It requires initially empty and understood local/effective GP policies. The fixture prepares one Script AuditOnly policy at a time, invokes the verified native PolicyConverter task and a bounded computer-policy refresh, then requires genuine public-command 8006 and 8005 records, source/receipt hashes and unchanged product context. Mocked fixtures never substitute for those events. It restores original local/effective GP policy, channel enablement and the exact PolicyConverter task definition/enabled state and preserves service startup mode. If Windows refuses to stop protected AppIDSvc, the cleanup receipt explicitly records the remaining running state and relies on disposal of that VM; it does not claim full service-state restoration. This fixture must not be run on production hosts. + +Microsoft references: [Script rule formats and host enforcement semantics](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/script-rules-in-applocker), [AppLocker event IDs](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/using-event-viewer-with-applocker), [native policy refresh and verification](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/refresh-an-applocker-policy), [Application Identity service](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/configure-the-application-identity-service). diff --git a/scripts/AppLockerScriptProbe.ps1 b/scripts/AppLockerScriptProbe.ps1 index 2925b0fa..6b03283b 100644 --- a/scripts/AppLockerScriptProbe.ps1 +++ b/scripts/AppLockerScriptProbe.ps1 @@ -86,7 +86,7 @@ function Start-WelaAppLockerScriptProcess { $template=[IO.File]::ReadAllText((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptWorker.ps1')) $scriptBytes=[Text.UTF8Encoding]::new($false).GetBytes((New-WelaAppLockerScriptText $template $nonce)) $artifact=Write-WelaArrivalArtifact $Root ([IO.Path]::GetFileName($path)) ([Text.UTF8Encoding]::new($false).GetString($scriptBytes)) - $source=$null;$scriptFile=$null;$process=$null;$started=$false + $source=$null;$scriptFile=$null;$process=$null;$started=$false;$stderr=$null try { $source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) $scriptFile=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) @@ -115,9 +115,13 @@ function Start-WelaAppLockerScriptProcess { if($process.ExitCode -ne 0 -or $out.TrimEnd("`r","`n") -cne ('WELA_SCRIPT_COMPLETE_'+$nonce) -or $err){throw ('The fixed script did not complete correctly. Exit='+$process.ExitCode+' Error='+$err)} if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey -or [Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle()) -cne $sourceId -or [Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) -cne $scriptId){throw 'Reader or held file identity changed during script execution.'} [pscustomobject][ordered]@{ProcessId=$process.Id;UserSid=$Reader.Sid;ChildToken=$child;NativePowerShell=$State.Source;NativePowerShellSha256=$State.SourceHash;NativePowerShellFileId=$sourceId;ScriptPath=$path;ScriptSha256=$artifact.Sha256;ScriptFileId=$scriptId;ScriptArtifact=$artifact;Nonce=$nonce;Arguments=$info.Arguments;StartedUtc=$start.ToString('o');CompletedUtc=$end.ToString('o');Clock='GetSystemTimePreciseAsFileTime';Ready=$line;Marker=$out.TrimEnd("`r","`n");ExitCode=$process.ExitCode} + }catch{ + $message=$_.Exception.Message + if($stderr -and $stderr.Status -eq [Threading.Tasks.TaskStatus]::RanToCompletion){$message+=' Native stderr: '+$stderr.GetAwaiter().GetResult()} + throw $message }finally{ - if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}} - if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()} + try{if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}}} + finally{if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()}} } } function Read-WelaAppLockerScriptEvents { diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 0857b29e..fad42c8a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) + - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) - `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index a4d80dc1..da5c9d15 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) + - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) - Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security) From 25fa7200a022bc0e98cba7988b31a493e3e7e080 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:27:05 +0900 Subject: [PATCH 07/21] Checkpoint native local WSMan create with fixed listener XML --- .../WecListener.Checkpoint.Windows.Tests.ps1 | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/WecListener.Checkpoint.Windows.Tests.ps1 b/tests/WecListener.Checkpoint.Windows.Tests.ps1 index b05d45b8..a38f5bb9 100644 --- a/tests/WecListener.Checkpoint.Windows.Tests.ps1 +++ b/tests/WecListener.Checkpoint.Windows.Tests.ps1 @@ -17,6 +17,18 @@ function ReadListeners { 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)} +function NewCheckpointListener($Selector,$Values) { + $automation=$null;$session=$null;$locator=$null + try { + $automation=New-Object -ComObject 'WSMan.Automation' + $session=$automation.CreateSession('',0,$null);$session.Timeout=5000 + $locator=$automation.CreateResourceLocator('http://schemas.microsoft.com/wbem/wsman/1/config/listener') + $locator.AddSelector('Address',[string]$Selector.Address);$locator.AddSelector('Transport',[string]$Selector.Transport) + $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)} + [string]$session.Create($locator,$doc.OuterXml,0) + }finally{foreach($item in @($locator,$session,$automation)){if($null -ne $item -and [Runtime.InteropServices.Marshal]::IsComObject($item)){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($item)}}} +} $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 { @@ -35,14 +47,14 @@ try { $values=@{Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''} Save 'selection.json' @{Selector=$selector;Values=$values} $created=$true - $result=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop - Save 'native-create.json' @{Xml=$result.OuterXml} + $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=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $selector -ValueSet $values -ErrorAction Stop}catch{$duplicateRejected=$true;$duplicateError=$_.ToString()} + 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')})) @@ -50,7 +62,7 @@ try { 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=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=$listener.Address;Transport=$listener.Transport} -ValueSet @{Port=$listener.Port;Hostname=$listener.Hostname;Enabled=$listener.Enabled;URLPrefix=$listener.URLPrefix;CertificateThumbprint=$listener.CertificateThumbprint} -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()} From a61181e86094f9c96dee81ce7b6569b776b9db2a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:27:26 +0900 Subject: [PATCH 08/21] Checkpoint exact native one-byte file access probe --- .github/workflows/file-access-probe.yml | 43 +++++ WELA.ps1 | 14 ++ scripts/FileAccessProbe.ps1 | 214 ++++++++++++++++++++++++ scripts/FileAccessProbeNative.cs | 104 ++++++++++++ scripts/FileAccessProbeWorker.ps1 | 22 +++ tests/FileAccessProbe.Cli.Tests.ps1 | 14 ++ tests/FileAccessProbe.Windows.Tests.ps1 | 62 +++++++ 7 files changed, 473 insertions(+) create mode 100644 .github/workflows/file-access-probe.yml create mode 100644 scripts/FileAccessProbe.ps1 create mode 100644 scripts/FileAccessProbeNative.cs create mode 100644 scripts/FileAccessProbeWorker.ps1 create mode 100644 tests/FileAccessProbe.Cli.Tests.ps1 create mode 100644 tests/FileAccessProbe.Windows.Tests.ps1 diff --git a/.github/workflows/file-access-probe.yml b/.github/workflows/file-access-probe.yml new file mode 100644 index 00000000..1a67ca95 --- /dev/null +++ b/.github/workflows/file-access-probe.yml @@ -0,0 +1,43 @@ +name: Native one-byte file access probe +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/FileAccessProbe*' + - 'tests/FileAccessProbe*' + - '.github/workflows/file-access-probe.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + file-access-probe: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Public CLI and actual file read in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/FileAccessProbe.Cli.Tests.ps1 + ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Public CLI and actual file read in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/FileAccessProbe.Cli.Tests.ps1 + ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain genuine XML, receipts and exact cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: file-access-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-file-access-*/ + if-no-files-found: error diff --git a/WELA.ps1 b/WELA.ps1 index 26475edf..41293d60 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -48,6 +48,10 @@ [string]$WmiProbeNamespace, [string]$WmiProbeOutputPath, [ValidateRange(1,30)][int]$WmiProbeTimeoutSeconds = 15, + [ValidateSet('Plan','Run')][string]$FileProbeAction = 'Plan', + [string]$FileProbePath, + [string]$FileProbeOutputPath, + [ValidateRange(1,30)][int]$FileProbeTimeoutSeconds = 15, [string[]]$WmiNamespace, [switch]$WmiIncludeChildren, [string]$RuleEvidencePath, @@ -184,6 +188,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") +. (Join-Path $ScriptRoot "scripts/FileAccessProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop @@ -1952,6 +1957,7 @@ Usage: ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 file-access-probe -Help ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml @@ -2052,6 +2058,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ throw 'intune-export accepts only Intune target/export options, IncludeOptional and Help. No command was run.' } +if ($Cmd -ne 'file-access-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FileProbe*'}).Count) {throw 'FileProbe options require file-access-probe.'} +if ($Cmd -eq 'file-access-probe' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileProbeAction','FileProbePath','FileProbeOutputPath','FileProbeTimeoutSeconds','Help')}).Count)) {throw 'file-access-probe accepts only its dedicated options.'} if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'} if ($Cmd -eq 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EvtxAction','EvtxProbePath','EvtxArchivePath','EvtxOutputPath','Help')}).Count) {throw 'evtx-recovery accepts only its dedicated options. No command was run.'} if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'} @@ -2264,6 +2272,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'file-access-probe' { + if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} + $report=Invoke-WelaFileAccessProbe -Action $FileProbeAction -FilePath $FileProbePath -OutputPath $FileProbeOutputPath -TimeoutSeconds $FileProbeTimeoutSeconds + $report|ConvertTo-Json -Depth 28|Write-Output + exit ([int]$report.ExitCode) + } 'audit-recovery' { if ($Help) {Write-Host 'Usage: audit-recovery [-RecoveryAction Plan] -RecoveryJournalPath before.jsonl -RecoveryOriginalResultsPath results.json -RecoveryControlId IDs -RecoveryOutputPath new-directory; then -RecoveryAction Restore -RecoveryPlanPath reviewed-plan.json -RecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/audit-recovery.md.';return} $report=Invoke-WelaAuditRecovery -Action $RecoveryAction -JournalPath $RecoveryJournalPath -OriginalResultsPath $RecoveryOriginalResultsPath -ControlId $RecoveryControlId -PlanPath $RecoveryPlanPath -OutputPath $RecoveryOutputPath -Auto:$Auto -DryRun:$DryRun diff --git a/scripts/FileAccessProbe.ps1 b/scripts/FileAccessProbe.ps1 new file mode 100644 index 00000000..6f99fdf3 --- /dev/null +++ b/scripts/FileAccessProbe.ps1 @@ -0,0 +1,214 @@ +# Explicit one-byte existing-file read and exact local Security4663 evidence. +function Initialize-WelaFileProbeNative { + Initialize-WelaWmiProbeNative + $source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$hash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){Add-Type -Path $source -ErrorAction Stop;$script:WelaFileProbeNativeHash=$hash} + if($script:WelaFileProbeNativeHash -cne $hash){throw 'Loaded file probe helper differs from its source; start a fresh session.'} +} +function Test-WelaFileProbeInteger {param($Value) ($Value -is [int] -or $Value -is [long] -or $Value -is [uint32] -or $Value -is [uint64])} +function Assert-WelaFileProbePath { + param([string]$Path) + if(-not $Path -or $Path.Length -gt 240 -or $Path -cnotmatch '^[A-Za-z]:\\' -or $Path.Substring(2).Contains(':') -or $Path -match '["*?<>|/\x00-\x1f]|(^|\\)\.\.?($|\\)|[ .](\\|$)|\\$|\\\\'){throw 'Select one exact ordinary absolute local leaf file, at most 240 characters; links, streams, wildcards and remote paths are unsupported.'} +} +function Get-WelaFileProbeSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/FileAccessProbe.ps1','scripts/FileAccessProbeWorker.ps1','scripts/FileAccessProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/Configuration.ps1','scripts/CustomAuditProfiles.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Get-WelaFileProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Get-WelaFileProbeTokenKey { + param($Token) + foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Incomplete typed file-reader token.'}} + if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $Token.TokenSource -cne 'Process' -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'An ordinary native primary-token file reader is required.'} + foreach($group in $Token.Groups){if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or -not(Test-WelaFileProbeInteger $group.Attributes)){throw 'Incomplete typed file-reader group.'}} + foreach($privilege in $Token.Privileges){if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$' -or -not(Test-WelaFileProbeInteger $privilege.Attributes)){throw 'Incomplete typed file-reader privilege.'}} + Get-WelaFileProbeKey $Token +} +function Get-WelaFileProbeReaderKey { + param($Reader,[switch]$AuthorizationOnly) + foreach($name in @('UserSid','UserName','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Reader.$name -isnot [string]){throw 'Incomplete typed reader observation.'}} + if($Reader.TokenType -cne 'Primary' -or $Reader.Impersonation -cne 'Absent' -or $Reader.ElevatedAdministrator -isnot [bool] -or -not $Reader.ElevatedAdministrator){throw 'An elevated primary-token reader with no impersonation is required.'} + if($AuthorizationOnly){Get-WelaFileProbeKey ($Reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation)} + else{Get-WelaFileProbeKey $Reader} +} +function Assert-WelaFileProbeSnapshot { + param($Snapshot) + foreach($name in @('Path','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}} + Assert-WelaFileProbePath $Snapshot.Path + if($Snapshot.StateKey -cnotmatch '^[a-f0-9]{64}$' -or -not(Test-WelaFileProbeInteger $Snapshot.Size) -or $Snapshot.Size -le 0 -or -not(Test-WelaFileProbeInteger $Snapshot.SecurityInformation) -or $Snapshot.SecurityInformation -ne 511 -or -not(Test-WelaFileProbeInteger $Snapshot.Links) -or $Snapshot.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Snapshot.Attributes) -or ($Snapshot.Attributes -band (16+1024+4096+16384+262144+4194304))){throw 'Only a complete nonempty ordinary single-link leaf-file observation is supported.'} + $Snapshot.LastWriteUtc=(ConvertTo-WelaArrivalUtc $Snapshot.LastWriteUtc).UtcDateTime.ToString('o') + if($Snapshot.Aces -isnot [array] -or $Snapshot.Aces.Count -gt 128){throw 'Missing or oversized file audit ACE inventory.'} + foreach($ace in $Snapshot.Aces){ + if($ace.Ordinary -isnot [bool] -or -not(Test-WelaFileProbeInteger $ace.Type) -or -not(Test-WelaFileProbeInteger $ace.Flags) -or -not(Test-WelaFileProbeInteger $ace.Mask) -or $ace.Binary -isnot [string]){throw 'Incomplete typed file audit ACE.'} + if($ace.Ordinary -and ($ace.Type -ne 2 -or $ace.Sid -isnot [string] -or $ace.Sid -cnotmatch '^S-1-\d+(-\d+)+$')){throw 'Incomplete ordinary file audit ACE.'} + } +} +function Get-WelaFileProbeSnapshot { + param([string]$Path) + Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative + $handle=[Wela.FileAccessProbe.FileHandle]::new($Path,$false) + try{$handle.Observe()}finally{$handle.Dispose()} +} +function Get-WelaFileProbeState { + param([string]$Path) + Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative + $services=@(Get-Service -Name EventLog,Winmgmt,RpcSs -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'EventLog, Winmgmt and RpcSs must already be running.'} + $null=Get-WelaFileProbeReaderKey (Get-WelaChannelReader) + $tokenBefore=[Wela.WmiProbe.Native]::Snapshot();$snapshot=Get-WelaFileProbeSnapshot $Path + $hostState=Get-WelaChannelReadHost + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security') + try{$log=[pscustomobject]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()} + $policy=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($guid in @($policy.Keys|Sort-Object)){$masks[$guid]=$policy[$guid]} + $engine=(Get-Process -Id $PID -ErrorAction Stop).Path + $reader=Get-WelaChannelReader;$token=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaFileProbeTokenKey $tokenBefore) -cne (Get-WelaFileProbeTokenKey $token)){throw 'File prerequisite observation changed token groups or privileges.'} + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Services=$services;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=$token;File=$snapshot;AuditPolicies=[pscustomobject]$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaFileProbeSources)} +} +function Get-WelaFileProbeStateKey { + param($State) + Assert-WelaFileProbeSnapshot $State.File;$null=Get-WelaFileProbeTokenKey $State.Token + if($State.Computer -isnot [string] -or -not $State.Computer -or $State.MachineGuid -isnot [string] -or $State.MachineGuid -cnotmatch '^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$' -or -not(Test-WelaFileProbeInteger $State.Host.ProductType) -or $State.Host.ProductType -notin @(1,2,3) -or -not(Test-WelaFileProbeInteger $State.Host.Build) -or $State.Host.Build -notin @(22000,22621,22631,20348,26100,26200) -or $State.Host.DomainJoined -isnot [bool]){throw 'Complete actual supported Windows host identity is required.'} + if($State.Services -isnot [array] -or $State.Services.Count -ne 3 -or (@($State.Services.Name)-join ',') -cne 'EventLog,RpcSs,Winmgmt'){throw 'Complete native service observations are required.'} + foreach($service in $State.Services){if($service.Status -isnot [string] -or $service.Status -cne 'Running'){throw 'Required services must already be running.'}} + $mask=$State.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030' + if(-not(Test-WelaFileProbeInteger $mask) -or $mask -notin @(1,3) -or $State.Precedence.ValueExists -isnot [bool] -or -not $State.Precedence.ValueExists -or $State.Precedence.Type -isnot [string] -or $State.Precedence.Type -cne 'DWord' -or -not(Test-WelaFileProbeInteger $State.Precedence.Value) -or $State.Precedence.Value -ne 1){throw 'File System success auditing and typed audit precedence DWORD1 must already be configured.'} + if($State.Channel.Name -isnot [string] -or $State.Channel.Name -cne 'Security' -or $State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.SecurityDescriptor -isnot [string] -or -not $State.Channel.SecurityDescriptor){throw 'The Security channel must already be enabled with readable configuration.'} + if($State.Reader.TokenType -isnot [string] -or $State.Reader.TokenType -cne 'Primary' -or $State.Reader.Impersonation -isnot [string] -or $State.Reader.Impersonation -cne 'Absent' -or $State.Reader.ElevatedAdministrator -isnot [bool] -or -not $State.Reader.ElevatedAdministrator -or $State.Reader.UserSid -isnot [string] -or $State.Reader.UserSid -cne $State.Token.Sid){throw 'Complete elevated primary-token reader identity is required.'} + $sids=@($State.Token.Sid)+@($State.Token.Groups|Where-Object {($_.Attributes -band 4) -and -not($_.Attributes -band 16)}|ForEach-Object Sid) + $matches=@($State.File.Aces|Where-Object {$_.Ordinary -and $_.Type -eq 2 -and ($_.Flags -band 64) -and -not($_.Flags -band 8) -and ($_.Mask -band 1) -and $_.Sid -in $sids}) + if(-not $matches.Count){throw 'No existing ordinary success ReadData audit ACE matches this token on the selected file; no SACL is added.'} + foreach($name in @('Engine','EngineHash')){if($State.$name -isnot [string] -or -not $State.$name){throw 'Missing native engine identity.'}} + if($State.EngineHash -cnotmatch '^[a-f0-9]{64}$' -or -not @($State.Sources.PSObject.Properties).Count){throw 'Missing implementation fingerprints.'} + foreach($source in $State.Sources.PSObject.Properties){if($source.Value -isnot [string] -or $source.Value -cnotmatch '^[a-f0-9]{64}$'){throw 'Malformed implementation fingerprint.'}} + # Windows paths may change spelling/case while referring to this same native identity. + $State|ConvertTo-Json -Depth 24 -Compress +} +function Get-WelaFileProbeWatermark { + $result=Read-WelaChannelLatest Security + if($result.Status -isnot [string] -or $result.Status -cne 'EventObserved' -or -not(Test-WelaFileProbeInteger $result.Event.RecordId) -or $result.Event.RecordId -lt 1){throw 'A successful native Security query and positive record boundary are required.'} + [long]$result.Event.RecordId +} +function Get-WelaFileProbeOutputKey { + param([string]$Path) + $full=Resolve-WelaArrivalPath $Path;$item=Get-Item -LiteralPath $full -Force -ErrorAction Stop + if(-not $item.PSIsContainer){throw 'Probe output is not a directory.'} + $acl=Get-Acl -LiteralPath $full -ErrorAction Stop + Get-WelaFileProbeKey ([pscustomobject]@{Path=$item.FullName;CreatedUtc=$item.CreationTimeUtc.ToString('o');Attributes=[int]$item.Attributes;Security=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)}) +} +function Write-WelaFileProbeArtifact { + param([string]$Root,[string]$OutputKey,[string]$Name,[string]$Text) + if((Get-WelaFileProbeOutputKey $Root) -cne $OutputKey){throw 'Private probe output directory changed.'} + $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name + $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} + $hash=Get-WelaArrivalHash $bytes + if((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $hash){throw 'Saved probe evidence hash differs.'} + [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length} +} +function Assert-WelaFileProbeOperation { + param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$LaunchedUtc,[DateTimeOffset]$ObservedUtc) + foreach($name in @('Kind','Nonce','Executable','FilePath')){if($Operation.$name -isnot [string]){throw 'Untyped fixed file worker authority.'}} + if($Operation.Kind -cne 'WelaOneByteFileRead' -or $Operation.Nonce -cne $Nonce -or -not(Test-WelaFileProbeInteger $Operation.ProcessId) -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $Operation.FilePath -ine $State.File.Path){throw 'Unexpected fixed file worker identity.'} + $read=$Operation.Read + foreach($name in @('Clock','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}} + if($read.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $read.Succeeded -isnot [bool] -or -not $read.Succeeded -or -not(Test-WelaFileProbeInteger $read.ReadCalls) -or $read.ReadCalls -ne 1 -or -not(Test-WelaFileProbeInteger $read.BytesRead) -or $read.BytesRead -ne 1 -or $read.HandleId -cnotmatch '^0x[0-9a-f]+$' -or [Convert]::ToUInt64($read.HandleId.Substring(2),16) -eq 0 -or $read.BeforeKey -cne $State.File.StateKey -or $read.AfterKey -cne $State.File.StateKey){throw 'Expected exactly one successful byte read from the unchanged held file.'} + $start=ConvertTo-WelaArrivalUtc $read.StartedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc + if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $end -lt $start -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte native read interval.'} + if((Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $Operation.AfterToken) -or (Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $State.Token) -or + (Get-WelaFileProbeReaderKey $Operation.BeforeReader) -cne (Get-WelaFileProbeReaderKey $Operation.AfterReader) -or (Get-WelaFileProbeReaderKey $Operation.BeforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $State.Reader)){throw 'Worker primary token differs from the caller or changed during the native read.'} + $read.StartedUtc=$start.UtcDateTime.ToString('o');$read.CompletedUtc=$end.UtcDateTime.ToString('o') +} +function Start-WelaFileProbeRead { + param($State,[string]$RequestPath,[string]$Nonce) + $fresh=Get-WelaFileProbeState $State.File.Path + if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $State)){throw 'File probe prerequisites drifted before worker launch.'} + $watermark=Get-WelaFileProbeWatermark;$worker=Join-Path $PSScriptRoot 'FileAccessProbeWorker.ps1' + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -Nonce '+$Nonce + $info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding;$process=$null + try { + $launch=[DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow();$process=[Diagnostics.Process]::Start($info) + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'File worker exceeded twenty seconds; the read may have been attempted.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),1000)){throw 'File worker output did not complete.'} + if($stdout.Result.Length -gt 1048576 -or $stderr.Result.Length -gt 65536){throw 'File worker output exceeded its evidence bound.'} + if($process.ExitCode -ne 0 -or $stderr.Result){throw ('Fixed file worker failed: '+$stderr.Result)} + $operation=ConvertFrom-WelaArrivalJson $stdout.Result + Assert-WelaFileProbeOperation $operation $State $Nonce $process.Id $launch ([DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow()) + $operation|Add-Member NoteProperty RecordIdBefore $watermark + $operation + }finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}} +} +function Read-WelaFileProbeEvents { + param($Operation) + # Keep out-of-interval candidates for diagnosis; the matcher never credits them. + $query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4663 and EventRecordID>$($Operation.RecordIdBefore)]]" + $reader=$null;$records=New-Object 'System.Collections.Generic.List[string]' + try { + $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Security',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);$reader.BatchSize=16 + while($records.Count -lt 256){$event=$reader.ReadEvent([TimeSpan]::FromSeconds(1));if($null -eq $event){break};try{$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Security event exceeds the XML bound.'};$records.Add($xml)}finally{$event.Dispose()}} + $status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}});Assert-WelaChannelQueryStatus Security $status + [pscustomobject]@{Xml=@($records.ToArray());Capped=($records.Count -ge 256);Query=$query;MaximumEvents=256;LogStatus=$status} + }finally{if($reader){$reader.Dispose()}} +} +function Test-WelaFileProbeEvent { + param([string]$Xml,$Operation,$State) + $reader=$null + try { + if($Xml.Length -gt 131072){return $false} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated','Level','Task','Opcode')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4663' -or $system.Version.InnerText -cne '1' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Level.InnerText -cne '0' -or $system.Task.InnerText -cne '12800' -or $system.Opcode.InnerText -cne '0' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Read.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Read.CompletedUtc)){return $false} + $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e')){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$name]=$node.InnerText} + foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectServer','ObjectType','ObjectName','HandleId','AccessList','AccessMask','ProcessId','ProcessName','ResourceAttributes')){if(-not $data.ContainsKey($name)){return $false}} + if($data.Count -ne 13 -or $data.ObjectServer -cne 'Security' -or $data.ObjectType -cne 'File' -or $data.ObjectName -ine $State.File.Path -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.AccessList.Trim() -cne '%%4416'){return $false} + foreach($name in @('SubjectLogonId','AccessMask','ProcessId','HandleId')){if($data[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} + if([Convert]::ToUInt64($data.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($data.AccessMask.Substring(2),16) -ne 1 -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($data.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Read.HandleId.Substring(2),16)){return $false} + $true + }catch{$false}finally{if($reader){$reader.Dispose()}} +} +function Invoke-WelaFileAccessProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$FilePath,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + Assert-WelaFileProbePath $FilePath + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FileProbeOutputPath; Plan creates no output.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileAccessProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=[datetime]::UtcNow.ToString('o');Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Query=$null;Artifacts=@();Diagnostic='';OutputPath=$null;ConfigurationChanges=0;FileDataWrites=0;RetainedContentBytes=0;SigmaEvtxCredit=0;Scope='One current-token local file ReadData success only; failure access, other files/rights/users, inheritance, forwarding and Sigma are unverified. Reads may update native access metadata.'} + $outputKey=$null;$beforeKey=$null + try { + if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot;$outputKey=Get-WelaFileProbeOutputKey $report.OutputPath} + $before=Get-WelaFileProbeState $FilePath;$beforeKey=Get-WelaFileProbeStateKey $before;$report.Before=$before + if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'before.json' ($before|ConvertTo-Json -Depth 24) + $nonce=[guid]::NewGuid().ToString('N');$intent=[pscustomobject]@{Kind='WelaOneByteFileReadIntent';Nonce=$nonce;Path=$before.File.Path;StateKey=$before.File.StateKey;ExpectedBytes=1;Outcome='Pending; interruption may leave an attempted read without a completion receipt.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'intent.json' ($intent|ConvertTo-Json -Depth 8) + $operation=Start-WelaFileProbeRead $before (Join-Path $report.OutputPath 'before.json') $nonce;$report.Operation=$operation + if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath 'before.json')).Hash.ToLowerInvariant() -cne $report.Artifacts[0].Sha256){throw 'Worker request evidence changed.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'operation.json' ($operation|ConvertTo-Json -Depth 16) + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() + do{$batch=Read-WelaFileProbeEvents $operation;$report.Candidates=@($batch.Xml).Count;$report.Query=$batch.Query + if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Security query reached its 256-event cap or completeness is unknown.'} + $matches=@($batch.Xml|Where-Object {Test-WelaFileProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.Matches=$matches.Count + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4663 was not observed in the precise read interval.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0] + if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'} + $after=Get-WelaFileProbeState $before.File.Path;$report.After=$after + if((Get-WelaFileProbeStateKey $after) -cne $beforeKey){throw 'File identity/security, policy, channel, host, token or implementation changed during the probe.'} + $report.Status='FileReadObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFileProbeState $report.Before.File.Path}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}} + if($report.OutputPath -and $outputKey){ + if($report.After){$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'after.json' ($report.After|ConvertTo-Json -Depth 24)} + $null=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'manifest.json' ($report|ConvertTo-Json -Depth 28) + } + $report +} diff --git a/scripts/FileAccessProbeNative.cs b/scripts/FileAccessProbeNative.cs new file mode 100644 index 00000000..5d49e387 --- /dev/null +++ b/scripts/FileAccessProbeNative.cs @@ -0,0 +1,104 @@ +// A held existing local file handle: observe security and read exactly one byte. +// No file creation, data/security writes, backup semantics, or retained contents. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Text; +namespace Wela.FileAccessProbe { + public sealed class Ace { public int Type,Flags,Mask; public string Sid,Binary; public bool Ordinary; } + public sealed class Observation { + public string Path,Identity,LastWriteUtc,DescriptorBase64,StateKey; + public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces; + } + public sealed class ReadReceipt { + public string StartedUtc,CompletedUtc,Clock,HandleId,BeforeKey,AfterKey; + public int ReadCalls,BytesRead; public bool Succeeded; + } + sealed class SecurityPrivilege : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;} + [StructLayout(LayoutKind.Sequential)] struct Privileges {public uint Count;public Luid Id;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required); + IntPtr token;Privileges previous; + public SecurityPrivilege() { + IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated file readers are unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {Luid id;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out id))throw new Win32Exception(Marshal.GetLastWin32Error()); + Privileges requested=new Privileges{Count=1,Id=id,Attributes=2};uint needed; + bool ok=AdjustTokenPrivileges(token,false,ref requested,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed); + error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing SeSecurityPrivilege is required to inspect the SACL."); + }catch{CloseHandle(token);token=IntPtr.Zero;throw;} + } + public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SACL observation privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} + } + public sealed class FileHandle : IDisposable { + [StructLayout(LayoutKind.Sequential,Pack=4)] struct FileInfo {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern uint GetFileType(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo info); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool ReadFile(IntPtr handle,[Out]byte[] buffer,uint count,out uint read,IntPtr overlapped); + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory); + [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); + [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + IntPtr handle;readonly bool canRead;bool readAttempted;readonly string selected; + public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + public FileHandle(string path,bool readData) { + if(String.IsNullOrEmpty(path)||path.Length>240||!System.Text.RegularExpressions.Regex.IsMatch(path,@"^[A-Za-z]:\\"))throw new InvalidOperationException("Select an ordinary absolute local file path, at most 240 characters."); + if(path.Substring(2).IndexOf(':')>=0||path.IndexOfAny(new char[]{'"','*','?','<','>','|','/','\r','\n','\0'})>=0||!String.Equals(Path.GetFullPath(path),path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Ambiguous file path refused."); + string root=Path.GetPathRoot(path);if(new DriveInfo(root).DriveType!=DriveType.Fixed)throw new InvalidOperationException("Only fixed local drives are supported."); + string part=root;foreach(string name in path.Substring(root.Length).Split('\\')) { + if(name.Length==0||name=="."||name==".."||name.EndsWith(".")||name.EndsWith(" "))throw new InvalidOperationException("Ambiguous file component refused."); + part=Path.Combine(part,name);if((File.GetAttributes(part)&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("Reparse components are unsupported."); + } + selected=path;canRead=readData; + try { + // READ_CONTROL + ACCESS_SYSTEM_SECURITY + READ_ATTRIBUTES, optionally READ_DATA. + // Share read only: reject concurrent write/delete handles while this handle is held. + using(new SecurityPrivilege()){handle=CreateFileW(path,0x01020080U|(readData?1U:0U),1,IntPtr.Zero,3,0x00200000,IntPtr.Zero);if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());}} + if(GetFileType(handle)!=1)throw new InvalidOperationException("The selected handle is not a disk file."); + Observe(); + }catch{Dispose();throw;} + } + public Observation Observe() { + if(handle==IntPtr.Zero)throw new ObjectDisposedException("FileHandle"); + FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + // No directories, links, EFS, offline/cloud recall, or empty data streams. + if((info.Attributes&(16U|1024U|4096U|16384U|0x40000U|0x400000U))!=0||info.Links!=1)throw new InvalidOperationException("Only ordinary local leaf files with one link are supported."); + long size=((long)info.SizeHigh<<32)|info.SizeLow;if(size<=0)throw new InvalidOperationException("The selected file must be nonempty."); + StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0); + if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),@"\\?\"+selected,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native final file path differs from the selected local path."); + string actual=final.ToString().Substring(4);IntPtr owner,group,dacl,sacl,descriptor; + uint error=GetSecurityInfo(handle,1,0x1ff,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full current SDK descriptor observation (0x1ff) failed."); + byte[] bytes;try{uint count=GetSecurityDescriptorLength(descriptor);if(count<20||count>131072)throw new InvalidOperationException("File descriptor exceeds its observation bound.");bytes=new byte[count];Marshal.Copy(descriptor,bytes,0,(int)count);}finally{LocalFree(descriptor);} + RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List entries=new List(); + if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){if(entries.Count>=128)throw new InvalidOperationException("File SACL exceeds 128 entries.");CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;byte[] binary=new byte[ace.BinaryLength];ace.GetBinaryForm(binary,0);entries.Add(new Ace{Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Binary=Convert.ToBase64String(binary),Ordinary=ordinary});} + string identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created,encoded=Convert.ToBase64String(bytes),written=DateTime.FromFileTimeUtc(info.Written).ToString("o"); + string value=actual.ToUpperInvariant()+"|"+identity+"|"+size+"|"+written+"|"+info.Attributes+"|"+info.Links+"|"+encoded,key; + using(SHA256 sha=SHA256.Create()){key=BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-","").ToLowerInvariant();} + return new Observation{Path=actual,Identity=identity,Size=size,LastWriteUtc=written,Attributes=info.Attributes,Links=info.Links,DescriptorBase64=encoded,SecurityInformation=511,Aces=entries.ToArray(),StateKey=key}; + } + public ReadReceipt ReadOne(string expectedKey) { + if(!canRead||readAttempted)throw new InvalidOperationException("Exactly one explicitly requested data read is permitted."); + Observation before=Observe();if(!String.Equals(before.StateKey,expectedKey,StringComparison.Ordinal))throw new InvalidOperationException("Selected file changed before its one-byte read."); + byte[] buffer=new byte[1];readAttempted=true;uint count=0;DateTime started=UtcNow(),completed;bool success;int error; + try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();completed=UtcNow();}finally{Array.Clear(buffer,0,buffer.Length);} + if(!success)throw new Win32Exception(error,"The one-byte read failed.");if(count!=1)throw new InvalidOperationException("The fixed read did not return exactly one byte."); + Observation after=Observe();if(after.StateKey!=before.StateKey)throw new InvalidOperationException("Held file identity, data metadata or descriptor changed during the read."); + return new ReadReceipt{StartedUtc=started.ToString("o"),CompletedUtc=completed.ToString("o"),Clock="GetSystemTimePreciseAsFileTime",ReadCalls=1,BytesRead=1,Succeeded=true,HandleId="0x"+unchecked((ulong)handle.ToInt64()).ToString("x"),BeforeKey=before.StateKey,AfterKey=after.StateKey}; + } + public void Dispose(){if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}} + } +} diff --git a/scripts/FileAccessProbeWorker.ps1 b/scripts/FileAccessProbeWorker.ps1 new file mode 100644 index 00000000..fdaeafce --- /dev/null +++ b/scripts/FileAccessProbeWorker.ps1 @@ -0,0 +1,22 @@ +param([Parameter(Mandatory)][string]$RequestPath,[Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce) +$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +if($args.Count){throw 'Unexpected file worker arguments.'} +$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')){. (Join-Path $PSScriptRoot ($name+'.ps1'))} +Initialize-WelaFileProbeNative +$requestFile=Get-Item -LiteralPath (Resolve-WelaArrivalPath $RequestPath) -ErrorAction Stop +if($requestFile.Length -gt 1048576){throw 'File worker request exceeds one MiB.'} +$state=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($requestFile.FullName,[Text.UTF8Encoding]::new($false,$true))) +$null=Get-WelaFileProbeStateKey $state +$fresh=Get-WelaFileProbeState $state.File.Path +if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $state)){throw 'Host, caller, source, file or audit prerequisites changed before worker access.'} +$handle=[Wela.FileAccessProbe.FileHandle]::new($state.File.Path,$true) +try { + if($handle.Observe().StateKey -cne $state.File.StateKey){throw 'Selected file changed before worker read.'} + $beforeReader=Get-WelaChannelReader;$beforeToken=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaFileProbeTokenKey $beforeToken) -cne (Get-WelaFileProbeTokenKey $state.Token) -or (Get-WelaFileProbeReaderKey $beforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $state.Reader)){throw 'Worker does not preserve the expected caller token.'} + $read=$handle.ReadOne($state.File.StateKey) + $afterToken=[Wela.WmiProbe.Native]::Snapshot();$afterReader=Get-WelaChannelReader + [pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;FilePath=$state.File.Path;BeforeReader=$beforeReader;AfterReader=$afterReader;BeforeToken=$beforeToken;AfterToken=$afterToken;Read=$read}|ConvertTo-Json -Depth 16 -Compress +}finally{$handle.Dispose()} diff --git a/tests/FileAccessProbe.Cli.Tests.ps1 b/tests/FileAccessProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..38b72896 --- /dev/null +++ b/tests/FileAccessProbe.Cli.Tests.ps1 @@ -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 diff --git a/tests/FileAccessProbe.Windows.Tests.ps1 b/tests/FileAccessProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..254b1290 --- /dev/null +++ b/tests/FileAccessProbe.Windows.Tests.ps1 @@ -0,0 +1,62 @@ +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 +$targetRoot=Join-Path $root 'owned-targets';$null=New-Item -ItemType Directory $targetRoot +$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.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 precise interval' + 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 + $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' + $held=[Wela.FileAccessProbe.FileHandle]::new($file,$false) + try{$denied=$false;try{Remove-Item -LiteralPath $file -Force -ErrorAction Stop}catch{$denied=$true};Assert $denied 'held native observation 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 From bfbdc6c476f867b7a89b3478b5542423645c5475 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:29:03 +0900 Subject: [PATCH 09/21] Read the owned release pipe with encoding-preamble detection --- scripts/AppLockerScriptWorker.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/AppLockerScriptWorker.ps1 b/scripts/AppLockerScriptWorker.ps1 index 2ebb46fe..4f7be74b 100644 --- a/scripts/AppLockerScriptWorker.ps1 +++ b/scripts/AppLockerScriptWorker.ps1 @@ -1,7 +1,10 @@ # Fixed locally generated script; no external inputs or configuration writes. $ErrorActionPreference = 'Stop' [Console]::Out.WriteLine(('WELA_SCRIPT_READY___WELA_SCRIPT_NONCE__|' + $ExecutionContext.SessionState.LanguageMode + '|' + $PSVersionTable.PSVersion)) -$release = [Console]::In.ReadLine() +# .NET Framework's redirected-input writer may emit an encoding preamble. +# Read the owned pipe through a BOM-aware reader, without changing console state. +$pipeReader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false, $true), $true, 128, $true) +try { $release = $pipeReader.ReadLine() } finally { $pipeReader.Dispose() } if ($release -cne 'WELA_SCRIPT_GO___WELA_SCRIPT_NONCE__') { exit 17 } [Console]::Out.WriteLine('WELA_SCRIPT_COMPLETE___WELA_SCRIPT_NONCE__') exit 0 From c1aaa693aca25cabfdd5a2756af0dc5ad529cbe9 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:30:13 +0900 Subject: [PATCH 10/21] Pass explicit held listener XML through the typed native cmdlet --- tests/WecListener.Checkpoint.Windows.Tests.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/WecListener.Checkpoint.Windows.Tests.ps1 b/tests/WecListener.Checkpoint.Windows.Tests.ps1 index a38f5bb9..0df9caa3 100644 --- a/tests/WecListener.Checkpoint.Windows.Tests.ps1 +++ b/tests/WecListener.Checkpoint.Windows.Tests.ps1 @@ -18,17 +18,17 @@ function Key($Value){ConvertTo-Json -InputObject @($Value|Select-Object Address, 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)} function NewCheckpointListener($Selector,$Values) { - $automation=$null;$session=$null;$locator=$null + $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)) + $held=[IO.File]::Open($payload,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) try { - $automation=New-Object -ComObject 'WSMan.Automation' - $session=$automation.CreateSession('',0,$null);$session.Timeout=5000 - $locator=$automation.CreateResourceLocator('http://schemas.microsoft.com/wbem/wsman/1/config/listener') - $locator.AddSelector('Address',[string]$Selector.Address);$locator.AddSelector('Transport',[string]$Selector.Transport) - $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)} - [string]$session.Create($locator,$doc.OuterXml,0) - }finally{foreach($item in @($locator,$session,$automation)){if($null -ne $item -and [Runtime.InteropServices.Marshal]::IsComObject($item)){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($item)}}} + $result=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $Selector -FilePath $payload -ErrorAction Stop + [string]$result.OuterXml + }finally{$held.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 { From 37e93e10ebc52d8a289ac4173eccd78ed277a539 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:34:09 +0900 Subject: [PATCH 11/21] Refuse stopped service dependencies before AppLocker CIM observation --- docs/applocker-script-probe.md | 2 +- scripts/AppLockerScriptProbe.ps1 | 19 +++++++++++++++++-- tests/AppLockerScriptProbe.Tests.ps1 | 21 ++++++++++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/applocker-script-probe.md b/docs/applocker-script-probe.md index 33311d7d..ab3ddf09 100644 --- a/docs/applocker-script-probe.md +++ b/docs/applocker-script-probe.md @@ -9,7 +9,7 @@ Related to #381. `applocker-script-probe` runs one fixed, locally generated `.ps Plan reads prerequisites without launching a child or writing files. Run requires a new private directory on a local fixed drive, with an existing parent. Only reviewed Windows 11 builds and Server 2022/2025 member hosts are accepted; domain controllers are excluded. Client and managed-environment acceptance remains separate from hosted-server CI. -The effective Group Policy Script collection must already contain rules in `AuditOnly` mode. Local and effective GP policy, management observations, AppIDSvc state and MSI and Script channel configuration must be readable. AppIDSvc must already run and the channel must already be enabled. AppLocker CSP policy remains **Unknown**: the native GP cmdlets do not enumerate that authority. Existing enforcement in other collections is preserved and can prevent the fixed native host from starting. +The effective Group Policy Script collection must already contain rules in `AuditOnly` mode. Local and effective GP policy, management observations, AppIDSvc state and MSI and Script channel configuration must be readable. Direct service observations require Winmgmt, EventLog and AppIDSvc to be running before any CIM connection; the channel must already be enabled. AppLocker CSP policy remains **Unknown**: the native GP cmdlets do not enumerate that authority. Existing enforcement in other collections is preserved and can prevent the fixed native host from starting. Run creates only the reviewed worker template with a fresh filename and nonce. It launches System32's `WindowsPowerShell\v1.0\powershell.exe` with `-NoLogo -NoProfile -NonInteractive -File`; there is no operator-supplied command, profile loading or execution-policy override. The existing execution policy must permit that locally generated unsigned file. For example, Restricted or AllSigned may prevent completion; that is an unverified result. The report records existing native execution-policy registry values and the inherited process preference, without equating these observations to all application-control authorities. diff --git a/scripts/AppLockerScriptProbe.ps1 b/scripts/AppLockerScriptProbe.ps1 index 6b03283b..a12953b2 100644 --- a/scripts/AppLockerScriptProbe.ps1 +++ b/scripts/AppLockerScriptProbe.ps1 @@ -36,7 +36,19 @@ function Get-WelaAppLockerScriptExecutionPolicy { } [pscustomobject]$values } +function Get-WelaAppLockerScriptServices { + # Direct SCM observations precede every CIM connection; observation must not + # implicitly start stopped WMI or AppLocker generation dependencies. + $rows=@() + foreach($name in @('Winmgmt','EventLog','AppIDSvc')) { + $service=Get-Service -Name $name -ErrorAction Stop + if($null -eq $service -or $service.Name -ine $name -or $service.Status -ne [ServiceProcess.ServiceControllerStatus]::Running){throw ($name+' must already be running; no CIM connection or child was started.')} + $rows+=[pscustomobject]@{Name=$name;Status=[string]$service.Status} + } + $rows +} function Get-WelaAppLockerScriptState { + $services=@(Get-WelaAppLockerScriptServices) $hostState=Get-WelaAppLockerHost $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop @@ -44,14 +56,17 @@ function Get-WelaAppLockerScriptState { $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script') try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;LogFilePath=$channel.LogFilePath}}finally{$channel.Dispose()} $source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe') - [pscustomobject][ordered]@{Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + [pscustomobject][ordered]@{Services=$services;Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} } function Get-WelaAppLockerScriptStateKey { param($State) + foreach($status in @($State.Host.Status,$State.LocalPolicy.Status,$State.EffectivePolicy.Status,$State.Service.Status,$State.Service.State,$State.Management.Status)){if($status -isnot [string]){throw 'Native context status must be a typed string.'}} + $services=@($State.Services) + if($services.Count -ne 3 -or @($services|Where-Object{$_.Name -isnot [string] -or $_.Status -isnot [string] -or $_.Status -cne 'Running'}).Count -or (($services.Name -join ',') -cne 'Winmgmt,EventLog,AppIDSvc')){throw 'Complete running-service preflight evidence is required.'} if($State.Host.Status -cne 'Candidate' -or $State.Host.Is64BitProcess -isnot [bool] -or -not $State.Host.Is64BitProcess -or $State.Host.ProductType -notin @(1,3) -or ($State.Host.ProductType -eq 1 -and $State.Host.Build -notin @(22000,22621,22631,26100,26200)) -or ($State.Host.ProductType -eq 3 -and $State.Host.Build -notin @(20348,26100))){throw 'A reviewed native Windows 11 or Server 2022/2025 member host is required; DCs are excluded.'} foreach($policy in @($State.LocalPolicy,$State.EffectivePolicy)){if($policy.Status -cne 'Observed' -or $policy.Policy.HasUnknownPolicyData -isnot [bool] -or $policy.Policy.HasUnknownPolicyData){throw 'Local and effective GP policy must be readable and understood.'}} $collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -CEQ 'Script') - if($collection.Count -ne 1 -or $collection[0].EnforcementMode -cne 'AuditOnly' -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'} + if($collection.Count -ne 1 -or $collection[0].EnforcementMode -isnot [string] -or $collection[0].EnforcementMode -cne 'AuditOnly' -or ($collection[0].RuleCount -isnot [int] -and $collection[0].RuleCount -isnot [long]) -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'} if($State.Service.Status -cne 'Observed' -or $State.Service.State -cne 'Running'){throw 'AppIDSvc must already be running.'} if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or -not $State.Channel.SecurityDescriptor){throw 'The native MSI and Script channel must already be enabled and readable.'} if($State.Management.Status -cne 'Observed' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or -not $State.MachineGuid -or -not $State.Computer){throw 'Incomplete management, machine or source observation.'} diff --git a/tests/AppLockerScriptProbe.Tests.ps1 b/tests/AppLockerScriptProbe.Tests.ps1 index 971e915d..48c4d86d 100644 --- a/tests/AppLockerScriptProbe.Tests.ps1 +++ b/tests/AppLockerScriptProbe.Tests.ps1 @@ -6,10 +6,29 @@ $repo=Split-Path $PSScriptRoot -Parent $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='' -$state=[pscustomobject]@{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=[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' From 506333c501b51aa71ec7603b51ef3172a295b000 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:36:15 +0900 Subject: [PATCH 12/21] Use a fixed native Windows PowerShell 5.1 listener adapter --- .../WecListener.Checkpoint.Windows.Tests.ps1 | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/WecListener.Checkpoint.Windows.Tests.ps1 b/tests/WecListener.Checkpoint.Windows.Tests.ps1 index 0df9caa3..cdbb85ff 100644 --- a/tests/WecListener.Checkpoint.Windows.Tests.ps1 +++ b/tests/WecListener.Checkpoint.Windows.Tests.ps1 @@ -17,16 +17,38 @@ function ReadListeners { 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)) - $held=[IO.File]::Open($payload,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $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 { - $result=Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet $Selector -FilePath $payload -ErrorAction Stop - [string]$result.OuterXml - }finally{$held.Dispose()} + 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 @@ -38,7 +60,7 @@ try { $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.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.' + 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 } From eb1b9989a17092d1fbb1345256ad37a1bc4b6a41 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:38:22 +0900 Subject: [PATCH 13/21] Load the service enum for isolated Windows PowerShell fixtures --- tests/AppLockerScriptProbe.Tests.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/AppLockerScriptProbe.Tests.ps1 b/tests/AppLockerScriptProbe.Tests.ps1 index 48c4d86d..f05edf4b 100644 --- a/tests/AppLockerScriptProbe.Tests.ps1 +++ b/tests/AppLockerScriptProbe.Tests.ps1 @@ -3,6 +3,8 @@ $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"} From 4905f82eb5d6c38a6b5b854414326f2f1876c7a1 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:40:05 +0900 Subject: [PATCH 14/21] Add reviewed WEC listener CLI contract and operator guide --- .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 ++ CHANGELOG.md | 2 ++ WELA.ps1 | 23 ++++++++++++++++ docs/wec-listener.md | 36 ++++++++++++++++++++++++++ docs/wef-deployment.md | 2 ++ tests/WecListener.Cli.Tests.ps1 | 25 ++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 ++ website/docs/resources/changelog.md | 2 ++ 9 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 docs/wec-listener.md create mode 100644 tests/WecListener.Cli.Tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d039e5b..f2c884b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 84cd5033..90c778da 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security) + - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) - 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 319e9100..cb89a7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security) + - Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) - Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. diff --git a/WELA.ps1 b/WELA.ps1 index fd44c7a9..1fa3dff4 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -143,6 +143,12 @@ [string]$RecoveryOutputPath, [string]$ArrivalProbePath, [string]$ArrivalOutputPath, + [ValidateSet('Plan','Apply')][string]$WecListenerAction = 'Plan', + [string]$WecListenerComputerName, + [string]$WecListenerLocalAddress, + [string]$WecListenerPlanPath, + [string]$WecListenerPlanHash, + [string]$WecListenerOutputPath, [ValidateSet('Plan','Apply')][string]$WecIngressAction = 'Plan', [string]$WecIngressName, [string[]]$WecIngressLocalAddress, @@ -233,6 +239,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/WefDeployment.ps1") . (Join-Path $ScriptRoot "scripts/WecUpdate.ps1") . (Join-Path $ScriptRoot "scripts/WecIngress.ps1") +. (Join-Path $ScriptRoot "scripts/WecListener.ps1") . (Join-Path $ScriptRoot "scripts/WecState.ps1") . (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") @@ -2022,6 +2029,7 @@ Usage: ./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes ./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write + ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription @@ -2110,6 +2118,8 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { if ($Cmd -ne 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'} if ($Cmd -eq 'eventlog-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EventRecoveryAction','EventRecoveryJournalPath','EventRecoveryOriginalResultsPath','EventRecoveryLog','EventRecoveryPlanPath','EventRecoveryPlanHash','EventRecoveryOutputPath','EventRecoveryAllowShrink','EventRecoveryAllowRetentionChange','Help')}).Count)) {throw 'eventlog-recovery accepts only dedicated options.'} +if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecListener*'}).Count) {throw 'WecListener options require wec-listener.'} +if ($Cmd -eq 'wec-listener' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecListenerAction','WecListenerComputerName','WecListenerLocalAddress','WecListenerPlanPath','WecListenerPlanHash','WecListenerOutputPath','Help')}).Count)) {throw 'wec-listener accepts only dedicated options.'} if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'} if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} @@ -2334,6 +2344,19 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaEventLogRecovery @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'wec-listener' { + if ($Help) {Write-Host 'Usage: wec-listener [-WecListenerAction Plan] -WecListenerComputerName actual-local-computer -WecListenerLocalAddress assigned-IPv4 -WecListenerOutputPath new-private-directory; then Apply with -WecListenerPlanPath plan.json -WecListenerPlanHash SHA256 -WecListenerOutputPath new-private-directory. Creates one fixed HTTP5985 /wsman listener using native Windows PowerShell5.1 under either host engine. Existing listener conflicts refuse; services, authentication and firewall settings are preserved. See docs/wec-listener.md.';return} + if ($WecListenerAction -eq 'Plan') { + if ($PSBoundParameters.ContainsKey('WecListenerPlanPath') -or $PSBoundParameters.ContainsKey('WecListenerPlanHash') -or [string]::IsNullOrWhiteSpace($WecListenerComputerName) -or [string]::IsNullOrWhiteSpace($WecListenerLocalAddress) -or [string]::IsNullOrWhiteSpace($WecListenerOutputPath)) {throw 'wec-listener Plan requires a computer name, assigned IPv4 and new output path; reviewed plan/hash options are for Apply.'} + } else { + if ($PSBoundParameters.ContainsKey('WecListenerComputerName') -or $PSBoundParameters.ContainsKey('WecListenerLocalAddress') -or [string]::IsNullOrWhiteSpace($WecListenerPlanPath) -or $WecListenerPlanHash -cnotmatch '^[a-fA-F0-9]{64}$' -or [string]::IsNullOrWhiteSpace($WecListenerOutputPath)) {throw 'wec-listener Apply requires only a reviewed plan, SHA256 and new output path; computer/address are taken from the reviewed plan.'} + } + $arguments=@{Action=$WecListenerAction;OutputPath=$WecListenerOutputPath} + $map=@{WecListenerComputerName='ComputerName';WecListenerLocalAddress='LocalAddress';WecListenerPlanPath='PlanPath';WecListenerPlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaWecListener @arguments;$report + if($report.ExitCode){exit $report.ExitCode} + } 'wec-ingress' { if ($Help) {Write-Host 'Usage: wec-ingress [-WecIngressAction Plan] -WecIngressName WELA-WEC-name -WecIngressLocalAddress IPv4 -WecIngressRemoteAddress IPv4/CIDR -WecIngressOutputPath new-directory; then Apply with -WecIngressPlanPath plan.json -WecIngressPlanHash SHA256 -WecIngressOutputPath new-directory. Creates one new Domain TCP5985 rule. See docs/wec-ingress.md.';return} $arguments=@{Action=$WecIngressAction;OutputPath=$WecIngressOutputPath} diff --git a/docs/wec-listener.md b/docs/wec-listener.md new file mode 100644 index 00000000..f9d819d1 --- /dev/null +++ b/docs/wec-listener.md @@ -0,0 +1,36 @@ +# 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. + +```powershell +# Use an actual assigned local IPv4 address and a new private directory. +.\WELA.ps1 wec-listener -WecListenerComputerName $env:COMPUTERNAME ` + -WecListenerLocalAddress 192.0.2.10 -WecListenerOutputPath C:\WELA-Evidence\listener-plan + +# Review plan.json, manifest.json and the retained configuration snapshots. +# Retain the SHA256 from that review before applying the same file. +.\WELA.ps1 wec-listener -WecListenerAction Apply ` + -WecListenerPlanPath C:\WELA-Evidence\listener-plan\plan.json ` + -WecListenerPlanHash '' -WecListenerOutputPath C:\WELA-Evidence\listener-apply +``` + +Plan writes review artifacts, including `plan.json` and `manifest.json` with `PlanHash`, and makes no Windows configuration change. Apply takes the computer and address from the reviewed plan. A separate new output directory retains its evidence. Mixed Plan/Apply inputs, unrelated options, `-Auto`, `-DryRun`, `-WhatIf` and unrecognized trailing arguments are rejected. Use Plan to review the proposed creation. + +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. + +| 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. | + +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. + +Creating this listener exposes a standard WinRM endpoint on the selected address. It does not restrict the endpoint to event forwarding. Existing authentication and authorization still apply. WELA preserves authentication, services, existing listeners and firewall settings; it does not run `winrm quickconfig`, `Enable-PSRemoting`, alter TrustedHosts or grant remote users access. Use the separate [reviewed firewall ingress command](wec-ingress.md) where an approved firewall rule is needed. + +This is one prerequisite for [collector deployment](wef-deployment.md). Listener readback does not prove remote reachability, client authentication, domain source membership, a subscription, collector arrival, sustained retention or Sigma readiness. Built-in Windows only; Sysmon is excluded. The disposable Windows tests exercise creation/collision/readback and fixture cleanup; connected domain-source acceptance remains separate. + +Microsoft documents the native [WinRM listener selectors and configuration](https://learn.microsoft.com/en-us/windows/win32/winrm/installation-and-configuration-for-windows-remote-management) and the [event-forwarding deployment prerequisites](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection). diff --git a/docs/wef-deployment.md b/docs/wef-deployment.md index 41c9e5e0..a05d122e 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -1,5 +1,7 @@ # Native WEF source configuration and collector subscriptions +For a missing collector listener, use the separately reviewed [`wec-listener` Plan/Apply](wec-listener.md) to create one assigned-IPv4 HTTP5985 listener. It refuses existing listeners and preserves WinRM authentication, services and firewall settings. Collector configuration still requires its own validated prerequisites; listener creation does not prove source arrival. + `wef-source` and `wec-collector` are separate, opt-in commands for a bounded domain/Kerberos topology: source-initiated subscriptions over HTTP 5985 to a dedicated domain member Windows Server collector. They require an operator JSON file with the actual collector FQDN/URI, explicitly permitted source computer/group SIDs, and selected native subscription XML files. Sysmon and EMET are excluded. Local channel enablement or successful configuration does not establish forwarding or add usable Sigma-rule credit. This implements source configuration and collector subscription creation, not every WEF topology or all acceptance evidence for issue #368. HTTPS/certificate enrollment, workgroups/cross-domain trust, collector-initiated/custom-delivery subscriptions, listener/firewall creation, remote GPO management, updating/deleting existing subscriptions and automatic rollback are outside this command's initial scope. Dedicated workload isolation, network logon rights, capacity and actual event collection remain operator responsibilities. diff --git a/tests/WecListener.Cli.Tests.ps1 b/tests/WecListener.Cli.Tests.ps1 new file mode 100644 index 00000000..a13048a9 --- /dev/null +++ b/tests/WecListener.Cli.Tests.ps1 @@ -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 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index cfae94e4..1eb0b137 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security) + - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) - 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 917aa220..9f67ed3a 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security) + - Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) - Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. From ce7b49a5ac923a120c2a76cc3e83d2209dcbc345 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:40:50 +0900 Subject: [PATCH 15/21] Bind observed native file paths and document exact read evidence --- .gitattributes | 4 ++ .github/workflows/file-access-probe.yml | 2 + .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + docs/file-access-probe.md | 31 +++++++++ scripts/FileAccessProbe.ps1 | 14 ++-- scripts/FileAccessProbeNative.cs | 13 ++-- tests/FileAccessProbe.Tests.ps1 | 93 +++++++++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 11 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 docs/file-access-probe.md create mode 100644 tests/FileAccessProbe.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 190bc45a..b55085e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -68,3 +68,7 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WecState* text eol=lf /scripts/WecRuntime* text eol=lf /tests/WecState* text eol=lf + +# Existing-file read receipts bind identical native/worker source bytes. +/scripts/FileAccessProbe* text eol=lf +/tests/FileAccessProbe* text eol=lf diff --git a/.github/workflows/file-access-probe.yml b/.github/workflows/file-access-probe.yml index 1a67ca95..f671f8f9 100644 --- a/.github/workflows/file-access-probe.yml +++ b/.github/workflows/file-access-probe.yml @@ -26,12 +26,14 @@ jobs: if: matrix.engine == 'powershell' shell: powershell run: | + ./tests/FileAccessProbe.Tests.ps1 ./tests/FileAccessProbe.Cli.Tests.ps1 ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite - name: Public CLI and actual file read in PowerShell 7 if: matrix.engine == 'pwsh' shell: pwsh run: | + ./tests/FileAccessProbe.Tests.ps1 ./tests/FileAccessProbe.Cli.Tests.ps1 ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite - name: Retain genuine XML, receipts and exact cleanup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d039e5b..35e15a7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/file-access-probe.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 84cd5033..4b68eb36 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。同じハンドルのDOS/NTパスと実体、実際のワーカー・トークン・時刻・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) + - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) - 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 319e9100..5b7a3a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Same-handle DOS/NT identity, exact worker/token/time/handle attribution, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) + - Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) - Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. diff --git a/docs/file-access-probe.md b/docs/file-access-probe.md new file mode 100644 index 00000000..9561f191 --- /dev/null +++ b/docs/file-access-probe.md @@ -0,0 +1,31 @@ +# One-byte local file access probe + +`file-access-probe` checks whether one explicit read of one existing file produces an attributable local Security 4663 event. Plan observes prerequisites without reading file data. Run opens the same selected leaf in a fixed worker, reads exactly one byte once, clears that buffer and retains no file contents. It changes no audit policy, ACL, service, channel setting or file data. A native read can update access metadata and can trigger existing monitoring. + +Run elevated in native 64-bit Windows PowerShell 5.1 or PowerShell 7. Select an ordinary, nonempty file on a fixed local drive using its exact absolute DOS path (at most 240 characters). UNC/device input paths, alternate streams, wildcards, reparse components, multiple hard links, EFS, offline/recall files and directories are refused. The token must already hold the security privilege needed to inspect the SACL; observation enables that existing privilege only around handle acquisition and restores its prior state before the data read. No privilege is granted and backup semantics are not used. + +The File System subcategory must already include Success, `SCENoApplyLegacyAuditPolicy` must be typed DWORD 1, and the enabled Security channel must be readable. One existing ordinary success ReadData audit ACE must apply directly to the user SID or an enabled, non-deny-only group. Inherit-only and conditional/callback ACEs cannot establish this prerequisite. EventLog, Winmgmt and RpcSs must already be running. This command does not install a SACL or repair prerequisites. + +```powershell +.\WELA.ps1 file-access-probe -FileProbePath C:\Audit\existing-file.txt +.\WELA.ps1 file-access-probe -FileProbeAction Run ` + -FileProbePath C:\Audit\existing-file.txt ` + -FileProbeOutputPath C:\Evidence\new-file-probe ` + -FileProbeTimeoutSeconds 15 +``` + +Run requires a fresh private evidence directory outside the code tree. Only dedicated options are accepted; no `-Auto`, `-DryRun`, generic `-WhatIf` or extra positional arguments. `FileProbeTimeoutSeconds` accepts 1–30 seconds for polling after the worker; worker execution has a separate 20-second limit. Native query work and cleanup add elapsed time. + +The request binds actual host/build/MachineGuid, engine and implementation hashes, token groups and privileges, all effective audit masks, precedence, Security configuration and full selected-file metadata/security. A held existing-file handle prevents concurrent write/delete opens. Volume/file ID, creation and last-write times, size, attributes, link count and full current SDK security descriptor must agree before and after the operation. Both DOS and NT volume names are observed from that same handle and bound to this identity. Path comparison is case-insensitive; other volume names or paths are not inferred or accepted. + +Before launch, the parent writes and flushes `before.json` and `intent.json`. The fixed worker independently rebuilds the request state, verifies its primary token, performs one native `ReadFile` call requesting one byte and returns a receipt with exact PID, handle and precise start/completion timestamps. The parent retains `operation.json`, the original matching `event.xml`, `after.json` and their SHA-256 hashes in `manifest.json`. These artifacts contain file paths, SIDs, security descriptors and audit context, but no target contents or target-content hashes. The manifest is not self-hashed. + +Success requires exactly one fresh version-1 Security 4663 from the expected provider, computer, user SID/logon ID, worker PID/executable, native handle, selected DOS or NT path and ReadData mask/access token. Event time must fall within the actual native read interval, with no padding. The query stops at 256 events and bounds individual XML size; hitting a cap, missing/duplicate evidence, drift, changed reader context or failed persistence prevents verified success. The final Security record boundary must not move backwards. + +`PrerequisitesObserved` (Plan) and `FileReadObserved` (Run) exit 0. `Unverified` exits 1 and explains the observed gap. A stopped or failed worker may already have attempted the read; durable intent alone does not prove completion. An interrupted process may leave only partial evidence, and a manifest-write failure fails outward while earlier receipts remain. Inspect retained artifacts before deciding whether to run another probe in a different fresh directory. + +This is evidence for that one current-token local ReadData success. It does not prove Failure auditing, other rights/users/files, child inheritance, forwarded delivery, backend parsing, Sigma readiness or general detection coverage. Security 4663 has no Failure variant. No Sigma/EVTX coverage points are added. + +The disposable Windows fixture owns its files and evidence directories, explicitly establishes the test SACL/policy, exercises the public Plan/Run path twice, verifies all artifact hashes and unchanged file content/security, tests missing-SACL/policy and file-replacement refusals, then restores all effective audit masks, typed precedence and token state. It removes only its owned target directory and retains cleanup evidence. Server 2022/2025 and PowerShell 5.1/7 run independently; these fixture changes are not product behavior. + +Microsoft references: [4663 event semantics and fields](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4663), [ReadFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile), and [same-handle DOS/NT path observation](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew). diff --git a/scripts/FileAccessProbe.ps1 b/scripts/FileAccessProbe.ps1 index 6f99fdf3..c8402ff9 100644 --- a/scripts/FileAccessProbe.ps1 +++ b/scripts/FileAccessProbe.ps1 @@ -1,9 +1,12 @@ # Explicit one-byte existing-file read and exact local Security4663 evidence. function Initialize-WelaFileProbeNative { Initialize-WelaWmiProbeNative - $source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$hash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash - if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){Add-Type -Path $source -ErrorAction Stop;$script:WelaFileProbeNativeHash=$hash} - if($script:WelaFileProbeNativeHash -cne $hash){throw 'Loaded file probe helper differs from its source; start a fresh session.'} + $source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$bytes=[IO.File]::ReadAllBytes($source);$hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){ + $definition=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_FILE_PROBE_SOURCE_SHA256__',$hash) + Add-Type -TypeDefinition $definition -ErrorAction Stop + } + if([Wela.FileAccessProbe.FileHandle]::SourceSha256 -cne $hash){throw 'Loaded file probe helper differs from its source; start a fresh session.'} } function Test-WelaFileProbeInteger {param($Value) ($Value -is [int] -or $Value -is [long] -or $Value -is [uint32] -or $Value -is [uint64])} function Assert-WelaFileProbePath { @@ -35,8 +38,9 @@ function Get-WelaFileProbeReaderKey { } function Assert-WelaFileProbeSnapshot { param($Snapshot) - foreach($name in @('Path','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}} + foreach($name in @('Path','NativePath','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}} Assert-WelaFileProbePath $Snapshot.Path + if($Snapshot.NativePath -cnotmatch '^\\Device\\[^\\]+\\'){throw 'Incomplete native NT file path observation.'} if($Snapshot.StateKey -cnotmatch '^[a-f0-9]{64}$' -or -not(Test-WelaFileProbeInteger $Snapshot.Size) -or $Snapshot.Size -le 0 -or -not(Test-WelaFileProbeInteger $Snapshot.SecurityInformation) -or $Snapshot.SecurityInformation -ne 511 -or -not(Test-WelaFileProbeInteger $Snapshot.Links) -or $Snapshot.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Snapshot.Attributes) -or ($Snapshot.Attributes -band (16+1024+4096+16384+262144+4194304))){throw 'Only a complete nonempty ordinary single-link leaf-file observation is supported.'} $Snapshot.LastWriteUtc=(ConvertTo-WelaArrivalUtc $Snapshot.LastWriteUtc).UtcDateTime.ToString('o') if($Snapshot.Aces -isnot [array] -or $Snapshot.Aces.Count -gt 128){throw 'Missing or oversized file audit ACE inventory.'} @@ -170,7 +174,7 @@ function Test-WelaFileProbeEvent { $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Read.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Read.CompletedUtc)){return $false} $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e')){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$name]=$node.InnerText} foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectServer','ObjectType','ObjectName','HandleId','AccessList','AccessMask','ProcessId','ProcessName','ResourceAttributes')){if(-not $data.ContainsKey($name)){return $false}} - if($data.Count -ne 13 -or $data.ObjectServer -cne 'Security' -or $data.ObjectType -cne 'File' -or $data.ObjectName -ine $State.File.Path -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.AccessList.Trim() -cne '%%4416'){return $false} + if($data.Count -ne 13 -or $data.ObjectServer -cne 'Security' -or $data.ObjectType -cne 'File' -or ($data.ObjectName -ine $State.File.Path -and $data.ObjectName -ine $State.File.NativePath) -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.AccessList.Trim() -cne '%%4416'){return $false} foreach($name in @('SubjectLogonId','AccessMask','ProcessId','HandleId')){if($data[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} if([Convert]::ToUInt64($data.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($data.AccessMask.Substring(2),16) -ne 1 -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($data.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Read.HandleId.Substring(2),16)){return $false} $true diff --git a/scripts/FileAccessProbeNative.cs b/scripts/FileAccessProbeNative.cs index 5d49e387..7fb95d1c 100644 --- a/scripts/FileAccessProbeNative.cs +++ b/scripts/FileAccessProbeNative.cs @@ -11,7 +11,7 @@ using System.Text; namespace Wela.FileAccessProbe { public sealed class Ace { public int Type,Flags,Mask; public string Sid,Binary; public bool Ordinary; } public sealed class Observation { - public string Path,Identity,LastWriteUtc,DescriptorBase64,StateKey; + public string Path,NativePath,Identity,LastWriteUtc,DescriptorBase64,StateKey; public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces; } public sealed class ReadReceipt { @@ -53,6 +53,7 @@ namespace Wela.FileAccessProbe { [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory); [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + public const string SourceSha256="__WELA_FILE_PROBE_SOURCE_SHA256__"; IntPtr handle;readonly bool canRead;bool readAttempted;readonly string selected; public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} public FileHandle(string path,bool readData) { @@ -80,15 +81,19 @@ namespace Wela.FileAccessProbe { long size=((long)info.SizeHigh<<32)|info.SizeLow;if(size<=0)throw new InvalidOperationException("The selected file must be nonempty."); StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0); if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),@"\\?\"+selected,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native final file path differs from the selected local path."); - string actual=final.ToString().Substring(4);IntPtr owner,group,dacl,sacl,descriptor; + string actual=final.ToString().Substring(4); + // Bind the NT volume name from this same held handle: Security4663 may use it. + StringBuilder native=new StringBuilder(32768);uint nativeLength=GetFinalPathNameByHandleW(handle,native,(uint)native.Capacity,2); + if(nativeLength==0||nativeLength>=native.Capacity||!System.Text.RegularExpressions.Regex.IsMatch(native.ToString(),@"^\\Device\\[^\\]+\\"))throw new InvalidOperationException("Native NT file path observation failed."); + string nativePath=native.ToString();IntPtr owner,group,dacl,sacl,descriptor; uint error=GetSecurityInfo(handle,1,0x1ff,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full current SDK descriptor observation (0x1ff) failed."); byte[] bytes;try{uint count=GetSecurityDescriptorLength(descriptor);if(count<20||count>131072)throw new InvalidOperationException("File descriptor exceeds its observation bound.");bytes=new byte[count];Marshal.Copy(descriptor,bytes,0,(int)count);}finally{LocalFree(descriptor);} RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List entries=new List(); if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){if(entries.Count>=128)throw new InvalidOperationException("File SACL exceeds 128 entries.");CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;byte[] binary=new byte[ace.BinaryLength];ace.GetBinaryForm(binary,0);entries.Add(new Ace{Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Binary=Convert.ToBase64String(binary),Ordinary=ordinary});} string identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created,encoded=Convert.ToBase64String(bytes),written=DateTime.FromFileTimeUtc(info.Written).ToString("o"); - string value=actual.ToUpperInvariant()+"|"+identity+"|"+size+"|"+written+"|"+info.Attributes+"|"+info.Links+"|"+encoded,key; + string value=actual.ToUpperInvariant()+"|"+nativePath.ToUpperInvariant()+"|"+identity+"|"+size+"|"+written+"|"+info.Attributes+"|"+info.Links+"|"+encoded,key; using(SHA256 sha=SHA256.Create()){key=BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-","").ToLowerInvariant();} - return new Observation{Path=actual,Identity=identity,Size=size,LastWriteUtc=written,Attributes=info.Attributes,Links=info.Links,DescriptorBase64=encoded,SecurityInformation=511,Aces=entries.ToArray(),StateKey=key}; + return new Observation{Path=actual,NativePath=nativePath,Identity=identity,Size=size,LastWriteUtc=written,Attributes=info.Attributes,Links=info.Links,DescriptorBase64=encoded,SecurityInformation=511,Aces=entries.ToArray(),StateKey=key}; } public ReadReceipt ReadOne(string expectedKey) { if(!canRead||readAttempted)throw new InvalidOperationException("Exactly one explicitly requested data read is permitted."); diff --git a/tests/FileAccessProbe.Tests.ps1 b/tests/FileAccessProbe.Tests.ps1 new file mode 100644 index 00000000..41cd9785 --- /dev/null +++ b/tests/FileAccessProbe.Tests.ps1 @@ -0,0 +1,93 @@ +$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';Succeeded=$true;ReadCalls=1;BytesRead=1;HandleId='0x888';BeforeKey=('a'*64);AfterKey=('a'*64);StartedUtc='2026-09-21T00:00:00.0001000Z';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 { + @" +4663101280000x802000000000000011SecurityFIXTURES-1-5-21-1-2-3-1001ReaderFIXTURE0x1234SecurityFileC:\Fixture\ReadCase.TxT0x888%%44160x10x4d2C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe- +"@ +} +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'} +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','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};'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'),@('1','0'),@('0x8020000000000000','0x8010000000000000'),@('12800','1'),@('>FIXTURE','>OTHER'),@('>0x1','>0x2'),@('>0x888','>0x889'),@('>0x4d2','>0x4d3'),@('>0x1234','>0x1235'),@('>File','>Key'),@('ReadCase.TxT','Other.txt'),@('>%%4416','>%%4417'),@('0001500Z','0000999Z'),@('0001500Z','0002001Z'),@('11','10'),@('1001','1002'),@('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'} +foreach($time in @('0001000Z','0002000Z')){Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z',$time) $script:operation $script:state) 'exact precise interval boundaries are inclusive'} +Assert (-not(Test-WelaFileProbeEvent $xml.Replace('','0x1') $script:operation $script:state)) 'duplicate XML authority is refused' +Assert (-not(Test-WelaFileProbeEvent (']>'+$xml) $script:operation $script:state)) 'DTD evidence is refused' +Assert (-not(Test-WelaFileProbeEvent (''+$xml+'') $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." diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index cfae94e4..5b7bcf48 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。同じハンドルのDOS/NTパスと実体、実際のワーカー・トークン・時刻・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) + - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) - 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 917aa220..e63d432b 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Same-handle DOS/NT identity, exact worker/token/time/handle attribution, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) + - Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) - Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. From 03bc63e996170517f85dc23e854e4880710eb9df Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:43:07 +0900 Subject: [PATCH 16/21] Respect inherited execution policy for the fixed file worker --- docs/file-access-probe.md | 2 +- scripts/FileAccessProbe.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/file-access-probe.md b/docs/file-access-probe.md index 9561f191..7e084772 100644 --- a/docs/file-access-probe.md +++ b/docs/file-access-probe.md @@ -18,7 +18,7 @@ Run requires a fresh private evidence directory outside the code tree. Only dedi The request binds actual host/build/MachineGuid, engine and implementation hashes, token groups and privileges, all effective audit masks, precedence, Security configuration and full selected-file metadata/security. A held existing-file handle prevents concurrent write/delete opens. Volume/file ID, creation and last-write times, size, attributes, link count and full current SDK security descriptor must agree before and after the operation. Both DOS and NT volume names are observed from that same handle and bound to this identity. Path comparison is case-insensitive; other volume names or paths are not inferred or accepted. -Before launch, the parent writes and flushes `before.json` and `intent.json`. The fixed worker independently rebuilds the request state, verifies its primary token, performs one native `ReadFile` call requesting one byte and returns a receipt with exact PID, handle and precise start/completion timestamps. The parent retains `operation.json`, the original matching `event.xml`, `after.json` and their SHA-256 hashes in `manifest.json`. These artifacts contain file paths, SIDs, security descriptors and audit context, but no target contents or target-content hashes. The manifest is not self-hashed. +Before launch, the parent writes and flushes `before.json` and `intent.json`. The worker inherits the existing execution policy without an override; a blocked worker remains unverified with its prior intent retained. The fixed worker independently rebuilds the request state, verifies its primary token, performs one native `ReadFile` call requesting one byte and returns a receipt with exact PID, handle and precise start/completion timestamps. The parent retains `operation.json`, the original matching `event.xml`, `after.json` and their SHA-256 hashes in `manifest.json`. These artifacts contain file paths, SIDs, security descriptors and audit context, but no target contents or target-content hashes. The manifest is not self-hashed. Success requires exactly one fresh version-1 Security 4663 from the expected provider, computer, user SID/logon ID, worker PID/executable, native handle, selected DOS or NT path and ReadData mask/access token. Event time must fall within the actual native read interval, with no padding. The query stops at 256 events and bounds individual XML size; hitting a cap, missing/duplicate evidence, drift, changed reader context or failed persistence prevents verified success. The final Security record boundary must not move backwards. diff --git a/scripts/FileAccessProbe.ps1 b/scripts/FileAccessProbe.ps1 index c8402ff9..4f183153 100644 --- a/scripts/FileAccessProbe.ps1 +++ b/scripts/FileAccessProbe.ps1 @@ -130,7 +130,7 @@ function Start-WelaFileProbeRead { $fresh=Get-WelaFileProbeState $State.File.Path if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $State)){throw 'File probe prerequisites drifted before worker launch.'} $watermark=Get-WelaFileProbeWatermark;$worker=Join-Path $PSScriptRoot 'FileAccessProbeWorker.ps1' - $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -Nonce '+$Nonce + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -Nonce '+$Nonce $info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true $info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding;$process=$null try { From d832b1e0903d647f0ba0fdbf9941f292eb37b1f2 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:51:50 +0900 Subject: [PATCH 17/21] Measure held-readback phase and refuse implementation targets before hashing --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- WELA.ps1 | 2 +- docs/file-access-probe.md | 10 ++-- scripts/FileAccessProbe.ps1 | 61 ++++++++++++++++++++++--- scripts/FileAccessProbeNative.cs | 11 +++-- tests/FileAccessProbe.Tests.ps1 | 13 ++++-- tests/FileAccessProbe.Windows.Tests.ps1 | 17 +++++-- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 10 files changed, 93 insertions(+), 29 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 4b68eb36..2283133a 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。同じハンドルのDOS/NTパスと実体、実際のワーカー・トークン・時刻・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) +- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。実装・実行中エンジンの選択をハッシュ処理前に拒否し、同じハンドルのDOS/NTパスと実体、読み取りと実体再確認の実測区間、実際のワーカー・トークン・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7a3a6a..2ce32463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Same-handle DOS/NT identity, exact worker/token/time/handle attribution, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) +- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Source/engine targets are refused before hashing. Same-handle DOS/NT identity, exact worker/token/handle attribution over the measured read and identity-readback phase, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) - Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 0c8ce389..ded7caf7 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2323,7 +2323,7 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) {exit $report.ExitCode} } 'file-access-probe' { - if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} + if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it; event matching uses the measured read plus held-handle identity/security readback phase, with the ReadFile return recorded separately. Source-tree/active-engine targets and aliases are refused before hashing. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} $report=Invoke-WelaFileAccessProbe -Action $FileProbeAction -FilePath $FileProbePath -OutputPath $FileProbeOutputPath -TimeoutSeconds $FileProbeTimeoutSeconds $report|ConvertTo-Json -Depth 28|Write-Output exit ([int]$report.ExitCode) diff --git a/docs/file-access-probe.md b/docs/file-access-probe.md index 7e084772..0398055d 100644 --- a/docs/file-access-probe.md +++ b/docs/file-access-probe.md @@ -2,7 +2,7 @@ `file-access-probe` checks whether one explicit read of one existing file produces an attributable local Security 4663 event. Plan observes prerequisites without reading file data. Run opens the same selected leaf in a fixed worker, reads exactly one byte once, clears that buffer and retains no file contents. It changes no audit policy, ACL, service, channel setting or file data. A native read can update access metadata and can trigger existing monitoring. -Run elevated in native 64-bit Windows PowerShell 5.1 or PowerShell 7. Select an ordinary, nonempty file on a fixed local drive using its exact absolute DOS path (at most 240 characters). UNC/device input paths, alternate streams, wildcards, reparse components, multiple hard links, EFS, offline/recall files and directories are refused. The token must already hold the security privilege needed to inspect the SACL; observation enables that existing privilege only around handle acquisition and restores its prior state before the data read. No privilege is granted and backup semantics are not used. +Run elevated in native 64-bit Windows PowerShell 5.1 or PowerShell 7. Select an ordinary, nonempty file on a fixed local drive using its exact absolute DOS path (at most 240 characters). UNC/device input paths, alternate streams, wildcards, reparse components, multiple hard links, EFS, offline/recall files and directories are refused. Targets inside the canonical WELA source tree, the active PowerShell executable and aliases are refused by an initial metadata-only check before implementation/engine hashing. The token must already hold the security privilege needed to inspect the SACL; observation enables that existing privilege only around handle acquisition and restores its prior state before the data read. No privilege is granted and backup semantics are not used. The File System subcategory must already include Success, `SCENoApplyLegacyAuditPolicy` must be typed DWORD 1, and the enabled Security channel must be readable. One existing ordinary success ReadData audit ACE must apply directly to the user SID or an enabled, non-deny-only group. Inherit-only and conditional/callback ACEs cannot establish this prerequisite. EventLog, Winmgmt and RpcSs must already be running. This command does not install a SACL or repair prerequisites. @@ -16,16 +16,16 @@ The File System subcategory must already include Success, `SCENoApplyLegacyAudit Run requires a fresh private evidence directory outside the code tree. Only dedicated options are accepted; no `-Auto`, `-DryRun`, generic `-WhatIf` or extra positional arguments. `FileProbeTimeoutSeconds` accepts 1–30 seconds for polling after the worker; worker execution has a separate 20-second limit. Native query work and cleanup add elapsed time. -The request binds actual host/build/MachineGuid, engine and implementation hashes, token groups and privileges, all effective audit masks, precedence, Security configuration and full selected-file metadata/security. A held existing-file handle prevents concurrent write/delete opens. Volume/file ID, creation and last-write times, size, attributes, link count and full current SDK security descriptor must agree before and after the operation. Both DOS and NT volume names are observed from that same handle and bound to this identity. Path comparison is case-insensitive; other volume names or paths are not inferred or accepted. +The request binds actual host/build/MachineGuid, engine and implementation hashes, token groups and privileges, all effective audit masks, precedence, Security configuration and full selected-file metadata/security. A held existing-file handle prevents concurrent write/delete opens. Volume/file ID, creation and last-write times, size, attributes, link count and full current SDK security descriptor must agree before and after the operation. Both DOS and NT volume names are observed from that same handle and bound to this identity. Path comparison is case-insensitive; other volume names or paths are not inferred or accepted. Some volumes can emit Removable Storage Task 12812 even when `DriveInfo` reports Fixed, as observed on a hosted runner data volume. This probe accepts only File System Task 12800; those other events remain unverified. -Before launch, the parent writes and flushes `before.json` and `intent.json`. The worker inherits the existing execution policy without an override; a blocked worker remains unverified with its prior intent retained. The fixed worker independently rebuilds the request state, verifies its primary token, performs one native `ReadFile` call requesting one byte and returns a receipt with exact PID, handle and precise start/completion timestamps. The parent retains `operation.json`, the original matching `event.xml`, `after.json` and their SHA-256 hashes in `manifest.json`. These artifacts contain file paths, SIDs, security descriptors and audit context, but no target contents or target-content hashes. The manifest is not self-hashed. +Before launch, the parent writes and flushes `before.json` and `intent.json`. The worker inherits the existing execution policy without an override; a blocked worker remains unverified with its prior intent retained. The fixed worker independently rebuilds the request state, verifies its primary token, performs one native `ReadFile` call requesting one byte and returns a receipt with exact PID, handle and precise `StartedUtc`, `ReadReturnedUtc` and `CompletedUtc` timestamps. Its fixed `OneByteReadAndHeldIdentityReadback` phase spans the one read and the existing same-handle identity/security readback; the immediate `ReadFile` return remains separately visible. Times must satisfy start <= read return <= phase completion <= parent observation. No sleep or timestamp padding is added. The parent retains `operation.json`, the original matching `event.xml`, `after.json` and their SHA-256 hashes in `manifest.json`. These artifacts contain file paths, SIDs, security descriptors and audit context, but no target contents or target-content hashes. The manifest is not self-hashed. -Success requires exactly one fresh version-1 Security 4663 from the expected provider, computer, user SID/logon ID, worker PID/executable, native handle, selected DOS or NT path and ReadData mask/access token. Event time must fall within the actual native read interval, with no padding. The query stops at 256 events and bounds individual XML size; hitting a cap, missing/duplicate evidence, drift, changed reader context or failed persistence prevents verified success. The final Security record boundary must not move backwards. +Success requires exactly one fresh version-1 Security 4663 from the expected provider, computer, user SID/logon ID, worker PID/executable, native handle, selected DOS or NT path and ReadData mask/access token. Event time must fall within that actual measured read/readback phase, with no padding; it need not fall inside the `ReadFile` call itself. The query stops at 256 events and bounds individual XML size; hitting a cap, missing/duplicate evidence, drift, changed reader context or failed persistence prevents verified success. The final Security record boundary must not move backwards. `PrerequisitesObserved` (Plan) and `FileReadObserved` (Run) exit 0. `Unverified` exits 1 and explains the observed gap. A stopped or failed worker may already have attempted the read; durable intent alone does not prove completion. An interrupted process may leave only partial evidence, and a manifest-write failure fails outward while earlier receipts remain. Inspect retained artifacts before deciding whether to run another probe in a different fresh directory. This is evidence for that one current-token local ReadData success. It does not prove Failure auditing, other rights/users/files, child inheritance, forwarded delivery, backend parsing, Sigma readiness or general detection coverage. Security 4663 has no Failure variant. No Sigma/EVTX coverage points are added. -The disposable Windows fixture owns its files and evidence directories, explicitly establishes the test SACL/policy, exercises the public Plan/Run path twice, verifies all artifact hashes and unchanged file content/security, tests missing-SACL/policy and file-replacement refusals, then restores all effective audit masks, typed precedence and token state. It removes only its owned target directory and retains cleanup evidence. Server 2022/2025 and PowerShell 5.1/7 run independently; these fixture changes are not product behavior. +The disposable Windows fixture owns its files in a fresh private system-volume directory and its separate evidence directory, explicitly establishes the test SACL/policy, exercises the public Plan/Run path twice, verifies all artifact hashes and unchanged file content/security, tests missing-SACL/policy and file-replacement refusals, then restores all effective audit masks, typed precedence and token state. It removes only its owned target directory and retains cleanup evidence. Server 2022/2025 and PowerShell 5.1/7 run independently; these fixture changes are not product behavior. Microsoft references: [4663 event semantics and fields](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4663), [ReadFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile), and [same-handle DOS/NT path observation](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew). diff --git a/scripts/FileAccessProbe.ps1 b/scripts/FileAccessProbe.ps1 index 4f183153..e6bc1ff3 100644 --- a/scripts/FileAccessProbe.ps1 +++ b/scripts/FileAccessProbe.ps1 @@ -1,4 +1,51 @@ # Explicit one-byte existing-file read and exact local Security4663 evidence. +# Resolve target scope before reading any external native-helper source or hashing dependencies. +# This small literal helper opens metadata only and never reads target bytes. +function Initialize-WelaFileProbeScopeNative { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'The file probe requires native 64-bit Windows.'} + $definition=@' +using System;using System.ComponentModel;using System.Runtime.InteropServices;using System.Text; +namespace Wela.FileAccessScope { + public sealed class Observation {public string Path;public uint Links,Attributes;} + public static class Native { + public const string SourceSha256="__WELA_FILE_SCOPE_SOURCE_SHA256__"; + [StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out Info info); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags); + public static Observation Observe(string path) { + IntPtr handle=CreateFileW(path,0x80,7,IntPtr.Zero,3,0x00200000,IntPtr.Zero); + if(handle==new IntPtr(-1))throw new Win32Exception(Marshal.GetLastWin32Error(),"Metadata-only target-scope observation failed."); + try{Info info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0); + if(length==0||length>=final.Capacity||!final.ToString().StartsWith(@"\\?\",StringComparison.Ordinal))throw new InvalidOperationException("Canonical local target scope is unknown."); + return new Observation{Path=final.ToString().Substring(4),Links=info.Links,Attributes=info.Attributes}; + }finally{CloseHandle(handle);} + } + } +} +'@ + $hash=Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($definition)) + if(-not ('Wela.FileAccessScope.Native' -as [type])){Add-Type -TypeDefinition $definition.Replace('__WELA_FILE_SCOPE_SOURCE_SHA256__',$hash) -ErrorAction Stop} + if([Wela.FileAccessScope.Native]::SourceSha256 -cne $hash){throw 'Loaded file scope helper differs; start a fresh session.'} +} +function Assert-WelaFileProbeScopeObservation { + param([string]$SelectedPath,$Selected,$SourceFile,$Engine) + foreach($item in @($Selected,$SourceFile,$Engine)){if($item.Path -isnot [string] -or -not $item.Path){throw 'Incomplete canonical target scope.'};Assert-WelaFileProbePath $item.Path} + $sourceRoot=$SourceFile.Path.Substring(0,$SourceFile.Path.LastIndexOf('\')+1) + if($Selected.Path.StartsWith($sourceRoot,[StringComparison]::OrdinalIgnoreCase)){throw 'Select a file outside the WELA source tree; implementation targets are unsupported.'} + if($Selected.Path.Equals($Engine.Path,[StringComparison]::OrdinalIgnoreCase)){throw 'The active PowerShell engine cannot be the selected file target.'} + if($Selected.Path -ine $SelectedPath -or -not(Test-WelaFileProbeInteger $Selected.Links) -or $Selected.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Selected.Attributes) -or ($Selected.Attributes -band 1040)){throw 'Only ordinary canonical single-link file targets are supported; aliases and reparse targets are refused.'} +} +function Assert-WelaFileProbeTargetScope { + param([string]$Path) + Initialize-WelaFileProbeScopeNative + $selected=[Wela.FileAccessScope.Native]::Observe($Path) + $source=[Wela.FileAccessScope.Native]::Observe((Join-Path $script:ScriptRoot 'WELA.ps1')) + $engine=[Wela.FileAccessScope.Native]::Observe((Get-Process -Id $PID -ErrorAction Stop).Path) + Assert-WelaFileProbeScopeObservation $Path $selected $source $engine +} function Initialize-WelaFileProbeNative { Initialize-WelaWmiProbeNative $source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$bytes=[IO.File]::ReadAllBytes($source);$hash=Get-WelaArrivalHash $bytes @@ -57,7 +104,7 @@ function Get-WelaFileProbeSnapshot { } function Get-WelaFileProbeState { param([string]$Path) - Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative + Assert-WelaFileProbePath $Path;Assert-WelaFileProbeTargetScope $Path;Initialize-WelaFileProbeNative $services=@(Get-Service -Name EventLog,Winmgmt,RpcSs -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'EventLog, Winmgmt and RpcSs must already be running.'} $null=Get-WelaFileProbeReaderKey (Get-WelaChannelReader) @@ -117,13 +164,13 @@ function Assert-WelaFileProbeOperation { foreach($name in @('Kind','Nonce','Executable','FilePath')){if($Operation.$name -isnot [string]){throw 'Untyped fixed file worker authority.'}} if($Operation.Kind -cne 'WelaOneByteFileRead' -or $Operation.Nonce -cne $Nonce -or -not(Test-WelaFileProbeInteger $Operation.ProcessId) -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $Operation.FilePath -ine $State.File.Path){throw 'Unexpected fixed file worker identity.'} $read=$Operation.Read - foreach($name in @('Clock','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}} - if($read.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $read.Succeeded -isnot [bool] -or -not $read.Succeeded -or -not(Test-WelaFileProbeInteger $read.ReadCalls) -or $read.ReadCalls -ne 1 -or -not(Test-WelaFileProbeInteger $read.BytesRead) -or $read.BytesRead -ne 1 -or $read.HandleId -cnotmatch '^0x[0-9a-f]+$' -or [Convert]::ToUInt64($read.HandleId.Substring(2),16) -eq 0 -or $read.BeforeKey -cne $State.File.StateKey -or $read.AfterKey -cne $State.File.StateKey){throw 'Expected exactly one successful byte read from the unchanged held file.'} - $start=ConvertTo-WelaArrivalUtc $read.StartedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc - if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $end -lt $start -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte native read interval.'} + foreach($name in @('Clock','Phase','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}} + if($read.Phase -cne 'OneByteReadAndHeldIdentityReadback' -or $read.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $read.Succeeded -isnot [bool] -or -not $read.Succeeded -or -not(Test-WelaFileProbeInteger $read.ReadCalls) -or $read.ReadCalls -ne 1 -or -not(Test-WelaFileProbeInteger $read.BytesRead) -or $read.BytesRead -ne 1 -or $read.HandleId -cnotmatch '^0x[0-9a-f]+$' -or [Convert]::ToUInt64($read.HandleId.Substring(2),16) -eq 0 -or $read.BeforeKey -cne $State.File.StateKey -or $read.AfterKey -cne $State.File.StateKey){throw 'Expected exactly one successful byte read from the unchanged held file.'} + $start=ConvertTo-WelaArrivalUtc $read.StartedUtc;$returned=ConvertTo-WelaArrivalUtc $read.ReadReturnedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc + if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $returned -lt $start -or $end -lt $returned -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte/readback operation interval.'} if((Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $Operation.AfterToken) -or (Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $State.Token) -or (Get-WelaFileProbeReaderKey $Operation.BeforeReader) -cne (Get-WelaFileProbeReaderKey $Operation.AfterReader) -or (Get-WelaFileProbeReaderKey $Operation.BeforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $State.Reader)){throw 'Worker primary token differs from the caller or changed during the native read.'} - $read.StartedUtc=$start.UtcDateTime.ToString('o');$read.CompletedUtc=$end.UtcDateTime.ToString('o') + $read.StartedUtc=$start.UtcDateTime.ToString('o');$read.ReadReturnedUtc=$returned.UtcDateTime.ToString('o');$read.CompletedUtc=$end.UtcDateTime.ToString('o') } function Start-WelaFileProbeRead { param($State,[string]$RequestPath,[string]$Nonce) @@ -202,7 +249,7 @@ function Invoke-WelaFileAccessProbe { $matches=@($batch.Xml|Where-Object {Test-WelaFileProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) $report.Matches=$matches.Count - if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4663 was not observed in the precise read interval.'} + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4663 was not observed in the measured one-byte/readback phase.'} $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0] if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'} $after=Get-WelaFileProbeState $before.File.Path;$report.After=$after diff --git a/scripts/FileAccessProbeNative.cs b/scripts/FileAccessProbeNative.cs index 7fb95d1c..c156f0cd 100644 --- a/scripts/FileAccessProbeNative.cs +++ b/scripts/FileAccessProbeNative.cs @@ -15,7 +15,7 @@ namespace Wela.FileAccessProbe { public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces; } public sealed class ReadReceipt { - public string StartedUtc,CompletedUtc,Clock,HandleId,BeforeKey,AfterKey; + public string StartedUtc,ReadReturnedUtc,CompletedUtc,Clock,Phase,HandleId,BeforeKey,AfterKey; public int ReadCalls,BytesRead; public bool Succeeded; } sealed class SecurityPrivilege : IDisposable { @@ -98,11 +98,14 @@ namespace Wela.FileAccessProbe { public ReadReceipt ReadOne(string expectedKey) { if(!canRead||readAttempted)throw new InvalidOperationException("Exactly one explicitly requested data read is permitted."); Observation before=Observe();if(!String.Equals(before.StateKey,expectedKey,StringComparison.Ordinal))throw new InvalidOperationException("Selected file changed before its one-byte read."); - byte[] buffer=new byte[1];readAttempted=true;uint count=0;DateTime started=UtcNow(),completed;bool success;int error; - try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();completed=UtcNow();}finally{Array.Clear(buffer,0,buffer.Length);} + byte[] buffer=new byte[1];readAttempted=true;uint count=0;DateTime started=UtcNow(),returned;bool success;int error; + try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();returned=UtcNow();}finally{Array.Clear(buffer,0,buffer.Length);} if(!success)throw new Win32Exception(error,"The one-byte read failed.");if(count!=1)throw new InvalidOperationException("The fixed read did not return exactly one byte."); Observation after=Observe();if(after.StateKey!=before.StateKey)throw new InvalidOperationException("Held file identity, data metadata or descriptor changed during the read."); - return new ReadReceipt{StartedUtc=started.ToString("o"),CompletedUtc=completed.ToString("o"),Clock="GetSystemTimePreciseAsFileTime",ReadCalls=1,BytesRead=1,Succeeded=true,HandleId="0x"+unchecked((ulong)handle.ToInt64()).ToString("x"),BeforeKey=before.StateKey,AfterKey=after.StateKey}; + // Measure the real completed phase, including the existing held-handle identity/security readback. + // Retain the immediate ReadFile return separately; add no delay or timestamp padding. + DateTime completed=UtcNow(); + return new ReadReceipt{StartedUtc=started.ToString("o"),ReadReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),Phase="OneByteReadAndHeldIdentityReadback",Clock="GetSystemTimePreciseAsFileTime",ReadCalls=1,BytesRead=1,Succeeded=true,HandleId="0x"+unchecked((ulong)handle.ToInt64()).ToString("x"),BeforeKey=before.StateKey,AfterKey=after.StateKey}; } public void Dispose(){if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}} } diff --git a/tests/FileAccessProbe.Tests.ps1 b/tests/FileAccessProbe.Tests.ps1 index 41cd9785..7b9257e6 100644 --- a/tests/FileAccessProbe.Tests.ps1 +++ b/tests/FileAccessProbe.Tests.ps1 @@ -10,7 +10,7 @@ function New-Fixture { $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';Succeeded=$true;ReadCalls=1;BytesRead=1;HandleId='0x888';BeforeKey=('a'*64);AfterKey=('a'*64);StartedUtc='2026-09-21T00:00:00.0001000Z';CompletedUtc='2026-09-21T00:00:00.0002000Z'};RecordIdBefore=10} + $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 { @@ -22,6 +22,10 @@ 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){ @@ -39,9 +43,9 @@ foreach($name in @('deny-only','disabled-group','inherit-only','failure-ace','ca 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','success','calls','bytes','handle','before','after','token-drift','reader-drift','pre-launch','post-observed','reverse')){ +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};'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'}} + 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 @@ -50,7 +54,8 @@ foreach($change in @(@('4663','4662'),@('1','0'),@('0x80200000 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'} -foreach($time in @('0001000Z','0002000Z')){Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z',$time) $script:operation $script:state) 'exact precise interval boundaries are inclusive'} +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('','0x1') $script:operation $script:state)) 'duplicate XML authority is refused' Assert (-not(Test-WelaFileProbeEvent (']>'+$xml) $script:operation $script:state)) 'DTD evidence is refused' Assert (-not(Test-WelaFileProbeEvent (''+$xml+'') $script:operation $script:state)) 'wrapped event is refused' diff --git a/tests/FileAccessProbe.Windows.Tests.ps1 b/tests/FileAccessProbe.Windows.Tests.ps1 index 254b1290..e6486031 100644 --- a/tests/FileAccessProbe.Windows.Tests.ps1 +++ b/tests/FileAccessProbe.Windows.Tests.ps1 @@ -11,7 +11,9 @@ function Policy-Key($Policy){$ordered=[ordered]@{};foreach($key in @($Policy.Key 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 -$targetRoot=Join-Path $root 'owned-targets';$null=New-Item -ItemType Directory $targetRoot +# 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 @@ -29,9 +31,10 @@ try { $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 precise interval' + 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 @@ -41,13 +44,19 @@ try { 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' - $held=[Wela.FileAccessProbe.FileHandle]::new($file,$false) - try{$denied=$false;try{Remove-Item -LiteralPath $file -Force -ErrorAction Stop}catch{$denied=$true};Assert $denied 'held native observation handle prevents deletion of the selected file'}finally{$held.Dispose()} + # 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) diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 5b7bcf48..ef3c4774 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。同じハンドルのDOS/NTパスと実体、実際のワーカー・トークン・時刻・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) +- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。実装・実行中エンジンの選択をハッシュ処理前に拒否し、同じハンドルのDOS/NTパスと実体、読み取りと実体再確認の実測区間、実際のワーカー・トークン・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) - 完了済みのファイアウォールテキストログ設定を1プロファイルずつ復元する、明示的な `firewall-recovery` を追加しました。元の記録・結果、確認済み計画ハッシュ、実行者・ソース、永続記録と変更前後の確認により、PersistentStore の4項目だけを復元し、強制設定・他のプロファイル・ルールとフィルターを保持します。実効ポリシーを別に報告し、途中失敗を未検証として扱い、自動ロールバックや Sigma 加点は行いません。使い捨て環境の公開 CLI で設定、変更検出、復元、冪等性、完全な後始末を検証します。(関連 #375) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index e63d432b..dda3347b 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Same-handle DOS/NT identity, exact worker/token/time/handle attribution, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) +- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Source/engine targets are refused before hashing. Same-handle DOS/NT identity, exact worker/token/handle attribution over the measured read and identity-readback phase, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) - Added opt-in `firewall-recovery` for one completed firewall text-log operation. Strict original journal/result matching, reviewed plan hashes, native operator/source guards, durable receipts and exact four-field PersistentStore restoration preserve enforcement, other profiles and bounded rule/filter configuration. Effective policy stays separately reported; partial writes remain unverified without automatic rollback or Sigma credit. Disposable public-CLI tests cover configuration, drift refusal, recovery, idempotence and exact cleanup. (Related #375) (@Shirofune-Security) 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 18/21] 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.'} +} 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 19/21] 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() From 8bd7aa0c74e43c204d862821a0cc24ec465566a9 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:07:16 +0900 Subject: [PATCH 20/21] Verify effective native worker module path and complete fixture exit status --- docs/wec-listener.md | 2 +- scripts/WecListener.ps1 | 10 +++++----- scripts/WecListenerWorker.ps1 | 1 + tests/WecListener.Tests.ps1 | 7 ++++--- tests/WecListener.Windows.Tests.ps1 | 5 +++++ 5 files changed, 16 insertions(+), 9 deletions(-) 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 From 39e8ce1d70b652a8041840d722512669dafab513 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:25:54 +0900 Subject: [PATCH 21/21] Resolve combined native source line-ending attributes --- .gitattributes | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 577ca155..d4d2d2bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -69,10 +69,7 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WecRuntime* text eol=lf /tests/WecState* text eol=lf -<<<<<<< HEAD /scripts/WecListener* text eol=lf -======= # Existing-file read receipts bind identical native/worker source bytes. /scripts/FileAccessProbe* text eol=lf /tests/FileAccessProbe* text eol=lf ->>>>>>> feat/373-native-file-access-probe