Merge pull request #464 from Shirofune-Security/feat/368-native-wef-query

Add exact native WEF source query preflight
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-22 14:54:19 +09:00
commit 5978665580
17 files changed
+789 -5

No files matched your search

+3
View File
@@ -97,6 +97,9 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
/tests/WecCollectorObservation* text eol=lf
/tests/WecSubscriptionInventory* text eol=lf
# Native query receipts bind the same source bytes on every supported engine.
/scripts/WefQuery* text eol=lf
/tests/WefQuery* text eol=lf
# Public filesystem-SACL disposable lifecycle evidence.
tests/FileSaclProfileFixture.cs text eol=lf
tests/FileSaclLifecycle.Windows.Tests.ps1 text eol=lf
+1 -1
View File
@@ -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, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/native-filesystem-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.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/wef-query.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, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/native-filesystem-sacl-validation.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+49
View File
@@ -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
+2
View File
@@ -4,6 +4,8 @@
**改善:**
- 読み取り専用の `wef-query` を追加しました。選択したソースのQueryListをそのままネイティブAPIで実行し、Select/Suppressの動作、チャネル別の失敗診断、上限付きの一致イベントXML、実際の操作者・ホスト・ソースの整合性を確認します。空の結果、アクセス拒否、未存在、不正クエリ、上限到達、状態変化を区別し、破棄可能なWindows環境で実イベントの選択・抑制と標準ユーザーの拒否、完全な後片付けを検証します。転送サービスのアクセス権、配送、Sigmaの準備完了は推定しません。 (Related #368) (@Shirofune-Security)
- Server 2022/2025 と PowerShell 5.1/7 の使い捨て環境で、リダイレクトされたユーザーフォルダーの実カタログを使う公開ファイルシステム SACL 設定を検証します。Plan/DryRun/Configure、子の変化による拒否、再実行時の無変更を確認し、無関係なセキュリティ情報と保護された子孫を保持します。継承された末端ファイルの SACL を公開プローブの正確な Security4663 と照合し、型付きプロファイル、ハイブ、トークン、監査ポリシー、所有ファイルの後始末を記録とハッシュで確認します。本番のプロファイル読み込み、遠隔ユーザー、将来の子孫、Sigma の保証は行いません。 (関連 #373) (@Shirofune-Security)
- 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security)
+2
View File
@@ -4,6 +4,8 @@
**Improvements:**
- Added read-only `wef-query` preflight for one exact selected source QueryList, with strict native Select/Suppress execution, separate per-channel failure diagnostics, bounded matching XML and actual caller/host/source guards. Empty, denied, missing, invalid, capped and drifted results remain distinct; disposable native tests cover real record selection/suppression and standard-user denial with exact cleanup. No forwarding-service access, delivery or Sigma credit is inferred. (Related #368) (@Shirofune-Security)
- Added native public filesystem SACL lifecycle validation for one genuine redirected per-user catalog target on disposable Server 2022/2025 with PowerShell 5.1/7. Actual Plan/DryRun/Configure, stale-child refusal and idempotence preserve unrelated security and protected descendants; an inherited leaf SACL is checked against an exact public-probe Security4663. Retained receipts and hashes verify full typed profile, hive, token, audit-policy and owned-file cleanup without production profile loading, remote-user, future-child or Sigma claims. (Related #373) (@Shirofune-Security)
- Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security)
+14
View File
@@ -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}
+1 -1
View File
@@ -52,7 +52,7 @@ ForwardedEvents enablement preserves its size, retention mode and security descr
[Native collector observations](wec-collector-observation.md) use complete bounded WEC name enumeration and strict Unicode XML reads. Failed or partial observations remain unknown; they never become permission to create a subscription. Raw Unicode descriptions/filters and actual disabled state are retained independently of the requested settings.
The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator.
The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator. Use the separate read-only [`wef-query` preflight](wef-query.md) to execute one exact selected QueryList on the local source under the actual caller token, with native Select/Suppress results and distinct empty/failure/partial evidence. It does not test the forwarding service token or remote delivery.
An empty `AllowedSourceDomainComputers` input is filled from the explicit `SourceSids`; a nonempty value must match that authorization exactly. No empty authorization reaches `wecutil`, avoiding Windows' broader default authorization. Non-domain/certificate authorization is not supported. The example Security 4740 filter is illustrative and is not a complete baseline or a recommendation to lock an account for testing.
+50
View File
@@ -0,0 +1,50 @@
# Native source QueryList preflight
`wef-query` executes the exact QueryList from one explicitly selected subscription in an existing WEF **Source** config against local Windows logs. It checks actual native query behavior and the current caller's read access, preserving matching event XML in a new private evidence directory. It changes no Windows settings, contacts no collector and creates no subscription or event.
```powershell
./WELA.ps1 wef-query -WefQueryConfigPath C:\WEF\source.json `
-WefQuerySubscriptionId 'WELA Native Security Example' `
-WefQueryOutputPath C:\Evidence\query-001 `
-WefQueryMaximumEvents 16
```
Use native 64-bit Windows PowerShell 5.1 or PowerShell 7 under the intended reader's session. The observed host must be within WELA's reviewed Windows 11 / Server 2022/2025 build scope, with EventLog and Winmgmt already running. The command does not start services, elevate, impersonate another user or refresh a token. `-Auto`, `-DryRun`, `-WhatIf`, alternate credentials, remote query options and unrelated configuration arguments are rejected. A source's current domain membership does not authorize a remote operation because this command performs none.
The source JSON and explicitly listed subscriptions use the existing [WEF deployment](wef-deployment.md) schema: collector FQDN/URI, domain-format source SIDs and supported built-in native subscription definitions. Select one exact, case-sensitive subscription ID from that config. The collector identity and requested enabled flag remain recorded operator inputs; neither becomes an observed collector setting or verified identity. An explicitly disabled subscription can still be preflighted against historical local records.
## 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. 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.
| Status | Meaning |
| --- | --- |
| `MatchesObserved` | Strict query completed, every selected channel status succeeded, and at least one native matching event was retained. |
| `ReadAllowedEmpty` | Strict query completed with successful channel statuses and no matching events. Empty is distinct from denial, missing logs or invalid syntax. |
| `QueryFailed` | Strict query creation failed; inspect its error and the separate diagnostic channel statuses. |
| `Partial` | The strict query opened, but a cap, read failure, channel error or incomplete cleanup prevented completeness. Retained samples remain individual observations. |
| `Unverified` | Input, worker, context, provenance or artifact checks failed. Inspect the diagnostic and available evidence. |
Only the first two statuses return exit 0. A valid query can legitimately return no records, and a broad valid query can exceed the sample limit. A native success establishes behavior for the current local logs and actual caller at observation time; it does not prove that a future event, another account or the forwarding service will have the same result.
## Bounds and evidence
Each original input is limited to 1 MiB, with 4 MiB aggregate decoded text. The selected QueryList is limited to 65,536 UTF-16 characters, 16 distinct channels and 128 filters. `WefQueryMaximumEvents` accepts 1–64 (default 16). One extra native record is requested to distinguish an exact-sized result from a cap; the extra record is not rendered or retained. Native XML is bounded to 1 MiB of UTF-16 per record and 4 MiB of aggregate UTF-8 matching XML.
The fixed worker uses the same installed PowerShell engine and actual caller context. Its native query handles remain on one thread. Each `EvtNext` uses a five-second timeout; the parent bounds the entire worker to 45 seconds, with bounded output draining and termination waits. A timeout or unconfirmed worker termination cannot earn a complete result. Bounded source, native query-status arrays and pipe buffers prevent unconstrained result allocation.
The new output directory grants access to the current user, SYSTEM and local Administrators. Original inputs and parent ACLs are not changed. Paths must be ordinary local paths accepted by WELA's recovery artifact helpers; existing output directories and observed reparse paths are refused. Raw event payloads can contain sensitive operational data, so retain them as evidence under the intended reader's access policy.
Outputs include decoded `source-config.json`, `subscription.xml`, exact `query.xml`, the worker `request.json`, `worker.json`, individual `event-NNN.xml` matches and a final `manifest.json`. The manifest records original file paths/hashes, source fingerprints, query hash, actual host/DNS context, engine hash/version, before/after reader and channel observations, strict/diagnostic query results and artifact hashes. The worker retains each XML render’s native `PropertyCount` as information; the Server 2022/2025 validation runs observed 1 even though the API documentation specifies 0 for XML. XML parsing uses bounded UTF-16 byte length and its final terminator, following the string rendering contract, independently of that values-array count. The original byte hashes are distinct from the decoded text artifacts. Unsuccessful runs retain whatever evidence was available; a missing final manifest means the output is incomplete.
The worker's SID, logon, group attributes and privileges must match the caller and remain stable. Host, input bytes, implementation, engine, channel configuration and saved hashes are rechecked before completeness. Returned event channel/record identity and exact observed local computer names must be consistent; no same-label arbitrary DNS suffix is accepted. These checks are observations rather than an atomic channel snapshot, and hashes establish consistency rather than authenticating an evidence author.
`ConfigurationChanges` and `ReadyRuleCredit` remain zero. The result does not establish NETWORK SERVICE's effective token, source group membership, policy/SACL generation prerequisites, subscription delivery, origin of historical records, loss, forwarding latency, retention duration or Sigma readiness. Use [channel-read](channel-read.md) for a simple current-token channel read and [wef-arrival](wef-arrival.md) for the separate exact collector-presence workflow. Issue #368 still requires representative multi-host source/collector validation.
## Native validation
The disposable Server 2022/2025 workflow runs both PowerShell engines through the public command. It selects an independently read real System record, verifies complete XML equality, suppresses that same record to obtain a genuine empty result, exercises malformed XPath and a mixed missing-channel query, and proves the event cap with an extra native record. An owned standard user and temporary CAPI2 deny ACE exercise actual access denial. The fixture independently restores the original channel descriptor and removes its owned account, then compares profile/loaded-hive inventories, selected channels, services, all audit masks, precedence and the operator token. The alternate-account process explicitly avoids loading a Windows user profile. These temporary fixture changes are absent from the product. No domain setup, event generation or forwarding is claimed by this native suite.
Microsoft references: [EvtQuery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtquery), [query flags and partial XPath recovery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_query_flags), [per-channel query information](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtgetqueryinfo), [native property types](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_query_property_id), [EvtNext completeness and timeout](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtnext), and [native event XML rendering](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtrender).
+4 -3
View File
@@ -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
+194
View File
@@ -0,0 +1,194 @@
# 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
ConvertTo-WelaWefQueryTokenObservation ([Wela.WefQueryToken.Native]::Snapshot())
}
function ConvertTo-WelaWefQueryTokenObservation {
param($Token)
if($Token -isnot [Wela.WefQueryToken.Token]){throw 'Expected the native query token observation.'}
# Normalize native DTOs at the boundary, using the same strict shape as worker receipts.
$observed=ConvertFrom-WelaArrivalJson (Get-WelaWefQueryKey $Token)
$null=Get-WelaWefQueryTokenKey $observed
$observed
}
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 Assert-WelaWefQuerySourceConfig {
param($Config)
Assert-WelaArrivalObject $Config @('SchemaVersion','Role','CollectorFqdn','CollectorUri','Authentication','SourceSids','SubscriptionFiles','Hardening','SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')
foreach($name in @('SchemaVersion','SubscriptionManagerSlot','RefreshSeconds')){if($Config.$name -isnot [int] -and $Config.$name -isnot [long]){throw 'Source config requires integer schema/slot/refresh fields.'}}
foreach($name in @('Role','CollectorFqdn','CollectorUri','Authentication','Hardening')){if($Config.$name -isnot [string]){throw 'Source config requires typed text fields.'}}
foreach($name in @('SourceSids','SubscriptionFiles')){if($Config.$name -isnot [array]){throw 'Source config requires explicit SID/file arrays.'};foreach($value in $Config.$name){if($value -isnot [string] -or -not $value){throw 'Source config requires nonempty SID/file strings.'}}}
foreach($name in @('GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')){if($Config.$name -isnot [bool]){throw 'Source config requires explicit Boolean permission settings.'}}
}
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()}
# This synchronous callback retains the caller's script scope. GetNewClosure
# creates a dynamic module that cannot see script-local artifact helpers.
$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;Assert-WelaWefQuerySourceConfig $json}
$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
}
$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) -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 {
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','XmlPropertyCounts')
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.'}
if($Result.XmlPropertyCounts -isnot [array] -or $Result.XmlPropertyCounts.Count -ne $Result.Events.Count){throw 'Native XML render observations do not match retained records.'};foreach($count in $Result.XmlPropertyCounts){Assert-WelaWefQueryUInt $count}
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','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
}
+187
View File
@@ -0,0 +1,187 @@
// 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 uint[] XmlPropertyCounts=new uint[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;
}
public static string DecodeXml(IntPtr buffer,uint allocated,uint used) {
if(buffer==IntPtr.Zero||allocated>MaximumBuffer||used<2||used>allocated||(used&1)!=0)throw new InvalidDataException("Native event XML byte boundary differs: used="+used+", allocated="+allocated+".");
if(Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML lacks the final UTF16 terminator.");
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;
}
static string Render(IntPtr value,out uint propertyCount) {
propertyCount=0;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();
// XML is a Unicode string, not an EVT_VARIANT array. Reviewed Server 2022/2025 runs returned
// PropertyCount=1 here despite the documented zero. Retain it as information;
// like .NET EventLogReader, never use it to size or interpret XML.
if(ok){propertyCount=count;return DecodeXml(buffer,size,used);}
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>();List<uint> propertyCounts=new List<uint>();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;}
uint propertyCount;string xml=Render(next[0],out propertyCount);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml);propertyCounts.Add(propertyCount);
}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();result.XmlPropertyCounts=propertyCounts.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);}
}
}
}
+24
View File
@@ -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
+17
View File
@@ -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 '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
+119
View File
@@ -0,0 +1,119 @@
$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
$nativeToken=[Wela.WefQueryToken.Token]::new();$nativeToken.Sid='S-1-5-21-1-2-3-1000';$nativeToken.Name='Host\reader';$nativeToken.AuthenticationId='0x123';$nativeToken.AuthenticationType='NTLM';$nativeToken.ImpersonationLevel='None';$nativeToken.TokenSource='Process'
$nativeGroup=[Wela.WefQueryToken.Group]::new();$nativeGroup.Sid='S-1-5-32-545';$nativeGroup.Attributes=[uint32]::MaxValue;$nativeToken.Groups=@($nativeGroup);$nativeToken.Privileges=@()
$observedToken=ConvertTo-WelaWefQueryTokenObservation $nativeToken
Assert ($observedToken -is [pscustomobject] -and $observedToken.Groups -is [array] -and $observedToken.Groups.Count -eq 1 -and $observedToken.Privileges -is [array] -and $observedToken.Privileges.Count -eq 0) 'Actual native DTO normalizes singleton groups and empty privileges for strict receipt validation.'
Assert ($observedToken.Groups[0].Attributes -eq [uint32]::MaxValue -and (Get-WelaWefQueryTokenKey $observedToken) -ceq (Get-WelaWefQueryTokenKey (Clone $nativeToken))) 'Native token normalization preserves every unsigned attribute and token comparison.'
$nativePrivilege=[Wela.WefQueryToken.Privilege]::new();$nativePrivilege.Luid='0x14';$nativePrivilege.Attributes=2;$nativeToken.Privileges=@($nativePrivilege)
Assert ((ConvertTo-WelaWefQueryTokenObservation $nativeToken).Privileges[0].Attributes -eq 2) 'Native privilege DTO normalizes without losing enabled attributes.'
$nativeToken.Sid='invalid';Reject {ConvertTo-WelaWefQueryTokenObservation $nativeToken} 'Invalid native token remains unverified.'
Reject {ConvertTo-WelaWefQueryTokenObservation $observedToken} 'Native boundary rejects a substituted arbitrary object.'
$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"}
$rendered='<Event>日本語 Ω</Event>';$raw=[Text.Encoding]::Unicode.GetBytes($rendered+[char]0);[Runtime.InteropServices.Marshal]::Copy($raw,0,$buffer,$raw.Length)
Assert ([Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length) -ceq $rendered) 'Bounded native rendered XML preserves exact Unicode.'
foreach($used in @(0,1,3,130)){Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$used)} "Invalid rendered byte boundary $used"}
Reject {[Wela.WefQuery.Native]::DecodeXml([IntPtr]::Zero,128,$raw.Length)} 'Null render buffer refused.'
Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,1048577,$raw.Length)} 'Render allocation cap enforced.'
Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length-2)} 'Missing final XML terminator refused.'
[Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,0);Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length)} 'Embedded rendered XML NUL refused.'
[Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length)} 'Invalid rendered UTF16 surrogate refused.'
}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=@();XmlPropertyCounts=@()}
Assert-WelaWefQueryNativeResult $result @('System') 16;Assert $true 'Complete empty strict result valid.'
$copy=Clone $result;$copy.Events=@('<Event/>');$copy.XmlPropertyCounts=@(1);Assert-WelaWefQueryNativeResult $copy @('System') 16;Assert $true 'Observed XML PropertyCount=1 is informational, not a values-array requirement.'
$copy.XmlPropertyCounts=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Every retained XML has a corresponding render observation.'
$copy.XmlPropertyCounts=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Render observation must be an actual native unsigned count.'
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/>');$failure.XmlPropertyCounts=@(1);Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.'
$copy=Clone $result;$copy.Events=@($true);$copy.XmlPropertyCounts=@(1);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.'
$copy=Clone $result;$copy.Events=@('x','y');$copy.XmlPropertyCounts=@(1,1);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>日本語 Ω &amp; 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.'
foreach($field in @('Role','Hardening','CollectorFqdn','CollectorUri','Authentication')){$config=ConvertFrom-WelaArrivalJson $original;$config.$field=$true;[IO.File]::WriteAllText($path,(Get-WelaWefQueryKey $config));Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} "Boolean text cannot pass source config $field"}
foreach($field in @('SourceSids','SubscriptionFiles')){$config=ConvertFrom-WelaArrivalJson $original;$config.$field=@($true);[IO.File]::WriteAllText($path,(Get-WelaWefQueryKey $config));Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} "Typed source array $field"}
[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()}
# Exercise complete command outcomes and changed evidence through real local artifacts.
$script:lifecycle=@{Case='';HostReads=0;ChannelReads=0;Config='';Xml=$xml}
function Get-WelaWefQueryHost {$script:lifecycle.HostReads++;[pscustomobject]@{Computer=$(if($script:lifecycle.Case -eq 'HostDrift' -and $script:lifecycle.HostReads -gt 1){'Other'}else{'Host'});DnsHostName='Host';DnsSuffix='example.test'}}
function Get-WelaWefQueryEngine {[pscustomobject]@{Path='fixture-engine';Sha256=('a'*64);Version='7.0';ModulePath='fixture-modules'}}
function Get-WelaWefQueryToken {[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='Host\Reader';AuthenticationId='0x123';AuthenticationType='Fixture';ImpersonationLevel='None';TokenSource='Process';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()}}
function Get-WelaWefQueryChannelState {param($Channels) $script:lifecycle.ChannelReads++;[pscustomobject]@{Name='System';State=$(if($script:lifecycle.Case -eq 'ChannelDrift' -and $script:lifecycle.ChannelReads -gt 1){'Disabled'}else{'Enabled'})}}
function Start-WelaWefQueryWorker {
param($Engine,$RequestPath,$RequestHash)
$request=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($RequestPath));$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@($script:lifecycle.Xml);XmlPropertyCounts=@(1)}
if($script:lifecycle.Case -eq 'Empty'){$result.Events=@();$result.XmlPropertyCounts=@()}
if($script:lifecycle.Case -eq 'Partial'){$result.Complete=$false;$result.Capped=$true}
if($script:lifecycle.Case -eq 'MissingStatus'){$result.Channels=@()}
if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml);$result.XmlPropertyCounts=@(1,1)}
if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@();$result.XmlPropertyCounts=@()}
$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,' ')}
if($script:lifecycle.Case -eq 'ArtifactDrift'){[IO.File]::AppendAllText((Join-Path (Split-Path $RequestPath -Parent) 'query.xml'),' ')}
[pscustomobject]@{Started=$true;ProcessId=4242;ExitCode=0;TimedOut=$false;TerminationConfirmed=($script:lifecycle.Case -ne 'Termination');Receipt=$receipt;Diagnostic=''}
}
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-query-lifecycle-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
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='<QueryList><Query Id="0" Path="System"><Select>*</Select></Query></QueryList>';[IO.File]::WriteAllText($subscription,$doc.OuterXml)
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'};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','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}
Write-Host "WefQuery.Tests: $script:count focused assertions passed."
$global:LASTEXITCODE=0
+118
View File
@@ -0,0 +1,118 @@
# 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.'}
$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 Profiles {
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
$key=$null;try{$key=$base.OpenSubKey('SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList',$false);if(-not $key){throw 'Profile inventory unavailable.'};@($key.GetSubKeyNames()|Sort-Object)}finally{if($key){$key.Dispose()};$base.Dispose()}
}
function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)}
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=$false;$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;Profiles=@(Profiles);Hives=@(Hives);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.'
Assert-WelaWefQueryUInt $match.Query.XmlPropertyCounts[0];Assert ($match.Query.XmlPropertyCounts.Count -eq 1) 'Actual native XML PropertyCount is informational and retained.'
$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);Profiles=@(Profiles);Hives=@(Hives);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=$false;$servicesRestored=$false;$policyRestored=$false;$tokenRestored=$false;$profilesRestored=$false;$accountRemoved=$false;$cleanupSources=$null
try{
$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)
$profilesRestored=$restored -and (Key $restored.Profiles) -ceq (Key $before.Profiles) -and (Key $restored.Hives) -ceq (Key $before.Hives)
}catch{$cleanupErrors+='Cleanup comparison: '+$_.Exception.Message}
try{$accountRemoved=-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue)}catch{$cleanupErrors+='Account observation: '+$_.Exception.Message}
try{$cleanupSources=Get-WelaWefQuerySources}catch{$cleanupErrors+='Source observation: '+$_.Exception.Message}
if(-not $profilesRestored -or -not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored -or -not $accountRemoved){$cleanupErrors+='Original profile/hive/channel/services/policy/token or owned account differ.'}
Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;ProfilesAndHivesRestored=[bool]$profilesRestored;AccountRemoved=[bool]$accountRemoved;Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=$cleanupSources})
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
+2
View File
@@ -7,6 +7,8 @@
**改善:**
- 読み取り専用の `wef-query` を追加しました。選択したソースのQueryListをそのままネイティブAPIで実行し、Select/Suppressの動作、チャネル別の失敗診断、上限付きの一致イベントXML、実際の操作者・ホスト・ソースの整合性を確認します。空の結果、アクセス拒否、未存在、不正クエリ、上限到達、状態変化を区別し、破棄可能なWindows環境で実イベントの選択・抑制と標準ユーザーの拒否、完全な後片付けを検証します。転送サービスのアクセス権、配送、Sigmaの準備完了は推定しません。 (Related #368) (@Shirofune-Security)
- Server 2022/2025 と PowerShell 5.1/7 の使い捨て環境で、リダイレクトされたユーザーフォルダーの実カタログを使う公開ファイルシステム SACL 設定を検証します。Plan/DryRun/Configure、子の変化による拒否、再実行時の無変更を確認し、無関係なセキュリティ情報と保護された子孫を保持します。継承された末端ファイルの SACL を公開プローブの正確な Security4663 と照合し、型付きプロファイル、ハイブ、トークン、監査ポリシー、所有ファイルの後始末を記録とハッシュで確認します。本番のプロファイル読み込み、遠隔ユーザー、将来の子孫、Sigma の保証は行いません。 (関連 #373) (@Shirofune-Security)
- 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security)
+2
View File
@@ -7,6 +7,8 @@
**Improvements:**
- Added read-only `wef-query` preflight for one exact selected source QueryList, with strict native Select/Suppress execution, separate per-channel failure diagnostics, bounded matching XML and actual caller/host/source guards. Empty, denied, missing, invalid, capped and drifted results remain distinct; disposable native tests cover real record selection/suppression and standard-user denial with exact cleanup. No forwarding-service access, delivery or Sigma credit is inferred. (Related #368) (@Shirofune-Security)
- Added native public filesystem SACL lifecycle validation for one genuine redirected per-user catalog target on disposable Server 2022/2025 with PowerShell 5.1/7. Actual Plan/DryRun/Configure, stale-child refusal and idempotence preserve unrelated security and protected descendants; an inherited leaf SACL is checked against an exact public-probe Security4663. Retained receipts and hashes verify full typed profile, hive, token, audit-policy and owned-file cleanup without production profile loading, remote-user, future-child or Sigma claims. (Related #373) (@Shirofune-Security)
- Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security)