diff --git a/.gitattributes b/.gitattributes index d4d2d2bb..893c0ce4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -73,3 +73,11 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf # Existing-file read receipts bind identical native/worker source bytes. /scripts/FileAccessProbe* text eol=lf /tests/FileAccessProbe* text eol=lf + +# Disposable public registry lifecycle fixture bytes are retained in evidence. +/tests/RegistrySacl* text eol=lf +/scripts/WecAuthorization* text eol=lf +/tests/WecAuthorization* text eol=lf +# Reviewed channel restoration binds exact installed source bytes. +/scripts/ChannelRecovery.ps1 text eol=lf +/tests/ChannelRecovery*.ps1 text eol=lf diff --git a/.github/workflows/channel-recovery.yml b/.github/workflows/channel-recovery.yml new file mode 100644 index 00000000..3df83b6a --- /dev/null +++ b/.github/workflows/channel-recovery.yml @@ -0,0 +1,41 @@ +name: Reviewed native channel recovery +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + channel-recovery: + 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: Focused and native public recovery on Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/ChannelRecovery.Tests.ps1 + ./tests/ChannelRecovery.Cli.Tests.ps1 + ./tests/ChannelRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Focused and native public recovery on PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/ChannelRecovery.Tests.ps1 + ./tests/ChannelRecovery.Cli.Tests.ps1 + ./tests/ChannelRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Retain native original, restoration and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: channel-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-channel-recovery-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/native-profile-configure.yml b/.github/workflows/native-profile-configure.yml new file mode 100644 index 00000000..f1ebdfe2 --- /dev/null +++ b/.github/workflows/native-profile-configure.yml @@ -0,0 +1,47 @@ +name: Native public audit profile configuration +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-profile-configure: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixtures and public guards in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/audit-profiles.Tests.ps1 + ./tests/IntegrationProfileConfiguration.Tests.ps1 + - name: Native profile configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/ProfileConfigure.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/audit-profiles.Tests.ps1 + ./tests/IntegrationProfileConfiguration.Tests.ps1 + - name: Native profile configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/ProfileConfigure.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-profile-configure-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-profile-configure-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/registry-sacl-lifecycle.yml b/.github/workflows/registry-sacl-lifecycle.yml new file mode 100644 index 00000000..2e6796b9 --- /dev/null +++ b/.github/workflows/registry-sacl-lifecycle.yml @@ -0,0 +1,44 @@ +name: Native public registry SACL lifecycle +on: + push: + branches: ['**'] + paths: + - 'tests/RegistrySacl*' + - 'scripts/SelectedSacl*' + - 'scripts/TargetedSaclPlanning.ps1' + - 'WELA.ps1' + - '.github/workflows/registry-sacl-lifecycle.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + registry-sacl-lifecycle: + 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: Actual public registry lifecycle in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/RegistrySaclLifecycle.Tests.ps1 + ./tests/RegistrySaclLifecycle.Windows.Tests.ps1 -AllowDisposableHiveWrite + - name: Actual public registry lifecycle in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/RegistrySaclLifecycle.Tests.ps1 + ./tests/RegistrySaclLifecycle.Windows.Tests.ps1 -AllowDisposableHiveWrite + - name: Retain public receipts, actual XML and exact cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: registry-sacl-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-registry-sacl-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce92a16a..9e0460fa 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 -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 -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/wec-authorization.yml b/.github/workflows/wec-authorization.yml new file mode 100644 index 00000000..c3c9e3ba --- /dev/null +++ b/.github/workflows/wec-authorization.yml @@ -0,0 +1,49 @@ +name: Reviewed WEC source authorization +on: + push: + paths: ['WELA.ps1', 'scripts/WecAuthorization*', 'tests/WecAuthorization*', '.github/workflows/wec-authorization.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wec-authorization: + 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: Portable guards in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WecAuthorization.Tests.ps1 + ./tests/WecAuthorization.Cli.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + - name: Actual owned source authorization updates in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecAuthorization.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Portable guards in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WecAuthorization.Tests.ps1 + ./tests/WecAuthorization.Cli.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + - name: Actual owned source authorization updates in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecAuthorization.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Retain native authorization evidence and cleanup receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: wec-authorization-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-wec-authorization-* + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index defe8483..ef9b8bad 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -5,6 +5,12 @@ **改善:** - 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) + +- 既存の無効なネイティブサブスクリプション1件の明示的なソースSID一覧を変更する、オプトインの `wec-authorization` Plan/Apply を追加しました。レビュー済みハッシュ、ホスト・トークン・実装・定義全体の照合、永続化した変更前証跡、認可プロパティのみのネイティブ更新と読み戻しにより他の設定を保持し、変更不要と部分失敗を区別します。使い捨てWindowsテストで追加・削除・復元・拒否・後処理を検証しますが、SID解決、ドメイン認証、転送、Sigmaの有効性は主張しません。(@Shirofune-Security) + +- カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security) - `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security) - 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。実装・実行中エンジンの選択をハッシュ処理前に拒否し、同じハンドルのDOS/NTパスと実体、読み取りと実体再確認の実測区間、実際のワーカー・トークン・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) @@ -12,6 +18,7 @@ - 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) - 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。`-ErrorAction` や `-Verbose` などの PowerShell 共通パラメーターも拒否するため、自動化ラッパーへの影響をヘルプと診断に明記しました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) +- 完了済みの標準チャネル設定を1件ずつ復元する `channel-recovery` を追加しました。元の記録と結果、確認済み計画ハッシュ、現在の記述子と設定を検証し、縮小・無効化・追加した読み取り権限の撤回には個別の同意を要求します。項目ごとの永続記録を残し、元の操作で変更した項目だけを復元します。公開 Configure/Restore の標準 Windows テストで拒否・途中失敗・元設定への後始末を確認し、イベント消失・保存期間・転送・Sigma 対応は別に扱います。(関連 #367、#365) (@Shirofune-Security) - Windows PowerShell 5.1 と PowerShell 7、使い捨ての Server 2022/2025 で標準チャネル設定の公開CLIを検証するテストを追加しました。有効化・サイズ・CAPI2読み取り専用権限の適用、既存記述子と大きいバッファーの保持、変更前記録、DryRun、再実行時の無変更、元設定への復元を確認し、ハッシュ付きの証拠を保存します。転送・保存期間・Sigmaの検証は別途必要です。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81cd871..80d66a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ **Improvements:** - 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) + +- Added opt-in `wec-authorization` Plan/Apply for the explicit source SID list of one existing disabled native subscription. Reviewed hashes, host/token/source and full-definition guards, durable pending evidence, one native authorization setter and readback preserve other settings; no-op and partial-save outcomes stay explicit. Native disposable tests cover add/remove/restore, refusals and cleanup without SID-resolution, domain authentication, forwarding or Sigma claims. (@Shirofune-Security) + +- Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security) - Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security) - Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Source/engine targets are refused before hashing. Same-handle DOS/NT identity, exact worker/token/handle attribution over the measured read and identity-readback phase, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) @@ -12,6 +18,7 @@ - Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) - Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. PowerShell common parameters such as `-ErrorAction` and `-Verbose` are also rejected; help and diagnostics explain the automation-wrapper compatibility change. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) +- Added opt-in `channel-recovery` for one completed native channel-settings operation. Strict journal/result reconstruction, reviewed plan hashes, exact current descriptor/settings, separate shrink/disable/read-revocation consent and per-field durable receipts restore only originally changed fields. Native public Configure/Restore tests cover refusal, partial outcomes and exact fixture cleanup; event loss, retention, forwarding and Sigma readiness remain separate. (Related #367, #365) (@Shirofune-Security) - Added disposable Server 2022/2025 validation of public native channel configuration under Windows PowerShell 5.1 and PowerShell 7. Tests apply enable/size controls and the explicit CAPI2 read-only grant, verify descriptor preservation, journals, larger buffers, DryRun and idempotence, and retain hashed native evidence with exact fixture cleanup. Forwarding, retention-duration and Sigma validation remain separate. (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 06d2e9c1..7cbe077d 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -130,6 +130,16 @@ [string]$TranscriptRecoveryPlanHash, [string]$TranscriptRecoveryOutputPath, [switch]$TranscriptRecoveryAllowTemporarySuspension, + [ValidateSet('Plan','Restore')][string]$ChannelRecoveryAction = 'Plan', + [string]$ChannelRecoveryJournalPath, + [string]$ChannelRecoveryOriginalResultsPath, + [string]$ChannelRecoveryChannel, + [string]$ChannelRecoveryPlanPath, + [string]$ChannelRecoveryPlanHash, + [string]$ChannelRecoveryOutputPath, + [switch]$ChannelRecoveryAllowShrink, + [switch]$ChannelRecoveryAllowDisable, + [switch]$ChannelRecoveryAllowRevoke, [ValidateSet('Plan','Restore')][string]$EventRecoveryAction = 'Plan', [string]$EventRecoveryJournalPath, [string]$EventRecoveryOriginalResultsPath, @@ -168,6 +178,12 @@ [string]$WecUpdatePlanPath, [string]$WecUpdatePlanHash, [string]$WecUpdateOutputPath, + [ValidateSet('Plan','Apply')][string]$WecAuthorizationAction = 'Plan', + [string]$WecAuthorizationId, + [string[]]$WecAuthorizationSourceSid, + [string]$WecAuthorizationPlanPath, + [string]$WecAuthorizationPlanHash, + [string]$WecAuthorizationOutputPath, [ValidateSet('Plan','Apply')][string]$WecStateAction = 'Plan', [string]$WecStateId, [string[]]$WecStateSourceSid, @@ -238,6 +254,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorActio Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/EventLogRecovery.ps1") +. (Join-Path $ScriptRoot "scripts/ChannelRecovery.ps1") Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") @@ -250,6 +267,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/WecIngress.ps1") . (Join-Path $ScriptRoot "scripts/WecListener.ps1") . (Join-Path $ScriptRoot "scripts/WecState.ps1") +. (Join-Path $ScriptRoot "scripts/WecAuthorization.ps1") . (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") @@ -2042,9 +2060,11 @@ Usage: ./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness ./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes ./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart + ./WELA.ps1 channel-recovery -Help # Review one completed channel-settings operation ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation + ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription ./WELA.ps1 capi2-probe -Help # Fixed offline chain and matched CAPI2 event 11 evidence @@ -2133,12 +2153,16 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' } } +if ($Cmd -ne 'channel-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'ChannelRecovery*'}).Count) {throw 'ChannelRecovery options require channel-recovery.'} +if ($Cmd -eq 'channel-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','ChannelRecoveryAction','ChannelRecoveryJournalPath','ChannelRecoveryOriginalResultsPath','ChannelRecoveryChannel','ChannelRecoveryPlanPath','ChannelRecoveryPlanHash','ChannelRecoveryOutputPath','ChannelRecoveryAllowShrink','ChannelRecoveryAllowDisable','ChannelRecoveryAllowRevoke','Help')}).Count)) {throw 'channel-recovery accepts only dedicated options.'} if ($Cmd -ne 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'} if ($Cmd -eq 'eventlog-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EventRecoveryAction','EventRecoveryJournalPath','EventRecoveryOriginalResultsPath','EventRecoveryLog','EventRecoveryPlanPath','EventRecoveryPlanHash','EventRecoveryOutputPath','EventRecoveryAllowShrink','EventRecoveryAllowRetentionChange','Help')}).Count)) {throw 'eventlog-recovery accepts only dedicated options.'} if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecListener*'}).Count) {throw 'WecListener options require wec-listener.'} if ($Cmd -eq 'wec-listener' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecListenerAction','WecListenerComputerName','WecListenerLocalAddress','WecListenerPlanPath','WecListenerPlanHash','WecListenerOutputPath','Help')}).Count)) {throw 'wec-listener accepts only dedicated options.'} if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'} +if ($Cmd -ne 'wec-authorization' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecAuthorization*'}).Count) {throw 'WecAuthorization options require wec-authorization.'} +if ($Cmd -eq 'wec-authorization' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecAuthorizationAction','WecAuthorizationId','WecAuthorizationSourceSid','WecAuthorizationPlanPath','WecAuthorizationPlanHash','WecAuthorizationOutputPath','Help')}).Count)) {throw 'wec-authorization accepts only dedicated options.'} if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} if ($Cmd -eq 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecStateAction','WecStateId','WecStateSourceSid','WecStateDesired','WecStatePlanPath','WecStatePlanHash','WecStateOutputPath','Help')}).Count) {throw 'wec-state accepts only dedicated options.'} if ($Cmd -ne 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecUpdate*'}).Count) {throw 'WecUpdate options require wec-update.'} @@ -2368,6 +2392,13 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'channel-recovery' { + if ($Help) {Write-Host 'Usage: channel-recovery [-ChannelRecoveryAction Plan] -ChannelRecoveryJournalPath before.jsonl -ChannelRecoveryOriginalResultsPath results.json -ChannelRecoveryChannel exact-channel -ChannelRecoveryOutputPath new-directory; then Restore with -ChannelRecoveryPlanPath plan.json -ChannelRecoveryPlanHash SHA256 -ChannelRecoveryOutputPath new-directory and applicable -ChannelRecoveryAllowShrink / -ChannelRecoveryAllowDisable / -ChannelRecoveryAllowRevoke. Shrink may discard records; disable stops generation; revoke may interrupt readers. See docs/channel-recovery.md.';return} + $arguments=@{Action=$ChannelRecoveryAction;OutputPath=$ChannelRecoveryOutputPath;AllowShrink=$ChannelRecoveryAllowShrink;AllowDisable=$ChannelRecoveryAllowDisable;AllowRevoke=$ChannelRecoveryAllowRevoke} + $map=@{ChannelRecoveryJournalPath='JournalPath';ChannelRecoveryOriginalResultsPath='OriginalResultsPath';ChannelRecoveryChannel='Channel';ChannelRecoveryPlanPath='PlanPath';ChannelRecoveryPlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaChannelRecovery @arguments;$report;exit $report.ExitCode + } 'eventlog-recovery' { if ($Help) {Write-Host 'Usage: eventlog-recovery [-EventRecoveryAction Plan] -EventRecoveryJournalPath before.jsonl -EventRecoveryOriginalResultsPath results.json -EventRecoveryLog channel -EventRecoveryOutputPath new-directory; then Restore with -EventRecoveryPlanPath plan.json -EventRecoveryPlanHash SHA256 -EventRecoveryOutputPath new-directory and applicable -EventRecoveryAllowShrink / -EventRecoveryAllowRetentionChange. See docs/eventlog-recovery.md.';return} $arguments=@{Action=$EventRecoveryAction;OutputPath=$EventRecoveryOutputPath;AllowShrink=$EventRecoveryAllowShrink;AllowRetentionChange=$EventRecoveryAllowRetentionChange} @@ -2397,6 +2428,13 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaWecIngress @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'wec-authorization' { + if ($Help) {Write-Host 'Usage: wec-authorization [-WecAuthorizationAction Plan] -WecAuthorizationId ID -WecAuthorizationSourceSid desired-SID1,desired-SID2 -WecAuthorizationOutputPath new-directory; then Apply with -WecAuthorizationPlanPath plan.json -WecAuthorizationPlanHash SHA256 -WecAuthorizationOutputPath new-directory. Only the explicit source SID authorization of one already disabled subscription. No SID resolution or forwarding proof. See docs/wec-authorization.md.';return} + $arguments=@{Action=$WecAuthorizationAction;OutputPath=$WecAuthorizationOutputPath} + foreach($pair in @(@('WecAuthorizationId','Id'),@('WecAuthorizationSourceSid','SourceSids'),@('WecAuthorizationPlanPath','PlanPath'),@('WecAuthorizationPlanHash','PlanHash'))){if($PSBoundParameters.ContainsKey($pair[0])){$arguments[$pair[1]]=$PSBoundParameters[$pair[0]]}} + $report=Invoke-WelaWecAuthorization @arguments;$report + if($report.ExitCode -ne 0){exit $report.ExitCode} + } 'wec-state' { if ($Help) {Write-Host 'Usage: wec-state [-WecStateAction Plan] -WecStateId ID -WecStateSourceSid SID -WecStateDesired Enabled|Disabled -WecStateOutputPath new-directory; then Apply with -WecStatePlanPath reviewed-plan.json -WecStatePlanHash SHA256 -WecStateOutputPath new-directory. Only Enabled on an existing subscription. Disable interrupts collection; enable/save activates it. See docs/wec-state.md.';return} $arguments=@{Action=$WecStateAction;OutputPath=$WecStateOutputPath} diff --git a/docs/audit-profiles.md b/docs/audit-profiles.md index afa8d512..3da93f00 100644 --- a/docs/audit-profiles.md +++ b/docs/audit-profiles.md @@ -90,3 +90,5 @@ RSoP schema references: [registry policy](https://learn.microsoft.com/en-us/prev Targeted file/registry SACL prerequisites are included as a read-only companion plan. See [targeted SACL planning](targeted-sacl-planning.md) for per-user gaps, source distinctions and `-SaclMode Skip`. The stronger profile's optional IPsec Main Mode control additionally requires positive local native prerequisite evidence during shared planning/configuration. See [conditional IPsec prerequisites](ipsec-prerequisites.md) for scope, statuses and fresh pre-write checks. + +A separate [public custom-profile native acceptance fixture](custom-audit-profiles.md#verification-and-recovery) exercises the shared configuration/precedence engine with actual writes on disposable Server 2022/2025 hosts. It verifies all 59 effective masks and exact cleanup without claiming full baseline, GPO, event or Sigma acceptance. diff --git a/docs/channel-recovery.md b/docs/channel-recovery.md new file mode 100644 index 00000000..d7c0e9e4 --- /dev/null +++ b/docs/channel-recovery.md @@ -0,0 +1,40 @@ +# Reviewed native channel recovery + +`channel-recovery` restores **one completed `channel-settings` operation** on one exact channel from the bundled Microsoft WEF Appendix C profile. It can restore the original enabled state and size, and remove only the exact Event Log Readers read ACE that operation added. It does not restore other configuration commands, partially completed original writes, event records, subscriptions or arbitrary channels. Sysmon is excluded. + +```powershell +./WELA.ps1 channel-recovery -ChannelRecoveryJournalPath C:\WELA\original\before.jsonl ` + -ChannelRecoveryOriginalResultsPath C:\WELA\original-results.json ` + -ChannelRecoveryChannel 'Microsoft-Windows-CAPI2/Operational' ` + -ChannelRecoveryOutputPath C:\WELA\recovery-plan + +# Inspect plan.json and manifest.json; independently retain manifest PlanHash. +./WELA.ps1 channel-recovery -ChannelRecoveryAction Restore ` + -ChannelRecoveryPlanPath C:\WELA\recovery-plan\plan.json ` + -ChannelRecoveryPlanHash REVIEWED_SHA256 ` + -ChannelRecoveryOutputPath C:\WELA\recovery-run ` + -ChannelRecoveryAllowShrink -ChannelRecoveryAllowDisable -ChannelRecoveryAllowRevoke +``` + +Supply only the consent switches the reviewed plan requires. **Shrinking can discard records; disabling stops channel generation; removing a read grant can interrupt collection.** These are separate decisions. Plan is read-only apart from new protected evidence files. Restore accepts a reviewed plan/hash and a new output directory; `-Auto`, `-DryRun`, `-WhatIf` and unrelated command options are rejected. There is no automatic rollback or continuation after a partial failure. + +The original journal must contain exactly one matching entry, with the same typed `Before`, `Desired` and target as one `Applied` result. The command independently rebuilds the enable/minimum-size/read-grant transformation from the current bundled profile. Unknown schemas, Boolean values in text/size fields, duplicate JSON properties, mismatched journals, unexplained post-write changes, missing read-grant authorization and unchanged operations are refused. The selected operation may be recovered even when another channel failed during the original invocation; it must itself be completed and fully consistent. + +Current settings must exactly match the original confirmed after-state. For descriptors, equality means the complete binary descriptor, including owner, group, SACL, DACL, resource-manager control and all ACE bytes/order. The canonical read-grant planner must reproduce the exact original addition, and SDDL conversion must round-trip without loss. An unrelated new ACE or another changed setting requires manual review; recovery never removes it. A previous read grant that was already present is preserved. + +Only originally changed fields are written, in size, descriptor, then enablement order. Each write has a flushed pending receipt, a fresh complete settings/metadata check and actual primary-token check, native `wevtutil` exit validation, independent readback, and a confirmed receipt. Other channel properties, including retention, path, provider parameters and isolation, must remain unchanged. A final read checks the full target. Recovery changes no other channel, audit policy, group membership, service or forwarding configuration. + +| Result | Meaning | +| --- | --- | +| `ReviewRequired` | A new plan and hash were retained; no native write occurred. | +| `Refused` | Evidence, consent or current context did not authorize a write. | +| `RestoredAndVerified` | Every selected original field was restored and observed with preservation checks. | +| `RestoreAttemptedUnverified` | At least one native write was attempted; inspect pending, observed, confirmed and failure-state receipts before manual action. | + +`ConfirmedFields` identifies steps whose immediate readback succeeded; a later failure does not establish that those settings stayed unchanged. Loss of power, process termination or output-storage failure can leave pending evidence without a final manifest. A read-grant removal could succeed even if the caller subsequently cannot read metadata; that remains unverified, without rollback. There is no atomic Windows compare-and-set, so another administrator can race the final check. + +Inputs and outputs must use ordinary local paths supported by WELA's protected recovery artifact helpers. Current host identity, actual operator/logon, source files and native executable/reader assembly hashes are bound to the plan. Plan and Restore must use the same installed implementation and PowerShell version. **Old version-1 journals contain only historical ComputerName; current guards do not authenticate historical ownership.** Treat original evidence as trusted operator records. Updating WELA invalidates older review plans; create and review a new plan rather than editing its fingerprints. + +Windows validation uses explicit disposable GitHub-hosted Server 2022/2025 fixtures under Windows PowerShell 5.1 and PowerShell 7. The fixture calls public Configure then Plan/Restore for actual CAPI2 enable/size changes, with and without the optional read ACE. It tests each missing consent, actual later size drift, replay, unsupported preview options, other-channel preservation, retained hashes, and exact original fixture configuration/all audit masks at cleanup. Shrinking during fixture cleanup can discard intervening records. Windows 11, domain/DC/CA forwarding identities, event generation, persistence through policy refresh, retention duration and backend Sigma evaluation remain separate acceptance work. No rule-readiness credit is granted. + +See [native channel configuration](native-channel-access.md) for original journal creation and [actual channel reads](channel-read.md) for separate current-token query evidence. [Microsoft's `wevtutil` contract](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil) documents channel enablement, maximum size and channel access; buffer configuration is not a retention guarantee. diff --git a/docs/custom-audit-profiles.md b/docs/custom-audit-profiles.md index de29a981..ef5cfb4d 100644 --- a/docs/custom-audit-profiles.md +++ b/docs/custom-audit-profiles.md @@ -110,7 +110,14 @@ undo partially applied changes, restore a GPO, or invoke policy refresh. Re-run assessment after GPO/MDM refresh to verify effective state. Tests exercise malformed files, preservation modes, validation ordering, mocked -writes, prompt-time file changes and final drift. Windows CI performs real read-only -custom-profile audits on Server 2022/2025 with PowerShell 5.1/7. Configuration and -benign event/backend acceptance on Windows 11, DC and AD CS labs remain separate; +writes, prompt-time file changes and final drift. Windows CI also exercises the actual public Plan, DryRun, Configure and Audit +commands on disposable Server 2022/2025 hosts with PowerShell 5.1/7. A fixture-owned +custom file selects four canonical controls: minimum and exact masks, an explicit +optional control, and Not Configured preservation. Tests compare all 59 masks, +typed precedence, source fingerprints, native channels and original journals, +then verify exact fixture restoration. Invalid role selection is refused before +configuration, and repeated configuration makes no further native change. +These are hosted standalone servers classified by the shared profile engine as +MemberServer; no domain join or GPO refresh is simulated. Configuration and +benign event/backend acceptance on Windows 11, domain-joined servers, DC and AD CS labs remain separate; no clean-install or detection-coverage claim is made. diff --git a/docs/native-channel-access.md b/docs/native-channel-access.md index 877b29ff..f1e75c16 100644 --- a/docs/native-channel-access.md +++ b/docs/native-channel-access.md @@ -30,7 +30,7 @@ The planner uses [RawSecurityDescriptor](https://learn.microsoft.com/en-us/dotne The shared configuration runner writes `before.jsonl` before each native mutation, capturing the original enabled state, exact size, full descriptor and retention mode. A fresh read must match both the plan and the journal snapshot before `wevtutil sl` executes. Only changed `/e:true`, `/ms:...` and explicitly authorized `/ca:...` arguments are sent. Native failure, failed readback, descriptor mismatch and final drift produce a nonzero result. There is no atomic Windows compare-and-set; another writer can still race the final check. Re-run after policy refresh to check persistence. No automatic rollback occurs. -Recovery is manual: review each journal `Before` against the current settings, identify the affected channel, and restore only the intended previous values using `wevtutil sl "CHANNEL" /e:true|false /ms:ORIGINAL_BYTES /ca:"ORIGINAL_SDDL"`. Pass the descriptor as one argument in PowerShell, for example `& wevtutil.exe sl $entry.Target.Channel ("/ca:" + $entry.Before.SecurityDescriptor)` after loading and reviewing the relevant JSONL entry. Restoring a smaller limit can discard events. Existing retention is not intentionally modified; investigate any changed mode before choosing recovery actions. Use a new backup directory for each run. +For a completed `channel-settings` operation, use [reviewed single-channel recovery](channel-recovery.md) to reconstruct the original changed fields, require exact current after-state and request separate shrink/disable/read-revocation consent. Partially completed originals and later drift still require manual review. For manual recovery, review each journal `Before` against the current settings, identify the affected channel, and restore only the intended previous values using `wevtutil sl "CHANNEL" /e:true|false /ms:ORIGINAL_BYTES /ca:"ORIGINAL_SDDL"`. Pass the descriptor as one argument in PowerShell, for example `& wevtutil.exe sl $entry.Target.Channel ("/ca:" + $entry.Before.SecurityDescriptor)` after loading and reviewing the relevant JSONL entry. Restoring a smaller limit can discard events. Existing retention is not intentionally modified; investigate any changed mode before choosing recovery actions. Use a new backup directory for each run. ## Native WEF prerequisites and validation diff --git a/docs/native-registry-sacl-validation.md b/docs/native-registry-sacl-validation.md new file mode 100644 index 00000000..8a5bb7cc --- /dev/null +++ b/docs/native-registry-sacl-validation.md @@ -0,0 +1,34 @@ +# Public registry SACL lifecycle acceptance + +The `Native public registry SACL lifecycle` workflow tests the existing public `targeted-sacl` command on disposable GitHub-hosted Windows Server 2022 and 2025 runners, each with native Windows PowerShell 5.1 and PowerShell 7. It is an acceptance fixture, not a new configuration command. Native job results and retained artifacts must be reviewed before claiming a particular matrix passed. + +## Owned target and public lifecycle + +The fixture creates a nonce-marked seed key below its own HKCU, saves it to a new private file, and loads that file under a new synthetic SID below HKU. It never loads an existing user's offline hive or modifies a catalog system key. Existing backup/restore privileges are enabled only around the native save/load/unload calls and their prior attributes are restored. Impersonation and name collisions are refused. The unchanged public catalog resolves the loaded SID's RunOnce definition from `asd-native-2021-10`; the missing ProfileList metadata remains explicit in user-inventory diagnostics. + +Only the fixture prepares typed audit precedence and the Registry success/failure subcategory. It creates a sentinel DWORD and a distinct SYSTEM QueryValue success audit ACE before exercising the selected target through actual `WELA.ps1` processes: + +- Plan with missing inheritance consent is blocked and Configure refuses before creating a journal. +- A reviewed Plan captures the exact selected SID/path, native descriptor and complete empty descendant inventory. +- DryRun leaves the descriptor unchanged and creates no write journal. +- Configure appends exactly the reviewed audit ACE. Independent native readback verifies original owner/group/DACL/control flags, original binary audit ACEs and the unrelated typed value. Pending, Confirmed and descendant-observation receipts agree with the independent observations. +- Replaying the stale plan fails before another journal. A fresh plan and Configure report `AlreadyCompliant`, preserve exact state and write no mutation receipts. +- Removing the prerequisite Registry audit bits makes planning blocked and Configure fail before journaling; the public command does not enable auditing. + +The chosen RunOnce target has no child keys. Populated and protected subtree behavior remains covered separately by the existing descendant fixture. This fixture does not establish production-tree, redirected-user, DC/CA or future-child behavior. + +## One actual registry event + +After public Configure succeeds, the fixture records a native Security event watermark, then performs exactly one `RegSetValueExW` call to create a fresh nonce REG_SZ. It reads the value's type and exact bytes back on that same native handle. Precise UTC receipts separately record write start, return and completion of this measured write/readback phase; no timestamp padding is added. + +A bounded native Security query must return exactly one matching 4657 from the observed phase, with the exact provider/version/task/success keyword, computer, newer record ID, subject SID/logon ID, process ID/executable, raw registry handle, native object path, value name, creation operation, REG_SZ type and nonce value. Event candidates, exact XML, operation receipt and artifact hashes are retained. Portable negative fixtures reject wrong attribution, old/out-of-window records, duplicate fields and DTD-bearing XML. A missing or ambiguous event fails acceptance; it does not relax attribution. + +This is evidence for one local registry **value** creation under the fixture's prepared policy. It does not prove all registry operations, production persistence, downstream forwarding, collector access or Sigma execution. Public reports retain `GenerationReadiness=Conditional` and `UsableRuleCredit=0`. + +## Cleanup and evidence + +Cleanup runs even after an assertion fails. It restores the original selected Registry audit mask and original precedence type/value or absence, then compares every one of the 59 audit masks. It checks the entire primary-token groups/privilege snapshot, unloads only the marker-verified owned hive, removes only its exact unchanged seed, and compares the complete original HKU mount inventory. The private backing files are deleted only after unload and inventory verification. Failure to restore or unload fails the job and remains explicit in `cleanup.json`. + +The artifact retains public reports/journals, independent before/after descriptors, exact event XML, native operation and cleanup evidence, and SHA-256 hashes. Successful cleanup retains no backing hive file. This test-only helper is not imported by WELA and is not packaged as a product hive-management feature. + +Primary references: Microsoft [RegSaveKeyExW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regsavekeyexw), [RegLoadKeyW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regloadkeyw), [RegUnLoadKeyW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regunloadkeyw), and [Security event 4657](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4657). diff --git a/docs/selected-sacl-configuration.md b/docs/selected-sacl-configuration.md index 6d68cf6d..9f6e2f6e 100644 --- a/docs/selected-sacl-configuration.md +++ b/docs/selected-sacl-configuration.md @@ -84,6 +84,8 @@ The Windows disposable fixture now uses populated file and registry trees, verif Mocked tests cover selection, source-specific masks, unsupported consent, source/plan/target races, denied reads, partial writes, non-SACL drift, pending/confirmed receipts, idempotence and public command guards. The Windows workflow explicitly permits mutations only on GitHub-hosted disposable Server 2022/2025 runners: it creates owned temporary file/registry targets, temporarily enables their two audit subcategories and precedence, adds audit ACEs through the real adapter, and searches for benign 4663/4657 events matching the exact targets. It restores all original audit masks and typed precedence and removes only owned targets. This fixture does not modify any catalog system target. +The separate [public registry lifecycle fixture](native-registry-sacl-validation.md) mounts a newly saved, fixture-owned hive under a fresh synthetic user SID. The unchanged public catalog resolves its RunOnce key, then actual CLI Plan/DryRun/Configure calls exercise the reviewed lifecycle and one exact local 4657. Only the fixture loads/unloads hives and prepares auditing; the product behavior above is unchanged. This leaf fixture does not replace populated-tree inheritance validation. + Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, user redirection, large/changing production trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`. Primary API references: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo and inheritance](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [registry open/link behavior](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw), [file handle and sharing flags](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew). diff --git a/docs/wec-authorization.md b/docs/wec-authorization.md new file mode 100644 index 00000000..3675ac5c --- /dev/null +++ b/docs/wec-authorization.md @@ -0,0 +1,29 @@ +# Reviewed WEC source authorization + +`wec-authorization` plans and applies the explicit source SID allow list of one **already disabled**, existing source-initiated HTTP/native-event subscription. This supplies the authorization update missing from the create-only collector command, query/description updater and separate Enabled transition. Related to #368; built-in Windows only, with no Sysmon. + +```powershell +# Invoke the PowerShell script directly when supplying an array of SIDs. +.\WELA.ps1 wec-authorization -WecAuthorizationId 'Reviewed subscription' ` + -WecAuthorizationSourceSid 'S-1-5-21-111-222-333-1234','S-1-5-21-111-222-333-1235' ` + -WecAuthorizationOutputPath C:\Evidence\authorization-plan + +# Review plan.json, including its complete original XML and desired source list. +# Retain its PlanHash from manifest.json before applying those exact bytes. +.\WELA.ps1 wec-authorization -WecAuthorizationAction Apply ` + -WecAuthorizationPlanPath C:\Evidence\authorization-plan\plan.json ` + -WecAuthorizationPlanHash '' ` + -WecAuthorizationOutputPath C:\Evidence\authorization-apply +``` + +Plan reads native configuration and writes review artifacts. Apply takes the subscription and desired list only from the reviewed plan. Both require a new private evidence directory on a local fixed drive. The actual elevated, non-impersonated reader, supported patched Server 2022/2025 standalone/member host, running Wecsvc/WMI/EventLog services, destination channel settings and implementation sources are observed and bound. No service is started, subscription enabled, channel changed or AD membership modified. Unknown options, mixed Plan/Apply inputs, `-Auto`, `-DryRun` and `-WhatIf` are refused; use Plan for review. + +The desired list contains 1–32 unique canonical `S-1-5-21-A-B-C-RID` strings with native-range subauthorities. Order is normalized; duplicate SIDs, aliases, arbitrary SDDL, null/empty/default authorization and non-domain/certificate settings are refused. The existing descriptor must already be the same supported explicit allow-list form. WELA neither resolves these strings nor verifies that they identify domain computer accounts or groups. Obtain and independently verify intended identities and group membership before review. Adding a SID can broaden future authorization; removing one entry does not establish that a machine lacks access through another allowed group. + +Apply checks the complete original definition and current context, flushes a pending receipt, opens only an existing native subscription and uses one `EcSubscriptionAllowedSourceDomainComputers` setter followed by save. The native adapter rechecks disabled/source-initiated state and selected native fields through a fresh handle. Native readback must match the desired list while all other observed XML fields, actual token, services and destination settings stay unchanged. Matching authorization returns `AlreadyMatches` without a save. Successful changes report `AuthorizationChangedAndVerified`; failures before save report `Refused`. Once save has been attempted, incomplete readback or preservation reports `SaveAttemptedUnverified` and retains available native after-XML. + +An enabled subscription is refused, including a no-op request. Use the separately reviewed [Enabled transition](wec-state.md) when an intentional interruption or activation is required. To restore an authorization list, make a fresh plan against the current disabled definition using the original retained SIDs. There is no automatic rollback or native compare-and-swap: another administrator can race the pre-save observations. Coordinate changes and inspect retained evidence after partial results. Plan hashes check consistency and do not authenticate an untrusted evidence author. + +The native CI fixture creates one uniquely named disabled subscription with inert SIDs, exercises public no-op/add/remove/restore, wrong-hash/stale/enabled refusals and a native fresh-handle drift check, then verifies original subscription inventory, service startup/state and complete channel restoration. Temporary service/channel changes belong only to explicitly opted-in disposable fixtures. These tests do not resolve or authenticate a source, change AD groups, verify forwarding or bookmarks, or award Sigma readiness credit. + +Microsoft documents the [authorization property](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/ne-evcoll-ec_subscription_property_id), [source-initiated subscription settings](https://learn.microsoft.com/en-us/windows/win32/wec/creating-a-source-initiated-subscription), [existing-only open flags](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscription) and [activation on saving enabled subscriptions](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecsavesubscription). diff --git a/docs/wef-deployment.md b/docs/wef-deployment.md index a05d122e..3614569b 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -58,6 +58,8 @@ Readback equality covers ID, enabled state, selected delivery preset, ReadExisti JSON retains exact filters, disabled flags, local channel enablement/mode/ACL, local configuration results, native `wecutil gr` output and its errors, and unverified prerequisites. A separate [`TypedRuntime`](wec-runtime.md) object adds native activity/error/time fields and bounded per-source observations; its Unknown/Partial status stays independent of local configuration success. 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. +Use the separate [reviewed authorization update](wec-authorization.md) to change an explicit source SID list on an already disabled existing subscription. It preserves the other observed fields and supplies no SID-resolution or forwarding proof. + ## 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. diff --git a/scripts/ChannelRecovery.ps1 b/scripts/ChannelRecovery.ps1 new file mode 100644 index 00000000..0ca44903 --- /dev/null +++ b/scripts/ChannelRecovery.ps1 @@ -0,0 +1,187 @@ +# Restore one completed canonical channel-settings operation; never replay arbitrary arguments. +function Get-WelaChannelRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Assert-WelaChannelRecoveryText {param($Value,[string[]]$Names) foreach($name in $Names){if($Value.$name -isnot [string]){throw "Missing or mistyped channel recovery text: $name"}}} +function Get-WelaChannelRecoveryDescriptorKey { + param([string]$Sddl) + if(-not $Sddl -or $Sddl.Length -gt 131072){throw 'A bounded full channel descriptor is required.'} + $descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($Sddl) + $bytes=New-Object byte[] $descriptor.BinaryLength;$descriptor.GetBinaryForm($bytes,0) + $round=[Security.AccessControl.RawSecurityDescriptor]::new($descriptor.GetSddlForm('All')) + $other=New-Object byte[] $round.BinaryLength;$round.GetBinaryForm($other,0) + if([Convert]::ToBase64String($bytes) -cne [Convert]::ToBase64String($other)){throw 'Channel descriptor cannot round-trip losslessly.'} + [Convert]::ToBase64String($bytes) +} +function Get-WelaChannelRecoveryTuple { + param($Value) + [pscustomobject][ordered]@{IsEnabled=$Value.IsEnabled;MaximumSizeInBytes=$Value.MaximumSizeInBytes;LogMode=$Value.LogMode;SecurityDescriptor=$Value.SecurityDescriptor} +} +function Get-WelaChannelRecoveryTupleKey { + param($Value) + Get-WelaChannelRecoveryKey ([ordered]@{IsEnabled=$Value.IsEnabled;MaximumSizeInBytes=$Value.MaximumSizeInBytes;LogMode=$Value.LogMode;Descriptor=(Get-WelaChannelRecoveryDescriptorKey $Value.SecurityDescriptor)}) +} +function Assert-WelaChannelRecoverySnapshot { + param($Value,[string]$Channel) + Assert-WelaArrivalObject $Value @('Name','State','IsEnabled','LogMode','SecurityDescriptor','MaximumSizeInBytes','ProviderNames','MetadataErrors','Error') + Assert-WelaChannelRecoveryText $Value @('Name','State','LogMode','SecurityDescriptor') + if($Value.Name -cne $Channel -or $Value.IsEnabled -isnot [bool] -or $Value.State -cne $(if($Value.IsEnabled){'Enabled'}else{'Disabled'}) -or + ($Value.MaximumSizeInBytes -isnot [int] -and $Value.MaximumSizeInBytes -isnot [long]) -or $Value.MaximumSizeInBytes -lt 1048576 -or $Value.MaximumSizeInBytes -gt 2199023255552 -or + $Value.LogMode -cnotin @('Circular','Retain','AutoBackup') -or $null -ne $Value.Error -or $null -eq $Value.MetadataErrors -or @($Value.MetadataErrors.PSObject.Properties).Count -ne 0 -or ($Value.ProviderNames -isnot [array] -and $Value.ProviderNames -isnot [string]) -or @($Value.ProviderNames).Count -gt 64){throw 'Original channel snapshot is incomplete, mistyped or unsupported.'} + foreach($name in $Value.ProviderNames){if($name -isnot [string] -or -not $name -or $name.Length -gt 512){throw 'Invalid original provider name.'}} + $null=Get-WelaChannelRecoveryDescriptorKey $Value.SecurityDescriptor +} +function Get-WelaChannelRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/ChannelRecovery.ps1','scripts/NativeChannelConfiguration.ps1','modules/NativeChannelAccess.psm1','modules/NativeProviders.psm1','modules/EventLogSettings.psm1','config/native_channel_profile.json','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){ + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){foreach($path in @((Join-Path ([Environment]::SystemDirectory) 'wevtutil.exe'),[Diagnostics.Eventing.Reader.EventLogConfiguration].Assembly.Location)){$sources[$path]=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}} + Get-WelaChannelRecoveryKey $sources +} +function Get-WelaChannelRecoveryContext { + foreach($name in @('EventLog','Winmgmt')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'EventLog and Winmgmt must already be running; recovery starts no services.'}} + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'The actual non-impersonated elevated administrator is required.'} + [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids};Engine=$PSVersionTable.PSVersion.ToString()} +} +function Read-WelaChannelRecoveryState { + param([string]$Channel) + $native=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Channel) + try { + if($native.LogName -cne $Channel -or [string]$native.LogType -cnotin @('Administrative','Operational')){throw 'An exact built-in administrative or operational channel is required.'} + $guard=[ordered]@{} + foreach($name in @('LogType','LogIsolation','LogFilePath','OwningProviderName','IsClassicLog','ProviderLevel','ProviderKeywords','ProviderBufferSize','ProviderMinimumNumberOfBuffers','ProviderMaximumNumberOfBuffers','ProviderLatency','ProviderControlGuid')){ + $value=$native.$name;$guard[$name]=if($null -eq $value){$null}else{[string]$value} + } + $tuple=[pscustomobject][ordered]@{IsEnabled=[bool]$native.IsEnabled;MaximumSizeInBytes=[long]$native.MaximumSizeInBytes;LogMode=[string]$native.LogMode;SecurityDescriptor=[string]$native.SecurityDescriptor} + $null=Get-WelaChannelRecoveryDescriptorKey $tuple.SecurityDescriptor + [pscustomobject][ordered]@{Channel=$native.LogName;Settings=$tuple;Guard=[pscustomobject]$guard} + }finally{$native.Dispose()} +} +function Get-WelaChannelRecoveryDefinition { + param([string]$JournalPath,[string]$OriginalResultsPath,[string]$Channel) + $profile=Get-WelaNativeChannelProfile;$controls=@($profile.controls|Where-Object channel -ceq $Channel) + if($controls.Count -ne 1){throw 'Select one exact channel from the bundled Microsoft WEF Appendix C profile.'};$control=$controls[0] + $context=Get-WelaChannelRecoveryContext + $journal=Read-WelaWecUpdateFile $JournalPath;$file=Read-WelaWecUpdateFile $OriginalResultsPath + $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_}) + if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'} + $result=ConvertFrom-WelaArrivalJson $file.Text + Assert-WelaChannelRecoveryText $result @('Scope','Action','ChannelProfile') + if($result.Scope -cne 'native-channel-settings-only' -or $result.Action -cne 'Configure' -or $result.ChannelProfile -cne $profile.id -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.GrantEventLogReadersRequested -isnot [bool] -or $result.Results -isnot [array] -or $result.Results.Count -gt 64){throw 'Expected original non-dry-run public channel-settings Configure results.'} + foreach($name in @('ExitCode','Failed','Skipped')){if(($result.$name -isnot [int] -and $result.$name -isnot [long]) -or $result.$name -lt 0){throw 'Original result counters must be nonnegative integers.'}} + $id='NativeChannel/'+$Channel+'/Settings';$seen=@{} + foreach($row in $result.Results){Assert-WelaChannelRecoveryText $row @('Id');if($seen.ContainsKey($row.Id)){throw 'Duplicate original result ID.'};$seen[$row.Id]=$true} + $rows=@($result.Results|Where-Object Id -ceq $id);$matching=@($entries|Where-Object Id -ceq $id) + if($rows.Count -ne 1 -or $matching.Count -ne 1){throw 'Exactly one completed result and its original journal entry are required.'} + $row=$rows[0];$entry=$matching[0] + Assert-WelaChannelRecoveryText $row @('Id','Kind','Status','Diagnostic');Assert-WelaChannelRecoveryText $entry @('ComputerName','Id','Kind') + if($row.Kind -cne 'NativeChannel' -or $row.Status -cne 'Applied' -or $entry.Kind -cne 'NativeChannel' -or $entry.PSObject.Properties['Phase'] -or + ($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer){throw 'Only one completed Applied native-channel operation on this named host is recoverable.'} + if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'} + foreach($field in @('Before','Desired','Target')){if((Get-WelaChannelRecoveryKey $entry.$field) -cne (Get-WelaChannelRecoveryKey $row.$field)){throw "Original journal/result $field differs."}} + Assert-WelaArrivalObject $row.Target @('Channel','Profile');Assert-WelaChannelRecoveryText $row.Target @('Channel','Profile') + if($row.Target.Channel -cne $Channel -or $row.Target.Profile -cne $profile.id){throw 'Original target does not match the canonical channel/profile.'} + foreach($state in @($row.Before,$row.After)){Assert-WelaChannelRecoverySnapshot $state $Channel} + $desired=$row.Desired;Assert-WelaArrivalObject $desired @('IsEnabled','SourceExampleBytes','RoundedMinimumBytes','MaximumSizeInBytes','LogMode','SecurityDescriptor','AccessChangeRequested') + Assert-WelaChannelRecoveryText $desired @('LogMode','SecurityDescriptor') + foreach($name in @('SourceExampleBytes','RoundedMinimumBytes','MaximumSizeInBytes')){if($desired.$name -isnot [int] -and $desired.$name -isnot [long]){throw 'Desired byte counts must be integers.'}} + if($desired.IsEnabled -isnot [bool] -or $desired.AccessChangeRequested -isnot [bool]){throw 'Desired switches must be Booleans.'} + $minimum=ConvertTo-WelaEventLogBytes $control.sourceExampleBytes + $acl=$row.Before.SecurityDescriptor;$revoke=$false;$accessRequested=[bool]($result.GrantEventLogReadersRequested -and $control.readerSid) + if($accessRequested){ + $access=Get-WelaChannelAccessPlan $acl + if($access.State -cnotin @('GrantPresent','GrantRequired')){throw 'Original descriptor has no reviewed read-grant transformation.'} + if($access.State -ceq 'GrantRequired'){$acl=$access.ProposedDescriptor;$revoke=$true} + } + $expected=[pscustomobject][ordered]@{IsEnabled=$(if($null -eq $control.enabled){$row.Before.IsEnabled}else{$control.enabled});MaximumSizeInBytes=[math]::Max([long]$row.Before.MaximumSizeInBytes,[long]$minimum);LogMode=$row.Before.LogMode;SecurityDescriptor=$acl} + if($desired.SourceExampleBytes -ne $control.sourceExampleBytes -or $desired.RoundedMinimumBytes -ne $minimum -or $desired.AccessChangeRequested -ne $accessRequested -or + (Get-WelaChannelRecoveryTupleKey $desired) -cne (Get-WelaChannelRecoveryTupleKey $expected) -or (Get-WelaChannelRecoveryTupleKey $row.After) -cne (Get-WelaChannelRecoveryTupleKey $expected) -or + (Get-WelaChannelRecoveryKey $row.Before.ProviderNames) -cne (Get-WelaChannelRecoveryKey $row.After.ProviderNames)){throw 'Completed operation includes an unexplained change beyond canonical enable/size/read-grant settings.'} + $recover=Get-WelaChannelRecoveryTuple $row.Before;$fields=@() + if($recover.MaximumSizeInBytes -ne $expected.MaximumSizeInBytes){$fields+='MaximumSizeInBytes'} + if((Get-WelaChannelRecoveryDescriptorKey $recover.SecurityDescriptor) -cne (Get-WelaChannelRecoveryDescriptorKey $expected.SecurityDescriptor)){$fields+='SecurityDescriptor'} + if($recover.IsEnabled -ne $expected.IsEnabled){$fields+='IsEnabled'} + if(-not $fields.Count){throw 'No completed channel setting change exists to recover.'} + [pscustomobject][ordered]@{Channel=$Channel;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$file.Path;Hash=$file.Hash};Expected=$expected;RecoverTo=$recover;Fields=$fields + RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresDisableConsent=($expected.IsEnabled -and -not $recover.IsEnabled);RequiresRevokeConsent=$revoke + HistoricalIdentity='Version1 journals bind historical ComputerName only. Current identity/source guards do not authenticate historical ownership. Recovery requires unchanged recorded post-state; no unrelated ACE is removed.'} +} +function Assert-WelaChannelRecoveryCurrent { + param($Definition,$Observed,$Expected,$Guard) + if($Observed.Channel -cne $Definition.Channel -or (Get-WelaChannelRecoveryTupleKey $Observed.Settings) -cne (Get-WelaChannelRecoveryTupleKey $Expected) -or + ($null -ne $Guard -and (Get-WelaChannelRecoveryKey $Observed.Guard) -cne (Get-WelaChannelRecoveryKey $Guard))){throw 'Current channel settings, descriptor or preserved metadata differ from the reviewed state.'} +} +function Set-WelaChannelRecoveryField { + param($Definition,[string]$Field) + $argument=switch -CaseSensitive ($Field){ + 'MaximumSizeInBytes' {'/ms:'+$Definition.RecoverTo.MaximumSizeInBytes} + 'SecurityDescriptor' {'/ca:'+$Definition.RecoverTo.SecurityDescriptor} + 'IsEnabled' {'/e:'+$Definition.RecoverTo.IsEnabled.ToString().ToLowerInvariant()} + default {throw 'Unsupported channel recovery field.'} + } + $null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::SystemDirectory) 'wevtutil.exe') -Arguments @('sl',$Definition.Channel,$argument) +} +function Invoke-WelaChannelRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Channel,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowDisable,[switch]$AllowRevoke) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Channel -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowDisable -or $AllowRevoke){throw 'Plan requires original journal/results, exact channel and new output only.'} + $source=Read-WelaWecUpdateFile $JournalPath + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Channel){throw 'Restore requires reviewed plan/hash, new output and applicable explicit consent only.'} + $source=Read-WelaWecUpdateFile $PlanPath + } + $output=New-WelaArrivalOutput $OutputPath $source.Path + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaChannelRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;ConfirmedFields=@();After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed channel-settings operation. Shrink can discard events; disable stops generation; read-grant removal can interrupt readers. No automatic rollback, event/retention/forwarding proof or Sigma credit. Sysmon excluded.'} + try { + $context=Get-WelaChannelRecoveryContext;$contextKey=Get-WelaChannelRecoveryKey $context;$sources=Get-WelaChannelRecoverySources + if($Action -eq 'Plan'){ + $definition=Get-WelaChannelRecoveryDefinition $JournalPath $OriginalResultsPath $Channel + $observed=Read-WelaChannelRecoveryState $Channel;Assert-WelaChannelRecoveryCurrent $definition $observed $definition.Expected $null + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaChannelRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard} + }else{ + if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'} + $plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard');Assert-WelaChannelRecoveryText $plan @('Kind','ContextKey','Sources') + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaChannelRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or sources differ.'} + $definition=Get-WelaChannelRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Channel + if((Get-WelaChannelRecoveryKey $definition) -cne (Get-WelaChannelRecoveryKey $plan.Definition)){throw 'Plan differs from independently rebuilt original evidence.'} + if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Explicit AllowShrink is required; shrinking can discard events.'} + if($definition.RequiresDisableConsent -and -not $AllowDisable){throw 'Explicit AllowDisable is required; disabling stops channel generation.'} + if($definition.RequiresRevokeConsent -and -not $AllowRevoke){throw 'Explicit AllowRevoke is required; removing the added read ACE can interrupt readers.'} + } + if((Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Channel)) -cne (Get-WelaChannelRecoveryKey $definition) -or (Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryContext)) -cne $contextKey -or (Get-WelaChannelRecoverySources) -cne $sources){throw 'Original evidence, actual host/operator or source changed.'} + Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $definition.Expected $plan.Guard + if($Action -eq 'Plan'){ + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + $report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text + $expected=Get-WelaChannelRecoveryTuple $definition.Expected;$token=Get-WelaChannelRecoveryKey (Get-WelaChannelReader);$index=0 + foreach($field in $definition.Fields){ + $index++;$pendingName=('pending-{0}-{1}.json' -f $index,$field) + $report.Artifacts+=Write-WelaWecUpdateArtifact $output $pendingName ([ordered]@{Status='Pending';Field=$field;Expected=$expected;RecoverTo=$definition.RecoverTo;Guard=$plan.Guard;PlanHash=$PlanHash;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 16) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaChannelRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $definition.Journal.Path).Hash -cne $definition.Journal.Hash -or (Read-WelaWecUpdateFile $definition.OriginalResults.Path).Hash -cne $definition.OriginalResults.Hash){throw 'Reviewed plan, sources or original evidence changed before write.'} + foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}} + Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $expected $plan.Guard + if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token){throw 'Actual current token changed before native write.'} + $report.NativeWriteAttempted=$true;Set-WelaChannelRecoveryField $definition $field + $expected.$field=$definition.RecoverTo.$field + $report.After=Read-WelaChannelRecoveryState $definition.Channel + $report.Artifacts+=Write-WelaWecUpdateArtifact $output ('observed-'+$index+'.json') ($report.After|ConvertTo-Json -Depth 16) + Assert-WelaChannelRecoveryCurrent $definition $report.After $expected $plan.Guard + if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token -or (Get-WelaChannelRecoverySources) -cne $sources){throw 'Actual token or source changed during native write/readback.'} + $report.Artifacts+=Write-WelaWecUpdateArtifact $output ('confirmed-'+$index+'.json') ([ordered]@{Status='Confirmed';Field=$field;After=$report.After;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 16) + $report.ConfirmedFields+=$field + } + Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $definition.RecoverTo $plan.Guard + if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token){throw 'Actual token changed before final confirmation.'} + if((Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryContext)) -cne $contextKey -or (Get-WelaChannelRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Final host/operator, source or reviewed plan changed.'} + if((Read-WelaWecUpdateFile $definition.Journal.Path).Hash -cne $definition.Journal.Hash -or (Read-WelaWecUpdateFile $definition.OriginalResults.Path).Hash -cne $definition.OriginalResults.Hash){throw 'Original historical evidence changed during restoration.'} + foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed during restoration.'}} + $report.Status='RestoredAndVerified';$report.ExitCode=0 + } + }catch{ + $report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message + if($report.NativeWriteAttempted){try{$report.After=Read-WelaChannelRecoveryState $definition.Channel;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failure-state.json' ($report.After|ConvertTo-Json -Depth 16)}catch{}} + } + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report +} diff --git a/scripts/WecAuthorization.ps1 b/scripts/WecAuthorization.ps1 new file mode 100644 index 00000000..18eec2ee --- /dev/null +++ b/scripts/WecAuthorization.ps1 @@ -0,0 +1,135 @@ +# Reviewed authorization only; an enabled subscription is never edited or paused. +function Get-WelaWecAuthorizationKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Get-WelaWecAuthorizationSids { + param([object[]]$SourceSids) + if($SourceSids.Count -lt 1 -or $SourceSids.Count -gt 32){throw 'Select 1 to 32 explicit domain-format source SIDs.'} + $seen=@{};$result=@() + foreach($sid in $SourceSids){ + if($sid -isnot [string] -or $sid -cnotmatch '^S-1-5-21-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})$'){throw 'Source SIDs must be canonical explicit domain-format strings.'} + foreach($part in @($sid.Split('-')|Select-Object -Skip 4)){$value=[uint32]0;if(-not [uint32]::TryParse($part,[ref]$value)){throw 'Source SID subauthority exceeds the native range.'}} + if($seen.ContainsKey($sid)){throw 'Duplicate source SID.'};$seen[$sid]=$true;$result+=$sid + } + [string[]]$ordered=$result;[Array]::Sort($ordered,[StringComparer]::Ordinal);$ordered +} +function Get-WelaWecAuthorizationDefinition { + param([string]$Xml) + if(-not $Xml -or $Xml.Length -gt 1048576){throw 'Native subscription XML is absent or oversized.'} + $doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('s','http://schemas.microsoft.com/2006/03/windows/events/subscription') + $nodes=@($root.SelectNodes('s:AllowedSourceDomainComputers',$ns));if($nodes.Count -ne 1){throw 'One explicit source authorization is required.'} + $authorization=[string]$nodes[0].InnerText + if($authorization.Length -gt 4096 -or $authorization -cnotmatch '^O:NSG:NSD:(?:\(A;;GA;;;S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+\)){1,32}$'){throw 'Only the explicit standard domain-source allow list is supported; no arbitrary/default SDDL.'} + $sids=@(Get-WelaWecAuthorizationSids @([regex]::Matches($authorization,'S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+')|ForEach-Object Value)) + if((Get-WelaWefAuthorization $sids) -cne $authorization){throw 'Observed authorization is not the canonical explicit SID list.'} + $model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $sids -Observed + if($model.Definition.Enabled -ne $false){throw 'Only an already disabled source-initiated subscription can change authorization.'} + $whole=Get-WelaWefXmlKey $root;$null=$root.RemoveChild($nodes[0]) + [pscustomobject][ordered]@{Id=$model.Id;Xml=$Xml;SourceSids=$sids;Authorization=$authorization;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);QueryKey=$model.Query.Key;Description=$model.Definition.Description} +} +function Read-WelaWecAuthorizationDefinition { + param([string]$Id) + if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Select one exact subscription ID.'} + $definition=Get-WelaWecAuthorizationDefinition (Read-WelaWecSubscriptionXml $Id) + if($definition.Id -cne $Id){throw 'Native subscription identity differs.'};$definition +} +function Get-WelaWecAuthorizationContext { + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'Actual non-impersonated elevated administrator required.'} + $required=@(Get-Service -Name Wecsvc,Winmgmt,EventLog -ErrorAction Stop) + if($required.Count -ne 3 -or @($required|Where-Object Status -ne Running).Count){throw 'Wecsvc, Winmgmt and EventLog must already be running; no services are started.'} + $hostState=Get-WelaChannelReadHost + if($hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100) -or $null -eq $hostState.UBR -or $hostState.UBR -lt 1){throw 'Observed patched Server 2022/2025 member or standalone collector required.'} + $services=@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog'" -ErrorAction Stop|Sort-Object Name|Select-Object Name,State,StartMode) + if($services.Count -ne 3 -or @($services|Where-Object {$_.State -ne 'Running' -or $_.StartMode -notin @('Auto','Manual')}).Count){throw 'Stable running service observations required.'} + $log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents') + try{$channel=[pscustomobject][ordered]@{Name=$log.LogName;Enabled=$log.IsEnabled;Mode=[string]$log.LogMode;MaximumBytes=$log.MaximumSizeInBytes;Path=$log.LogFilePath;SecurityDescriptor=$log.SecurityDescriptor}}finally{$log.Dispose()} + if((Get-WelaWecAuthorizationKey (Get-WelaChannelReader)) -cne (Get-WelaWecAuthorizationKey $reader)){throw 'Actual token changed during observations.'} + [pscustomobject][ordered]@{Host=$hostState;Reader=$reader;Services=$services;Destination=$channel} +} +function Get-WelaWecAuthorizationReviewKey { + param($Context) + $copy=Get-WelaWecAuthorizationKey $Context|ConvertFrom-Json + $copy.Reader.ProcessId=$null;$copy.Reader.TokenId=$null;$copy.Reader.ModifiedId=$null + Get-WelaWecAuthorizationKey $copy +} +function Get-WelaWecAuthorizationSources { + $sources=[ordered]@{} + foreach($path in @('WELA.ps1','scripts/WecAuthorization.ps1','scripts/WecAuthorizationNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + Get-WelaWecAuthorizationKey $sources +} +function Initialize-WelaWecAuthorizationNative { + $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'WecAuthorizationNative.cs'));if($bytes.Length -gt 65536){throw 'Native authorization source exceeds bound.'} + $hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.WecAuthorization.Edit' -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 source binding marker is missing or ambiguous.'} + Add-Type -TypeDefinition $source.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop + } + if([Wela.WecAuthorization.Edit]::SourceSha256 -cne $hash){throw 'Loaded authorization setter differs from source; start a fresh process.'} +} +function New-WelaWecAuthorizationEdit { + param($Before) + Initialize-WelaWecAuthorizationNative;$edit=[Wela.WecAuthorization.Edit]::new($Before.Id) + try{if($edit.OriginalAuthorization -cne $Before.Authorization -or $edit.OriginalDescription -cne $Before.Description -or (ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey){throw 'Native handle differs from reviewed subscription.'};$edit}catch{$edit.Dispose();throw} +} +function Assert-WelaWecAuthorizationPlan { + param($Plan) + Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','DesiredSourceSids','ContextKey','Sources','BeforeXml','RecordedUtc') + if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -isnot [string] -or $Plan.Kind -cne 'WelaWecAuthorizationPlan' -or $Plan.Id -isnot [string] -or $Plan.Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$' -or $Plan.DesiredSourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or -not $Plan.ContextKey -or $Plan.Sources -isnot [string] -or -not $Plan.Sources -or $Plan.BeforeXml -isnot [string]){throw 'Unknown or mistyped authorization plan.'} + $desired=@(Get-WelaWecAuthorizationSids $Plan.DesiredSourceSids) + if((Get-WelaWecAuthorizationKey $desired) -cne (Get-WelaWecAuthorizationKey $Plan.DesiredSourceSids)){throw 'Desired SID list is not canonical.'} + $null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc + $before=Get-WelaWecAuthorizationDefinition $Plan.BeforeXml;if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts original subscription.'} +} +function Assert-WelaWecAuthorizationArtifacts { + param([string]$Output,$Artifacts) + foreach($a in $Artifacts){if((Get-FileHash -LiteralPath (Join-Path $Output $a.Name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $a.Sha256){throw 'Retained authorization evidence changed.'}} +} +function Invoke-WelaWecAuthorization { + param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[object[]]$SourceSids,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath) + $ErrorActionPreference='Stop' + if($args.Count){throw 'Unknown authorization arguments are not supported.'} + if($Action -eq 'Plan'){ + if(-not $Id -or -not $SourceSids -or -not $OutputPath -or $PSBoundParameters.ContainsKey('PlanPath') -or $PSBoundParameters.ContainsKey('PlanHash')){throw 'Plan requires exact ID, desired source SIDs and new output only.'} + $desired=@(Get-WelaWecAuthorizationSids $SourceSids);$reviewed=$null;$source=Join-Path $script:ScriptRoot 'scripts' + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-fA-F0-9]{64}$' -or -not $OutputPath -or $PSBoundParameters.ContainsKey('Id') -or $PSBoundParameters.ContainsKey('SourceSids')){throw 'Apply accepts only a reviewed plan, SHA256 and new output.'} + $PlanHash=$PlanHash.ToLowerInvariant();$reviewed=Read-WelaWecUpdateFile $PlanPath;$source=$reviewed.Path + } + $output=New-WelaArrivalOutput $OutputPath $source + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecAuthorization';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeSaveAttempted=$false;NativeErrorCode=$null;BeforeSourceSids=@();DesiredSourceSids=@();After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='Only the explicit source-domain SID authorization of one existing disabled native subscription. No SID resolution, AD membership, authentication, forwarding, bookmark or Sigma proof; Sysmon excluded.'} + $edit=$null;$plan=$null + try { + $context=Get-WelaWecAuthorizationContext;$contextKey=Get-WelaWecAuthorizationKey $context;$sources=Get-WelaWecAuthorizationSources + if($Action -eq 'Plan'){ + $before=Read-WelaWecAuthorizationDefinition $Id + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecAuthorizationPlan';Id=$Id;DesiredSourceSids=$desired;ContextKey=(Get-WelaWecAuthorizationReviewKey $context);Sources=$sources;BeforeXml=$before.Xml;RecordedUtc=[DateTime]::UtcNow.ToString('o')} + Assert-WelaWecAuthorizationPlan $plan + if((Read-WelaWecAuthorizationDefinition $Id).WholeKey -cne $before.WholeKey -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Get-WelaWecAuthorizationSources) -cne $sources){throw 'Context, subscription or sources changed during planning.'} + $text=$plan|ConvertTo-Json -Depth 24;if([Text.Encoding]::UTF8.GetByteCount($text) -gt 4194304){throw 'Reviewed plan exceeds four MiB.'} + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $text;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired' + }else{ + if($reviewed.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'};$plan=ConvertFrom-WelaArrivalJson $reviewed.Text;Assert-WelaWecAuthorizationPlan $plan;$report.PlanHash=$PlanHash + if($plan.ContextKey -cne (Get-WelaWecAuthorizationReviewKey $context) -or $plan.Sources -cne $sources){throw 'Reviewed actual host/operator/service/channel or sources differ.'} + $before=Get-WelaWecAuthorizationDefinition $plan.BeforeXml;$desired=@($plan.DesiredSourceSids);$desiredAuthorization=Get-WelaWefAuthorization $desired + if((Read-WelaWecAuthorizationDefinition $plan.Id).WholeKey -cne $before.WholeKey){throw 'Current subscription differs from reviewed complete definition.'} + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $reviewed.Text + if($before.Authorization -ceq $desiredAuthorization){$report.Status='AlreadyMatches'}else{ + $edit=New-WelaWecAuthorizationEdit $before + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';PlanHash=$PlanHash;Context=$context;BeforeXml=$before.Xml;DesiredSourceSids=$desired;DesiredAuthorization=$desiredAuthorization;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 24) + Assert-WelaWecAuthorizationArtifacts $output $report.Artifacts + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecAuthorizationSources) -cne $sources -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Read-WelaWecAuthorizationDefinition $plan.Id).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'} + try{$edit.Save($desiredAuthorization)}finally{$report.NativeSaveAttempted=[bool]$edit.SaveAttempted} + } + $after=Read-WelaWecAuthorizationDefinition $plan.Id;$report.After=$after;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml + if($after.Authorization -cne $desiredAuthorization -or $after.PreservedKey -cne $before.PreservedKey -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Get-WelaWecAuthorizationSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Authorization readback, preserved definition, context, source or plan differs after operation.'} + if($report.NativeSaveAttempted){$report.Status='AuthorizationChangedAndVerified'} + } + $report.BeforeSourceSids=@($before.SourceSids);$report.DesiredSourceSids=@($plan.DesiredSourceSids) + Assert-WelaWecAuthorizationArtifacts $output $report.Artifacts;$report.ExitCode=0 + }catch{ + $report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message + $exception=$_.Exception;while($exception){if($exception -is [ComponentModel.Win32Exception]){$report.NativeErrorCode=$exception.NativeErrorCode;break};$exception=$exception.InnerException} + if($report.NativeSaveAttempted -and $plan){try{$xml=Read-WelaWecSubscriptionXml $plan.Id;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failed-after.xml' $xml}catch{$report.Diagnostic+=' Final native definition unavailable: '+$_.Exception.Message}} + }finally{if($edit){try{$edit.Dispose()}catch{$report.ExitCode=1;$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.Diagnostic+=' Native handle cleanup failed: '+$_.Exception.Message}}} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report +} diff --git a/scripts/WecAuthorizationNative.cs b/scripts/WecAuthorizationNative.cs new file mode 100644 index 00000000..d8a3f34b --- /dev/null +++ b/scripts/WecAuthorizationNative.cs @@ -0,0 +1,86 @@ +// Existing-only native WEC authorization setter. No creation, deletion, activation or other setters. +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecAuthorization { + public sealed class Edit : IDisposable { + public const string SourceSha256="__WELA_SOURCE_SHA256__"; + [StructLayout(LayoutKind.Explicit, Size=16)] struct Variant { + [FieldOffset(0)] public IntPtr Text; [FieldOffset(8)] public uint Count; [FieldOffset(12)] public uint Type; + } + [DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr EcOpenSubscription(string name,uint access,uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcGetSubscriptionProperty(IntPtr handle,int property,uint flags,uint size,IntPtr value,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSetSubscriptionProperty(IntPtr handle,int property,uint flags,ref Variant value); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSaveSubscription(IntPtr handle,uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + IntPtr handle; readonly string name,oldQuery,oldDescription,oldAuthorization; + public string OriginalQuery {get{return oldQuery;}} + public string OriginalDescription {get{return oldDescription;}} + public string OriginalAuthorization {get{return oldAuthorization;}} + public bool SaveAttempted {get;private set;} + public static void ValidateAuthorization(string value) { + if(String.IsNullOrEmpty(value)||value.Length>4096||!value.StartsWith("O:NSG:NSD:",StringComparison.Ordinal))throw new ArgumentException("Explicit canonical domain-source authorization required."); + var matches=System.Text.RegularExpressions.Regex.Matches(value,@"\(A;;GA;;;(S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+)\)"); + if(matches.Count<1||matches.Count>32)throw new ArgumentException("Select 1 to 32 source SIDs."); + var expected=new StringBuilder("O:NSG:NSD:");string prior=null; + foreach(System.Text.RegularExpressions.Match match in matches) { + string sid=match.Groups[1].Value;string[] parts=sid.Split('-'); + for(int i=4;i=0)throw new ArgumentException("SIDs must be unique and sorted.");prior=sid;expected.Append(match.Value); + } + if(!String.Equals(expected.ToString(),value,StringComparison.Ordinal))throw new ArgumentException("Unsupported authorization descriptor."); + } + // Public only for allocated-buffer ABI/type regression tests; performs no native calls. + public static object Decode(IntPtr buffer,uint size,int property) { + if(buffer==IntPtr.Zero||size<16||size>1048576||property!=0&&property!=6&&property!=7&&property!=10&&property!=11&&property!=19&&property!=27&&property!=31)throw new InvalidOperationException("Invalid native property buffer or selection."); + int type=Marshal.ReadInt32(buffer,12); + if(property==27){if(type!=2)throw new InvalidOperationException("Subscription type is not UInt32.");return unchecked((uint)Marshal.ReadInt32(buffer));} + if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;} + if(type==0&&property==6)return ""; + if(type!=4)throw new InvalidOperationException("Expected scalar native string."); + IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64(); + if(pointer==IntPtr.Zero||offset<16||offset>size-2)throw new InvalidOperationException("Native string pointer is outside its buffer."); + StringBuilder text=new StringBuilder(); + for(int i=0;i<524288&&offset+2L*i+2<=size;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);} + throw new InvalidOperationException("Unterminated native string."); + } + static object Read(IntPtr h,int property) { + uint size=16; + for(int attempt=0;attempt<3;attempt++) { + IntPtr buffer=Marshal.AllocHGlobal((int)size); + try { + uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error(); + if(!ok){if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;} + if(used<16||used>size)throw new InvalidOperationException("Invalid native property length."); + return Decode(buffer,used,property); + }finally{Marshal.FreeHGlobal(buffer);} + } + throw new InvalidOperationException("Native property changed repeatedly."); + } + void Check(IntPtr h) { + if((bool)Read(h,0)||(uint)Read(h,27)!=0||!String.Equals((string)Read(h,7),"http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog",StringComparison.Ordinal)||!String.Equals((string)Read(h,11),"HTTP",StringComparison.Ordinal)||!String.Equals((string)Read(h,19),"ForwardedEvents",StringComparison.Ordinal)||!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal)||!String.Equals((string)Read(h,31),oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Native enabled/query/description/authorization changed since review."); + } + public Edit(string id) { + if(String.IsNullOrWhiteSpace(id)||id.Length>128||id.IndexOf('\0')>=0)throw new ArgumentException("Invalid subscription ID."); + name=id;handle=EcOpenSubscription(name,3,2);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{oldQuery=(string)Read(handle,10);oldDescription=(string)Read(handle,6);oldAuthorization=(string)Read(handle,31);ValidateAuthorization(oldAuthorization);Check(handle);}catch{Dispose();throw;} + } + public void Save(string authorization) { + if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit"); + if(SaveAttempted)throw new InvalidOperationException("A native edit may be saved only once."); + ValidateAuthorization(authorization); + if(String.Equals(authorization,oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Idempotent authorization must not save."); + IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{Check(fresh);}finally{EcClose(fresh);} + IntPtr text=Marshal.StringToHGlobalUni(authorization); + try { + Variant value=new Variant{Text=text,Count=0,Type=4}; + if(!EcSetSubscriptionProperty(handle,31,0,ref value))throw new Win32Exception(Marshal.GetLastWin32Error()); + SaveAttempted=true; + if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error()); + }finally{Marshal.FreeHGlobal(text);} + } + public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}} + } +} diff --git a/tests/ChannelRecovery.Cli.Tests.ps1 b/tests/ChannelRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..ec1bf731 --- /dev/null +++ b/tests/ChannelRecovery.Cli.Tests.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('channel-recovery','-ChannelRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help');Code=0;Pattern='AllowShrink'}, + @{Args=@('configure','-ChannelRecoveryAction','Restore','-Auto');Code=1;Pattern='require channel-recovery'}, + @{Args=@('channel-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help','-Auto');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated|Unknown|unbound'}, + @{Args=@('channel-recovery','-ChannelRecoveryAction','Restore','-ChannelRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('channel-recovery','-ChannelRecoveryOutputPath','not-created');Code=1;Pattern='Plan requires'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "Channel recovery CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/ChannelRecovery.Tests.ps1 b/tests/ChannelRecovery.Tests.ps1 new file mode 100644 index 00000000..a5ee4456 --- /dev/null +++ b/tests/ChannelRecovery.Tests.ps1 @@ -0,0 +1,99 @@ +$ErrorActionPreference='Stop';$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/NativeChannelAccess.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/NativeChannelConfiguration.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/ChannelRecovery.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Copy-State($Value){ConvertFrom-WelaArrivalJson (Get-WelaChannelRecoveryKey $Value)} +function Get-WelaChannelRecoveryContext {[pscustomobject]@{Host=[ordered]@{Computer='TEST';MachineGuid='owned-host'};Reader='owned-logon'}} +function Get-WelaChannelReader {[pscustomobject]@{UserSid='TEST';TokenId='token';ModifiedId=$script:token}} +# Portable tests exercise authority, reconstruction and write ordering; real descriptor +# bytes are exercised separately by the native public Configure/Restore workflow. +function Get-WelaChannelRecoveryDescriptorKey {param($Sddl) if($Sddl -cnotin @('original','original+read','foreign')){throw 'Invalid fixture descriptor'};$Sddl} +function Get-WelaChannelAccessPlan {param($SecurityDescriptor) if($SecurityDescriptor -ceq 'original'){[pscustomobject]@{State='GrantRequired';ProposedDescriptor='original+read'}}else{[pscustomobject]@{State='GrantPresent'}}} +function Test-WelaChannelDescriptorEqual {param($First,$Second) $First -ceq $Second} +function Get-WelaNativeChannel {param($Name) [pscustomobject][ordered]@{Name=$Name;State=$(if($script:settings.IsEnabled){'Enabled'}else{'Disabled'});IsEnabled=$script:settings.IsEnabled;LogMode=$script:settings.LogMode;SecurityDescriptor=$script:settings.SecurityDescriptor;MaximumSizeInBytes=$script:settings.MaximumSizeInBytes;ProviderNames='Microsoft-Windows-CAPI2';MetadataErrors=[pscustomobject]@{};Error=$null}} +function Invoke-WelaNative {param($FilePath,$Arguments) foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:settings.MaximumSizeInBytes=[long]$arg.Substring(4)};if($arg -like '/ca:*'){$script:settings.SecurityDescriptor=$arg.Substring(4)};if($arg -ceq '/e:true'){$script:settings.IsEnabled=$true}}} +function Read-WelaChannelRecoveryState { + param($Channel) + $script:reads++ + if($script:case -eq 'fresh-drift' -and $script:reads -eq 2){$script:settings.MaximumSizeInBytes+=65536} + [pscustomobject]@{Channel=$Channel;Settings=(Copy-State $script:settings);Guard=[ordered]@{Path=$script:path;Provider='CAPI2';Other='preserved'}} +} +function Set-WelaChannelRecoveryField { + param($Definition,$Field) + $script:writes++ + Assert (Test-Path (Join-Path $script:output ('pending-'+$script:writes+'-'+$Field+'.json'))) 'Durable per-field pending receipt precedes each write.' + if($script:case -eq 'native-fail' -and $script:writes -eq 2){throw 'Native second write failed'} + if($script:case -ne 'false-success'){$script:settings.$Field=$Definition.RecoverTo.$Field} + if($script:case -eq 'preservation'){$script:path='changed'} + if($script:case -eq 'token'){$script:token='changed'} + if($script:writes -eq 3 -and $script:case -eq 'last-history'){[IO.File]::AppendAllText($script:originalFile,' ')} + if($script:writes -eq 3 -and $script:case -eq 'last-artifact'){[IO.File]::AppendAllText((Join-Path $script:output 'pending-3-IsEnabled.json'),' ')} +} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-channel-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST';$channel='Microsoft-Windows-CAPI2/Operational' +function Original([string]$Dir,[bool]$Grant=$true){ + $script:settings=[pscustomobject][ordered]@{IsEnabled=$false;MaximumSizeInBytes=1052672L;LogMode='Circular';SecurityDescriptor='original'};$script:case='';$script:token='stable';$script:path='preserved';$script:reads=0;$script:writes=0 + $profile=Get-WelaNativeChannelProfile;$plans=@(Get-WelaNativeChannelPlan -Profile $profile -GrantEventLogReaders:$Grant|Where-Object {$_.Definition.channel -ceq $channel}) + $context=New-WelaConfigurationContext -Auto -BackupPath "$Dir/journal" + Set-WelaNativeChannelControls $context $plans $profile.id + $r=Complete-WelaConfiguration $context -Scope 'native-channel-settings-only' + $r|Add-Member NoteProperty Action Configure;$r|Add-Member NoteProperty ChannelProfile $profile.id;$r|Add-Member NoteProperty GrantEventLogReadersRequested $Grant + $r|ConvertTo-Json -Depth 20|Set-Content "$Dir/original.json" -Encoding UTF8 + Assert ($r.Results.Count -eq 1 -and $r.Results[0].Status -ceq 'Applied') 'Actual shared configuration callbacks produce original journal/result evidence.' +} +try { + foreach($scenario in @('ok','no-grant','no-shrink','no-disable','no-revoke','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','token','last-history','last-artifact')){ + $dir=Join-Path $root $scenario;$null=New-Item -ItemType Directory $dir;Original $dir ($scenario -ne 'no-grant') + $plan=Invoke-WelaChannelRecovery -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Channel $channel -OutputPath "$dir/plan" + Assert ($plan.Status -ceq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $scenario : $($plan.Diagnostic)" + Assert ($script:writes -eq 0) 'Plan does not mutate.' + $planPath="$dir/plan/plan.json";$hash=$plan.PlanHash + if($scenario -eq 'hash'){$hash='f'*64} + if($scenario -in @('tamper','duplicate')){$text=[IO.File]::ReadAllText($planPath);if($scenario -eq 'tamper'){$text=$text.Replace('1052672','2097152')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')};[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()} + if($scenario -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')} + if($scenario -eq 'drift'){$script:settings.SecurityDescriptor='foreign'} + $script:case=$scenario;$script:originalFile="$dir/original.json";$script:reads=0;$script:output="$dir/restore" + $r=Invoke-WelaChannelRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath $script:output -AllowShrink:($scenario -ne 'no-shrink') -AllowDisable:($scenario -ne 'no-disable') -AllowRevoke:($scenario -notin @('no-revoke','no-grant')) + Assert (($r.ExitCode -eq 0) -eq ($scenario -in @('ok','no-grant'))) "Restore $scenario : $($r.Diagnostic)" + Assert ($r.ReadyRuleCredit -eq 0 -and (Test-Path "$dir/restore/manifest.json")) 'Outcome evidence is retained without Sigma credit.' + if($scenario -in @('ok','no-grant')){ + Assert ($script:settings.SecurityDescriptor -ceq 'original' -and -not $script:settings.IsEnabled -and $script:settings.MaximumSizeInBytes -eq 1052672 -and $r.Status -ceq 'RestoredAndVerified') 'Original changed fields restored.' + Assert ($r.ConfirmedFields.Count -eq $(if($scenario -eq 'ok'){3}else{2})) 'Only originally changed fields are written and confirmed.' + $again=Invoke-WelaChannelRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowDisable -AllowRevoke + Assert ($again.Status -ceq 'Refused') 'Completed old plan cannot be replayed.' + }elseif($scenario -in @('native-fail','false-success','preservation','token','last-history','last-artifact')){ + Assert ($r.Status -ceq 'RestoreAttemptedUnverified' -and $script:writes -gt 0) 'Possible partial write is explicit; no rollback is inferred.' + if($scenario -eq 'native-fail'){Assert ($r.ConfirmedFields.Count -eq 1 -and $script:settings.MaximumSizeInBytes -eq 1052672 -and $script:settings.SecurityDescriptor -ceq 'original+read' -and $script:settings.IsEnabled) 'Second-write failure retains one confirmed step and stops before disable.'} + }else{Assert ($r.Status -ceq 'Refused' -and $script:writes -eq 0) 'Unreviewed or drifted input refuses before write.'} + foreach($artifact in $r.Artifacts){$matches=(Get-FileHash (Join-Path $r.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256;Assert ($matches -eq (-not ($scenario -eq 'last-artifact' -and $artifact.Name -ceq 'pending-3-IsEnabled.json'))) 'Retained hashes expose the deliberately changed artifact; all other bytes match.'} + } + $dir=Join-Path $root 'history';$null=New-Item -ItemType Directory $dir;Original $dir + $savedResult=[IO.File]::ReadAllText("$dir/original.json");$savedJournal=[IO.File]::ReadAllText("$dir/journal/before.jsonl") + $cases=@('Status','Kind','Id','Action','Scope','ChannelProfile','Channel','Profile','Version','ComputerName','State','IsEnabled','MaximumSizeInBytes','LogMode','SecurityDescriptor','AfterDrift','DesiredDrift','ExtraAce','NoGrantAuthority','DuplicateJournal') + foreach($bad in $cases){ + $r=ConvertFrom-WelaArrivalJson $savedResult;$e=ConvertFrom-WelaArrivalJson $savedJournal + switch($bad){ + {$_ -in @('Status','Kind','Id')} {$r.Results[0].$bad=$true} + {$_ -in @('Action','Scope','ChannelProfile')} {$r.$bad=$true} + {$_ -in @('Channel','Profile')} {$e.Target.$bad=$true;$r.Results[0].Target.$bad=$true} + {$_ -in @('Version','ComputerName')} {$e.$bad=$true} + {$_ -in @('State','IsEnabled','MaximumSizeInBytes','LogMode','SecurityDescriptor')} {$e.Before.$bad=if($bad -eq 'IsEnabled'){'false'}else{$true};$r.Results[0].Before=Copy-State $e.Before} + 'AfterDrift' {$r.Results[0].After.MaximumSizeInBytes+=65536} + 'DesiredDrift' {$e.Desired.MaximumSizeInBytes+=65536;$r.Results[0].Desired=Copy-State $e.Desired} + 'ExtraAce' {$e.Desired.SecurityDescriptor='foreign';$r.Results[0].Desired=Copy-State $e.Desired;$r.Results[0].After.SecurityDescriptor='foreign'} + 'NoGrantAuthority' {$r.GrantEventLogReadersRequested=$false} + } + $r|ConvertTo-Json -Depth 20|Set-Content "$dir/original.json" -Encoding UTF8 + $text=$e|ConvertTo-Json -Depth 20 -Compress;if($bad -eq 'DuplicateJournal'){$text+="`n"+$text};[IO.File]::WriteAllText("$dir/journal/before.jsonl",$text) + $p=Invoke-WelaChannelRecovery -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Channel $channel -OutputPath "$dir/reject-$bad" + Assert ($p.Status -ceq 'Refused' -and -not $p.NativeWriteAttempted) "History $bad rejected before any write: $($p.Diagnostic)" + } +}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count channel recovery authority/order/partial-outcome assertions. Native descriptors require the Windows fixture." diff --git a/tests/ChannelRecovery.Windows.Tests.ps1 b/tests/ChannelRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..dcb2e45a --- /dev/null +++ b/tests/ChannelRecovery.Windows.Tests.ps1 @@ -0,0 +1,108 @@ +param([switch]$AllowDisposableChannelWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows channel-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" +$count=0;$errors=@();$primary=$null +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +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');$copy.DocumentElement.RemoveAttribute('channelAccess');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml} +function Save($Name,$Value){$Value|ConvertTo-Json -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +Add-Type -TypeDefinition @' +using System; using System.IO; using System.Text; using System.Threading.Tasks; +public static class WelaChannelRecoveryFixturePipe { + 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-channel-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){ + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',"$repo/WELA.ps1")+$Arguments + 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=[WelaChannelRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaChannelRecoveryFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(120000)){throw 'Public command exceeded two 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} + } +} +$profile=Get-WelaNativeChannelProfile;$before=@{};$raw=@{};$policies=Get-WelaEffectiveAuditPolicy +foreach($control in $profile.controls){$name=$control.channel;$before[$name]=Get-WelaNativeChannel $name;if(Test-WelaNativeChannelSnapshot $before[$name]){$raw[$name]=Read-Raw $name}} +$capi='Microsoft-Windows-CAPI2/Operational';$app='Microsoft-Windows-AppLocker/EXE and DLL' +$expectedConfigure=if(@($before.Values|Where-Object State -eq 'Not installed').Count){1}else{0} +Save 'before.json' $before;$rawText=@{};foreach($name in $raw.Keys){$rawText[$name]=$raw[$name].OuterXml};Save 'raw-before.json' $rawText +try { + $os=Get-CimInstance Win32_OperatingSystem + Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server 2022/2025 required.' + Assert ($raw.ContainsKey($capi) -and $raw.ContainsKey($app)) 'Readable CAPI2 and AppLocker channels required.' + Assert (@($before.Values|Where-Object State -notin @('Enabled','Disabled','Not installed')).Count -eq 0) 'Unreadable original settings refuse fixture mutation.' + # Owned disposable preparation removes only this group read ACE, preserving every + # captured original byte for final cleanup. Product recovery never uses this shortcut. + $descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($before[$capi].SecurityDescriptor) + for($i=$descriptor.DiscretionaryAcl.Count-1;$i -ge 0;$i--){$ace=$descriptor.DiscretionaryAcl[$i];if($ace -is [Security.AccessControl.CommonAce] -and $ace.AceQualifier -eq 'AccessAllowed' -and $ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and ($ace.AccessMask -band 1)){$descriptor.DiscretionaryAcl.RemoveAce($i)}} + $withoutRead=$descriptor.GetSddlForm('All');Assert ((Get-WelaChannelAccessPlan $withoutRead).State -ceq 'GrantRequired') 'Actual descriptor permits one lossless read-only grant.' + $null=Invoke-WelaNative wevtutil.exe @('sl',$app,'/ms:2147483648') + foreach($scenario in @('grant','no-grant')){ + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false','/ms:1048576',('/ca:'+$withoutRead)) + $prepared=Get-WelaNativeChannel $capi;Save ($scenario+'-prepared.json') $prepared + $journal=Join-Path $root ($scenario+'-original-journal');$resultPath=Join-Path $root ($scenario+'-original.json') + $options=@('channel-settings','-ChannelAction','Configure','-Auto','-BackupPath',$journal,'-ResultsPath',$resultPath);if($scenario -ceq 'grant'){$options+='-GrantEventLogReaders'} + Public ($scenario+'-configure') $options $expectedConfigure + $original=Get-Content $resultPath -Raw|ConvertFrom-Json;$selected=@($original.Results|Where-Object {$_.Target.Channel -ceq $capi}) + Assert ($selected.Count -eq 1 -and $selected[0].Status -ceq 'Applied') 'Public Configure supplies a genuinely Applied selected operation.' + $configured=Get-WelaNativeChannel $capi;Assert ($configured.IsEnabled -and $configured.MaximumSizeInBytes -eq 102432768) 'Actual enable and size changes observed.' + $others=@{};foreach($name in $raw.Keys){if($name -cne $capi){$others[$name]=(Read-Raw $name).OuterXml}} + $planDir=Join-Path $root ($scenario+'-plan') + Public ($scenario+'-plan') @('channel-recovery','-ChannelRecoveryJournalPath',"$journal/before.jsonl",'-ChannelRecoveryOriginalResultsPath',$resultPath,'-ChannelRecoveryChannel',$capi,'-ChannelRecoveryOutputPath',$planDir) + $plan=Get-Content "$planDir/manifest.json" -Raw|ConvertFrom-Json + Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.NativeWriteAttempted -and (Get-FileHash "$planDir/plan.json").Hash.ToLowerInvariant() -ceq $plan.PlanHash) 'Public Plan is read-only with an independently checked exact hash.' + $restoreArgs=@('channel-recovery','-ChannelRecoveryAction','Restore','-ChannelRecoveryPlanPath',"$planDir/plan.json",'-ChannelRecoveryPlanHash',$plan.PlanHash) + $consents=@('-ChannelRecoveryAllowShrink','-ChannelRecoveryAllowDisable');if($scenario -ceq 'grant'){$consents+='-ChannelRecoveryAllowRevoke'} + foreach($consent in $consents){ + $refuseDir=Join-Path $root ($scenario+'-missing-'+$consent.TrimStart('-')) + Public ($scenario+'-missing-'+$consent.TrimStart('-')) ($restoreArgs+@('-ChannelRecoveryOutputPath',$refuseDir)+@($consents|Where-Object {$_ -cne $consent})) 1 + $r=Get-Content "$refuseDir/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted -and (Test-WelaNativeChannelSnapshotEqual $configured (Get-WelaNativeChannel $capi))) 'Each required consent refuses before native write.' + } + $whatIf=Join-Path $root ($scenario+'-whatif');Public ($scenario+'-whatif') ($restoreArgs+@('-ChannelRecoveryOutputPath',$whatIf,'-WhatIf')+$consents) 1 + Assert (-not (Test-Path $whatIf)) 'Unsupported preview option refuses before dispatch/output.' + # Real native drift between reviewed plan and Restore must not be undone. + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,('/ms:'+($configured.MaximumSizeInBytes+65536))) + $drift=Join-Path $root ($scenario+'-drift');Public ($scenario+'-drift') ($restoreArgs+@('-ChannelRecoveryOutputPath',$drift)+$consents) 1 + $r=Get-Content "$drift/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted -and (Get-WelaNativeChannel $capi).MaximumSizeInBytes -eq ($configured.MaximumSizeInBytes+65536)) 'Actual native drift refuses without overwriting the later setting.' + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,('/ms:'+$configured.MaximumSizeInBytes)) + $restoredDir=Join-Path $root ($scenario+'-restore');Public ($scenario+'-restore') ($restoreArgs+@('-ChannelRecoveryOutputPath',$restoredDir)+$consents) + $r=Get-Content "$restoredDir/manifest.json" -Raw|ConvertFrom-Json + Assert ($r.Status -ceq 'RestoredAndVerified' -and $r.NativeWriteAttempted -and $r.ConfirmedFields.Count -eq $(if($scenario -ceq 'grant'){3}else{2})) 'Every originally changed field has verified durable restoration.' + Assert (Test-WelaNativeChannelSnapshotEqual $prepared (Get-WelaNativeChannel $capi)) 'Actual original enable/size/descriptor/retention tuple restored.' + Assert ((Guard-Raw (Read-Raw $capi)) -ceq (Guard-Raw $raw[$capi])) 'All other raw selected-channel configuration fields preserved.' + foreach($name in $others.Keys){Assert ((Read-Raw $name).OuterXml -ceq $others[$name]) 'Recovery does not touch another profile channel.'} + foreach($artifact in $r.Artifacts){Assert ((Get-FileHash (Join-Path $restoredDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Restoration artifact hash matches actual bytes.'} + $replay=Join-Path $root ($scenario+'-replay');Public ($scenario+'-replay') ($restoreArgs+@('-ChannelRecoveryOutputPath',$replay)+$consents) 1 + $r=Get-Content "$replay/manifest.json" -Raw|ConvertFrom-Json;Assert ($r.Status -ceq 'Refused' -and -not $r.NativeWriteAttempted) 'Restored old plan refuses replay.' + } + Write-Host "PASS: $count actual public channel Configure/Restore assertions." +}catch{$primary=$_;Write-Host $_;Get-ChildItem -LiteralPath $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host ([IO.File]::ReadAllText($_.FullName))}} +finally { + foreach($name in $raw.Keys){try{$s=$before[$name];$null=Invoke-WelaNative wevtutil.exe @('sl',$name,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor));if(-not (Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $name)) -or (Read-Raw $name).OuterXml -cne $raw[$name].OuterXml){throw 'Original full channel metadata differs after fixture cleanup'}}catch{$errors+=$name+': '+$_.Exception.Message}} + try{$current=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($policies[$guid] -ne $current[$guid]){$errors+='Audit policy changed: '+$guid}}}catch{$errors+=$_.Exception.Message} + Save 'cleanup.json' ([ordered]@{Complete=($errors.Count -eq 0);Errors=$errors;OriginalChannels=@($raw.Keys);AuditMasksCompared=$policies.Count;PrimaryError=[string]$primary;EventRecordsRestored=$false;Boundary='Fixture restores exact original configuration; shrinking may discard intervening records. No retention or forwarding proof.'}) +} +$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}}) +Save 'acceptance.json' ([ordered]@{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;ReadyRuleCredit=0}) +if($errors.Count){throw ('Cleanup failed: '+($errors -join '; '))};if($primary){throw $primary};exit 0 diff --git a/tests/ProfileConfigure.Windows.Tests.ps1 b/tests/ProfileConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..8eaeeef5 --- /dev/null +++ b/tests/ProfileConfigure.Windows.Tests.ps1 @@ -0,0 +1,113 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'} +$repo=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +. (Join-Path $repo 'scripts/Configuration.ps1') +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-profile-configure-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $root +$count=0;$failure=$null;$cleanupErrors=@() +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 Key($Value){ConvertTo-Json -InputObject $Value -Depth 25 -Compress} +function Masks($Value){@($Value.Keys|Sort-Object|ForEach-Object{"$_=$($Value[$_])"}) -join ';'} +function Public([string]$Label,[string[]]$Arguments,[int]$Expected=0){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($code -eq $Expected) "Public $Label exited $code, expected $Expected : $output" +} +function Channels { @(foreach($name in @('Security','System','Application','ForwardedEvents','Microsoft-Windows-CAPI2/Operational')){Get-WelaNativeChannel $name}) } +function TypedPrecedence {Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy} +$before=Get-WelaEffectiveAuditPolicy;$precedence=TypedPrecedence;$channels=Channels +$hostState=Get-WelaHostContext +$actualOs=Get-CimInstance Win32_OperatingSystem | Select-Object Version,BuildNumber,ProductType +$actualComputer=Get-CimInstance Win32_ComputerSystem | Select-Object DomainRole,PartOfDomain +$patch=Get-ItemPropertyValue -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR +Assert ($actualOs.ProductType -eq 3 -and $actualComputer.DomainRole -eq 2 -and -not $actualComputer.PartOfDomain) 'Fixture records an actual standalone server, without simulating domain membership.' +Assert ($hostState.Role -eq 'MemberServer' -and $hostState.Build -in @(20348,26100)) 'Only the actual hosted server context is supported by this fixture.' +$catalog=Join-Path $repo 'config/audit_profiles.json';$example=Join-Path $repo 'config/custom-audit-profile.example.json' +$catalogHash=(Get-FileHash $catalog).Hash;$exampleHash=(Get-FileHash $example).Hash +$profilePath=Join-Path $root 'profile.json' +$custom=Get-Content $example -Raw|ConvertFrom-Json +$custom.profiles[0].id='custom-native-acceptance' +$custom.profiles[0].appliesTo=@([pscustomobject]@{roles=@($hostState.Role);minBuild=$hostState.Build;maxBuild=$hostState.Build}) +$custom.profiles[0].note='Disposable native acceptance fixture; no baseline or detection claim.' +Save 'profile.json' $custom +$sourceHash=(Get-FileHash $profilePath).Hash.ToLowerInvariant() +$ids=@{Creation='0CCE922B-69AE-11D9-BED3-505054503030';Termination='0CCE922C-69AE-11D9-BED3-505054503030';Share='0CCE9244-69AE-11D9-BED3-505054503030';File='0CCE921D-69AE-11D9-BED3-505054503030'} +$base=@('-Profile','custom-native-acceptance','-ProfileFile',$profilePath,'-SaclMode','Skip') +Save 'original.json' @{Host=$hostState;NativeOS=$actualOs;NativeComputer=$actualComputer;UBR=$patch;Engine=$PSVersionTable.PSVersion.ToString();Masks=$before;Precedence=$precedence;Channels=$channels;Sources=@{Custom=$sourceHash;Catalog=$catalogHash;Example=$exampleHash}} +try{ + # Fixture-only initial values distinguish exact, minimum, optional and NC semantics. + $null=New-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -PropertyType DWord -Value 0 -Force + Set-WelaEffectiveAuditPolicy -Guid $ids.Creation -Mask 2 -Mode exact + Set-WelaEffectiveAuditPolicy -Guid $ids.Termination -Mask 3 -Mode exact + Set-WelaEffectiveAuditPolicy -Guid $ids.Share -Mask 1 -Mode exact + Set-WelaEffectiveAuditPolicy -Guid $ids.File -Mask 0 -Mode exact + $seed=Get-WelaEffectiveAuditPolicy;$seedPrecedence=TypedPrecedence + Save 'seeded.json' @{Masks=$seed;Precedence=$seedPrecedence} + $planPath=Join-Path $root 'plan.json' + Public 'plan' (@('plan')+$base+@('-PlanPath',$planPath)) + $plan=Get-Content $planPath -Raw|ConvertFrom-Json + Assert ($plan.role -eq $hostState.Role -and $plan.build -eq $hostState.Build -and $plan.policies.Count -eq 59) 'Public Plan retains actual context and all59 controls.' + Assert ($plan.CustomProfileSource.Sha256 -ceq $sourceHash) 'Plan binds the selected custom source bytes.' + $dryPath=Join-Path $root 'dry.json';$dryBackup=Join-Path $root 'dry-backup' + Public 'dry' (@('configure')+$base+@('-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',$dryPath)) + $dry=Get-Content $dryPath -Raw|ConvertFrom-Json + Assert ($dry.DryRun -and $dry.ExitCode -eq 0 -and -not(Test-Path $dryBackup)) 'DryRun returns explicit preview without creating a journal.' + Assert ((Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $seed) -and (Key (TypedPrecedence)) -ceq (Key $seedPrecedence)) 'Plan/DryRun preserve all59 masks and typed precedence.' + Public 'wrong-role' (@('configure')+$base+@('-Auto','-Role','Client','-Build',[string]$hostState.Build,'-BackupPath',(Join-Path $root 'wrong-backup'),'-ResultsPath',(Join-Path $root 'wrong.json'))) 1 + Assert (-not(Test-Path (Join-Path $root 'wrong-backup')) -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $seed)) 'Mismatched actual role refuses before native writes or a journal.' + $backup=Join-Path $root 'configure-backup';$resultPath=Join-Path $root 'configured.json' + Public 'configure' (@('configure')+$base+@('-Auto','-BackupPath',$backup,'-ResultsPath',$resultPath)) + $result=Get-Content $resultPath -Raw|ConvertFrom-Json + $expected=$seed.Clone();$expected[$ids.Creation]=3;$expected[$ids.Termination]=1 + Assert ((Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $expected)) 'Actual Configure enables minimum Success without clearing Failure, applies exact Success, and preserves optional/NC/omitted controls.' + Assert ((TypedPrecedence).Type -eq 'DWord' -and (TypedPrecedence).Value -eq 1) 'Public Configure applies and verifies actual DWORD precedence before audit writes.' + Assert ($result.ExitCode -eq 0 -and $result.Scope -ceq 'advanced-audit-policy-and-precedence' -and $result.ProfileScope -ceq 'advanced-audit-policy-only') 'Completed public results retain the narrow scope and success.' + Assert ($result.CustomProfileSource.Sha256 -ceq $sourceHash -and $result.CustomProfileSource.CanonicalSha256 -ieq $catalogHash) 'Completed result retains source and canonical catalog fingerprints.' + $journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 3 -and $journal[0].Target.Name -ceq 'SCENoApplyLegacyAuditPolicy') 'Only precedence and the two changed subcategories are journaled, in prerequisite order.' + foreach($entry in $journal){ + $row=@($result.Results|Where-Object Id -ceq $entry.Id) + Assert ($row.Count -eq 1 -and $row[0].Status -ceq 'Applied' -and (Key $row[0].Before) -ceq (Key $entry.Before)) 'Every native write has matching original journal and Applied result.' + } + $repeatPath=Join-Path $root 'repeat.json';$repeatBackup=Join-Path $root 'repeat-backup' + Public 'repeat' (@('configure')+$base+@('-Auto','-BackupPath',$repeatBackup,'-ResultsPath',$repeatPath)) + $repeat=Get-Content $repeatPath -Raw|ConvertFrom-Json + Assert ($repeat.ExitCode -eq 0 -and @($repeat.Results|Where-Object Status -eq 'Applied').Count -eq 0 -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $expected)) 'Repeated public Configure is idempotent with no native write.' + $optionalPath=Join-Path $root 'optional.json' + Public 'optional' (@('configure')+$base+@('-Auto','-IncludeOptional','-BackupPath',(Join-Path $root 'optional-backup'),'-ResultsPath',$optionalPath)) + $optional=Get-Content $optionalPath -Raw|ConvertFrom-Json;$expected[$ids.File]=3 + Assert ($optional.ExitCode -eq 0 -and (Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $expected)) 'Explicit IncludeOptional changes only File System; all other masks are preserved.' + Assert (@($optional.Results|Where-Object Status -eq 'Applied').Count -eq 1) 'Optional second stage records exactly one applied control.' + $auditPath=Join-Path $root 'audit.json' + Public 'audit' (@('audit-settings')+$base+@('-IncludeOptional','-PlanPath',$auditPath)) + $audit=Get-Content $auditPath -Raw|ConvertFrom-Json + Assert ($audit.policies.Count -eq 59 -and $audit.CustomProfileSource.Sha256 -ceq $sourceHash) 'Post-configure public Audit reads the same59 controls and source.' + Assert ((Key (Channels)) -ceq (Key $channels)) 'Advanced-audit-only configuration preserves native channel configuration.' + Assert ((Get-FileHash $profilePath).Hash -ieq $sourceHash -and (Get-FileHash $catalog).Hash -ceq $catalogHash -and (Get-FileHash $example).Hash -ceq $exampleHash) 'No input policy or canonical source file was changed.' + Save 'completed.json' @{Status='Passed';Assertions=$count;ExpectedMasks=$expected;ObservedMasks=Get-WelaEffectiveAuditPolicy;Scope='Actual public custom-profile advanced policy/precedence only; no GPO refresh, event generation or Sigma claim.'} +}catch{$failure=$_.ToString();throw}finally{ + try{ + $now=Get-WelaEffectiveAuditPolicy + foreach($guid in $before.Keys){ + if($now[$guid] -ne $before[$guid]){ + try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $before[$guid] -Mode exact}catch{$cleanupErrors+="$guid : $($_.ToString())"} + } + } + }catch{$cleanupErrors+=$_.ToString()} + try{ + if($precedence.ValueExists){$null=New-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -PropertyType $precedence.Type -Value $precedence.Value -Force} + else{Remove-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop} + }catch{$cleanupErrors+=$_.ToString()} + $masksOk=$false;$precedenceOk=$false;$channelsOk=$false + try{$masksOk=(Masks (Get-WelaEffectiveAuditPolicy)) -ceq (Masks $before);$precedenceOk=(Key (TypedPrecedence)) -ceq (Key $precedence);$channelsOk=(Key (Channels)) -ceq (Key $channels)}catch{$cleanupErrors+=$_.ToString()} + Save 'cleanup.json' @{Failure=$failure;Errors=$cleanupErrors;All59MasksRestored=$masksOk;TypedPrecedenceRestored=$precedenceOk;ChannelsPreserved=$channelsOk;Complete=($masksOk -and $precedenceOk -and $channelsOk -and -not $cleanupErrors.Count)} + if(-not $masksOk -or -not $precedenceOk -or -not $channelsOk -or $cleanupErrors.Count){throw 'Native profile fixture cleanup failed; inspect retained evidence.'} +} +Write-Host "PASS: $count public native profile configuration assertions and exact cleanup." +exit 0 diff --git a/tests/RegistrySaclFixtureNative.cs b/tests/RegistrySaclFixtureNative.cs new file mode 100644 index 00000000..f4d39905 --- /dev/null +++ b/tests/RegistrySaclFixtureNative.cs @@ -0,0 +1,82 @@ +// Disposable CI fixture only. Never imported by WELA product code. +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32; +namespace Wela.RegistrySaclFixture { + sealed class Privilege : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;} + [StructLayout(LayoutKind.Sequential)] struct Privileges {public uint Count;public Luid Id;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required); + IntPtr token;Privileges previous; + public Privilege(string name){ + if(name!="SeBackupPrivilege"&&name!="SeRestorePrivilege")throw new InvalidOperationException("Unreviewed fixture privilege."); + IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated fixture refused.");}int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try{Luid id;if(!LookupPrivilegeValue(null,name,out id))throw new Win32Exception(Marshal.GetLastWin32Error());Privileges request=new Privileges{Count=1,Id=id,Attributes=2};uint needed;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing fixture privilege is required: "+name);}catch{CloseHandle(token);token=IntPtr.Zero;throw;} + } + public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Fixture privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} + } + public sealed class WriteReceipt {public string StartedUtc,ReturnedUtc,CompletedUtc,HandleId,ValueName,Value;public int Calls;public bool Success;} + public sealed class Hive : IDisposable { + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + static readonly IntPtr HKCU=new IntPtr(unchecked((int)0x80000001)),HKU=new IntPtr(unchecked((int)0x80000003)); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegCreateKeyExW(IntPtr root,string path,int reserved,string cls,uint options,uint access,IntPtr security,out IntPtr result,out uint disposition); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string path,uint options,uint access,out IntPtr key); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSetValueExW(IntPtr key,string name,uint reserved,uint type,byte[] data,uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,byte[] data,ref uint size); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSaveKeyExW(IntPtr key,string file,IntPtr security,uint flags); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegLoadKeyW(IntPtr root,string name,string file); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegUnLoadKeyW(IntPtr root,string name); + public readonly string Nonce,Sid,SeedPath,FilePath; + public bool SeedCreated{get;private set;} public bool Saved{get;private set;} public bool Loaded{get;private set;} + public Hive(string nonce,string file){ + if(!System.Text.RegularExpressions.Regex.IsMatch(nonce??"","^[a-f0-9]{32}$"))throw new InvalidOperationException("Exact fixture nonce required."); + Nonce=nonce;Sid="S-1-5-21-"+Convert.ToUInt32(nonce.Substring(0,8),16)+"-"+Convert.ToUInt32(nonce.Substring(8,8),16)+"-"+Convert.ToUInt32(nonce.Substring(16,8),16)+"-1001"; + SeedPath="Software\\WELARegistrySaclSeed_"+nonce;FilePath=Path.GetFullPath(file); + if(File.Exists(FilePath))throw new InvalidOperationException("Fixture hive file must be new."); + } + static void Check(int status,string operation){if(status!=0)throw new Win32Exception(status,operation);} + static bool Exists(RegistryKey root,string name){using(RegistryKey key=root.OpenSubKey(name)){return key!=null;}} + public void Prepare(){ + if(SeedCreated||Saved||Loaded||Exists(Registry.Users,Sid))throw new InvalidOperationException("Fixture identity already exists."); + IntPtr key;uint disposition;Check(RegCreateKeyExW(HKCU,SeedPath,0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned seed"); + try{if(disposition!=1)throw new InvalidOperationException("Seed collided with an existing key.");SeedCreated=true; + using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath,true)){seed.SetValue("WelaFixtureOwner",Nonce,RegistryValueKind.String);seed.Flush();} + using(new Privilege("SeBackupPrivilege")){Check(RegSaveKeyExW(key,FilePath,IntPtr.Zero,2),"Save owned seed to a new hive file");Saved=true;} + }finally{RegCloseKey(key);} + if(Exists(Registry.Users,Sid))throw new InvalidOperationException("Fixture HKU mount collided."); + using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegLoadKeyW(HKU,Sid,FilePath),"Load owned hive under its fresh SID");Loaded=true;} + AssertOwned(); + } + public void AssertOwned(){using(RegistryKey key=Registry.Users.OpenSubKey(Sid)){if(!Loaded||key==null||key.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned hive marker changed.");}} + public void CreateRunOnce(){AssertOwned();IntPtr key;uint disposition;Check(RegCreateKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned catalog RunOnce target");try{if(disposition!=1)throw new InvalidOperationException("Owned target unexpectedly exists.");}finally{RegCloseKey(key);} + using(RegistryKey target=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",true)){target.SetValue("KeepTypedDword",321,RegistryValueKind.DWord);} + } + public void AssertValues(bool probe){using(RegistryKey key=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce")){if(key==null||key.ValueCount!=(probe?2:1)||key.GetValueKind("KeepTypedDword")!=RegistryValueKind.DWord||!(key.GetValue("KeepTypedDword") is int)||(int)key.GetValue("KeepTypedDword")!=321)throw new InvalidOperationException("Unrelated owned typed values changed.");if(probe&&(key.GetValueKind("WelaProbe_"+Nonce)!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaProbe_"+Nonce) as string,Nonce,StringComparison.Ordinal)))throw new InvalidOperationException("Owned nonce value mismatch.");}} + public WriteReceipt WriteProbe(){ + AssertOwned();AssertValues(false);string name="WelaProbe_"+Nonce;IntPtr key; + Check(RegOpenKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,0x103,out key),"Open owned value writer"); + try{ + byte[] bytes=System.Text.Encoding.Unicode.GetBytes(Nonce+"\0");string handle="0x"+unchecked((ulong)key.ToInt64()).ToString("x"); + DateTime start=UtcNow();Check(RegSetValueExW(key,name,0,1,bytes,(uint)bytes.Length),"Write one owned nonce REG_SZ");DateTime returned=UtcNow(); + uint type,size=(uint)bytes.Length;byte[] actual=new byte[size];Check(RegQueryValueExW(key,name,IntPtr.Zero,out type,actual,ref size),"Read back same-handle owned nonce"); + if(type!=1||size!=bytes.Length||Convert.ToBase64String(actual)!=Convert.ToBase64String(bytes))throw new InvalidOperationException("Probe write readback failed.");DateTime completed=UtcNow(); + return new WriteReceipt{StartedUtc=start.ToString("o"),ReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),HandleId=handle,ValueName=name,Value=Nonce,Calls=1,Success=true}; + }finally{RegCloseKey(key);} + } + public void Dispose(){ + if(Loaded){AssertOwned();using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegUnLoadKeyW(HKU,Sid),"Unload owned fixture hive");Loaded=false;}} + if(SeedCreated){using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath)){if(seed==null||seed.SubKeyCount!=0||seed.ValueCount!=1||seed.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(seed.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned seed changed; refuse deletion.");}Registry.CurrentUser.DeleteSubKey(SeedPath,true);SeedCreated=false;} + } + } +} diff --git a/tests/RegistrySaclLifecycle.Tests.ps1 b/tests/RegistrySaclLifecycle.Tests.ps1 new file mode 100644 index 00000000..406893d1 --- /dev/null +++ b/tests/RegistrySaclLifecycle.Tests.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent +. (Join-Path $root 'scripts/WefArrival.ps1');. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1') +$operation=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';NativePath='\REGISTRY\USER\S-1-5-21-1-2-3-1001\Software\Microsoft\Windows\CurrentVersion\RunOnce';RecordIdBefore=10;Token=[pscustomobject]@{Sid='S-1-5-21-4-5-6-500';AuthenticationId='0x1234'};Write=[pscustomobject]@{ValueName='WelaProbe_nonce';Value='nonce';HandleId='0x456';StartedUtc='2026-09-22T00:00:00.0001000Z';ReturnedUtc='2026-09-22T00:00:00.0001500Z';CompletedUtc='2026-09-22T00:00:00.0002000Z'}} +$xml=@" +46570128010x8020000000000000SecurityFIXTURE11S-1-5-21-4-5-6-500ReaderFIXTURE0x1234$($operation.NativePath)WelaProbe_nonce0x456%%1904--%%1873nonce0x4d2$($operation.Engine) +"@ +$count=0 +function Assert($value,$message){if(-not $value){throw $message};$script:count++} +Assert (Test-WelaRegistrySaclFixtureEvent $xml $operation) 'Exact native-schema creation must match the measured write/readback phase.' +foreach($change in @(@('4657','4663'),@('%%1904','%%1905'),@('%%1873','%%1874'),@('0x1234','0x1235'),@('0x456','0x457'),@('0x4d2','0x4d3'),@('6-500','6-501'),@('RunOnce','Other'),@('>nonce<','>other<'),@('0001800Z','0000999Z'),@('0001800Z','0002001Z'),@('EventRecordID>11','EventRecordID>10'),@('12801','12800'),@('8020000000000000','8010000000000000'))){Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace($change[0],$change[1]) $operation)) ('Changed attribution must fail: '+$change[0])} +Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace('','0x4d2') $operation)) 'Duplicate identity fields must fail.' +Assert (-not(Test-WelaRegistrySaclFixtureEvent (']>'+$xml) $operation)) 'DTD input must fail.' +Write-Host "Passed $count registry SACL fixture evidence assertions." diff --git a/tests/RegistrySaclLifecycle.Windows.Tests.ps1 b/tests/RegistrySaclLifecycle.Windows.Tests.ps1 new file mode 100644 index 00000000..d011cdd5 --- /dev/null +++ b/tests/RegistrySaclLifecycle.Windows.Tests.ps1 @@ -0,0 +1,147 @@ +# Mutating test fixture only: public WELA never loads hives or prepares audit policy. +param([switch]$AllowDisposableHiveWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableHiveWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted native Windows fixture only.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1') +Initialize-WelaWmiProbeNative +Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop +$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-sacl-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot +$files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files +function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))} +function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))} +function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +$script:assertions=0 +function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++} +function Invoke-PublicFixture([string]$Name,[string[]]$Arguments,[int]$ExpectedExit=0,[string]$Diagnostic=''){ + $old=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') targeted-sacl @Arguments -ResultsPath (Join-Path $root ($Name+'.json')) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0} + Save ($Name+'-output.json') @($output|ForEach-Object {[string]$_}) + Assert ($code -eq $ExpectedExit) ("Public $Name exited $code : "+($output -join ' ')) + if($Diagnostic){Assert (($output -join ' ') -match $Diagnostic) ("Public $Name did not report the expected refusal: "+($output -join ' '))} + if($ExpectedExit -eq 0){Read-Receipt ($Name+'.json')} +} +function Assert-SelectedRow($Plan,[string]$Status){ + Assert ($Plan.Kind -is [string] -and $Plan.Kind -ceq 'WelaSelectedSaclPlan' -and @($Plan.Rows).Count -eq 1 -and $Plan.Rows[0].Id -is [string] -and $Plan.Rows[0].Id -ceq $selected.Id -and $Plan.Rows[0].Status -is [string] -and $Plan.Rows[0].Status -ceq $Status) ('Exact public selected row must be '+$Status) + Assert ($Plan.Rows[0].Definition.UserSid -ceq $hive.Sid -and $Plan.Rows[0].Definition.Path -ieq $providerPath -and $Plan.GenerationReadiness -ceq 'Conditional' -and $Plan.UsableRuleCredit -eq 0) 'Public plan must remain bound to the owned target without generation/Sigma credit.' +} +function Assert-PreparedState { + Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks) 'Public selected-SACL calls must preserve all 59 prepared audit masks.' + Assert ((Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Public selected-SACL calls must preserve typed precedence.' + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Current process token groups and privilege attributes must remain exact.' + $hive.AssertValues($false) +} +$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030' +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy' +$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName +Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence +$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false +try { + Assert ($beforeMasks.Count -eq 59) 'All 59 native audit subcategories must be observed before fixture mutation.' + $hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false) + Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only the fresh owned SID hive may appear in HKU.' + $providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce' + Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Loaded=$hive.Loaded;Target=$providerPath}) + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Fixture save/load must restore existing backup/restore privilege attributes.' + $catalog=Invoke-PublicFixture 'catalog' @('-TargetSaclProfile','asd-native-2021-10','-IncludeOptional') + $selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath}) + Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Id -cmatch '^sacl-[a-f0-9]{24}$' -and $selectedRows[0].Definition.Resolution -ceq 'Resolved') 'Actual public catalog must resolve exactly one owned registry target.' + $selected=$selectedRows[0];Save 'selected.json' $selected + # Seed a distinct explicit SYSTEM QueryValue audit ACE to prove additive preservation. + Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null + try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$original=$target.Read();$seeded=$target.Add($original.Identity,$original.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()} + Save 'before-public-snapshot.json' $seeded + $policyTouched=$true + Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1 + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact + $preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName) + $selection=@('-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional') + $noConsent=Invoke-PublicFixture 'no-consent-plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $noConsent 'Blocked' + $refusedBackup=Join-Path $root 'refused-no-consent' + Invoke-PublicFixture 'no-consent-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'no-consent-plan.json'),'-BackupPath',$refusedBackup,'-Auto')) 1 'inheritance requires explicit' + Assert (-not(Test-Path -LiteralPath $refusedBackup)) 'Missing inheritance consent must fail before journal creation.' + $selection+=@('-TargetSaclIncludeChildren') + $plan=Invoke-PublicFixture 'plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $plan 'ChangeRequired' + Assert ($plan.Rows[0].DescendantsBefore.Status -ceq 'Complete' -and @($plan.Rows[0].DescendantsBefore.Entries).Count -eq 0) 'Owned RunOnce must have a complete empty descendant capture.' + Assert ((Get-WelaSelectedSaclSnapshotKey $plan.Rows[0].Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'Read-only planning must preserve the entire native target descriptor/identity.' + $configure=$selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'plan.json'),'-Auto') + $dry=Invoke-PublicFixture 'dry-run' ($configure+@('-DryRun')) + Assert ($dry.Kind -ceq 'WelaSelectedSaclResult' -and $dry.DryRun -is [bool] -and $dry.DryRun -and $dry.Results[0].Status -is [string] -and $dry.Results[0].Status -ceq 'Skipped' -and $null -eq $dry.BackupPath) 'Public DryRun must skip mutation and journal creation.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'DryRun must leave the exact native descriptor unchanged.' + Assert-PreparedState + $backup=Join-Path $root 'applied-journal' + $applied=Invoke-PublicFixture 'applied' ($configure+@('-BackupPath',$backup)) + Assert ($applied.Results.Count -eq 1 -and $applied.Results[0].Status -is [string] -and $applied.Results[0].Status -ceq 'Applied' -and $applied.GenerationReadiness -ceq 'Conditional' -and $applied.UsableRuleCredit -eq 0) 'Exactly the owned target must be Applied without event or rule credit.' + $after=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'after-public-snapshot.json' $after + Assert-WelaSelectedSaclPreserved $seeded $after $plan.Rows[0].Ace + Assert ($after.Aces.Count -eq $seeded.Aces.Count+1) 'Public Configure must append exactly one ACE and preserve the unrelated explicit audit ACE.' + Assert ((Get-WelaSelectedSaclSnapshotKey $after) -ceq (Get-WelaSelectedSaclSnapshotKey $applied.Results[0].After)) 'Public result must match independent native final readback.' + $pending=Read-Receipt ('applied-journal/'+$selected.Id+'.pending.json');$confirmed=Read-Receipt ('applied-journal/'+$selected.Id+'.confirmed.json');$desc=Read-Receipt ('applied-journal/'+$selected.Id+'.descendants-observed.json') + Assert ($pending.State -is [string] -and $pending.State -ceq 'Pending' -and $null -eq $pending.After -and $confirmed.State -is [string] -and $confirmed.State -ceq 'Confirmed' -and $pending.Id -ceq $selected.Id -and $confirmed.Id -ceq $selected.Id -and $desc.Verification.Status -ceq 'Observed') 'Durable pending/confirmed and descendant receipts must name the exact selected target.' + Assert ((Get-WelaSelectedSaclSnapshotKey $pending.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded) -and (Get-WelaSelectedSaclSnapshotKey $confirmed.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Journal snapshots must agree with both independent native observations.' + Assert-PreparedState + $staleBackup=Join-Path $root 'refused-stale' + Invoke-PublicFixture 'stale-configure' ($configure+@('-BackupPath',$staleBackup)) 1 'changed; review a new plan' + Assert (-not(Test-Path -LiteralPath $staleBackup)) 'Replaying the old descriptor must fail before another journal.' + $fresh=Invoke-PublicFixture 'idempotent-plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $fresh 'AlreadyCompliant' + $idemBackup=Join-Path $root 'idempotent-journal' + $idempotent=Invoke-PublicFixture 'idempotent' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$idemBackup,'-Auto')) + Assert ($idempotent.Results[0].Status -is [string] -and $idempotent.Results[0].Status -ceq 'AlreadyCompliant' -and @(Get-ChildItem -LiteralPath $idemBackup -Force).Count -eq 0) 'Fresh idempotent public Configure must make no write receipts.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Stale refusal and idempotent Configure must preserve exact native state.' + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact + $blocked=Invoke-PublicFixture 'missing-policy-plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $blocked 'Blocked' + $policyBackup=Join-Path $root 'refused-policy' + Invoke-PublicFixture 'missing-policy-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$policyBackup,'-Auto')) 1 'outcomes are not already effective' + Assert (-not(Test-Path -LiteralPath $policyBackup) -and (Get-WelaEffectiveAuditPolicy)[$guid] -eq 0) 'Public Configure must refuse ineffective auditing without preparing policy or a journal.' + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact + Assert-PreparedState + $boundary=Read-WelaChannelLatest 'Security';Save 'security-boundary.json' $boundary + Assert ($boundary.Status -ceq 'EventObserved' -and $boundary.Event.RecordId -gt 0) 'Actual Security watermark must be observed before the single value write.' + $operation=[pscustomobject]@{Phase='OneRegSetValueAndSameHandleTypedReadback';Computer=$boundary.Event.Computer;ProcessId=$PID;Engine=$engine;NativePath=('\REGISTRY\USER\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce');RecordIdBefore=$boundary.Event.RecordId;Token=[Wela.WmiProbe.Native]::Snapshot();Write=$hive.WriteProbe();ObservedUtc=[Wela.WmiProbe.Native]::UtcNow().ToString('o')} + Save 'operation.json' $operation + Assert ($operation.Write.Calls -eq 1 -and $operation.Write.Success -is [bool] -and $operation.Write.Success -and (ConvertTo-WelaArrivalUtc $operation.Write.StartedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -le (ConvertTo-WelaArrivalUtc $operation.ObservedUtc)) 'Exactly one native write and its same-handle typed readback must have ordered measured times.' + $hive.AssertValues($true) + $found=@{};$candidates=@{};$deadline=[DateTime]::UtcNow.AddSeconds(20) + $xpath="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4657 and EventRecordID > $($boundary.Event.RecordId)]] and *[EventData[Data[@Name='ObjectName']='$($operation.NativePath)']]" + do { + $events=@();try{$events=@(Get-WinEvent -LogName Security -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notmatch 'NoMatchingEventsFound'){throw}} + try { + if($events.Count -ge 256){throw 'Owned-target native event query reached its bound.'} + foreach($event in $events){$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Owned-target event exceeds its XML bound.'};$candidates[[string]$event.RecordId]=$xml;if(Test-WelaRegistrySaclFixtureEvent $xml $operation){$found[[string]$event.RecordId]=$xml}} + }finally{foreach($event in $events){if($event -is [IDisposable]){$event.Dispose()}}} + if($found.Count -eq 0){Start-Sleep -Milliseconds 250} + }while($found.Count -eq 0 -and [DateTime]::UtcNow -lt $deadline) + Save 'event-candidates.json' $candidates + Assert ($found.Count -eq 1) ('Expected exactly one attributable native4657; candidates='+$candidates.Count+' matches='+$found.Count) + [IO.File]::WriteAllText((Join-Path $root 'event.xml'),[string]@($found.Values)[0],[Text.UTF8Encoding]::new($false)) + Assert ((Get-WelaSelectedSaclSnapshot $selected.Definition).DescriptorBase64 -ceq $after.DescriptorBase64) 'The value operation must preserve every native descriptor section.' + Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Observed event delivery must not alter prepared auditing.' + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'The actual native value operation must preserve the full primary token.' +}catch{$failure=$_}finally { + if($policyTouched){ + try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message} + try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message} + } + try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message} + $hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false + $afterHives=$null;$afterToken=$null;$masks=$null;$afterPrecedence=$null + try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='HKU verification: '+$_.Exception.Message} + try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message} + try{$masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message} + try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message} + if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}} + $cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$masks;AfterPrecedence=$afterPrecedence} + Save 'cleanup.json' $cleanup + $artifacts=@(Get-ChildItem -LiteralPath $root -Recurse -File |Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) + Save 'artifact-hashes.json' $artifacts +} +if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))} +Write-Host "Passed $script:assertions actual public registry SACL lifecycle assertions and one exact4657; all cleanup confirmed. Evidence: $root" +$global:LASTEXITCODE=0 diff --git a/tests/RegistrySaclLifecycleEvidence.ps1 b/tests/RegistrySaclLifecycleEvidence.ps1 new file mode 100644 index 00000000..3adcc2f7 --- /dev/null +++ b/tests/RegistrySaclLifecycleEvidence.ps1 @@ -0,0 +1,21 @@ +# Test-only attribution for the single fixture-owned REG_SZ creation. +function Test-WelaRegistrySaclFixtureEvent { + param([string]$Xml,$Operation) + $reader=$null + try { + if($Xml.Length -gt 131072){return $false} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','Task','Keywords','Channel','Computer','EventRecordID','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4657' -or $system.Version.InnerText -cne '0' -or $system.Task.InnerText -cne '12801' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Computer.InnerText -ine $Operation.Computer -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Write.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Write.CompletedUtc)){return $false} + $fields=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $fields.ContainsKey($name)){return $false};$fields[$name]=$node.InnerText} + if($fields.Count -ne 14){return $false};foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectName','ObjectValueName','HandleId','OperationType','OldValueType','OldValue','NewValueType','NewValue','ProcessId','ProcessName')){if(-not $fields.ContainsKey($name)){return $false}} + if($fields.SubjectUserSid -cne $Operation.Token.Sid -or $fields.ObjectName -ine $Operation.NativePath -or $fields.ObjectValueName -cne $Operation.Write.ValueName -or $fields.OperationType -cne '%%1904' -or $fields.NewValueType -cne '%%1873' -or $fields.NewValue -cne $Operation.Write.Value -or $fields.ProcessName -ine $Operation.Engine){return $false} + foreach($name in @('SubjectLogonId','ProcessId','HandleId')){if($fields[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} + if([Convert]::ToUInt64($fields.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Token.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($fields.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($fields.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Write.HandleId.Substring(2),16)){return $false} + $true + }catch{$false}finally{if($reader){$reader.Dispose()}} +} diff --git a/tests/WecAuthorization.Cli.Tests.ps1 b/tests/WecAuthorization.Cli.Tests.ps1 new file mode 100644 index 00000000..10e148d2 --- /dev/null +++ b/tests/WecAuthorization.Cli.Tests.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-auth-cli-'+[guid]::NewGuid().ToString('N')) +$cases=@( + @{Args=@('wec-authorization','-Help');Code=0;Pattern='already disabled'}, + @{Args=@('configure','-WecAuthorizationAction','Apply','-Auto');Code=1;Pattern='require wec-authorization'}, + @{Args=@('wec-authorization','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-authorization','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-authorization','-WhatIf');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-authorization','-Typo');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-authorization','-Help','-WecStateDesired','Enabled');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-authorization','-ResultsPath',$root);Code=1;Pattern='only dedicated'}, + @{Args=@('wec-authorization','-WecAuthorizationOutputPath',$root);Code=1;Pattern='Plan requires'}, + @{Args=@('wec-authorization','-WecAuthorizationId','test','-WecAuthorizationSourceSid','S-1-5-21-1-2-3-4','-WecAuthorizationPlanPath','missing','-WecAuthorizationOutputPath',$root);Code=1;Pattern='Plan requires'}, + @{Args=@('wec-authorization','-WecAuthorizationAction','Apply','-WecAuthorizationOutputPath',$root);Code=1;Pattern='reviewed plan'}, + @{Args=@('wec-authorization','-WecAuthorizationAction','Apply','-WecAuthorizationPlanPath','missing','-WecAuthorizationPlanHash',('a'*64),'-WecAuthorizationId','test','-WecAuthorizationOutputPath',$root);Code=1;Pattern='only'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++} +if(Test-Path $root){throw 'Refused CLI input unexpectedly created output.'} +Write-Host "PASS: $count WEC authorization public CLI guards." +$global:LASTEXITCODE=0 diff --git a/tests/WecAuthorization.Tests.ps1 b/tests/WecAuthorization.Tests.ps1 new file mode 100644 index 00000000..bdc8198a --- /dev/null +++ b/tests/WecAuthorization.Tests.ps1 @@ -0,0 +1,108 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/WecAuthorization.ps1" +Initialize-WelaWecAuthorizationNative +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action,[string]$Pattern='.'){$m='';try{&$Action|Out-Null}catch{$m=$_.Exception.Message};Assert ($m -match $Pattern) "Expected $Pattern; got $m; value=$bad; action=$Action"} +function Copy-Auth($Value){Get-WelaWecAuthorizationKey $Value|ConvertFrom-Json} +$sidA='S-1-5-21-11-22-33-1001';$sidB='S-1-5-21-11-22-33-1002';$id='WELA Native Security Example' +$authA=Get-WelaWefAuthorization @($sidA);$authB=Get-WelaWefAuthorization @($sidB);$authAB=Get-WelaWefAuthorization @($sidA,$sidB) +$base=[IO.File]::ReadAllText("$repo/config/wef-examples/native-security.xml").Replace('true','false').Replace('',(''+$authA+'')) +foreach($bad in @('S-1-1-0','S-1-5-20','s-1-5-21-11-22-33-1001','S-1-5-21-011-22-33-1001','S-1-5-21-4294967296-22-33-1001','S-1-5-21-11-22-33','S-1-5-21-11-22-33-1001 ',1,$true,$null)){Reject {Get-WelaWecAuthorizationSids @($bad)}} +Reject {Get-WelaWecAuthorizationSids @()};Reject {Get-WelaWecAuthorizationSids @($sidA,$sidA)} 'Duplicate';Reject {Get-WelaWecAuthorizationSids @($sidA*33)} +Assert ((Get-WelaWecAuthorizationKey @(Get-WelaWecAuthorizationSids @($sidB,$sidA))) -ceq (Get-WelaWecAuthorizationKey @($sidA,$sidB))) 'SID order is canonical' +foreach($good in @($authA,$authAB)){[Wela.WecAuthorization.Edit]::ValidateAuthorization($good);$count++} +foreach($bad in @('',$authA.Replace('GA','GR'),$authA.Replace('NSG:NS','SYG:SY'),($authA+$authA),$authA.Replace('11-22','011-22'),$authA.Replace('11-22','4294967296-22'),$authAB.Replace($sidB,$sidA),('O:NSG:NSD:(A;;GA;;;'+$sidB+')(A;;GA;;;'+$sidA+')'),($authA+'S:(AU;SA;GA;;;WD)'))){Reject {[Wela.WecAuthorization.Edit]::ValidateAuthorization($bad)}} +Assert ([Wela.WecAuthorization.Edit]::SourceSha256 -ceq (Get-WelaArrivalHash ([IO.File]::ReadAllBytes("$repo/scripts/WecAuthorizationNative.cs")))) 'Compiled native helper binds the exact source bytes' +# Allocated EC_VARIANT buffers exercise the WEC ABI rather than EVT_VARIANT values. +$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64) +try { + for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,2) + Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]0) 'EcVarTypeUInt32 is 2 and source-initiated value is zero' + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,1) + Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]1) 'Collector-initiated scalar remains distinguishable' + foreach($type in @(0,1,4,8,130)){[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,27)} 'UInt32'} + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,1) + Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $true) 'Native scalar Boolean decodes true' + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,0) + Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $false) 'Native scalar Boolean decodes false' + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,2);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,0)} 'Boolean' + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,4) + [Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16));[Runtime.InteropServices.Marshal]::WriteInt16($buffer,16,65) + Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,20,31) -ceq 'A') 'String data is decoded within returned bounds' + Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,18,31)} 'Unterminated' + [Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,64));Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'outside' + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,132);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'scalar' + Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,15,31)} 'buffer';Reject {[Wela.WecAuthorization.Edit]::Decode([IntPtr]::Zero,16,31)} 'buffer' + Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,1)} 'selection' + [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,0) + Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,6) -ceq '') 'Absent description normalizes to empty' + Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,31)} 'scalar' +}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} +$before=Get-WelaWecAuthorizationDefinition $base;$after=Get-WelaWecAuthorizationDefinition ($base.Replace($authA,$authAB)) +Assert ($before.SourceSids.Count -eq 1 -and $after.SourceSids.Count -eq 2 -and $before.PreservedKey -ceq $after.PreservedKey -and $before.WholeKey -cne $after.WholeKey) 'Only the explicit allow list is excluded from preserved XML' +foreach($bad in @($base.Replace('SourceInitiated','CollectorInitiated'),$base.Replace('>false','>true'),$base.Replace($authA,'D:(A;;GA;;;WD)'),$base.Replace($authA,''),$base.Replace($authA,$authAB.Replace($sidB,$sidA)),$base.Replace('Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"'),$base.Replace('','bad'))){Reject {Get-WelaWecAuthorizationDefinition $bad}} +$reader=[pscustomobject][ordered]@{ProcessId=10;TokenId='1';ModifiedId='2';UserSid=$sidA;AuthenticationId='3';ElevatedAdministrator=$true;GroupSids=@('S-1-5-32-544')} +$context=[pscustomobject][ordered]@{Host='TEST';Reader=$reader;Services='Running';Destination='unchanged'} +$copy=Copy-Auth $context;$copy.Reader.ProcessId=11;$copy.Reader.TokenId='4';$copy.Reader.ModifiedId='5';Assert ((Get-WelaWecAuthorizationReviewKey $copy) -ceq (Get-WelaWecAuthorizationReviewKey $context)) 'Separate same-logon CLI processes can use a reviewed plan' +$copy.Reader.AuthenticationId='6';Assert ((Get-WelaWecAuthorizationReviewKey $copy) -cne (Get-WelaWecAuthorizationReviewKey $context)) 'Different logon is not accepted' +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-auth-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$script:xml=$base;$script:mode='ok';$script:reads=0;$script:contextReads=0;$script:saves=0;$script:pending='' +function Get-WelaWecAuthorizationContext {$script:contextReads++;$v=Copy-Auth $context;if($script:mode -eq 'token-drift' -and $script:contextReads -gt 1){$v.Reader.ModifiedId='drift'};$v} +function Read-WelaWecAuthorizationDefinition {param($Id);$script:reads++;if($script:mode -eq 'drift' -and $script:reads -eq 2){$script:xml=$script:xml.Replace('MinLatency','Normal')};if($script:mode -eq 'denied'){throw 'Native access denied'};Get-WelaWecAuthorizationDefinition $script:xml} +function Read-WelaWecSubscriptionXml {param($Id);$script:xml} +function New-WelaWecAuthorizationEdit { + param($Before) + $edit=[pscustomobject]@{SaveAttempted=$false} + $edit|Add-Member ScriptMethod Save {param($Authorization) + Assert (Test-Path $script:pending) 'Pending artifact exists before native call' + $pending=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($script:pending));Assert ($pending.Status -ceq 'Pending' -and $pending.DesiredAuthorization -ceq $Authorization) 'Durable intent states exact allow list' + if($script:mode -eq 'native-refusal'){throw 'Native current view differs'} + $this.SaveAttempted=$true;$script:saves++ + if($script:mode -eq 'native-error'){throw 'Native save failed'} + if($script:mode -eq 'false-success'){return} + $doc=Read-WelaWefXml $script:xml;$doc.Subscription.AllowedSourceDomainComputers=$Authorization + if($script:mode -eq 'preservation'){$doc.Subscription.ReadExistingEvents='true'} + if($script:mode -eq 'unexpected-enabled'){$doc.Subscription.Enabled='true'} + $script:xml=$doc.OuterXml + if($script:mode -eq 'artifact-drift'){[IO.File]::AppendAllText($script:pending,' ')} + } + $edit|Add-Member ScriptMethod Dispose {if($script:mode -eq 'cleanup-error'){throw 'Handle cleanup failed'}} + $edit +} +try { + Reject {Invoke-WelaWecAuthorization -Id $id -SourceSids @($sidA) -PlanHash ('a'*64) -OutputPath (Join-Path $root 'bad')} 'Plan requires' + Reject {Invoke-WelaWecAuthorization Apply -PlanPath missing -PlanHash ('a'*64) -Id '' -OutputPath (Join-Path $root 'bad')} 'only' + Reject {Invoke-WelaWecAuthorization -Id $id -SourceSids @($sidA) -WhatIf -OutputPath (Join-Path $root 'bad')} 'Unknown' + foreach($scenario in @('ok','no-op','hash','schema','kind','duplicate-json','context','source','stale','enabled','denied','drift','token-drift','native-refusal','native-error','false-success','preservation','unexpected-enabled','artifact-drift','cleanup-error')){ + $script:mode='ok';$script:xml=$base;$script:reads=0;$script:contextReads=0;$script:saves=0 + $desired=if($scenario -eq 'no-op'){@($sidA)}else{@($sidA,$sidB)} + $planned=Invoke-WelaWecAuthorization -Id $id -SourceSids $desired -OutputPath (Join-Path $root ($scenario+'-plan')) + Assert ($planned.Status -ceq 'ReviewRequired' -and $planned.ExitCode -eq 0 -and -not $planned.NativeSaveAttempted -and $script:saves -eq 0) "Plan: $($planned.Diagnostic)" + $path=Join-Path $planned.OutputPath 'plan.json';$hash=$planned.PlanHash + if($scenario -eq 'hash'){$hash='f'*64} + if($scenario -in @('schema','kind','duplicate-json','context','source')){ + $text=[IO.File]::ReadAllText($path) + switch($scenario){schema{$text=$text -replace '"SchemaVersion"\s*:\s*1','"SchemaVersion":true'}kind{$text=$text -replace '"Kind"\s*:\s*"WelaWecAuthorizationPlan"','"Kind":true'}duplicate-json{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}context{$text=$text.Replace('TEST','OTHER')}source{$text=$text.Replace('scripts/WecAuthorization.ps1','scripts/other.ps1')}} + [IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant() + } + if($scenario -eq 'stale'){$script:xml=$base.Replace('MinLatency','Normal')};if($scenario -eq 'enabled'){$script:xml=$base.Replace('>false','>true')} + $script:mode=$scenario;$script:reads=0;$script:contextReads=0;$out=Join-Path $root ($scenario+'-apply');$script:pending=Join-Path $out 'before-save.json' + $applied=Invoke-WelaWecAuthorization Apply -PlanPath $path -PlanHash $hash -OutputPath $out + Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','no-op'))) "Scenario $scenario : $($applied.Diagnostic)" + Assert ($applied.ReadyRuleCredit -eq 0 -and (Test-Path (Join-Path $out 'manifest.json'))) 'No readiness credit and final result retained' + if($scenario -in @('native-error','false-success','preservation','unexpected-enabled','artifact-drift','cleanup-error')){Assert ($applied.NativeSaveAttempted -and $applied.Status -ceq 'SaveAttemptedUnverified') 'Possible persistent change remains unverified'}elseif($scenario -notin @('ok','no-op')){Assert (-not $applied.NativeSaveAttempted -and $script:saves -eq 0 -and $applied.Status -ceq 'Refused') 'Rejected before native save'} + if($scenario -eq 'no-op'){Assert ($applied.Status -ceq 'AlreadyMatches' -and -not $applied.NativeSaveAttempted -and $script:saves -eq 0) 'No-op never saves'} + if($scenario -eq 'ok'){ + Assert ($applied.Status -ceq 'AuthorizationChangedAndVerified' -and $applied.NativeSaveAttempted -and $applied.After.PreservedKey -ceq $before.PreservedKey) 'Successful update changes only explicit authorization' + $again=Invoke-WelaWecAuthorization Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay');Assert ($again.Status -ceq 'Refused' -and -not $again.NativeSaveAttempted -and $script:saves -eq 1) 'Changed pre-state refuses stale plan' + } + if($scenario -ne 'artifact-drift'){foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained artifact hash matches bytes'}} + } +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count focused WEC authorization assertions; no native delivery proof." diff --git a/tests/WecAuthorization.Windows.Tests.ps1 b/tests/WecAuthorization.Windows.Tests.ps1 new file mode 100644 index 00000000..a774a78b --- /dev/null +++ b/tests/WecAuthorization.Windows.Tests.ps1 @@ -0,0 +1,107 @@ +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 +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/ChannelRead.ps1" +. "$repo/scripts/WecAuthorization.ps1" +$count=0;$engine=(Get-Process -Id $PID).Path +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){Get-WelaWecAuthorizationKey $Value} +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 EnableChannel([bool]$Value){$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{$c.IsEnabled=$Value;$c.SaveChanges()}finally{$c.Dispose()}} +Add-Type -Path (Join-Path $PSScriptRoot 'WecAuthorizationFixtureNative.cs') +function Ids {[Wela.WecAuthorizationFixture.Inventory]::Read()|Sort-Object} +function Inventory {$ids=@(Ids);Save 'last-observed-ids.json' $ids;@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})} +$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Authorization-'+$nonce;$description='Owned authorization '+$nonce+' '+[char]0x65e5+[char]0x672c +$sidA='S-1-5-21-111111111-222222222-333333333-1234';$sidB='S-1-5-21-111111111-222222222-333333333-1235' +$root=Join-Path $env:RUNNER_TEMP ('wela-wec-authorization-'+$nonce);$null=New-Item -ItemType Directory $root +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +# Native -File argument binding cannot portably carry a string[] on both engines. +# This fixture wrapper supplies the selected array to the actual public script. +$wrapper=Join-Path $root 'invoke-public.ps1' +@' +param([string]$WelaPath,[string]$Action,[string]$Id,[string]$Sids,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath) +$ErrorActionPreference='Stop' +$p=@{Cmd='wec-authorization';WecAuthorizationAction=$Action;WecAuthorizationOutputPath=$OutputPath} +if($PSBoundParameters.ContainsKey('Id')){$p.WecAuthorizationId=$Id} +if($PSBoundParameters.ContainsKey('Sids')){$p.WecAuthorizationSourceSid=@($Sids.Split(';'))} +if($PSBoundParameters.ContainsKey('PlanPath')){$p.WecAuthorizationPlanPath=$PlanPath} +if($PSBoundParameters.ContainsKey('PlanHash')){$p.WecAuthorizationPlanHash=$PlanHash} +$global:LASTEXITCODE=0 +& $WelaPath @p +exit $LASTEXITCODE +'@|Set-Content -LiteralPath $wrapper -Encoding UTF8 +function Public([string[]]$Arguments,[string]$Output,[bool]$Success=$true){ + $prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File $wrapper -WelaPath "$repo/WELA.ps1" @Arguments -OutputPath $Output 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + if(($code -eq 0) -ne $Success){Write-Host ($text -join "`n");Get-ChildItem $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host (Get-Content $_.FullName -Raw)};throw "Public command returned $code"};$script:count++ + $result=Get-Content -LiteralPath (Join-Path $Output 'manifest.json') -Raw|ConvertFrom-Json + foreach($a in $result.Artifacts){Assert ((Get-FileHash (Join-Path $Output $a.Name)).Hash.ToLowerInvariant() -ceq $a.Sha256) 'Public evidence hash matches actual bytes'} + $result +} +$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart +$original=$null;$created=$false;$failure=$null;$cleanupErrors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$endServices=$null;$endChannel=$null;$endDelayed=$null +Save 'before-fixture.json' @{Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed} +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 service state required' + if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc} + if(-not $beforeChannel.Enabled){EnableChannel $true} + Save 'original-console-enumeration.json' (Invoke-WelaNative 'wecutil.exe' @('es')) + $original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Ids) -notcontains $id) 'Unique owned subscription is initially absent' + $query='' + $xml=@" +$idSourceInitiated$descriptionfalsehttp://schemas.microsoft.com/wbem/wsman/1/windows/EventLogNormalfalseHTTPEventsForwardedEvents$(Get-WelaWefAuthorization @($sidA)) +"@ + $path=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false));$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$path) + $before=Read-WelaWecAuthorizationDefinition $id;[IO.File]::WriteAllText((Join-Path $root 'original.xml'),$before.Xml,[Text.UTF8Encoding]::new($false));$duringServices=Services;$duringChannel=Channel + Initialize-WelaWecAuthorizationNative + $missing=$false;try{$e=[Wela.WecAuthorization.Edit]::new($id+'-missing');$e.Dispose()}catch{$missing=$true};Assert ($missing -and @(Ids) -notcontains ($id+'-missing')) 'Native existing-only handle never creates a missing ID' + $index=0 + foreach($desired in @(@($sidA),@($sidA,$sidB),@($sidA,$sidB),@($sidB),@($sidA))){ + $index++;$prior=Read-WelaWecAuthorizationDefinition $id;$changed=$prior.Authorization -cne (Get-WelaWefAuthorization $desired) + $plan=Public @('-Action','Plan','-Id',$id,'-Sids',($desired -join ';')) (Join-Path $root "plan-$index") + Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.NativeSaveAttempted -and (Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $prior.WholeKey) 'Actual public Plan preserved original native definition' + $planPath=Join-Path $plan.OutputPath 'plan.json' + if($index -eq 2){$bad=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',('f'*64)) (Join-Path $root 'bad-hash') $false;Assert ($bad.Status -ceq 'Refused' -and -not $bad.NativeSaveAttempted) 'Wrong hash refuses before native save'} + $apply=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',$plan.PlanHash) (Join-Path $root "apply-$index") + Assert ($apply.NativeSaveAttempted -eq $changed -and $apply.Status -ceq $(if($changed){'AuthorizationChangedAndVerified'}else{'AlreadyMatches'})) 'Only a changed allow list saves' + $after=Read-WelaWecAuthorizationDefinition $id + Assert ($after.Authorization -ceq (Get-WelaWefAuthorization $desired) -and $after.PreservedKey -ceq $before.PreservedKey -and $apply.ReadyRuleCredit -eq 0) 'Actual disabled definition changes only selected authorization; no readiness credit' + if($index -eq 2){$stale=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',$plan.PlanHash) (Join-Path $root 'stale') $false;Assert ($stale.Status -ceq 'Refused' -and -not $stale.NativeSaveAttempted) 'Stale pre-state plan refuses replay'} + } + Assert ((Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $before.WholeKey) 'Fresh public plan restored complete original subscription' + # Direct native fresh-handle guard checks a concurrently changed description. + $edit=New-WelaWecAuthorizationEdit $before + try{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift')));$refused=$false;try{$edit.Save((Get-WelaWefAuthorization @($sidB)))}catch{$refused=$true};Assert ($refused -and -not $edit.SaveAttempted) 'Native guard refuses a changed current definition before save'}finally{$edit.Dispose();$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))} + try{ + $null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,'/e:true') + $enabled=Public @('-Action','Plan','-Id',$id,'-Sids',$sidB) (Join-Path $root 'enabled-refusal') $false + Assert ($enabled.Status -ceq 'Refused' -and -not $enabled.NativeSaveAttempted) 'Public command refuses actual enabled subscription' + }finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,'/e:false')} + Assert ((Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $before.WholeKey) 'Fixture drift and enabled-state probes restored full original XML' + Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $duringChannel)) 'Product preserves services and complete channel configuration' + [IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false)) + Write-Host "PASS: $count actual WEC authorization assertions on $($PSVersionTable.PSVersion). No real source or forwarding proof." +}catch{$failure=$_.ToString();Write-Host $failure}finally{ + try{ + if($created -and @(Ids) -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{$cleanupErrors+=$_.ToString()} + try{if((Channel).Enabled -ne $beforeChannel.Enabled){EnableChannel $beforeChannel.Enabled};$endChannel=Channel;$channelOk=(Key $endChannel) -ceq (Key $beforeChannel)}catch{$cleanupErrors+=$_.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}} + $endServices=Services;$endDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart;$servicesOk=(Key $endServices) -ceq (Key $beforeServices) -and (Key $endDelayed) -ceq (Key $beforeDelayed) + }catch{$cleanupErrors+=$_.ToString()} + Save 'after-fixture.json' @{Services=$endServices;Channel=$endChannel;DelayedAutoStart=$endDelayed} + Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelRestored=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $cleanupErrors.Count);Assertions=$count;Computer=[Environment]::MachineName;Engine=$PSVersionTable.PSVersion.ToString();Scope='Owned disabled subscription authorization only; inert SIDs are not resolved or authenticated.'} +} +if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $cleanupErrors.Count){throw "Native authorization or fixture cleanup failed; inspect $root"} +exit 0 diff --git a/tests/WecAuthorizationFixtureNative.cs b/tests/WecAuthorizationFixtureNative.cs new file mode 100644 index 00000000..de6e5881 --- /dev/null +++ b/tests/WecAuthorizationFixtureNative.cs @@ -0,0 +1,25 @@ +// Read-only disposable-fixture inventory; bypasses console/native text decoding. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecAuthorizationFixture { + public static class Inventory { + [DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags); + [DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,StringBuilder name,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + public static string[] Read() { + IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + var names=new List(); + while(true) { + var name=new StringBuilder(4096);uint used; + if(!EcEnumNextSubscription(handle,4096,name,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return names.ToArray();throw new Win32Exception(error);} + if(used<2||used>4096||name.Length==0||name.Length+1!=used||names.Count>=64||names.Contains(name.ToString()))throw new InvalidOperationException("Disposable native subscription inventory is invalid, duplicated or exceeds its bound."); + names.Add(name.ToString()); + } + }finally {EcClose(handle);} + } + } +} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 7347fbf2..b4f75fc6 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -8,6 +8,12 @@ **改善:** - 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) + +- 既存の無効なネイティブサブスクリプション1件の明示的なソースSID一覧を変更する、オプトインの `wec-authorization` Plan/Apply を追加しました。レビュー済みハッシュ、ホスト・トークン・実装・定義全体の照合、永続化した変更前証跡、認可プロパティのみのネイティブ更新と読み戻しにより他の設定を保持し、変更不要と部分失敗を区別します。使い捨てWindowsテストで追加・削除・復元・拒否・後処理を検証しますが、SID解決、ドメイン認証、転送、Sigmaの有効性は主張しません。(@Shirofune-Security) + +- カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security) - `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security) - 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。実装・実行中エンジンの選択をハッシュ処理前に拒否し、同じハンドルのDOS/NTパスと実体、読み取りと実体再確認の実測区間、実際のワーカー・トークン・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) @@ -15,6 +21,7 @@ - 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) - 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。`-ErrorAction` や `-Verbose` などの PowerShell 共通パラメーターも拒否するため、自動化ラッパーへの影響をヘルプと診断に明記しました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) +- 完了済みの標準チャネル設定を1件ずつ復元する `channel-recovery` を追加しました。元の記録と結果、確認済み計画ハッシュ、現在の記述子と設定を検証し、縮小・無効化・追加した読み取り権限の撤回には個別の同意を要求します。項目ごとの永続記録を残し、元の操作で変更した項目だけを復元します。公開 Configure/Restore の標準 Windows テストで拒否・途中失敗・元設定への後始末を確認し、イベント消失・保存期間・転送・Sigma 対応は別に扱います。(関連 #367、#365) (@Shirofune-Security) - Windows PowerShell 5.1 と PowerShell 7、使い捨ての Server 2022/2025 で標準チャネル設定の公開CLIを検証するテストを追加しました。有効化・サイズ・CAPI2読み取り専用権限の適用、既存記述子と大きいバッファーの保持、変更前記録、DryRun、再実行時の無変更、元設定への復元を確認し、ハッシュ付きの証拠を保存します。転送・保存期間・Sigmaの検証は別途必要です。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index ce04db07..1ba91895 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -8,6 +8,12 @@ **Improvements:** - 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) + +- Added opt-in `wec-authorization` Plan/Apply for the explicit source SID list of one existing disabled native subscription. Reviewed hashes, host/token/source and full-definition guards, durable pending evidence, one native authorization setter and readback preserve other settings; no-op and partial-save outcomes stay explicit. Native disposable tests cover add/remove/restore, refusals and cleanup without SID-resolution, domain authentication, forwarding or Sigma claims. (@Shirofune-Security) + +- Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security) - Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security) - Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Source/engine targets are refused before hashing. Same-handle DOS/NT identity, exact worker/token/handle attribution over the measured read and identity-readback phase, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) @@ -15,6 +21,7 @@ - Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) - Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. PowerShell common parameters such as `-ErrorAction` and `-Verbose` are also rejected; help and diagnostics explain the automation-wrapper compatibility change. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) +- Added opt-in `channel-recovery` for one completed native channel-settings operation. Strict journal/result reconstruction, reviewed plan hashes, exact current descriptor/settings, separate shrink/disable/read-revocation consent and per-field durable receipts restore only originally changed fields. Native public Configure/Restore tests cover refusal, partial outcomes and exact fixture cleanup; event loss, retention, forwarding and Sigma readiness remain separate. (Related #367, #365) (@Shirofune-Security) - Added disposable Server 2022/2025 validation of public native channel configuration under Windows PowerShell 5.1 and PowerShell 7. Tests apply enable/size controls and the explicit CAPI2 read-only grant, verify descriptor preservation, journals, larger buffers, DryRun and idempotence, and retain hashed native evidence with exact fixture cleanup. Forwarding, retention-duration and Sigma validation remain separate. (@Shirofune-Security)