From 9a69600947ba267a854eb5fd10f2ef9de0f81dc5 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 07:23:47 +0900
Subject: [PATCH 1/5] Add opt-in native WEF source and collector subscription
controls
---
.github/workflows/wef-deployment.yml | 29 ++
CHANGELOG-Japanese.md | 1 +
CHANGELOG.md | 1 +
WELA.ps1 | 28 +-
config/wef-examples/collector.json | 14 +
config/wef-examples/native-security.xml | 15 +
config/wef-examples/source.json | 15 +
docs/wef-deployment.md | 74 +++++
modules/WefSubscriptions.psm1 | 162 +++++++++++
scripts/Configuration.ps1 | 2 +-
scripts/WefDeployment.ps1 | 348 ++++++++++++++++++++++++
tests/WefDeployment.Cli.Tests.ps1 | 23 ++
tests/WefDeployment.Tests.ps1 | 238 ++++++++++++++++
tests/WefDeployment.Windows.Tests.ps1 | 32 +++
website/docs/resources/changelog.ja.md | 1 +
website/docs/resources/changelog.md | 1 +
16 files changed, 982 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/wef-deployment.yml
create mode 100644 config/wef-examples/collector.json
create mode 100644 config/wef-examples/native-security.xml
create mode 100644 config/wef-examples/source.json
create mode 100644 docs/wef-deployment.md
create mode 100644 modules/WefSubscriptions.psm1
create mode 100644 scripts/WefDeployment.ps1
create mode 100644 tests/WefDeployment.Cli.Tests.ps1
create mode 100644 tests/WefDeployment.Tests.ps1
create mode 100644 tests/WefDeployment.Windows.Tests.ps1
diff --git a/.github/workflows/wef-deployment.yml b/.github/workflows/wef-deployment.yml
new file mode 100644
index 00000000..b02b7294
--- /dev/null
+++ b/.github/workflows/wef-deployment.yml
@@ -0,0 +1,29 @@
+name: Native WEF deployment regressions
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ wef-deployment:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Safe public command fixtures in Windows PowerShell 5.1
+ shell: powershell
+ run: |
+ ./tests/WefDeployment.Tests.ps1
+ ./tests/WefDeployment.Cli.Tests.ps1
+ - name: Safe public command fixtures in PowerShell 7
+ shell: pwsh
+ run: |
+ ./tests/WefDeployment.Tests.ps1
+ ./tests/WefDeployment.Cli.Tests.ps1
+ - name: Native read-only smoke in Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/WefDeployment.Windows.Tests.ps1
+ - name: Native read-only smoke in PowerShell 7
+ shell: pwsh
+ run: ./tests/WefDeployment.Windows.Tests.ps1
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 9cb8750f..65dc33e8 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,7 @@
**改善:**
+- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (issue #368) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34855338..590d1455 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
**Improvements:**
+- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (issue #368) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index 30ace2ba..66cad68b 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -32,6 +32,8 @@
[string]$ChannelProfile = 'microsoft-wef-appendix-c',
[ValidateSet('Baseline', 'Suspect', 'Both')][string]$WefQuerySet = 'Both',
[switch]$GrantEventLogReaders,
+ [ValidateSet('Audit', 'Plan', 'Configure')][string]$WefAction = 'Audit',
+ [string]$WefConfigPath,
[ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
[string]$AppLockerPolicyPath,
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
@@ -62,6 +64,8 @@ Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorActi
. (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1")
Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop
. (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1")
+Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop
+. (Join-Path $ScriptRoot "scripts/WefDeployment.ps1")
. (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1")
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
@@ -1697,6 +1701,9 @@ Usage:
./WELA.ps1 channel-settings -ChannelAction Audit -WefQuerySet Both -ResultsPath channels.json
./WELA.ps1 channel-settings -ChannelAction Plan -GrantEventLogReaders
./WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -DryRun
+
+ ./WELA.ps1 wef-source -WefAction Plan -WefConfigPath source.json -ResultsPath source-plan.json
+ ./WELA.ps1 wec-collector -WefAction Configure -WefConfigPath collector.json -DryRun
# Native channels only; ACL changes require -GrantEventLogReaders. Forwarding identity access needs a separate test.
./WELA.ps1 wmi-auditing -WmiAction List
./WELA.ps1 wmi-auditing -WmiAction Plan -WmiNamespace root\cimv2 -ResultsPath wmi-plan.json
@@ -1746,6 +1753,9 @@ Write-Host ""
if (($PSBoundParameters.ContainsKey('AppLockerAction') -or $AppLockerPolicyPath) -and $Cmd -ne 'applocker-readiness') {
throw '-AppLockerAction and -AppLockerPolicyPath require applocker-readiness. No command was run.'
}
+if (($PSBoundParameters.ContainsKey('WefAction') -or $PSBoundParameters.ContainsKey('WefConfigPath')) -and $Cmd -notin @('wef-source','wec-collector')) {
+ throw '-WefAction and -WefConfigPath require wef-source or wec-collector. No command was run.'
+}
if ($Cmd -eq 'applocker-readiness' -and ($Profile -or $Baseline)) {
throw 'applocker-readiness uses its own operator-supplied policy, not -Profile or -Baseline. No command was run.'
}
@@ -1765,9 +1775,10 @@ if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
-not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and
+ -not ($Cmd -in @('wef-source','wec-collector') -and $WefAction -eq 'Configure') -and
-not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and
-not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) {
- throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run."
+ throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run."
}
if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') {
throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.'
@@ -1793,6 +1804,21 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi
}
switch ($Cmd.ToLower()) {
+ { $_ -in @('wef-source','wec-collector') } {
+ if ($Help) {
+ Write-Host 'Usage: ./WELA.ps1 wef-source|wec-collector -WefConfigPath operator.json [-WefAction Audit|Plan|Configure] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+ Write-Host 'Native domain/Kerberos HTTP source configuration and create-only collector subscriptions. Existing collector listener and explicit scoped ingress are prerequisites. Optional ASD hardening is explicit in JSON. See docs/wef-deployment.md; forwarding/event arrival remain unverified.'
+ return
+ }
+ if ($Profile -or $Baseline -or $HtmlPath) { throw 'WEF commands require their own explicit JSON config and use -ResultsPath; -Profile, -Baseline and -HtmlPath are unsupported.' }
+ if ($WefAction -eq 'Configure' -and -not (TestAdministrator)) { throw 'WEF Configure requires Administrator privileges.' }
+ try {
+ $wefRole=if ($Cmd -eq 'wef-source') { 'Source' } else { 'Collector' }
+ $report=Invoke-WelaWefCommand -Role $wefRole -Action $WefAction -ConfigPath $WefConfigPath -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
+ $report
+ if ($report.ExitCode) { exit $report.ExitCode }
+ } catch { Write-Host "[Failed] WEF configuration: $_" -ForegroundColor Red; exit 1 }
+ }
'channel-settings' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 channel-settings [-ChannelAction Audit|Plan|Configure] [-ChannelProfile microsoft-wef-appendix-c] [-WefQuerySet Baseline|Suspect|Both] [-GrantEventLogReaders] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
diff --git a/config/wef-examples/collector.json b/config/wef-examples/collector.json
new file mode 100644
index 00000000..222d0b2d
--- /dev/null
+++ b/config/wef-examples/collector.json
@@ -0,0 +1,14 @@
+{
+ "SchemaVersion": 1,
+ "Role": "Collector",
+ "CollectorFqdn": "collector.example.test",
+ "CollectorUri": "http://collector.example.test:5985/wsman/SubscriptionManager/WEC",
+ "Authentication": "Kerberos",
+ "SourceSids": ["S-1-5-21-111-222-333-1234"],
+ "SubscriptionFiles": ["native-security.xml"],
+ "Hardening": "AssessOnly",
+ "ListenerAddress": "*",
+ "IngressRuleName": "Operator-WEC-Domain-5985",
+ "IngressLocalAddresses": ["192.0.2.10"],
+ "IngressRemoteAddresses": ["192.0.2.0/24"]
+}
diff --git a/config/wef-examples/native-security.xml b/config/wef-examples/native-security.xml
new file mode 100644
index 00000000..a0e91fd9
--- /dev/null
+++ b/config/wef-examples/native-security.xml
@@ -0,0 +1,15 @@
+
+ WELA Native Security Example
+ SourceInitiated
+ Operator-selected native account-lockout example; audit policy and event generation are separate prerequisites.
+ true
+ http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog
+ MinLatency
+ ]]>
+ false
+ HTTP
+ RenderedText
+
+ ForwardedEvents
+
+
diff --git a/config/wef-examples/source.json b/config/wef-examples/source.json
new file mode 100644
index 00000000..ba9b09db
--- /dev/null
+++ b/config/wef-examples/source.json
@@ -0,0 +1,15 @@
+{
+ "SchemaVersion": 1,
+ "Role": "Source",
+ "CollectorFqdn": "collector.example.test",
+ "CollectorUri": "http://collector.example.test:5985/wsman/SubscriptionManager/WEC",
+ "Authentication": "Kerberos",
+ "SourceSids": ["S-1-5-21-111-222-333-1234"],
+ "SubscriptionFiles": ["native-security.xml"],
+ "Hardening": "AssessOnly",
+ "SubscriptionManagerSlot": 1,
+ "RefreshSeconds": 60,
+ "GrantNetworkServiceRead": false,
+ "ApplyChannelProfile": false,
+ "GrantCapi2Read": false
+}
diff --git a/docs/wef-deployment.md b/docs/wef-deployment.md
new file mode 100644
index 00000000..0271fd49
--- /dev/null
+++ b/docs/wef-deployment.md
@@ -0,0 +1,74 @@
+# Native WEF source configuration and collector subscriptions
+
+`wef-source` and `wec-collector` are separate, opt-in commands for a bounded domain/Kerberos topology: source-initiated subscriptions over HTTP 5985 to a dedicated domain member Windows Server collector. They require an operator JSON file with the actual collector FQDN/URI, explicitly permitted source computer/group SIDs, and selected native subscription XML files. Sysmon and EMET are excluded. Local channel enablement or successful configuration does not establish forwarding or add usable Sigma-rule credit.
+
+This implements source configuration and collector subscription creation, not every WEF topology or all acceptance evidence for issue #368. HTTPS/certificate enrollment, workgroups/cross-domain trust, collector-initiated/custom-delivery subscriptions, listener/firewall creation, remote GPO management, updating/deleting existing subscriptions and automatic rollback are outside this command's initial scope. Dedicated workload isolation, network logon rights, capacity and actual event collection remain operator responsibilities.
+
+## Review a plan, then configure
+
+Copy the files under [`config/wef-examples`](../config/wef-examples) into an operator-owned directory. Replace the example FQDN, domain SID and documentation addresses with real values. Select each XML file explicitly; there is no implicit import of a downloaded subscription directory. The same selected definitions and source SIDs belong in both role configs. `SourceSids` controls collector authorization; it does not add computers to a domain group or assert that the current source belongs to a selected group.
+
+```powershell
+./WELA.ps1 wef-source -WefAction Plan -WefConfigPath C:\WEF\source.json -ResultsPath C:\WEF\source-plan.json
+./WELA.ps1 wec-collector -WefAction Plan -WefConfigPath C:\WEF\collector.json -ResultsPath C:\WEF\collector-plan.json
+
+# On the corresponding source / collector, from an elevated local session:
+./WELA.ps1 wef-source -WefAction Configure -WefConfigPath C:\WEF\source.json -DryRun
+./WELA.ps1 wec-collector -WefAction Configure -WefConfigPath C:\WEF\collector.json -DryRun
+./WELA.ps1 wef-source -WefAction Configure -WefConfigPath C:\WEF\source.json -Auto -BackupPath C:\WEF\source-before-01 -ResultsPath C:\WEF\source-result.json
+./WELA.ps1 wec-collector -WefAction Configure -WefConfigPath C:\WEF\collector.json -Auto -BackupPath C:\WEF\collector-before-01 -ResultsPath C:\WEF\collector-result.json
+```
+
+The default action is `Audit`. `Audit` and `Plan` only read. A stopped WinRM service is reported as an unmet prerequisite; these actions do not enter the WSMan provider in a way that might start it. `Configure -DryRun` creates no backup directory and makes no changes. It reports currently unmet prerequisites rather than pretending proposed service/hardening changes already took effect. Configuration without `-Auto` asks for each change. Declines and partial failures remain visible. Use a new backup directory for every real run.
+
+## Source controls
+
+| JSON setting | Behavior |
+|---|---|
+| `CollectorFqdn`, `CollectorUri` | Matching DNS identity and `http://FQDN:5985/wsman/SubscriptionManager/WEC`; IP identities, credentials, fragments and alternate endpoints are rejected. |
+| `SubscriptionManagerSlot`, `RefreshSeconds` | One explicit numeric REG_SZ value under the machine SubscriptionManager policy list, with a refresh interval of 10–86400 seconds. WELA validates the local `EventForwarding.admx` mapping. Other values are preserved; an occupied different slot is refused. |
+| `GrantNetworkServiceRead` | Explicit boolean. On a confirmed domain member workstation/server, `true` permits adding NETWORK SERVICE (`S-1-5-20`) to Event Log Readers (`S-1-5-32-573`). Existing members are preserved and journaled. `false` only assesses membership. |
+| `ApplyChannelProfile` | Explicit boolean. `true` reuses the shared Microsoft WEF Appendix C channel controls, including their exact byte sizes, larger-buffer preservation and readback. |
+| `GrantCapi2Read` | Separate explicit boolean; requires `ApplyChannelProfile`. Allows only the shared CAPI2 Event Log Readers read-ACE operation, with the existing descriptor-preservation checks. See [channel settings](native-channel-access.md). |
+| `Hardening` | `AssessOnly` reads the ASD Digest prerequisite; `ApplyASD` allows setting `WSMan:\localhost\Client\Auth\Digest` to `false`. |
+
+WinRM is set to Automatic and started when necessary, without creating a source listener. Configuring SubscriptionManager is blocked until the observed local source prerequisites match: domain membership, supported ADMX mapping, running Automatic WinRM, disabled Digest, enabled Kerberos, observed NETWORK SERVICE membership and enabled selected channels. These are configuration checks only; provider auditing/SACLs, token refresh, effective read access and actual events are separate.
+
+On a domain controller, BUILTIN group membership has domain/AD authority rather than endpoint-local authority. WELA does **not** add the forwarding identity through this local group workflow. Have the domain administrator establish and verify appropriate membership/access separately; an existing membership may be observed if the local read API supports it, otherwise it is reported as unknown. There is no fallback that changes replicated AD membership. Windows 11, member-server, DC and AD CS sources each still need their applicable audit policy and representative event evidence.
+
+## Collector controls and prerequisites
+
+The local host must be a domain member server whose observed DNS name equals `CollectorFqdn`. WinRM and Wecsvc can be set to Automatic and started. WELA never runs `winrm quickconfig`, `wecutil qc` or `Enable-PSRemoting`, and never creates or broadens listeners/firewall rules.
+
+Before enabling ForwardedEvents or creating a subscription, WELA requires one existing listener matching `ListenerAddress`, HTTP, enabled state, port 5985 and URL prefix `wsman`; one named effective ActiveStore ingress rule matching inbound Allow, Domain profile, TCP 5985 and the exact `IngressLocalAddresses`/`IngressRemoteAddresses`; running Automatic services; enabled collector Kerberos; and the two assessed ASD hardening settings below. Supply explicit IP/CIDR address lists, not `Any` or `/0`. The check verifies the selected definitions, not actual packet acceptance, reachability, profile activation or the absence of other broad rules. Listener/rule evidence is retained in JSON.
+
+`Hardening: "ApplyASD"` explicitly permits setting `WSMan:\localhost\Service\Auth\CbtHardeningLevel` to `Strict` and `WSMan:\localhost\Shell\AllowRemoteShellAccess` to `false`. Disabling remote shells prevents new remote-shell sessions; review this on a dedicated collector using local/out-of-band administration. `AssessOnly` records unmet hardening and blocks subscription creation. Policy-owned mismatches are refused; WELA does not rewrite their controlling GPO. No Basic, CredSSP, TrustedHosts, authentication fallback or firewall access setting is changed.
+
+ForwardedEvents enablement preserves its size, retention mode and security descriptor. Size/retention planning is a separate [`configure-eventlogs`](eventlog-settings.md) operation. Existing subscriptions with matching selected fields are left unchanged; a different or unreadable existing subscription is refused. New selected subscriptions are created with `wecutil cs` using a locked UTF-8 XML file stored in the recovery directory. Every native exit code is checked, the definition is read back and checked again at completion, and prerequisites are reread before each create and at the final check.
+
+## Subscription XML and evidence
+
+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.
+
+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.
+
+Readback equality covers ID, enabled state, selected delivery preset, ReadExistingEvents, content format, locale, description, destination, explicit source authorization and normalized QueryList structure/text. It is not full byte equality. Native-generated Delivery/EventSources and default transport/credential fields may be returned by `gs`; delivery/runtime expansion is not counted as an operator configuration difference when a supported preset is selected. Unknown observed fields or unrecognized authorization representations fail closed rather than receiving a matching claim. Requested and observed definitions/enabled flags are separate: an observed disabled subscription remains `ObservedEnabled: false`, even if the operator input requests enablement. Source-only runs leave the collector's observed state unknown.
+
+JSON retains exact filters, disabled flags, local channel enablement/mode/ACL, local configuration results, native `wecutil gr` output and its errors, and unverified prerequisites. On collectors, local channel metadata is explicitly labeled **collector only**; it does not describe remote source states. Localized runtime text is preserved as evidence without inferring connected-source counts or arrival success. `LocalConfigurationStatus: RequestedSettingsMatch` describes the selected local settings only. A non-dry-run with unmet prerequisites, failed writes or mismatched final settings exits nonzero and is incomplete.
+
+## Recovery and lab acceptance
+
+`before.jsonl` is written before each mutation. Review its exact Target/Before/Desired and the results before recovery. For a newly created subscription, it records absence and stores the prepared XML; remove that exact ID only after verifying its current definition still belongs to this run. Existing subscriptions are never edited. For the new SubscriptionManager value, compare the current value with Desired before removing only that value; keep other list entries and parent keys. Restore WSMan values and service start/running states only after verifying their present state and current policy authority. Remove only the newly added group SID after comparing the full membership snapshot; DC membership is never changed by this workflow. For channel restoration, use the channel journal and descriptor-preservation guidance. Recovery is deliberately manual so a newer operator/GPO change is not overwritten.
+
+Safe fixture tests exercise the public command/report, journals, readback failures, occupied slots, explicit authorization, native create failures, configuration drift, DC group protection and blocked prerequisites. Windows PowerShell 5.1/PowerShell 7 CI adds real **read-only** channel, service, WSMan, firewall and ADMX assessment. These tests do not deploy subscriptions or prove forwarding.
+
+Before closing issue #368, an isolated domain lab must configure a dedicated collector and Windows 11/member-server/DC/AD CS sources, verify source identity/token read access (including any required token/service refresh), preserve runtime status, and demonstrate native events matching each selected query arriving with the expected source identity/timestamps. Include disabled-query, denied-source, absent-channel, GPO refresh, idempotence, drift and recovery cases. Use a deliberately chosen benign native Application/System event or a controlled test account/object relevant to the query; record actual events, not merely a successful command or ACE. Forwarded Sigma coverage remains unassessed until those events and the processing pipeline are validated.
+
+## Sources
+
+- [Microsoft: WEF and source prerequisites, including Appendix C](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection).
+- [Microsoft: source-initiated subscription setup and validation](https://learn.microsoft.com/en-us/windows/win32/wec/setting-up-a-source-initiated-subscription).
+- [Microsoft: wecutil command semantics](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil).
+- [Microsoft: WinRM settings and authentication](https://learn.microsoft.com/en-us/windows/win32/winrm/installation-and-configuration-for-windows-remote-management), [remote authentication](https://learn.microsoft.com/en-us/windows/win32/winrm/authentication-for-remote-connections).
+- [Microsoft: RemoteManagement policy](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-remotemanagement), [RemoteShell policy](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-remoteshell).
+- [ASD: Windows event logging and forwarding](https://www.cyber.gov.au/business-government/detecting-responding-to-threats/event-logging/windows-event-logging-and-forwarding), assessed for the source Digest, collector CBT/remote-shell, service, access and scoped-ingress requirements. This command does not claim complete ASD collector compliance.
diff --git a/modules/WefSubscriptions.psm1 b/modules/WefSubscriptions.psm1
new file mode 100644
index 00000000..93065d27
--- /dev/null
+++ b/modules/WefSubscriptions.psm1
@@ -0,0 +1,162 @@
+# Bounded native, domain source-initiated WEF input model. No Windows mutations.
+function Read-WelaWefXml {
+ param([string]$Xml)
+ $settings = New-Object Xml.XmlReaderSettings
+ $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit; $settings.XmlResolver = $null; $settings.MaxCharactersInDocument = 10485760
+ $reader = [Xml.XmlReader]::Create((New-Object IO.StringReader($Xml)), $settings)
+ try { $doc = New-Object Xml.XmlDocument; $doc.XmlResolver = $null; $doc.Load($reader); return ,$doc }
+ finally { $reader.Dispose() }
+}
+
+function Test-WelaWefContainerText {
+ param($Node)
+ foreach ($child in $Node.ChildNodes) {
+ if ($child.NodeType -eq 'ProcessingInstruction' -or ($child.NodeType -in @('Text','CDATA') -and -not [string]::IsNullOrWhiteSpace($child.Value))) { throw 'Unexpected text or processing instruction in XML container.' }
+ }
+}
+
+function Get-WelaWefXmlKey {
+ param($Node)
+ $attributes = @($Node.Attributes | Where-Object { $_.Name -ne 'xmlns' } | Sort-Object Name | ForEach-Object { @($_.Name, $_.Value) -join '=' })
+ $children = @($Node.ChildNodes | Where-Object NodeType -eq Element | ForEach-Object { Get-WelaWefXmlKey $_ })
+ # Text/CDATA boundaries have no XML semantic meaning. Trimming individual
+ # fragments would erase significant whitespace inside XPath string literals.
+ $text = (@($Node.ChildNodes | Where-Object { $_.NodeType -in @('Text','CDATA') } | ForEach-Object { $_.Value }) -join '').Trim()
+ ConvertTo-Json -InputObject @($Node.LocalName, $Node.NamespaceURI, $attributes, $text, $children) -Depth 30 -Compress
+}
+
+function ConvertFrom-WelaWefQuery {
+ param([string]$Xml)
+ $doc = Read-WelaWefXml $Xml
+ if ($doc.DocumentElement.Name -cne 'QueryList' -or $doc.DocumentElement.NamespaceURI -or $doc.DocumentElement.Attributes.Count) { throw 'Query must contain an unqualified QueryList without attributes.' }
+ Test-WelaWefContainerText $doc
+ Test-WelaWefContainerText $doc.DocumentElement
+ if ($Xml -match '(?i)Sysmon|\bEMET\b') { throw 'Sysmon and EMET subscriptions are outside native-only scope.' }
+ $rows = @(); $ids = @{}
+ foreach ($query in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
+ if ($query.Name -cne 'Query' -or $query.NamespaceURI -or @($query.Attributes | Where-Object Name -cnotin @('Id','Path')).Count) { throw 'Unsupported Query element/attribute.' }
+ $id = $query.GetAttribute('Id')
+ Test-WelaWefContainerText $query
+ if ($id -notmatch '^\d+$' -or $ids.ContainsKey($id)) { throw 'Query IDs must be explicit and unique integers.' }
+ $ids[$id] = $true
+ $selectCount = 0
+ foreach ($filter in @($query.ChildNodes | Where-Object NodeType -eq Element)) {
+ if ($filter.Name -cnotin @('Select','Suppress') -or $filter.NamespaceURI -or @($filter.Attributes | Where-Object Name -cne 'Path').Count -or @($filter.ChildNodes | Where-Object NodeType -eq Element).Count) { throw 'Unsupported query filter structure.' }
+ $channel = $filter.GetAttribute('Path'); if (-not $channel) { $channel = $query.GetAttribute('Path') }
+ if ($channel -match '(?i)Sysmon|\bEMET\b') { throw 'Decoded Sysmon/EMET channel names are outside native-only scope.' }
+ if (@($filter.ChildNodes | Where-Object NodeType -eq ProcessingInstruction).Count) { throw 'Processing instructions in XPath filters are unsupported.' }
+ if ($channel -notin @('Security','System','Application','Windows PowerShell') -and $channel -notmatch '^Microsoft-Windows-[A-Za-z0-9 -]+/[A-Za-z0-9 -]+$') { throw "Unsupported/non-native or wildcard channel: $channel" }
+ if ([string]::IsNullOrWhiteSpace($filter.InnerText)) { throw 'Empty XPath filter is not accepted.' }
+ if ($filter.Name -eq 'Select') { $selectCount++ }
+ $rows += [pscustomobject]@{ QueryId=$id; Channel=$channel; Operation=$filter.Name; XPath=$filter.InnerText.Trim() }
+ }
+ if (-not $selectCount) { throw 'Each query must contain at least one Select.' }
+ }
+ if (-not $rows.Count) { throw 'Empty QueryList is not accepted.' }
+ [pscustomobject]@{ Xml=$doc.OuterXml; Key=(Get-WelaWefXmlKey $doc.DocumentElement); Filters=$rows; Channels=@($rows.Channel | Sort-Object -Unique) }
+}
+
+function Get-WelaWefAuthorization {
+ param([string[]]$SourceSids)
+ if (-not $SourceSids.Count) { throw 'Explicit source computer/group domain SIDs are required; no default broad authorization is used.' }
+ foreach ($sid in $SourceSids) { if ($sid -notmatch '^S-1-5-21-\d+-\d+-\d+-\d+$') { throw "Expected an explicit domain computer/group SID: $sid" } }
+ return 'O:NSG:NSD:' + ((@($SourceSids | Sort-Object -Unique) | ForEach-Object { '(A;;GA;;;' + $_ + ')' }) -join '')
+}
+
+function ConvertFrom-WelaWefSubscription {
+ param([string]$Xml, [string[]]$SourceSids, [switch]$Observed)
+ $doc = Read-WelaWefXml $Xml
+ $ns = 'http://schemas.microsoft.com/2006/03/windows/events/subscription'
+ if ($doc.DocumentElement.LocalName -cne 'Subscription' -or $doc.DocumentElement.NamespaceURI -cne $ns) { throw 'Expected the native Windows Subscription XML namespace.' }
+ Test-WelaWefContainerText $doc
+ Test-WelaWefContainerText $doc.DocumentElement
+ if (@($doc.DocumentElement.Attributes | Where-Object Name -cne 'xmlns').Count) { throw 'Unknown subscription root attributes.' }
+ $allowed = @('SubscriptionId','SubscriptionType','Description','Enabled','Uri','ConfigurationMode','Query','ReadExistingEvents','TransportName','ContentFormat','Locale','LogFile','PublisherName','AllowedSourceDomainComputers','AllowedSourceNonDomainComputers')
+ if ($Observed) { $allowed += @('Delivery','EventSources','CredentialsType','TransportPort') }
+ $elements = @{}
+ foreach ($node in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
+ if ($node.NamespaceURI -cne $ns -or $node.LocalName -cnotin $allowed -or $elements.ContainsKey($node.LocalName)) { throw "Unknown/duplicate subscription field: $($node.Name)" }
+ $elements[$node.LocalName] = $node
+ }
+ foreach ($required in @('SubscriptionId','SubscriptionType','Enabled','Uri','ConfigurationMode','Query','ReadExistingEvents','TransportName','ContentFormat','Locale','LogFile','AllowedSourceDomainComputers')) {
+ if (-not $elements.ContainsKey($required)) { throw "Subscription requires explicit $required." }
+ }
+ $id = $elements.SubscriptionId.InnerText
+ if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$') { throw 'Unsupported subscription ID.' }
+ if ($elements.SubscriptionType.InnerText -cne 'SourceInitiated' -or $elements.TransportName.InnerText -ine 'HTTP' -or
+ $elements.Uri.InnerText -cne 'http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog' -or $elements.LogFile.InnerText -cne 'ForwardedEvents') { throw 'Only HTTP source-initiated native EventLog subscriptions to ForwardedEvents are supported.' }
+ if ($elements.ConfigurationMode.InnerText -cnotin @('Normal','MinLatency','MinBandwidth')) { throw 'Use a native Normal, MinLatency or MinBandwidth preset; Custom delivery is outside this initial scope.' }
+ foreach ($field in @('Enabled','ReadExistingEvents')) { if ($elements[$field].InnerText -cnotin @('true','false')) { throw "$field must be explicit true or false." } }
+ if ($elements.ContentFormat.InnerText -cnotin @('Events','RenderedText')) { throw 'Unsupported content format.' }
+ if ($elements.Locale.GetAttribute('Language') -notmatch '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$') { throw 'Explicit locale language is required.' }
+ if ($elements.PublisherName -and $elements.PublisherName.InnerText -cne 'Microsoft-Windows-EventCollector') { throw 'Only the native EventCollector publisher is supported.' }
+ if ($elements.AllowedSourceNonDomainComputers) {
+ $nonDomain = $elements.AllowedSourceNonDomainComputers
+ if ($nonDomain.Attributes.Count -or -not [string]::IsNullOrWhiteSpace($nonDomain.InnerText)) { throw 'Non-domain/certificate sources require a separately designed topology.' }
+ foreach ($child in @($nonDomain.ChildNodes | Where-Object NodeType -eq Element)) {
+ if ($child.LocalName -cne 'AllowedIssuerCAList' -or $child.NamespaceURI -cne $ns -or $child.Attributes.Count -or $child.ChildNodes.Count) { throw 'Unknown non-domain authorization structure is not accepted.' }
+ }
+ }
+ if ($elements.CredentialsType -and $elements.CredentialsType.InnerText -cne 'Default') { throw 'Explicit credentials are not accepted.' }
+ if ($elements.TransportPort -and $elements.TransportPort.InnerText -ne '5985') { throw 'Only the standard HTTP transport port is supported.' }
+ foreach ($node in $elements.Values) {
+ if ($node.LocalName -in @('AllowedSourceNonDomainComputers','Delivery','EventSources')) { continue }
+ if (@($node.ChildNodes | Where-Object NodeType -eq ProcessingInstruction).Count) { throw 'Processing instructions in subscription settings are unsupported.' }
+ if (@($node.ChildNodes | Where-Object NodeType -eq Element).Count -or @($node.Attributes | Where-Object { -not ($node.LocalName -eq 'Locale' -and $_.Name -ceq 'Language') }).Count) { throw "Unexpected nested/attributed subscription setting: $($node.Name)" }
+ }
+ $authorization = Get-WelaWefAuthorization $SourceSids
+ $currentAuthorization = $elements.AllowedSourceDomainComputers.InnerText.Trim()
+ if ($currentAuthorization -and $currentAuthorization -cne $authorization) { throw 'Subscription source authorization does not exactly match the explicitly configured source SIDs.' }
+ if ($Observed -and -not $currentAuthorization) { throw 'Observed subscription has missing/default source authorization.' }
+ $elements.AllowedSourceDomainComputers.InnerText = $authorization
+ $query = ConvertFrom-WelaWefQuery $elements.Query.InnerText
+ $definition = [ordered]@{
+ Id=$id; Enabled=($elements.Enabled.InnerText -eq 'true'); ConfigurationMode=$elements.ConfigurationMode.InnerText
+ ReadExistingEvents=($elements.ReadExistingEvents.InnerText -eq 'true'); ContentFormat=$elements.ContentFormat.InnerText
+ Locale=$elements.Locale.GetAttribute('Language'); Description=$(if ($elements.Description) { $elements.Description.InnerText } else { '' })
+ LogFile='ForwardedEvents'; SourceAuthorization=$authorization; QueryKey=$query.Key
+ }
+ [pscustomobject]@{ Id=$id; Xml=$doc.OuterXml; Definition=[pscustomobject]$definition; Key=($definition | ConvertTo-Json -Depth 30 -Compress); Query=$query; SourceSids=@($SourceSids | Sort-Object -Unique) }
+}
+
+function Import-WelaWefConfig {
+ param([string]$Path, [ValidateSet('Source','Collector')][string]$Role)
+ $full = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
+ $config = Get-Content -LiteralPath $full -Raw -Encoding UTF8 -ErrorAction Stop | 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." }
+ if ($config.CollectorFqdn -notmatch '^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$') { throw 'An actual collector FQDN is required.' }
+ $ipLiteral=$null
+ if ([Net.IPAddress]::TryParse([string]$config.CollectorFqdn,[ref]$ipLiteral)) { throw 'CollectorFqdn must be a DNS identity, not an IP literal.' }
+ $uri = $null
+ if (-not [Uri]::TryCreate([string]$config.CollectorUri,[UriKind]::Absolute,[ref]$uri) -or $uri.Scheme -ne 'http' -or $uri.Port -ne 5985 -or $uri.DnsSafeHost -ine $config.CollectorFqdn -or
+ $uri.AbsolutePath -cne '/wsman/SubscriptionManager/WEC' -or $uri.Query -or $uri.Fragment -or $uri.UserInfo) { throw 'CollectorUri must match CollectorFqdn and http://FQDN:5985/wsman/SubscriptionManager/WEC exactly, without credentials/query/fragment.' }
+ if ($config.Authentication -cne 'Kerberos' -or $config.Hardening -cnotin @('AssessOnly','ApplyASD')) { throw 'Explicit Kerberos authentication and AssessOnly/ApplyASD hardening selection are required.' }
+ $null = Get-WelaWefAuthorization @($config.SourceSids)
+ if (@($config.SubscriptionFiles).Count -lt 1 -or @($config.SubscriptionFiles).Count -gt 32) { throw 'Select 1 to 32 explicit native subscription XML files.' }
+ if ($Role -eq 'Source') {
+ foreach ($field in @('ListenerAddress','IngressRuleName','IngressLocalAddresses','IngressRemoteAddresses')) { if ($config.PSObject.Properties[$field]) { throw "Collector-only field is not accepted in Source config: $field" } }
+ if ([string]$config.SubscriptionManagerSlot -notmatch '^[1-9]\d{0,3}$' -or ($config.RefreshSeconds -isnot [int] -and $config.RefreshSeconds -isnot [long]) -or $config.RefreshSeconds -lt 10 -or $config.RefreshSeconds -gt 86400) { throw 'Source config requires a numeric SubscriptionManagerSlot (1..9999) and integer RefreshSeconds (10..86400).' }
+ foreach ($field in @('GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')) { if ($config.$field -isnot [bool]) { throw "Explicit boolean $field is required." } }
+ if ($config.GrantCapi2Read -and -not $config.ApplyChannelProfile) { throw 'GrantCapi2Read requires explicit ApplyChannelProfile.' }
+ } else {
+ foreach ($field in @('SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')) { if ($config.PSObject.Properties[$field]) { throw "Source-only field is not accepted in Collector config: $field" } }
+ if (-not $config.ListenerAddress -or -not $config.IngressRuleName -or @($config.IngressLocalAddresses).Count -eq 0 -or @($config.IngressRemoteAddresses).Count -eq 0) { throw 'Collector requires an existing listener address, firewall rule name and explicit local/remote address scopes.' }
+ foreach ($range in @($config.IngressLocalAddresses) + @($config.IngressRemoteAddresses)) {
+ $parts = [string]$range -split '/'; $address = $null
+ if ($parts.Count -gt 2 -or -not [Net.IPAddress]::TryParse($parts[0],[ref]$address) -or ($parts.Count -eq 2 -and ($parts[1] -notmatch '^\d+$' -or [int]$parts[1] -lt 1 -or [int]$parts[1] -gt $(if ($address.AddressFamily -eq 'InterNetwork') { 32 } else { 128 })))) { throw "Use explicit IP/CIDR ingress addresses, not Any or zero-prefix ranges: $range" }
+ }
+ }
+ $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
+ $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
+ }
+ [pscustomobject]@{ Config=$config; Path=$full; Subscriptions=$subscriptions }
+}
+
+Export-ModuleMember -Function Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig
diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1
index a301b898..3b1a9826 100644
--- a/scripts/Configuration.ps1
+++ b/scripts/Configuration.ps1
@@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl {
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
- [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only")]
+ [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "wef-source-configuration-only", "wec-collector-subscriptions-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
# A second read detects a value that was compliant earlier but changed during
diff --git a/scripts/WefDeployment.ps1 b/scripts/WefDeployment.ps1
new file mode 100644
index 00000000..dd9829ed
--- /dev/null
+++ b/scripts/WefDeployment.ps1
@@ -0,0 +1,348 @@
+# Domain/Kerberos source configuration and create-only collector subscriptions.
+# All writes run through Configuration.ps1; assessment never grants Sigma credit.
+function Get-WelaWefHost {
+ $computer = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
+ [pscustomobject]@{ DomainJoined=($computer.PartOfDomain -eq $true); Fqdn=([string]$computer.DNSHostName + '.' + [string]$computer.Domain); DomainRole=[int]$computer.DomainRole }
+}
+
+function Get-WelaWefControlState {
+ param([string]$Kind, $Target)
+ switch ($Kind) {
+ 'Service' {
+ $service = Get-CimInstance Win32_Service -Filter ("Name='{0}'" -f $Target.Name) -ErrorAction Stop
+ if (-not $service -or $service.StartMode -notin @('Auto','Manual','Disabled') -or $service.State -notin @('Running','Stopped')) { throw 'Service state is absent, pending or unsupported.' }
+ return [pscustomobject]@{ Name=$Target.Name; StartMode=[string]$service.StartMode; State=[string]$service.State }
+ }
+ 'Wsman' {
+ # The WSMan provider can offer to start WinRM when accessed. Audit/Plan
+ # must remain read-only, so never enter it while the service is stopped.
+ if ((Get-WelaWefControlState Service @{ Name='WinRM' }).State -ne 'Running') { throw 'WinRM is stopped; WSMan settings were not queried to avoid implicit service startup.' }
+ $item = Get-Item -LiteralPath $Target.Path -ErrorAction Stop
+ if (-not $item.PSObject.Properties['Value'] -or -not $item.PSObject.Properties['SourceOfValue']) { throw 'WSMan value/provenance is unreadable.' }
+ return [pscustomobject]@{ Value=([string]$item.Value).ToLowerInvariant(); SourceOfValue=[string]$item.SourceOfValue }
+ }
+ 'Readers' {
+ $group = Get-LocalGroup -SID 'S-1-5-32-573' -ErrorAction Stop
+ $members = @(Get-LocalGroupMember -Group $group -ErrorAction Stop)
+ if (@($members | Where-Object { -not $_.SID }).Count) { throw 'Unreadable group membership; no additive write can be verified.' }
+ return [pscustomobject]@{ GroupSid='S-1-5-32-573'; MemberSids=@($members | ForEach-Object { $_.SID.ToString() } | Sort-Object -Unique) }
+ }
+ 'SubscriptionManager' { return Get-WelaRegistryState -Path $Target.Path -Name $Target.Name }
+ 'ForwardedEvents' { return Get-WelaNativeChannel -Name 'ForwardedEvents' }
+ 'Subscription' {
+ $ids = @((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('es')).Output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ })
+ if ($ids -notcontains $Target.Id) { return [pscustomobject]@{ Exists=$false; Xml=$null; Key=$null; Definition=$null } }
+ $xml = (Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gs',$Target.Id,'/f:xml')).Diagnostic
+ $model = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids $Target.SourceSids -Observed
+ return [pscustomobject]@{ Exists=$true; Xml=$xml; Key=$model.Key; Definition=$model.Definition }
+ }
+ default { throw "Unsupported WEF control kind: $Kind" }
+ }
+}
+
+function Get-WelaWefStateKey {
+ param($Value)
+ ConvertTo-Json -InputObject $Value -Depth 25 -Compress
+}
+
+function Test-WelaWefControl {
+ param($Value, $Entry)
+ switch ($Entry.Kind) {
+ 'Service' { return $Value.StartMode -eq 'Auto' -and $Value.State -eq 'Running' }
+ 'Wsman' { return $Value.Value -ceq $Entry.Desired.Value }
+ 'Readers' {
+ $expected=@(@($Entry.Before.MemberSids) + 'S-1-5-20' | Sort-Object -Unique)
+ return $Value.MemberSids -contains 'S-1-5-20' -and -not @(Compare-Object $expected @($Value.MemberSids)).Count
+ }
+ 'SubscriptionManager' { return $Value.ValueExists -and $Value.Type -eq 'String' -and $Value.Value -ceq $Entry.Desired.Value }
+ 'ForwardedEvents' {
+ return (Test-WelaNativeChannelSnapshot $Value) -and $Value.IsEnabled -and $Value.MaximumSizeInBytes -eq $Entry.Before.MaximumSizeInBytes -and
+ $Value.LogMode -eq $Entry.Before.LogMode -and $Value.SecurityDescriptor -ceq $Entry.Before.SecurityDescriptor
+ }
+ 'Subscription' { return $Value.Exists -and $Value.Key -ceq $Entry.Desired.Key }
+ }
+ return $false
+}
+
+function New-WelaWefEntry {
+ param([string]$Kind, $Target, $Desired)
+ $before=$null; $diagnostic=''
+ try { $before = Get-WelaWefControlState -Kind $Kind -Target $Target } catch { $diagnostic=$_.ToString() }
+ $entry = [pscustomobject]@{ Kind=$Kind; Target=$Target; Desired=$Desired; Before=$before; Status='Unknown'; Diagnostic=$diagnostic }
+ if ($before) { $entry.Status = if (Test-WelaWefControl $before $entry) { 'RequestedSettingsMatch' } else { 'ChangeRequired' } }
+ if ($Kind -eq 'Wsman' -and $before -and $before.SourceOfValue -and $entry.Status -ne 'RequestedSettingsMatch') { $entry.Status='ManualReview'; $entry.Diagnostic='Policy-owned WSMan value is not overwritten.' }
+ if ($Kind -eq 'SubscriptionManager' -and $before.ValueExists -and $entry.Status -ne 'RequestedSettingsMatch') { $entry.Status='ManualReview'; $entry.Diagnostic='Selected slot already contains another value/type; select a free slot or manage its owning policy.' }
+ if ($Kind -eq 'Subscription' -and $before.Exists -and $entry.Status -ne 'RequestedSettingsMatch') { $entry.Status='ManualReview'; $entry.Diagnostic='An existing different subscription is never updated or replaced.' }
+ if ($Kind -eq 'Readers' -and $entry.Status -eq 'ChangeRequired') {
+ try {
+ if ((Get-WelaWefHost).DomainRole -in @(4,5)) { $entry.Status='ManualReview'; $entry.Diagnostic='Domain controller BUILTIN membership has AD/domain policy authority; WELA never changes it through this local workflow. Arrange and verify the forwarding identity membership separately.' }
+ } catch { $entry.Status='Unknown'; $entry.Diagnostic='Host role cannot be verified for a safe local group update. ' + $_.ToString() }
+ }
+ return $entry
+}
+
+function Get-WelaWefHardeningEntries {
+ param([string]$Role)
+ if ($Role -eq 'Source') {
+ New-WelaWefEntry Wsman @{ Path='WSMan:\localhost\Client\Auth\Digest' } @{ Value='false' }
+ } else {
+ New-WelaWefEntry Wsman @{ Path='WSMan:\localhost\Service\Auth\CbtHardeningLevel' } @{ Value='strict' }
+ New-WelaWefEntry Wsman @{ Path='WSMan:\localhost\Shell\AllowRemoteShellAccess' } @{ Value='false' }
+ }
+}
+
+function Test-WelaWefAdmx {
+ # Verify the OS's actual SubscriptionManager list mapping before registry writes.
+ $path = Join-Path $env:windir 'PolicyDefinitions/EventForwarding.admx'
+ $doc = Read-WelaWefXml (Get-Content -LiteralPath $path -Raw -ErrorAction Stop)
+ $policies = @($doc.SelectNodes("//*[local-name()='policy' and @name='SubscriptionManager']"))
+ if ($policies.Count -ne 1 -or $policies[0].GetAttribute('class') -cne 'Machine') { throw 'Local EventForwarding ADMX SubscriptionManager policy is unsupported.' }
+ $lists = @($policies[0].SelectNodes("./*[local-name()='elements']/*[local-name()='list']"))
+ if ($lists.Count -ne 1 -or $lists[0].GetAttribute('key') -ine 'Software\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager' -or
+ $lists[0].GetAttribute('valuePrefix') -ne '' -or $lists[0].GetAttribute('valueType') -notin @('','string')) { throw 'Local SubscriptionManager list mapping is unsupported; no guessed policy mapping is written.' }
+ return $true
+}
+
+function Get-WelaWefCollectorPrerequisites {
+ param($Config)
+ $checks = @()
+ try {
+ $hostState=Get-WelaWefHost
+ $ok=$hostState.DomainJoined -and $hostState.Fqdn -ieq $Config.CollectorFqdn -and $hostState.DomainRole -in @(2,3)
+ $checks += [pscustomobject]@{ Name='Domain member server identity'; Verified=[bool]$ok; Evidence=$hostState; Diagnostic='Dedicated collector workload isolation is an operator prerequisite, not detected from domain role.' }
+ } catch { $checks += [pscustomobject]@{ Name='Domain member server identity'; Verified=$false; Evidence=$null; Diagnostic=$_.ToString() } }
+ try {
+ if ((Get-WelaWefControlState Service @{ Name='WinRM' }).State -ne 'Running') { throw 'WinRM is stopped; listener enumeration was not attempted.' }
+ $listeners = @(Get-WSManInstance -ResourceURI 'winrm/config/Listener' -Enumerate -ErrorAction Stop)
+ $matched = @($listeners | Where-Object { $_.Address -ceq $Config.ListenerAddress -and $_.Transport -ieq 'HTTP' -and [string]$_.Port -eq '5985' -and [string]$_.Enabled -ieq 'true' -and $_.URLPrefix -ieq 'wsman' })
+ $checks += [pscustomobject]@{ Name='Existing matching HTTP listener'; Verified=($matched.Count -eq 1); Evidence=@($listeners | Select-Object Address,Transport,Port,Enabled,URLPrefix,ListeningOn); Diagnostic='Listener definition only; reachability is not tested.' }
+ } catch { $checks += [pscustomobject]@{ Name='Existing matching HTTP listener'; Verified=$false; Evidence=$null; Diagnostic=$_.ToString() } }
+ try {
+ $rules = @(Get-NetFirewallRule -Name $Config.IngressRuleName -PolicyStore ActiveStore -ErrorAction Stop)
+ if ($rules.Count -ne 1) { throw 'Expected exactly one existing effective firewall rule.' }
+ $rule=$rules[0]; $ports=@($rule | Get-NetFirewallPortFilter -ErrorAction Stop); $addresses=@($rule | Get-NetFirewallAddressFilter -ErrorAction Stop)
+ $scopeMatches=$addresses.Count -eq 1 -and
+ (@(Compare-Object @($Config.IngressLocalAddresses | Sort-Object -Unique) @($addresses[0].LocalAddress | Sort-Object -Unique)).Count -eq 0) -and
+ (@(Compare-Object @($Config.IngressRemoteAddresses | Sort-Object -Unique) @($addresses[0].RemoteAddress | Sort-Object -Unique)).Count -eq 0)
+ $ok=[string]$rule.Enabled -eq 'True' -and [string]$rule.Direction -eq 'Inbound' -and [string]$rule.Action -eq 'Allow' -and [string]$rule.Profile -eq 'Domain' -and
+ $ports.Count -eq 1 -and [string]$ports[0].Protocol -in @('TCP','6') -and [string]$ports[0].LocalPort -eq '5985' -and $scopeMatches
+ $checks += [pscustomobject]@{ Name='Existing scoped domain ingress rule'; Verified=[bool]$ok; Evidence=@{ Rule=($rule | Select-Object Name,Enabled,Direction,Action,Profile,PolicyStoreSourceType,EnforcementStatus); Ports=$ports | Select-Object Protocol,LocalPort,RemotePort; Addresses=$addresses | Select-Object LocalAddress,RemoteAddress }; Diagnostic='Exact selected rule definition only; other rules, network reachability and effective packet acceptance are not established.' }
+ } catch { $checks += [pscustomobject]@{ Name='Existing scoped domain ingress rule'; Verified=$false; Evidence=$null; Diagnostic=$_.ToString() } }
+ foreach ($name in @('WinRM','Wecsvc')) {
+ $entry=New-WelaWefEntry Service @{ Name=$name } @{ StartMode='Auto'; State='Running' }
+ $checks += [pscustomobject]@{ Name="$name service"; Verified=($entry.Status -eq 'RequestedSettingsMatch'); Evidence=$entry.Before; Diagnostic=$entry.Diagnostic }
+ }
+ foreach ($entry in @(Get-WelaWefHardeningEntries Collector)) { $checks += [pscustomobject]@{ Name=$entry.Target.Path; Verified=($entry.Status -eq 'RequestedSettingsMatch'); Evidence=$entry.Before; Diagnostic=$entry.Diagnostic } }
+ $entry=New-WelaWefEntry Wsman @{ Path='WSMan:\localhost\Service\Auth\Kerberos' } @{ Value='true' }
+ $checks += [pscustomobject]@{ Name='Collector Kerberos authentication'; Verified=($entry.Status -eq 'RequestedSettingsMatch'); Evidence=$entry.Before; Diagnostic=$entry.Diagnostic }
+ return $checks
+}
+
+function Get-WelaWefPrerequisites {
+ param($Model,[string]$Role)
+ if ($Role -eq 'Collector') { return @(Get-WelaWefCollectorPrerequisites $Model.Config) }
+ $checks=@()
+ try { $hostState=Get-WelaWefHost; $checks += [pscustomobject]@{ Name='Source domain membership'; Verified=[bool]$hostState.DomainJoined; Evidence=$hostState; Diagnostic='' } }
+ catch { $checks += [pscustomobject]@{ Name='Source domain membership'; Verified=$false; Evidence=$null; Diagnostic=$_.ToString() } }
+ try { $valid=Test-WelaWefAdmx; $checks += [pscustomobject]@{ Name='Local SubscriptionManager ADMX mapping'; Verified=[bool]$valid; Evidence=$null; Diagnostic='' } }
+ catch { $checks += [pscustomobject]@{ Name='Local SubscriptionManager ADMX mapping'; Verified=$false; Evidence=$null; Diagnostic=$_.ToString() } }
+ $entries=@(New-WelaWefEntry Service @{ Name='WinRM' } @{ StartMode='Auto'; State='Running' })
+ $entries+=@(Get-WelaWefHardeningEntries Source)
+ $entries+=New-WelaWefEntry Wsman @{ Path='WSMan:\localhost\Client\Auth\Kerberos' } @{ Value='true' }
+ $entries+=New-WelaWefEntry Readers @{ GroupSid='S-1-5-32-573' } @{ AddMemberSid='S-1-5-20' }
+ foreach ($entry in $entries) { $checks += [pscustomobject]@{ Name=($entry.Kind + ':' + ($entry.Target | ConvertTo-Json -Compress)); Verified=($entry.Status -eq 'RequestedSettingsMatch'); Evidence=$entry.Before; Diagnostic=$entry.Diagnostic } }
+ foreach ($channel in @($Model.Subscriptions | ForEach-Object { $_.Query.Channels } | Sort-Object -Unique)) {
+ $observed=Get-WelaNativeChannel -Name $channel
+ $checks += [pscustomobject]@{ Name=$channel; Verified=($observed.State -eq 'Enabled'); Evidence=$observed; Diagnostic='Channel enablement only; effective token access and event generation are not tested.' }
+ }
+ return $checks
+}
+
+function Set-WelaWefPrerequisiteCheck {
+ param($Context,$Model,[string]$Role)
+ $state=@{ Model=$Model; Role=$Role }
+ $read={ param($state) return ,@(Get-WelaWefPrerequisites $state.Model $state.Role) }
+ $test={ param($value,$state) return -not @($value | Where-Object { -not $_.Verified }).Count }
+ $apply={ param($state) throw 'Required WEF prerequisites remain unmet; configure them explicitly and retry. No automatic topology changes were attempted.' }
+ Invoke-WelaConfigurationControl -Context $Context -Id 'WEF/Prerequisites' -Kind WefPrerequisite -Target @{ Role=$Role } -Desired @{ AllVerified=$true } -Read $read -Compliant $test -Apply $apply -CallbackState $state
+}
+
+function Add-WelaWefFailure {
+ param($Context,[string]$Id,[string]$Diagnostic,$Evidence)
+ $Context.Results.Add([pscustomobject]@{ Id=$Id; Kind='WefPrerequisite'; Target=$null; Desired=$null; Before=$Evidence; After=$null; Status='Failed'; Diagnostic=$Diagnostic })
+ Write-Host "[Failed] $Id $Diagnostic" -ForegroundColor Red
+}
+
+function Set-WelaWefEntry {
+ param($Context, $Entry, $Config)
+ if ($Entry.Status -in @('Unknown','ManualReview')) { Add-WelaWefFailure $Context $Entry.Kind $Entry.Diagnostic $Entry.Before; return }
+ $state=@{ Entry=$Entry; Config=$Config; Initial=$true; Snapshot=$null; BackupPath=$Context.BackupPath }
+ $read={
+ param($state)
+ $entry=$state.Entry; $current=Get-WelaWefControlState $entry.Kind $entry.Target
+ if ($state.Initial) {
+ if ((Get-WelaWefStateKey $current) -cne (Get-WelaWefStateKey $entry.Before)) { throw 'WEF control changed since planning; review a fresh plan.' }
+ $state.Initial=$false; $state.Snapshot=$current
+ }
+ return $current
+ }
+ $test={ param($current,$state) Test-WelaWefControl $current $state.Entry }
+ $apply={
+ param($state)
+ $entry=$state.Entry
+ $fresh=Get-WelaWefControlState $entry.Kind $entry.Target
+ if ((Get-WelaWefStateKey $fresh) -cne (Get-WelaWefStateKey $state.Snapshot)) { throw 'WEF control changed after the recovery snapshot; no write attempted.' }
+ switch ($entry.Kind) {
+ 'Service' {
+ if ($fresh.StartMode -ne 'Auto') { Set-Service -Name $entry.Target.Name -StartupType Automatic -ErrorAction Stop }
+ if ($fresh.State -ne 'Running') { Start-Service -Name $entry.Target.Name -ErrorAction Stop }
+ }
+ 'Wsman' {
+ if ($fresh.SourceOfValue) { throw 'Policy-owned WSMan settings are not overwritten.' }
+ Set-Item -LiteralPath $entry.Target.Path -Value $entry.Desired.Value -ErrorAction Stop
+ }
+ 'Readers' {
+ $hostState=Get-WelaWefHost
+ if (-not $hostState.DomainJoined -or $hostState.DomainRole -notin @(1,3)) { throw 'Only a confirmed domain member workstation/server can receive a local Event Log Readers update; DC or unknown group authority requires manual administration.' }
+ $group=Get-LocalGroup -SID 'S-1-5-32-573' -ErrorAction Stop
+ Add-LocalGroupMember -Group $group -Member 'S-1-5-20' -ErrorAction Stop
+ $after=Get-WelaWefControlState Readers $entry.Target
+ $expected=@($fresh.MemberSids + 'S-1-5-20' | Sort-Object -Unique)
+ if (@(Compare-Object $expected @($after.MemberSids)).Count) { throw 'Group membership changed beyond the requested additive NETWORK SERVICE member.' }
+ }
+ 'SubscriptionManager' {
+ $null=Test-WelaWefAdmx
+ if ($fresh.ValueExists) { throw 'Existing SubscriptionManager values are not overwritten.' }
+ New-WelaRegistryKey $entry.Target.Path
+ # Recheck after key creation; a concurrent policy writer may populate the slot.
+ if ((Get-WelaRegistryState $entry.Target.Path $entry.Target.Name).ValueExists) { throw 'SubscriptionManager slot was populated concurrently.' }
+ New-ItemProperty -LiteralPath $entry.Target.Path -Name $entry.Target.Name -Value $entry.Desired.Value -PropertyType String -ErrorAction Stop | Out-Null
+ }
+ 'ForwardedEvents' {
+ if (-not (Test-WelaNativeChannelSnapshot $fresh)) { throw 'ForwardedEvents settings cannot be preserved from an unreadable snapshot.' }
+ Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments @('sl','ForwardedEvents','/e:true')
+ $after=Get-WelaWefControlState ForwardedEvents $entry.Target
+ if ($after.MaximumSizeInBytes -ne $fresh.MaximumSizeInBytes -or $after.LogMode -ne $fresh.LogMode -or $after.SecurityDescriptor -cne $fresh.SecurityDescriptor) { throw 'ForwardedEvents buffer, mode or ACL drifted during enablement.' }
+ }
+ 'Subscription' {
+ $prerequisites=@(Get-WelaWefCollectorPrerequisites $state.Config)
+ if (@($prerequisites | Where-Object { -not $_.Verified }).Count) { throw 'Collector prerequisites changed or remain unmet; subscription creation is blocked.' }
+ if ($fresh.Exists) { throw 'Existing subscriptions are never overwritten.' }
+ $file=Join-Path $state.BackupPath ('subscription-' + [guid]::NewGuid().ToString('N') + '.xml')
+ $bytes=(New-Object Text.UTF8Encoding($false)).GetBytes($entry.Desired.Xml)
+ $stream=[IO.File]::Open($file,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try { $stream.Write($bytes,0,$bytes.Length) } finally { $stream.Dispose() }
+ # Hold a read-sharing lock over the exact prepared XML throughout native import.
+ $lock=[IO.File]::Open($file,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ try {
+ $observedBytes=New-Object byte[] $lock.Length
+ if ($lock.Read($observedBytes,0,$observedBytes.Length) -ne $bytes.Length -or [Convert]::ToBase64String($observedBytes) -cne [Convert]::ToBase64String($bytes)) { throw 'Prepared subscription XML changed before import.' }
+ Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('cs',$file)
+ } finally { $lock.Dispose() }
+ }
+ }
+ }
+ Invoke-WelaConfigurationControl -Context $Context -Id ("WEF/{0}/{1}" -f $Entry.Kind,($Entry.Target | ConvertTo-Json -Compress)) -Kind $Entry.Kind -Target $Entry.Target -Desired $Entry.Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Apply this explicitly selected native WEF setting; preserve unrelated configuration.'
+}
+
+function Get-WelaWefInventory {
+ param($InputModel,[string]$Role)
+ foreach ($subscription in $InputModel.Subscriptions) {
+ $channels=@()
+ foreach ($name in $subscription.Query.Channels) { $channels += Get-WelaNativeChannel -Name $name }
+ $runtime=$null; $observed=$null; $observationError=''
+ if ($Role -eq 'Collector') {
+ try { $observed=Get-WelaWefControlState Subscription @{ Id=$subscription.Id; SourceSids=$subscription.SourceSids } }
+ catch { $observationError=$_.ToString() }
+ try { $native=Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gr',$subscription.Id); $runtime=[pscustomobject]@{ State='CommandSucceeded'; Raw=$native.Diagnostic; Diagnostic='Localized native runtime status is retained without inferring event arrival.' } }
+ catch { $runtime=[pscustomobject]@{ State='Unknown'; Raw=$null; Diagnostic=$_.ToString() } }
+ }
+ [pscustomobject]@{ Id=$subscription.Id; RequestedEnabled=$subscription.Definition.Enabled; RequestedDefinition=$subscription.Definition; ObservedEnabled=$(if ($observed.Exists) { $observed.Definition.Enabled } else { $null }); ObservedSubscription=$observed; ObservationError=$observationError; Filters=$subscription.Query.Filters; SourceChannels=$channels; ChannelObservationLocation=$(if ($Role -eq 'Collector') { 'Collector only; remote source states are not observed' } else { 'Local source' }); Runtime=$runtime; EffectiveSourceReadAccess='Not tested'; EventArrival='Not tested'; ForwardedSigmaCoverage='Not assessed' }
+ }
+}
+
+function Invoke-WelaWefCommand {
+ param([ValidateSet('Source','Collector')][string]$Role,[ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',
+ [string]$ConfigPath,[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath)
+ if (-not $ConfigPath) { throw '-WefConfigPath is required.' }
+ $model=Import-WelaWefConfig -Path $ConfigPath -Role $Role; $config=$model.Config
+ if ($env:OS -ne 'Windows_NT') { throw 'Native WEF commands require Windows.' }
+ if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires WefAction Configure; Audit and Plan are read-only.' }
+ $scope=if ($Role -eq 'Source') { 'wef-source-configuration-only' } else { 'wec-collector-subscriptions-only' }
+ $hostState=$null; $hostError=''
+ try { $hostState=Get-WelaWefHost } catch { $hostError=$_.ToString() }
+ $hostReady=$hostState -and $hostState.DomainJoined -and ($Role -eq 'Source' -or ($hostState.Fqdn -ieq $config.CollectorFqdn -and $hostState.DomainRole -eq 3))
+ $entries=@(); $services=if ($Role -eq 'Source') { @('WinRM') } else { @('WinRM','Wecsvc') }
+ foreach ($service in $services) { $entries += New-WelaWefEntry Service @{ Name=$service } @{ StartMode='Auto'; State='Running' } }
+ $entries += @(Get-WelaWefHardeningEntries $Role)
+ if ($Role -eq 'Source') {
+ $entries += New-WelaWefEntry Readers @{ GroupSid='S-1-5-32-573' } @{ AddMemberSid='S-1-5-20' }
+ $entries += New-WelaWefEntry SubscriptionManager @{ Path='HKLM:\Software\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager'; Name=[string]$config.SubscriptionManagerSlot } @{ Value=('Server={0},Refresh={1}' -f $config.CollectorUri,$config.RefreshSeconds) }
+ } else {
+ $entries += New-WelaWefEntry ForwardedEvents @{ Channel='ForwardedEvents' } @{ IsEnabled=$true }
+ foreach ($subscription in $model.Subscriptions) { $entries += New-WelaWefEntry Subscription @{ Id=$subscription.Id; SourceSids=$subscription.SourceSids } @{ Key=$subscription.Key; Xml=$subscription.Xml } }
+ }
+ $channelPlan=@()
+ if ($Role -eq 'Source' -and $config.ApplyChannelProfile) { $channelProfile=Get-WelaNativeChannelProfile; $channelPlan=@(Get-WelaNativeChannelPlan -Profile $channelProfile -GrantEventLogReaders:$config.GrantCapi2Read) }
+ if ($Action -eq 'Configure') {
+ $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
+ if (-not $hostReady) { Add-WelaWefFailure $context HostIdentity ('Domain membership / collector member-server FQDN prerequisite is unmet. ' + $hostError) $hostState }
+ else {
+ # Service startup is separate from listeners/firewall configuration. No qc/remoting shortcut.
+ foreach ($entry in @($entries | Where-Object Kind -eq 'Service')) { Set-WelaWefEntry $context $entry $config }
+ foreach ($entry in @(Get-WelaWefHardeningEntries $Role)) {
+ if ($config.Hardening -eq 'ApplyASD') { Set-WelaWefEntry $context $entry $config }
+ }
+ if ($Role -eq 'Source') {
+ $admxReady=$false
+ try { $admxReady=Test-WelaWefAdmx } catch { Add-WelaWefFailure $context SubscriptionManagerAdmx $_.ToString() $null }
+ foreach ($entry in @($entries | Where-Object Kind -eq 'Readers')) {
+ if ($config.GrantNetworkServiceRead) { Set-WelaWefEntry $context $entry $config }
+ }
+ if ($channelPlan.Count) { Set-WelaNativeChannelControls -Context $context -Plan $channelPlan -Profile $channelProfile.id }
+ if ($admxReady -and -not @($context.Results | Where-Object Status -eq 'Failed').Count) {
+ $sourcePrerequisites=@(Get-WelaWefPrerequisites $model Source)
+ if (@($sourcePrerequisites | Where-Object { -not $_.Verified }).Count) { Add-WelaWefFailure $context SourcePrerequisites 'SubscriptionManager configuration is blocked until local source prerequisites are verified.' $sourcePrerequisites }
+ else { foreach ($entry in @($entries | Where-Object Kind -eq 'SubscriptionManager')) { Set-WelaWefEntry $context $entry $config } }
+ }
+ } else {
+ $prerequisites=@(Get-WelaWefCollectorPrerequisites $config)
+ if (@($prerequisites | Where-Object { -not $_.Verified }).Count) { Add-WelaWefFailure $context CollectorPrerequisites 'Subscription creation is blocked until the explicit listener, ingress, service and ASD hardening prerequisites are verified.' $prerequisites }
+ else {
+ foreach ($entry in @($entries | Where-Object Kind -eq 'ForwardedEvents')) { Set-WelaWefEntry $context $entry $config }
+ if (-not @($context.Results | Where-Object Status -eq 'Failed').Count) {
+ foreach ($subscription in $model.Subscriptions) {
+ $entry=New-WelaWefEntry Subscription @{ Id=$subscription.Id; SourceSids=$subscription.SourceSids } @{ Key=$subscription.Key; Xml=$subscription.Xml }
+ Set-WelaWefEntry $context $entry $config
+ }
+ }
+ }
+ }
+ }
+ Set-WelaWefPrerequisiteCheck $context $model $Role
+ $report=Complete-WelaConfiguration -Context $context -Scope $scope -SuccessMessage 'Requested local WEF controls read back. Runtime source access and event arrival remain unverified.'
+ } else { $report=[pscustomobject]@{ Scope=$scope; ExitCode=0; DryRun=$false } }
+ $current=@()
+ foreach ($entry in $entries) { $current += New-WelaWefEntry $entry.Kind $entry.Target $entry.Desired }
+ $prerequisites=@(Get-WelaWefPrerequisites $model $Role)
+ $inventory=@(Get-WelaWefInventory $model $Role)
+ $unmet=@($current | Where-Object Status -ne 'RequestedSettingsMatch')
+ $sourceChannelProblems=@($inventory | ForEach-Object SourceChannels | Where-Object State -ne 'Enabled')
+ $localMatch=$hostReady -and -not $unmet.Count -and -not @($prerequisites | Where-Object { -not $_.Verified }).Count -and ($Role -ne 'Source' -or -not $sourceChannelProblems.Count)
+ if ($Action -eq 'Configure' -and -not $DryRun -and -not $localMatch) { $report.ExitCode=1 }
+ if ($Action -ne 'Configure' -and (-not $hostReady -or @($current | Where-Object Status -in @('Unknown','ManualReview')).Count)) { $report.ExitCode=1 }
+ $report | Add-Member NoteProperty Action $Action
+ $report | Add-Member NoteProperty Role $Role
+ $report | Add-Member NoteProperty CollectorUri $config.CollectorUri
+ $report | Add-Member NoteProperty HostIdentity $hostState
+ $report | Add-Member NoteProperty LocalConfigurationStatus $(if ($localMatch) { 'RequestedSettingsMatch' } else { 'Incomplete' })
+ $report | Add-Member NoteProperty Controls $current
+ $report | Add-Member NoteProperty Prerequisites $prerequisites
+ $report | Add-Member NoteProperty ChannelPlan $channelPlan
+ $report | Add-Member NoteProperty Subscriptions $inventory
+ $report | Add-Member NoteProperty UnverifiedPrerequisites @('Source identity authorization/group token refresh and effective channel read access','Domain trust/Kerberos, endpoint reachability and packet acceptance','Network logon rights, event generation, subscription runtime health and representative collector arrivals','GPO refresh persistence, collection capacity/retention and forwarded Sigma coverage')
+ Write-Host "Local WEF configuration: $($report.LocalConfigurationStatus). Effective read access, event arrival and forwarded Sigma coverage are not verified." -ForegroundColor Yellow
+ $current | Select-Object Kind,Target,Status,Diagnostic | Format-Table -AutoSize | Out-Host
+ if ($ResultsPath) { try { $report | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } catch { $report.ExitCode=1; Write-Host "[Failed] Writing WEF results: $_" -ForegroundColor Red } }
+ return $report
+}
diff --git a/tests/WefDeployment.Cli.Tests.ps1 b/tests/WefDeployment.Cli.Tests.ps1
new file mode 100644
index 00000000..7d7db2fa
--- /dev/null
+++ b/tests/WefDeployment.Cli.Tests.ps1
@@ -0,0 +1,23 @@
+$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' # Native stderr in Windows PowerShell 5.1.
+ $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, wanted $ExitCode): $($Arguments -join ' ')`n$output" }
+ $script:count++
+}
+Assert-Cli @('wef-source','-Help') 0 'wef-source|wec-collector'
+Assert-Cli @('wec-collector','-Help') 0 'existing'
+Assert-Cli @('configure','-WefAction','Plan') 1 'require wef-source'
+Assert-Cli @('version','-WefConfigPath','operator.json') 1 'require wef-source'
+Assert-Cli @('wef-source','-WefAction','Audit','-DryRun') 1 'DryRun is supported only'
+Assert-Cli @('wec-collector','-Profile','wela') 1 'own explicit JSON'
+Assert-Cli @('wef-source','-HtmlPath','file.html') 1 'own explicit JSON'
+Assert-Cli @('wec-collector','-LogProfile','ASD') 1 'LogProfile is supported only'
+Assert-Cli @('wef-source','-ChannelAction','Configure') 1 'Channel options require'
+Assert-Cli @('wef-source') 1 'WefConfigPath is required'
+Write-Host "WefDeployment.Cli.Tests: $script:count public CLI checks passed."
diff --git a/tests/WefDeployment.Tests.ps1 b/tests/WefDeployment.Tests.ps1
new file mode 100644
index 00000000..c1916792
--- /dev/null
+++ b/tests/WefDeployment.Tests.ps1
@@ -0,0 +1,238 @@
+# Safe fixtures exercise the public command, native argument boundary and JSON report.
+$ErrorActionPreference='Stop'
+$repo=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -Force
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/WefDeployment.ps1')
+$script:ScriptRoot=$repo; $script:count=0
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-wef-' + [guid]::NewGuid().ToString('N'))
+$null=New-Item -ItemType Directory $temp
+Copy-Item (Join-Path $repo 'config/wef-examples/*') $temp
+function Assert($Value,[string]$Message) { if (-not $Value) { throw "FAIL: $Message" }; $script:count++ }
+function Assert-Throws([scriptblock]$Code,[string]$Message) { $caught=$false; try { & $Code | Out-Null } catch { $caught=$true }; Assert $caught $Message }
+function Reset-Fixture {
+ $global:WelaWefFixture=@{
+ Services=@{ WinRM=[pscustomobject]@{ StartMode='Auto'; State='Running' }; Wecsvc=[pscustomobject]@{ StartMode='Auto'; State='Running' } }
+ Wsman=@{ 'WSMan:\localhost\Client\Auth\Digest'='false'; 'WSMan:\localhost\Client\Auth\Kerberos'='true'; 'WSMan:\localhost\Service\Auth\CbtHardeningLevel'='strict'; 'WSMan:\localhost\Shell\AllowRemoteShellAccess'='false'; 'WSMan:\localhost\Service\Auth\Kerberos'='true' }
+ Policy=@{}; MemberSids=@('S-1-5-20','S-1-5-21-11-22-33-1000'); Slots=@{}; Subs=@{}; Writes=(New-Object 'System.Collections.Generic.List[object]')
+ Listener=$true; Ingress=$true; Domain=$true; Fqdn='collector.example.test'; Mode=3; Fail=''; Reads=@{}; DriftKind=''; DriftAt=0; Deny=''; Admx=$true
+ Forwarded=$true; ChannelEnabled=$true; Prompt='Y'
+ }
+ $script:backup=Join-Path $temp ([guid]::NewGuid().ToString('N')); $global:WelaWefFixture.Backup=$script:backup
+ $script:source=Get-Content (Join-Path $repo 'config/wef-examples/source.json') -Raw | ConvertFrom-Json
+ $script:collector=Get-Content (Join-Path $repo 'config/wef-examples/collector.json') -Raw | ConvertFrom-Json
+ Save-Configs
+}
+function Save-Configs {
+ $script:source | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $temp 'source.json')
+ $script:collector | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $temp 'collector.json')
+}
+function Count-Read([string]$Kind) {
+ $f=$global:WelaWefFixture
+ if (-not $f.Reads.ContainsKey($Kind)) { $f.Reads[$Kind]=0 }; $f.Reads[$Kind]++
+ if ($f.Deny -eq $Kind) { throw 'Fixture access denied' }
+ if ($f.DriftKind -eq $Kind -and $f.Reads[$Kind] -eq $f.DriftAt) {
+ switch ($Kind) {
+ 'SubscriptionManager' { $f.Slots['1']=[pscustomobject]@{ KeyExists=$true; ValueExists=$true; Value='concurrent'; Type='String' } }
+ 'Readers' { $f.MemberSids=@('S-1-5-21-11-22-33-9999') }
+ 'Wsman' { $f.Policy['WSMan:\localhost\Client\Auth\Digest']='GPO' }
+ }
+ }
+}
+function Record-Write([string]$Kind,$Value) {
+ $f=$global:WelaWefFixture
+ $journal=Join-Path $f.Backup 'before.jsonl'
+ Assert (Test-Path $journal) 'Recovery journal exists before each setter/native write'
+ $record=@(Get-Content $journal | ForEach-Object { $_ | ConvertFrom-Json })[-1]
+ Assert ($null -ne $record.Before) 'Journal contains pre-change state'
+ $f.Writes.Add([pscustomobject]@{ Kind=$Kind; Value=$Value })
+ if ($f.Fail -eq $Kind) { throw 'Fixture native/setter failure' }
+}
+function Get-WelaWefHost { $f=$global:WelaWefFixture; [pscustomobject]@{ DomainJoined=$f.Domain; Fqdn=$f.Fqdn; DomainRole=$f.Mode } }
+function Test-WelaWefAdmx { if (-not $global:WelaWefFixture.Admx) { throw 'Unsupported fixture ADMX' }; return $true }
+function Get-CimInstance { param($ClassName,$Filter) if ($ClassName -ne 'Win32_Service') { throw 'Unexpected CIM class' }; $name=($Filter -split "'")[1]; $global:WelaWefFixture.Services[$name].PSObject.Copy() }
+function Set-Service { param($Name,$StartupType) Record-Write Service $Name; $global:WelaWefFixture.Services[$Name].StartMode='Auto' }
+function Start-Service { param($Name) Record-Write Service $Name; $global:WelaWefFixture.Services[$Name].State='Running' }
+function Get-Item {
+ param($LiteralPath)
+ Count-Read Wsman
+ if (-not $global:WelaWefFixture.Wsman.ContainsKey($LiteralPath)) { throw 'Unexpected WSMan path' }
+ [pscustomobject]@{ Value=$global:WelaWefFixture.Wsman[$LiteralPath]; SourceOfValue=[string]$global:WelaWefFixture.Policy[$LiteralPath] }
+}
+function Set-Item { param($LiteralPath,$Value) Record-Write Wsman $LiteralPath; if ($global:WelaWefFixture.Fail -ne 'false-success') { $global:WelaWefFixture.Wsman[$LiteralPath]=[string]$Value } }
+function Get-LocalGroup { param($SID) Assert ($SID -eq 'S-1-5-32-573') 'Only builtin Event Log Readers is selected by SID'; [pscustomobject]@{ SID=$SID } }
+function Get-LocalGroupMember { param($Group) Count-Read Readers; foreach ($sid in $global:WelaWefFixture.MemberSids) { [pscustomobject]@{ SID=$sid } } }
+function Add-LocalGroupMember { param($Group,$Member) Assert ($Member -eq 'S-1-5-20') 'Only NETWORK SERVICE membership is added'; Record-Write Readers $Member; $global:WelaWefFixture.MemberSids+=@($Member) }
+function Get-WelaRegistryState { param($Path,$Name) Count-Read SubscriptionManager; if ($global:WelaWefFixture.Slots.ContainsKey($Name)) { return $global:WelaWefFixture.Slots[$Name].PSObject.Copy() }; [pscustomobject]@{ KeyExists=$true; ValueExists=$false; Value=$null; Type=$null } }
+function New-WelaRegistryKey { param($Path) }
+function New-ItemProperty { param($LiteralPath,$Name,$Value,$PropertyType) Record-Write SubscriptionManager $Name; Assert ($PropertyType -eq 'String') 'SubscriptionManager uses REG_SZ'; $global:WelaWefFixture.Slots[$Name]=[pscustomobject]@{ KeyExists=$true; ValueExists=$true; Value=$Value; Type=$PropertyType } }
+function Get-WelaNativeChannel {
+ param($Name)
+ $enabled=if ($Name -eq 'ForwardedEvents') { $global:WelaWefFixture.Forwarded } else { $global:WelaWefFixture.ChannelEnabled }
+ [pscustomobject]@{ Name=$Name; State=$(if ($enabled) { 'Enabled' } else { 'Disabled' }); IsEnabled=$enabled; MaximumSizeInBytes=[long]123207680; LogMode='Retain'; SecurityDescriptor='O:BAG:SYD:(A;;0x1;;;SY)'; Error=$null; MetadataErrors=@{} }
+}
+function Test-WelaNativeChannelSnapshot { param($Snapshot) return $Snapshot.IsEnabled -is [bool] -and $Snapshot.SecurityDescriptor }
+function Get-WSManInstance {
+ param($ResourceURI,[switch]$Enumerate)
+ if ($global:WelaWefFixture.Listener) { [pscustomobject]@{ Address='*'; Transport='HTTP'; Port=5985; Enabled=$true; URLPrefix='wsman'; ListeningOn=@('192.0.2.10') } }
+}
+function Get-NetFirewallRule { param($Name,$PolicyStore) Assert ($PolicyStore -eq 'ActiveStore') 'Ingress is read from effective ActiveStore'; [pscustomobject]@{ Name=$Name; Enabled=$global:WelaWefFixture.Ingress; Direction='Inbound'; Action='Allow'; Profile='Domain'; PolicyStoreSourceType='Local'; EnforcementStatus='Full' } }
+function Get-NetFirewallPortFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ Protocol='TCP'; LocalPort='5985'; RemotePort='Any' } } }
+function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10'); RemoteAddress=@('192.0.2.0/24') } } }
+function Read-Host { param($Prompt) return $global:WelaWefFixture.Prompt }
+function Invoke-WelaNative {
+ param($FilePath,$Arguments)
+ $f=$global:WelaWefFixture
+ if ($FilePath -eq 'wevtutil.exe') {
+ Assert (($Arguments -join ' ') -eq 'sl ForwardedEvents /e:true') 'ForwardedEvents enablement leaves size/mode/ACL untouched'
+ Record-Write ForwardedEvents $Arguments; $f.Forwarded=$true
+ return [pscustomobject]@{ ExitCode=0; Diagnostic=''; Output=@() }
+ }
+ Assert ($FilePath -eq 'wecutil.exe') 'Only native wecutil subscription API is called'
+ switch ($Arguments[0]) {
+ 'es' { return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs.Keys); Diagnostic=(@($f.Subs.Keys) -join "`n") } }
+ 'gs' { if (-not $f.Subs.ContainsKey($Arguments[1])) { throw 'No fixture subscription' }; return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs[$Arguments[1]]); Diagnostic=$f.Subs[$Arguments[1]] } }
+ 'gr' { return [pscustomobject]@{ ExitCode=0; Output=@('Localized runtime fixture'); Diagnostic='Localized runtime fixture' } }
+ 'cs' {
+ Record-Write Subscription $Arguments
+ $xml=Get-Content -LiteralPath $Arguments[1] -Raw
+ $model=ConvertFrom-WelaWefSubscription $xml @('S-1-5-21-111-222-333-1234') -Observed
+ Assert ($model.Definition.SourceAuthorization -notmatch ';;;DC\)') 'Native import contains explicit source SIDs rather than default Domain Computers'
+ if ($f.Fail -ne 'false-subscription') { $f.Subs[$model.Id]=$xml }
+ return [pscustomobject]@{ ExitCode=0; Output=@(); Diagnostic='Created fixture subscription' }
+ }
+ default { throw 'Unexpected native operation; no qc, ss or ds is allowed.' }
+ }
+}
+function Invoke-Source([string]$Action='Configure',[switch]$DryRun,[string]$ResultsPath) { Invoke-WelaWefCommand -Role Source -Action $Action -ConfigPath (Join-Path $temp 'source.json') -Auto -DryRun:$DryRun -BackupPath $script:backup -ResultsPath $ResultsPath }
+function Invoke-Collector([string]$Action='Configure',[switch]$DryRun,[string]$ResultsPath) { Invoke-WelaWefCommand -Role Collector -Action $Action -ConfigPath (Join-Path $temp 'collector.json') -Auto -DryRun:$DryRun -BackupPath $script:backup -ResultsPath $ResultsPath }
+$savedOS=$env:OS
+try {
+ $env:OS='Windows_NT'
+ Reset-Fixture
+ $model=Import-WelaWefConfig (Join-Path $temp 'source.json') Source
+ Assert ($model.Subscriptions.Count -eq 1 -and $model.Subscriptions[0].Query.Channels[0] -eq 'Security') 'Example native query imports'
+ $xml=Get-Content (Join-Path $temp 'native-security.xml') -Raw
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace 'SourceInitiated','CollectorInitiated') $source.SourceSids } 'Collector-initiated input is rejected'
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace '>HTTP<','>HTTPS<') $source.SourceSids } 'Unsupported HTTPS topology is rejected'
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace 'MinLatency','Custom') $source.SourceSids } 'Unreviewed custom delivery is rejected'
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace 'Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"') $source.SourceSids } 'Sysmon cannot enter native-only subscriptions'
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace 'Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"') $source.SourceSids } 'Entity-encoded non-native channel names are rejected after decoding'
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace 'Path="Security"','Path="Vendor-Product/Operational"') $source.SourceSids } 'Unrecognized external-provider channels are rejected'
+ Assert-Throws { ConvertFrom-WelaWefSubscription ($xml -replace 'true','truefalse') $source.SourceSids } 'Duplicate fields are rejected'
+ Assert-Throws { Read-WelaWefXml ']>&x;' } 'DTD/entity expansion is rejected'
+ Assert-Throws { Get-WelaWefAuthorization @() } 'Empty authorization cannot select native broad defaults'
+ Assert-Throws { Get-WelaWefAuthorization @('S-1-1-0') } 'Everyone source authorization is rejected'
+ $plain=ConvertFrom-WelaWefQuery ''
+ $split=ConvertFrom-WelaWefQuery ''
+ Assert ($plain.Key -cne $split.Key -and $plain.Filters[0].XPath -cne $split.Filters[0].XPath) 'Text/CDATA normalization preserves significant XPath literal whitespace'
+ $same=ConvertFrom-WelaWefQuery ''
+ Assert ($plain.Key -ceq $same.Key) 'Equivalent adjacent text/CDATA serialization has the same key'
+ $source.CollectorUri='http://wrong.example.test:5985/wsman/SubscriptionManager/WEC'; Save-Configs
+ Assert-Throws { Import-WelaWefConfig (Join-Path $temp 'source.json') Source } 'Mismatched collector identity and URI are rejected'
+ $source.CollectorFqdn='10.0.0.1'; $source.CollectorUri='http://10.0.0.1:5985/wsman/SubscriptionManager/WEC'; Save-Configs
+ Assert-Throws { Import-WelaWefConfig (Join-Path $temp 'source.json') Source } 'IP literals cannot masquerade as Kerberos FQDN identities'
+ Reset-Fixture
+ $report=Invoke-Source Plan -ResultsPath (Join-Path $temp 'plan.json')
+ $json=Get-Content (Join-Path $temp 'plan.json') -Raw | ConvertFrom-Json
+ Assert ($global:WelaWefFixture.Writes.Count -eq 0 -and -not (Test-Path $backup)) 'Plan does not write or create a backup directory'
+ Assert ($json.Subscriptions[0].Filters[0].XPath -eq '*[System[(EventID=4740)]]') 'Public JSON preserves exact selected XPath'
+ Assert ($json.Subscriptions[0].SourceChannels[0].LogMode -eq 'Retain' -and $json.Subscriptions[0].SourceChannels[0].SecurityDescriptor) 'Public JSON retains channel mode and ACL'
+ Assert ($json.Subscriptions[0].EffectiveSourceReadAccess -eq 'Not tested' -and $json.Subscriptions[0].ForwardedSigmaCoverage -eq 'Not assessed') 'Enabled channels/membership do not fabricate effective access or forwarded coverage'
+ $report=Invoke-Source Configure -DryRun
+ Assert ($global:WelaWefFixture.Writes.Count -eq 0 -and -not (Test-Path $backup)) 'Configure dry-run is mutation and journal free'
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 0 -and $report.LocalConfigurationStatus -eq 'RequestedSettingsMatch') 'Valid source configuration is read back'
+ Assert ($global:WelaWefFixture.Slots['1'].Value -eq 'Server=http://collector.example.test:5985/wsman/SubscriptionManager/WEC,Refresh=60') 'Only the selected explicit SubscriptionManager format is written'
+ $beforeWrites=$global:WelaWefFixture.Writes.Count; $script:backup=Join-Path $temp ([guid]::NewGuid().ToString('N')); $global:WelaWefFixture.Backup=$backup
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 0 -and $global:WelaWefFixture.Writes.Count -eq $beforeWrites) 'Repeated source configure is idempotent'
+ Reset-Fixture
+ $global:WelaWefFixture.MemberSids=@('S-1-5-21-11-22-33-1000'); $source.GrantNetworkServiceRead=$true; Save-Configs
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 0 -and $global:WelaWefFixture.MemberSids -contains 'S-1-5-21-11-22-33-1000') 'Explicit group addition preserves existing members'
+ Reset-Fixture
+ $global:WelaWefFixture.Mode=5; $global:WelaWefFixture.MemberSids=@('S-1-5-21-11-22-33-1000'); $source.GrantNetworkServiceRead=$true; Save-Configs
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'DC BUILTIN membership remains an explicit manual domain-authority prerequisite'
+ Reset-Fixture
+ $global:WelaWefFixture.Slots['1']=[pscustomobject]@{ KeyExists=$true; ValueExists=$true; Value='operator-existing'; Type='String' }
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Slots['1'].Value -eq 'operator-existing' -and $global:WelaWefFixture.Writes.Count -eq 0) 'An occupied different SubscriptionManager slot is preserved and fails clearly'
+ Reset-Fixture
+ $global:WelaWefFixture.DriftKind='SubscriptionManager'; $global:WelaWefFixture.DriftAt=2
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Plan-to-initial-read registry drift blocks all slot writes'
+ Reset-Fixture
+ $global:WelaWefFixture.DriftKind='SubscriptionManager'; $global:WelaWefFixture.DriftAt=3
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Pre-write registry drift preserves the concurrent slot'
+ Reset-Fixture
+ $global:WelaWefFixture.Wsman['WSMan:\localhost\Client\Auth\Digest']='true'; $source.Hardening='ApplyASD'; Save-Configs
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 0 -and $global:WelaWefFixture.Wsman['WSMan:\localhost\Client\Auth\Digest'] -eq 'false') 'Explicit ASD source hardening changes Digest and verifies readback'
+ Reset-Fixture
+ $global:WelaWefFixture.Wsman['WSMan:\localhost\Client\Auth\Digest']='true'; $global:WelaWefFixture.Policy['WSMan:\localhost\Client\Auth\Digest']='GPO'; $source.Hardening='ApplyASD'; Save-Configs
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'GPO-owned noncompliant WSMan settings are not overridden'
+ Reset-Fixture
+ $global:WelaWefFixture.Admx=$false
+ $report=Invoke-Source
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Unsupported local ADMX never receives a guessed registry write'
+ Reset-Fixture
+ $global:WelaWefFixture.Domain=$false
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Unknown/non-domain collector identity prevents mutations'
+ Reset-Fixture
+ $global:WelaWefFixture.Listener=$false
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Subs.Count -eq 0 -and $report.LocalConfigurationStatus -eq 'Incomplete') 'Missing collector listener blocks subscriptions and any configured claim'
+ Reset-Fixture
+ $global:WelaWefFixture.Ingress=$false
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Subs.Count -eq 0) 'Disabled ingress definition cannot provision subscriptions'
+ Reset-Fixture
+ $global:WelaWefFixture.Wsman['WSMan:\localhost\Service\Auth\CbtHardeningLevel']='relaxed'
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'AssessOnly reports unmet ASD hardening and does not silently skip the prerequisite'
+ Reset-Fixture
+ $collector.Hardening='ApplyASD'; Save-Configs
+ $global:WelaWefFixture.Wsman['WSMan:\localhost\Service\Auth\CbtHardeningLevel']='relaxed'
+ $global:WelaWefFixture.Wsman['WSMan:\localhost\Shell\AllowRemoteShellAccess']='true'
+ $global:WelaWefFixture.Forwarded=$false
+ $report=Invoke-Collector -ResultsPath (Join-Path $temp 'collector-result.json')
+ Assert ($report.ExitCode -eq 0 -and $global:WelaWefFixture.Subs.Count -eq 1) 'Verified collector creates only the selected explicit subscription'
+ Assert ($report.Subscriptions[0].Runtime.Raw -eq 'Localized runtime fixture' -and $report.Subscriptions[0].EventArrival -eq 'Not tested') 'Native runtime evidence is retained without inventing successful arrivals'
+ Assert ($report.Subscriptions[0].ChannelObservationLocation -like 'Collector only*') 'Collector channel inventory is not misrepresented as remote source state'
+ $beforeWrites=$global:WelaWefFixture.Writes.Count; $script:backup=Join-Path $temp ([guid]::NewGuid().ToString('N')); $global:WelaWefFixture.Backup=$backup
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 0 -and $global:WelaWefFixture.Writes.Count -eq $beforeWrites) 'Equivalent existing subscriptions and hardened settings are idempotent'
+ Reset-Fixture
+ $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector
+ $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml.Replace('true','false')
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'An existing disabled/different subscription is never silently updated'
+ Assert ($report.Subscriptions[0].RequestedEnabled -and $report.Subscriptions[0].ObservedEnabled -eq $false) 'Inventory distinguishes an observed disabled subscription from the requested enabled definition'
+ Reset-Fixture
+ $global:WelaWefFixture.Fail='false-subscription'
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1) 'A successful native exit without matching subscription readback fails'
+ Reset-Fixture
+ $global:WelaWefFixture.Fail='Subscription'
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Subs.Count -eq 0) 'Native create failure remains incomplete'
+ Reset-Fixture
+ $report=Invoke-Collector Configure -DryRun
+ Assert ($global:WelaWefFixture.Writes.Count -eq 0 -and -not (Test-Path $backup)) 'Collector dry-run creates no subscriptions, files or backup directory'
+ Assert-Throws { Invoke-Source Plan -DryRun } 'Action-specific dry-run misuse is rejected before mutation'
+ Reset-Fixture
+ $global:WelaWefFixture.Services.WinRM.State='Stopped'
+ $report=Invoke-Source Audit
+ Assert ($global:WelaWefFixture.Reads.Wsman -eq $null -and $global:WelaWefFixture.Writes.Count -eq 0) 'Read-only audit never enters the WSMan provider while WinRM is stopped'
+ Assert ($report.LocalConfigurationStatus -eq 'Incomplete') 'Stopped service audit cannot claim source configuration matches'
+ Reset-Fixture
+ $global:WelaWefFixture.Wsman['WSMan:\localhost\Service\Auth\Kerberos']='false'
+ $report=Invoke-Collector
+ Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Subs.Count -eq 0) 'Disabled Kerberos is an explicit collector prerequisite failure'
+ Write-Host "WefDeployment.Tests: $script:count assertions passed."
+} finally { $env:OS=$savedOS; Remove-Item -LiteralPath $temp -Recurse -Force }
diff --git a/tests/WefDeployment.Windows.Tests.ps1 b/tests/WefDeployment.Windows.Tests.ps1
new file mode 100644
index 00000000..aea135ba
--- /dev/null
+++ b/tests/WefDeployment.Windows.Tests.ps1
@@ -0,0 +1,32 @@
+# Actual Windows read-only paths only. No listener/service/policy/subscription writes.
+$ErrorActionPreference='Stop'
+if ($env:OS -ne 'Windows_NT') { throw 'Windows read-only smoke requires Windows.' }
+$repo=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -Force
+Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/NativeChannelConfiguration.ps1')
+. (Join-Path $repo 'scripts/WefDeployment.ps1')
+$script:ScriptRoot=$repo
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-wef-readonly-' + [guid]::NewGuid().ToString('N'))
+$null=New-Item -ItemType Directory $temp
+function Assert($Value,[string]$Message) { if (-not $Value) { throw "FAIL: $Message" } }
+$before=@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc'" | Select-Object Name,StartMode,State | Sort-Object Name)
+try {
+ foreach ($role in @('Source','Collector')) {
+ $out=Join-Path $temp ($role + '.json')
+ $report=Invoke-WelaWefCommand -Role $role -Action Audit -ConfigPath (Join-Path $repo ('config/wef-examples/' + $role.ToLowerInvariant() + '.json')) -ResultsPath $out
+ $json=Get-Content -LiteralPath $out -Raw | ConvertFrom-Json
+ Assert ($json.Role -eq $role -and $json.Action -eq 'Audit') 'Real public audit path exports its role/action'
+ Assert ($json.Subscriptions.Count -eq 1 -and $json.Subscriptions[0].EffectiveSourceReadAccess -eq 'Not tested') 'Actual channel read does not claim effective forwarding access'
+ Assert ($json.Subscriptions[0].SourceChannels[0].Name -eq 'Security') 'Actual native Security channel metadata is inventoried'
+ Assert ($json.Subscriptions[0].ForwardedSigmaCoverage -eq 'Not assessed') 'No rule credit is inferred by native WEF audit'
+ }
+ # The ADMX adapter uses the actual host definition; unsupported layouts are
+ # recorded as unmet, rather than allowing a registry write from this smoke.
+ $sourceJson=Get-Content (Join-Path $temp 'Source.json') -Raw | ConvertFrom-Json
+ Assert (@($sourceJson.Prerequisites | Where-Object Name -eq 'Local SubscriptionManager ADMX mapping').Count -eq 1) 'OS ADMX mapping is explicitly assessed'
+ $after=@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc'" | Select-Object Name,StartMode,State | Sort-Object Name)
+ Assert (($before | ConvertTo-Json -Compress) -ceq ($after | ConvertTo-Json -Compress)) 'Read-only audit leaves both service start modes and states unchanged'
+ Write-Host 'WEF Windows read-only smoke passed; no subscription deployment or event delivery is claimed.'
+} finally { Remove-Item -LiteralPath $temp -Recurse -Force }
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index a654f834..ba1cfdae 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,7 @@
**改善:**
+- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (issue #368) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 630d34bd..fcb55697 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,7 @@
**Improvements:**
+- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (issue #368) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
From 9b9347390417bd301f546854022d759ce1874b37 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 07:24:31 +0900
Subject: [PATCH 2/5] Link WEF provisioning changelog to PR 406
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 65dc33e8..e8d927f5 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (issue #368) (@Shirofune-Security)
+- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 590d1455..9f317a6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (issue #368) (@Shirofune-Security)
+- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index ba1cfdae..ea3f6519 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (issue #368) (@Shirofune-Security)
+- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index fcb55697..88b47741 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (issue #368) (@Shirofune-Security)
+- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
From 787c66e0099ceffecea0be0b865426e303c5fd26 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 07:31:23 +0900
Subject: [PATCH 3/5] Normalize WEF XML evidence before PowerShell 5.1 JSON
serialization
---
.github/workflows/wef-deployment.yml | 19 +++++++++++++------
scripts/WefDeployment.ps1 | 5 ++++-
tests/WefDeployment.Tests.ps1 | 1 +
3 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/wef-deployment.yml b/.github/workflows/wef-deployment.yml
index b02b7294..b81482c3 100644
--- a/.github/workflows/wef-deployment.yml
+++ b/.github/workflows/wef-deployment.yml
@@ -9,18 +9,25 @@ permissions:
jobs:
wef-deployment:
runs-on: windows-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Safe public command fixtures in Windows PowerShell 5.1
shell: powershell
- run: |
- ./tests/WefDeployment.Tests.ps1
- ./tests/WefDeployment.Cli.Tests.ps1
+ timeout-minutes: 3
+ run: ./tests/WefDeployment.Tests.ps1
+ - name: Public CLI rejection checks in Windows PowerShell 5.1
+ shell: powershell
+ timeout-minutes: 3
+ run: ./tests/WefDeployment.Cli.Tests.ps1
- name: Safe public command fixtures in PowerShell 7
shell: pwsh
- run: |
- ./tests/WefDeployment.Tests.ps1
- ./tests/WefDeployment.Cli.Tests.ps1
+ timeout-minutes: 3
+ run: ./tests/WefDeployment.Tests.ps1
+ - name: Public CLI rejection checks in PowerShell 7
+ shell: pwsh
+ timeout-minutes: 3
+ run: ./tests/WefDeployment.Cli.Tests.ps1
- name: Native read-only smoke in Windows PowerShell 5.1
shell: powershell
run: ./tests/WefDeployment.Windows.Tests.ps1
diff --git a/scripts/WefDeployment.ps1 b/scripts/WefDeployment.ps1
index dd9829ed..f44cd1b7 100644
--- a/scripts/WefDeployment.ps1
+++ b/scripts/WefDeployment.ps1
@@ -32,7 +32,10 @@ function Get-WelaWefControlState {
'Subscription' {
$ids = @((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('es')).Output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ })
if ($ids -notcontains $Target.Id) { return [pscustomobject]@{ Exists=$false; Xml=$null; Key=$null; Definition=$null } }
- $xml = (Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gs',$Target.Id,'/f:xml')).Diagnostic
+ # Keep evidence as a plain string. Windows PowerShell 5.1's JSON
+ # serializer expands ETS properties on strings (for example a test
+ # reader's PSDrive/PSProvider graph), unlike modern PowerShell.
+ $xml = [string]::Concat((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gs',$Target.Id,'/f:xml')).Diagnostic)
$model = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids $Target.SourceSids -Observed
return [pscustomobject]@{ Exists=$true; Xml=$xml; Key=$model.Key; Definition=$model.Definition }
}
diff --git a/tests/WefDeployment.Tests.ps1 b/tests/WefDeployment.Tests.ps1
index c1916792..ebb74a95 100644
--- a/tests/WefDeployment.Tests.ps1
+++ b/tests/WefDeployment.Tests.ps1
@@ -202,6 +202,7 @@ try {
$global:WelaWefFixture.Forwarded=$false
$report=Invoke-Collector -ResultsPath (Join-Path $temp 'collector-result.json')
Assert ($report.ExitCode -eq 0 -and $global:WelaWefFixture.Subs.Count -eq 1) 'Verified collector creates only the selected explicit subscription'
+ Assert ($null -eq $report.Subscriptions[0].ObservedSubscription.Xml.PSObject.Properties['PSDrive']) 'Observed XML strips reader ETS metadata before Windows PowerShell 5.1 JSON serialization'
Assert ($report.Subscriptions[0].Runtime.Raw -eq 'Localized runtime fixture' -and $report.Subscriptions[0].EventArrival -eq 'Not tested') 'Native runtime evidence is retained without inventing successful arrivals'
Assert ($report.Subscriptions[0].ChannelObservationLocation -like 'Collector only*') 'Collector channel inventory is not misrepresented as remote source state'
$beforeWrites=$global:WelaWefFixture.Writes.Count; $script:backup=Join-Path $temp ([guid]::NewGuid().ToString('N')); $global:WelaWefFixture.Backup=$backup
From 0403f14446829ddad8bf64b62ea04421a3e4d2d9 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 07:33:32 +0900
Subject: [PATCH 4/5] Clear expected child failure exit status after CLI
assertions
---
tests/WefDeployment.Cli.Tests.ps1 | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/WefDeployment.Cli.Tests.ps1 b/tests/WefDeployment.Cli.Tests.ps1
index 7d7db2fa..09dd006e 100644
--- a/tests/WefDeployment.Cli.Tests.ps1
+++ b/tests/WefDeployment.Cli.Tests.ps1
@@ -21,3 +21,5 @@ Assert-Cli @('wec-collector','-LogProfile','ASD') 1 'LogProfile is supported onl
Assert-Cli @('wef-source','-ChannelAction','Configure') 1 'Channel options require'
Assert-Cli @('wef-source') 1 'WefConfigPath is required'
Write-Host "WefDeployment.Cli.Tests: $script:count public CLI checks passed."
+# Expected child failures are asserted above; do not leak their exit code to CI.
+$global:LASTEXITCODE = 0
From f9303313148c80ad1d26376b943459d38598547b Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 07:35:04 +0900
Subject: [PATCH 5/5] Clear handled native-read exit status after Windows smoke
assertions
---
tests/WefDeployment.Windows.Tests.ps1 | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/WefDeployment.Windows.Tests.ps1 b/tests/WefDeployment.Windows.Tests.ps1
index aea135ba..fc31ebd8 100644
--- a/tests/WefDeployment.Windows.Tests.ps1
+++ b/tests/WefDeployment.Windows.Tests.ps1
@@ -30,3 +30,6 @@ try {
Assert (($before | ConvertTo-Json -Compress) -ceq ($after | ConvertTo-Json -Compress)) 'Read-only audit leaves both service start modes and states unchanged'
Write-Host 'WEF Windows read-only smoke passed; no subscription deployment or event delivery is claimed.'
} finally { Remove-Item -LiteralPath $temp -Recurse -Force }
+# The report retains handled native-read failures (for example a stopped Wecsvc).
+# Successful smoke assertions must not inherit that command's exit status.
+$global:LASTEXITCODE = 0