From ad7ddf4de710e98c4c86f1107328c7bd0a09168b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:14:05 +0900 Subject: [PATCH] Handle native host casing and timestamp compatibility in query evidence --- docs/wef-query.md | 2 +- scripts/WefQuery.ps1 | 4 ++-- tests/WefQuery.Cli.Tests.ps1 | 4 ++-- tests/WefQuery.Tests.ps1 | 10 ++++++---- tests/WefQuery.Windows.Tests.ps1 | 3 ++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/wef-query.md b/docs/wef-query.md index cf0702a1..b9efab22 100644 --- a/docs/wef-query.md +++ b/docs/wef-query.md @@ -15,7 +15,7 @@ The source JSON and explicitly listed subscriptions use the existing [WEF deploy ## Exact query and separate error diagnostics -The existing parser validates supported subscription structure, native channel paths, Select/Suppress clauses and excluded external providers. The extracted QueryList text is then passed directly to native `EvtQuery`; WELA does not rewrite XPath, remove suppressions, substitute a fixed query or enable tolerance for matching evidence. The query runs in reverse record order. Every native channel status must be attributable to a selected channel, and every selected channel must have a reported status before a complete result is possible. +The existing parser validates supported subscription structure, native channel paths, Select/Suppress clauses and excluded external providers. The extracted QueryList text is then passed directly to native `EvtQuery`; WELA does not rewrite XPath, remove suppressions, substitute a fixed query or enable tolerance for matching evidence. The query runs in reverse record order. Windows does not support reverse queries on Analytic/Debug channels; those native failures remain failures, without changing channel state or silently choosing another query mode. Every native channel status must be attributable to a selected channel, and every selected channel must have a reported status before a complete result is possible. If strict query creation fails, its native error remains the primary outcome. A second, separately labeled native diagnostic query can return per-channel status codes with `EvtQueryTolerateQueryErrors`. Windows may recover only part of a malformed XPath under that flag, so **no records from the diagnostic query are read or accepted as matches**. Missing diagnostic details remain unknown. Numeric error codes are preserved without parsing localized message text. diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 index b2eb225e..66ab9d61 100644 --- a/scripts/WefQuery.ps1 +++ b/scripts/WefQuery.ps1 @@ -83,7 +83,7 @@ function Assert-WelaWefQueryInputs { } function Get-WelaWefQueryEngine { $path=(Get-Process -Id $PID -ErrorAction Stop).Path - if([IO.Path]::GetFileName($path) -cnotin @('powershell.exe','pwsh.exe') -or $PSVersionTable.PSVersion.Major -notin @(5,7)){throw 'Native Windows PowerShell 5.1 or PowerShell 7 is required.'} + if([IO.Path]::GetFileName($path) -notin @('powershell.exe','pwsh.exe') -or $PSVersionTable.PSVersion.Major -notin @(5,7)){throw 'Native Windows PowerShell 5.1 or PowerShell 7 is required.'} [pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();Version=$PSVersionTable.PSVersion.ToString();ModulePath=[IO.Path]::Combine($PSHOME,'Modules')} } function Close-WelaWefQueryWorker { @@ -162,7 +162,7 @@ function Invoke-WelaWefQuery { $worker=Start-WelaWefQueryWorker $engine (Join-Path $output 'request.json') $artifact.Sha256;$report.Worker=$worker if(-not $worker.Started -or -not $worker.TerminationConfirmed -or $worker.TimedOut -or $worker.Diagnostic -or $worker.ExitCode -ne 0 -or -not $worker.Receipt){throw ('Query worker did not complete verified observation. '+$worker.Diagnostic)} $receipt=$worker.Receipt;Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Nonce','ProcessId','Engine','ModulePath','StartedUtc','CompletedUtc','ReaderBefore','ReaderAfter','Host','Sources','QuerySha256','Result') - foreach($name in @('Kind','Nonce','ModulePath','StartedUtc','CompletedUtc','QuerySha256')){if($receipt.$name -isnot [string]){throw 'Mistyped worker receipt identity.'}} + foreach($name in @('Kind','Nonce','ModulePath','QuerySha256')){if($receipt.$name -isnot [string]){throw 'Mistyped worker receipt identity.'}} if(($receipt.SchemaVersion -isnot [int] -and $receipt.SchemaVersion -isnot [long]) -or $receipt.SchemaVersion -ne 1 -or $receipt.Kind -cne 'WelaWefQueryWorker' -or $receipt.Nonce -cne $request.Nonce -or ($receipt.ProcessId -isnot [int] -and $receipt.ProcessId -isnot [long]) -or $receipt.ProcessId -ne $worker.ProcessId -or $receipt.ModulePath -cne $engine.ModulePath -or $receipt.QuerySha256 -cne $selection.QuerySha256 -or (Get-WelaWefQueryKey $receipt.Engine) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $receipt.Host) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey $receipt.Sources) -cne (Get-WelaWefQueryKey $sources)){throw 'Worker receipt differs from actual reviewed query/engine/host/source context.'} if((Get-WelaWefQueryTokenKey $receipt.ReaderBefore) -cne $tokenKey -or (Get-WelaWefQueryTokenKey $receipt.ReaderAfter) -cne $tokenKey){throw 'Worker token differs from the actual caller or changed during query.'} if((ConvertTo-WelaArrivalUtc $receipt.StartedUtc) -gt (ConvertTo-WelaArrivalUtc $receipt.CompletedUtc)){throw 'Worker time interval is invalid.'} diff --git a/tests/WefQuery.Cli.Tests.ps1 b/tests/WefQuery.Cli.Tests.ps1 index 2871c67e..d4ad6bf6 100644 --- a/tests/WefQuery.Cli.Tests.ps1 +++ b/tests/WefQuery.Cli.Tests.ps1 @@ -10,8 +10,8 @@ Assert-Cli @('wef-query','-DryRun') 1 'dedicated read-only options' Assert-Cli @('wef-query','-WhatIf') 1 'dedicated read-only options' Assert-Cli @('wef-query','-ResultsPath','out.json') 1 'dedicated read-only options' Assert-Cli @('wef-query','-WefAction','Configure') 1 'dedicated read-only options' -Assert-Cli @('wef-query','-WefQueryMaximumEvents','0') 1 'less than the minimum' -Assert-Cli @('wef-query','-WefQueryMaximumEvents','65') 1 'greater than the maximum' +Assert-Cli @('wef-query','-WefQueryMaximumEvents','0') 1 'WefQueryMaximumEvents' +Assert-Cli @('wef-query','-WefQueryMaximumEvents','65') 1 'WefQueryMaximumEvents' Assert-Cli @('wef-query') 1 'exact source config path and subscription ID' Write-Host "WefQuery.Cli.Tests: $script:count public CLI checks passed." $global:LASTEXITCODE=0 diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 index 9361b576..54f1912c 100644 --- a/tests/WefQuery.Tests.ps1 +++ b/tests/WefQuery.Tests.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +$ErrorActionPreference='Stop';$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 foreach($name in @('WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} @@ -73,6 +73,8 @@ function Start-WelaWefQueryWorker { if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml)} if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@()} $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=4242;Engine=$Engine;ModulePath=$Engine.ModulePath;StartedUtc='2026-01-01T00:00:00Z';CompletedUtc='2026-01-01T00:00:01Z';ReaderBefore=(Get-WelaWefQueryToken);ReaderAfter=(Get-WelaWefQueryToken);Host=$request.Host;Sources=$request.Sources;QuerySha256=$request.QuerySha256;Result=$result} + if($script:lifecycle.Case -eq 'DateTimeReceipt'){$receipt.StartedUtc=[DateTime]::SpecifyKind([datetime]'2026-01-01T00:00:00',[DateTimeKind]::Utc);$receipt.CompletedUtc=$receipt.StartedUtc.AddSeconds(1)} + if($script:lifecycle.Case -eq 'InvalidTimeReceipt'){$receipt.StartedUtc=$true} if($script:lifecycle.Case -eq 'TokenDrift'){$receipt.ReaderAfter.AuthenticationId='0x999'} if($script:lifecycle.Case -eq 'ReceiptBoolean'){$receipt.Kind=$true} if($script:lifecycle.Case -eq 'InputDrift'){[IO.File]::AppendAllText($script:lifecycle.Config,' ')} @@ -84,12 +86,12 @@ try{ Copy-Item (Join-Path $script:ScriptRoot 'config/wef-examples/*') $temp $script:lifecycle.Config=Join-Path $temp 'source.json';$originalConfig=[IO.File]::ReadAllText($script:lifecycle.Config) $subscription=Join-Path $temp 'native-security.xml';$doc=Read-WelaWefXml ([IO.File]::ReadAllText($subscription));$doc.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText='';[IO.File]::WriteAllText($subscription,$doc.OuterXml) - foreach($case in @('Match','Empty','Partial','FailedQuery','MissingStatus','DuplicateEvents','TokenDrift','ReceiptBoolean','InputDrift','ArtifactDrift','Termination','HostDrift','ChannelDrift')){ + foreach($case in @('Match','DateTimeReceipt','InvalidTimeReceipt','Empty','Partial','FailedQuery','MissingStatus','DuplicateEvents','TokenDrift','ReceiptBoolean','InputDrift','ArtifactDrift','Termination','HostDrift','ChannelDrift')){ $script:lifecycle.Case=$case;$script:lifecycle.HostReads=0;$script:lifecycle.ChannelReads=0;[IO.File]::WriteAllText($script:lifecycle.Config,$originalConfig) $report=Invoke-WelaWefQuery $script:lifecycle.Config 'WELA Native Security Example' (Join-Path $temp $case) - $expected=switch($case){Match{'MatchesObserved'};Empty{'ReadAllowedEmpty'};Partial{'Partial'};FailedQuery{'QueryFailed'};default{'Unverified'}} + $expected=switch($case){Match{'MatchesObserved'};DateTimeReceipt{'MatchesObserved'};Empty{'ReadAllowedEmpty'};Partial{'Partial'};FailedQuery{'QueryFailed'};default{'Unverified'}} Assert ($report.Status -ceq $expected) ("Public lifecycle $case expected $expected : "+$report.Diagnostic) - Assert ($report.ExitCode -eq $(if($case -in @('Match','Empty')){0}else{1}) -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) "Public lifecycle $case exit/credit boundaries." + Assert ($report.ExitCode -eq $(if($case -in @('Match','DateTimeReceipt','Empty')){0}else{1}) -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) "Public lifecycle $case exit/credit boundaries." Assert (Test-Path -LiteralPath (Join-Path (Join-Path $temp $case) 'manifest.json')) "Failure/complete manifest retained for $case." } }finally{Remove-Item -LiteralPath $temp -Recurse -Force} diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 index 3c1fdb95..f3d5c12a 100644 --- a/tests/WefQuery.Windows.Tests.ps1 +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -1,10 +1,11 @@ -# Fixture-only owned account and temporary CAPI2 deny ACE. Product is read-only. +# Fixture-only owned account and temporary CAPI2 deny ACE. Product is read-only. param([switch]$AllowDisposableAccount) $ErrorActionPreference='Stop' if(-not $AllowDisposableAccount -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted Windows fixture required.'} $repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -ErrorAction Stop Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -ErrorAction Stop +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -ErrorAction Stop foreach($name in @('Configuration','WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $repo ('scripts/'+$name+'.ps1'))} $hostState=Get-WelaWefQueryHost if($hostState.DomainJoined -or $hostState.DomainRole -ne 2 -or $hostState.ProductType -ne 3){throw 'Standalone disposable Server fixture required.'}