diff --git a/.gitattributes b/.gitattributes index 893c0ce4..a9a8b9d3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -74,6 +74,9 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/FileAccessProbe* text eol=lf /tests/FileAccessProbe* text eol=lf +# Disposable native provider configuration fixture +tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf + # Disposable public registry lifecycle fixture bytes are retained in evidence. /tests/RegistrySacl* text eol=lf /scripts/WecAuthorization* text eol=lf @@ -81,3 +84,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf # Reviewed channel restoration binds exact installed source bytes. /scripts/ChannelRecovery.ps1 text eol=lf /tests/ChannelRecovery*.ps1 text eol=lf + +# Collector inventory reads native UTF16 names and bounded Unicode XML. +/modules/WecSubscriptionInventory.cs text eol=lf +/tests/WecCollectorObservation* text eol=lf +/tests/WecSubscriptionInventory* text eol=lf diff --git a/.github/workflows/native-provider-configure.yml b/.github/workflows/native-provider-configure.yml new file mode 100644 index 00000000..0112f9ef --- /dev/null +++ b/.github/workflows/native-provider-configure.yml @@ -0,0 +1,43 @@ +name: Native provider configuration acceptance +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-provider-configure: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Focused provider regressions in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeProviderPacks.Tests.ps1 + - name: Public native provider configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeProviderConfigure.Windows.Tests.ps1 -AllowDisposableProviderWrite + - name: Focused provider regressions in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeProviderPacks.Tests.ps1 + - name: Public native provider configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeProviderConfigure.Windows.Tests.ps1 -AllowDisposableProviderWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-provider-configure-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-provider-configure-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c76e9667..9a085a73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/wec-collector-observation.yml b/.github/workflows/wec-collector-observation.yml new file mode 100644 index 00000000..dc1b3aa4 --- /dev/null +++ b/.github/workflows/wec-collector-observation.yml @@ -0,0 +1,59 @@ +name: Native collector subscription observation +on: + push: + paths: ['WELA.ps1', 'modules/WefSubscriptions.psm1', 'modules/WecSubscription*', 'scripts/WefDeployment.ps1', 'tests/WecCollectorObservation*', 'tests/WecSubscriptionInventory*', '.github/workflows/wec-collector-observation.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + collector-observation: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Native observation regressions in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WecSubscriptionInventory.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + ./tests/WefDeployment.Cli.Tests.ps1 + - name: Native observation regressions in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WecSubscriptionInventory.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + ./tests/WefDeployment.Cli.Tests.ps1 + - name: Existing read-only service preservation in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WefDeployment.Windows.Tests.ps1 + - name: Existing read-only service preservation in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WefDeployment.Windows.Tests.ps1 + - name: Actual public collector observations in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecCollectorObservation.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Actual public collector observations in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecCollectorObservation.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Retain native observations and exact cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: collector-observation-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-wec-observation-* + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a58bfd14..e6f789d8 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -6,6 +6,10 @@ - `outgoing-ntlm` のAudit/Plan/Configureを追加し、送信NTLM監査DWORDを個別に設定できます。既存の拒否設定は既定で維持し、置換には明示的なAuditを要求します。不明な型・値や書込直前の変化を拒否し、元の型付き記録と再読取を保持します。Server 2022/2025のテストで範囲と復元を確認し、認証・イベント生成は未検証として報告します。(関連 #362) (@Shirofune-Security) +- コレクターのサブスクリプション観測で、コンソール経由の文字変換を、上限付きの完全なネイティブ名前列挙と厳密なUnicode XML読取に置き換えました。空の一覧・読取失敗・実際の無効状態を区別し、Unicodeの説明とXPathを保持します。Server 2022/2025と両PowerShellで公開Audit/Planおよび正確な後処理を検証し、ドメイン展開・転送・Sigma対応は主張しません。(関連 #368) (@Shirofune-Security) + +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。 WinRM のマニフェスト ID をネイティブの Int64 として比較し、対象外の大きい ID により必要なイベントの確認が失敗する不具合も修正。 (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index d59feaf0..b096a218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add `outgoing-ntlm` Audit/Plan/Configure to manage the outgoing audit DWORD independently of broad configuration. Preserve existing deny by default, require explicit Audit to replace it, refuse unknown types/values and pre-write drift, and retain typed original journals plus native readback. Native Server 2022/2025 tests verify narrow scope and exact cleanup; authentication/event generation remain unverified. (Related #362) (@Shirofune-Security) +- Fixed collector subscription observations to use complete bounded native name enumeration and strict Unicode XML reads instead of console decoding. Empty inventories, failed reads and actual disabled state remain distinct; Unicode descriptions and XPath are preserved. Disposable public Audit/Plan tests cover both Server 2022/2025 and PowerShell engines with exact cleanup, without domain deployment, forwarding or Sigma claims. (Related #368) (@Shirofune-Security) + +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. Fixed WinRM manifest inspection to preserve native Int64 event IDs, so unrelated large IDs no longer block the selected event schema. (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 6e51edb3..7c7319f8 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2577,7 +2577,7 @@ 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.' + 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 and docs/wec-collector-observation.md; native inventory/XML read failures stay unknown, and 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.' } diff --git a/docs/native-provider-acceptance.md b/docs/native-provider-acceptance.md new file mode 100644 index 00000000..53441d85 --- /dev/null +++ b/docs/native-provider-acceptance.md @@ -0,0 +1,28 @@ +# Native provider configuration acceptance + +The dedicated `Native provider configuration acceptance` workflow tests the public `provider-packs` command on disposable GitHub-hosted Windows Server 2022 and 2025, separately under Windows PowerShell 5.1 and PowerShell 7. It complements the read-only manifest inventory and mocked failure tests described in [the provider-pack guide](native-provider-packs.md). + +This fixture is destructive to the selected channels' temporary configuration and can discard records when restoring smaller buffers. It requires `-AllowDisposableProviderWrite`, `GITHUB_ACTIONS=true` and `RUNNER_ENVIRONMENT=github-hosted`; do not run it on ordinary machines. Production behavior is unchanged; only this opted-in disposable fixture prepares and restores the temporary test settings. + +## Actual public behavior checked + +- The real provider/channel registrations and expected event schemas must permit all four explicitly selected client-side packs: `dns-client`, `capi2`, `winrm` and `rdp-client`. Missing or incompatible metadata fails the fixture; it is never replaced with a mock or skipped success. +- Plan and Configure with `-DryRun` preserve prepared native settings. Unsupported preview and reader-grant options are refused before a recovery directory is created. +- Configure actually enables the four channels and applies their exact minimum buffers. A prepared 2 GiB WinRM buffer stays larger, and a prepared CAPI2 `Retain` mode stays intact. The complete descriptor is preserved; provider packs never request an Event Log Readers grant. +- Every Applied result and its original journal entry are compared with independent native before/after observations. Repeated Configure is idempotent and creates no write journal. +- Both manual DNS packs refuse configuration. The hosted image must genuinely lack the DNS Server service, and `dns-server-audit` must refuse that missing prerequisite. No DNS role is installed or removed to manufacture the result. +- A mixed CAPI2/manual-DNS invocation performs one real selected change and reports the other failure with a nonzero overall exit and exactly one journal entry. Partial application is explicit. + +The fixture does not issue DNS queries, RDP connections or WinRM sessions, change service configuration, or intentionally generate test events. Ordinary background Windows events may occur while the channels are enabled. All rules retain zero Ready credit; enabling a source does not establish event fields, effective reader access, ingestion or matching backend queries. + +## Preservation, cleanup and evidence + +Before preparation, the fixture captures native settings and complete `wevtutil gl /f:xml` configuration for registered catalog channels and additional unselected Security, System, Application, AppLocker and DriverFrameworks controls. During public configuration it compares every selected XML field except the permitted enabled flag and maximum size; unselected registered channels must remain byte-for-byte equivalent at the XML level. It also compares the state/start type of EventLog, Winmgmt, WinRM, TermService and DNS, and all 59 effective audit masks. + +Each selected channel has independent cleanup that restores original enablement, exact byte limit, descriptor and retention/backup mode. A failure restoring one channel does not skip the remaining channels. Final observations compare original full XML, service state and audit masks. Cleanup failure prevents a passing result. Owned child commands have bounded execution and output, and termination failures remain in the cleanup receipt. + +`original.json`, public JSON reports, command output, actual journals, `completed.json`, `cleanup.json` and a SHA256 manifest are retained for seven days by the workflow. The manifest binds the fixture, product helpers, catalog, corpus and full reviewed rule-source bytes. Event records are not restored, and no retention-duration, Windows 11, domain/DC/ADCS, positive installed-DNS, forwarding or Sigma acceptance is implied. This advances issues #386 and #366 without closing their broader acceptance work. + +The underlying enablement, size, retention and backup options follow Microsoft's [wevtutil command reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). The product's existing channel floors and schema gates remain unchanged. + +The first native run exposed a WinRM manifest bug: an unrelated event ID `3221734403` overflowed the reader's signed 32-bit cast and made the whole provider schema unknown. The reader now compares [EventMetadata.Id in its native Int64 domain](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata.id), then parses only the exact reviewed event/channel templates. Focused tests also require refusal when only unrelated large IDs exist; schema gates are unchanged. diff --git a/docs/native-provider-packs.md b/docs/native-provider-packs.md index 1061ba35..0af10746 100644 --- a/docs/native-provider-packs.md +++ b/docs/native-provider-packs.md @@ -47,7 +47,7 @@ Successful channel configuration says nothing about benign operation generation, Every attempted native write first records the original enabled flag, exact buffer size, retention and complete descriptor in `before.jsonl`. Restore only the recorded selected channel values using an elevated `wevtutil sl` after reviewing concurrent GPO/administrator changes; do not replace an entire descriptor with an example. No automatic rollback overwrites later changes. Event loss/volume and long-term storage requirements require a measured deployment plan. -The mocked regression suite exercises missing fields/providers, unsupported types/builds, role/service gates, journal-before-write, dry-run, decline, idempotence, preserved ACL/retention/larger buffers, native failure/false success, prompt races and final schema drift. Windows Server 2022/2025 CI on PowerShell 5.1/7 reads real provider manifests and the public CLI plan and checks that channel settings stay unchanged. It creates no DNS queries, log entries, services or subscriptions. Windows 11/DC/CA event-generation and actual backend/collector validation remain pending acceptance work for issue #386. +The mocked regression suite exercises missing fields/providers, unsupported types/builds, role/service gates, journal-before-write, dry-run, decline, idempotence, preserved ACL/retention/larger buffers, native failure/false success, prompt races and final schema drift. Windows Server 2022/2025 CI on PowerShell 5.1/7 reads real provider manifests and the public CLI plan and checks that channel settings stay unchanged. It creates no DNS queries, log entries, services or subscriptions. Windows 11/DC/CA event-generation and actual backend/collector validation remain pending acceptance work for issue #386. A separate [disposable native configuration acceptance suite](native-provider-acceptance.md) now exercises actual public Configure, dry-run, idempotence, refusal, partial outcomes, journals and exact cleanup for the four client-side packs. It supplies configuration proof only. Primary references: [Microsoft WEF Appendix C/F](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), [DNS logging and diagnostics](https://learn.microsoft.com/en-us/windows-server/networking/dns/dns-logging-and-diagnostics), [EventMetadata](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata?view=windowsdesktop-10.0), [EventLogLink](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventloglink?view=windowsdesktop-10.0), [Windows 11 release families](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information), and [Windows Server release families](https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info). The WEF sample identifies event/channel candidates; it does not validate these rule definitions or this implementation on every build. diff --git a/docs/wec-collector-observation.md b/docs/wec-collector-observation.md new file mode 100644 index 00000000..281a2253 --- /dev/null +++ b/docs/wec-collector-observation.md @@ -0,0 +1,22 @@ +# Native collector subscription observations + +The existing `wec-collector` Audit and Plan commands now enumerate subscription names through the local Windows Event Collector API and read selected XML through the shared bounded Unicode reader. This avoids treating PowerShell console output, including a BOM-only empty result, as subscription identity. Non-ASCII descriptions and XPath literals remain intact in the report. + +```powershell +.\WELA.ps1 wec-collector -WefAction Audit ` + -WefConfigPath C:\Reviewed\collector.json -ResultsPath C:\Evidence\collector-audit.json +.\WELA.ps1 wec-collector -WefAction Plan ` + -WefConfigPath C:\Reviewed\collector.json -ResultsPath C:\Evidence\collector-plan.json +``` + +Use the explicit collector configuration described in [WEF deployment](wef-deployment.md). These commands observe the selected local subscriptions and prerequisites. They do not create, save, enable or delete subscriptions. Existing Configure remains create-only and retains its domain, listener, ingress and hardening prerequisites. + +A successful complete enumeration can establish `ObservedSubscription.Exists: false`; its `ObservedEnabled` remains null. An enumeration error, cap, duplicate/invalid native name, vanished or unreadable selected definition, mismatched XML identity or unsupported authorization remains unknown, with `ObservationError` and an `Unknown` control. Failed observations never authorize creation. A valid disabled definition is reported as disabled even when the requested XML says enabled. A readable difference requires manual review rather than a replacement. + +Enumeration preserves exact native UTF-16 names, including Unicode and whitespace; it does not trim names or parse localized command output. It is limited to 4,096 names, 1,023 UTF-16 characters per name and 1,048,576 total characters including terminators. Exceeding a bound fails the observation instead of returning a partial list. Selected subscription IDs continue to use the existing supported ASCII ID syntax, and native XML reads retain their ten-MiB and thirty-second bounds. Native API errors are preserved as failures. The loaded enumeration helper is bound to its implementation bytes. + +Enumeration and XML readback are sequential observations, not a transaction or protection against another administrator. A disappearing subscription is unknown for that observation; retry with a fresh audit. A complete configuration match still does not establish source identity, effective source access, runtime health, event arrival, bookmark continuity or Sigma coverage. Collector-local channel observations describe only the collector. + +The disposable native suite exercises the actual public commands on Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7. It uses one uniquely owned disabled subscription with Unicode description and XPath, verifies absence, exact observation, requested/observed state separation, changed-description review and authorization-mismatch uncertainty, then removes only the owned subscription and restores original service startup/state. It preserves the destination channel and original subscription inventory. The real standalone fixture remains `Incomplete` with exit 1 for domain deployment prerequisites; those checks are neither mocked nor counted as domain or forwarding proof. Native name-buffer, cap, duplicate and read-failure regressions supplement that Windows acceptance. + +Microsoft references: [subscription enumeration](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecenumnextsubscription), [enumeration handles](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscriptionenum), and [wecutil XML/read-only commands](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil). diff --git a/docs/wef-deployment.md b/docs/wef-deployment.md index 3614569b..108558e7 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -50,6 +50,8 @@ ForwardedEvents enablement preserves its size, retention mode and security descr ## Subscription XML and evidence +[Native collector observations](wec-collector-observation.md) use complete bounded WEC name enumeration and strict Unicode XML reads. Failed or partial observations remain unknown; they never become permission to create a subscription. Raw Unicode descriptions/filters and actual disabled state are retained independently of the requested settings. + The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator. 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. @@ -64,7 +66,7 @@ Use the separate [reviewed authorization update](wec-authorization.md) to change `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. +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. Those read-only smoke tests do not deploy subscriptions or prove forwarding. The separate [native observation fixture](wec-collector-observation.md) now exercises public Audit/Plan against one owned disabled subscription on standalone Server 2022/2025 runners, preserving real unmet domain prerequisites and exact fixture cleanup; it does not test domain deployment or delivery. 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. diff --git a/modules/WecSubscriptionInventory.cs b/modules/WecSubscriptionInventory.cs new file mode 100644 index 00000000..7c1654a0 --- /dev/null +++ b/modules/WecSubscriptionInventory.cs @@ -0,0 +1,52 @@ +// Read-only WEC names, returned only after complete bounded native enumeration. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecInventory { + public static class Reader { + public const string SourceSha256="__WELA_SOURCE_SHA256__"; + const uint Capacity=1024; + [DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,IntPtr name,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + // Public only for safe allocated-buffer ABI and boundary regression tests. + public static string DecodeName(IntPtr buffer,uint used,uint capacity) { + if(buffer==IntPtr.Zero||capacity<2||capacity>Capacity||used<2||used>capacity)throw new InvalidDataException("Invalid native subscription-name buffer."); + if(Marshal.ReadInt16(buffer,checked((int)(used-1)*2))!=0)throw new InvalidDataException("Native subscription name is not terminated."); + byte[] bytes=new byte[checked((int)(used-1)*2)];Marshal.Copy(buffer,bytes,0,bytes.Length); + string name=new UnicodeEncoding(false,false,true).GetString(bytes); + if(name.IndexOf('\0')>=0)throw new InvalidDataException("Native subscription name contains an embedded terminator."); + return name; + } + // Public so duplicate, count and aggregate bounds can be tested without Windows. + public static string[] ValidateNames(string[] names) { + if(names==null||names.Length>4096)throw new InvalidDataException("Native subscription inventory exceeds 4096 entries."); + var unique=new HashSet(StringComparer.OrdinalIgnoreCase);long characters=0; + foreach(string name in names) { + if(String.IsNullOrEmpty(name)||name.Length>=Capacity||name.IndexOf('\0')>=0||!unique.Add(name))throw new InvalidDataException("Native subscription inventory contains an invalid or duplicate name."); + new UnicodeEncoding(false,false,true).GetBytes(name); + characters+=name.Length+1;if(characters>1048576)throw new InvalidDataException("Native subscription inventory exceeds its total character bound."); + } + string[] result=(string[])names.Clone();Array.Sort(result,StringComparer.Ordinal);return result; + } + public static string[] ReadNames() { + IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + IntPtr buffer=Marshal.AllocHGlobal(checked((int)Capacity*2)); + try { + var names=new List();long characters=0; + while(true) { + uint used; + if(!EcEnumNextSubscription(handle,Capacity,buffer,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return ValidateNames(names.ToArray());throw new Win32Exception(error);} + string name=DecodeName(buffer,used,Capacity);characters+=name.Length+1; + if(names.Count>=4096||characters>1048576)throw new InvalidDataException("Native subscription inventory exceeded its bounds; no absence is established."); + names.Add(name); + } + }finally {Marshal.FreeHGlobal(buffer);} + }finally {EcClose(handle);} + } + } +} diff --git a/modules/WefSubscriptions.psm1 b/modules/WefSubscriptions.psm1 index 7f40d9d6..46ab3492 100644 --- a/modules/WefSubscriptions.psm1 +++ b/modules/WefSubscriptions.psm1 @@ -194,6 +194,27 @@ function Import-WelaWefConfig { [pscustomobject]@{ Config=$config; Path=$full; Subscriptions=$subscriptions } } +function Initialize-WelaWecSubscriptionInventory { + $path=Join-Path $PSScriptRoot 'WecSubscriptionInventory.cs' + $bytes=[IO.File]::ReadAllBytes($path);if($bytes.Length -gt 65536){throw 'Native inventory source exceeds its bound.'} + $sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + if(-not ('Wela.WecInventory.Reader' -as [type])){ + $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Native inventory source binding marker is missing or ambiguous.'} + $compile=@{TypeDefinition=$source.Replace('__WELA_SOURCE_SHA256__',$hash);ErrorAction='Stop'} + if($PSVersionTable.PSEdition -eq 'Desktop'){$compile.ReferencedAssemblies=@('System.dll','System.Core.dll')} + Add-Type @compile + } + if([Wela.WecInventory.Reader]::SourceSha256 -cne $hash){throw 'Loaded native inventory differs from its source; start a fresh process.'} +} + +function Get-WelaWecSubscriptionIds { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC inventory requires 64-bit Windows.'} + Initialize-WelaWecSubscriptionInventory + # The native method returns nothing until enumeration has completed successfully. + [Wela.WecInventory.Reader]::ReadNames() +} + function Read-WelaWecSubscriptionXml { param([Parameter(Mandatory)][string]$Id) if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC XML reads require 64-bit Windows.'} @@ -207,4 +228,4 @@ function Read-WelaWecSubscriptionXml { [Wela.WecXml.Reader]::ReadXml($Id) } -Export-ModuleMember -Function ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig +Export-ModuleMember -Function Get-WelaWecSubscriptionIds, ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig diff --git a/scripts/NativeProviderPacks.ps1 b/scripts/NativeProviderPacks.ps1 index 1ba0a64d..744d7d26 100644 --- a/scripts/NativeProviderPacks.ps1 +++ b/scripts/NativeProviderPacks.ps1 @@ -53,12 +53,15 @@ function Get-WelaProviderPackSchema { if ([guid]$provider.Id -eq [guid]::Empty) { throw 'Provider GUID is unknown.' } if (@($logs[0].ProviderNames) -notcontains $Pack.provider -or @($provider.LogLinks.LogName) -notcontains $Pack.channel) { throw 'Provider/channel links disagree.' } $events = @() + # EventMetadata.Id is Int64; WinRM includes unrelated IDs above Int32.MaxValue. + # Compare before parsing selected templates, without narrowing the native ID. + $expectedIds = @($Pack.events | ForEach-Object { [long]$_.id }) foreach ($event in $provider.Events) { - if (@($Pack.events.id) -contains [int]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) { + if ($expectedIds -contains [long]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) { $fields = @(Get-WelaProviderTemplateFields -Template $event.Template) $sha = [Security.Cryptography.SHA256]::Create() try { $templateHash = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes([string]$event.Template)))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } - $events += [pscustomobject]@{ Id=[int]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash } + $events += [pscustomobject]@{ Id=[long]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash } } } [pscustomobject]@{ State='Observed'; Provider=[string]$provider.Name; ProviderGuid=[string]$provider.Id; ChannelType=[string]$logs[0].LogType; Events=$events; Diagnostic=$null } diff --git a/scripts/WefDeployment.ps1 b/scripts/WefDeployment.ps1 index 39d9597b..bb0f78cc 100644 --- a/scripts/WefDeployment.ps1 +++ b/scripts/WefDeployment.ps1 @@ -30,13 +30,14 @@ function Get-WelaWefControlState { '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 { $_ }) + $ids = @(Get-WelaWecSubscriptionIds) if ($ids -notcontains $Target.Id) { return [pscustomobject]@{ Exists=$false; Xml=$null; Key=$null; Definition=$null } } # 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) + $xml = [string]::Concat((Read-WelaWecSubscriptionXml -Id $Target.Id)) $model = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids $Target.SourceSids -Observed + if($model.Id -cne $Target.Id){throw 'Native subscription identity differs from the selected ID.'} return [pscustomobject]@{ Exists=$true; Xml=$xml; Key=$model.Key; Definition=$model.Definition } } default { throw "Unsupported WEF control kind: $Kind" } diff --git a/tests/NativeProviderConfigure.Windows.Tests.ps1 b/tests/NativeProviderConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..52019079 --- /dev/null +++ b/tests/NativeProviderConfigure.Windows.Tests.ps1 @@ -0,0 +1,174 @@ +param([switch]$AllowDisposableProviderWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableProviderWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows provider-write opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +Import-Module "$repo/modules/NativeProviders.psm1" -Force +Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/NativeChannelConfiguration.ps1" +. "$repo/scripts/NativeProviderPacks.ps1" +$count=0;$errors=@();$primary=$null;$mutated=@() +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 30|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Read-Raw([string]$Name){$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml');$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.LoadXml(($r.Output -join "`n"));return ,$doc} +function Guard-Raw($Xml){$copy=$Xml.CloneNode($true);$copy.DocumentElement.RemoveAttribute('enabled');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml} +function Services { + foreach($name in @('EventLog','Winmgmt','WinRM','TermService','DNS')){ + $state=Get-WelaNativeService $name + if($state.State -eq 'Unknown'){throw "Service $name is unreadable"} + [pscustomobject][ordered]@{Name=$name;State=$state.State;Start=$(if($state.State -ne 'Not installed'){[string](Get-Service -Name $name -ErrorAction Stop).StartType}else{$null})} + } +} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 20 -Compress} +Add-Type -TypeDefinition @' +using System; using System.IO; using System.Text; using System.Threading.Tasks; +public static class WelaProviderConfigureFixturePipe { + public static async Task Read(TextReader reader) { + var text=new StringBuilder(); var buffer=new char[1024]; + while(true) { int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false); if(n==0)return text.ToString(); + if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeded 1Mi characters.");text.Append(buffer,0,n); } + } +} +'@ +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-provider-configure-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$wrapper=Join-Path $root 'public.ps1' +@' +param([string]$InputPath) +$ErrorActionPreference='Stop';$global:LASTEXITCODE=0 +$p=Get-Content -LiteralPath $InputPath -Raw|ConvertFrom-Json +$a=@{ProviderAction=[string]$p.Action;ProviderPack=[string[]]$p.Names;ResultsPath=[string]$p.ResultsPath} +if($p.Action -ceq 'Configure'){$a.Auto=$true;$a.BackupPath=[string]$p.BackupPath} +if($p.DryRun){$a.DryRun=$true} +# Array-splatted strings are positional values, not named PowerShell switches. +# Fixed literal branches exercise the public parameter parser exactly. +if(@($p.Extra).Count -eq 0){& ([string]$p.Script) provider-packs @a} +elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-WhatIf'){& ([string]$p.Script) provider-packs @a -WhatIf} +elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-GrantEventLogReaders'){& ([string]$p.Script) provider-packs @a -GrantEventLogReaders} +else{throw 'Unreviewed fixture option.'} +exit $global:LASTEXITCODE +'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 +function Public([string]$Name,[string]$Action,[string[]]$Names,[switch]$DryRun,[int]$Expected=0,[string[]]$Extra=@()){ + $inputPath=Join-Path $root ($Name+'-input.json');$resultPath=Join-Path $root ($Name+'.json');$backup=Join-Path $root ($Name+'-journal') + Save ($Name+'-input.json') @{Script="$repo/WELA.ps1";Action=$Action;Names=$Names;DryRun=[bool]$DryRun;ResultsPath=$resultPath;BackupPath=$backup;Extra=$Extra} + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-InputPath',$inputPath) + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try { + if(-not $process.Start()){throw 'Public process did not start'};$started=$true + $stdout=[WelaProviderConfigureFixturePipe]::Read($process.StandardOutput);$stderr=[WelaProviderConfigureFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public command output drain timed out.'} + $text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text) + Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text" + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed'}} + try{$process.Dispose()}catch{$script:errors+=$_.Exception.Message} + } + if(Test-Path $resultPath){$r=Get-Content $resultPath -Raw|ConvertFrom-Json;Assert ($r.ExitCode -eq $Expected -and $r.ReadyRules -eq 0 -and $r.UnverifiedEvidence.Count -eq 4) 'Public result agrees with process exit and grants no readiness credit.';return $r} + Assert ($Expected -eq 1 -and -not(Test-Path $backup)) 'Invalid CLI refuses before report or recovery directory creation.' +} +$catalog=Get-WelaProviderPackCatalog +$names=@('dns-client','capi2','winrm','rdp-client');$selected=@($catalog.packs|Where-Object {$names -contains $_.id}) +$channels=@(@($catalog.packs.channel)+@('Security','System','Application','Microsoft-Windows-AppLocker/EXE and DLL','Microsoft-Windows-DriverFrameworks-UserMode/Operational')|Sort-Object -Unique) +$before=@{};$raw=@{};$prepared=@{};$preparedRaw=@{};$services=@(Services);$policies=Get-WelaEffectiveAuditPolicy +foreach($channel in $channels){$before[$channel]=Get-WelaNativeChannel $channel;if(Test-WelaNativeChannelSnapshot $before[$channel]){$raw[$channel]=Read-Raw $channel}} +$rawText=@{};foreach($channel in $raw.Keys){$rawText[$channel]=$raw[$channel].OuterXml} +Save 'original.json' @{Channels=$before;RawXml=$rawText;Services=$services;AuditMasks=$policies;Engine=$PSVersionTable.PSVersion.ToString()} +function Stable-Selected {foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $configured[$channel] (Get-WelaNativeChannel $channel)) 'Idempotent/refused/partial invocation preserves the expected complete selected-channel tuple.'}} +function Preserved { + foreach($channel in $channels){ + $now=Get-WelaNativeChannel $channel + if($selected.channel -contains $channel){Assert ((Guard-Raw (Read-Raw $channel)) -ceq (Guard-Raw $preparedRaw[$channel])) 'Selected channel preserves complete descriptor, retention, path and publisher settings.'} + elseif($raw.ContainsKey($channel)){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Unselected registered channel retains every configuration field.'} + else{Assert ((Key $now) -ceq (Key $before[$channel])) 'Uninstalled/unreadable nonselected channel observation remains unchanged.'} + } + Assert ((Key @(Services)) -ceq (Key $services)) 'EventLog, Winmgmt, WinRM, RDP and DNS service state/start types remain unchanged.' + $nowMasks=Get-WelaEffectiveAuditPolicy;Assert ($nowMasks.Count -eq 59 -and $policies.Count -eq 59) 'All59 native audit masks are present.' + foreach($guid in $policies.Keys){if($nowMasks[$guid] -ne $policies[$guid]){throw "Audit mask changed: $guid"}};$script:count++ +} +try { + Assert (@($services|Where-Object {$_.Name -in @('Winmgmt','EventLog') -and $_.State -ne 'Running'}).Count -eq 0) 'Metadata dependencies must already be running; fixture never starts services.' + $os=Get-CimInstance Win32_OperatingSystem + Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server2022/2025 required.' + Assert (@($services|Where-Object {$_.Name -eq 'DNS' -and $_.State -eq 'Not installed'}).Count -eq 1) 'Fixture requires genuine DNS Server absence; no role is installed/removed for acceptance.' + foreach($pack in $selected){Assert ($raw.ContainsKey($pack.channel)) "Actual selected channel required: $($pack.id)"} + # First real public observation must support all four reviewed manifest gates. + $initial=Public 'initial' 'Plan' $names + foreach($entry in $initial.ControlsPlan){Assert ($entry.ProviderEvidence.CanConfigure -and $entry.ProviderEvidence.Schema.State -ceq 'Observed' -and $entry.ProviderEvidence.Schema.Provider -ceq $entry.Pack.provider) 'Exact actual provider/schema permits the selected pack.'} + Assert ($initial.ControlsPlan.Count -eq 4) 'Exactly four explicit packs are observed.' + foreach($channel in $raw.Keys){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Initial public Plan preserves every registered channel configuration.'} + foreach($pack in $selected){ + $channel=$pack.channel;$mutated+=,$channel + $size=if($pack.id -ceq 'winrm'){2147483648L}else{1048576L} + $nativeArguments=@('sl',$channel,'/e:false',('/ms:'+$size));if($pack.id -ceq 'capi2'){$nativeArguments+=@('/rt:true','/ab:false')} + $null=Invoke-WelaNative wevtutil.exe $nativeArguments + $prepared[$channel]=Get-WelaNativeChannel $channel;$preparedRaw[$channel]=Read-Raw $channel + } + $preparedText=@{};foreach($channel in $preparedRaw.Keys){$preparedText[$channel]=$preparedRaw[$channel].OuterXml} + Save 'prepared.json' $prepared;Save 'prepared-xml.json' $preparedText + $planned=Public 'plan' 'Plan' $names + Assert (@($planned.ControlsPlan|Where-Object Status -cne 'ChangeRequired').Count -eq 0) 'Actual disabled/small prepared channels require change.' + $dry=Public 'dry' 'Configure' $names -DryRun + Assert ($dry.DryRun -and $dry.Results.Count -eq 4 -and @($dry.Results|Where-Object Status -cne 'Skipped').Count -eq 0 -and -not(Test-Path "$root/dry-journal")) 'Public DryRun skips all selected writes and creates no journal.' + $null=Public 'whatif' 'Configure' $names -Expected 1 -Extra @('-WhatIf') + $null=Public 'grant-option' 'Configure' $names -Expected 1 -Extra @('-GrantEventLogReaders') + foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$channel] (Get-WelaNativeChannel $channel)) 'Plan, DryRun and invalid options preserve prepared actual state.'} + Preserved + $configured=@{} + $applied=Public 'configure' 'Configure' $names + Assert ($applied.Action -ceq 'Configure' -and $applied.Scope -ceq 'native-channel-settings-only' -and $applied.Results.Count -eq 4 -and @($applied.Results|Where-Object Status -cne 'Applied').Count -eq 0) 'All four explicit configurations are actually Applied.' + $journal=@(Get-Content "$root/configure-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 4 -and @($journal|Where-Object {$selected.channel -notcontains $_.Target.Channel}).Count -eq 0) 'Exactly four selected changes have durable original journals.' + foreach($pack in $selected){ + $channel=$pack.channel;$entry=@($applied.Results|Where-Object {$_.Target.Channel -ceq $channel});$j=@($journal|Where-Object {$_.Target.Channel -ceq $channel});$now=Get-WelaNativeChannel $channel;$configured[$channel]=$now + $minimum=if($pack.id -ceq 'capi2'){102432768L}elseif($pack.id -ceq 'winrm'){2147483648L}else{33554432L} + Assert ($entry.Count -eq 1 -and $j.Count -eq 1 -and (Test-WelaNativeChannelSnapshotEqual $j[0].Before $prepared[$channel]) -and (Test-WelaNativeChannelSnapshotEqual $entry[0].Before $prepared[$channel])) 'Native journal and result retain exact prepared before-state.' + Assert ($now.IsEnabled -and $now.MaximumSizeInBytes -eq $minimum -and (Test-WelaNativeChannelSnapshotEqual $entry[0].After $now)) 'Exact native enable/floor/larger-buffer readback matches Applied after-state.' + Assert ((Test-WelaChannelDescriptorEqual $now.SecurityDescriptor $prepared[$channel].SecurityDescriptor) -and $now.LogMode -ceq $prepared[$channel].LogMode -and -not $entry[0].Desired.AccessChangeRequested) 'Every descriptor byte and retention mode is preserved without a read grant.' + } + $configuredText=@{};foreach($channel in $selected.channel){$configuredText[$channel]=(Read-Raw $channel).OuterXml};Save 'configured-xml.json' $configuredText + Assert ((Get-WelaNativeChannel 'Microsoft-Windows-CAPI2/Operational').LogMode -ceq 'Retain') 'An actual nondefault Retain setting survives provider configuration.' + Preserved + $repeat=Public 'repeat' 'Configure' $names + Assert (@($repeat.Results|Where-Object Status -cne 'AlreadyCompliant').Count -eq 0 -and -not(Test-Path "$root/repeat-journal/before.jsonl")) 'Native repeat is idempotent and journals no write.' + Stable-Selected + $manual=Public 'manual' 'Configure' @('dns-server-analytical','dns-server-classic') -Expected 1 + Assert ($manual.Results.Count -eq 2 -and @($manual.Results|Where-Object Status -cne 'Failed').Count -eq 0 -and -not(Test-Path "$root/manual-journal/before.jsonl")) 'Both actual manual-only selections fail without channel mutation or journal.' + Stable-Selected + $missing=Public 'missing-dns' 'Configure' @('dns-server-audit') -Expected 1 + Assert ($missing.Results[0].Status -ceq 'Failed' -and $missing.ControlsPlan[0].ProviderEvidence.Service.State -ceq 'Not installed' -and -not(Test-Path "$root/missing-dns-journal/before.jsonl")) 'Missing actual DNS service cannot be replaced by an assumed server role.' + Stable-Selected + # A genuine partial public run must retain one success and one manual refusal. + $capi='Microsoft-Windows-CAPI2/Operational';$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false');$partialBefore=Get-WelaNativeChannel $capi + $partial=Public 'partial' 'Configure' @('capi2','dns-server-analytical') -Expected 1 + Assert (@($partial.Results|Where-Object Status -ceq 'Applied').Count -eq 1 -and @($partial.Results|Where-Object Status -ceq 'Failed').Count -eq 1 -and (Get-WelaNativeChannel $capi).IsEnabled) 'Actual partial configuration retains one verified change and explicit nonzero failure.' + $partialJournal=@(Get-Content "$root/partial-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($partialJournal.Count -eq 1 -and $partialJournal[0].Target.Channel -ceq $capi -and (Test-WelaNativeChannelSnapshotEqual $partialJournal[0].Before $partialBefore)) 'Partial run journals only its actual selected write.' + Stable-Selected + Preserved + Save 'completed.json' @{Status='Passed';Assertions=$count;ActualAppliedControls=5;IdempotentControls=4;ManualRefusals=3;MissingServiceRefusals=1;ReadyRuleCredit=0} +}catch{$primary=$_} +finally { + foreach($channel in $mutated){ + try { + $s=$before[$channel];$retention=if($s.LogMode -ceq 'Circular'){'false'}else{'true'};$backup=if($s.LogMode -ceq 'AutoBackup'){'true'}else{'false'} + $null=Invoke-WelaNative wevtutil.exe @('sl',$channel,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor),('/rt:'+$retention),('/ab:'+$backup)) + if(-not(Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $channel)) -or (Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Exact original channel configuration differs after cleanup.'} + }catch{$errors+="$channel : $($_.Exception.Message)"} + } + $after=@{};$afterRaw=@{};foreach($channel in $channels){try{$after[$channel]=Get-WelaNativeChannel $channel;if($raw.ContainsKey($channel)){$afterRaw[$channel]=(Read-Raw $channel).OuterXml;if($afterRaw[$channel] -cne $raw[$channel].OuterXml){throw 'Original channel XML differs'}}elseif((Key $after[$channel]) -cne (Key $before[$channel])){throw 'Original unavailable observation differs'}}catch{$errors+="$channel : $($_.Exception.Message)"}} + $serviceAfter=$null;try{$serviceAfter=@(Services);if((Key $serviceAfter) -cne (Key $services)){throw 'Service state/start type differs'}}catch{$errors+=$_.Exception.Message} + $maskAfter=$null;try{$maskAfter=Get-WelaEffectiveAuditPolicy;if($maskAfter.Count -ne $policies.Count){throw 'Audit mask count differs'};foreach($guid in $policies.Keys){if($maskAfter[$guid] -ne $policies[$guid]){throw "Audit mask differs: $guid"}}}catch{$errors+=$_.Exception.Message} + Save 'cleanup.json' @{CleanupVerified=($errors.Count -eq 0);Original=$before;After=$after;AfterRawXml=$afterRaw;ServicesBefore=$services;ServicesAfter=$serviceAfter;AuditMasksCompared=$policies.Count;AuditMasksAfter=$maskAfter;Errors=$errors;PrimaryError=[string]$primary;Assertions=$count} +} +$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}}) +$sourcePaths=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','scripts/NativeProviderPacks.ps1','modules/AuditProfiles.psm1','modules/EventLogSettings.psm1','modules/NativeProviders.psm1','modules/NativeChannelAccess.psm1','config/native_channel_profile.json','config/native_provider_packs.json','config/security_rules.json','tests/NativeProviderConfigure.Windows.Tests.ps1')+@($catalog.ruleReviews|ForEach-Object {'config/'+$_.localPath}) +$sources=@($sourcePaths|ForEach-Object {[ordered]@{Path=$_;Sha256=(Get-FileHash -LiteralPath (Join-Path $repo $_) -Algorithm SHA256).Hash}}) +Save 'manifest.json' @{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;Sources=$sources;EventGenerationVerified=$false;ForwardingVerified=$false;ReadyRuleCredit=0} +if($errors.Count){throw "Fixture cleanup failed: $($errors -join '; '); primary=$primary"};if($primary){throw $primary} +Write-Host "PASS: $count native public provider-pack assertions and exact channel/service/audit cleanup. No event generation or Sigma proof." +exit 0 diff --git a/tests/NativeProviderPacks.Tests.ps1 b/tests/NativeProviderPacks.Tests.ps1 index 84cc23c7..6e51e5fd 100644 --- a/tests/NativeProviderPacks.Tests.ps1 +++ b/tests/NativeProviderPacks.Tests.ps1 @@ -43,6 +43,10 @@ function Get-WinEvent { $channel=if($f.TemplateMode -eq 'wrongchannel'){'Other/Operational'}else{$p.channel} $events+= [pscustomobject]@{Id=$e.id;Version=0;LogLink=[pscustomobject]@{LogName=$channel};Template=$template} } + if($f.LargeIds){ + if($f.LargeOnly){$events=@()} + foreach($large in @([long]3221734403,[long]4294967295)){$events+=[pscustomobject]@{Id=$large;Version=0;LogLink=[pscustomobject]@{LogName=$p.channel};Template='unselected template is never parsed'}} + } [pscustomobject]@{Name=$ListProvider;Id='11111111-1111-1111-1111-111111111111';LogLinks=@([pscustomobject]@{LogName=$p.channel});Events=$events} } function Read-Host {param($Prompt) if($f.PromptSchemaDrift){$f.TemplateMode='missing'};$f.Prompt} @@ -95,6 +99,11 @@ try { Assert (@($entry.RuleReviews|Where-Object Eligibility -ne 'Conditional').Count -eq 0 -and $report.ReadyRules -eq 0) 'Provider settings never convert incomplete rule evidence into Ready.' Assert ($entry.ProviderEvidence.Schema.Events[0].Fields[0].InType -eq 'win:UnicodeString' -and $entry.ProviderEvidence.Schema.Events[0].TemplateSha256.Length -eq 64) 'Report retains runtime version, native field types and template fingerprint.' Assert ($f.Writes.Count -eq 0 -and -not(Test-Path $backup)) 'Read-only plan creates no journal and makes no channel changes.' + Reset;$f.LargeIds=$true;$r=Invoke-WelaProviderPackCommand -Action Plan -Names winrm + Assert ($r.ExitCode -eq 0 -and $r.ControlsPlan[0].ProviderEvidence.CanConfigure) 'Actual WinRM Int64 event IDs above Int32 do not invalidate unrelated selected event6.' + Assert ($r.ControlsPlan[0].ProviderEvidence.Schema.Events.Count -eq 1 -and $r.ControlsPlan[0].ProviderEvidence.Schema.Events[0].Id -eq 6) 'Only the exact reviewed event6 enters schema evidence; large unselected IDs/templates are excluded.' + Reset;$f.LargeIds=$true;$f.LargeOnly=$true;$r=Invoke-WelaProviderPackCommand -Action Configure -Names winrm -Auto -BackupPath $backup + Assert ($r.ExitCode -eq 1 -and -not $r.ControlsPlan[0].ProviderEvidence.CanConfigure -and $f.Writes.Count -eq 0) 'Unrelated large native IDs cannot substitute for a missing selected event6.' Reset;$f.States['Microsoft-Windows-DNS-Client/Operational'].State='Not installed';$f.States['Microsoft-Windows-DNS-Client/Operational'].IsEnabled=$null $r=Invoke-WelaProviderPackCommand -Action Configure -Names dns-client -Auto -BackupPath $backup Assert ($r.ExitCode -eq 1 -and $f.Writes.Count -eq 0) 'Missing actual channel metadata cannot be replaced by provider-manifest availability.' diff --git a/tests/WecCollectorObservation.Windows.Tests.ps1 b/tests/WecCollectorObservation.Windows.Tests.ps1 new file mode 100644 index 00000000..21ab6ff8 --- /dev/null +++ b/tests/WecCollectorObservation.Windows.Tests.ps1 @@ -0,0 +1,101 @@ +param([switch]$AllowDisposableSubscription) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ChannelRead.ps1" +$count=0;$engine=(Get-Process -Id $PID).Path +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)} +function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}} +function Inventory {$ids=@(Get-WelaWecSubscriptionIds);if($ids.Count -gt 64){throw 'Disposable fixture inventory exceeds 64 entries.'};@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})} +$hostState=Get-WelaChannelReadHost +Assert ($hostState.Build -in @(20348,26100) -and $hostState.UBR -gt 0 -and $hostState.ProductType -eq 3 -and $hostState.DomainRole -eq 2 -and -not $hostState.DomainJoined) 'Actual patched standalone Server2022/2025 fixture; no invented domain identity' +$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Observe-'+$nonce;$unicode=([string][char]0x65e5)+([string][char]0x672c) +$description='Owned observation '+$nonce+' '+$unicode;$sid='S-1-5-21-111111111-222222222-333333333-1234' +$root=Join-Path $env:RUNNER_TEMP ('wela-wec-observation-'+$nonce);$null=New-Item -ItemType Directory $root +function Save($Name,$Value){$text=ConvertTo-Json -InputObject $Value -Depth 30;[IO.File]::WriteAllText((Join-Path $root $Name),$text,[Text.UTF8Encoding]::new($false))} +$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart +$original=$null;$created=$false;$failure=$null;$errors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$reports=@();$afterServices=$null;$afterChannel=$null;$afterDelayed=$null +$sources=[ordered]@{};foreach($p in @('WELA.ps1','scripts/WefDeployment.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionInventory.cs','modules/WecSubscriptionXml.cs')){$sources[$p]=(Get-FileHash (Join-Path $repo $p)).Hash.ToLowerInvariant()} +Save 'before-fixture.json' @{Host=$hostState;Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed;Sources=$sources} +$config=Get-Content "$repo/config/wef-examples/collector.json" -Raw|ConvertFrom-Json +# Existing Audit/Plan deliberately remain incomplete on this real standalone runner. +# The example collector identity is never resolved, contacted or asserted as local. +$config.SourceSids=@($sid);$config.SubscriptionFiles=@('requested.xml');$config.IngressRuleName='WELA-Absent-'+$nonce +$path=Join-Path $root 'requested.xml';$configPath=Join-Path $root 'collector.json';Save 'collector.json' $config +$query='' +$xml=@" +$idSourceInitiated$descriptionfalsehttp://schemas.microsoft.com/wbem/wsman/1/windows/EventLogNormalfalseHTTPEventsForwardedEvents +"@ +[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false)) +function Public([string]$Action,[string]$Name){ + $out=Join-Path $root ($Name+'.json');$prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" wec-collector -WefAction $Action -WefConfigPath $configPath -ResultsPath $out 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + [IO.File]::WriteAllText((Join-Path $root ($Name+'.log')),($text -join "`n"),[Text.UTF8Encoding]::new($false)) + Assert ($code -eq 1 -and (Test-Path $out)) 'Public collector reports incomplete real standalone prerequisites with exit1' + $r=Get-Content -LiteralPath $out -Raw -Encoding UTF8|ConvertFrom-Json + Assert ($r.Action -ceq $Action -and $r.Role -ceq 'Collector' -and $r.LocalConfigurationStatus -ceq 'Incomplete' -and -not $r.HostIdentity.DomainJoined) 'Public report preserves actual role and incomplete domain prerequisites' + Assert ($r.Subscriptions.Count -eq 1 -and $r.Subscriptions[0].Id -ceq $id -and $r.Subscriptions[0].EventArrival -ceq 'Not tested' -and $r.Subscriptions[0].ForwardedSigmaCoverage -ceq 'Not assessed' -and $r.Subscriptions[0].ChannelObservationLocation -like 'Collector only*') 'No source authentication, remote channel or forwarded coverage claim' + $script:reports+=($Name+'.json');$r +} +function SubscriptionControl($Report){@($Report.Controls|Where-Object Kind -eq Subscription)[0]} +try { + $wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original collector service state required' + if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc} + $original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Get-WelaWecSubscriptionIds) -notcontains $id) 'Unique owned subscription initially absent' + Save 'console-enumeration-before.json' (Invoke-WelaNative 'wecutil.exe' @('es')) + $duringServices=Services + $absent=Public Audit 'absent-before' + Assert ($absent.Subscriptions[0].ObservedSubscription.Exists -eq $false -and $null -eq $absent.Subscriptions[0].ObservedEnabled -and -not $absent.Subscriptions[0].ObservationError -and (SubscriptionControl $absent).Status -ceq 'ChangeRequired') 'Complete native enumeration establishes selected absence' + $model=Import-WelaWefConfig $configPath Collector;$ownedPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($ownedPath,$model.Subscriptions[0].Xml,[Text.UTF8Encoding]::new($false)) + $created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$ownedPath) + $before=Read-WelaWecSubscriptionXml $id;[IO.File]::WriteAllText((Join-Path $root 'original-owned.xml'),$before,[Text.UTF8Encoding]::new($false)) + Assert (@(Get-WelaWecSubscriptionIds) -ccontains $id) 'Actual native enumeration returns exact owned ID' + foreach($action in @('Audit','Plan')){ + $r=Public $action ('present-'+$action.ToLowerInvariant());$o=$r.Subscriptions[0] + Assert ($o.ObservedSubscription.Exists -and $o.ObservedEnabled -eq $false -and -not $o.ObservationError -and (SubscriptionControl $r).Status -ceq 'RequestedSettingsMatch') 'Existing disabled native definition is observed and matched' + Assert ($o.ObservedSubscription.Xml -ceq $before -and $o.ObservedSubscription.Definition.Description -ceq $description -and $o.Filters[0].XPath -ceq ('*[System[(EventID=1)] and EventData[Data='''+$unicode+''']]')) 'Public JSON preserves exact Unicode native XML, description and selected XPath' + Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'Public Audit/Plan does not save or alter existing subscription' + } + [IO.File]::WriteAllText($path,$xml.Replace('false','true'),[Text.UTF8Encoding]::new($false)) + $r=Public Plan 'requested-enabled' + Assert ($r.Subscriptions[0].RequestedEnabled -and $r.Subscriptions[0].ObservedEnabled -eq $false -and (SubscriptionControl $r).Status -ceq 'ManualReview') 'Actual disabled state is not replaced with requested enabled state' + [IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false)) + try { + $null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift'))) + $r=Public Audit 'description-drift' + Assert ((SubscriptionControl $r).Status -ceq 'ManualReview' -and $r.Subscriptions[0].ObservedSubscription.Definition.Description -ceq ($description+' drift')) 'Native Unicode drift is retained and does not become a match' + }finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))} + $config.SourceSids=@($sid.Replace('-1234','-1235'));Save 'collector.json' $config + $r=Public Audit 'authorization-mismatch' + Assert ((SubscriptionControl $r).Status -ceq 'Unknown' -and $null -eq $r.Subscriptions[0].ObservedSubscription -and $null -eq $r.Subscriptions[0].ObservedEnabled -and $r.Subscriptions[0].ObservationError) 'Unsupported observed authorization remains unknown, never absent' + $config.SourceSids=@($sid);Save 'collector.json' $config + Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'All public observations and fixture drift restoration preserve original raw XML' + [IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false)) + $null=Invoke-WelaNative 'wecutil.exe' @('ds',$id);$created=$false + $r=Public Audit 'absent-after';Assert ($r.Subscriptions[0].ObservedSubscription.Exists -eq $false -and -not $r.Subscriptions[0].ObservationError) 'Actual removed owned subscription returns confirmed absence' + Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $beforeChannel)) 'Read-only public commands preserve services and complete destination configuration' + Write-Host "PASS: $count actual collector observation assertions on $($PSVersionTable.PSVersion). No domain/forwarding proof." +}catch{$failure=$_.ToString();Write-Host $failure}finally{ + try { + if($created -and @(Get-WelaWecSubscriptionIds) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)} + $restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original) + }catch{$errors+=$_.ToString()} + try{$afterChannel=Channel;$channelOk=(Key $afterChannel) -ceq (Key $beforeChannel)}catch{$errors+=$_.ToString()} + try { + $wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0] + if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc} + if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled} + if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}} + $afterServices=Services;$afterDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart + $servicesOk=(Key $afterServices) -ceq (Key $beforeServices) -and (Key $afterDelayed) -ceq (Key $beforeDelayed) + }catch{$errors+=$_.ToString()} + Save 'after-fixture.json' @{Services=$afterServices;Channel=$afterChannel;DelayedAutoStart=$afterDelayed} + $artifacts=@(Get-ChildItem $root -File|ForEach-Object {[pscustomobject]@{Name=$_.Name;Bytes=$_.Length;Sha256=(Get-FileHash $_.FullName).Hash.ToLowerInvariant()}}) + Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$errors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelPreserved=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $errors.Count);Assertions=$count;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostState;Sources=$sources;Artifacts=$artifacts;PublicReports=$reports;Scope='Native local collector observation only; real standalone prerequisites remain incomplete.'} +} +if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $errors.Count){throw "Native observation or fixture cleanup failed; inspect $root"} +exit 0 diff --git a/tests/WecSubscriptionInventory.Tests.ps1 b/tests/WecSubscriptionInventory.Tests.ps1 new file mode 100644 index 00000000..87e04847 --- /dev/null +++ b/tests/WecSubscriptionInventory.Tests.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +& (Get-Module WefSubscriptions) {Initialize-WelaWecSubscriptionInventory} +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed 'Malformed, duplicate, partial or oversized native inventory must be rejected'} +Assert ([Wela.WecInventory.Reader]::SourceSha256 -ceq (Get-FileHash "$repo/modules/WecSubscriptionInventory.cs").Hash.ToLowerInvariant()) 'Loaded inventory binds exact source bytes' +Assert ([Wela.WecInventory.Reader]::ValidateNames([string[]]@()).Length -eq 0) 'Completed empty inventory is distinct from an error' +$unicode='Name '+[char]0x65e5+[char]0x672c +$names=[string[]]@('z',$unicode,'A',' leading ',([string][char]0xfeff)) +$observed=[Wela.WecInventory.Reader]::ValidateNames($names) +Assert ($observed.Length -eq 5 -and $observed -ccontains $unicode -and $observed -ccontains ' leading ' -and $observed -ccontains ([string][char]0xfeff)) 'Actual Unicode and whitespace names are retained, never console-trimmed' +Assert ($names[0] -ceq 'z') 'Validation does not mutate caller inventory' +Reject {[Wela.WecInventory.Reader]::ValidateNames($null)} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('same','SAME'))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(''))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@("ab`0cd"))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('x'*1024))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@([string][char]0xd800))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..4097|ForEach-Object {"id-$_"}))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..1025|ForEach-Object {$prefix=[string]$_;$prefix+('x'*(1023-$prefix.Length))}))} +$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64) +try { + for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)} + $bytes=[Text.Encoding]::Unicode.GetBytes($unicode+[char]0);[Runtime.InteropServices.Marshal]::Copy($bytes,0,$buffer,$bytes.Length) + Assert ([Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32) -ceq $unicode) 'Native used length counts UTF16 characters including terminator' + foreach($used in @(0,1,33)){Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$used,32)}} + Reject {[Wela.WecInventory.Reader]::DecodeName([IntPtr]::Zero,2,32)} + Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,1025)} + Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$unicode.Length,32)} + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,2,0);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32)} + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,32)} +}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} +Write-Host "PASS: $count native subscription inventory buffer/boundary assertions." diff --git a/tests/WefDeployment.Tests.ps1 b/tests/WefDeployment.Tests.ps1 index fba71b03..95b8509b 100644 --- a/tests/WefDeployment.Tests.ps1 +++ b/tests/WefDeployment.Tests.ps1 @@ -80,6 +80,15 @@ function Get-NetFirewallRule { param($Name,$PolicyStore) Assert ($PolicyStore -e 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/255.255.255.255'); RemoteAddress=@('192.0.2.0/255.255.255.0') } } } function Read-Host { param($Prompt) return $global:WelaWefFixture.Prompt } +function Get-WelaWecSubscriptionIds { + if($global:WelaWefFixture.Fail -eq 'Inventory'){throw 'Incomplete native inventory'} + @($global:WelaWefFixture.Subs.Keys) +} +function Read-WelaWecSubscriptionXml { + param($Id) + if($global:WelaWefFixture.Fail -eq 'ReadXml' -or -not $global:WelaWefFixture.Subs.ContainsKey($Id)){throw 'Native definition is no longer readable'} + $global:WelaWefFixture.Subs[$Id] +} function Invoke-WelaNative { param($FilePath,$Arguments) $f=$global:WelaWefFixture @@ -90,8 +99,7 @@ function Invoke-WelaNative { } 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]] } } + {$_ -in @('es','gs')} {throw 'Subscription inventory and XML must bypass console decoding.'} 'gr' { return [pscustomobject]@{ ExitCode=0; Output=@('Localized runtime fixture'); Diagnostic='Localized runtime fixture' } } 'cs' { Record-Write Subscription $Arguments @@ -215,6 +223,22 @@ try { 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 + foreach($failure in @('Inventory','ReadXml')) { + Reset-Fixture + $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector + $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml + $global:WelaWefFixture.Fail=$failure + $report=Invoke-Collector + Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Incomplete enumeration or disappearing/unreadable XML cannot authorize creation' + Assert ($null -eq $report.Subscriptions[0].ObservedSubscription -and $null -eq $report.Subscriptions[0].ObservedEnabled -and $report.Subscriptions[0].ObservationError) 'Read failure stays unknown rather than absent or disabled' + Assert (@($report.Controls|Where-Object {$_.Kind -eq 'Subscription' -and $_.Status -eq 'Unknown'}).Count -eq 1) 'Partial observation remains an unknown control' + } + Reset-Fixture + $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector + $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml.Replace($model.Subscriptions[0].Id,'Different native ID') + $report=Invoke-Collector + Assert ($report.ExitCode -eq 1 -and $report.Subscriptions[0].ObservationError -match 'identity differs' -and $global:WelaWefFixture.Writes.Count -eq 0) 'Mismatched native XML identity cannot become selected subscription evidence' + Reset-Fixture $global:WelaWefFixture.Fail='false-subscription' $report=Invoke-Collector Assert ($report.ExitCode -eq 1) 'A successful native exit without matching subscription readback fails' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index eeb7d40a..3827bc37 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -9,6 +9,10 @@ - `outgoing-ntlm` のAudit/Plan/Configureを追加し、送信NTLM監査DWORDを個別に設定できます。既存の拒否設定は既定で維持し、置換には明示的なAuditを要求します。不明な型・値や書込直前の変化を拒否し、元の型付き記録と再読取を保持します。Server 2022/2025のテストで範囲と復元を確認し、認証・イベント生成は未検証として報告します。(関連 #362) (@Shirofune-Security) +- コレクターのサブスクリプション観測で、コンソール経由の文字変換を、上限付きの完全なネイティブ名前列挙と厳密なUnicode XML読取に置き換えました。空の一覧・読取失敗・実際の無効状態を区別し、Unicodeの説明とXPathを保持します。Server 2022/2025と両PowerShellで公開Audit/Planおよび正確な後処理を検証し、ドメイン展開・転送・Sigma対応は主張しません。(関連 #368) (@Shirofune-Security) + +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。 WinRM のマニフェスト ID をネイティブの Int64 として比較し、対象外の大きい ID により必要なイベントの確認が失敗する不具合も修正。 (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 589feb83..9f3725db 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -9,6 +9,10 @@ - Add `outgoing-ntlm` Audit/Plan/Configure to manage the outgoing audit DWORD independently of broad configuration. Preserve existing deny by default, require explicit Audit to replace it, refuse unknown types/values and pre-write drift, and retain typed original journals plus native readback. Native Server 2022/2025 tests verify narrow scope and exact cleanup; authentication/event generation remain unverified. (Related #362) (@Shirofune-Security) +- Fixed collector subscription observations to use complete bounded native name enumeration and strict Unicode XML reads instead of console decoding. Empty inventories, failed reads and actual disabled state remain distinct; Unicode descriptions and XPath are preserved. Disposable public Audit/Plan tests cover both Server 2022/2025 and PowerShell engines with exact cleanup, without domain deployment, forwarding or Sigma claims. (Related #368) (@Shirofune-Security) + +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. Fixed WinRM manifest inspection to preserve native Int64 event IDs, so unrelated large IDs no longer block the selected event schema. (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security)