mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-11-15 09:24:11 +01:00
Add bounded native preflight for one selected WEF QueryList
This commit is contained in:
1 parent
70a812556d
commit
54f9d6bf51
9 files changed
+625
-3
No files matched your search
@@ -0,0 +1,49 @@
|
||||
name: Native WEF query preflight
|
||||
on:
|
||||
push:
|
||||
paths: ['WELA.ps1', 'modules/WefSubscriptions.psm1', 'scripts/WefQuery*', 'tests/WefQuery*', '.github/workflows/wef-query.yml']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
wef-query:
|
||||
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: Query regressions in Windows PowerShell 5.1
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: |
|
||||
./tests/WefQuery.Tests.ps1
|
||||
./tests/WefQuery.Cli.Tests.ps1
|
||||
./tests/WefDeployment.Tests.ps1
|
||||
- name: Query regressions in PowerShell 7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./tests/WefQuery.Tests.ps1
|
||||
./tests/WefQuery.Cli.Tests.ps1
|
||||
./tests/WefDeployment.Tests.ps1
|
||||
- name: Actual public query semantics in Windows PowerShell 5.1
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: ./tests/WefQuery.Windows.Tests.ps1 -AllowDisposableAccount
|
||||
- name: Actual public query semantics in PowerShell 7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: ./tests/WefQuery.Windows.Tests.ps1 -AllowDisposableAccount
|
||||
- name: Retain native query evidence and exact fixture cleanup
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: wef-query-${{ matrix.os }}-${{ matrix.engine }}
|
||||
path: ${{ runner.temp }}/wela-wef-query-*
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -47,6 +47,10 @@
|
||||
[string]$ChannelReadOutputPath,
|
||||
[ValidateSet('Audit', 'Plan', 'Configure')][string]$WefAction = 'Audit',
|
||||
[string]$WefConfigPath,
|
||||
[string]$WefQueryConfigPath,
|
||||
[string]$WefQuerySubscriptionId,
|
||||
[string]$WefQueryOutputPath,
|
||||
[ValidateRange(1,64)][int]$WefQueryMaximumEvents = 16,
|
||||
[string]$RetentionConfigPath,
|
||||
[string]$RetentionPreviousPath,
|
||||
[ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
|
||||
@@ -275,6 +279,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorA
|
||||
. (Join-Path $ScriptRoot "scripts/DnsAnalytical.ps1")
|
||||
Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop
|
||||
. (Join-Path $ScriptRoot "scripts/WefDeployment.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WefQuery.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WecUpdate.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WecIngress.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WecListener.ps1")
|
||||
@@ -2015,6 +2020,7 @@ Usage:
|
||||
./WELA.ps1 provider-packs -ProviderAction List
|
||||
./WELA.ps1 provider-packs -ProviderAction Plan -ProviderPack dns-client,capi2 -ResultsPath provider-plan.json
|
||||
|
||||
./WELA.ps1 wef-query -Help # Execute one selected source QueryList locally
|
||||
./WELA.ps1 wef-source -WefAction Plan -WefConfigPath source.json -ResultsPath source-plan.json
|
||||
./WELA.ps1 wec-collector -WefAction Configure -WefConfigPath collector.json -DryRun
|
||||
|
||||
@@ -2188,6 +2194,8 @@ if ($Cmd -eq 'outgoing-ntlm') {
|
||||
if ($OutgoingNtlmMode -eq 'Deny') {throw 'outgoing-ntlm configures auditing only; Deny enforcement is not accepted.'}
|
||||
if ($NtlmAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAction Configure.'}
|
||||
}
|
||||
if ($Cmd -ne 'wef-query' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WefQuery*'}).Count) {throw 'WefQuery options require wef-query.'}
|
||||
if ($Cmd -eq 'wef-query' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WefQueryConfigPath','WefQuerySubscriptionId','WefQueryOutputPath','WefQueryMaximumEvents','Help')}).Count)) {throw 'wef-query accepts only dedicated read-only options.'}
|
||||
if ($Cmd -ne 'wec-authorization' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecAuthorization*'}).Count) {throw 'WecAuthorization options require wec-authorization.'}
|
||||
if ($Cmd -eq 'wec-authorization' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecAuthorizationAction','WecAuthorizationId','WecAuthorizationSourceSid','WecAuthorizationPlanPath','WecAuthorizationPlanHash','WecAuthorizationOutputPath','Help')}).Count)) {throw 'wec-authorization accepts only dedicated options.'}
|
||||
if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'}
|
||||
@@ -2469,6 +2477,12 @@ switch ($Cmd.ToLower()) {
|
||||
$report
|
||||
if ($report.ExitCode) {exit $report.ExitCode}
|
||||
}
|
||||
'wef-query' {
|
||||
if ($Help) {Write-Host 'Usage: wef-query -WefQueryConfigPath source.json -WefQuerySubscriptionId exact-ID -WefQueryOutputPath new-directory [-WefQueryMaximumEvents 16]. Executes the exact selected local QueryList under the actual caller token. Strict query failures and separate partial diagnostics remain visible; empty reads differ from denied/missing/invalid/capped results. No configuration, NETWORK SERVICE access, forwarding or Sigma claim. See docs/wef-query.md.';return}
|
||||
$report=Invoke-WelaWefQuery -ConfigPath $WefQueryConfigPath -SubscriptionId $WefQuerySubscriptionId -OutputPath $WefQueryOutputPath -MaximumEvents $WefQueryMaximumEvents
|
||||
$report | ConvertTo-Json -Depth 32 | Write-Output
|
||||
exit ([int]$report.ExitCode)
|
||||
}
|
||||
'wec-authorization' {
|
||||
if ($Help) {Write-Host 'Usage: wec-authorization [-WecAuthorizationAction Plan] -WecAuthorizationId ID -WecAuthorizationSourceSid desired-SID1,desired-SID2 -WecAuthorizationOutputPath new-directory; then Apply with -WecAuthorizationPlanPath plan.json -WecAuthorizationPlanHash SHA256 -WecAuthorizationOutputPath new-directory. Only the explicit source SID authorization of one already disabled subscription. No SID resolution or forwarding proof. See docs/wec-authorization.md.';return}
|
||||
$arguments=@{Action=$WecAuthorizationAction;OutputPath=$WecAuthorizationOutputPath}
|
||||
|
||||
@@ -155,9 +155,10 @@ function Test-WelaWefFirewallAddressSet {
|
||||
}
|
||||
|
||||
function Import-WelaWefConfig {
|
||||
param([string]$Path, [ValidateSet('Source','Collector')][string]$Role)
|
||||
param([string]$Path, [ValidateSet('Source','Collector')][string]$Role, [scriptblock]$ReadText)
|
||||
$full = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
||||
$config = Get-Content -LiteralPath $full -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
||||
$configText=if($ReadText){ & $ReadText $full }else{Get-Content -LiteralPath $full -Raw -Encoding UTF8 -ErrorAction Stop}
|
||||
$config = $configText | ConvertFrom-Json -ErrorAction Stop
|
||||
$known = @('SchemaVersion','Role','CollectorFqdn','CollectorUri','Authentication','SourceSids','SubscriptionFiles','Hardening','SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read','ListenerAddress','IngressRuleName','IngressLocalAddresses','IngressRemoteAddresses')
|
||||
foreach ($property in $config.PSObject.Properties) { if ($property.Name -cnotin $known) { throw "Unknown WEF config field: $($property.Name)" } }
|
||||
if ($config.SchemaVersion -ne 1 -or $config.Role -cne $Role) { throw "Expected schema 1 $Role configuration." }
|
||||
@@ -186,7 +187,7 @@ function Import-WelaWefConfig {
|
||||
$subscriptions = @(); $ids = @{}
|
||||
foreach ($file in $config.SubscriptionFiles) {
|
||||
$target = if ([IO.Path]::IsPathRooted($file)) { $file } else { Join-Path (Split-Path $full -Parent) $file }
|
||||
$xml = Get-Content -LiteralPath $target -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
$xml = if($ReadText){ & $ReadText $target }else{Get-Content -LiteralPath $target -Raw -Encoding UTF8 -ErrorAction Stop}
|
||||
$subscription = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids @($config.SourceSids)
|
||||
if ($ids.ContainsKey($subscription.Id)) { throw 'Duplicate subscription ID in selected files.' }
|
||||
$ids[$subscription.Id] = $true; $subscriptions += $subscription
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Exact selected QueryList, current primary token, local read-only native execution.
|
||||
function Get-WelaWefQueryKey {
|
||||
param($Value)
|
||||
(ConvertTo-Json -InputObject $Value -Depth 32 -Compress).Replace('<','\u003c').Replace('>','\u003e').Replace('&','\u0026').Replace("'",'\u0027')
|
||||
}
|
||||
function Initialize-WelaWefQueryNative {
|
||||
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'WefQueryNative.cs'))
|
||||
if($bytes.Length -gt 131072){throw 'Native query source exceeds its bound.'};$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not('Wela.WefQuery.Native' -as [type])){
|
||||
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
if([regex]::Matches($source,'__WELA_WEF_QUERY_SHA256__').Count -ne 1){throw 'Native query source marker is missing or ambiguous.'}
|
||||
Add-Type -TypeDefinition $source.Replace('__WELA_WEF_QUERY_SHA256__',$hash) -ErrorAction Stop
|
||||
}
|
||||
if([Wela.WefQuery.Native]::SourceSha256 -cne $hash){throw 'Loaded query helper differs from current source.'}
|
||||
}
|
||||
function Get-WelaWefQuerySources {
|
||||
$result=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/WefQuery.ps1','scripts/WefQueryNative.cs','scripts/WefQueryWorker.ps1','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/CustomAuditProfiles.ps1','modules/WefSubscriptions.psm1','modules/AuditProfiles.psm1','modules/NativeProviders.psm1','config/native_channel_profile.json')){
|
||||
$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
function Get-WelaWefQueryToken {
|
||||
Initialize-WelaWefQueryNative
|
||||
[Wela.WefQueryToken.Native]::Snapshot()
|
||||
}
|
||||
function Get-WelaWefQueryTokenKey {
|
||||
param($Token)
|
||||
Assert-WelaArrivalObject $Token @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource','Groups','Privileges')
|
||||
foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Mistyped query token text.'}}
|
||||
if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or -not $Token.Name -or $Token.TokenSource -cnotin @('Process','EquivalentSelfThread') -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'Incomplete query token observation.'}
|
||||
foreach($group in $Token.Groups){Assert-WelaArrivalObject $group @('Sid','Attributes');if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$'){throw 'Invalid group SID.'};Assert-WelaWefQueryUInt $group.Attributes}
|
||||
foreach($privilege in $Token.Privileges){Assert-WelaArrivalObject $privilege @('Luid','Attributes');if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$'){throw 'Invalid token privilege.'};Assert-WelaWefQueryUInt $privilege.Attributes}
|
||||
Get-WelaWefQueryKey ([pscustomobject][ordered]@{Sid=$Token.Sid;Name=$Token.Name;AuthenticationId=$Token.AuthenticationId;AuthenticationType=$Token.AuthenticationType;Groups=$Token.Groups;Privileges=$Token.Privileges})
|
||||
}
|
||||
function Assert-WelaWefQueryUInt {param($Value) if(($Value -isnot [int] -and $Value -isnot [long] -and $Value -isnot [uint32]) -or $Value -lt 0 -or $Value -gt [uint32]::MaxValue){throw 'Expected a native unsigned integer.'}}
|
||||
function Import-WelaWefQuerySelection {
|
||||
param([string]$ConfigPath,[string]$SubscriptionId)
|
||||
if(-not $ConfigPath -or -not $SubscriptionId -or $SubscriptionId.Length -gt 256 -or $SubscriptionId -match '[\x00-\x1f]'){throw 'An exact source config path and subscription ID are required.'}
|
||||
$capture=@{Files=[Collections.Generic.List[object]]::new();Bytes=0;Texts=[Collections.Generic.List[string]]::new()}
|
||||
$reader={param($path)
|
||||
$file=Read-WelaWecUpdateFile $path 1048576
|
||||
if($capture.Files.Path -contains $file.Path){throw 'Duplicate input file path.'}
|
||||
if($capture.Files.Count -eq 0){$json=ConvertFrom-WelaArrivalJson $file.Text;if($json.SchemaVersion -isnot [int] -and $json.SchemaVersion -isnot [long]){throw 'WEF schema version must be an integer.'}}
|
||||
$capture.Bytes+=[Text.Encoding]::UTF8.GetByteCount($file.Text);if($capture.Bytes -gt 4194304){throw 'WEF input text exceeds four MiB aggregate.'}
|
||||
$capture.Files.Add([pscustomobject]@{Path=$file.Path;Sha256=$file.Hash});$capture.Texts.Add($file.Text)
|
||||
$file.Text
|
||||
}.GetNewClosure()
|
||||
$model=Import-WelaWefConfig -Path $ConfigPath -Role Source -ReadText $reader
|
||||
$selected=@($model.Subscriptions|Where-Object Id -CEQ $SubscriptionId)
|
||||
if($selected.Count -ne 1){throw 'Select one exact subscription ID from the source config.'};$selected=$selected[0]
|
||||
$doc=Read-WelaWefXml $selected.Xml;$query=[string]$doc.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText
|
||||
$parsed=ConvertFrom-WelaWefQuery $query
|
||||
if($query.Length -gt 65536 -or $parsed.Channels.Count -gt 16 -or $parsed.Filters.Count -gt 128){throw 'Selected QueryList exceeds 65536 characters, 16 channels or 128 filters.'}
|
||||
$index=0;while($model.Subscriptions[$index].Id -cne $SubscriptionId){$index++}
|
||||
[pscustomobject][ordered]@{Id=$SubscriptionId;RequestedEnabled=$selected.Definition.Enabled;CollectorFqdn=$model.Config.CollectorFqdn;CollectorUri=$model.Config.CollectorUri;Query=$query;QuerySha256=(Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($query)));Channels=@($parsed.Channels);Filters=@($parsed.Filters);Files=@($capture.Files.ToArray());ConfigText=$capture.Texts[0];SubscriptionText=$capture.Texts[$index+1]}
|
||||
}
|
||||
function Get-WelaWefQueryHost {
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'}
|
||||
foreach($service in @('Winmgmt','EventLog')){if((Get-Service -Name $service -ErrorAction Stop).Status -ne 'Running'){throw 'Observation services must already be running.'}}
|
||||
$observed=Get-WelaChannelReadHost;$dns=[Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
|
||||
$observed|Add-Member NoteProperty DnsHostName ([string]$dns.HostName)
|
||||
$observed|Add-Member NoteProperty DnsSuffix ([string]$dns.DomainName)
|
||||
$observed
|
||||
}
|
||||
function Get-WelaWefQueryChannelState {
|
||||
param([string[]]$Channels)
|
||||
foreach($channel in $Channels){Get-WelaNativeChannel -Name $channel}
|
||||
}
|
||||
function Assert-WelaWefQueryInputs {
|
||||
param($Selection)
|
||||
foreach($file in $Selection.Files){if((Read-WelaWecUpdateFile $file.Path 1048576).Hash -cne $file.Sha256){throw 'Original WEF configuration or subscription bytes changed.'}}
|
||||
}
|
||||
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.'}
|
||||
[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 {
|
||||
param($Process,$Result)
|
||||
if($Result.Started){
|
||||
$exited=$false;try{$exited=$Process.HasExited}catch{$Result.Diagnostic+=' Exit observation failed: '+$_.Exception.Message}
|
||||
if(-not $exited){try{$Process.Kill()}catch{$Result.Diagnostic+=' Termination request failed: '+$_.Exception.Message};try{$exited=$Process.WaitForExit(5000)}catch{$Result.Diagnostic+=' Termination wait failed: '+$_.Exception.Message}}
|
||||
$Result.TerminationConfirmed=[bool]$exited;if(-not $exited){$Result.Diagnostic+=' Worker termination unconfirmed.'}
|
||||
}
|
||||
try{$Process.Dispose()}catch{$Result.Diagnostic+=' Process cleanup failed: '+$_.Exception.Message}
|
||||
}
|
||||
function Start-WelaWefQueryWorker {
|
||||
param($Engine,[string]$RequestPath,[string]$RequestHash)
|
||||
$worker=Join-Path $PSScriptRoot 'WefQueryWorker.ps1'
|
||||
foreach($path in @($Engine.Path,$worker,$RequestPath)){if($path.Contains('"') -or $path.EndsWith('\') -or $path -match '[\x00-\x1f]'){throw 'Ambiguous query worker path.'}}
|
||||
Initialize-WelaWefQueryNative
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$Engine.Path;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -RequestHash '+$RequestHash
|
||||
$info.EnvironmentVariables['PSModulePath']=$Engine.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)
|
||||
$result=[pscustomobject]@{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 'Query worker did not start.'};$result.Started=$true;$result.ProcessId=$process.Id
|
||||
$stdout=[Wela.WefQuery.Native]::ReadPipe($process.StandardOutput,33554432);$stderr=[Wela.WefQuery.Native]::ReadPipe($process.StandardError,65536)
|
||||
if(-not $process.WaitForExit(45000)){$result.TimedOut=$true;throw 'Native query worker exceeded 45 seconds.'};$result.ExitCode=$process.ExitCode
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Native query output drain timed out.'}
|
||||
if($stderr.Result){throw ('Query worker error output: '+$stderr.Result)}
|
||||
$result.Receipt=ConvertFrom-WelaArrivalJson $stdout.Result
|
||||
}catch{$result.Diagnostic=$_.Exception.Message}finally{Close-WelaWefQueryWorker $process $result}
|
||||
$result
|
||||
}
|
||||
function Assert-WelaWefQueryNativeResult {
|
||||
param($Result,[string[]]$Channels,[int]$MaximumEvents)
|
||||
Assert-WelaArrivalObject $Result @('Opened','Complete','Capped','CleanupConfirmed','NativeError','Diagnostic','Channels','DiagnosticChannels','DiagnosticNativeError','Events')
|
||||
foreach($name in @('Opened','Complete','Capped','CleanupConfirmed')){if($Result.$name -isnot [bool]){throw 'Mistyped native query outcome.'}}
|
||||
if($Result.Diagnostic -isnot [string] -or $Result.Events -isnot [array] -or $Result.Events.Count -gt $MaximumEvents){throw 'Invalid native query evidence count or diagnostic.'}
|
||||
foreach($name in @('NativeError','DiagnosticNativeError')){if($null -ne $Result.$name){Assert-WelaWefQueryUInt $Result.$name}}
|
||||
foreach($field in @('Channels','DiagnosticChannels')){
|
||||
$entries=$Result.$field;if($entries -isnot [array] -or $entries.Count -gt 128){throw 'Invalid native query status list.'}
|
||||
foreach($entry in $entries){Assert-WelaArrivalObject $entry @('Channel','Error');if($entry.Channel -isnot [string] -or $entry.Channel -cnotin $Channels){throw 'Native query status refers to an unselected channel.'};Assert-WelaWefQueryUInt $entry.Error}
|
||||
}
|
||||
if(-not $Result.Opened -and ($Result.Events.Count -or $Result.Channels.Count -or $Result.Complete -or $Result.Capped -or $null -eq $Result.NativeError)){throw 'An unopened strict query cannot have matching evidence.'}
|
||||
if($Result.Opened -and ($Result.DiagnosticChannels.Count -or $null -ne $Result.DiagnosticNativeError)){throw 'Successful strict query has unexpected alternate diagnostic evidence.'}
|
||||
if($Result.Complete -and ($Result.Capped -or -not $Result.CleanupConfirmed -or $null -ne $Result.NativeError -or $Result.Diagnostic)){throw 'Native completeness contradicts an error/cap/cleanup outcome.'}
|
||||
if($Result.Opened){foreach($channel in $Channels){if(-not @($Result.Channels|Where-Object Channel -CEQ $channel).Count){throw 'Native query status omits a selected channel.'}}}
|
||||
$bytes=0
|
||||
foreach($xml in $Result.Events){if($xml -isnot [string] -or $xml.Length -gt 524287){throw 'Invalid or oversized event XML.'};$bytes+=[Text.Encoding]::UTF8.GetByteCount($xml);if($bytes -gt 4194304){throw 'Matching event XML exceeds four MiB.'}}
|
||||
}
|
||||
function Read-WelaWefQueryEvent {
|
||||
param([string]$Xml,[string[]]$Channels,$HostContext)
|
||||
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement
|
||||
if($root.LocalName -cne 'Event' -or $root.NamespaceURI -cne 'http://schemas.microsoft.com/win/2004/08/events/event'){throw 'Native result is not Windows Event XML.'}
|
||||
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e',$root.NamespaceURI)
|
||||
$system=@($root.SelectNodes('e:System',$ns));if($system.Count -ne 1){throw 'Event System identity is missing or ambiguous.'}
|
||||
foreach($name in @('Provider','EventID','EventRecordID','Channel','Computer','TimeCreated')){if(@($system[0].SelectNodes('e:'+$name,$ns)).Count -ne 1){throw 'Event identity is missing or duplicated.'}}
|
||||
$channel=[string]$system[0].SelectSingleNode('e:Channel',$ns).InnerText;$machine=[string]$system[0].SelectSingleNode('e:Computer',$ns).InnerText;$record=[string]$system[0].SelectSingleNode('e:EventRecordID',$ns).InnerText;$provider=$system[0].SelectSingleNode('e:Provider',$ns).GetAttribute('Name');$eventId=[string]$system[0].SelectSingleNode('e:EventID',$ns).InnerText
|
||||
$names=@([string]$HostContext.Computer);if($HostContext.DnsHostName){$names+=[string]$HostContext.DnsHostName;if($HostContext.DnsSuffix){$names+=([string]$HostContext.DnsHostName+'.'+[string]$HostContext.DnsSuffix)}}
|
||||
if($channel -cnotin $Channels -or -not $machine -or $machine -inotIn $names -or $record -cnotmatch '^[1-9][0-9]{0,18}$' -or -not $provider -or $eventId -cnotmatch '^[0-9]{1,5}$'){throw 'Returned event identity differs from selected local provenance.'}
|
||||
$time=ConvertTo-WelaArrivalUtc $system[0].SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime')
|
||||
[pscustomobject]@{Channel=$channel;Computer=$machine;RecordId=[long]$record;Provider=$provider;EventId=[int]$eventId;TimeCreatedUtc=$time.ToString('o')}
|
||||
}
|
||||
function Invoke-WelaWefQuery {
|
||||
param([string]$ConfigPath,[string]$SubscriptionId,[string]$OutputPath,[ValidateRange(1,64)][int]$MaximumEvents=16)
|
||||
$selection=Import-WelaWefQuerySelection $ConfigPath $SubscriptionId
|
||||
$hostState=Get-WelaWefQueryHost;$sources=Get-WelaWefQuerySources;$engine=Get-WelaWefQueryEngine
|
||||
if(-not $OutputPath){throw 'wef-query requires a new output directory.'};$output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWefQueryPreflight';Status='Unverified';ExitCode=1;SubscriptionId=$selection.Id;RequestedEnabled=$selection.RequestedEnabled;CollectorFqdn=$selection.CollectorFqdn;CollectorUri=$selection.CollectorUri;QuerySha256=$selection.QuerySha256;MaximumEvents=$MaximumEvents;Sources=$sources;Inputs=$selection.Files;Host=$hostState;Engine=$engine;ReaderBefore=$null;ReaderAfter=$null;ChannelBefore=@();ChannelAfter=@();Worker=$null;Query=$null;Matches=@();Artifacts=@();Diagnostic='';ConfigurationChanges=0;ReadyRuleCredit=0;Forwarding='Not tested';SourceServiceTokenAccess='Not tested; actual caller token only';Scope='Exact selected local QueryList at observation time; disabled selection may read historical events.'}
|
||||
try{
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'source-config.json' $selection.ConfigText
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'subscription.xml' $selection.SubscriptionText
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'query.xml' $selection.Query
|
||||
$report.ChannelBefore=@(Get-WelaWefQueryChannelState $selection.Channels)
|
||||
$report.ReaderBefore=Get-WelaWefQueryToken;$tokenKey=Get-WelaWefQueryTokenKey $report.ReaderBefore
|
||||
$request=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryRequest';Nonce=[guid]::NewGuid().ToString('N');Query=$selection.Query;QuerySha256=$selection.QuerySha256;Channels=$selection.Channels;MaximumEvents=$MaximumEvents;Sources=$sources;Host=$hostState;Reader=$report.ReaderBefore;Engine=$engine}
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'request.json' (Get-WelaWefQueryKey $request);$report.Artifacts+=$artifact
|
||||
Assert-WelaWefQueryInputs $selection
|
||||
if((Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources) -or (Get-WelaWefQueryTokenKey (Get-WelaWefQueryToken)) -cne $tokenKey){throw 'Sources or actual reader changed before query.'}
|
||||
$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.'}}
|
||||
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.'}
|
||||
Assert-WelaWefQueryNativeResult $receipt.Result $selection.Channels $MaximumEvents
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'worker.json' (Get-WelaWefQueryKey $receipt)
|
||||
$report.Query=$receipt.Result;$number=0;$seen=@{}
|
||||
foreach($xml in $receipt.Result.Events){$metadata=Read-WelaWefQueryEvent $xml $selection.Channels $hostState;$key=$metadata.Channel+':'+$metadata.RecordId;if($seen[$key]){throw 'Duplicate native event identity.'};$seen[$key]=$true;$number++;$name='event-{0:d3}.xml' -f $number;$report.Artifacts+=Write-WelaWecUpdateArtifact $output $name $xml;$report.Matches+=[pscustomobject]@{Artifact=$name;Metadata=$metadata}}
|
||||
# XML is retained in named artifacts/worker evidence, not repeated in the manifest.
|
||||
$report.Query.Events=@();$worker.Receipt=$null
|
||||
$report.ChannelAfter=@(Get-WelaWefQueryChannelState $selection.Channels);$report.ReaderAfter=Get-WelaWefQueryToken
|
||||
Assert-WelaWefQueryInputs $selection
|
||||
if((Get-WelaWefQueryKey (Get-WelaWefQueryHost)) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources) -or (Get-WelaWefQueryTokenKey $report.ReaderAfter) -cne $tokenKey -or (Get-WelaWefQueryKey (Get-WelaWefQueryEngine)) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $report.ChannelAfter) -cne (Get-WelaWefQueryKey $report.ChannelBefore)){throw 'Host/token/source/engine or channel configuration changed during query.'}
|
||||
foreach($file in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $file.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $file.Sha256){throw 'Retained query evidence changed.'}}
|
||||
$result=$report.Query
|
||||
if($result.Opened -and $result.Complete -and $result.CleanupConfirmed -and -not $result.Capped -and $null -eq $result.NativeError -and -not $result.Diagnostic -and -not @($result.Channels|Where-Object Error -NE 0).Count){$report.Status=if($report.Matches.Count){'MatchesObserved'}else{'ReadAllowedEmpty'};$report.ExitCode=0}
|
||||
elseif($result.Opened){$report.Status='Partial'}else{$report.Status='QueryFailed'}
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaWefQueryKey $report)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Read-only native Event Log query and bounded output helpers.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Security.Principal;
|
||||
using System.Threading.Tasks;
|
||||
namespace Wela.WefQuery {
|
||||
public sealed class LogStatus { public string Channel; public uint Error; }
|
||||
public sealed class Result {
|
||||
public bool Opened, Complete, Capped, CleanupConfirmed=true;
|
||||
public uint? NativeError; public string Diagnostic="";
|
||||
public LogStatus[] Channels=new LogStatus[0], DiagnosticChannels=new LogStatus[0];
|
||||
public uint? DiagnosticNativeError;
|
||||
public string[] Events=new string[0];
|
||||
}
|
||||
public static class Native {
|
||||
public const string SourceSha256="__WELA_WEF_QUERY_SHA256__";
|
||||
const int MaximumBuffer=1048576;
|
||||
[DllImport("wevtapi.dll",CharSet=CharSet.Unicode,ExactSpelling=true,SetLastError=true)] static extern IntPtr EvtQuery(IntPtr session,string path,string query,uint flags);
|
||||
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtGetQueryInfo(IntPtr query,int property,uint size,IntPtr buffer,out uint used);
|
||||
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtNext(IntPtr query,uint size,[Out] IntPtr[] events,uint timeout,uint flags,out uint returned);
|
||||
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtRender(IntPtr context,IntPtr value,uint flags,uint size,IntPtr buffer,out uint used,out uint count);
|
||||
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtClose(IntPtr value);
|
||||
static int Offset(IntPtr buffer,int used,IntPtr value,long length) {
|
||||
long offset=value.ToInt64()-buffer.ToInt64();
|
||||
if(value==IntPtr.Zero||offset<16||length<0||offset>used||length>used-offset)throw new InvalidDataException("Native pointer escapes its returned query buffer.");
|
||||
return (int)offset;
|
||||
}
|
||||
static string Text(IntPtr buffer,int used,IntPtr value,int maximum) {
|
||||
int offset=Offset(buffer,used,value,2);if((offset&1)!=0)throw new InvalidDataException("Unaligned native UTF16 string.");
|
||||
int length=0;while(length<=maximum&&offset+2L*length+2<=used){if(Marshal.ReadInt16(buffer,offset+2*length)==0){byte[] bytes=new byte[length*2];Marshal.Copy(value,bytes,0,bytes.Length);return new UnicodeEncoding(false,false,true).GetString(bytes);}length++;}
|
||||
throw new InvalidDataException("Unterminated or oversized native query name.");
|
||||
}
|
||||
static int Header(IntPtr buffer,int used,int expected) {
|
||||
if(buffer==IntPtr.Zero||used<16||used>MaximumBuffer||Marshal.ReadInt32(buffer,12)!=expected)throw new InvalidDataException("Unexpected native query variant type or size.");
|
||||
int count=Marshal.ReadInt32(buffer,8);if(count<0||count>128)throw new InvalidDataException("Native query status count exceeds 128.");return count;
|
||||
}
|
||||
// EVT (not EC) UInt32 is 8; arrays require the exact array bit.
|
||||
public static string[] DecodeNames(IntPtr buffer,int used) {
|
||||
int count=Header(buffer,used,129);IntPtr values=Marshal.ReadIntPtr(buffer);string[] result=new string[count];
|
||||
if(count>0){Offset(buffer,used,values,(long)count*IntPtr.Size);for(int i=0;i<count;i++){result[i]=Text(buffer,used,Marshal.ReadIntPtr(values,i*IntPtr.Size),1024);if(result[i].Length==0)throw new InvalidDataException("Empty native query channel.");}}
|
||||
return result;
|
||||
}
|
||||
public static uint[] DecodeStatuses(IntPtr buffer,int used) {
|
||||
int count=Header(buffer,used,136);IntPtr values=Marshal.ReadIntPtr(buffer);uint[] result=new uint[count];
|
||||
if(count>0){Offset(buffer,used,values,(long)count*4);for(int i=0;i<count;i++)result[i]=unchecked((uint)Marshal.ReadInt32(values,i*4));}return result;
|
||||
}
|
||||
static object Info(IntPtr query,int property) {
|
||||
uint size=0;for(int attempt=0;attempt<4;attempt++){
|
||||
IntPtr buffer=size==0?IntPtr.Zero:Marshal.AllocHGlobal((int)size);
|
||||
try{uint used;bool ok=EvtGetQueryInfo(query,property,size,buffer,out used);int error=Marshal.GetLastWin32Error();
|
||||
if(ok){if(used<16||used>size)throw new InvalidDataException("Native query returned an invalid used length.");return property==0?(object)DecodeNames(buffer,(int)used):DecodeStatuses(buffer,(int)used);}
|
||||
if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native query buffer bound exceeded.");size=used;
|
||||
}finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);}
|
||||
}throw new InvalidDataException("Native query buffer did not stabilize.");
|
||||
}
|
||||
static LogStatus[] Statuses(IntPtr query) {
|
||||
string[] names=(string[])Info(query,0);uint[] codes=(uint[])Info(query,1);
|
||||
if(names.Length!=codes.Length||names.Length==0)throw new InvalidDataException("Incomplete native query channel status arrays.");
|
||||
LogStatus[] result=new LogStatus[names.Length];for(int i=0;i<names.Length;i++)result[i]=new LogStatus {Channel=names[i],Error=codes[i]};return result;
|
||||
}
|
||||
static string Render(IntPtr value) {
|
||||
uint size=0;for(int attempt=0;attempt<4;attempt++){
|
||||
IntPtr buffer=size==0?IntPtr.Zero:Marshal.AllocHGlobal((int)size);
|
||||
try{uint used,count;bool ok=EvtRender(IntPtr.Zero,value,1,size,buffer,out used,out count);int error=Marshal.GetLastWin32Error();
|
||||
if(ok){if(used<2||used>size||(used&1)!=0||count!=0||Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML has an invalid UTF16 boundary.");byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes);if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml;}
|
||||
if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native event XML exceeds one MiB.");size=used;
|
||||
}finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);}
|
||||
}throw new InvalidDataException("Native event XML buffer did not stabilize.");
|
||||
}
|
||||
static void Close(IntPtr handle,Result result) {if(handle!=IntPtr.Zero&&!EvtClose(handle)){result.CleanupConfirmed=false;result.Complete=false;result.Diagnostic+=" Native query/event handle close failed.";}}
|
||||
public static Result Read(string query,int maximum) {
|
||||
if(String.IsNullOrEmpty(query)||query.Length>65536||maximum<1||maximum>64)throw new ArgumentException("Query text/event count exceeds the explicit bound.");
|
||||
Result result=new Result();List<string> events=new List<string>();IntPtr handle=IntPtr.Zero;
|
||||
try{
|
||||
// Local log query, reverse order. Never tolerate errors for matching evidence.
|
||||
handle=EvtQuery(IntPtr.Zero,null,query,0x201);
|
||||
if(handle==IntPtr.Zero){result.NativeError=unchecked((uint)Marshal.GetLastWin32Error());
|
||||
// Diagnostic-only alternate query. Windows may recover parts of invalid XPath.
|
||||
IntPtr diagnostic=EvtQuery(IntPtr.Zero,null,query,0x1201);
|
||||
if(diagnostic==IntPtr.Zero)result.DiagnosticNativeError=unchecked((uint)Marshal.GetLastWin32Error());
|
||||
else try{result.DiagnosticChannels=Statuses(diagnostic);}catch(Exception e){result.Diagnostic+=" Diagnostic status read failed: "+e.Message;}finally{Close(diagnostic,result);}
|
||||
return result;
|
||||
}
|
||||
result.Opened=true;result.Channels=Statuses(handle);long bytes=0;
|
||||
while(true){IntPtr[] next=new IntPtr[1];uint returned=0;bool ok=EvtNext(handle,1,next,5000,0,out returned);int error=Marshal.GetLastWin32Error();
|
||||
try{
|
||||
if(!ok){if(returned!=0||next[0]!=IntPtr.Zero)throw new InvalidDataException("Failed EvtNext returned an unexpected event.");if(error==259)result.Complete=true;else result.NativeError=unchecked((uint)error);break;}
|
||||
if(returned!=1||next[0]==IntPtr.Zero)throw new InvalidDataException("EvtNext returned an invalid count or handle.");
|
||||
if(events.Count==maximum){result.Capped=true;break;}
|
||||
string xml=Render(next[0]);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml);
|
||||
}finally{Close(next[0],result);}
|
||||
}
|
||||
}catch(Win32Exception e){result.NativeError=unchecked((uint)e.NativeErrorCode);result.Complete=false;result.Diagnostic+=e.Message;}
|
||||
catch(Exception e){result.Complete=false;result.Diagnostic+=e.Message;}
|
||||
finally{Close(handle,result);if(!result.CleanupConfirmed)result.Complete=false;result.Events=events.ToArray();}
|
||||
return result;
|
||||
}
|
||||
public static async Task<string> ReadPipe(TextReader reader,int maximum) {
|
||||
var text=new StringBuilder();var buffer=new char[2048];while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(count>maximum-text.Length)throw new InvalidDataException("Worker output exceeds its bound.");text.Append(buffer,0,count);}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Wela.WefQueryToken {
|
||||
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, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource;
|
||||
public Group[] Groups; public Privilege[] Privileges;
|
||||
}
|
||||
public static class Native {
|
||||
[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(length<Marshal.SizeOf(typeof(Statistics)))throw new InvalidOperationException("Truncated token statistics.");result.AuthenticationId=Hex(((Statistics)Marshal.PtrToStructure(p,typeof(Statistics))).AuthenticationId);}finally{Marshal.FreeHGlobal(p);}
|
||||
p=Read(token,2,out length);
|
||||
try {int count=Marshal.ReadInt32(p),offset=(int)Marshal.OffsetOf(typeof(TokenGroups),"First"),size=Marshal.SizeOf(typeof(SidAndAttributes));if(count<0||count>4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List<Group> groups=new List<Group>();for(int i=0;i<count;i++){SidAndAttributes g=(SidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,offset+i*size),typeof(SidAndAttributes));groups.Add(new Group{Sid=new SecurityIdentifier(g.Sid).Value,Attributes=g.Attributes});}groups.Sort((a,b)=>String.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<Privilege> privileges=new List<Privilege>();for(int i=0;i<count;i++){LuidAndAttributes v=(LuidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,4+i*size),typeof(LuidAndAttributes));privileges.Add(new Privilege{Luid=Hex(v.Luid),Attributes=v.Attributes});}privileges.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);}
|
||||
return result;
|
||||
}
|
||||
static bool Equivalent(Token a,Token b) {
|
||||
if(a.Sid!=b.Sid||a.AuthenticationId!=b.AuthenticationId||a.Groups.Length!=b.Groups.Length||a.Privileges.Length!=b.Privileges.Length)return false;
|
||||
for(int i=0;i<a.Groups.Length;i++)if(a.Groups[i].Sid!=b.Groups[i].Sid||a.Groups[i].Attributes!=b.Groups[i].Attributes)return false;
|
||||
for(int i=0;i<a.Privileges.Length;i++)if(a.Privileges[i].Luid!=b.Privileges[i].Luid||a.Privileges[i].Attributes!=b.Privileges[i].Attributes)return false;
|
||||
return true;
|
||||
}
|
||||
static string Difference(Token a,Token b) {
|
||||
if(a.Sid!=b.Sid)return "user SID differs";
|
||||
if(a.AuthenticationId!=b.AuthenticationId)return "logon LUID differs";
|
||||
if(a.Groups.Length!=b.Groups.Length)return "group count differs";
|
||||
for(int i=0;i<a.Groups.Length;i++)if(a.Groups[i].Sid!=b.Groups[i].Sid||a.Groups[i].Attributes!=b.Groups[i].Attributes)return "group "+a.Groups[i].Sid+" process="+a.Groups[i].Attributes+" effective="+b.Groups[i].Attributes;
|
||||
if(a.Privileges.Length!=b.Privileges.Length)return "privilege count differs";
|
||||
for(int i=0;i<a.Privileges.Length;i++)if(a.Privileges[i].Luid!=b.Privileges[i].Luid||a.Privileges[i].Attributes!=b.Privileges[i].Attributes)return "privilege "+a.Privileges[i].Luid+" process="+a.Privileges[i].Attributes+" effective="+b.Privileges[i].Attributes;
|
||||
return "unknown difference";
|
||||
}
|
||||
[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)){int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);}
|
||||
try {
|
||||
if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if(IsTokenRestricted(process)||(thread!=IntPtr.Zero&&IsTokenRestricted(thread)))throw new InvalidOperationException("Restricted tokens are unsupported.");
|
||||
Token primary=ReadToken(process,"Process");
|
||||
if(thread==IntPtr.Zero)return primary;
|
||||
Token effective=ReadToken(thread,"EquivalentSelfThread");
|
||||
if(!Equivalent(primary,effective))throw new InvalidOperationException("Effective thread token differs from the process token ("+Difference(primary,effective)+"); an ordinary child cannot preserve this caller context.");
|
||||
return effective;
|
||||
} finally {if(process!=IntPtr.Zero)CloseHandle(process);if(thread!=IntPtr.Zero)CloseHandle(thread);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
param([Parameter(Mandatory)][string]$RequestPath,[Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{64}$')][string]$RequestHash)
|
||||
# Startup can reconstruct PSModulePath. Reset before any cmdlet/module can load.
|
||||
$env:PSModulePath=[IO.Path]::Combine($PSHOME,'Modules')
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$script:ScriptRoot=[IO.Path]::GetFullPath([IO.Path]::Combine($PSScriptRoot,'..'))
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/WefSubscriptions.psm1') -ErrorAction Stop
|
||||
foreach($name in @('WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $PSScriptRoot ($name+'.ps1'))}
|
||||
$file=Read-WelaWecUpdateFile $RequestPath 2097152
|
||||
if($file.Hash -cne $RequestHash){throw 'Query request hash differs.'};$request=ConvertFrom-WelaArrivalJson $file.Text
|
||||
Assert-WelaArrivalObject $request @('SchemaVersion','Kind','Nonce','Query','QuerySha256','Channels','MaximumEvents','Sources','Host','Reader','Engine')
|
||||
if(($request.SchemaVersion -isnot [int] -and $request.SchemaVersion -isnot [long]) -or $request.SchemaVersion -ne 1 -or $request.Kind -isnot [string] -or $request.Kind -cne 'WelaWefQueryRequest' -or $request.Nonce -isnot [string] -or $request.Nonce -cnotmatch '^[a-f0-9]{32}$' -or $request.Query -isnot [string] -or $request.QuerySha256 -isnot [string] -or $request.QuerySha256 -cne (Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($request.Query)))){throw 'Invalid native query request identity.'}
|
||||
Assert-WelaWefQueryUInt $request.MaximumEvents
|
||||
if($request.MaximumEvents -lt 1 -or $request.MaximumEvents -gt 64 -or $request.Query.Length -gt 65536){throw 'Native query request exceeds its bound.'}
|
||||
$query=ConvertFrom-WelaWefQuery $request.Query
|
||||
if($query.Filters.Count -gt 128 -or $query.Channels.Count -gt 16 -or (Get-WelaWefQueryKey @($query.Channels)) -cne (Get-WelaWefQueryKey $request.Channels)){throw 'Native query channel selection differs.'}
|
||||
$hostState=Get-WelaWefQueryHost;$sources=Get-WelaWefQuerySources;$engine=Get-WelaWefQueryEngine;$before=Get-WelaWefQueryToken
|
||||
if((Get-WelaWefQueryKey $hostState) -cne (Get-WelaWefQueryKey $request.Host) -or (Get-WelaWefQueryKey $sources) -cne (Get-WelaWefQueryKey $request.Sources) -or (Get-WelaWefQueryKey $engine) -cne (Get-WelaWefQueryKey $request.Engine) -or (Get-WelaWefQueryTokenKey $before) -cne (Get-WelaWefQueryTokenKey $request.Reader)){throw 'Worker actual context differs from request.'}
|
||||
$started=[Wela.WefQueryToken.Native]::UtcNow()
|
||||
$result=[Wela.WefQuery.Native]::Read($request.Query,$request.MaximumEvents)
|
||||
$completed=[Wela.WefQueryToken.Native]::UtcNow();$after=Get-WelaWefQueryToken
|
||||
if((Get-WelaWefQueryTokenKey $before) -cne (Get-WelaWefQueryTokenKey $after) -or (Read-WelaWecUpdateFile $RequestPath 2097152).Hash -cne $RequestHash -or (Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources)){throw 'Worker token, request or source changed during query.'}
|
||||
[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=$PID;Engine=$engine;ModulePath=$env:PSModulePath;StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');ReaderBefore=$before;ReaderAfter=$after;Host=$hostState;Sources=$sources;QuerySha256=$request.QuerySha256;Result=$result}|ConvertTo-Json -Depth 32 -Compress
|
||||
exit 0
|
||||
@@ -0,0 +1,17 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$shell=(Get-Process -Id $PID).Path;$script:count=0
|
||||
function Assert-Cli([string[]]$Arguments,[int]$ExitCode,[string]$Text){
|
||||
$ErrorActionPreference='Continue';$output=(& $shell -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1|Out-String);$actual=$LASTEXITCODE;$ErrorActionPreference='Stop'
|
||||
if($actual -ne $ExitCode -or $output -notmatch [regex]::Escape($Text)){throw "CLI regression ($actual expected $ExitCode): $($Arguments -join ' ')`n$output"};$script:count++
|
||||
}
|
||||
Assert-Cli @('wef-query','-Help') 0 'exact selected local QueryList'
|
||||
Assert-Cli @('version','-WefQueryConfigPath','source.json') 1 'WefQuery options require'
|
||||
Assert-Cli @('wef-query','-Auto') 1 'dedicated read-only options'
|
||||
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') 1 'exact source config path and subscription ID'
|
||||
Write-Host "WefQuery.Cli.Tests: $script:count public CLI checks passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,60 @@
|
||||
$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'))}
|
||||
$script:count=0
|
||||
function Assert($value,[string]$message){if(-not $value){throw $message};$script:count++}
|
||||
function Reject([scriptblock]$code,[string]$message){$caught=$false;try{& $code|Out-Null}catch{$caught=$true};Assert $caught $message}
|
||||
function Clone($value){ConvertFrom-WelaArrivalJson (Get-WelaWefQueryKey $value)}
|
||||
Initialize-WelaWefQueryNative
|
||||
$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(128)
|
||||
try{
|
||||
function Reset-Buffer([int]$type,[int]$count){for($i=0;$i -lt 128;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)};[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);[Runtime.InteropServices.Marshal]::WriteInt32($buffer,8,$count);[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16))}
|
||||
Reset-Buffer 136 2;[Runtime.InteropServices.Marshal]::WriteInt32($buffer,16,0);[Runtime.InteropServices.Marshal]::WriteInt32($buffer,20,-1)
|
||||
$values=[Wela.WefQuery.Native]::DecodeStatuses($buffer,24);Assert ($values.Count -eq 2 -and $values[1] -eq [uint32]::MaxValue) 'Native EVT UInt32 status preserves unsigned errors.'
|
||||
foreach($type in @(2,8,130,129,264)){Reset-Buffer $type 1;Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,24)} "Reject wrong status variant $type"}
|
||||
Reset-Buffer 136 129;Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,128)} 'Status count cap.'
|
||||
Reset-Buffer 136 2;Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,20)} 'Status pointer cannot exceed used bytes.'
|
||||
Reset-Buffer 136 1;[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,8));Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,24)} 'Status pointer cannot overlap header.'
|
||||
Reset-Buffer 129 1;[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,16,[IntPtr]::Add($buffer,32));$text=[Text.Encoding]::Unicode.GetBytes('System'+[char]0);[Runtime.InteropServices.Marshal]::Copy($text,0,[IntPtr]::Add($buffer,32),$text.Length)
|
||||
Assert ([Wela.WefQuery.Native]::DecodeNames($buffer,46)[0] -ceq 'System') 'Native string-array pointer and UTF16.'
|
||||
Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,44)} 'Unterminated names refuse.'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt16($buffer,32,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,46)} 'Unpaired Unicode surrogate refuses.'
|
||||
foreach($used in @(0,15,1048577)){Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,$used)} "Invalid buffer length $used"}
|
||||
}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)}
|
||||
$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@()}
|
||||
Assert-WelaWefQueryNativeResult $result @('System') 16;Assert $true 'Complete empty strict result valid.'
|
||||
foreach($field in @('Opened','Complete','Capped','CleanupConfirmed')){$copy=Clone $result;$copy.$field='true';Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed Boolean $field"}
|
||||
foreach($field in @('NativeError','DiagnosticNativeError')){$copy=Clone $result;$copy.$field=$true;Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed native code $field"}
|
||||
$copy=Clone $result;$copy.Channels=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Missing native per-channel provenance.'
|
||||
$copy=Clone $result;$copy.Channels[0].Channel='Application';Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Unexpected native channel.'
|
||||
$copy=Clone $result;$copy.Channels[0].Error=$true;Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Boolean error rejected.'
|
||||
foreach($field in @('Capped','Diagnostic','NativeError','CleanupConfirmed')){$copy=Clone $result;switch($field){Capped{$copy.Capped=$true};Diagnostic{$copy.Diagnostic='failure'};NativeError{$copy.NativeError=5};CleanupConfirmed{$copy.CleanupConfirmed=$false}};Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Completeness cannot coexist with $field"}
|
||||
$failure=Clone $result;$failure.Opened=$false;$failure.Complete=$false;$failure.NativeError=15001;$failure.Channels=@();$failure.DiagnosticChannels=@([pscustomobject]@{Channel='System';Error=15001})
|
||||
Assert-WelaWefQueryNativeResult $failure @('System') 16;Assert $true 'Failed strict query retains separate diagnostic errors.'
|
||||
$failure.Events=@('<Event/>');Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.'
|
||||
$copy=Clone $result;$copy.Events=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.'
|
||||
$copy=Clone $result;$copy.Events=@('x','y');Reject {Assert-WelaWefQueryNativeResult $copy @('System') 1} 'Event bound enforced.'
|
||||
$xml='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Native"/><EventID>1</EventID><EventRecordID>42</EventRecordID><Channel>System</Channel><Computer>Host.example.test</Computer><TimeCreated SystemTime="2026-01-01T00:00:00.1234567Z"/></System><EventData><Data>日本語 Ω & value</Data></EventData></Event>'
|
||||
$hostContext=[pscustomobject]@{Computer='Host';DnsHostName='Host';DnsSuffix='example.test'}
|
||||
$event=Read-WelaWefQueryEvent $xml @('System') $hostContext;Assert ($event.RecordId -eq 42 -and $event.Channel -ceq 'System') 'Native event selected channel/local host provenance.'
|
||||
foreach($bad in @($xml.Replace('<Channel>System','<Channel>Application'),$xml.Replace('Host.example.test','Other.example.test'),$xml.Replace('Host.example.test','Host.unrelated.test'),$xml.Replace('<EventRecordID>42</EventRecordID>',''),$xml.Replace('<EventID>1</EventID>','<EventID>1</EventID><EventID>2</EventID>'),$xml.Replace('2026-01-01T00:00:00.1234567Z','not-utc'))){Reject {Read-WelaWefQueryEvent $bad @('System') $hostContext} 'Native event malformed or mismatched provenance.'}
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-query-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
|
||||
try{
|
||||
Copy-Item (Join-Path $script:ScriptRoot 'config/wef-examples/*') $temp
|
||||
$path=Join-Path $temp 'source.json';$subscription=Join-Path $temp 'native-security.xml';$original=[IO.File]::ReadAllText($path)
|
||||
$selected=Import-WelaWefQuerySelection $path 'WELA Native Security Example'
|
||||
Assert ($selected.Id -ceq 'WELA Native Security Example' -and $selected.Files.Count -eq 2 -and $selected.Channels -contains 'Security') 'Existing source config/parser used with exact bounded inputs.'
|
||||
Assert ($selected.QuerySha256 -ceq (Get-WelaArrivalHash ([Text.Encoding]::UTF8.GetBytes($selected.Query)))) 'Exact extracted QueryList hashed.'
|
||||
Assert-WelaWefQueryInputs $selected;Assert $true 'Unchanged original input hashes valid.'
|
||||
Reject {Import-WelaWefQuerySelection $path 'wela Native Security Example'} 'Selected ID case exact.'
|
||||
[IO.File]::WriteAllText($path,$original.Replace('"SchemaVersion": 1','"SchemaVersion": 1, "SchemaVersion": 1'))
|
||||
Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} 'Duplicate config properties refused.'
|
||||
[IO.File]::WriteAllText($path,$original.Replace('"SchemaVersion": 1','"SchemaVersion": true'))
|
||||
Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} 'Boolean schema rejected.'
|
||||
[IO.File]::WriteAllText($path,$original)
|
||||
[IO.File]::AppendAllText($subscription,' ');Reject {Assert-WelaWefQueryInputs $selected} 'Original subscription byte drift invalidates evidence.'
|
||||
}finally{Remove-Item -LiteralPath $temp -Recurse -Force}
|
||||
$stream=[IO.StringReader]::new('abcdef');try{Reject {[Wela.WefQuery.Native]::ReadPipe($stream,5).GetAwaiter().GetResult()} 'Bounded pipe rejects excess before growing without limit.'}finally{$stream.Dispose()}
|
||||
Write-Host "WefQuery.Tests: $script:count focused assertions passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,105 @@
|
||||
# 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
|
||||
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.'}
|
||||
$nonce=[guid]::NewGuid().ToString('N');$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-wef-query-'+$nonce)) $repo
|
||||
$code=Join-Path $root 'code';$null=New-Item -ItemType Directory $code
|
||||
foreach($name in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $repo $name) -Destination $code -Recurse}
|
||||
$engine=(Get-Process -Id $PID).Path;$channel='Microsoft-Windows-CAPI2/Operational';$userName='WelaQ'+$nonce.Substring(0,12);$ownedSid=$null;$aclChanged=$false;$passed=$false;$cleanupErrors=@();$script:assertions=0
|
||||
function Key($value){Get-WelaWefQueryKey $value}
|
||||
function Assert($value,[string]$message){if(-not $value){throw $message};$script:assertions++}
|
||||
function Save([string]$name,$value){[IO.File]::WriteAllText((Join-Path $root $name),(Key $value),[Text.UTF8Encoding]::new($false))}
|
||||
function Services {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog'"|Sort-Object Name|Select-Object Name,State,StartMode)}
|
||||
function NativeChannels {@('System','Security',$channel)|ForEach-Object {Get-WelaNativeChannel $_}}
|
||||
function New-Case([string]$name,[string]$query){
|
||||
$inputDirectory=Join-Path $root ('input-'+$name);$null=New-Item -ItemType Directory $inputDirectory
|
||||
$config=Get-Content -LiteralPath (Join-Path $repo 'config/wef-examples/source.json') -Raw|ConvertFrom-Json;$config.SubscriptionFiles=@('subscription.xml')
|
||||
$xml=Read-WelaWefXml ([IO.File]::ReadAllText((Join-Path $repo 'config/wef-examples/native-security.xml')))
|
||||
$xml.DocumentElement.SelectSingleNode('*[local-name()="SubscriptionId"]').InnerText='Wela Query '+$nonce
|
||||
$xml.DocumentElement.SelectSingleNode('*[local-name()="Enabled"]').InnerText='false'
|
||||
$xml.DocumentElement.SelectSingleNode('*[local-name()="Description"]').InnerText='Native read-only query 日本語 Ω '+$nonce
|
||||
$xml.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText=$query
|
||||
[IO.File]::WriteAllText((Join-Path $inputDirectory 'source.json'),(Key $config),[Text.UTF8Encoding]::new($false))
|
||||
[IO.File]::WriteAllText((Join-Path $inputDirectory 'subscription.xml'),$xml.OuterXml,[Text.UTF8Encoding]::new($false))
|
||||
[pscustomobject]@{Name=$name;Config=(Join-Path $inputDirectory 'source.json');Id=('Wela Query '+$nonce)}
|
||||
}
|
||||
function Invoke-Public($case,[int]$expected,[int]$maximum=16,[switch]$AsUser){
|
||||
$parent=if($AsUser){$readerHome}else{$root};$output=Join-Path $parent ('result-'+$case.Name)
|
||||
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $code 'WELA.ps1'),'wef-query','-WefQueryConfigPath',$case.Config,'-WefQuerySubscriptionId',$case.Id,'-WefQueryOutputPath',$output,'-WefQueryMaximumEvents',[string]$maximum)
|
||||
foreach($arg in $all){if($arg.Contains('"') -or $arg.EndsWith('\') -or $arg -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}}
|
||||
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
|
||||
if($AsUser){$start.UserName=$userName;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true;$start.WorkingDirectory=$readerHome;$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome}
|
||||
$process=[Diagnostics.Process]::new();$process.StartInfo=$start;$state=[pscustomobject]@{Started=$false;TerminationConfirmed=$false;Diagnostic=''}
|
||||
try{
|
||||
if(-not $process.Start()){throw 'Public query command did not start.'};$state.Started=$true
|
||||
$stdout=[Wela.WefQuery.Native]::ReadPipe($process.StandardOutput,50331648);$stderr=[Wela.WefQuery.Native]::ReadPipe($process.StandardError,1048576)
|
||||
if(-not $process.WaitForExit(180000)){throw 'Public query exceeded three minutes.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'}
|
||||
Save ($case.Name+'-stdout.json') $stdout.Result;Save ($case.Name+'-stderr.json') $stderr.Result
|
||||
Assert ($process.ExitCode -eq $expected) ("Public $($case.Name) exit $($process.ExitCode), expected $expected. "+$stderr.Result+' '+$stdout.Result)
|
||||
}finally{Close-WelaWefQueryWorker $process $state;if($state.Diagnostic -or -not $state.TerminationConfirmed){$script:cleanupErrors+='Public child cleanup: '+$state.Diagnostic}}
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json')))
|
||||
Assert ($manifest.ConfigurationChanges -eq 0 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.RequestedEnabled -eq $false) 'Read-only/disabled selection boundary.'
|
||||
Assert ($manifest.Worker.TerminationConfirmed -and -not $manifest.Worker.Diagnostic) 'Actual bounded worker completed.'
|
||||
foreach($artifact in $manifest.Artifacts){$path=Join-Path $output $artifact.Name;Assert ((Get-Item -LiteralPath $path).Length -eq $artifact.Bytes -and (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Actual artifact bytes/hash.'}
|
||||
foreach($file in $manifest.Inputs){Assert ((Get-FileHash -LiteralPath $file.Path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $file.Sha256) 'Original retained native input bytes.'}
|
||||
if($AsUser){Assert ($manifest.ReaderBefore.Sid -ceq $ownedSid -and $manifest.ReaderBefore.Groups.Sid -notcontains 'S-1-5-32-544' -and $manifest.ReaderBefore.Groups.Sid -notcontains 'S-1-5-32-573') 'Actual owned standard-user token.'}
|
||||
$manifest
|
||||
}
|
||||
$before=[pscustomobject]@{Host=$hostState;Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)}
|
||||
Save 'original.json' $before
|
||||
try{
|
||||
$record=Get-WinEvent -LogName System -MaxEvents 1 -ErrorAction Stop
|
||||
try{$recordId=$record.RecordId;$originalXml=$record.ToXml()}finally{$record.Dispose()}
|
||||
[IO.File]::WriteAllText((Join-Path $root 'original-event.xml'),$originalXml,[Text.UTF8Encoding]::new($false))
|
||||
$query='<QueryList><Query Id="0" Path="System"><Select Path="System">*[System[EventRecordID='+$recordId+']]</Select></Query></QueryList>'
|
||||
$match=Invoke-Public (New-Case 'match' $query) 0
|
||||
Assert ($match.Status -ceq 'MatchesObserved' -and $match.Matches.Count -eq 1 -and $match.Matches[0].Metadata.RecordId -eq $recordId) 'Actual exact System record selected.'
|
||||
$found=[IO.File]::ReadAllText((Join-Path $root 'result-match/event-001.xml'))
|
||||
Assert ((Get-WelaWefXmlKey (Read-WelaWefXml $found).DocumentElement) -ceq (Get-WelaWefXmlKey (Read-WelaWefXml $originalXml).DocumentElement)) 'Actual returned full event matches independent native XML.'
|
||||
$suppressed=$query.Replace('</Query>','<Suppress Path="System">*[System[EventRecordID='+$recordId+']]</Suppress></Query>')
|
||||
$empty=Invoke-Public (New-Case 'suppress' $suppressed) 0
|
||||
Assert ($empty.Status -ceq 'ReadAllowedEmpty' -and $empty.Matches.Count -eq 0 -and $empty.Query.Complete) 'Actual Suppress excludes the selected event and ends empty.'
|
||||
$invalid=Invoke-Public (New-Case 'invalid' '<QueryList><Query Id="0" Path="System"><Select>*[System[EventID=]]</Select></Query></QueryList>') 1
|
||||
Assert ($invalid.Status -ceq 'QueryFailed' -and -not $invalid.Query.Opened -and $invalid.Query.NativeError -ne 0 -and $invalid.Matches.Count -eq 0) 'Native invalid XPath cannot become successful evidence.'
|
||||
$missing='Microsoft-Windows-WelaMissing-'+$nonce+'/Operational'
|
||||
$mixedQuery=$query.Replace('</QueryList>','<Query Id="1" Path="'+$missing+'"><Select>*</Select></Query></QueryList>')
|
||||
$mixed=Invoke-Public (New-Case 'missing' $mixedQuery) 1
|
||||
Assert ($mixed.Status -ceq 'QueryFailed' -and -not $mixed.Query.Opened -and $mixed.Matches.Count -eq 0) 'A missing selected channel fails the strict mixed query.'
|
||||
Assert (@($mixed.Query.DiagnosticChannels|Where-Object {$_.Channel -ceq $missing -and $_.Error -ne 0}).Count -eq 1) 'Separate native diagnostics preserve missing-channel failure.'
|
||||
$capped=Invoke-Public (New-Case 'capped' '<QueryList><Query Id="0" Path="System"><Select>*</Select></Query></QueryList>') 1 1
|
||||
Assert ($capped.Status -ceq 'Partial' -and $capped.Query.Capped -and -not $capped.Query.Complete -and $capped.Matches.Count -eq 1) 'Actual second native record proves event cap.'
|
||||
Assert ((Key (Services)) -ceq (Key $before.Services) -and (Key (NativeChannels)) -ceq (Key $before.Channels)) 'Admin public cases preserve services and all selected channel settings.'
|
||||
$password=ConvertTo-SecureString ('Wela!9'+[guid]::NewGuid().ToString('N')+'rA#') -AsPlainText -Force
|
||||
$user=New-LocalUser -Name $userName -Password $password -Description ('WELA query '+$nonce) -AccountNeverExpires;$ownedSid=$user.SID.Value
|
||||
Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
|
||||
$readerHome=Join-Path $root 'reader';$null=New-Item -ItemType Directory $readerHome
|
||||
$acl=Get-Acl -LiteralPath $root;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $root -AclObject $acl
|
||||
$acl=Get-Acl -LiteralPath $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $readerHome -AclObject $acl
|
||||
$channelBefore=@($before.Channels|Where-Object Name -CEQ $channel)[0];$descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($channelBefore.SecurityDescriptor)
|
||||
$descriptor.DiscretionaryAcl.InsertAce(0,[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,[Security.AccessControl.AceQualifier]::AccessDenied,1,$user.SID,$false,$null));$deny=$descriptor.GetSddlForm([Security.AccessControl.AccessControlSections]::All)
|
||||
$aclChanged=$true;& wevtutil.exe sl $channel ('/ca:'+$deny);if($LASTEXITCODE -ne 0){throw 'Fixture owned deny ACE setter failed.'};$global:LASTEXITCODE=0
|
||||
Assert ((Get-WelaNativeChannel $channel).SecurityDescriptor -ceq $deny) 'Actual fixture-only deny descriptor readback.'
|
||||
$deniedQuery='<QueryList><Query Id="0" Path="'+$channel+'"><Select>*</Select></Query></QueryList>'
|
||||
$denied=Invoke-Public (New-Case 'denied' $deniedQuery) 1 16 -AsUser
|
||||
Assert ($denied.Status -ceq 'QueryFailed' -and $denied.Query.NativeError -eq 5 -and $denied.Matches.Count -eq 0) 'Actual standard-user native access denied.'
|
||||
Assert ((Get-WelaNativeChannel $channel).SecurityDescriptor -ceq $deny) 'Public denied read leaves prepared descriptor unchanged.'
|
||||
$passed=$true
|
||||
}catch{Save 'failure.json' ([pscustomobject]@{Message=$_.Exception.Message;Stack=$_.ScriptStackTrace});throw}
|
||||
finally{
|
||||
if($aclChanged){try{& wevtutil.exe sl $channel ('/ca:'+$channelBefore.SecurityDescriptor);if($LASTEXITCODE -ne 0){throw 'Original descriptor restore failed.'};$global:LASTEXITCODE=0}catch{$cleanupErrors+=$_.Exception.Message}}
|
||||
if($ownedSid){try{$current=Get-LocalUser -Name $userName -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Owned account changed identity.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$cleanupErrors+=$_.Exception.Message}}
|
||||
$restored=$null;try{$restored=[pscustomobject]@{Host=(Get-WelaWefQueryHost);Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)};Save 'restored.json' $restored}catch{$cleanupErrors+=$_.Exception.Message}
|
||||
$channelsRestored=$restored -and (Key $restored.Channels) -ceq (Key $before.Channels);$servicesRestored=$restored -and (Key $restored.Services) -ceq (Key $before.Services);$policyRestored=$restored -and (Key $restored.Masks) -ceq (Key $before.Masks) -and (Key $restored.Precedence) -ceq (Key $before.Precedence);$tokenRestored=$restored -and (Get-WelaWefQueryTokenKey $restored.Token) -ceq (Get-WelaWefQueryTokenKey $before.Token)
|
||||
if(-not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored){$cleanupErrors+='Original channel/services/policy/token differ.'}
|
||||
Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;AccountRemoved=[bool](-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue));Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=(Get-WelaWefQuerySources)})
|
||||
if($cleanupErrors.Count){throw ('Fixture cleanup incomplete: '+($cleanupErrors -join '; '))}
|
||||
}
|
||||
Write-Host "WefQuery.Windows.Tests: $script:assertions actual native assertions passed; complete owned fixture cleanup."
|
||||
exit 0
|
||||
Reference in new issue
Block a user