diff --git a/.github/workflows/wef-arrival.yml b/.github/workflows/wef-arrival.yml
new file mode 100644
index 00000000..ec85a4a8
--- /dev/null
+++ b/.github/workflows/wef-arrival.yml
@@ -0,0 +1,40 @@
+name: Native WEF arrival verification
+on:
+ push:
+ branches: ['**']
+ paths:
+ - 'WELA.ps1'
+ - 'scripts/WefArrival.ps1'
+ - 'scripts/NativeValidation.ps1'
+ - 'scripts/ControlApplicability.ps1'
+ - 'scripts/CustomAuditProfiles.ps1'
+ - 'modules/AuditProfiles.psm1'
+ - 'config/audit_profiles.json'
+ - 'tests/WefArrival*'
+ - 'tests/fixtures/WefArrival.Fixture.ps1'
+ - '.github/workflows/wef-arrival.yml'
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ wef-arrival:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, windows-2025]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Synthetic source and collector regressions in Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/WefArrival.Tests.ps1
+ - name: Actual read-only collector queries in Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/WefArrival.Windows.Tests.ps1
+ - name: Synthetic source and collector regressions in PowerShell 7
+ shell: pwsh
+ run: ./tests/WefArrival.Tests.ps1
+ - name: Actual read-only collector queries in PowerShell 7
+ shell: pwsh
+ run: ./tests/WefArrival.Windows.Tests.ps1
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 0ca6d80d..f3eea279 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,7 @@
**改善:**
+- 読み取り専用の`wef-arrival`を追加し、完了したWindows標準4688プローブの資料を検証して、ローカル収集サーバーに元イベントが1件だけ一致するか確認できるようにしました。ハッシュ・形式・環境の厳密な確認、件数を制限したネイティブ検索、実際の読み取りユーザーと変更検出、保護された生XMLの保存により、不完全・曖昧な結果は未検証として保持します。イベントの存在を、配信サブスクリプション・遅延・時刻同期・Sigma利用可能性の証明とは扱いません。ホスト間の正常到着は別途ラボ検証が必要です。 (#418) (@Shirofune-Security)
- 読み取り専用の `score` JSON・自己完結型 HTML レポートを追加し、高度な監査プロファイルへの適合率と重大度別のネイティブルール検証率を分けて表示します。バージョン付き重み、分子・分母、未確認・除外項目、ソースのハッシュ、記録時の証拠コンテキストを保持します。オフラインでは現在の設定を未確認とし、設定の有効化だけで Ready を加算しません。総合的なセキュリティ評価や Sysmon の検知範囲は示しません。 (#417) (@Shirofune-Security)
- 共有する詳細監査プロファイルと優先設定のセキュリティテンプレートについて、オフラインで計画・出力・検証する`gpo-package`を追加しました。省略項目を保持し、成功または失敗だけの最低要件を両方へ拡張する場合は明示指定を求め、未検証のゼロ値による配備は拒否します。出典・申告対象・全項目レビュー・検証済みハッシュを含む配備用ファイルであり、GPOバックアップではありません。正規のGPMC/LGPO準備と未リンクGPO作成のレビュー手順を文書化しました。ドメイン配備と実イベントの検証は別途ラボで必要となり、Sigma検知範囲には加算しません。 (#415) (@Shirofune-Security)
- 検証対象のWindows 11クライアント向けに、共通の標準監査プロファイルからオフラインで出力する`intune-export`を追加しました。Microsoft DDFに基づく59件の明示的な対応表、整数型のOMA-URI CSV/Graphデータ、監査サブカテゴリの優先設定、出典と省略理由の一覧を保存します。最小監査マスクは既定で拒否し、`PromoteToBoth`の明示指定時だけ成功・失敗の両方へ拡張します。新規ローカル出力には検証済みハッシュを付け、アップロード・割り当て・ポリシー削除・Windows設定変更は行いません。Intune配備・競合・復旧・イベントの確認は別途必要です。 (#414) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e3de412..4dc0f583 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
**Improvements:**
+- Added read-only `wef-arrival` to validate a completed native 4688 probe bundle and query the local collector for one exact original event. Strict hashes/schema/context checks, bounded native queries, actual reader observations, drift checks and protected raw evidence keep failed or ambiguous results unverified. Presence is separate from subscription attribution, latency, clock synchronization and Sigma readiness; positive cross-host acceptance remains pending. (#418) (@Shirofune-Security)
- Added read-only `score` JSON and self-contained HTML reports with separate advanced audit-profile compliance and severity-weighted native rule readiness. Versioned weights, explicit numerators/denominators, unknowns, exclusions, source fingerprints and recorded evidence contexts make each result reviewable. Offline scenarios keep current settings Unknown; enabled settings grant no Ready credit, and no overall security grade or Sysmon coverage is implied. (#417) (@Shirofune-Security)
- Added offline `gpo-package` plan, export and verification for shared advanced audit profiles and the precedence security template. Packages preserve omissions, require explicit expansion of one-sided minimum masks, reject unvalidated zero-mask deployment, and include source/target context, full reviews and verified file hashes. These are deployment components, not GPO backups; genuine GPMC/LGPO preparation and reviewed create-unlinked procedures are documented. Native domain application and event evidence remain separate lab acceptance with no Sigma credit. (#415) (@Shirofune-Security)
- Added offline `intune-export` for shared native audit profiles on reviewed Windows 11 client targets, with 59 explicit Microsoft DDF mappings, typed OMA-URI CSV/Graph artifacts, the audit precedence prerequisite and complete source/omission manifests. Static minimum masks are rejected unless explicitly expanded with `PromoteToBoth`; fresh local bundles include verified fingerprints and never upload, assign, delete policies or change Windows. Intune deployment, conflicts, recovery and event evidence remain separate validation. (#414) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index b9f08c71..2d74af81 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -77,6 +77,8 @@
[ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan',
[string]$ProbeOutputPath,
[ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15,
+ [string]$ArrivalProbePath,
+ [string]$ArrivalOutputPath,
[switch]$Help
)
@@ -97,6 +99,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/LdapDiagnostics.ps1")
. (Join-Path $ScriptRoot "scripts/ControlApplicability.ps1")
. (Join-Path $ScriptRoot "scripts/NativeValidation.ps1")
+. (Join-Path $ScriptRoot "scripts/WefArrival.ps1")
. (Join-Path $ScriptRoot "scripts/AuditNotifications.ps1")
. (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1")
. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1")
@@ -1878,6 +1881,7 @@ Usage:
./WELA.ps1 audit-notifications -Help # OneSettings audit and Security warning policy
./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness
./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes
+ ./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector
./WELA.ps1 native-validation -Help # Collect a fixed native 4688 probe without changing policy
./WELA.ps1 version # Show the WELA version
./WELA.ps1 help # Show this help
@@ -1916,6 +1920,12 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) {
if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' }
}
+if ($Cmd -ne 'wef-arrival' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('ArrivalProbePath','ArrivalOutputPath')}).Count) {
+ throw 'Arrival options require wef-arrival. No command was run.'
+}
+if ($Cmd -eq 'wef-arrival' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','ArrivalProbePath','ArrivalOutputPath','Help')}).Count) {
+ throw 'wef-arrival accepts only its dedicated source and output paths. No command was run.'
+}
if ($Cmd -ne 'native-validation' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('ProbeAction','ProbeOutputPath','ProbeTimeoutSeconds') }).Count) {
throw 'Probe options require native-validation. No command was run.'
}
@@ -2040,6 +2050,13 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] Intune export: $_" -ForegroundColor Red; exit 1 }
}
+ 'wef-arrival' {
+ if ($Help) {Write-Host 'Usage: ./WELA.ps1 wef-arrival -ArrivalProbePath existing-native-probe-directory -ArrivalOutputPath new-private-directory. Reads local ForwardedEvents and matches the exact original probe payload. No subscriptions, policy changes, latency or Sigma readiness claims. See docs/wef-arrival.md.'; return}
+ if (-not $ArrivalProbePath -or -not $ArrivalOutputPath) {throw 'ArrivalProbePath and ArrivalOutputPath are required.'}
+ $report=Invoke-WelaWefArrival -ProbePath $ArrivalProbePath -OutputPath $ArrivalOutputPath
+ $report
+ if ($report.ExitCode) {exit $report.ExitCode}
+ }
'native-validation' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 native-validation [-ProbeAction Plan|Run] [-ProbeOutputPath new-directory] [-ProbeTimeoutSeconds 1..30]. Plan reads prerequisites; Run launches a fixed benign cmd.exe probe and collects exact native Security 4688 XML. No policy changes or Sigma readiness credit. See docs/native-validation.md.'; return }
$report=Invoke-WelaNativeValidation -Action $ProbeAction -OutputPath $ProbeOutputPath -TimeoutSeconds $ProbeTimeoutSeconds
diff --git a/docs/wef-arrival.md b/docs/wef-arrival.md
new file mode 100644
index 00000000..08e0c8bc
--- /dev/null
+++ b/docs/wef-arrival.md
@@ -0,0 +1,47 @@
+# Native probe presence on a WEF collector
+
+`wef-arrival` reads the **local ForwardedEvents log** and checks whether it contains the exact original Security 4688 event from a completed [native-validation](native-validation.md) probe bundle. It queries Windows and writes a new private evidence directory; it does not configure subscriptions, start services, enable auditing, launch a process, change existing permissions or contact a remote computer.
+
+```powershell
+# First collect a real fixed probe on the source using native-validation.
+# Transfer that completed, protected bundle through your approved process.
+# On the collector, using the actual intended reader's session:
+./WELA.ps1 wef-arrival -ArrivalProbePath C:\ReviewedSourceEvidence\probe-001 `
+ -ArrivalOutputPath C:\ReviewedCollectorEvidence\arrival-001
+```
+
+Use 64-bit Windows PowerShell 5.1 or PowerShell 7. Both paths must be ordinary local filesystem paths on fixed drives. The output's parent must exist, the output directory must be new and outside the source bundle, and observed reparse points are refused. Relative paths follow PowerShell's current location. No automatic elevation, alternate credentials or remote session is used. All unrelated configuration/profile/output options, including `-Auto` and `-DryRun`, are rejected before command dispatch.
+
+## Source evidence validation
+
+The importer accepts the five-file `WelaNativeProbeComponents` / `security-4688-command-line-v1` contract from the existing probe collector: `manifest.json`, `before-state.json`, `after-state.json`, `process.json` and `event.xml`. It checks exact artifact names, SHA-256 fingerprints, strict JSON without duplicate properties, successful native-probe status and equality between embedded metadata and hashed files. Input files are limited to 4 MiB each; unknown or incomplete bundles are rejected before querying the collector or creating output.
+
+It validates all 59 typed audit masks, typed registry prerequisites, actual recorded source role/build/patch/join context, the fixed System32 cmd.exe echo command and unique probe marker, process identities, source timestamp ordering and stable before/after state. The source XML must match the recorded native probe, including provider GUID, successful 4688 version 2, original computer, child/creator PIDs and complete command line. Combined DC/CA and source builds outside the existing probe's reviewed scope are refused. Source evidence is revalidated after a successful query; a changed bundle prevents a presence result.
+
+These checks establish consistency, not authenticity. Hashes and producer status are not signatures, and an evidence author can manufacture a self-consistent bundle. Protect the original source evidence and use an independently reviewed collection/transfer process. Do not use the synthetic test fixtures as real source evidence.
+
+## Collector query and matching
+
+The query uses the physical `ForwardedEvents` channel, the Security-Auditing provider, EventID 4688, the exact source computer and a two-second window centered on the original event's timestamp. This is a search window around the recorded source timestamp, not a clock-skew allowance or measured delivery time. It reads at most 512 candidates; reaching that limit leaves completeness unknown and the result unverified. This is one synchronous native query, not a polling loop or a guaranteed wall-clock timeout. Rerun into a new directory after a later collector observation if needed.
+
+Every original `System` and `EventData` value must match. XML namespace prefixes, attribute order and formatting indentation are insignificant, while original payload text, attributes, record identity and data order remain significant. The optional native `RenderingInfo` section can differ because it contains rendered/localized strings. Other added event sections, duplicate structures, DTDs and processing instructions within the event are refused. Microsoft documents that [forwarding retains original event data and can add information](https://learn.microsoft.com/en-us/windows/win32/wec/windows-event-collector); [RenderingInfo](https://learn.microsoft.com/en-us/windows/win32/wes/eventschema-renderinginfo-eventtype-element) contains rendered message strings. This deliberately narrow matcher leaves unsupported serialization unverified rather than guessing.
+
+The reader records its actual SID, account name, authentication/impersonation information and group SIDs. Collector computer/build/patch/domain/installed-role context and ForwardedEvents configuration are read before and after the query. Changes to the host, reader or channel prevent a successful result. Effective read access is demonstrated only for the session that actually performed this query; observed group SIDs are not an access assessment for other users. A disabled channel can still contain historical records: its observed enabled flag is reported separately from presence, and no WEC service/active subscription health is inferred.
+
+## Results and recovery
+
+`PresentOnCollector` and exit 0 require exactly one matching original event, uncapped query results, unchanged collector context and unchanged source evidence. No match, duplicates, malformed candidates, denied reads, caps or drift return `Unverified` and exit 1. Absence does not prove loss; an event could be pending, excluded, expired or outside the currently readable data. Presence can be historical and does not establish when or how the event entered the log.
+
+The output contains `source-event.xml`, `collector-before.json`, `collector-after.json` when readable, and `collector-event.xml` for a single match. For ambiguous matches, at most two raw duplicate XML records are retained without selecting one. `manifest.json` is written last and includes the original source manifest/fingerprint, actual collector observations, exact query and observation times, counts, diagnostics and hashes for emitted components. Candidate events that do not match are not exported. Failed runs preserve available observations and raw evidence; a missing final manifest means output is incomplete. Manifest or file-write failure propagates as a command failure.
+
+The new output directory's DACL grants access only to the current user, SYSTEM and local Administrators. Existing input directories and their ACLs are untouched. Files use CreateNew and verified readback rather than overwriting previous evidence. Treat evidence as sensitive host/process metadata. After an interrupted run, inspect the owned output directory and retain or remove it through your normal evidence process; no Windows policy recovery is needed. Filesystem checks are observations, not a lock against later concurrent changes, so protect and reverify evidence before sharing it.
+
+The result always retains `SubscriptionAttribution: Not established`, `TransmissionLatency: Not measured`, `ClockSynchronization: Not established`, `ReadyRuleCredit: 0` and `PolicyChanges: 0`. Multiple subscriptions can share ForwardedEvents. An exact record does not identify which subscription delivered it, prove accurate shared clocks, establish end-to-end latency, validate a translated query or make a Sigma rule Ready. Sysmon and other external telemetry are excluded.
+
+## Tests and remaining lab acceptance
+
+Fixtures test every source validation boundary, original-payload changes, permitted rendering additions, significant whitespace, ambiguity, caps, access failures, resource disposal, reader/channel/source drift, path safety and early public guards. Their positive matches are synthetic and supply no native forwarding evidence.
+
+Server 2022/2025 CI under PowerShell 5.1/7 performs real read-only local collector/context queries, verifies a synthetic source marker is absent and checks output protection and unchanged collector settings. It does not create a subscription, generate an event or claim a successful cross-host arrival.
+
+Before closing #368, test representative Windows 11, member server, DC and member CA sources against a dedicated isolated collector. Retain the real source probe and matched collector XML, actual reader and both contexts. Include delayed/missing delivery, disabled subscriptions, denied readers, duplicate events and configuration drift. Confirm both Events and RenderedText formats against native output. Validate query membership and subscription attribution separately; source/collector clock agreement and actual ingestion latency need independent evidence. See [WEF deployment](wef-deployment.md), [Microsoft WEF operation and formats](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), and [Get-WinEvent query controls](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.diagnostics/get-winevent).
diff --git a/scripts/WefArrival.ps1 b/scripts/WefArrival.ps1
new file mode 100644
index 00000000..a4549e6f
--- /dev/null
+++ b/scripts/WefArrival.ps1
@@ -0,0 +1,241 @@
+# Read-only local collector correlation. No subscription, policy, service or process changes.
+function Assert-WelaArrivalObject {
+ param($Value,[string[]]$Fields)
+ if ($Value -isnot [pscustomobject] -or @($Value.PSObject.Properties).Count -ne $Fields.Count -or
+ @($Value.PSObject.Properties.Name | Where-Object {$_ -cnotin $Fields}).Count) {throw 'Unexpected or incomplete arrival evidence object.'}
+}
+function Get-WelaArrivalHash {
+ param([byte[]]$Bytes)
+ $sha=[Security.Cryptography.SHA256]::Create()
+ try {([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()}
+}
+function ConvertFrom-WelaArrivalJson {
+ param([string]$Text)
+ # Reuse the merged strict JSON lexer/duplicate-key validator, without executing input.
+ $null=& (Get-Module AuditProfiles -ErrorAction Stop) {param($value) ConvertFrom-WelaCustomProfileJson $value} $Text
+ $arguments=@{InputObject=$Text;ErrorAction='Stop'}
+ if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {$arguments.DateKind='String'}
+ ConvertFrom-Json @arguments
+}
+function ConvertTo-WelaArrivalUtc {
+ param($Value)
+ if ($Value -is [datetime]) {
+ if ($Value.Kind -ne [DateTimeKind]::Utc) {throw 'Expected an explicit UTC evidence timestamp.'}
+ return [DateTimeOffset]$Value
+ }
+ if ($Value -isnot [string] -or $Value -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,7})?Z$') {throw 'Expected an explicit UTC evidence timestamp.'}
+ return [DateTimeOffset]::Parse($Value,[Globalization.CultureInfo]::InvariantCulture)
+}
+function Resolve-WelaArrivalPath {
+ param([Parameter(Mandatory)][string]$Path)
+ if ($Path -match '[\x00-\x1f*?\[\]]') {throw 'Arrival evidence requires exact paths without wildcards or control characters.'}
+ $provider=$null;$drive=$null
+ $full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
+ if ($provider.Name -ne 'FileSystem' -or $full -match '^[\\/]{2}' -or $full.Substring([IO.Path]::GetPathRoot($full).Length).Contains(':')) {throw 'Arrival evidence requires ordinary local filesystem paths, without remote/device paths or streams.'}
+ $full=[IO.Path]::GetFullPath($full);$ancestor=$full
+ while ($ancestor) {
+ $item=Get-Item -LiteralPath $ancestor -Force -ErrorAction SilentlyContinue
+ if ($item -and ([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint)) {throw 'Arrival evidence cannot traverse reparse points.'}
+ $parent=[IO.Directory]::GetParent($ancestor);if (-not $parent) {break};$ancestor=$parent.FullName
+ }
+ if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and ([IO.DriveInfo]::new([IO.Path]::GetPathRoot($full))).DriveType -ne [IO.DriveType]::Fixed) {throw 'Arrival evidence requires a local fixed drive.'}
+ $full
+}
+function Get-WelaArrivalXmlKey {
+ param($Node)
+ # Namespace-aware semantic equality; attribute order/prefixes are not event data.
+ $attributes=@($Node.Attributes | Where-Object {$_.NamespaceURI -ne 'http://www.w3.org/2000/xmlns/'} | Sort-Object NamespaceURI,LocalName | ForEach-Object {ConvertTo-Json -InputObject @($_.NamespaceURI,$_.LocalName,$_.Value) -Compress})
+ $children=@();$text='';$hasElements=@($Node.ChildNodes|Where-Object NodeType -eq Element).Count -gt 0
+ foreach ($child in $Node.ChildNodes) {
+ if ($child.NodeType -eq 'Element') {$children+=Get-WelaArrivalXmlKey $child}
+ elseif ($child.NodeType -in @('Text','CDATA','SignificantWhitespace')) {$text+=$child.Value}
+ elseif ($child.NodeType -eq 'Whitespace') {if(-not $hasElements){$text+=$child.Value}}
+ else {throw 'Unsupported event XML node.'}
+ }
+ ConvertTo-Json -InputObject @($Node.NamespaceURI,$Node.LocalName,$attributes,$text,$children) -Depth 30 -Compress
+}
+function Read-WelaArrivalEvent {
+ param([string]$Xml)
+ $settings=New-Object Xml.XmlReaderSettings
+ $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304
+ $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings)
+ try {$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.PreserveWhitespace=$true;$doc.Load($reader)} finally {$reader.Dispose()}
+ $ns='http://schemas.microsoft.com/win/2004/08/events/event';$root=$doc.DocumentElement
+ if ($root.LocalName -cne 'Event' -or $root.NamespaceURI -cne $ns -or @($root.Attributes|Where-Object NamespaceURI -ne 'http://www.w3.org/2000/xmlns/').Count) {throw 'Unknown event root or attributes.'}
+ $parts=@{}
+ foreach ($node in $root.ChildNodes) {
+ if ($node.NodeType -in @('Whitespace')) {continue}
+ if ($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns -or $node.LocalName -cnotin @('System','EventData','RenderingInfo') -or $parts.ContainsKey($node.LocalName)) {throw 'Only one System/EventData and optional RenderingInfo are supported.'}
+ $parts[$node.LocalName]=$node
+ }
+ if (-not $parts.System -or -not $parts.EventData) {throw 'Original System and EventData are required.'}
+ $system=@{}
+ foreach ($node in $parts.System.ChildNodes) {
+ if ($node.NodeType -eq 'Whitespace') {continue}
+ if ($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns -or $system.ContainsKey($node.LocalName)) {throw 'Ambiguous event System data.'}
+ $system[$node.LocalName]=$node
+ }
+ if ($system.Computer.InnerText -cnotmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$') {throw 'Unsupported source computer identity.'}
+ $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
+ [pscustomobject]@{Key=((Get-WelaArrivalXmlKey $parts.System)+'|'+(Get-WelaArrivalXmlKey $parts.EventData));Computer=$system.Computer.InnerText;EventUtc=$time;RecordId=$system.EventRecordID.InnerText;RenderingInfoPresent=[bool]$parts.RenderingInfo}
+}
+function ConvertTo-WelaArrivalState {
+ param($State)
+ Assert-WelaArrivalObject $State @('capturedAtUtc','context','hostObservation','auditPolicies','auditPrecedence','commandLineCapture','securityChannelEnabled')
+ Assert-WelaArrivalObject $State.context @('computer','role','build','patch','domainJoined','installedRoles')
+ Assert-WelaArrivalObject $State.hostObservation @('Status','Build','UBR','Edition','ProductType','DomainRole','DomainJoined','Domain','Architecture','ProcessorArchitecture','InstalledRoles','RolesStatus','Diagnostic')
+ foreach ($registry in @($State.auditPrecedence,$State.commandLineCapture)) {
+ Assert-WelaArrivalObject $registry @('KeyExists','ValueExists','Value','Type')
+ if ($registry.KeyExists -isnot [bool] -or -not $registry.KeyExists -or $registry.ValueExists -isnot [bool]) {throw 'Unknown registry prerequisite presence.'}
+ }
+ foreach ($value in @($State.context.build,$State.hostObservation.ProductType,$State.hostObservation.DomainRole)) {if ($value -isnot [int] -and $value -isnot [long]) {throw 'Mistyped source role/build identity.'}}
+ if ($State.context.role -cnotin @('Client','MemberServer','DomainController','ADCS') -or $State.context.computer -cnotmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$') {throw 'Unknown source role or computer.'}
+ $policies=@{};$catalog=(Import-WelaAuditProfiles).catalog
+ foreach ($entry in $State.auditPolicies.PSObject.Properties) {
+ if ($entry.Name -notin $catalog.guid -or ($entry.Value -isnot [int] -and $entry.Value -isnot [long]) -or $entry.Value -notin @(0,1,2,3)) {throw 'Unknown or mistyped native audit policy evidence.'}
+ $policies[$entry.Name]=$entry.Value
+ }
+ if ($policies.Count -ne 59) {throw 'A complete 59-subcategory source snapshot is required.'}
+ $State.auditPolicies=$policies
+ Assert-WelaProbePrerequisites $State
+ if (($State.context.role -eq 'Client' -and $State.context.build -notin @(22000,22621,22631,26100,26200)) -or
+ ($State.context.role -ne 'Client' -and $State.context.build -notin @(20348,26100)) -or
+ ($State.context.role -eq 'DomainController' -and $State.context.installedRoles -contains 'ADCS-Cert-Authority')) {throw 'Source role/build is outside the reviewed native probe scope.'}
+ return $State
+}
+function Import-WelaArrivalProbe {
+ param([Parameter(Mandatory)][string]$Path)
+ $root=Resolve-WelaArrivalPath $Path
+ if (-not (Test-Path -LiteralPath $root -PathType Container)) {throw 'Probe bundle directory is missing.'}
+ $expected=@('manifest.json','before-state.json','process.json','event.xml','after-state.json')
+ $items=@(Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop)
+ if ($items.Count -ne 5 -or @($items|Where-Object {$_.Name -cnotin $expected -or $_.PSIsContainer -or ([int]$_.Attributes -band [int][IO.FileAttributes]::ReparsePoint)}).Count) {throw 'Expected exactly five regular native probe files.'}
+ $files=@{};$utf8=New-Object Text.UTF8Encoding($false,$true)
+ foreach ($item in $items) {
+ if ($item.Length -gt 4194304) {throw 'Probe artifact exceeds 4 MiB.'}
+ $bytes=[IO.File]::ReadAllBytes($item.FullName)
+ if ($bytes.Length -gt 4194304) {throw 'Probe artifact grew beyond 4 MiB.'}
+ $files[$item.Name]=[pscustomobject]@{Name=$item.Name;Sha256=(Get-WelaArrivalHash $bytes);Text=$utf8.GetString($bytes).TrimStart([char]0xFEFF)}
+ }
+ $manifest=ConvertFrom-WelaArrivalJson $files['manifest.json'].Text
+ Assert-WelaArrivalObject $manifest @('SchemaVersion','Kind','Probe','Action','Status','ExitCode','GeneratedUtc','PolicyChanges','ReadyRuleCredit','Scope','RequiredEvidence','BeforeState','AfterState','Process','Artifacts','Diagnostic','OutputPath')
+ foreach ($name in @('SchemaVersion','ExitCode','PolicyChanges','ReadyRuleCredit')) {if ($manifest.$name -isnot [int] -and $manifest.$name -isnot [long]) {throw 'Mistyped probe status.'}}
+ if ($manifest.SchemaVersion -ne 1 -or $manifest.Kind -cne 'WelaNativeProbeComponents' -or $manifest.Probe -cne 'security-4688-command-line-v1' -or $manifest.Action -cne 'Run' -or $manifest.Status -cne 'NativeEventObserved' -or $manifest.ExitCode -ne 0 -or $manifest.PolicyChanges -ne 0 -or $manifest.ReadyRuleCredit -ne 0 -or $manifest.Diagnostic -cne '' -or $manifest.Artifacts -isnot [array] -or $manifest.Artifacts.Count -ne 4) {throw 'Only successful native 4688 probe components are accepted; no readiness evidence is inferred.'}
+ if ($manifest.Scope -isnot [string] -or [string]::IsNullOrWhiteSpace($manifest.Scope) -or $manifest.OutputPath -isnot [string] -or [string]::IsNullOrWhiteSpace($manifest.OutputPath) -or $manifest.RequiredEvidence -isnot [array] -or ($manifest.RequiredEvidence -join '|') -cne 'Reviewed complete rule and normalization|Backend ingestion|Translated query and successful query result') {throw 'Incomplete source scope or required-evidence metadata.'}
+ $seen=@{}
+ foreach ($entry in $manifest.Artifacts) {
+ Assert-WelaArrivalObject $entry @('path','sha256')
+ if ($entry.path -cnotin @('before-state.json','process.json','event.xml','after-state.json') -or $seen.ContainsKey($entry.path) -or $entry.sha256 -cnotmatch '^[a-f0-9]{64}$' -or $entry.sha256 -cne $files[$entry.path].Sha256) {throw 'Missing, duplicate or mismatched source artifact hash.'}
+ $seen[$entry.path]=$true
+ }
+ $before=ConvertFrom-WelaArrivalJson $files['before-state.json'].Text
+ $after=ConvertFrom-WelaArrivalJson $files['after-state.json'].Text
+ $process=ConvertFrom-WelaArrivalJson $files['process.json'].Text
+ foreach ($pair in @(@($before,$manifest.BeforeState),@($after,$manifest.AfterState),@($process,$manifest.Process))) {
+ if ((ConvertTo-Json -InputObject $pair[0] -Depth 20 -Compress) -cne (ConvertTo-Json -InputObject $pair[1] -Depth 20 -Compress)) {throw 'Embedded source metadata differs from its hashed artifact.'}
+ }
+ $before=ConvertTo-WelaArrivalState $before;$after=ConvertTo-WelaArrivalState $after
+ if ((Get-WelaProbeStateKey $before) -cne (Get-WelaProbeStateKey $after)) {throw 'Source context or prerequisites drifted.'}
+ Assert-WelaArrivalObject $process @('ProcessId','ParentProcessId','Executable','Arguments','Marker','StartedUtc','CompletedUtc','ExitCode')
+ foreach ($name in @('ProcessId','ParentProcessId')) {if (($process.$name -isnot [int] -and $process.$name -isnot [long]) -or $process.$name -lt 1 -or $process.$name -gt [uint32]::MaxValue) {throw 'Invalid probe process identity.'}}
+ if (($process.ExitCode -isnot [int] -and $process.ExitCode -isnot [long]) -or $process.ExitCode -ne 0 -or $process.Marker -cnotmatch '^WELA_PROBE_[a-f0-9]{32}$' -or
+ $process.Arguments -cne ('/d /c echo '+$process.Marker) -or $process.Executable -notmatch '^[A-Za-z]:\\(?:[^<>:"/\\|?*\x00-\x1f]+\\)*System32\\cmd\.exe$') {throw 'Source must describe only the fixed native cmd.exe echo probe.'}
+ $generated=ConvertTo-WelaArrivalUtc $manifest.GeneratedUtc;$began=ConvertTo-WelaArrivalUtc $before.capturedAtUtc
+ $started=ConvertTo-WelaArrivalUtc $process.StartedUtc;$completed=ConvertTo-WelaArrivalUtc $process.CompletedUtc;$ended=ConvertTo-WelaArrivalUtc $after.capturedAtUtc
+ if ($generated -gt $began -or $began -gt $started -or $started -gt $completed -or $completed -gt $ended) {throw 'Source evidence timestamps are out of order.'}
+ if (-not (Test-WelaProbeEvent -Xml $files['event.xml'].Text -Process $process -State $before -EndUtc $ended.UtcDateTime)) {throw 'Source event does not match the fixed process and context.'}
+ $event=Read-WelaArrivalEvent $files['event.xml'].Text
+ [pscustomobject]@{Path=$root;Fingerprint=(@($files.Keys|Sort-Object|ForEach-Object {$_+'='+$files[$_].Sha256})-join ';');Manifest=$manifest;Files=$files;Event=$event}
+}
+function Get-WelaArrivalCollector {
+ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess) {throw 'WEF arrival queries require 64-bit Windows.'}
+ $hostState=Get-WelaDefaultContext
+ if (-not (Test-WelaDefaultContextComplete $hostState)) {throw "Complete collector context is unavailable: $($hostState.Diagnostic)"}
+ $identity=[Security.Principal.WindowsIdentity]::GetCurrent()
+ try {$reader=[pscustomobject]@{UserSid=$identity.User.Value;Name=$identity.Name;AuthenticationType=$identity.AuthenticationType;IsSystem=$identity.IsSystem;ImpersonationLevel=[string]$identity.ImpersonationLevel;GroupSids=@($identity.Groups|ForEach-Object {$_.Value}|Sort-Object)}} finally {$identity.Dispose()}
+ $log=Get-WinEvent -ListLog ForwardedEvents -ErrorAction Stop
+ try {
+ if (@($log).Count -ne 1 -or $log.LogName -cne 'ForwardedEvents') {throw 'Exact ForwardedEvents channel configuration is unavailable.'}
+ $channel=[pscustomobject]@{Name=$log.LogName;Enabled=[bool]$log.IsEnabled;LogMode=[string]$log.LogMode;MaximumSizeInBytes=[long]$log.MaximumSizeInBytes;SecurityDescriptor=$log.SecurityDescriptor;LogFilePath=$log.LogFilePath}
+ } finally {if ($log) {$log.Dispose()}}
+ [pscustomobject]@{CapturedUtc=[DateTime]::UtcNow.ToString('o');Computer=[Environment]::MachineName;Host=$hostState;Reader=$reader;Channel=$channel}
+}
+function Get-WelaArrivalCollectorKey {
+ param($Context)
+ if (-not (Test-WelaDefaultContextComplete $Context.Host) -or [string]::IsNullOrWhiteSpace($Context.Computer) -or $Context.Reader.UserSid -notmatch '^S-1-\d+(-\d+)+$' -or
+ $Context.Channel.Name -cne 'ForwardedEvents' -or $Context.Channel.Enabled -isnot [bool] -or -not $Context.Channel.SecurityDescriptor) {throw 'Incomplete collector identity/channel observation.'}
+ [ordered]@{Computer=$Context.Computer;Host=(Get-WelaDefaultContextKey $Context.Host);Reader=$Context.Reader;Channel=$Context.Channel}|ConvertTo-Json -Depth 12 -Compress
+}
+function Read-WelaArrivalEvents {
+ param([Parameter(Mandatory)]$SourceEvent,[ValidateRange(1,512)][int]$MaximumEvents=512)
+ if ($SourceEvent.Computer -cnotmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$') {throw 'Unsupported source identity for native query.'}
+ $start=$SourceEvent.EventUtc.AddSeconds(-1).UtcDateTime.ToString('o');$end=$SourceEvent.EventUtc.AddSeconds(1).UtcDateTime.ToString('o')
+ $query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4688 and Computer='$($SourceEvent.Computer)' and TimeCreated[@SystemTime>='$start' and @SystemTime<='$end']]]"
+ $records=@();$xml=@();$begin=[DateTime]::UtcNow
+ try {
+ try {$records=@(Get-WinEvent -LogName ForwardedEvents -FilterXPath $query -MaxEvents $MaximumEvents -ErrorAction Stop)}
+ catch {if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {throw}}
+ foreach ($record in $records) {$xml+=[string]$record.ToXml()}
+ [pscustomobject]@{Channel='ForwardedEvents';Query=$query;StartedUtc=$begin.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');Xml=$xml;Capped=($records.Count -ge $MaximumEvents);MaximumEvents=$MaximumEvents}
+ } finally {foreach ($record in $records) {$record.Dispose()}}
+}
+function New-WelaArrivalOutput {
+ param([string]$Path,[string]$SourcePath)
+ $full=Resolve-WelaArrivalPath $Path
+ $comparison=if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {[StringComparison]::OrdinalIgnoreCase}else{[StringComparison]::Ordinal}
+ if ($full.Equals($SourcePath,$comparison) -or $full.StartsWith($SourcePath.TrimEnd([IO.Path]::DirectorySeparatorChar)+[IO.Path]::DirectorySeparatorChar,$comparison)) {throw 'Output must be outside the source bundle.'}
+ if (Test-Path -LiteralPath $full) {throw 'Arrival output must be a new directory; existing evidence is never overwritten.'}
+ if (-not (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($full)) -PathType Container)) {throw 'Output parent directory must already exist.'}
+ $null=New-Item -ItemType Directory -Path $full -ErrorAction Stop
+ if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {
+ $acl=New-Object Security.AccessControl.DirectorySecurity;$acl.SetAccessRuleProtection($true,$false)
+ foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')|Select-Object -Unique) {
+ $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))
+ }
+ Set-Acl -LiteralPath $full -AclObject $acl -ErrorAction Stop
+ }
+ $full
+}
+function Write-WelaArrivalArtifact {
+ param([string]$Root,[string]$Name,[string]$Text)
+ $null=Resolve-WelaArrivalPath $Root
+ $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name
+ $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush()} finally {$stream.Dispose()}
+ $hash=Get-WelaArrivalHash $bytes
+ if ($hash -cne (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()) {throw 'Arrival artifact readback differs from the written bytes.'}
+ [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length}
+}
+function Invoke-WelaWefArrival {
+ param([Parameter(Mandatory)][string]$ProbePath,[Parameter(Mandatory)][string]$OutputPath)
+ $source=Import-WelaArrivalProbe $ProbePath
+ $output=New-WelaArrivalOutput -Path $OutputPath -SourcePath $source.Path
+ $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeWefArrival';RecordedUtc=[DateTime]::UtcNow.ToString('o');Status='Unverified';ExitCode=1;SourceBundlePath=$source.Path;SourceFingerprint=$source.Fingerprint;SourceManifest=$source.Manifest;CollectorBefore=$null;CollectorAfter=$null;Query=$null;Candidates=0;ExactMatches=0;Diagnostic='';Artifacts=@();OutputPath=$output;Scope='Exact native probe presence in this local ForwardedEvents channel only';SubscriptionAttribution='Not established';TransmissionLatency='Not measured';ClockSynchronization='Not established';ReadyRuleCredit=0;PolicyChanges=0}
+ try {
+ $report.Artifacts+=Write-WelaArrivalArtifact $output 'source-event.xml' $source.Files['event.xml'].Text
+ $before=Get-WelaArrivalCollector;$report.CollectorBefore=$before;$beforeKey=Get-WelaArrivalCollectorKey $before
+ $report.Artifacts+=Write-WelaArrivalArtifact $output 'collector-before.json' ($before|ConvertTo-Json -Depth 16)
+ $batch=Read-WelaArrivalEvents -SourceEvent $source.Event
+ $report.Query=[pscustomobject]@{Channel=$batch.Channel;XPath=$batch.Query;StartedUtc=$batch.StartedUtc;CompletedUtc=$batch.CompletedUtc;Capped=$batch.Capped;MaximumEvents=$batch.MaximumEvents}
+ $report.Candidates=@($batch.Xml).Count
+ if ($batch.Capped -isnot [bool] -or $batch.Capped) {throw 'Collector query reached its event cap or completeness is unknown.'}
+ $matches=@();foreach ($xml in $batch.Xml) {if ((Read-WelaArrivalEvent $xml).Key -ceq $source.Event.Key) {$matches+=$xml}}
+ $report.ExactMatches=$matches.Count
+ if ($matches.Count -gt 1) {for ($i=0;$i -lt [Math]::Min(2,$matches.Count);$i++) {$report.Artifacts+=Write-WelaArrivalArtifact $output ('collector-duplicate-'+($i+1)+'.xml') $matches[$i]}}
+ if ($matches.Count -eq 1) {$report.Artifacts+=Write-WelaArrivalArtifact $output 'collector-event.xml' $matches[0]}
+ $after=Get-WelaArrivalCollector;$report.CollectorAfter=$after
+ $report.Artifacts+=Write-WelaArrivalArtifact $output 'collector-after.json' ($after|ConvertTo-Json -Depth 16)
+ if ((Get-WelaArrivalCollectorKey $after) -cne $beforeKey) {throw 'Collector host, reader identity or channel configuration drifted during the query.'}
+ if ((Import-WelaArrivalProbe $ProbePath).Fingerprint -cne $source.Fingerprint) {throw 'Source evidence changed during collector verification.'}
+ if ($matches.Count -ne 1) {throw "Expected one exact original event; observed $($matches.Count). Absence does not prove transport loss, and duplicates do not establish a unique arrival."}
+ $report.Status='PresentOnCollector';$report.ExitCode=0
+ } catch {$report.Diagnostic=$_.Exception.Message}
+ finally {
+ if ($report.CollectorBefore -and -not $report.CollectorAfter) {
+ try {$report.CollectorAfter=Get-WelaArrivalCollector;$report.Artifacts+=Write-WelaArrivalArtifact $output 'collector-after.json' ($report.CollectorAfter|ConvertTo-Json -Depth 16)}
+ catch {$report.Diagnostic+=' Final collector observation failed: '+$_.Exception.Message}
+ }
+ }
+ $null=Write-WelaArrivalArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24)
+ return $report
+}
diff --git a/tests/WefArrival.Tests.ps1 b/tests/WefArrival.Tests.ps1
new file mode 100644
index 00000000..3a570e88
--- /dev/null
+++ b/tests/WefArrival.Tests.ps1
@@ -0,0 +1,118 @@
+$ErrorActionPreference='Stop'
+$repo=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
+. (Join-Path $repo 'scripts/ControlApplicability.ps1')
+. (Join-Path $repo 'scripts/NativeValidation.ps1')
+. (Join-Path $repo 'scripts/WefArrival.ps1')
+. (Join-Path $PSScriptRoot 'fixtures/WefArrival.Fixture.ps1')
+$script:checks=0
+function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
+function Reject([scriptblock]$Code,[string]$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
+function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24)}
+function Save($Path,$Value){[IO.File]::WriteAllText($Path,($Value|ConvertTo-Json -Depth 24),[Text.UTF8Encoding]::new($false))}
+function Update-Source($Directory,$Name,$Value) {
+ Save (Join-Path $Directory $Name) $Value
+ $m=ConvertFrom-WelaArrivalJson (Get-Content (Join-Path $Directory 'manifest.json') -Raw)
+ ($m.Artifacts|Where-Object path -eq $Name).sha256=(Get-FileHash (Join-Path $Directory $Name)).Hash.ToLowerInvariant()
+ switch($Name){'before-state.json'{$m.BeforeState=$Value};'after-state.json'{$m.AfterState=$Value};'process.json'{$m.Process=$Value}}
+ Save (Join-Path $Directory 'manifest.json') $m
+}
+function Start-WelaProbeProcess {throw 'Forbidden process launch'}
+function Invoke-WelaNative {throw 'Forbidden native mutation'}
+function Set-ItemProperty {throw 'Forbidden registry mutation'}
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-arrival-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
+try {
+ $fixture=New-WelaArrivalFixture (Join-Path $temp 'source')
+ $source=Import-WelaArrivalProbe $fixture.Directory
+ Assert ($source.Event.Computer -eq 'source01.lab.test' -and $source.Files.Count -eq 5) 'Completed native-shaped source bundle validates with all hashes'
+ foreach($case in @('hash','extra','duplicate-json','bad-status','embedded','typed','missing-mask','source-drift','time','process-command','unknown-field','bad-kind','duplicate-artifact')) {
+ $dir=Join-Path $temp $case;Copy-Item $fixture.Directory $dir -Recurse
+ $m=Clone $fixture.Manifest
+ switch($case){
+ 'hash'{Add-Content (Join-Path $dir 'event.xml') 'tampered'}
+ 'extra'{Set-Content (Join-Path $dir 'extra.txt') 'extra'}
+ 'duplicate-json'{$text=ConvertTo-Json -InputObject $m -Depth 24 -Compress;$t=$text.Replace('"SchemaVersion":1,','"SchemaVersion":1,"SchemaVersion":1,');Assert ($t -cne $text) 'Duplicate-key fixture changed input';[IO.File]::WriteAllText((Join-Path $dir 'manifest.json'),$t)}
+ 'bad-status'{$m.Status='Unverified';Save (Join-Path $dir 'manifest.json') $m}
+ 'embedded'{$m.BeforeState.context.computer='different';Save (Join-Path $dir 'manifest.json') $m}
+ 'typed'{$m.BeforeState.auditPrecedence.Value='1';Update-Source $dir 'before-state.json' $m.BeforeState}
+ 'missing-mask'{$m.BeforeState.auditPolicies.PSObject.Properties.Remove(@($m.BeforeState.auditPolicies.PSObject.Properties.Name)[0]);Update-Source $dir 'before-state.json' $m.BeforeState}
+ 'source-drift'{$m.AfterState.auditPolicies.'0CCE922B-69AE-11D9-BED3-505054503030'=3;Update-Source $dir 'after-state.json' $m.AfterState}
+ 'time'{$m.Process.StartedUtc=[DateTime]::UtcNow.AddDays(1).ToString('o');Update-Source $dir 'process.json' $m.Process}
+ 'process-command'{$m.Process.Arguments='/c whoami';Update-Source $dir 'process.json' $m.Process}
+ 'unknown-field'{$m|Add-Member NoteProperty Ready $true;Save (Join-Path $dir 'manifest.json') $m}
+ 'bad-kind'{$m.Kind='WelaNativeRuleEvidence';Save (Join-Path $dir 'manifest.json') $m}
+ 'duplicate-artifact'{$m.Artifacts[1]=$m.Artifacts[0];Save (Join-Path $dir 'manifest.json') $m}
+ }
+ Reject {Import-WelaArrivalProbe $dir} 'hash|five|Duplicate|successful|differs|DWORD|59-subcategory|drifted|timestamps|fixed|Unexpected'
+ }
+ foreach($text in @('{"x":1,"X":2}','{x:1}','{"x":1,}',"{'x':1}",'{"x":NaN}')) {Reject {ConvertFrom-WelaArrivalJson $text} 'JSON|Duplicate|strict'}
+ $rendered=$fixture.Xml.Replace('','Localized <script> text')
+ Assert ((Read-WelaArrivalEvent $rendered).Key -ceq $source.Event.Key) 'RenderingInfo does not change original event identity'
+ Assert ((Read-WelaArrivalEvent ($fixture.Xml.Replace('>LAB','> '))).Key -cne (Read-WelaArrivalEvent ($fixture.Xml.Replace('>LAB','>'))).Key) 'Whitespace-only original payload values remain distinct from empty values'
+ foreach($change in @(@('source01.lab.test','wrong.lab.test'),@('0x7b','0x7c'),@('S-1-16-16384','S-1-16-8192'),@('%%1936','%%1937'),@('100','101'),@('2','1'),@('WELA_PROBE_0123456789abcdef0123456789abcdef','WELA_PROBE_1123456789abcdef0123456789abcdef'))) {
+ Assert ((Read-WelaArrivalEvent ($fixture.Xml.Replace($change[0],$change[1]))).Key -cne $source.Event.Key) "Original event mutation stays unmatched: $($change[0])"
+ }
+ foreach($xml in @($fixture.Xml.Replace('',''),$fixture.Xml.Replace('',''),(']>'+$fixture.Xml))) {Reject {Read-WelaArrivalEvent $xml} 'System|DTD'}
+ $script:collector=[pscustomobject]@{CapturedUtc=[DateTime]::UtcNow.ToString('o');Computer='collector01';Host=$fixture.Host;Reader=[pscustomobject]@{UserSid='S-1-5-18';Name='NT AUTHORITY\SYSTEM';AuthenticationType='NTLM';IsSystem=$true;ImpersonationLevel='None';GroupSids=@('S-1-5-32-544')};Channel=[pscustomobject]@{Name='ForwardedEvents';Enabled=$true;LogMode='Circular';MaximumSizeInBytes=20971520;SecurityDescriptor='O:BAG:BAD:(A;;0x1;;;SY)';LogFilePath='C:\Windows\System32\winevt\Logs\ForwardedEvents.evtx'}}
+ # Exercise the real query adapter with native-shaped records and a captured query.
+ & {
+ $script:disposed=0;$script:queryArgs=$null;$script:queryCase='records'
+ function Get-WinEvent {
+ param($LogName,$FilterXPath,$MaxEvents,$ErrorAction)
+ $script:queryArgs=@{LogName=$LogName;FilterXPath=$FilterXPath;MaxEvents=$MaxEvents}
+ if($queryCase -eq 'denied'){throw 'native query denied'}
+ if($queryCase -eq 'empty') {Write-Error -Message 'No events' -ErrorId NoMatchingEventsFound -Category ObjectNotFound;return}
+ foreach($i in 1..2){$r=[pscustomobject]@{Payload=$rendered};$r|Add-Member ScriptMethod ToXml {if($script:queryCase -eq 'xml-failed'){throw 'render failed'};$this.Payload};$r|Add-Member ScriptMethod Dispose {$script:disposed++};$r}
+ }
+ $batch=Read-WelaArrivalEvents $source.Event -MaximumEvents 2
+ Assert ($batch.Capped -and $batch.Xml.Count -eq 2 -and $disposed -eq 2) 'Native cap is explicit and every native record is disposed'
+ Assert ($queryArgs.LogName -ceq 'ForwardedEvents' -and $queryArgs.FilterXPath -match "Computer='source01.lab.test'" -and $queryArgs.FilterXPath -match 'EventID=4688' -and $queryArgs.FilterXPath -match 'TimeCreated' -and $queryArgs.MaxEvents -eq 2) 'Native query targets physical collector log with exact source identity and bounded original event time'
+ $script:queryCase='empty';$batch=Read-WelaArrivalEvents $source.Event
+ Assert ($batch.Xml.Count -eq 0 -and -not $batch.Capped) 'No native matches differs from denied query'
+ $script:queryCase='denied';Reject {Read-WelaArrivalEvents $source.Event} 'denied'
+ $script:queryCase='xml-failed';$script:disposed=0;Reject {Read-WelaArrivalEvents $source.Event} 'render failed'
+ Assert ($disposed -eq 2) 'XML conversion failure still releases all native record handles'
+ }
+ $script:reads=0;$script:scenario='match'
+ function Get-WelaArrivalCollector {$script:reads++;if($scenario -eq 'context-denied'){throw 'context denied'};$value=Clone $script:collector;if($reads -gt 1 -and $scenario -eq 'reader-drift'){$value.Reader.UserSid='S-1-5-19'};if($reads -gt 1 -and $scenario -eq 'channel-drift'){$value.Channel.Enabled=$false};$value}
+ function Read-WelaArrivalEvents {
+ param($SourceEvent)
+ if($scenario -eq 'denied'){throw 'ForwardedEvents denied'}
+ if($scenario -eq 'source-race'){Add-Content (Join-Path $fixture.Directory 'event.xml') 'race'}
+ $rows=switch($scenario){'missing'{@()};'duplicate'{@($rendered,$rendered)};'wrong'{@($rendered.Replace('S-1-16-16384','S-1-16-8192'))};default{@($rendered)}}
+ [pscustomobject]@{Channel='ForwardedEvents';Query='fixed synthetic query';StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');Xml=@($rows);Capped=($scenario -eq 'cap');MaximumEvents=512}
+ }
+ foreach($case in @('match','missing','duplicate','wrong','denied','cap','reader-drift','channel-drift','context-denied','source-race')) {
+ $script:scenario=$case;$script:reads=0;$output=Join-Path $temp ('out-'+$case)
+ $result=Invoke-WelaWefArrival $fixture.Directory $output
+ Assert (Test-Path (Join-Path $output 'manifest.json')) "Final or failed evidence manifest retained: $case"
+ Assert ($result.PolicyChanges -eq 0 -and $result.ReadyRuleCredit -eq 0 -and $result.SubscriptionAttribution -eq 'Not established' -and $result.TransmissionLatency -eq 'Not measured') 'Presence never becomes policy, subscription, latency or rule evidence'
+ if($case -eq 'match'){
+ Assert ($result.ExitCode -eq 0 -and $result.Status -eq 'PresentOnCollector' -and $result.ExactMatches -eq 1) 'One exact event establishes local presence only'
+ Assert ((Get-Content (Join-Path $output 'collector-event.xml') -Raw) -ceq $rendered) 'Raw native-shaped collector XML including rendering is retained'
+ foreach($artifact in $result.Artifacts){Assert ((Get-FileHash (Join-Path $output $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Evidence fingerprints match written bytes'}
+ }else{Assert ($result.ExitCode -eq 1 -and $result.Status -eq 'Unverified' -and $result.Diagnostic) "Incomplete collection stays unverified: $case"}
+ if($case -ne 'context-denied'){Assert ($null -ne $result.CollectorAfter) 'Final actual collector observation retained even on query failure'}
+ if($case -eq 'duplicate'){Assert (@($result.Artifacts|Where-Object Name -like 'collector-duplicate*').Count -eq 2) 'Duplicate diagnostic XML is retained without choosing an arrival'}
+ if($case -eq 'source-race'){[IO.File]::WriteAllText((Join-Path $fixture.Directory 'event.xml'),$fixture.Xml,[Text.UTF8Encoding]::new($false))}
+ }
+ $script:scenario='match';$script:reads=0
+ Push-Location $temp
+ try {$r=Invoke-WelaWefArrival './source' './relative-output';Assert ($r.OutputPath -eq (Join-Path $temp 'relative-output')) 'Relative output follows PowerShell location'} finally {Pop-Location}
+ Reject {Invoke-WelaWefArrival $fixture.Directory (Join-Path $temp 'relative-output')} 'new directory'
+ Reject {Invoke-WelaWefArrival $fixture.Directory (Join-Path $fixture.Directory 'nested')} 'outside'
+ $link=Join-Path $temp 'link';$null=New-Item -ItemType SymbolicLink -Path $link -Target $fixture.Directory
+ Reject {Import-WelaArrivalProbe $link} 'reparse'
+ Reject {New-WelaArrivalOutput (Join-Path $link 'child') $fixture.Directory} 'reparse'
+ Reject {Resolve-WelaArrivalPath 'HKLM:\test'} 'filesystem|drive'
+ Reject {Resolve-WelaArrivalPath 'wild*path'} 'exact paths'
+ $errors=$null;[void][Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'),[ref]$null,[ref]$errors)
+ Assert ($errors.Count -eq 0) 'Public CLI parses'
+ foreach($arguments in @(@('configure','-Profile','wela','-ArrivalProbePath','x'),@('wef-arrival','-Auto'),@('wef-arrival','-Role','Client'),@('wef-arrival','-DryRun'))) {
+ $saved=$ErrorActionPreference;$ErrorActionPreference='Continue'
+ try {$text=& (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $repo 'WELA.ps1') @arguments 2>&1;$code=$LASTEXITCODE} finally {$ErrorActionPreference=$saved}
+ Assert ($code -ne 0 -and ($text -join ' ') -match 'Arrival options require|wef-arrival accepts only') 'Early public guard rejects unrelated mutation options'
+ }
+ $global:LASTEXITCODE=0
+ Write-Host "PASS: $script:checks WEF arrival assertions. Fixtures are synthetic; no actual forwarding is claimed."
+} finally {Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue}
diff --git a/tests/WefArrival.Windows.Tests.ps1 b/tests/WefArrival.Windows.Tests.ps1
new file mode 100644
index 00000000..edd7708b
--- /dev/null
+++ b/tests/WefArrival.Windows.Tests.ps1
@@ -0,0 +1,33 @@
+# Real read-only collector observations only. The source bundle below is explicitly synthetic.
+$ErrorActionPreference='Stop'
+if ($env:OS -ne 'Windows_NT') {throw 'This test requires Windows.'}
+$repo=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
+. (Join-Path $repo 'scripts/ControlApplicability.ps1')
+. (Join-Path $repo 'scripts/NativeValidation.ps1')
+. (Join-Path $repo 'scripts/WefArrival.ps1')
+. (Join-Path $PSScriptRoot 'fixtures/WefArrival.Fixture.ps1')
+function Start-WelaProbeProcess {throw 'Native event generation is forbidden in this read-only test.'}
+function Set-ItemProperty {throw 'Registry mutation is forbidden in this read-only test.'}
+function Invoke-WelaNative {throw 'Native configuration commands are forbidden in this read-only test.'}
+$script:checks=0
+function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-arrival-readonly-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $temp
+try {
+ $before=Get-WelaArrivalCollector;$beforeKey=Get-WelaArrivalCollectorKey $before
+ Assert ($before.Reader.UserSid -eq [Security.Principal.WindowsIdentity]::GetCurrent().User.Value -and $before.Computer -eq [Environment]::MachineName) 'Actual collector and current reader identities are observed'
+ $fixture=New-WelaArrivalFixture (Join-Path $temp 'synthetic-source')
+ $source=Import-WelaArrivalProbe $fixture.Directory
+ $batch=Read-WelaArrivalEvents -SourceEvent $source.Event
+ Assert ($batch.Channel -eq 'ForwardedEvents' -and -not $batch.Capped -and $batch.Xml.Count -eq 0) 'Native bounded query reads actual ForwardedEvents and finds no synthetic probe'
+ $output=Join-Path $temp 'negative-result'
+ $report=Invoke-WelaWefArrival $fixture.Directory $output
+ Assert ($report.ExitCode -eq 1 -and $report.Status -eq 'Unverified' -and $report.ExactMatches -eq 0 -and $report.Query -and $report.CollectorAfter) 'Native negative verification preserves observations and never claims forwarding'
+ Assert ($report.PolicyChanges -eq 0 -and $report.ReadyRuleCredit -eq 0) 'Read-only collection grants no policy or readiness credit'
+ $acl=Get-Acl -LiteralPath $output
+ $allowed=@([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')
+ Assert ($acl.AreAccessRulesProtected -and @($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])|Where-Object {$_.IdentityReference.Value -notin $allowed -or $_.AccessControlType -ne 'Allow'}).Count -eq 0) 'New output directory is private to the current user, SYSTEM and Administrators'
+ foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Native observation output hashes match actual bytes'}
+ Assert ((Get-WelaArrivalCollectorKey (Get-WelaArrivalCollector)) -ceq $beforeKey) 'Collector host, reader and channel settings remain unchanged'
+ Write-Host "PASS: $script:checks native read-only WEF arrival checks. No event was generated or forwarded; cross-host positive acceptance is pending."
+} finally {Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue}
diff --git a/tests/fixtures/WefArrival.Fixture.ps1 b/tests/fixtures/WefArrival.Fixture.ps1
new file mode 100644
index 00000000..d603edef
--- /dev/null
+++ b/tests/fixtures/WefArrival.Fixture.ps1
@@ -0,0 +1,19 @@
+# Synthetic source/collector data only. No native event generation or telemetry claim.
+function New-WelaArrivalFixture {
+ param([string]$Directory)
+ $now=[DateTime]::UtcNow.AddSeconds(-5)
+ $hostState=[pscustomobject][ordered]@{Status='Observed';Build=20348;UBR=4000;Edition='ServerDatacenter';ProductType=3;DomainRole=3;DomainJoined=$true;Domain='lab.test';Architecture='64-bit';ProcessorArchitecture=9;InstalledRoles=@('Web-Server');RolesStatus='Observed';Diagnostic=''}
+ $policies=@{};foreach ($p in (Import-WelaAuditProfiles).catalog) {$policies[$p.guid]=0};$policies['0cce922b-69ae-11d9-bed3-505054503030']=1
+ $state=[pscustomobject][ordered]@{capturedAtUtc=$now.AddSeconds(-2).ToString('o');context=[pscustomobject]@{computer='source01';role='MemberServer';build=20348;patch='20348.4000';domainJoined=$true;installedRoles=@('Web-Server')};hostObservation=$hostState;auditPolicies=$policies;auditPrecedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};commandLineCapture=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};securityChannelEnabled=$true}
+ $process=[pscustomobject]@{ProcessId=123;ParentProcessId=456;Executable='C:\Windows\System32\cmd.exe';Arguments='/d /c echo WELA_PROBE_0123456789abcdef0123456789abcdef';Marker='WELA_PROBE_0123456789abcdef0123456789abcdef';StartedUtc=$now.ToString('o');CompletedUtc=$now.AddSeconds(1).ToString('o');ExitCode=0}
+ $before=$state|ConvertTo-Json -Depth 16
+ $state.capturedAtUtc=$now.AddSeconds(2).ToString('o');$after=$state|ConvertTo-Json -Depth 16
+ $xml=@"
+4688201331200x8020000000000000100Securitysource01.lab.testS-1-5-18SOURCE01$LAB0x3e70x7bC:\Windows\System32\cmd.exe%%19360x1c8"C:\Windows\System32\cmd.exe" /d /c echo WELA_PROBE_0123456789abcdef0123456789abcdefS-1-0-0--0x0C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exeS-1-16-16384
+"@
+ $null=New-Item -ItemType Directory -Path $Directory
+ $artifacts=@();foreach ($entry in @(@('before-state.json',$before),@('after-state.json',$after),@('process.json',($process|ConvertTo-Json -Depth 6)),@('event.xml',$xml))) {$artifacts+=Write-WelaProbeArtifact $Directory $entry[0] $entry[1]}
+ $manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeProbeComponents';Probe='security-4688-command-line-v1';Action='Run';Status='NativeEventObserved';ExitCode=0;GeneratedUtc=$now.AddSeconds(-3).ToString('o');PolicyChanges=0;ReadyRuleCredit=0;Scope='Synthetic test fixture';RequiredEvidence=@('Reviewed complete rule and normalization','Backend ingestion','Translated query and successful query result');BeforeState=(ConvertFrom-Json $before);AfterState=(ConvertFrom-Json $after);Process=$process;Artifacts=$artifacts;Diagnostic='';OutputPath=$Directory}
+ $null=Write-WelaProbeArtifact $Directory 'manifest.json' ($manifest|ConvertTo-Json -Depth 20)
+ [pscustomobject]@{Directory=$Directory;Xml=$xml;Host=$hostState;Process=$process;Manifest=$manifest}
+}
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 8277d857..f62ddae4 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,7 @@
**改善:**
+- 読み取り専用の`wef-arrival`を追加し、完了したWindows標準4688プローブの資料を検証して、ローカル収集サーバーに元イベントが1件だけ一致するか確認できるようにしました。ハッシュ・形式・環境の厳密な確認、件数を制限したネイティブ検索、実際の読み取りユーザーと変更検出、保護された生XMLの保存により、不完全・曖昧な結果は未検証として保持します。イベントの存在を、配信サブスクリプション・遅延・時刻同期・Sigma利用可能性の証明とは扱いません。ホスト間の正常到着は別途ラボ検証が必要です。 (#418) (@Shirofune-Security)
- 読み取り専用の `score` JSON・自己完結型 HTML レポートを追加し、高度な監査プロファイルへの適合率と重大度別のネイティブルール検証率を分けて表示します。バージョン付き重み、分子・分母、未確認・除外項目、ソースのハッシュ、記録時の証拠コンテキストを保持します。オフラインでは現在の設定を未確認とし、設定の有効化だけで Ready を加算しません。総合的なセキュリティ評価や Sysmon の検知範囲は示しません。 (#417) (@Shirofune-Security)
- 共有する詳細監査プロファイルと優先設定のセキュリティテンプレートについて、オフラインで計画・出力・検証する`gpo-package`を追加しました。省略項目を保持し、成功または失敗だけの最低要件を両方へ拡張する場合は明示指定を求め、未検証のゼロ値による配備は拒否します。出典・申告対象・全項目レビュー・検証済みハッシュを含む配備用ファイルであり、GPOバックアップではありません。正規のGPMC/LGPO準備と未リンクGPO作成のレビュー手順を文書化しました。ドメイン配備と実イベントの検証は別途ラボで必要となり、Sigma検知範囲には加算しません。 (#415) (@Shirofune-Security)
- 検証対象のWindows 11クライアント向けに、共通の標準監査プロファイルからオフラインで出力する`intune-export`を追加しました。Microsoft DDFに基づく59件の明示的な対応表、整数型のOMA-URI CSV/Graphデータ、監査サブカテゴリの優先設定、出典と省略理由の一覧を保存します。最小監査マスクは既定で拒否し、`PromoteToBoth`の明示指定時だけ成功・失敗の両方へ拡張します。新規ローカル出力には検証済みハッシュを付け、アップロード・割り当て・ポリシー削除・Windows設定変更は行いません。Intune配備・競合・復旧・イベントの確認は別途必要です。 (#414) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 0051d6e9..2591f4c4 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,7 @@
**Improvements:**
+- Added read-only `wef-arrival` to validate a completed native 4688 probe bundle and query the local collector for one exact original event. Strict hashes/schema/context checks, bounded native queries, actual reader observations, drift checks and protected raw evidence keep failed or ambiguous results unverified. Presence is separate from subscription attribution, latency, clock synchronization and Sigma readiness; positive cross-host acceptance remains pending. (#418) (@Shirofune-Security)
- Added read-only `score` JSON and self-contained HTML reports with separate advanced audit-profile compliance and severity-weighted native rule readiness. Versioned weights, explicit numerators/denominators, unknowns, exclusions, source fingerprints and recorded evidence contexts make each result reviewable. Offline scenarios keep current settings Unknown; enabled settings grant no Ready credit, and no overall security grade or Sysmon coverage is implied. (#417) (@Shirofune-Security)
- Added offline `gpo-package` plan, export and verification for shared advanced audit profiles and the precedence security template. Packages preserve omissions, require explicit expansion of one-sided minimum masks, reject unvalidated zero-mask deployment, and include source/target context, full reviews and verified file hashes. These are deployment components, not GPO backups; genuine GPMC/LGPO preparation and reviewed create-unlinked procedures are documented. Native domain application and event evidence remain separate lab acceptance with no Sigma credit. (#415) (@Shirofune-Security)
- Added offline `intune-export` for shared native audit profiles on reviewed Windows 11 client targets, with 59 explicit Microsoft DDF mappings, typed OMA-URI CSV/Graph artifacts, the audit precedence prerequisite and complete source/omission manifests. Static minimum masks are rejected unless explicitly expanded with `PromoteToBoth`; fresh local bundles include verified fingerprints and never upload, assign, delete policies or change Windows. Intune deployment, conflicts, recovery and event evidence remain separate validation. (#414) (@Shirofune-Security)